From 27bf1039dbb974be00cce8bd17d813c032fba6fb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:10:27 +0800 Subject: [PATCH] refactor(session)!: distinguish event seqs from log offsets --- .../2026-06-20-branded-ids.i18n.yaml | 4 +- .../architecture/2026-06-20-branded-ids.md | 2 +- .../architecture/2026-06-20-branded-ids.zh.md | 2 +- ...30-session-end-seed-log-boundary.i18n.yaml | 4 +- ...026-07-30-session-end-seed-log-boundary.md | 2 +- ...-07-30-session-end-seed-log-boundary.zh.md | 2 +- ...ubagent-list-identity-projection.i18n.yaml | 4 +- ...08-06-subagent-list-identity-projection.md | 57 ++-- ...06-subagent-list-identity-projection.zh.md | 57 ++-- ...n-sequence-and-log-offset-brands.i18n.yaml | 6 + ...-session-sequence-and-log-offset-brands.md | 45 ++++ ...ssion-sequence-and-log-offset-brands.zh.md | 45 ++++ ...026-06-30-session-store-fork-api.i18n.yaml | 4 +- .../2026-06-30-session-store-fork-api.md | 6 +- .../2026-06-30-session-store-fork-api.zh.md | 6 +- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 4 +- ...-21-continuable-background-subagents.zh.md | 4 +- ...3-session-telemetry-otel-revival.i18n.yaml | 4 +- ...26-07-23-session-telemetry-otel-revival.md | 2 +- ...07-23-session-telemetry-otel-revival.zh.md | 2 +- ...7-25-subagent-policy-inheritance.i18n.yaml | 4 +- .../2026-07-25-subagent-policy-inheritance.md | 2 +- ...26-07-25-subagent-policy-inheritance.zh.md | 2 +- .../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 +- ...-fork-child-replay-seed-boundary.i18n.yaml | 4 +- ...6-06-22-fork-child-replay-seed-boundary.md | 14 +- ...6-22-fork-child-replay-seed-boundary.zh.md | 14 +- .../tests/semantic-checkpoint.expected.e2e.ts | 13 +- .../session-format-guard.expected.e2e.ts | 9 +- .../tests/subagent-diagnostic.expected.e2e.ts | 14 +- .../subagent-inheritance.expected.e2e.ts | 13 +- .../workspace-context-resume.expected.e2e.ts | 10 +- apps/cli/tests/web-agent-presets.e2e.ts | 10 +- apps/web/tests/agent-preset-selection.e2e.ts | 18 +- apps/web/tests/chat-long-interactions.e2e.ts | 2 +- apps/web/tests/complex-history.perf.ts | 4 +- apps/web/tests/reference-composer.e2e.ts | 3 +- apps/web/tests/scaffold.ts | 19 +- apps/web/tests/schedule-after.e2e.ts | 10 +- apps/web/tests/subagent-conversation.e2e.ts | 44 ++-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 30 +-- docs/config-catalog.zh.md | 22 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 14 +- docs/event-producer-consumer.zh.md | 14 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 64 ++--- docs/persistence-catalog.zh.md | 62 ++--- docs/subsystems/commands.i18n.yaml | 4 +- docs/subsystems/commands.md | 2 +- docs/subsystems/commands.zh.md | 2 +- docs/subsystems/compaction.i18n.yaml | 4 +- docs/subsystems/compaction.md | 18 +- docs/subsystems/compaction.zh.md | 18 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 2 +- docs/subsystems/core.zh.md | 2 +- docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 75 ++++-- docs/subsystems/persistence.zh.md | 75 ++++-- docs/subsystems/schedule.i18n.yaml | 4 +- docs/subsystems/schedule.md | 2 +- docs/subsystems/schedule.zh.md | 2 +- docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 28 +- docs/subsystems/session-projection.zh.md | 28 +- docs/subsystems/session-query.i18n.yaml | 4 +- docs/subsystems/session-query.md | 28 +- docs/subsystems/session-query.zh.md | 28 +- docs/subsystems/session-title.i18n.yaml | 4 +- docs/subsystems/session-title.md | 10 +- docs/subsystems/session-title.zh.md | 10 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 100 +++++-- docs/subsystems/session.zh.md | 100 +++++-- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 2 +- docs/subsystems/subagent.zh.md | 2 +- docs/subsystems/token-meter.i18n.yaml | 4 +- docs/subsystems/token-meter.md | 4 +- docs/subsystems/token-meter.zh.md | 4 +- packages/acp/acp/tests/bridge.spec.ts | 17 +- packages/acp/acp/tests/updates.spec.ts | 8 +- packages/api/session-controller/src/agent.ts | 11 +- .../src/client/contract/session.ts | 6 +- .../src/client/sessions/manager.ts | 19 +- .../src/client/sessions/projection-store.ts | 9 +- .../src/client/sessions/service.ts | 4 +- .../src/client/sessions/session.ts | 38 ++- .../api/session-controller/src/commands.ts | 19 +- .../api/session-controller/src/history.ts | 98 +++++-- packages/api/session-controller/src/index.ts | 11 +- packages/api/session-controller/src/list.ts | 5 +- packages/api/session-controller/src/types.ts | 25 +- .../tests/agent.host.spec.ts | 50 ++-- .../tests/client-contract.client.spec.ts | 40 ++- .../commands-queue-attachment.host.spec.ts | 28 +- .../tests/controller.host.spec.ts | 37 ++- .../tests/event-script.client.ts | 66 +++-- .../tests/manager.client.spec.ts | 3 +- .../tests/projection-store.client.spec.ts | 41 +-- .../tests/queue-store.client.spec.ts | 6 +- .../tests/session-cold.host.spec.ts | 119 ++++++--- .../tests/session-fork.host.spec.ts | 30 ++- .../session-history-journal.host.spec.ts | 15 +- ...session-pending-submissions.client.spec.ts | 16 +- .../tests/session-projections.host.spec.ts | 40 ++- .../tests/session-search.host.spec.ts | 5 +- .../tests/session-skills.host.spec.ts | 4 +- .../tests/session.client.spec.ts | 179 ++++++------- .../session-controller/tests/test-remote.ts | 8 + .../tests/transport.host.spec.ts | 141 +++++++--- packages/bundle/headless/src/index.ts | 7 +- .../client/connection/src/client/fixture.ts | 20 +- .../connection/tests/fixture.client.spec.ts | 7 +- .../ui-chat/src/client/chat/ChatView.tsx | 3 +- .../src/client/chat/turn-rail-items.ts | 9 +- .../ui-chat/src/client/contract/slots.ts | 3 +- .../tests/apply-inject.client.spec.tsx | 4 +- .../tests/turn-rail-items.client.spec.ts | 2 + .../conversation-assembler.client.spec.ts | 155 +++++------ .../tests/history-transport.perf.client.ts | 3 +- .../tests/request-inspection.client.spec.ts | 19 +- .../tests/tool-details-render.client.tsx | 9 +- .../tests/rename-assembly.client.spec.tsx | 4 +- .../tests/command-compact.spec.ts | 12 +- .../tests/loader-composition.spec.ts | 12 +- .../compaction/compaction-basic/src/index.ts | 6 +- .../compaction/compaction-basic/src/region.ts | 26 +- .../tests/compaction-basic.spec.ts | 16 +- .../src/index.ts | 4 +- .../src/types.ts | 5 +- packages/compaction/compaction/src/index.ts | 6 +- .../compaction/compaction/src/invariant.ts | 96 +++++-- .../compaction/compaction/src/tool-pairing.ts | 12 +- packages/compaction/compaction/src/types.ts | 19 +- .../compaction/tests/compaction.spec.ts | 5 +- .../compaction/tests/invariant.spec.ts | 124 +++++++-- .../compaction/tests/tool-pairing.spec.ts | 50 ++-- .../tests/agent-instructions.spec.ts | 14 +- .../context/session-reference/src/index.ts | 8 +- .../session-reference/src/projection.ts | 8 +- .../context/session-reference/src/types.ts | 4 +- .../tests/session-reference.spec.ts | 14 +- packages/context/time-context/src/index.ts | 3 +- .../time-context/tests/invariant.spec.ts | 12 +- packages/core/agent-loop/src/agent.ts | 4 +- packages/core/agent-loop/src/index.ts | 9 +- .../core/agent-loop/src/runtime-context.ts | 6 +- packages/core/agent-loop/src/tool-calls.ts | 8 +- packages/core/agent-loop/tests/resume.spec.ts | 38 +-- packages/core/agent/src/inbox.ts | 2 +- packages/core/agent/src/index.ts | 10 +- packages/core/agent/src/types.ts | 8 +- 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 | 23 +- packages/core/session/src/index.ts | 113 +++++--- packages/core/session/src/invariant.ts | 4 +- packages/core/session/src/repair.ts | 11 +- packages/core/session/src/seq-ranges.ts | 17 +- packages/core/session/src/surface.ts | 74 +++--- packages/core/session/src/types.ts | 65 ++++- .../core/session/tests/chunk-rows.spec.ts | 72 ++--- packages/core/session/tests/fork.spec.ts | 37 ++- packages/core/session/tests/invariant.spec.ts | 12 +- packages/core/session/tests/repair.spec.ts | 17 +- .../core/session/tests/request-header.spec.ts | 8 +- .../core/session/tests/seq-ranges.spec.ts | 14 +- .../core/session/tests/sequence-types.spec.ts | 153 +++++++++++ packages/core/session/tests/session.spec.ts | 78 +++--- packages/core/session/tests/surface.spec.ts | 215 ++++++++------- .../experimental/agent-team/src/mailbox.ts | 5 +- .../experimental/agent-team/src/roster.ts | 8 +- .../agent-team/tests/persistence.spec.ts | 2 +- .../tests/projection-events.spec.ts | 82 +++--- .../agent-team/tests/team.spec.ts | 11 +- .../home/storages/session_projcache.json | 6 +- .../tests/vfs-example-fixture.spec.ts | 36 ++- .../tests/vfs-example-fixture.ts | 71 +++-- .../src/client/api-catalog.ts | 2 +- .../src/client/slot-catalog.ts | 12 +- .../extensions/tool-cordis/src/api-catalog.ts | 144 ++++++---- .../feedback/message-feedback/src/index.ts | 11 +- .../message-feedback/tests/helpers.ts | 31 ++- .../tests/loader-composition.spec.ts | 4 +- .../tests/message-feedback.spec.ts | 13 +- packages/fs/tool-fs/tests/tools.spec.ts | 65 ++++- .../tests/tools.spec.ts | 2 +- packages/goal/goal/src/index.ts | 15 +- packages/goal/goal/tests/goal.spec.ts | 3 +- packages/goal/tool-goal/src/authority.ts | 4 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 11 +- .../hook-protocol/tests/invariant.spec.ts | 8 +- packages/interaction/commands/src/index.ts | 7 +- packages/interaction/commands/src/types.ts | 5 +- .../commands/tests/commands.spec.ts | 1 + .../commands/tests/invariant.spec.ts | 6 +- .../tests/invariant.spec.ts | 6 +- .../interaction/user-approval/src/index.ts | 5 +- .../user-approval/tests/invariant.spec.ts | 8 +- .../token-meter/src/breakdown-projection.ts | 7 +- packages/llm/token-meter/src/index.ts | 20 +- packages/llm/token-meter/src/surface-fold.ts | 6 +- .../llm/token-meter/src/surface-projection.ts | 12 +- packages/llm/token-meter/src/types.ts | 5 +- .../llm/token-meter/src/usage-projection.ts | 5 +- .../context-breakdown-projection.spec.ts | 45 ++-- .../llm/token-meter/tests/token-meter.spec.ts | 24 +- .../tests/token-usage-projection.spec.ts | 12 +- .../plan/plan-mode/tests/invariant.spec.ts | 10 +- .../agent-presets/tests/session.spec.ts | 11 +- .../sandbox-policy/tests/policy.spec.ts | 1 + packages/schedule/AGENTS.md | 2 +- packages/schedule/schedule/README.i18n.yaml | 4 +- packages/schedule/schedule/README.md | 4 +- packages/schedule/schedule/README.zh.md | 4 +- packages/schedule/schedule/src/domain.ts | 15 +- packages/schedule/schedule/src/invariant.ts | 10 +- packages/schedule/schedule/src/projection.ts | 16 +- packages/schedule/schedule/src/runtime.ts | 5 +- packages/schedule/schedule/src/tools.ts | 2 +- .../schedule/schedule/tests/domain.spec.ts | 9 +- .../schedule/schedule/tests/invariant.spec.ts | 13 +- .../schedule/tests/jsonl-restart.spec.ts | 2 +- .../schedule/tests/projection.spec.ts | 78 +++--- .../tests/archive.host.spec.ts | 3 + .../tests/route.host.spec.ts | 16 +- .../session-query-sqlite/README.i18n.yaml | 4 +- .../session-query-sqlite/README.md | 2 +- .../session-query-sqlite/README.zh.md | 2 +- .../session-query-sqlite/src/index.ts | 44 +++- .../tests/load-path.e2e.ts | 11 +- .../session-query-sqlite/tests/sqlite.spec.ts | 122 +++++++-- .../session-query/README.i18n.yaml | 4 +- .../session-query/session-query/README.md | 2 + .../session-query/session-query/README.zh.md | 2 + .../session-query/session-query/src/corpus.ts | 21 +- .../session-query/src/documents.ts | 6 +- .../session-query/session-query/src/index.ts | 24 +- .../session-query/src/observation.ts | 20 +- .../session-query/src/sources.ts | 2 +- .../session-query/src/tracing.ts | 26 +- .../session-query/session-query/src/types.ts | 31 ++- .../session-query/tests/observation.spec.ts | 35 ++- .../tests/search-helpers.spec.ts | 47 ++-- .../session-query/tests/session-query.spec.ts | 111 +++++--- .../session-query/tests/tracing.spec.ts | 117 +++++---- .../tool-session-query/src/operations.ts | 7 +- .../tests/sqlite-integration.spec.ts | 13 +- .../tests/tool-session-query.spec.ts | 27 +- .../tests/session-checkpoint-policy.spec.ts | 10 +- .../session-log-deepseek/README.i18n.yaml | 4 +- .../session/session-log-deepseek/README.md | 2 +- .../session/session-log-deepseek/README.zh.md | 2 +- .../session/session-log-deepseek/package.json | 1 + .../session/session-log-deepseek/src/index.ts | 90 +++++-- .../session-log-deepseek/src/invariant.ts | 12 +- .../session/session-log-deepseek/src/types.ts | 39 ++- .../tests/invariant.spec.ts | 36 ++- .../session-log-deepseek/tests/upload.spec.ts | 136 ++++++++-- .../README.i18n.yaml | 4 +- .../session-persistence-jsonl/README.md | 4 +- .../session-persistence-jsonl/README.zh.md | 4 +- .../session-persistence-jsonl/src/format.ts | 118 ++++++--- .../session-persistence-jsonl/src/index.ts | 65 +++-- .../tests/jsonl.spec.ts | 123 +++++++-- .../tests/zstd.spec.ts | 28 +- .../session-persistence/README.i18n.yaml | 4 +- .../session/session-persistence/README.md | 10 +- .../session/session-persistence/README.zh.md | 10 +- .../session-persistence/src/coordinator.ts | 229 ++++++++++++---- .../session/session-persistence/src/index.ts | 48 +++- .../session-persistence/tests/contract.ts | 130 +++++++--- .../tests/coordinator-contract.ts | 167 ++++++++++-- .../tests/persistence.spec.ts | 245 +++++++++++++----- .../tests/write-behind.spec.ts | 38 +-- .../session-projection-cache/README.i18n.yaml | 4 +- .../session-projection-cache/README.md | 4 +- .../session-projection-cache/README.zh.md | 4 +- .../session-projection-cache/src/index.ts | 87 +++++-- .../session-projection-cache/src/spec.ts | 10 +- .../tests/cache.spec.ts | 148 ++++++++--- .../session-projection/README.i18n.yaml | 4 +- packages/session/session-projection/README.md | 6 +- .../session/session-projection/README.zh.md | 6 +- .../session/session-projection/src/index.ts | 105 +++++--- .../session-projection/tests/registry.spec.ts | 172 +++++++++--- .../session-telemetry-otel/tests/otel.spec.ts | 4 +- .../session-telemetry/src/coordinator.ts | 16 +- .../session-telemetry/tests/telemetry.spec.ts | 5 +- .../tests/provider.spec.ts | 3 +- .../session/session-title-llm/src/index.ts | 3 +- packages/session/session-title/src/index.ts | 22 +- .../session/session-title/src/invariant.ts | 48 +++- packages/session/session-title/src/types.ts | 9 +- .../session-title/tests/invariant.spec.ts | 56 +++- .../session-title/tests/projection.spec.ts | 26 +- .../session-title/tests/provider.spec.ts | 4 +- .../tests/service-contracts.spec.ts | 8 +- .../session-title/tests/session-title.spec.ts | 6 +- .../session-turn-outline/src/projection.ts | 4 +- .../session/session-turn-outline/src/types.ts | 4 +- .../tests/projection.spec.ts | 30 ++- .../tests/loader-composition.spec.ts | 2 +- .../tool-bash-persistent/tests/tools.spec.ts | 1 + packages/shell/tool-bash/tests/tools.spec.ts | 42 ++- .../tests/loader-composition.spec.ts | 2 +- .../tool-pwsh-persistent/tests/tools.spec.ts | 1 + packages/shell/tool-pwsh/tests/tools.spec.ts | 50 ++-- packages/skill/tool-skill/src/index.ts | 4 +- .../skill/tool-skill/tests/tool-skill.spec.ts | 2 +- .../tests/subagent-fork-in-process.spec.ts | 14 +- .../subagent-in-process-driver/src/index.ts | 12 +- .../tests/inheritance.spec.ts | 6 +- .../tests/subagent-in-process-driver.spec.ts | 3 +- packages/subagent/subagent/src/child-agent.ts | 6 +- .../subagent/subagent/src/continuation.ts | 19 +- packages/subagent/subagent/src/lifecycle.ts | 5 +- .../subagent/subagent/src/list-children.ts | 45 ++-- .../subagent/subagent/src/projection-types.ts | 8 +- packages/subagent/subagent/src/projection.ts | 5 +- .../tests/continuation-inheritance.spec.ts | 6 +- .../subagent/tests/continuation.spec.ts | 3 +- .../subagent/tests/list-children.spec.ts | 53 ++-- .../tests/tool-subagent-control.spec.ts | 5 +- packages/subagent/tool-subagent/src/index.ts | 3 +- .../terminal-bash/tests/index.spec.ts | 4 +- packages/test-support/llm-replay/src/index.ts | 21 +- .../llm-replay/tests/llm-replay.spec.ts | 156 +++++------ .../session-snapshot/src/normalize.ts | 10 +- .../todo/tool-todo/tests/invariant.spec.ts | 8 +- .../todo/tool-todo/tests/tool-todo.spec.ts | 4 +- .../generator/tests/schema-emitter.spec.ts | 34 +++ packages/util/brand/README.i18n.yaml | 4 +- packages/util/brand/README.md | 30 ++- packages/util/brand/README.zh.md | 30 ++- packages/util/brand/package.json | 2 +- packages/util/brand/src/index.ts | 24 +- packages/util/brand/tests/brand.spec.ts | 13 + .../tool-ralph/tests/integration.spec.ts | 3 +- .../workspace/tests/workspace.spec.ts | 1 + pnpm-lock.yaml | 3 + scripts/gen-cordis-catalog.ts | 5 + scripts/session-fixture-layout.spec.ts | 4 +- scripts/type-equiv.manifest.json | 30 +++ 351 files changed, 5912 insertions(+), 2837 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.md create mode 100644 .agents/notes/implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.zh.md create mode 100644 packages/core/session/tests/sequence-types.spec.ts create mode 100644 packages/util/brand/tests/brand.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml index 2c4a5e3544..e847027821 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.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-20-branded-ids.md -2026-06-20-branded-ids.md: 954fd89aa229ba587cd1293973b4038cfeb20473 -2026-06-20-branded-ids.zh.md: 0dd761da2e5b5fc3e864fe03c250b9781be9ee59 +2026-06-20-branded-ids.md: 1f579a7afb7ac5f7facd6c5e8040d5df719c4bed +2026-06-20-branded-ids.zh.md: eaf032027f2f61bec9e4ab624622a5012c97e9b9 diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md index 954fd89aa2..1f579a7afb 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md @@ -51,7 +51,7 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o - **`ModelId`** (`GenerateOptions.model`, the `LlmRuntime` adapter-registry key) — a real cross-package lookup key (config → agent → llm → adapter); a reasonable next brand, left out only to keep this decision's blast radius focused. - **`ToolName`** (the `ToolRuntime` key) — author-defined, human-readable, and rarely confused with another id; the weakest candidate, likely not worth a brand. - **`ErrorCode`** (`HarnessError.code`) — a closed vocabulary (`ABORTED`, `NO_ADAPTER`, …), not a per-instance id; better served by a string-literal union than a brand, if anything. -- **Numeric ordinals** — turn number, step number, and the event `seq` are `number`, not `string`, so `Branded` does not apply; a parallel `number & { readonly [BRAND]: B }` variant could brand them, but they are positional ordinals rarely passed across boundaries, so the payoff is low. +- **Other numeric ordinals** — the [Session sequence and log-offset decision](2026-08-31-session-sequence-and-log-offset-brands.md) brands event identities and log gaps because they cross persistence and reference seams. Turn and step numbers remain plain numbers: they are payload-local ordinals and are not interchangeable with Session event positions. - **Validated construction** — `brandString()` performs no runtime check, and every boundary (ACP `sessionId`, provider-issued `call.id`, the empty-string fallback in `dsh-llm-deepseek`) trusts the raw string. A `SessionId.parse()` / `isValid()` companion that throws on malformed input at boundaries is a genuine gap, but it is a runtime-behavior change with its own design (what is "malformed"? what happens on failure?) and belongs in its own decision. ## Verification diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md index 0dd761da2e..eaf032027f 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md @@ -51,7 +51,7 @@ const owner = brandString('session-1') - **`ModelId`**(`GenerateOptions.model`,`LlmRuntime` 适配器注册表的键):一个真正的跨包查找键(config → agent → llm → 适配器);合理的下一个 brand,仅为控制本决策的影响范围而暂不纳入。 - **`ToolName`**(`ToolRuntime` 的键):由作者定义、人类可读,且很少与其他 id 混淆;最弱的候选,可能不值得加 brand。 - **`ErrorCode`**(`HarnessError.code`):一个封闭词汇(`ABORTED`、`NO_ADAPTER`……),不是逐实例的 id;如果要做,string 字面量联合类型比 brand 更合适。 -- **数值序号**:轮次号、步骤号和事件 `seq` 是 `number` 而非 `string`,`Branded` 不适用;可以用并行的 `number & { readonly [BRAND]: B }` 变体来 brand 它们,但它们是位置序号、很少跨边界传递,收益较低。 +- **其他数值序号**:[Session 序列号与日志偏移决策](2026-08-31-session-sequence-and-log-offset-brands.zh.md)会为事件身份与日志间隙加 brand,因为它们跨越 persistence 与引用 seam。turn 与 step number 保持普通 number:它们是 payload-local ordinal,不会与 Session 事件位置互换。 - **带校验的构造**:`brandString()` 不执行运行时检查,且每个边界(ACP `sessionId`、提供方签发的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)都信任裸 string。一个在边界处对格式错误的输入抛异常的 `SessionId.parse()` / `isValid()` 配套工具确实是缺口,但它属于运行时行为变更,有自己的设计问题(什么算「格式错误」?失败时会怎样?),应在独立决策中处理。 ## 验证 diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml index a995ac778f..b91733813c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.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-30-session-end-seed-log-boundary.md -2026-07-30-session-end-seed-log-boundary.md: 1c5a8097a6b901f133205dcd52d674a8e3594b28 -2026-07-30-session-end-seed-log-boundary.zh.md: ea3549543229a15d0fba7ad0316a8683c674a558 +2026-07-30-session-end-seed-log-boundary.md: c6ed3911a797480804d064273922d85412664c79 +2026-07-30-session-end-seed-log-boundary.zh.md: 1e9517f9a5aed819fdaff6194ab952c322c85b82 diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md index 1c5a8097a6..c6ed3911a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md @@ -40,7 +40,7 @@ The predicate holds for a bracket *this* session inherited, not as a liveness si **A boundary appended at loop start.** The loop calls `resumeWith`, so it covers the resume paths, but it misses `fork()` and `adopt()` entirely, and the event would have to fire on `'startup'` — the source a fork child publishes — so `SessionStartSource` would stop discriminating. It also publishes the session before the marker is appended, so a `session/created` listener could observe a seeded log with no boundary. -**Reusing `header.seedLength`.** It is the durable *fork-lineage* boundary and deliberately keeps the original fork value across a resume, where the constructor seed is the whole stored log. The two facts differ and conflating them would lose both. +**Reusing `Session.inheritedEventCount`.** It is the durable *fork-lineage* cut and deliberately keeps the original fork value across a resume, where the constructor seed is the whole stored log. The two facts differ and conflating them would lose both. **Crash repair closing `compaction/*` alongside turn boundaries.** Rejected: it moves every plugin's bracket semantics into core's repair pass, and core cannot know what closing another package's bracket should record. diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md index ea35495432..1e9517f9a5 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md @@ -40,7 +40,7 @@ Status: implemented **在 loop 启动时追加边界。** loop 调用 `resumeWith`,因此覆盖恢复路径,但完全漏掉 `fork()` 与 `adopt()`,而且事件不得不在 `'startup'` 上触发——那是 fork 子会话发布的来源——于是 `SessionStartSource` 将不再具有区分力。它还会在追加标记之前就发布会话,因此 `session/created` 监听方可能观察到一份没有边界的带种子日志。 -**复用 `header.seedLength`。** 它是持久的 *fork 血缘*边界,并且刻意在恢复时保留原始 fork 取值——而恢复时构造种子是整份存储日志。这两个事实并不相同,混同会同时失去两者。 +**复用 `Session.inheritedEventCount`。** 它是持久的 *fork 血缘* cut,并且刻意在恢复时保留原始 fork 取值——而恢复时构造种子是整份存储日志。这两个事实并不相同,混同会同时失去两者。 **让崩溃修复连同轮次边界一起关闭 `compaction/*`。** 否决:这会把每个插件的括号语义搬进核心的修复流程,而核心无法知道关闭另一个包的括号应该记录什么。 diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml index 29613b58c7..cfdc3bd607 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-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 .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md -2026-08-06-subagent-list-identity-projection.md: aeed828530f615b1bb4958a360b5ba4db543f714 -2026-08-06-subagent-list-identity-projection.zh.md: b2b64eaa7c06b738734a7b975adb5948704465bd +2026-08-06-subagent-list-identity-projection.md: cbb15696314930acfaf20ba8651699c53c5dbde2 +2026-08-06-subagent-list-identity-projection.zh.md: dbb62dbfc6bb6ca3ab63504de1ba8dd35327bbbf diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md index aeed828530..cbb1569631 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md @@ -14,16 +14,16 @@ The root cause is that the [durable-subagent-catalog decision](../feature/2026-0 ## Decision -mode and label are folded by the new `subagent` projection unit (pure identity, two arms), and the unit is the sole authority over the fold rules; `listChildren` no longer depends on session-query — enumeration is a subagent-owned live-preferred merge, and value retrieval walks a three-rung compute-and-discard ladder: a live child synchronously reads the registry's existing watermark cache (zero log reads); a cold child first asks the optional `sessionProjectionCache` checkpoint, and a served identity that passes the seq gate is final; otherwise it pays one full `persistence.inspect` read plus a fold through the registered `subagent` unit. No index, no cache of its own, no write-back. +mode and label are folded by the `subagent` projection unit (pure identity, two arms), and the unit is the sole authority over the fold rules. Enumeration uses the shared Session query corpus, while value retrieval walks a three-rung compute-and-discard ladder: a live child synchronously reads the registry's existing watermark cache (zero log reads); an unseeded cold child may use the optional `sessionProjectionCache` checkpoint because its exact inherited cut is known to be zero; every seeded child and every cache miss pays one body-bearing Session observation plus a fold through the registered `subagent` unit. No index, no cache of its own, no list-side write-back. There are three families of escape from the per-child scan: promote mode/label into the header (the write path pays); build a durable derivation for the projection (a checkpoint ladder, or values landed during query-index rebuild with read-side reconciliation); or compute at read time (live from the watermark cache, cold from one full read). This note takes the third. "Values landed with the query index" was retired wholesale: query infrastructure was forced to learn domain vocabulary while the sole consumer is satisfied by read-time computation — the live child's zero reads come for free from session-projection's existing watermark cache, and the cold child's single full read is explicitly accepted as compute-and-discard. The first two routes and the retirement rationale are detailed under Alternatives considered. Key points: -- **The subagent list does not depend on session-query**: enumeration is completed by a subagent-owned live-preferred merge, and mode/label is retrieved through `ctx.sessionProjections`; deployments without a query backend list as usual. -- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.snapshot(session, ['subagent'])` (the registry's existing watermark cache, zero log reads); a cold child first reads the optional `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`, using the non-null identity directly when it passes the seq gate (`seq >= seedLength ?? 0`); otherwise it pays one full Session observation plus a fold through the registered `subagent` unit; beyond that, absent is absent — no cache of its own, no write-back, no index. +- **The subagent list uses the Session query corpus for enumeration and body-bearing observations**: mode/label still comes through `ctx.sessionProjections`, and the list owns no descriptor parser or domain index. +- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.snapshot(session, ['subagent'])` (the registry's existing watermark cache, zero log reads); an unseeded cold child may read `sessionProjectionCache.cachedSnapshot(header, SessionLogOffset(0), ['subagent'])`; a seeded child or cache miss pays one Session observation carrying `inheritedEventCount` plus a fold through the registered `subagent` unit. Beyond that, absent is absent — no cache of its own, no list-side write-back, no index. - **The `subagent` projection unit is the sole authority over the fold rules**: live and cold snapshots both run the one registered unit; no second copy of descriptor-interpretation logic exists. -- **The header, the descriptor (v2), session-persistence, session-projection(-cache), and session-query(-sqlite) are all untouched**; pre-existing data acquires exact values through one `inspect` computation the first time it is listed — no degraded unknown state, no migration. +- **The descriptor (v2) remains untouched**. Session, persistence, projection cache, and query now carry the exact inherited cut separately from the logical header; pre-existing data acquires exact values through one body-bearing observation when listing cannot prove a zero cut — no degraded unknown state and no durable format migration. Relationship to existing notes: @@ -36,8 +36,8 @@ It hangs beside the existing `subagentTiming` ([projection.ts](../../../../packa ```ts ignore-check export type SubagentIdentityProjection = - | { mode: 'one-shot'; label?: string; seq: number } - | { mode: 'continuable'; label: string; seq: number } + | { mode: 'one-shot'; label?: string; seq: SessionSeq } + | { mode: 'continuable'; label: string; seq: SessionSeq } declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { @@ -52,19 +52,19 @@ declare module '@deepseek-ai/dsh-session-projection/types' { - The projection is pure identity, and **the projection system has no failure channel**: a unit never throws; a corrupt payload or an unrecognized version folds exactly like a log with no descriptor at all. The host checkpoint state is the serializable wrapper `{ identity?: SubagentIdentityProjection }`; absence is `{}`. Its client view is the non-optional `SubagentIdentityProjection | null` entry. `null` passes JSON losslessly, so a pushed reset replaces a stale identity instead of being dropped by stringify. The judging discipline: consuming surfaces treat null and an absent client key alike as no value. How "computed to nothing" is presented is the consumer's own business (see the `listChildren` four-state mapping below). - Label strength is decided by the descriptor schema: a continuable's label is mandatory at parse, a one-shot's was always optional; the mode/label discriminant matches the child row's strong contract below exactly (the row carries no `seq` — it is the projection's internal own-suffix proof). -- The identity carries `seq`: the seq of the `subagent/descriptor` event it was folded from, mandatory on both arms and absent on the null sentinel — `seq >= header.seedLength ?? 0` proves the identity was folded from the child's own suffix rather than a fork seed's replayed ancestor descriptor. The unit maps the wrapper's validated identity to its client wire view and is checkpointed like every unit (the `persist` opt-in is gone); its `stateVersion` is 2, bumped when `seq` was added. Existing older checkpoint rows are invalidated by version mismatch per the registry contract, falling to the authoritative refold. +- The identity carries branded `seq`: the seq of the `subagent/descriptor` event it was folded from, mandatory on both arms and absent on the null sentinel. A live Session checks it through `isOwnSeq()`; a cold body-bearing observation compares it with `inheritedEventCount`. Header-only seeded candidates skip the cache because the header intentionally exposes no integer cut; unseeded candidates know the cut is zero. The unit maps the wrapper's validated identity to its client wire view and is checkpointed like every unit (the `persist` opt-in is gone); its `stateVersion` is 2, bumped when `seq` was added. Existing older checkpoint rows are invalidated by version mismatch per the registry contract, falling to the authoritative refold. - Fold rule: `subagent/descriptor` is last-wins, under the same descriptor-reset discipline as `subagentTiming` — ancestor descriptors in the fork prefix are overridden by the session's own descriptor. A corrupt or unrecognized-version payload is last-wins all the same: it resets to the null sentinel rather than keeping the prior identity, so a fork of a healthy ancestor does not inherit an identity its own descriptor cannot stand up. -### Enumeration: subagent-owned live-preferred merge +### Enumeration: query corpus with live preference -`listChildren`'s ([list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts)) enumeration goes through no query service: the two sources `ctx.sessions.list()` and `ctx.get('sessionPersistence')?.list()` merge by id, with a live record overriding the same-id persisted record wholesale and no header consistency check. Everything enumeration needs is header facts: +`listChildren` ([list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts)) asks `sessionQuery.listSessions()` for the canonical live-preferred corpus, then pairs each listed id with `ctx.sessions.get(id)` when a live Session exists. The live header overrides the listed header for that id. Everything enumeration needs is header facts: - Filtering: `header.origin === 'subagent' && header.parentSession === parentSessionId`. - `hasChildren`: the same merged material, looked at one level down — a direct descendant exists with `origin === 'subagent'` whose `parentSession` is that child. - `activity`: a live record is `running`; one present only in persistence is `inactive`. - Ordering: `createdAt` ascending, then child id ascending (matching the old contract). -- **Absent persistence degrades to live-only enumeration, not an error**: in a deployment without persistence, a cold child could not be resumed anyway, and listing live children remains meaningful. (Contrast: the old implementation rejected wholesale when sessionQuery was missing.) -- A persistence listing failure fails the whole enumeration; per-child isolation applies only to the per-child cold reads. +- An absent `sessionQuery` service fails with `SUBAGENT_CONTROL_QUERY_UNAVAILABLE`; the shared query corpus owns whether a deployment can enumerate live-only or persisted Sessions. +- A query-corpus failure fails the whole enumeration; per-child isolation applies only to per-child cold observations. ### Value retrieval: the three-rung compute-and-discard ladder @@ -73,20 +73,20 @@ For each enumerated child, mode/label retrieval walks a three-rung ladder — co | Rung | Read | Cost | | --- | --- | --- | | 1: live child | `ctx.sessionProjections.snapshot(session, ['subagent'])` | Zero log reads — the registry's existing watermark cache, synchronous retrieval | -| 2: cold child, cache hit | The optional `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`, used directly only when the non-null identity satisfies `identity.seq >= header.seedLength ?? 0` — an own descriptor is immutable once appended, and the seq gate proves the value was folded from the child's own suffix, regardless of the row's watermark | Zero log reads | -| 3: cold child, fallback | One full `persistence.inspect(id)` read + a fold through the registered `subagent` unit | One full read computed per listing | +| 2: unseeded cold child, cache hit | The optional `sessionProjectionCache.cachedSnapshot(header, SessionLogOffset(0), ['subagent'])`; every valid seq is owned when the exact cut is zero | Zero log reads | +| 3: seeded child or cold fallback | One body-bearing `sessionQuery.observeSession(id)` + the registered `subagent` projection, with `inheritedEventCount` available for the own-suffix check | One full read computed per listing | -- Error contract: `sessionProjections` is a required injection — `SubagentRuntime` declares it in its inject set, so a deployment without the registry never activates the service (or the loop), and `listChildren` is unreachable rather than served degraded rows ([mandatory-seam note](2026-08-19-session-projection-mandatory-seam.md)); the loud runtime check and `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` are deleted with it. The session store keeps the explicit posture: an absent `ctx.get('sessions')` (a strict global read, never the caller-scope-bound property proxy) fails with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. apiproxy's dedicated `PROJECTIONS_UNAVAILABLE` wire face is deleted along with the code; `SESSION_STORE_UNAVAILABLE` goes through the generic internal fallback — apiproxy's composition injects `sessions` itself, so that error is unreachable in its deployment, and a dedicated mapping would violate the need principle. `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is deleted along with the session-query dependency. -- The cache is a purely optional acceleration layer: an absent service is skipped on a null check — no error code, no part in configuration validation (in contrast to `sessionProjections`, a required injection). Anything the second rung throws (including a poisoned unit row in the cache detonating `viewCheckpoint`) silently falls to the third rung — the cache is derived data, so its faults never produce a `corrupt` verdict; the final judgment belongs to the authoritative refold. A row whose checkpoint cut predates the descriptor naturally lacks the `subagent` key and falls through automatically, with no special-casing; a null sentinel in the row does not count either — it falls to the third rung for the authoritative refold's verdict. A count/interval checkpoint inside the creation window can land a fork seed's replayed ancestor identity in the row — the ancestor's seq falls inside the seed range, the seq gate rejects it, and it likewise falls to the third rung's verdict. +- Error contract: `sessionProjections`, the Session store, and `sessionQuery` are required runtime services for listing. Their explicit failures are `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`, and `SUBAGENT_CONTROL_QUERY_UNAVAILABLE`; no empty result disguises a missing classification or corpus capability. +- The cache is a purely optional acceleration layer: an absent service is skipped on a null check — no error code, no part in configuration validation (in contrast to `sessionProjections`, a required injection). A seeded header skips this rung because it cannot supply the cache identity's exact cut without a body read. For an unseeded child, anything the second rung throws (including a poisoned unit row detonating `viewCheckpoint`) silently falls to the third rung — the cache is derived data, so its faults never produce a `corrupt` verdict; the final judgment belongs to the authoritative refold. A row whose checkpoint cut predates the descriptor, an absent key, or a null sentinel likewise falls through. - Per-child isolation: a single child's failed cold full read only turns that row into an `unavailable` diagnostic, naturally retried on the next listing, without affecting siblings (see the four-state mapping). -- The cold path's lifecycle witness: preparation's result must still point at the lifecycle that was enumerated — the witness field set is the same seven fields as the old SOURCE_CONFLICT check (version, id, createdAt, cwd, parentSession, seedLength, delegationDepth); a session deleted and republished under the same id degrades to a `corrupt` row in the old parent's catalog, leaking nothing of the new owner's child. +- The cold path's lifecycle witness: the observation must still point at the lifecycle that was enumerated. The witness fields are version, id, createdAt, cwd, parentSession, isSeeded, delegationDepth, origin, and agentPreset; a Session deleted and republished under the same id degrades to a `corrupt` row in the old parent's catalog, leaking nothing of the new owner's child. - Cold-read concurrency is bounded by the constant 4 — it constrains a read-only scan of local media, not deployment behavior; when a networked persistence backend appears, it is promoted to a validated `Config` field. -- The cold-read cost, recorded honestly: only with the cache unmounted or missed does a cold child pay one full read per listing, at a cost proportional to its transcript size; the settled stance is compute-and-discard, and no cache of its own is built. The full read goes through `inspect()` into the [Session preparation](2026-08-05-session-preparation.md) cold read, so short-term repeated reads of the same id can hit its LRU for reuse, but listing does not depend on this. A live child reads zero log throughout. +- The cold-read cost, recorded honestly: every seeded child and every unseeded cache miss pays one full query observation per listing, at a cost proportional to its transcript size; the settled stance is compute-and-discard, and no cache of its own is built. The observation may reuse the query/persistence preparation layer, but listing does not depend on that optimization. A live child reads zero log throughout. - Cancellation: the caller's signal is checked before and after each persistence read, and a read that settles only after abort is rejected, normalized to the stable error code `CANCELLED`. ### Authority model -- The session log is the sole authority; this design adds no derived persistence of any kind — no index values, no checkpoints of its own, no in-process memo; the `sessionProjectionCache` checkpoint the second rung reads is an existing composition item's derived data, which this design only reads and never writes. Values are computed on read and discarded, and a value's freshness is exactly the live state or persisted revision at the moment of the read (an own descriptor is immutable once appended — a cached identity past the seq gate has no staleness problem; the gate guards against seed-replayed ancestor identities). +- The session log is the sole authority; this design adds no domain index, checkpoint of its own, or in-process memo. The `sessionProjectionCache` checkpoint the second rung reads is an existing composition item's derived data, which the list only reads. Values are computed on read and discarded. Seeded candidates use a body-bearing observation to classify the identity against the exact cut; unseeded cached identities need no seq gate because every valid seq is owned. - The Session and persistence write paths are entirely unaware of listing and projection consumption: no event-listener write-back, no fold-on-write. - Enumeration and value retrieval constitute no second authorization source and make no unpublished child visible — the two sources see only published live records and durably written persisted records, consistent with the rule the durable-subagent-catalog note laid down for derived read surfaces. @@ -127,25 +127,24 @@ For each enumerated child, the ladder's result maps to a row through four states Known boundary deviations (deliberately accepted, recorded with this note): -- A fork child that died in its publication window, with an ancestor descriptor in its seed, gets the ancestor identity from last-wins and wrongly surfaces as a child row; resume still fails against the own-suffix fold authority (`NOT_RESUMABLE`). The old implementation omitted it via `seedLength` filtering; the projection unit cannot see the header, and this debris-grade deviation is accepted (`subagentTiming` has the same kind of pre-existing exposure). - Multiple descriptors in the own suffix: the old implementation judged corrupt; last-wins now takes the final one (the provider contract guarantees exactly one anyway). - A live/persisted header conflict: the old implementation made it per-child corrupt; enumeration now prefers live with no consistency check, the conflict goes unnoticed, and the live record forms the row. - A source-read failure on damaged storage (e.g. a bad surface rejected by the cold full read): the old implementation mapped it to per-child `corrupt`; it is now uniformly an `unavailable` row (the read side cannot tell the causes apart). - An unknown parent: the old implementation threw not-found through session-query ('parent session … was not found'); the subagent-owned merge now yields an empty subset for a nonexistent parent, enumeration returns an empty list, and later operations on the wire land as child-level subagent-not-found — a silent change of semantics and wording, recorded as explicitly accepted. -- Rung 2's later-event window: a cache row lands right after the first own descriptor, the log then appends a second own descriptor (or a malformed payload setting the null sentinel), and the process crashes before the next checkpoint — from then on a cold listing's rung 2, admitted by the seq≥seedLength gate, keeps serving the row's old identity (the first own descriptor's value), diverging from the authoritative refold (last-wins, the second), and a rung-2 hit triggers no refold, so nothing notices. Three boundaries: ① the precondition is a second own descriptor on the same child, violating the establishing provider's append-exactly-once contract — corruption-class data, same family and source as the multi-descriptor deviation; ② it takes both "corruption + a crash missing every checkpoint (the two mandatory points, turn/end and disposal, and the count/interval throttle points all unmet)" at once; ③ a healthy child (exactly one own descriptor) is unaffected — what the seq gate admits is precisely the only true identity. Self-healing: any live run of that child (the turn/end mandatory checkpoint) or any moment that triggers cache.write overwrites the whole row with a fresh fold (whole-record replace), and rung 2 serves correctly from then on; the authoritative paths (the rung-3 refold, the live snapshot, the resume fold) are correct from the start, and the divergence exists only in listing reads while the child stays cold and the row is never rewritten. The mechanical fixes were not taken: gate reconciliation would need the log-end seq, unavailable to a zero-read cold path; a cache row carrying the revision is an opaque token, incomparable and a cross-domain schema change — filed as accepted under the "the cache is never authoritative" doctrine. +- Rung 2's later-event window applies only to an unseeded child: a cache row lands right after the first descriptor, the log then appends a second descriptor (or a malformed payload setting the null sentinel), and the process crashes before the next checkpoint. Cold listing can keep serving the old identity until a live run or cache write replaces the row. The precondition violates the provider's append-exactly-once contract and also requires missing every mandatory checkpoint; healthy children are unaffected. Seeded children never take rung 2 without the body-owned cut. -Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entirely as it was, zero changes** (the `list_agents` description and output schema are untouched; the plugin's load requirement changes — `sessionQuery` dropped from inject, `sessionProjections` added as a required injection). The only behavioral changes are in apiproxy: on the route segment, the `hasSubagentDescriptor()` scan is deleted and `hasSubagentOwner` looks only at `header.origin` — pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this; and `subagents.history` is aligned with `session.history`'s source — a live child served from in-memory events and the registry's watermark snapshot, a cold child from `inspectServable` reading persistence directly with a detached fold, no query service involved, the SESSION_QUERY_* error arms retired with it, and the wire shape unchanged (the `history` JSDoc wording becomes the live in-memory snapshot / cold persisted log dual arm). +Consuming surfaces keep the same row and diagnostic wire shape. `list_agents` reaches the required query corpus plus projection registry; live identities come from the registry snapshot and cold identities from cache or query observation. Host ownership still uses `header.origin`, and history uses the shared live/cold Session query sources; no consumer parses descriptor events independently. ### Change footprint | Area | Files | Change | | --- | --- | --- | | subagent | projection.ts, projection-types.ts, index.ts | New client-visible `subagent` unit and its registration | -| subagent | list-children.ts and its types | Rewritten as subagent-owned enumeration plus the projection-ladder four-state mapping; the session-query dependency, per-child event reads, and in-place classification machinery deleted; error code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` deleted, and `sessionProjections` becomes a required injection (no projection error code remains); new optional dependency dsh-session-projection-cache (pure read acceleration, skipped when absent) | -| host/apiproxy | api-proxy.ts | `hasSubagentDescriptor` deleted; the owner check looks only at `header.origin`; `subagents.history` shares `session.history`'s source — live from in-memory events and the registry's watermark snapshot, cold from `inspectServable` reading persistence directly with a detached fold, no query service, the SESSION_QUERY_* error arms and the dedicated `PROJECTIONS_UNAVAILABLE` wire face retired with it | -| tool | tool-subagent-control/list-agents.ts | Load requirement narrowed (`sessionQuery` dropped from inject); model-visible schema, description, and rendering unchanged | +| subagent | list-children.ts and its types | Query-corpus enumeration plus the projection-ladder four-state mapping; required projections/query services and optional projection-cache acceleration | +| host/apiproxy | Session controller/query integration | Owner checks use `header.origin`; live/cold history and listing consume the shared query and projection sources | +| tool | tool-subagent-control/list-agents.ts | Model-visible schema, description, and rendering remain unchanged | | wire/client | api/subagents.ts, runtime sessions/service.ts, GUI | Types, row shape, and diagnostic handling **unchanged**; api/subagents.ts only reworded the `history` JSDoc to the dual arm | -| core/session, session-persistence, session-projection(-cache), session-query(-sqlite) | — | **Zero changes** | +| core/session, session-persistence, session-projection(-cache), session-query(-sqlite) | body-bearing cut and branded seq plumbing | Logical headers expose `isSeeded`; Session, persistence observations, cache identity, and query records carry exact `inheritedEventCount` separately | ## Alternatives considered @@ -169,20 +168,20 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir ## Verification -`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; without the registry the service never activates (the mandatory seam — a `setup` variant asserting `ctx.get('subagents')` stays undefined); a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes (`SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` for an absent store). Second-rung cases: an own-seq identity used directly with zero `inspect`, a fork seed's ancestor identity (seq inside the seed range) rejected by the gate and falling through, an in-row identity absence (null sentinel or absent key) falling through, an absent cache service falling through, and a poisoned cache row silently falling through to the refold; cold-path lifecycle tampering degrades to `corrupt` field by witness field (`it.each` over the seven). The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the existing keyless snapshots (`subagent-list-agents` among others) are unchanged, pinning that the healthy path's wire and model-visible surfaces did not move; a new keyless snapshot, `subagent-diagnostic` (examples/headless-agent), pins the four-state mapping's diagnostic classification — the model-visible changes such as descriptor-less settled debris becoming a `corrupt` row. +`packages/subagent/subagent/tests/list-children.spec.ts` pins this contract: live identity checks use `Session.isOwnSeq()`; an unseeded cold identity may use the cache at cut zero; seeded candidates skip that cache rung and use an observation carrying `inheritedEventCount`; ancestor identities fail the own-suffix check; absent, null, poisoned, and unavailable cache/observation cases fall through or produce the documented diagnostic; lifecycle tampering degrades to `corrupt` across the complete witness field set. The existing keyless snapshots keep the healthy wire and model-visible surfaces fixed, while `subagent-diagnostic` pins diagnostic classification. ## Consequences - Listing a live child reads zero log throughout; with the cache unmounted or missed, a cold child pays one full `inspect` read per listing, at a cost proportional to its transcript size and repeated with listing frequency — compute-and-discard is the settled stance: no cache of its own is built, nothing is written back, and short-term repeated full reads of the same id can hit the preparation-phase LRU, though listing does not depend on it. -- The subagent list no longer requires a query backend: both pure-live and persistence-less deployments can list; `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is gone, loading the `list_agents` plugin no longer requires `sessionQuery`, and `sessionProjections` becomes a required injection of `SubagentRuntime` — a deployment without the projection registry never activates the service (the mandatory seam). +- The subagent list requires the Session query corpus and projection registry; missing services fail explicitly instead of producing incomplete rows. The optional projection cache changes only the number of body reads. - Identity interpretation exists only in the single unit registered with the registry: the list's three-rung ladder and GUI history's cold read use its live, cached, or observed wire snapshots, and no hand-written bypass fold exists; if some future consuming surface bypasses the unit with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee. - Per-child isolation is back: a single child's cold-read failure loses only that row and healthy siblings are unaffected; a persistence listing failure still fails the whole enumeration. -- The diagnostic and enumeration semantics leaves six boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, damaged-source read failures shifting from `corrupt` to `unavailable`, an unknown parent yielding an empty list instead of not-found, and rung 2's later-event window); the full semantics is in the known-boundary-deviations list; the first four are display or classification deviations on debris-grade data, the unknown-parent one is a silent query-semantics change, and the rung-2 window is a self-healing cache-serving divergence under the double condition of corruption plus a crash; resume authorization is unaffected throughout, all explicitly accepted. +- The diagnostic and enumeration semantics leaves five boundary deviations (multiple descriptors resolving to the last, header conflicts going unnoticed, damaged-source read failures changing classification, an unknown parent yielding an empty list instead of not-found, and the unseeded rung-2 later-event window). Seeded ancestor identities are no longer a deviation because body-bearing reads compare them with `inheritedEventCount`; resume authorization remains unaffected. - Pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and pre-release carries no compatibility promise. ## Related -- [Durable subagent catalog and list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) — partially superseded by this note: the descriptor remains the durable authority for mode/label and the fold input, while the list's enumeration and value retrieval move to the subagent-owned merge plus the projection ladder. +- [Durable subagent catalog and list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) — partially superseded by this note: the descriptor remains the durable authority for mode/label and the fold input, while value retrieval moves to the projection ladder over the shared query corpus. - [Session projections and command lifecycle logging](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) — the authority for the registry contract; this note adds the `subagent` identity unit and consumes its live and cold wire snapshots. - [Session projection state and client views](2026-08-19-session-projection-state-and-client-views.md) — the state/client split; both `subagent` and `subagentTiming` provide client wire views. - [Session projections as a required seam](2026-08-19-session-projection-mandatory-seam.md) — `sessionProjections` becomes a required injection; the list's error contract follows it (registry absence is an activation-time failure, and the projection error code is deleted). diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md index b2b64eaa7c..dbb62dbfc6 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -14,16 +14,16 @@ Status: implemented ## 决策 -mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 不再依赖 session-query——枚举是 subagent 自管的 live-preferred 合并,取值走三级「算完即止」阶梯:live child 同步读注册表的既有水位缓存(零日志读);cold child 先问可选的 `sessionProjectionCache` checkpoint,取到过 seq 门的身份即定值;否则一次 `persistence.inspect` 整读加经注册的 `subagent` unit 折叠。无索引、不自建缓存、无回写。 +mode 与 label 由 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威。枚举使用共享 Session query corpus,取值则走三级「算完即止」阶梯:live child 同步读注册表的既有水位缓存(零日志读);unseeded cold child 可以使用可选 `sessionProjectionCache` checkpoint,因为其精确 inherited cut 已知为零;每个 seeded child 与每次 cache miss 都执行一次含正文的 Session observation,再经注册的 `subagent` unit 折叠。无索引、不自建缓存、列表侧无回写。 消除逐 child 扫描的出路有三类:把 mode/label 提升进 header(写路承担);为投影建持久派生(checkpoint 阶梯,或随查询索引重建落值、读端对账);读时现算(live 走水位缓存,cold 一次整读)。本记录取第三条。「值随查询索引落库」已整体退役:查询基础设施被迫认识领域词汇,而唯一消费方读时现算即可满足——live child 的零读由 session-projection 既有水位缓存白拿,cold child 的一次整读被「算完即止」显式接受。前两条与退役理由详见考虑过的替代方案一节。 要点: -- **subagent 列表不依赖 session-query**:枚举由 subagent 自管的 live-preferred 合并完成,mode/label 经 `ctx.sessionProjections` 取值;没有 query backend 的部署照常列表。 -- **取值三级「算完即止」阶梯**:live child 读 `sessionProjections.snapshot(session, ['subagent'])`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`,非 null 身份通过 seq 门(`seq >= seedLength ?? 0`)即直接使用;否则执行一次完整 Session 观察,再经注册的 `subagent` unit 折叠;再没有就没有——不自建缓存、无回写、无索引。 +- **subagent 列表使用 Session query corpus 完成枚举与含正文 observation**:mode/label 仍经 `ctx.sessionProjections` 获取,列表不拥有 descriptor parser 或领域索引。 +- **取值三级「算完即止」阶梯**:live child 读 `sessionProjections.snapshot(session, ['subagent'])`(注册表既有水位缓存,零日志读);unseeded cold child 可读 `sessionProjectionCache.cachedSnapshot(header, SessionLogOffset(0), ['subagent'])`;seeded child 或 cache miss 执行一次携带 `inheritedEventCount` 的 Session observation,再经注册的 `subagent` unit 折叠。再没有就没有——不自建缓存、列表侧无回写、无索引。 - **`subagent` projection unit 是折叠规则唯一权威**:live 与 cold 快照都运行同一份已注册 unit,不存在第二份描述符解释逻辑。 -- **header、描述符(v2)、session-persistence、session-projection(-cache)、session-query(-sqlite) 全部零改动**;存量数据第一次被列表时一次 `inspect` 现算获得精确值,无 unknown 降级态、无迁移。 +- **描述符(v2)保持不变**。Session、persistence、projection cache 与 query 在 logical header 之外单独携带精确 inherited cut;listing 无法证明 cut 为零时,存量数据经一次含正文 observation 获得精确值——无 unknown 降级态,也无持久格式迁移。 与既有记录的关系: @@ -36,8 +36,8 @@ mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠 ```ts ignore-check export type SubagentIdentityProjection = - | { mode: 'one-shot'; label?: string; seq: number } - | { mode: 'continuable'; label: string; seq: number } + | { mode: 'one-shot'; label?: string; seq: SessionSeq } + | { mode: 'continuable'; label: string; seq: SessionSeq } declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { @@ -52,19 +52,19 @@ declare module '@deepseek-ai/dsh-session-projection/types' { - 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样。host checkpoint 状态使用可序列化的包装 `{ identity?: SubagentIdentityProjection }`,缺席为 `{}`;客户端 view 则是非可选的 `SubagentIdentityProjection | null` 条目。`null` 完好通过 JSON,因此推送 reset 会替换旧身份,而不会被 stringify 丢掉。判定纪律:消费面把 null 与客户端 key 缺席一律视为无值。「算出来没有」如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。 - label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;mode/label 判别与下文 child 行的强约定完全一致(行不携带 `seq`——它是投影内部的 own-suffix 证明)。 -- 身份携带 `seq`:折出该身份的 `subagent/descriptor` 事件 seq,两臂必有、null 哨兵无——`seq >= header.seedLength ?? 0` 证明身份折叠自 child 自身后缀,而非 fork 种子回放的祖先描述符。unit 把包装状态中校验后的身份映射为客户端 wire view,并与其他 unit 一律检查点化(`persist` 选项已删除);`stateVersion` 为 2,在增加 `seq` 时升版。更早的 checkpoint 行按 registry 约定版本失配失效、落权威重折。 +- 身份携带品牌化 `seq`:折出该身份的 `subagent/descriptor` 事件 seq,两臂必有、null 哨兵无。live Session 通过 `isOwnSeq()` 检查它;cold 含正文 observation 则与 `inheritedEventCount` 比较。仅 header 的 seeded candidate 会跳过 cache,因为 header 有意不暴露整数 cut;unseeded candidate 知道 cut 为零。unit 把包装状态中校验后的身份映射为客户端 wire view,并与其他 unit 一律检查点化(`persist` 选项已删除);`stateVersion` 为 2,在增加 `seq` 时升版。更早的 checkpoint 行按 registry 约定版本失配失效、落权威重折。 - 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。损坏或版本不认识的载荷同样 last-wins:重置为 null 哨兵而非保留先前身份,健康祖先的 fork 不会继承自身描述符立不住的身份。 -### 枚举:subagent 自管 live-preferred 合并 +### 枚举:query corpus 与 live preference -`listChildren`([list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts))的枚举不经任何查询服务:`ctx.sessions.list()` 与 `ctx.get('sessionPersistence')?.list()` 两个来源按 id 合并,live 记录整条覆盖同 id 持久化记录、不做 header 一致性校验。枚举所需全部是 header 事实: +`listChildren`([list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts))通过 `sessionQuery.listSessions()` 取得 canonical live-preferred corpus,再把每个 listed id 与可能存在的 `ctx.sessions.get(id)` 配对;同 id 存在 live Session 时使用 live header。枚举所需全部是 header 事实: - 过滤:`header.origin === 'subagent' && header.parentSession === parentSessionId`。 - `hasChildren`:同一份合并材料向下看一层——存在 `origin === 'subagent'` 且 `parentSession` 为该 child 的直接后代。 - `activity`:live 记录为 `running`,仅存在于持久化的为 `inactive`。 - 排序:`createdAt` 升序、再按 child id 升序(与旧约定一致)。 -- **persistence 缺席退为 live-only 枚举,不报错**:没有 persistence 的部署,cold child 本就无法 resume,列出 live child 仍然有意义。(对照:旧实现在 sessionQuery 缺失时整体拒绝。) -- persistence 列表失败使整次枚举失败;per-child 隔离只作用于逐 child 的冷读。 +- `sessionQuery` 服务缺席时以 `SUBAGENT_CONTROL_QUERY_UNAVAILABLE` 失败;共享 query corpus 负责决定部署能枚举 live-only 还是持久化 Session。 +- query corpus 失败使整次枚举失败;per-child 隔离只适用于逐 child cold observation。 ### 取值:三级「算完即止」阶梯 @@ -73,20 +73,20 @@ declare module '@deepseek-ai/dsh-session-projection/types' { | 级 | 读法 | 成本 | | --- | --- | --- | | 1:live child | `ctx.sessionProjections.snapshot(session, ['subagent'])` | 零日志读——注册表既有水位缓存,同步取值 | -| 2:cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`,非 null 身份满足 `identity.seq >= header.seedLength ?? 0` 才直接使用——own descriptor 一经追加不可变,seq 门证明该值折叠自 child 自身后缀,无视行水位 | 零日志读 | -| 3:cold child,兜底 | `persistence.inspect(id)` 整读 + 经注册的 `subagent` unit 折叠 | 每次列表一次整读现算 | +| 2:unseeded cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header, SessionLogOffset(0), ['subagent'])`;精确 cut 为零时,每个合法 seq 都归 child 自有 | 零日志读 | +| 3:seeded child 或 cold 兜底 | 一次含正文 `sessionQuery.observeSession(id)` 加已注册的 `subagent` projection,使用 `inheritedEventCount` 做 own-suffix 检查 | 每次列表一次整读现算 | -- 错误约定:`sessionProjections` 是必需注入——`SubagentRuntime` 在 inject 集里声明它,没有 registry 的部署根本无法激活服务(与 loop),`listChildren` 不可达,而不是供出降级行([mandatory-seam 记录](2026-08-19-session-projection-mandatory-seam.zh.md));响亮运行时检查与 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 随之删除。会话存储保留显式姿态:`ctx.get('sessions')`(严格全局读取,不走调用方作用域的属性代理)缺席以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 失败。apiproxy 为 `PROJECTIONS_UNAVAILABLE` 设的专门 wire 脸随码删除;`SESSION_STORE_UNAVAILABLE` 走通用 internal 兜底——apiproxy 组合自身就 inject `sessions`,该错误在其部署不可达,专门映射违反 need 原则。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。 -- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的必需注入相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 `viewCheckpoint` 引爆)静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 切面早于描述符的行,`subagent` key 天然缺席,自动落底,无特判;行里的 null 哨兵同样不作数——一律落第三级,由权威重折裁决。创建窗口内的 count/interval checkpoint 可能把 fork 种子回放的祖先身份落进行——祖先 seq 落在 seed 区间,被 seq 门拒绝,同样落第三级裁决。 +- 错误约定:`sessionProjections`、Session store 与 `sessionQuery` 都是 listing 所需的 runtime service。三者分别以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`、`SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 与 `SUBAGENT_CONTROL_QUERY_UNAVAILABLE` 显式失败;缺失分类或 corpus 能力不会伪装成空结果。 +- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的必需注入相对)。seeded header 会跳过该级,因为不读取正文就无法提供 cache identity 所需的精确 cut。对 unseeded child,第二级任何抛错(包括中毒 unit 行引爆 `viewCheckpoint`)都会静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 早于 descriptor、key 缺席或 null 哨兵也都会落底。 - per-child 隔离:单 child 的 cold 整读失败只使该行成为 `unavailable` diagnostic,下次列表自然重试,不影响 sibling(见四态映射)。 -- 冷路径的生命周期见证:preparation 的结果必须仍指向枚举时的那个生命周期——见证字段集与旧 SOURCE_CONFLICT 检查同款七字段(version、id、createdAt、cwd、parentSession、seedLength、delegationDepth);同 id 删除后重新发布的会话对旧 parent 的目录降级为 `corrupt` 行,不外漏新 owner 的 child。 +- 冷路径的生命周期见证:observation 必须仍指向枚举时的那个生命周期。见证字段为 version、id、createdAt、cwd、parentSession、isSeeded、delegationDepth、origin 与 agentPreset;同 id 删除后重新发布的 Session 对旧 parent 的目录降级为 `corrupt` 行,不外漏新 owner 的 child。 - 冷读并发以常数 4 有界——它约束的是本地介质的一次只读扫描而非部署行为;出现联网 persistence backend 时提升为验证过的 `Config` 字段。 -- 冷读成本如实记录:cache 未挂载或未命中时,cold child 每次列表才付一次整读,成本与其 transcript 大小成正比;定案「算完即止」,不自建缓存。整读经 `inspect()` 走 [Session 准备阶段](2026-08-05-session-preparation.zh.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。 +- 冷读成本如实记录:每个 seeded child 与每次 unseeded cache miss 都会在每次列表时支付一次完整 query observation,成本与其 transcript 大小成正比;定案「算完即止」,不自建缓存。observation 可以复用 query/persistence preparation 层,但列表不依赖该优化。live child 全程零日志读。 - 取消:每次 persistence 读前后检查调用方 signal,abort 之后才结算的读拒绝归一化为稳定错误码 `CANCELLED`。 ### 权威模型 -- session log 是唯一权威;本方案不新增任何派生持久化——没有索引值、没有自己的 checkpoint、没有进程 memo;第二级读取的 `sessionProjectionCache` checkpoint 是既有组合项的派生数据,本方案只读不写。取值现算现弃,值的新鲜度就是读取时点的 live 状态或持久化 revision(own descriptor 一经追加不可变——缓存身份过 seq 门后无陈旧性问题,门防的是种子回放的祖先身份)。 +- session log 是唯一权威;本方案不新增领域索引、自有 checkpoint 或进程 memo。第二级读取的 `sessionProjectionCache` checkpoint 是既有组合项的派生数据,列表只读。取值现算现弃;seeded candidate 用含正文 observation 按精确 cut 分类,unseeded cached identity 无需 seq 门,因为每个合法 seq 都归自身所有。 - Session 与 persistence 写路完全不感知列表与投影消费:没有事件监听回写,没有写时折叠。 - 枚举与取值不构成第二个鉴权来源,也不让尚未发布的 child 可见——两个来源只见已发布的 live 记录与已落盘的持久化记录,与 durable-subagent-catalog 记录对派生读面立下的规则一致。 @@ -127,25 +127,24 @@ export type SubagentListEntry = 已知边界偏差(有意接受,随本记录留档): -- 死于发布窗口的 fork child,seed 里若有祖先描述符,last-wins 会给出祖先身份,误现为 child 行;恢复仍按 own-suffix 折叠权威失败(`NOT_RESUMABLE`)。旧实现靠 `seedLength` 过滤将其 omit;projection unit 看不到 header,接受此残骸级偏差(`subagentTiming` 有同类既有暴露)。 - own suffix 出现多个描述符,旧实现判 corrupt,现 last-wins 取末者(提供方约定本就保证恰一)。 - live/persisted header 冲突,旧实现是 per-child corrupt;现枚举 live 优先、不做一致性校验,冲突不再被察觉,以 live 记录成行。 - 损坏存储的源读失败(如坏 surface 被冷读整读拒收),旧实现映射 per-child `corrupt`,现统一成 `unavailable` 行(读侧无从区分成因)。 - 未知 parent,旧实现经 session-query 抛 not-found(「parent session … was not found」);现自管合并对不存在的 parent 得到空子集,枚举返回空列表,wire 上后续操作落到 child 级 subagent-not-found——语义与文案的静默变化,显式接受。 -- rung 2 的更晚事件窗口:cache 行恰在首个自有描述符之后落盘,日志随后追加第二个自有描述符(或 malformed 载荷置 null 哨兵),且进程在下一次 checkpoint 前崩溃——此后冷列表的 rung 2 凭 seq≥seedLength 门持续供出行内旧身份(第一个自有描述符的值),与权威重折(last-wins 第二个)分歧,且 rung 2 命中期间不触发重折、无从察觉。边界三条:①前提是同一 child 出现第二个自有描述符,违反建档提供方「恰追加一次」约定,属损坏类数据,与多描述符偏差同族同源;②需「损坏 + 崩溃错过 checkpoint(turn/end 与 disposal 两个 mandatory 点及 count/interval 节流点全部未及)」双条件同时成立;③健康 child(恰一自有描述符)不受影响——seq 门放行的正是唯一真身份。自愈条件:该 child 任一次 live 运行(turn/end mandatory checkpoint)或任何触发 cache.write 的时点,都会以新 fold 整行覆写(whole-record replace),rung 2 随即供正;权威路径(rung 3 重折、live snapshot、resume 折叠)自始正确,分歧只存在于持续冷、行未再更新期间的列表读。机制修法不采:gate 对账需知日志末端 seq,冷路径零读不可得;cache 行携 revision 是 opaque token,无法比较且跨域改 schema——按「cache 永不为权威」总纲归档为接受项。 +- rung 2 的更晚事件窗口只适用于 unseeded child:cache 行恰在首个 descriptor 后落盘,日志随后追加第二个 descriptor(或 malformed 载荷置 null 哨兵),且进程在下一次 checkpoint 前崩溃。cold listing 可能持续供出旧身份,直到一次 live 运行或 cache write 替换该行。其前提违反 provider 的「恰追加一次」约定,并且还需错过所有 mandatory checkpoint;健康 child 不受影响。seeded child 没有 body-owned cut 时绝不进入 rung 2。 -消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件的加载要求变化——inject 去掉 `sessionQuery`、新增必需注入 `sessionProjections`)。行为上动的只有 apiproxy:路由段的 `hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受;`subagents.history` 与 `session.history` 同源对齐——live child 用内存事件与注册表水位快照,cold child 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役,wire 形状不变(`history` 的 JSDoc 措辞改为 live 内存快照/cold 持久日志双臂)。 +消费面保持相同的 row 与 diagnostic wire 形状。`list_agents` 使用必需的 query corpus 与 projection registry;live identity 来自 registry snapshot,cold identity 来自 cache 或 query observation。Host ownership 仍使用 `header.origin`,history 使用共享的 live/cold Session query source;没有消费方独立解析 descriptor event。 ### 改动落点 | 区域 | 文件 | 改动 | | --- | --- | --- | | subagent | projection.ts、projection-types.ts、index.ts | 新客户端可见 `subagent` unit 与注册 | -| subagent | list-children.ts 及类型 | 重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 删除,`sessionProjections` 转为必需注入(不再存在投影错误码);新增可选依赖 dsh-session-projection-cache(纯加速读取,缺席跳过) | -| host/apiproxy | api-proxy.ts | 删 `hasSubagentDescriptor`,属主判定只看 `header.origin`;`subagents.history` 与 `session.history` 同源——live 用内存事件与注册表水位快照,cold 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂与 `PROJECTIONS_UNAVAILABLE` 专门 wire 脸随之退役 | -| tool | tool-subagent-control/list-agents.ts | 加载要求收窄(inject 去 `sessionQuery`);model-visible schema、描述与渲染零改动 | +| subagent | list-children.ts 及类型 | query-corpus 枚举加 projection 阶梯四态映射;必需 projections/query service 与可选 projection-cache 加速 | +| host/apiproxy | Session controller/query integration | owner 检查使用 `header.origin`;live/cold history 与 listing 消费共享 query 和 projection source | +| tool | tool-subagent-control/list-agents.ts | model-visible schema、描述与渲染保持不变 | | wire/client | api/subagents.ts、runtime sessions/service.ts、GUI | 类型、行形状与 diagnostic 处理**零改动**;api/subagents.ts 仅 `history` 的 JSDoc 措辞改为双臂 | -| core/session、session-persistence、session-projection(-cache)、session-query(-sqlite) | — | **零改动** | +| core/session、session-persistence、session-projection(-cache)、session-query(-sqlite) | 含正文 cut 与品牌化 seq 传递 | Logical header 暴露 `isSeeded`;Session、persistence observation、cache identity 与 query record 单独携带精确 `inheritedEventCount` | ## 考虑过的替代方案 @@ -169,20 +168,20 @@ export type SubagentListEntry = ## 验证 -`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本约定:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时服务根本不激活(mandatory seam——`setup` 变体断言 `ctx.get('subagents')` 保持 undefined);live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;提供方未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码(存储缺席时 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`)。第二级例:own-seq 身份直用零 `inspect`、fork 种子祖先身份(seq 落在 seed 区间)被门拒绝落底、行内无身份(null 哨兵或 key 缺席)落底、cache 服务缺席落底、缓存行中毒静默落底重折;冷路径 lifecycle 篡改按见证七字段逐一(`it.each`)降级为 `corrupt`。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;既有无密钥快照(`subagent-list-agents` 等)零变化,钉住健康路径的 wire 与 model-visible 面不变;新增无密钥快照 `subagent-diagnostic`(examples/headless-agent)钉住四态映射的诊断分类——descriptor-less 定局残骸成 `corrupt` 行等模型可见变化。 +`packages/subagent/subagent/tests/list-children.spec.ts` 固定本约定:live identity 通过 `Session.isOwnSeq()` 检查;unseeded cold identity 可在 cut 零时使用 cache;seeded candidate 跳过该 cache rung,转而使用携带 `inheritedEventCount` 的 observation;祖先 identity 无法通过 own-suffix 检查;缺席、null、中毒与不可用的 cache/observation 会按约定落底或产生 diagnostic;lifecycle 篡改按完整见证字段集降级为 `corrupt`。既有无密钥快照保持健康 wire 与 model-visible 面不变,`subagent-diagnostic` 则固定诊断分类。 ## 后果 - live child 的列表全程零日志读;cold child 在 cache 未挂载或未命中时每次列表一次 `inspect` 整读,成本与其 transcript 大小成正比、随列表频率重复——定案「算完即止」,不自建缓存、不回写,同 id 短期重复整读可命中准备阶段 LRU 但列表不依赖它。 -- subagent 列表不再要求 query backend:纯 live 与无 persistence 的部署都能列表;`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 消失,`list_agents` 插件加载不再要求 `sessionQuery`,而 `sessionProjections` 转为 `SubagentRuntime` 的必需注入——没有投影 registry 的部署根本不会激活服务(mandatory seam)。 +- subagent 列表要求 Session query corpus 与 projection registry;服务缺失会显式失败,而不是供出不完整 row。可选 projection cache 只改变正文读取次数。 - 身份解释只存在于 registry 注册的一份 unit:列表三级阶梯与 GUI history 冷读使用其 live、cached 或 observed wire 快照,不存在手写旁路折叠;若未来某消费面绕开该 unit 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。 - per-child 隔离回归:单 child 冷读失败只损失该行,healthy sibling 不受影响;persistence 列表失败仍使整次枚举失败。 -- 诊断与枚举语义留下六处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`、未知 parent 由 not-found 改为空列表、rung 2 更晚事件窗口),完整语义见已知边界偏差清单;前四处为残骸级数据的展示或分类偏差,未知 parent 一处是查询语义的静默变化,rung 2 窗口一处是损坏加崩溃双条件下可自愈的缓存供值分歧;恢复鉴权均不受影响,显式接受。 +- 诊断与枚举语义留下五处边界偏差(多描述符取末者、header 冲突不再被察觉、损坏源读失败改变分类、未知 parent 由 not-found 改为空列表、unseeded rung 2 更晚事件窗口)。seeded 祖先 identity 已不再构成偏差,因为含正文读取会把它与 `inheritedEventCount` 比较;恢复鉴权始终不受影响。 - pre-#1569 的无 `origin` 存量不再被认作 subagent 属主;其本就不进目录,pre-release 无兼容承诺。 ## 相关 -- [durable-subagent-catalog 与 list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md)——被本记录部分取代:描述符仍是 mode/label 的持久权威与折叠输入,列表的枚举与取值改为自管合并加投影阶梯。 +- [durable-subagent-catalog 与 list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md)——被本记录部分取代:描述符仍是 mode/label 的持久权威与折叠输入,取值改为共享 query corpus 上的 projection 阶梯。 - [session projections 与命令生命周期日志](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)——registry 约定的权威;本记录为其新增 `subagent` 身份 unit,并消费其 live 与 cold wire 快照。 - [session projection 状态与客户端视图](2026-08-19-session-projection-state-and-client-views.zh.md)——state/client 拆分;`subagent` 与 `subagentTiming` 都提供客户端 wire view。 - [session projections 作为必需接缝](2026-08-19-session-projection-mandatory-seam.zh.md)——`sessionProjections` 转为必需注入;列表的错误约定随其变化(registry 缺席是激活期失败,投影错误码删除)。 diff --git a/.agents/notes/implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.i18n.yaml new file mode 100644 index 0000000000..06883b0ef8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.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-session-sequence-and-log-offset-brands.md +2026-08-31-session-sequence-and-log-offset-brands.md: 0057b2b6c95391abbb4ec2464ee58b54e2fe24f2 +2026-08-31-session-sequence-and-log-offset-brands.zh.md: f51ad5953cab34294a53db6e3c91884292497faf diff --git a/.agents/notes/implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.md b/.agents/notes/implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.md new file mode 100644 index 0000000000..0057b2b6c9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.md @@ -0,0 +1,45 @@ +# Agent Note: Distinguish Session event identities from log offsets + +Status: implemented + +English | [中文](2026-08-31-session-sequence-and-log-offset-brands.zh.md) + +## Problem + +Session positions used one structural `number` type for two incompatible meanings. An event reference names an existing row, while a prefix length, next append position, or read cut names a gap and may equal the event count. The compiler therefore accepted an offset where an event identity was required and could not expose a missed sequence-field migration. + +`SessionHeader.seedLength` also mixed a v0 storage coordinate into metadata used by body-free readers. Listing needs to know whether a Session has fork lineage, but only a reader that holds the event body can interpret the exact inherited prefix length. + +## Decision + +`@deepseek-ai/dsh-brand` exports the erased numeric primitive `BrandedNumber` and the runtime-identity helper `brandNumber()`. `@deepseek-ai/dsh-session` owns two validated brands: `SessionSeq` names one existing event and `SessionLogOffset` names a log gap, prefix length, or read offset. `SessionSeqCursor = SessionSeq | -1` represents an inclusive watermark before or after the first event, and `OptionalSessionSeq = SessionSeq | null` represents an event identity whose absence is data. + +`SessionEvent.seq`, surface replacement endpoints, provenance, and owner payload fields that identify Session events use `SessionSeq`. `Session.seq`, `Session.firstLiveSeq`, `Session.inheritedEventCount`, body-read offsets, and inherited prefix cuts use `SessionLogOffset`. Arithmetic returns an ordinary number and re-enters either domain through its validating constructor. + +The logical `SessionHeader` carries `isSeeded: boolean` and no numeric seed cut. Body-bearing storage values and observations carry `inheritedEventCount` beside the header; `Session.ownEvents()` and `Session.isOwnSeq()` hide the comparison from ordinary consumers. A seeded constructor requires an explicit seed and exact cut, including an empty seed with cut zero, because constructor input may contain child-owned setup events after the inherited prefix. + +The v0 JSONL header remains byte-compatible: absent `seedLength` decodes to `isSeeded: false` with cut zero, while present zero or nonzero values decode to `isSeeded: true` with the exact cut. Header-only listing translates only the presence bit. API, SDK, DeepSeek, telemetry, query-row, and JSON representations continue to carry ordinary numbers; their owning adapters validate and brand values when they enter same-process domain code. + +## Admission and ownership + +Domain constructors reject negative, fractional, non-finite, and unsafe integer values. Parsers validate a raw number once and retain the parsed object where the brand does not require a runtime wrapper. A compile-time brand does not discover unknown numeric fields in an external event; a format migration still needs an exhaustive owner disposition and must refuse schemas it cannot safely rewrite. + +`session/end-seed` remains a lifecycle marker, not the source of the inherited cut. Every constructor restore appends or retains that marker, including unseeded replay, so projections and cold readers receive `inheritedEventCount` explicitly instead of scanning the log. + +## Alternatives considered + +**Keep every position as `number`.** Rejected because event identities, counts, and cursors cross package and persistence seams frequently enough that accidental interchange is a migration risk, not a local arithmetic convenience. + +**Use one branded Session position for identities and offsets.** Rejected because it would again permit `eventCount` or `fromSeq` where an existing event is required and would force the `-1` and `null` sentinels into unrelated operations. + +**Derive the inherited cut from `session/end-seed`.** Rejected because the marker records constructor lifecycle, not only fork lineage, and a constructor seed may contain child-owned events after the inherited prefix. + +## Consequences + +Sequence-bearing code now states whether a number identifies an event or a gap. Header-only readers receive stable lineage metadata without opening the body, while persistence, projection, query, and authorization paths retain the exact cut they need. The on-disk v0 format and public numeric wires do not change. + +The cost is explicit conversion at durable and wire parsers and a separate exact-cut field on body-bearing observations. Projection-cache identity includes the lineage bit and exact cut, so its disposable storage domain advances and older rows rebuild on demand; body-free readers skip seeded cache hints when they do not hold the cut. Turn numbers, step numbers, message-list indexes, workflow member ordinals, token counts, and unrelated numeric domains remain plain numbers because they do not identify Session events. + +## Testing + +Type assertions pin that `SessionSeq` and `SessionLogOffset` are not interchangeable. Runtime suites cover constructor validation, mixed inherited and child-owned seeds, empty seeds, `ownEvents()` and `isOwnSeq()`, v0 JSONL absent/zero/nonzero headers in plain and Zstandard encodings, header-only listing, cold prepare and reopen, query and projection cuts, and unchanged numeric wire values. diff --git a/.agents/notes/implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.zh.md b/.agents/notes/implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.zh.md new file mode 100644 index 0000000000..f51ad5953c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 区分 Session 事件身份与日志偏移 + +Status: implemented + +[English](2026-08-31-session-sequence-and-log-offset-brands.md) | 中文 + +## Problem + +Session 位置曾用同一个结构化 `number` 类型表达两种不兼容的含义。事件引用指向一条已存在的记录,而前缀长度、下一追加位置或读取切点指向记录间隙,并且可以等于事件总数。因此,编译器会在需要事件身份的位置接受偏移,也无法暴露迁移时漏改的序号字段。 + +`SessionHeader.seedLength` 还把 v0 存储坐标混入了无须读取正文的 metadata consumer。列表只需要知道 Session 是否有 fork lineage,只有同时持有事件正文的读取方才能解释精确的继承前缀长度。 + +## Decision + +`@deepseek-ai/dsh-brand` 导出编译后消失的数值原语 `BrandedNumber` 与运行时保持原值的 helper `brandNumber()`。`@deepseek-ai/dsh-session` 拥有两个经验证的 brand:`SessionSeq` 指明一条已存在事件,`SessionLogOffset` 指明日志间隙、前缀长度或读取偏移。`SessionSeqCursor = SessionSeq | -1` 表达首条事件之前或之后的闭区间 watermark,`OptionalSessionSeq = SessionSeq | null` 表达允许以缺失为数据的事件身份。 + +`SessionEvent.seq`、surface 替换端点、provenance 以及 owner payload 中指向 Session 事件的字段使用 `SessionSeq`。`Session.seq`、`Session.firstLiveSeq`、`Session.inheritedEventCount`、带正文读取的偏移与继承前缀切点使用 `SessionLogOffset`。算术结果恢复为普通 number,并通过对应的验证构造函数重新进入任一领域。 + +逻辑 `SessionHeader` 携带 `isSeeded: boolean`,不携带数值 seed cut。包含正文的存储值和 observation 在 header 旁携带 `inheritedEventCount`;`Session.ownEvents()` 与 `Session.isOwnSeq()` 向普通 consumer 隐藏比较。seeded constructor 必须显式提供 seed 与精确 cut,包括 cut 为零的空 seed,因为 constructor 输入可能在继承前缀之后还包含 child-owned setup event。 + +v0 JSONL header 保持字节兼容:缺少 `seedLength` 时解码为 `isSeeded: false` 和零 cut,存在零或非零值时解码为 `isSeeded: true` 和对应精确 cut。仅 header 的 listing 只转换字段是否存在。API、SDK、DeepSeek、telemetry、query row 与 JSON 表示继续携带普通 number;由它们各自的 adapter 在值进入同进程 domain code 时完成验证与 brand。 + +## Admission and ownership + +Domain constructor 拒绝负数、小数、非有限值与非安全整数。parser 验证原始 number 一次;brand 不需要运行时 wrapper 时,保留原解析对象。编译期 brand 无法发现外部事件里的未知数值字段;格式迁移仍须获得穷尽的 owner disposition,并拒绝无法安全改写的 schema。 + +`session/end-seed` 仍是 lifecycle marker,不是继承 cut 的来源。每次 constructor restore 都会追加或保留该 marker,unseeded replay 也一样,因此 projection 与 cold reader 会显式接收 `inheritedEventCount`,而不是扫描日志。 + +## Alternatives considered + +**继续让所有位置都使用 `number`。** 拒绝,因为事件身份、计数与 cursor 已频繁跨越 package 与 persistence seam,意外混用是迁移风险,而不是局部算术便利。 + +**用同一个 branded Session position 表达身份和偏移。** 拒绝,因为这样仍会在需要已存在事件的位置接受 `eventCount` 或 `fromSeq`,还会迫使 `-1` 与 `null` sentinel 进入互不相关的操作。 + +**从 `session/end-seed` 推导继承 cut。** 拒绝,因为该 marker 记录 constructor lifecycle,并不只记录 fork lineage,而且 constructor seed 可以在继承前缀之后包含 child-owned event。 + +## Consequences + +携带序号的代码会明确说明一个 number 指向事件还是间隙。仅 header 的 reader 无须打开正文即可取得稳定 lineage metadata,persistence、projection、query 与 authorization path 则保留所需的精确 cut。磁盘 v0 格式与公共数值 wire 不变。 + +代价是在 durable 与 wire parser 处显式转换,并让含正文 observation 携带独立的精确 cut 字段。Projection cache identity 包含 lineage bit 与精确 cut,因此其可丢弃的 storage domain 会推进,旧 row 按需重建;不持有 cut 的仅 header reader 会跳过 seeded cache hint。turn number、step number、message-list index、workflow member ordinal、token count 与无关数值领域保持普通 number,因为它们不指向 Session 事件。 + +## Testing + +类型断言钉住 `SessionSeq` 与 `SessionLogOffset` 不可互换。运行时 suite 覆盖 constructor 验证、混合继承与 child-owned seed、空 seed、`ownEvents()` 与 `isOwnSeq()`、plain 与 Zstandard 编码中的 v0 JSONL 缺失/零/非零 header、仅 header 的 listing、cold prepare 与 reopen、query 和 projection cut,以及不变的数值 wire 值。 diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml index d121a5db15..fc258a1609 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.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-06-30-session-store-fork-api.md -2026-06-30-session-store-fork-api.md: ff7617f4bb306926a7b0782751ae82f6ef0c6371 -2026-06-30-session-store-fork-api.zh.md: ecef7ba2985321677b29d3fc7692a8dcb7afb2b6 +2026-06-30-session-store-fork-api.md: a2d169a36a8a0b624abef98377d644d713e5d64c +2026-06-30-session-store-fork-api.zh.md: 5ebf05e677f2d3676f860220b1cf9ff960565402 diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md index ff7617f4bb..a2d169a36a 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md @@ -20,11 +20,11 @@ The store exposes one operation: type SessionForkSource = Session | SessionId class SessionStore extends Service { - fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session + fork(source: SessionForkSource, boundary?: SessionSeq, childSessionId?: SessionId): Session } ``` -`boundary` is the inclusive source event `seq` to copy through. When omitted, it defaults to the source session's current last event; on an empty source, omitted `boundary` creates an empty child. Fork-specific validation checks that the requested boundary exists and that the selected prefix's latest turn boundary is not an unmatched `turn/start`. The selected prefix may therefore end at `turn/end` or at a later standalone event, then is deep-cloned into the child seed. The child inherits the source session's `cwd`, stamps `parentSession` to the source id, and sets `seedLength` to the copied prefix length. When `childSessionId` is omitted, `SessionStore` generates one using its existing id policy. +`boundary` is the branded inclusive source event `seq` to copy through. When omitted, it defaults to the source session's current last event; on an empty source, omitted `boundary` creates an empty child. Fork-specific validation checks that the requested boundary exists and that the selected prefix's latest turn boundary is not an unmatched `turn/start`. The selected prefix may therefore end at `turn/end` or at a later standalone event, then is deep-cloned into the child seed. The child inherits the source session's `cwd`, stamps `parentSession` to the source id, sets logical `isSeeded: true`, and supplies the copied prefix length separately as `inheritedEventCount`. When `childSessionId` is omitted, `SessionStore` generates one using its existing id policy. An empty prefix is forkable; any non-empty boundary must be a safe existing sequence outside an open turn. Typed errors distinguish missing sources, stale objects, duplicate child ids, invalid boundaries, and prefixes ending during execution. Broader log validation and crash repair remain with their existing owners. @@ -44,6 +44,6 @@ The Host creates the child through the agent registry with the selected seed and ## Consequences -The public API stays small and discoverable: live session branching is part of `ctx.sessions`, next to `create({ seed })`, rather than a standalone service or a two-step helper pair. Persistence continues to work through existing `session/created` and `session/flush` behavior: a forked child starts life with seeded events, so existing backends persist that seed once and preserve `parentSession` / `seedLength` in the header. +The public API stays small and discoverable: live session branching is part of `ctx.sessions`, next to `create({ seed })`, rather than a standalone service or a two-step helper pair. Persistence continues to work through existing `session/created` and `session/flush` behavior: a forked child starts life with seeded events, so the JSONL backend persists that seed once and preserves logical `parentSession` / `isSeeded` plus the separate exact cut (encoded as v0 physical `seedLength`). This decision excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. If a future ACP method is added, it should advertise the capability only after it has protocol and snapshot coverage; this Agent Note adds no ACP wire behavior, so no ACP snapshot is required. Fork-child replay remains covered by the existing [seed-boundary testing Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md); focused store, Host, carrier, and client tests pin the boundary and reconciliation contracts, while the real Chromium scenario pins the assembled message action and lineage tree. diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md index ecef7ba298..5ebf05e677 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md @@ -20,11 +20,11 @@ store 暴露一个操作: type SessionForkSource = Session | SessionId class SessionStore extends Service { - fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session + fork(source: SessionForkSource, boundary?: SessionSeq, childSessionId?: SessionId): Session } ``` -`boundary` 是要复制到的源事件 `seq`(含该序号)。省略时默认为源会话当前的最后一个事件;对空源会话省略 `boundary` 则创建一个空的子会话。fork 特有的校验会检查请求的边界存在,并确认所选前缀最近的轮次边界不是未匹配的 `turn/start`。因此,所选前缀可以结束于 `turn/end` 或更晚的独立事件,随后被深拷贝到子会话的种子中。子会话继承源会话的 `cwd`,将 `parentSession` 设为源会话 id,并将 `seedLength` 设为已复制前缀的长度。省略 `childSessionId` 时,`SessionStore` 使用其现有的 id 策略生成一个。 +`boundary` 是要复制到的品牌化源事件 `seq`(含该序号)。省略时默认为源会话当前的最后一个事件;对空源会话省略 `boundary` 则创建一个空的子会话。fork 特有的校验会检查请求的边界存在,并确认所选前缀最近的轮次边界不是未匹配的 `turn/start`。因此,所选前缀可以结束于 `turn/end` 或更晚的独立事件,随后被深拷贝到子会话的种子中。子会话继承源会话的 `cwd`,将 `parentSession` 设为源会话 id,设置 logical `isSeeded: true`,并把复制前缀的长度单独作为 `inheritedEventCount` 传入。省略 `childSessionId` 时,`SessionStore` 使用其现有的 id 策略生成一个。 空前缀可以被 fork;任何非空边界都必须是位于开放轮次之外且安全、已存在的序号。类型化的错误区分源缺失、对象陈旧、子 id 重复、边界无效和前缀结束于执行过程中等情况。更广泛的日志校验与崩溃恢复仍由其现有的负责方处理。 @@ -44,6 +44,6 @@ Host 通过 agent(智能体)注册表,以选定的种子和谱系创建子 ## 后果 -公开 API 保持精简且易于发现:活跃会话分支是 `ctx.sessions` 的一部分,紧邻 `create({ seed })`,而非一个独立服务或一对两步辅助函数。持久化继续通过现有的 `session/created` 和 `session/flush` 行为运作:fork 出的子会话创建时便带有种子事件,因此现有后端只需持久化该种子一次,并在 header 中保存 `parentSession`/`seedLength`。 +公开 API 保持精简且易于发现:活跃会话分支是 `ctx.sessions` 的一部分,紧邻 `create({ seed })`,而非一个独立服务或一对两步辅助函数。持久化继续通过现有的 `session/created` 和 `session/flush` 行为运作:fork 出的子会话创建时便带有种子事件,因此 JSONL 后端只需持久化该种子一次,并保留 logical `parentSession`/`isSeeded` 与单独的精确 cut(编码为 v0 物理 `seedLength`)。 本决策排除 ACP(Agent Client Protocol)`session/fork`、对未加载的已持久化会话执行 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备协议与快照覆盖后才声明支持该能力;本 Agent Note 不添加任何 ACP 协议行为,因此不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.zh.md)覆盖;store、Host、载体与客户端的专项测试固定边界和对账约定,真实 Chromium 场景则固定组装后的消息操作与谱系树。 diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index 57581fa295..2bd3567191 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md -2026-07-21-continuable-background-subagents.md: e1adf8efa19cce8e0632170886770b8d12bc9e3d -2026-07-21-continuable-background-subagents.zh.md: f62c314320a1dace43f3028b68a1bb7d65203e84 +2026-07-21-continuable-background-subagents.md: 24ab64879fa18f5de9952af2dba321e2cae17477 +2026-07-21-continuable-background-subagents.zh.md: 0cf06f7fe30d0953bb6e7f1a4c96527fa181805a diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index e1adf8efa1..24ab64879f 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -71,9 +71,9 @@ Human input uses the same `followup` operation. The UI may display the child tra ### Durable child handle and cold resume -The continuation manager snapshots every descriptor input with the seam's `snapshotSubagentDescriptor()` (built on [`snapshotJsonValue`](../../../../packages/util/values/src/index.ts)) before Task creation, matching the detached lossless-JSON boundary already used by Agent messages. A child-scoped setup contribution — a prepended one-shot `agent/prompt-submit` listener installed by the in-process driver — appends one model-hidden `subagent/descriptor` event before downstream prompt admission can block or throw. Allowed admission opens the initial child turn afterward; rejected admission leaves the descriptor as a pre-turn log-only fact, and the activation's final required checkpoint persists it. The event carries no `surfaceOp`, remains outside model history, and survives when compaction replaces surface history. A known child id is resumable only when loading that child session yields a supported descriptor in the child's own suffix (after `seedLength`, so a fork seed cannot leak an ancestor's descriptor) and its header identifies the caller as the direct parent. +The continuation manager snapshots every descriptor input with the seam's `snapshotSubagentDescriptor()` (built on [`snapshotJsonValue`](../../../../packages/util/values/src/index.ts)) before Task creation, matching the detached lossless-JSON boundary already used by Agent messages. A child-scoped setup contribution — a prepended one-shot `agent/prompt-submit` listener installed by the in-process driver — appends one model-hidden `subagent/descriptor` event before downstream prompt admission can block or throw. Allowed admission opens the initial child turn afterward; rejected admission leaves the descriptor as a pre-turn log-only fact, and the activation's final required checkpoint persists it. The event carries no `surfaceOp`, remains outside model history, and survives when compaction replaces surface history. A known child id is resumable only when loading that child session yields a supported descriptor at or after its exact `inheritedEventCount`, so a fork seed cannot leak an ancestor's descriptor, and its header identifies the caller as the direct parent. -The continuable arm of the versioned descriptor (`SUBAGENT_DESCRIPTOR_VERSION` in [descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts)) carries `mode: 'continuable'`, the subagent provider name, resolved child `agentOptions.provider` and `agentOptions.model`, and optional `persona` and `toolFilter`. It does not snapshot the merge-extensible `AgentOptions` object: unrelated extension values cannot make continuation fail merely because they are not JSON. It deliberately omits `subagentDepth`; cold resume relies on the persisted header's `delegationDepth` rather than reconstructing depth from the descriptor. `outputSchema` belongs to one activation's result contract rather than durable child composition. The child header remains authoritative for the child id, `cwd`, `parentSession`, `seedLength`, and `delegationDepth`, while the persisted child transcript owns the fork seed and subsequent history. [`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) takes the maximum of header and runtime values, so reconstructed runtime options may deepen the persisted value but never lower it and a resumed child cannot regain a top-level delegation budget. +The continuable arm of the versioned descriptor (`SUBAGENT_DESCRIPTOR_VERSION` in [descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts)) carries `mode: 'continuable'`, the subagent provider name, resolved child `agentOptions.provider` and `agentOptions.model`, and optional `persona` and `toolFilter`. It does not snapshot the merge-extensible `AgentOptions` object: unrelated extension values cannot make continuation fail merely because they are not JSON. It deliberately omits `subagentDepth`; cold resume relies on the persisted header's `delegationDepth` rather than reconstructing depth from the descriptor. `outputSchema` belongs to one activation's result contract rather than durable child composition. The child header remains authoritative for the child id, `cwd`, `parentSession`, `isSeeded`, and `delegationDepth`; body-bearing persistence metadata owns the exact `inheritedEventCount`, while the child transcript owns the fork seed and subsequent history. [`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) takes the maximum of header and runtime values, so reconstructed runtime options may deepen the persisted value but never lower it and a resumed child cannot regain a top-level delegation budget. Cold resume cannot depend on an optional method of `SubagentRun`, because that run has been disposed and is not retained across process restart. A run represents one disposable activation and exposes only activation-scoped operations. `SubagentRun.steer?()` names the confirmed live-only capability so it cannot be confused with service orchestration or the model-facing tool. diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index f62c314320..0cf06f7fe3 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -71,9 +71,9 @@ durable child Session ### 持久化 child handle 与从持久化存储恢复 -继续执行管理器在创建 Task 前,通过 seam 的 `snapshotSubagentDescriptor()`(基于 [`snapshotJsonValue`](../../../../packages/util/values/src/index.ts) 构建)对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution——由进程内驱动前置安装的一次性 `agent/prompt-submit` 监听器——会在下游 prompt admission 能够阻止请求或抛出异常之前追加一个对模型隐藏的 `subagent/descriptor` 事件。admission 获准后才会开启 child 的初始轮次;admission 被拒绝时,描述符会作为轮次前的仅日志事实保留,并由该 activation 最终的必需检查点持久化。该事件不携带 `surfaceOp`,不进入模型历史,并在压缩替换 surface 历史时继续保留。只有在加载已知 child id 对应的 child 会话后,能在该 child 自身的后缀中(`seedLength` 之后,因此 fork seed 不会泄露祖先的描述符)得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。 +继续执行管理器在创建 Task 前,通过 seam 的 `snapshotSubagentDescriptor()`(基于 [`snapshotJsonValue`](../../../../packages/util/values/src/index.ts) 构建)对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution——由进程内驱动前置安装的一次性 `agent/prompt-submit` 监听器——会在下游 prompt admission 能够阻止请求或抛出异常之前追加一个对模型隐藏的 `subagent/descriptor` 事件。admission 获准后才会开启 child 的初始轮次;admission 被拒绝时,描述符会作为轮次前的仅日志事实保留,并由该 activation 最终的必需检查点持久化。该事件不携带 `surfaceOp`,不进入模型历史,并在压缩替换 surface 历史时继续保留。只有在加载已知 child id 对应的 child 会话后,能在其精确 `inheritedEventCount` 位置或之后得到受支持的描述符,从而阻止 fork seed 泄露祖先描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。 -版本化描述符的可继续分支([descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts) 中的 `SUBAGENT_DESCRIPTOR_VERSION`)携带 `mode: 'continuable'`、subagent 提供方名称、已解析的 child `agentOptions.provider` 和 `agentOptions.model`,以及可选的 `persona` 与 `toolFilter`。它不会对可通过声明合并扩展的 `AgentOptions` 对象建立快照:与此无关的扩展值不会仅因无法表示为 JSON 而导致继续执行失败。描述符会特意省略 `subagentDepth`;从持久化存储恢复时,系统依赖持久化 header 中的 `delegationDepth`,而不根据描述符重建深度。`outputSchema` 属于单次激活的结果约定,不属于持久化 child 组合配置。child header 仍是 child id、`cwd`、`parentSession`、`seedLength` 和 `delegationDepth` 的权威信息,持久化 child transcript 则负责保存 fork seed 和后续历史。[`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) 会在 header 值和运行时值中取最大值,因此重建后的运行时选项可以加深持久化值,但绝不能降低它,恢复后的 child 无法重新获得顶层委派预算。 +版本化描述符的可继续分支([descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts) 中的 `SUBAGENT_DESCRIPTOR_VERSION`)携带 `mode: 'continuable'`、subagent 提供方名称、已解析的 child `agentOptions.provider` 和 `agentOptions.model`,以及可选的 `persona` 与 `toolFilter`。它不会对可通过声明合并扩展的 `AgentOptions` 对象建立快照:与此无关的扩展值不会仅因无法表示为 JSON 而导致继续执行失败。描述符会特意省略 `subagentDepth`;从持久化存储恢复时,系统依赖持久化 header 中的 `delegationDepth`,而不根据描述符重建深度。`outputSchema` 属于单次激活的结果约定,不属于持久化 child 组合配置。child header 仍是 child id、`cwd`、`parentSession`、`isSeeded` 和 `delegationDepth` 的权威信息;含正文的持久化 metadata 拥有精确 `inheritedEventCount`,child transcript 则负责保存 fork seed 和后续历史。[`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) 会在 header 值和运行时值中取最大值,因此重建后的运行时选项可以加深持久化值,但绝不能降低它,恢复后的 child 无法重新获得顶层委派预算。 从持久化存储恢复不能依赖 `SubagentRun` 的可选方法,因为该 run 已被 dispose,并且进程重启后不会保留。run 表示一次可 dispose 的激活,只暴露作用于当前激活的操作。`SubagentRun.steer?()` 这一名称明确指代提供确认语义且仅适用于在线消息的功能,以免该功能与服务编排或面向模型的工具混淆。 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index 05e4629152..67b922e203 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md -2026-07-23-session-telemetry-otel-revival.md: 836531f605eee5c0dcdf108e9ee2b2744d6f72aa -2026-07-23-session-telemetry-otel-revival.zh.md: 9874598a58ff91bd2cc5ef58c771744d3f3d9610 +2026-07-23-session-telemetry-otel-revival.md: 1110db4b0dfcfd68bde97f9964383ddd041f328b +2026-07-23-session-telemetry-otel-revival.zh.md: 6c046c5f963760638deacacda51f8d94a13e0efd diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index 836531f605..1110db4b0d 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -28,7 +28,7 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry **Map onto OTel spans (GenAI semantic conventions) instead of logs.** Rejected for this revival: the branch implementation's log mapping is reviewed and shipped-shaped; the span model is lossy for forkable, interruptible sessions and belongs to a future consumer with real span queries to serve. -**Full-log replay when no handoff cursor survived (re-export constructor seeds).** Shipped in the first revival round, then narrowed: adoption now replays from the session's construction boundary (`Session.firstLiveSeq`, the constructor-seed length — a fact the session already validated but did not expose; `header.seedLength` cannot serve, it is the durable fork-lineage value and a resumed session's constructor seed is its full stored log). A resumed session's history already shipped from the previous process under the same id, and a fork's inherited prefix already shipped in the parent's stream — re-exporting either re-billed every resume for its full history and doubled query-time counts on OTLP backends with no native ingest dedupe. Receivers stitch fork lineage via `session.parent_id` + `session.seed_length`. What the narrowing gives up, consistently with the at-most-once stance: a resume no longer backfills records the previous process failed to deliver (telemetry unmounted then, or queued at crash) — the full replay's only real benefit, bought at the common case's expense. A deployment that states a backfill requirement needs the deferred outbox above, not replay. The boundary also swallows the synthetic turn closers `SessionPersistence.load()` writes when repairing a crash-interrupted log (they sit below `firstLiveSeq` despite never existing in the previous process) — deliberate, not incidental: exporting a synthetic closer cannot complete the remote turn whose real tail records died in the crashed process's queue, it can only make an incomplete turn look closed. The wire stream stays faithful to what the crashed process actually shipped; receivers read a never-closed turn on a resumed stream as "the previous process died inside it" (the OTel README states the rule), and a later clean `shutdown` marker attests only to the resumed process's exit. Threading the pre-repair boundary through load/prepare so repairs export as live events would couple three packages to un-ship that signal. +**Full-log replay when no handoff cursor survived (re-export constructor seeds).** Shipped in the first revival round, then narrowed: adoption now replays from the session's construction boundary (`Session.firstLiveSeq`, the constructor-seed length; `Session.inheritedEventCount` cannot serve because it is the durable fork-lineage cut and a resumed session's constructor seed is its full stored log). A resumed session's history already shipped from the previous process under the same id, and a fork's inherited prefix already shipped in the parent's stream — re-exporting either re-billed every resume for its full history and doubled query-time counts on OTLP backends with no native ingest dedupe. Receivers stitch fork lineage via `session.parent_id` + `session.seed_length`. What the narrowing gives up, consistently with the at-most-once stance: a resume no longer backfills records the previous process failed to deliver (telemetry unmounted then, or queued at crash) — the full replay's only real benefit, bought at the common case's expense. A deployment that states a backfill requirement needs the deferred outbox above, not replay. The boundary also swallows the synthetic turn closers `SessionPersistence.load()` writes when repairing a crash-interrupted log (they sit below `firstLiveSeq` despite never existing in the previous process) — deliberate, not incidental: exporting a synthetic closer cannot complete the remote turn whose real tail records died in the crashed process's queue, it can only make an incomplete turn look closed. The wire stream stays faithful to what the crashed process actually shipped; receivers read a never-closed turn on a resumed stream as "the previous process died inside it" (the OTel README states the rule), and a later clean `shutdown` marker attests only to the resumed process's exit. Threading the pre-repair boundary through load/prepare so repairs export as live events would couple three packages to un-ship that signal. **Forwarding the seam's turn-boundary `flush()` hint to the OTel provider's `forceFlush()`.** Shipped in the first revival round, then removed: three distinct silent-loss paths shared the wrapper state — a dispose racing an in-flight flush (the SDK's concurrent-flush guard makes shutdown's internal drain skip), overlapping hints displacing the retained promise, and the provider's fixed 30-second flush timeout rejecting while the processor still drains. Every path exists only because the forwarding made this backend the process's second flusher against undocumented SDK internals from the upstream experimental tree; with no `flush()` implemented, the batch processor is the only flusher, its `scheduledDelayMillis` (already deployment-tunable through the `processor` passthrough) governs export cadence, and `shutdown()`'s drain is complete by construction. Reinstate only if a deployment states a turn-boundary latency requirement `scheduledDelayMillis` cannot meet — and then by calling the retained `BatchLogRecordProcessor`'s own `forceFlush()`, never the provider's timeout-wrapped one. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index 9874598a58..6c046c5f96 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -29,7 +29,7 @@ Status: implemented **映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费方。 -**handoff 游标未存活时全量回放日志(重新导出构造函数种子)。** 首轮复活曾交付此方案,其后收窄:接管操作现在从会话的构造边界起回放(`Session.firstLiveSeq`,即构造函数种子长度,这一事实会话早已校验过却未曾暴露;`header.seedLength` 不能胜任:它是持久保存的 fork 谱系(lineage)值,而恢复会话的构造函数种子是其完整的已存储日志)。恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀也已在父会话的流中发出;再次导出任何一者,都会让每次恢复为其完整历史重复付费,并在没有原生摄取去重的 OTLP 后端上使查询时的计数翻倍。接收端基于 `session.parent_id` + `session.seed_length` 拼接 fork 谱系。此次收窄放弃的内容与至多一次立场一致:恢复不再回填上一个进程未能投递的记录(彼时遥测未挂载,或崩溃时仍在队列中)——这本是全量回放唯一的真实收益,代价却由常见情形承担。提出回填要求的部署需要的是上文已推迟的 outbox,而不是回放。该边界同样吞掉 `SessionPersistence.load()` 修复被崩溃打断的日志时写入的合成轮次关闭事件(它们落在 `firstLiveSeq` 之前,尽管在上一个进程中从未存在过)。这是有意为之,而非附带效果:远端轮次的真实尾部记录已随崩溃进程的队列一同消亡,导出合成关闭事件无法补全该轮次,只会让一个未完成的轮次看起来已经关闭。导出的流忠实于崩溃进程实际发出的内容;接收端会把恢复后的流中一个从未关闭的轮次读作「上一个进程死在了该轮次之内」(OTel README 陈述了这条规则),其后干净的 `shutdown` 标记也只证明恢复后进程自身的退出。若为让修复以实时事件的身份导出而将修复前边界贯穿 load/prepare 传递,将使三个包相互耦合,只为抹除这一信号。 +**handoff 游标未存活时全量回放日志(重新导出构造函数种子)。** 首轮复活曾交付此方案,其后收窄:接管操作现在从会话的构造边界起回放(`Session.firstLiveSeq`,即构造函数种子长度;`Session.inheritedEventCount` 不能胜任,因为它是持久保存的 fork 谱系 cut,而恢复会话的构造函数种子是其完整的已存储日志)。恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀也已在父会话的流中发出;再次导出任何一者,都会让每次恢复为其完整历史重复付费,并在没有原生摄取去重的 OTLP 后端上使查询时的计数翻倍。接收端基于 `session.parent_id` + `session.seed_length` 拼接 fork 谱系。此次收窄放弃的内容与至多一次立场一致:恢复不再回填上一个进程未能投递的记录(彼时遥测未挂载,或崩溃时仍在队列中)——这本是全量回放唯一的真实收益,代价却由常见情形承担。提出回填要求的部署需要的是上文已推迟的 outbox,而不是回放。该边界同样吞掉 `SessionPersistence.load()` 修复被崩溃打断的日志时写入的合成轮次关闭事件(它们落在 `firstLiveSeq` 之前,尽管在上一个进程中从未存在过)。这是有意为之,而非附带效果:远端轮次的真实尾部记录已随崩溃进程的队列一同消亡,导出合成关闭事件无法补全该轮次,只会让一个未完成的轮次看起来已经关闭。导出的流忠实于崩溃进程实际发出的内容;接收端会把恢复后的流中一个从未关闭的轮次读作「上一个进程死在了该轮次之内」(OTel README 陈述了这条规则),其后干净的 `shutdown` 标记也只证明恢复后进程自身的退出。若为让修复以实时事件的身份导出而将修复前边界贯穿 load/prepare 传递,将使三个包相互耦合,只为抹除这一信号。 **将 seam 的轮次边界 `flush()` 提示转发到 OTel 提供方的 `forceFlush()`。** 首轮复活曾交付此转发,其后移除:三条不同的静默丢失路径共用同一份包装层状态——dispose 与进行中的 flush 之间的竞态(SDK 的并发 flush 防护会令 shutdown 的内部排空被跳过)、相互重叠的提示顶掉留存的 promise、以及提供方固定的 30 秒 flush 超时在批处理器仍在排空时便 reject。这些路径存在的唯一原因,是该转发让这个后端成为进程内第二个执行 flush 的组件,面对的还是上游实验性(experimental)源码树中未见诸文档的 SDK 内部行为;不实现 `flush()` 时,批处理器就是唯一执行 flush 的组件,其 `scheduledDelayMillis`(已可由部署方经 `processor` passthrough 调优)决定导出节奏,`shutdown()` 的排空从构造上就是完整的。仅当某个部署提出 `scheduledDelayMillis` 无法满足的轮次边界延迟要求时才恢复此转发——且届时应调用留存的 `BatchLogRecordProcessor` 自身的 `forceFlush()`,绝不调用提供方那个带超时包装的版本。 diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index 8a4c76f227..ac6cdbcd01 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md -2026-07-25-subagent-policy-inheritance.md: 34751a4e29e48c84d37425857b8b1b56c8d866eb -2026-07-25-subagent-policy-inheritance.zh.md: 3d5a6c7a88579583a0ab027c52dd3063eed0a8b2 +2026-07-25-subagent-policy-inheritance.md: 6df9ad53f8588018fca53ebae3a9dfba0d4382de +2026-07-25-subagent-policy-inheritance.zh.md: f3f7328d558ea55379cb95e53bb010f9f2d2c792 diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index 34751a4e29..6df9ad53f8 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -12,7 +12,7 @@ Sandbox and approval overrides are per-session log folds. An in-process subagent The delegation boundary snapshots `sandboxPolicy.overrideOf(parent.session)` before its first await, through the shared child-agent helpers (`captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides` in `dsh-subagent`), which the one-shot driver and the [continuable start](2026-08-10-continuable-subagent-policy-inheritance.md) both call. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. The sandbox-policy service is optional, and only the explicit session override is copied, never deployment defaults or one-shot grants. The approval policy is not inherited: the same capture pins every child to `'never'` — the [approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md) supersedes this note's original approval-override inheritance. -Each captured value becomes a source-tagged `sandbox/mode` or `approval/policy` event appended during the child factory's unpublished setup. The session constructor has already fixed `Session.firstLiveSeq` at the fork-prefix length, so the inherited facts follow fork history, reach telemetry when the child is announced, and leave `SessionHeader.seedLength` at the prefix length. Existing last-event-wins folds therefore make the delegation snapshot beat stale fork history and let a later child switch beat the snapshot. A grandchild folds its parent's logged state, so the rule composes without another inheritance mechanism. +Each captured value becomes a source-tagged `sandbox/mode` or `approval/policy` event appended during the child factory's unpublished setup. The session constructor has already fixed `Session.firstLiveSeq` after the constructor seed, while `Session.inheritedEventCount` keeps the exact fork-prefix length, so the inherited facts follow fork history and reach telemetry when the child is announced without changing its lineage cut. Existing last-event-wins folds therefore make the delegation snapshot beat stale fork history and let a later child switch beat the snapshot. A grandchild folds its parent's logged state, so the rule composes without another inheritance mechanism. Ordinary session appends validate the inherited events before publication, and persistence captures the complete unpublished log when the session is announced. Any materialized child log therefore stores the inherited events with its first batch; there is no second policy store, schema field, or query index. The `source: 'delegation'` marker lets approval narration distinguish inheritance from a child-side user switch. diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index 3d5a6c7a88..f3f7328d55 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -12,7 +12,7 @@ Status: implemented 委派边界在第一次 await 之前,经由共享的子 agent 辅助函数(`dsh-subagent` 中的 `captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides`)对 `sandboxPolicy.overrideOf(parent.session)` 获取快照;一次性驱动器与[可继续启动](2026-08-10-continuable-subagent-policy-inheritance.zh.md)都会调用这些辅助函数。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。沙箱策略服务为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。审批策略不继承:同一次捕获会把每个子 agent 钉定为 `'never'`——[审批钉定决策](2026-08-10-subagent-approval-pinned-never.zh.md)取代了本 note 原先的审批覆盖项继承。 -每个捕获值都会成为子 agent 工厂在未发布设置阶段追加的一条带来源标记的 `sandbox/mode` 或 `approval/policy` 事件。会话构造函数已将 `Session.firstLiveSeq` 固定为 fork 前缀的长度,因此继承事实会排在 fork 历史之后,在子 agent 公布时进入遥测,同时让 `SessionHeader.seedLength` 保持为此前缀的长度。因此,既有的末事件胜出折叠会让委派快照压过陈旧的 fork 历史,并让子 agent 后续的切换压过该快照。孙代 agent 会折叠其父级已记录的状态,因此无需另一套继承机制即可组合此规则。 +每个捕获值都会成为子 agent 工厂在未发布设置阶段追加的一条带来源标记的 `sandbox/mode` 或 `approval/policy` 事件。会话构造函数已把 `Session.firstLiveSeq` 固定在 constructor seed 之后,而 `Session.inheritedEventCount` 保留精确的 fork 前缀长度,因此继承事实会排在 fork 历史之后,并在子 agent 公布时进入遥测,却不改变其谱系 cut。因此,既有的末事件胜出折叠会让委派快照压过陈旧的 fork 历史,并让子 agent 后续的切换压过该快照。孙代 agent 会折叠其父级已记录的状态,因此无需另一套继承机制即可组合此规则。 普通的会话追加会在发布前校验继承事件,持久化层则在会话公布时捕获完整的未发布日志。因此,任何已物化的子 agent 日志都会在首批数据中存下继承事件;不存在第二套策略存储、schema 字段或查询索引。`source: 'delegation'` 标记让审批叙述能够区分继承与子 agent 侧的用户切换。 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 5381b7f346..e5d2539eb1 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: bac4a5cfd8965032dad2cf689ca42b1f8e5da1e3 -2026-08-05-durable-web-schedule.zh.md: 266a90d3a56caccda56be9fe648c059939b8155f +2026-08-05-durable-web-schedule.md: 1e7a343472cce638a7d1476871cec221fa9fd889 +2026-08-05-durable-web-schedule.zh.md: 50f3347a2c312e3c1a2ea28c582a277cc93a614e 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 bac4a5cfd8..1e7a343472 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 @@ -22,11 +22,11 @@ The user-visible boundary is `session-local`: the original Session runs an on-ti | Due while busy | Active create remains in the fold | Owner waits for idle maintenance, queues one follow-up, then appends dispatch | A later ordinary conversation turn | | Several Every records are overdue | Each active record retains its earliest unaccepted anchor-aligned target | One decision selects each record's latest occurrence and advances it past now | One ordinary follow-up containing one occurrence per record | | Process stopped or Session cold | Active create remains persisted | No timer or background scan; resume rebuilds the owner | Future target waits; overdue target is attempted | -| Fork | Parent events remain in the inherited prefix | Child fold starts at `seedLength` | Parent work does not become active in the child | +| Fork | Parent events remain in the inherited prefix | Child fold starts at the exact `inheritedEventCount` | Parent work does not become active in the child | ### Session-log authority and tools -The version-1 `schedule/change` stream is the only durable Schedule authority. A create record owns a Session-local, non-reused branded id, the trimmed prompt, its rule discriminator, and UTC target. Delete and one-shot dispatch are terminal transitions. Every dispatch stores its id and decision time so the fold advances that record directly past missed occurrences. The strict decoder and pure fold reject unknown versions, extra fields, reused ids, mismatched dispatch shapes, and transitions against inactive records. A normal Session folds its complete stream; a fork folds only events at or after `SessionHeader.seedLength`. +The version-1 `schedule/change` stream is the only durable Schedule authority. A create record owns a Session-local, non-reused branded id, the trimmed prompt, its rule discriminator, and UTC target. Delete and one-shot dispatch are terminal transitions. Every dispatch stores its id and decision time so the fold advances that record directly past missed occurrences. The strict decoder and pure fold reject unknown versions, extra fields, reused ids, mismatched dispatch shapes, and transitions against inactive records. A normal Session folds its complete stream; a fork folds only events at or after the `inheritedEventCount` passed into projection initialization. When `ctx.sessionProjections` exists, Schedule registers a strict unit that uses the same transition and publishes the complete active `ScheduleRecord[]`; the shared [projection state decision](../architecture/2026-08-19-session-projection-state-and-client-views.md) owns its initialization and restore contract. Corrupt durable input fails the existing read path rather than yielding a partial array. The browser-safe record vocabulary is exposed through the type-only `@deepseek-ai/dsh-schedule/client` subpath. 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 266a90d3a5..50f3347a2c 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 @@ -22,11 +22,11 @@ Status: implemented | 到期时繁忙 | 活动 create 仍在 fold 中 | owner 等待 idle maintenance,排入一个 follow-up,再追加 dispatch | 后续一个普通对话轮次 | | 多条 Every 记录逾期 | 每条活动记录都保留最早一个尚未接受且与锚点对齐的目标 | 一次决策选择每条记录的最新发生时点,并将其推进到当前时刻之后 | 一个普通 follow-up,其中每条记录各有一个发生时点 | | 进程停止或 Session cold | 活动 create 仍在 persistence 中 | 不存在 timer 或后台扫描;resume 重建 owner | 未来目标继续等待;overdue 目标会被尝试 | -| fork | 父 event 留在继承前缀 | child fold 从 `seedLength` 开始 | 父工作不会在 child 中变为活动状态 | +| fork | 父 event 留在继承前缀 | child fold 从精确 `inheritedEventCount` 开始 | 父工作不会在 child 中变为活动状态 | ### Session 日志权威与工具 -版本 1 `schedule/change` stream 是唯一持久的 Schedule 权威。create 记录拥有一个 Session 内不复用的品牌 id、trim 后的提示词、规则判别字段和 UTC 目标。delete 与一次性 dispatch 是终结转换。Every dispatch 会存储 id 与决策时点,使 fold 将该记录直接推进到错过的发生时点之后。严格 decoder 与纯 fold 会拒绝未知版本、额外字段、重复使用的 id、形状不匹配的 dispatch,以及针对非活动记录的转换。普通 Session 折叠完整 stream;fork 只折叠 `SessionHeader.seedLength` 位置及其后的 event。 +版本 1 `schedule/change` stream 是唯一持久的 Schedule 权威。create 记录拥有一个 Session 内不复用的品牌 id、trim 后的提示词、规则判别字段和 UTC 目标。delete 与一次性 dispatch 是终结转换。Every dispatch 会存储 id 与决策时点,使 fold 将该记录直接推进到错过的发生时点之后。严格 decoder 与纯 fold 会拒绝未知版本、额外字段、重复使用的 id、形状不匹配的 dispatch,以及针对非活动记录的转换。普通 Session 折叠完整 stream;fork 只折叠传入 projection 初始化的 `inheritedEventCount` 位置及其后的 event。 `ctx.sessionProjections` 存在时,Schedule 会注册一个复用同一 transition 的严格单元,并发布完整的活动 `ScheduleRecord[]`;共享的 [projection state 决策](../architecture/2026-08-19-session-projection-state-and-client-views.zh.md)拥有其初始化与 restore 约定。损坏的持久输入会使既有读取路径失败,而不会产生部分数组。浏览器安全的记录词汇通过纯类型子路径 `@deepseek-ai/dsh-schedule/client` 暴露。 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml index 26fd9e4836..0032116546 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md -2026-06-22-fork-child-replay-seed-boundary.md: cf4a974035b38ee61e4c2d1cab34776b2ad186c8 -2026-06-22-fork-child-replay-seed-boundary.zh.md: bcc60a583a5f9a4d1fd10ec16e90c49c879d295c +2026-06-22-fork-child-replay-seed-boundary.md: 1fefc3546ebe029ba95318dad3ac2d463a3fe497 +2026-06-22-fork-child-replay-seed-boundary.zh.md: bb43eacf075318dc0101fca2cacb45a03d847764 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md index cf4a974035..1fefc3546e 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md +++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md @@ -18,19 +18,19 @@ Deriving the child script from the whole fork-child log therefore replays the ** Record where a session's **inherited** prefix ends, persist it, and have the replay harness derive a child's script from its **own** events only. -### 1. `seedLength` on the session header +### 1. Lineage metadata and an exact body-owned cut -`SessionHeader` gains an optional `seedLength: number` — how many leading events were inherited via a seed rather than produced by this session. The fork backend stamps it (= the seeded-prefix length) when it creates the child; a fresh spawn leaves it absent (≡ 0). It is threaded through `CreateSessionOptions.meta` (and `CreateAgentOptions.meta`), set in `SessionStore.prepare`. +`SessionHeader.isSeeded` records whether a Session has inherited lineage without exposing a body coordinate to header-only readers. The exact leading-event count is the separately branded `SessionLogOffset` `inheritedEventCount`; a fork supplies both `isSeeded: true` and the copied-prefix length, while a fresh spawn supplies an unseeded header and cut zero. The cut travels through `CreateSessionOptions`, `CreateAgentOptions`, persistence inspection, and restored Session state. -`seedLength` is **explicit**, never inferred from `seed.length`. A reconstruction (resume/load) seeds the session with its WHOLE stored log, so `seed.length` there is the full length, not the original boundary — the resume path passes the persisted `seedLength` back from the loaded header instead. (Same shape as `createdAt`, which is also explicitly preserved on reconstruction rather than re-defaulted to now.) +`inheritedEventCount` is **explicit**, never inferred from `seed.length`. A reconstruction (resume/load) seeds the session with its WHOLE stored log, so `seed.length` there is the full length, not the original boundary — the resume path passes the decoded cut beside the logical header instead. ### 2. JSONL round-trips it -JSONL stores `seedLength` on the header line (`toHeaderLine`/`fromHeaderLine`) and returns it through the shared persistence contract. +The v0 JSONL header keeps its optional numeric `seedLength` for byte compatibility. `toHeaderLine` / `fromHeaderLine` translate it to and from logical `isSeeded` plus the exact `inheritedEventCount`, which the shared body-bearing persistence values return separately. ### 3. Replay derives a child script after the boundary -`dsh-llm-replay`'s `parseSessionHeader` now also reads `seedLength` (absent ⇒ 0), and `loadSessionScripts` derives a child's entries from `parseSessionLog(text).slice(seedLength)` — the events at or after the boundary, i.e. the child's own model calls. For a spawn child `seedLength` is 0 and this is a no-op, so spawn scenarios are byte-for-byte unchanged. +`dsh-llm-replay`'s private v0 parser reads physical `seedLength` into `inheritedEventCount` (absent ⇒ 0), and `loadSessionScripts` derives a child's entries from `parseSessionLog(text).slice(inheritedEventCount)` — the events at or after the boundary, i.e. the child's own model calls. For a spawn child the cut is 0 and this is a no-op, so spawn scenarios are byte-for-byte unchanged. This closes the routing correctness gap, and two recorded fork scenarios exercise it end to end — see [Record fork and mixed spawn+fork snapshot scenarios](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md). @@ -40,5 +40,5 @@ This closes the routing correctness gap, and two recorded fork scenarios exercis ## Consequences -- A new persisted header field spans core and the JSONL provider; the subsystems catalog (`persistence.md`) is updated in the same change (its `SessionHeader` / `CreateSessionOptions` `type-equiv` blocks). -- Spawn replay is unchanged (`seedLength` 0). Fork replay now routes a child to its own script; covered by a regression in `llm-replay`'s tests (a child fixture whose seeded prefix carries a parent chunk — the derived child script must exclude it, proven red without the slice) and a JSONL persistence round trip through the shared coordinator contract. +- The lineage bit spans logical Session metadata while the exact cut spans only body-bearing core, persistence, query, and replay values; the v0 physical header remains unchanged. +- Spawn replay is unchanged (cut 0). Fork replay routes a child to its own script; covered by a regression in `llm-replay`'s tests (a child fixture whose seeded prefix carries a parent chunk — the derived child script must exclude it, proven red without the slice) and a JSONL persistence round trip through the shared coordinator contract. diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md index bcc60a583a..bb43eacf07 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md @@ -18,19 +18,19 @@ subagent 脚本由 [`deriveReplayScript`](../../../../packages/test-support/llm- 记录会话**继承**前缀的结束位置,将其持久化,并让回放 harness 仅从子会话**自身**的事件推导脚本。 -### 1. 会话头部的 `seedLength` +### 1. 谱系 metadata 与正文拥有的精确 cut -`SessionHeader` 新增可选字段 `seedLength: number`——表示有多少前导事件是通过 seed 继承而来、而非本会话产生的。fork 后端在创建子会话时设置它(= 播种前缀的长度);全新的 spawn 子会话不设置(等同于 0)。它通过 `CreateSessionOptions.meta`(及 `CreateAgentOptions.meta`)传递,在 `SessionStore.prepare` 中设置。 +`SessionHeader.isSeeded` 记录 Session 是否具有继承谱系,而不向仅 header 的 reader 暴露正文坐标。精确的前导事件数量是单独品牌化为 `SessionLogOffset` 的 `inheritedEventCount`;fork 同时提供 `isSeeded: true` 与复制前缀的长度,全新的 spawn 则提供 unseeded header 与零 cut。该 cut 经 `CreateSessionOptions`、`CreateAgentOptions`、持久化 inspection 与恢复后的 Session 状态传递。 -`seedLength` 是**显式**的,绝不从 `seed.length` 推断。恢复/加载时用会话的完整已存储日志作为 seed,此时 `seed.length` 是全长而非原始边界——恢复路径改为从加载的 header 中取回持久化的 `seedLength`。(做法与 `createdAt` 相同:恢复时显式保留,而非重新默认为当前时间。) +`inheritedEventCount` 是**显式**的,绝不从 `seed.length` 推断。恢复/加载时用会话的完整已存储日志作为 seed,此时 `seed.length` 是全长而非原始边界——恢复路径改为在 logical header 之外传递解码后的 cut。 ### 2. JSONL 完整往返 -JSONL 把 `seedLength` 存在 header 行(`toHeaderLine`/`fromHeaderLine`),并通过共享持久化约定返回它。 +v0 JSONL header 为保持字节兼容而继续携带可选数值 `seedLength`。`toHeaderLine`/`fromHeaderLine` 在它与 logical `isSeeded` 加精确 `inheritedEventCount` 之间转换,共享的含正文持久化值再单独返回该 cut。 ### 3. 回放从边界之后推导子会话脚本 -`dsh-llm-replay` 的 `parseSessionHeader` 现在也读取 `seedLength`(缺失则为 0),`loadSessionScripts` 从 `parseSessionLog(text).slice(seedLength)` 推导子会话条目——即边界及之后的事件,也就是子会话自身的模型调用。对 spawn 子会话而言 `seedLength` 为 0,此操作是空操作,spawn 场景逐字节不变。 +`dsh-llm-replay` 的私有 v0 parser 把物理 `seedLength` 读入 `inheritedEventCount`(缺失则为 0),`loadSessionScripts` 从 `parseSessionLog(text).slice(inheritedEventCount)` 推导子会话条目——即边界及之后的事件,也就是子会话自身的模型调用。对 spawn 子会话而言 cut 为 0,此操作是空操作,spawn 场景逐字节不变。 这弥补了路由正确性的缺口,两个已录制的 fork 场景对其进行端到端验证——见[记录 fork 与混合 spawn+fork 快照场景](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md)。 @@ -40,5 +40,5 @@ JSONL 把 `seedLength` 存在 header 行(`toHeaderLine`/`fromHeaderLine`), ## 后果 -- core 与 JSONL provider 新增一个持久化 header 字段;子系统目录(`persistence.md`)在同一变更中更新(其 `SessionHeader` / `CreateSessionOptions` 的 `type-equiv` 块)。 -- spawn 回放不变(`seedLength` 为 0)。fork 回放现在将子会话路由到自身的脚本;由 `llm-replay` 测试中的一个回归用例覆盖(一个子会话 fixture,其播种前缀包含父会话的分片——推导出的子会话脚本必须排除它,不做 slice 时该用例会失败),以及通过共享 coordinator 约定执行的 JSONL 持久化往返测试。 +- 谱系 bit 横跨 logical Session metadata,精确 cut 则只横跨含正文的 core、持久化、query 与 replay 值;v0 物理 header 保持不变。 +- spawn 回放不变(cut 为 0)。fork 回放将子会话路由到自身的脚本;由 `llm-replay` 测试中的一个回归用例覆盖(一个子会话 fixture,其播种前缀包含父会话的分片——推导出的子会话脚本必须排除它,不做 slice 时该用例会失败),以及通过共享 coordinator 约定执行的 JSONL 持久化往返测试。 diff --git a/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts index 0d80cba144..498a4e9499 100644 --- a/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts @@ -5,7 +5,7 @@ import { Context } from '@deepseek-ai/cordis' import { normalizeSessionSnapshot, type NormalizeContext } from '@deepseek-ai/dsh-session-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createUserMessage, ToolCallId , createMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { describe, expect, it } from 'vitest' @@ -29,17 +29,18 @@ async function seedInterruptedSession(root: string, cwd: string): Promise { prepare: async (runCwd) => { sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION, [ ...closedTurn(), - { type: 'future/event', seq: 2, time: 3, data: { payload: 1 } } as unknown as SessionEvent, + { type: 'future/event', seq: SessionSeq(2), time: 3, data: { payload: 1 } } as unknown as SessionEvent, ]) }, }) diff --git a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts index 22bca50b83..e3c80f9219 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts @@ -11,7 +11,7 @@ import { Context } from '@deepseek-ai/cordis' import { normalizeSessionSnapshot, type NormalizeContext } from '@deepseek-ai/dsh-session-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { describe, expect, it } from 'vitest' @@ -40,12 +40,13 @@ async function seedDescriptorlessChild(root: string, cwd: string): Promise id: parentId, createdAt: 1, cwd, + isSeeded: false, delegationDepth: 0, } const parentEvents: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } }, - { type: 'user/message', seq: 1, time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Start a background job.' }], source: { kind: 'user' } }), surfaceOp: 'append' }, - { type: 'turn/end', seq: 2, time: 12, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(0), time: 10, data: { turn: 1 } }, + { type: 'user/message', seq: SessionSeq(1), time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Start a background job.' }], source: { kind: 'user' } }), surfaceOp: 'append' }, + { type: 'turn/end', seq: SessionSeq(2), time: 12, data: { turn: 1, reason: { kind: 'completed' } } }, ] const childMeta: SessionHeader = { version: SESSION_FORMAT_VERSION, @@ -53,12 +54,13 @@ async function seedDescriptorlessChild(root: string, cwd: string): Promise createdAt: 2, cwd, parentSession: parentId, + isSeeded: false, origin: 'subagent', delegationDepth: 1, } const childEvents: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 20, data: { turn: 1 } }, - { type: 'turn/end', seq: 1, time: 21, data: { turn: 1, reason: { kind: 'interrupted' } } }, + { type: 'turn/start', seq: SessionSeq(0), time: 20, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(1), time: 21, data: { turn: 1, reason: { kind: 'interrupted' } } }, ] try { await ctx.sessionPersistence.create(parentMeta) diff --git a/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts index 3536082391..9f9da8707d 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts @@ -10,7 +10,7 @@ import { Context } from '@deepseek-ai/cordis' import { normalizeSessionSnapshot, type NormalizeContext } from '@deepseek-ai/dsh-session-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { describe, expect, it } from 'vitest' @@ -36,15 +36,16 @@ async function seedReadOnlyParent(root: string, cwd: string): Promise { id: sessionId, createdAt: 1, cwd, + isSeeded: false, delegationDepth: 0, } const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } }, - { type: 'user/message', seq: 1, time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }), surfaceOp: 'append' }, - { type: 'sandbox/mode', seq: 2, time: 12, data: { mode: 'read-only' } }, + { type: 'turn/start', seq: SessionSeq(0), time: 10, data: { turn: 1 } }, + { type: 'user/message', seq: SessionSeq(1), time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }), surfaceOp: 'append' }, + { type: 'sandbox/mode', seq: SessionSeq(2), time: 12, data: { mode: 'read-only' } }, { type: 'request/header', - seq: 3, + seq: SessionSeq(3), time: 13, data: { header: { @@ -57,7 +58,7 @@ async function seedReadOnlyParent(root: string, cwd: string): Promise { reason: 'initial', }, }, - { type: 'turn/end', seq: 4, time: 14, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/end', seq: SessionSeq(4), time: 14, data: { turn: 1, reason: { kind: 'completed' } } }, ] try { await ctx.sessionPersistence.create(meta) diff --git a/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts index 043c7f4514..2fded92b5b 100644 --- a/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts @@ -14,6 +14,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, + SessionSeq, type SessionEvent, type SessionHeader, } from '@deepseek-ai/dsh-session' @@ -53,6 +54,7 @@ async function seedVisibleBaseline( id: sessionId, createdAt: 1, cwd, + isSeeded: false, delegationDepth: 0, } const files = options.files ?? [{ name: 'AGENTS.md', content: oldInstruction }] @@ -69,17 +71,17 @@ async function seedVisibleBaseline( : { instructionFileCandidates: options.instructionFileCandidates }, }) const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } }, + { type: 'turn/start', seq: SessionSeq(0), time: 10, data: { turn: 1 } }, { type: 'user/message', - seq: 1, + seq: SessionSeq(1), time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Remember the workspace instruction.' }], source: { kind: 'user' } }), surfaceOp: 'append', }, { type: 'user/message', - seq: 2, + seq: SessionSeq(2), time: 12, data: createUserMessage({ content: [{ type: 'text', text: baseline.text }], @@ -98,7 +100,7 @@ async function seedVisibleBaseline( }), surfaceOp: 'append', }, - { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/end', seq: SessionSeq(3), time: 13, data: { turn: 1, reason: { kind: 'completed' } } }, ] try { await ctx.sessionPersistence.create(meta) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 0af082c324..6c2ac3db7b 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -6,7 +6,7 @@ import { dirname, join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { boot, healProfilesModuleFallback, loadOverlayPatches, loadProfile } from '@deepseek-ai/dsh-app-boot' import { provideCmdline } from '@deepseek-ai/dsh-cmdline' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' @@ -655,9 +655,11 @@ describe('a forked session', () => { const inherited = ctx.sessionProjections.stateOf(parent.agent.session, 'agentPreset') ?? undefined const child = await ctx.agents.create({ sessionId: SessionId('preset-fork-child'), + seed: [], + inheritedEventCount: SessionLogOffset(0), meta: { parentSession: SessionId('preset-fork-parent'), - seedLength: 0, + isSeeded: true, ...inherited === undefined ? {} : { agentPreset: inherited }, }, setup: agentCtx => ctx.agentPresets.mount(agentCtx, inherited).then(() => undefined), @@ -685,7 +687,7 @@ describe('a delegated child', () => { // Exactly what an in-process subagent driver's creation window does. const child = await parent.agent.ctx.agents.create({ sessionId: SessionId('preset-child'), - meta: childSessionMeta(parent.agent, 1, 0), + meta: childSessionMeta(parent.agent, 1, false), setup: (agentCtx) => { applyChildComposition(agentCtx, parent.agent, {}) }, @@ -711,7 +713,7 @@ describe('a delegated child', () => { await ctx.agentPresets.recompose(parent.agent.ctx, 'minimal') const child = await parent.agent.ctx.agents.create({ sessionId: SessionId('preset-child-switch'), - meta: childSessionMeta(parent.agent, 1, 0), + meta: childSessionMeta(parent.agent, 1, false), setup: (agentCtx) => { applyChildComposition(agentCtx, parent.agent, {}) }, diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 367b83634d..99437f7f8c 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -15,8 +15,9 @@ import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { MessageId } from '@deepseek-ai/dsh-llm' import { - SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionHeader, type SessionId, + SESSION_FORMAT_VERSION, SessionId as sessionId, SessionSeq, type SessionHeader, type SessionId, } from '@deepseek-ai/dsh-session' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { @@ -116,6 +117,7 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise createdAt, cwd: scaffold.workspaceCwd, parentSession: parentId, + isSeeded: false, origin: 'subagent', delegationDepth: 1, agentPreset: 'minimal', @@ -124,15 +126,17 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise await scaffold.ctx.sessionPersistence.append(childId, [ { type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: createdAt, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }, { type: 'user/message', - seq: 1, + seq: SessionSeq(1), time: createdAt + 1, data: { + id: MessageId(`legacy-message:${childId}:1`), + role: 'user', content: [{ type: 'text', text: 'Check the session-header action order.' }], source: { kind: 'user' }, }, @@ -140,7 +144,7 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise }, { type: 'subagent/descriptor', - seq: 2, + seq: SessionSeq(2), time: createdAt + 2, data: snapshotSubagentDescriptor({ mode: 'one-shot', provider: 'spawn', label: 'header order probe', @@ -148,11 +152,11 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise }, { type: 'turn/end', - seq: 3, + seq: SessionSeq(3), time: createdAt + 3, data: { turn: 1, reason: { kind: 'completed' } }, }, - ] as SessionEvent[]) + ]) } /** diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 15d598a58f..b8e9e4c042 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -311,7 +311,7 @@ describe('web e2e: long Chat interaction contract', () => { const child = scaffold.ctx.agents.list() .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.inheritedEventCount).toBe(boundary.seq + 1) 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) diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts index 564334b1f3..4ba4d4b727 100644 --- a/apps/web/tests/complex-history.perf.ts +++ b/apps/web/tests/complex-history.perf.ts @@ -17,7 +17,7 @@ import { createUserMessage, } from '@deepseek-ai/dsh-llm' import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionSeq } from '@deepseek-ai/dsh-session' import { SESSION_FORMAT_VERSION, Session, @@ -187,7 +187,7 @@ function text(value: string): { type: 'text'; text: string }[] { return [{ type: 'text', text: value }] } -function appendTitle(session: Session, title: string, messageSeq: number): void { +function appendTitle(session: Session, title: string, messageSeq: SessionSeq): void { session.append('session/title', { title, messageSeqs: [messageSeq], diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index dc84191180..3587272a12 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -12,6 +12,7 @@ import { SESSION_FORMAT_VERSION, Session, SessionId, + SessionSeq, } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-reference/types' import type {} from '@deepseek-ai/dsh-session-title' @@ -80,7 +81,7 @@ function targetSessionFixture(): string { references: [{ sessionId: SOURCE_SESSION_ID, label: 'Research notes', - capturedThroughSeq: 4, + capturedThroughSeq: SessionSeq(4), compacted: false, originalMessages: 2, retainedMessages: 2, diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 5a60a049ca..95a7118560 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -67,6 +67,7 @@ import SessionStore, { packChunkRuns, SESSION_FORMAT_VERSION, SessionId, + SessionSeq, type Session, type SessionEvent, type SessionHeader, @@ -801,8 +802,20 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise JSON.stringify(record)), '', ].join('\n') @@ -1030,6 +1043,7 @@ export async function seedSession( id: SessionId(id), createdAt: Date.now() - 60_000, cwd: scaffold.workspaceCwd, + isSeeded: false, delegationDepth: 0, ...agentPreset === undefined ? {} : { agentPreset }, } @@ -1054,11 +1068,12 @@ export async function seedBlankSession( id: SessionId(id), createdAt: Date.now() - 60_000, cwd, + isSeeded: false, delegationDepth: 0, } await persistSeedSession(scaffold, meta, [{ type: 'session/end-seed', - seq: 0, + seq: SessionSeq(0), time: meta.createdAt, data: {}, }]) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 72b98105aa..28c3425941 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -10,7 +10,7 @@ import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { composeEntries, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import { ToolCallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, SessionLogOffset, type SessionEvent } from '@deepseek-ai/dsh-session' import { ScheduleId, createEveryScheduleRecord, @@ -615,8 +615,12 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { await workspace.attachSession(CATALOG_SESSION_ID) // Seed the zero-I/O list view before the Session is opened. - const catalog = await scaffold.ctx.sessionPersistence.readFrom(CATALOG_SESSION_ID, 0) - scaffold.ctx.sessionProjectionCache.coldSnapshot(catalog.meta, catalog.events) + const catalog = await scaffold.ctx.sessionPersistence.readFrom(CATALOG_SESSION_ID, SessionLogOffset(0)) + scaffold.ctx.sessionProjectionCache.coldSnapshot( + catalog.meta, + catalog.inheritedEventCount, + catalog.events, + ) browser = await chromium.launch() page = await browser.newPage({ diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 92750e5d41..56b96adc4c 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -5,8 +5,9 @@ import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { MessageId } from '@deepseek-ai/dsh-llm' import { - SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionHeader, type SessionId, + SESSION_FORMAT_VERSION, SessionId as sessionId, SessionLogOffset, SessionSeq, type SessionEvent, type SessionHeader, type SessionId, } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-agent' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' @@ -66,9 +67,10 @@ async function waitForAgentToSettle(scaffold: WebScaffold, id: SessionId): Promi async function waitForCacheRow( scaffold: WebScaffold, header: SessionHeader, + inheritedEventCount: SessionLogOffset, ): Promise { const deadline = Date.now() + 10_000 - while (scaffold.ctx.sessionProjectionCache.cachedSnapshot(header) === undefined) { + while (scaffold.ctx.sessionProjectionCache.cachedSnapshot(header, inheritedEventCount) === undefined) { if (Date.now() >= deadline) throw new Error(`cache row for "${header.id}" did not land`) await new Promise(resolve => setTimeout(resolve, 10)) } @@ -136,6 +138,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = createdAt: oneShotAt, cwd: scaffold.workspaceCwd, parentSession: parent.id, + isSeeded: false, origin: 'subagent', delegationDepth: 1, } @@ -143,15 +146,17 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const oneShotEvents = [ { type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: oneShotAt, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }, { type: 'user/message', - seq: 1, + seq: SessionSeq(1), time: oneShotAt + 1, data: { + id: MessageId(`legacy-message:${oneShotId}:1`), + role: 'user', content: [{ type: 'text', text: 'Review the event sourcing explanation.' }], source: { kind: 'user' }, }, @@ -159,7 +164,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = }, { type: 'subagent/descriptor', - seq: 2, + seq: SessionSeq(2), time: oneShotAt + 2, data: snapshotSubagentDescriptor({ mode: 'one-shot', provider: 'spawn', label: ONE_SHOT_LABEL, @@ -167,14 +172,14 @@ describe('web e2e: persisted subagent conversation and human continuation', () = }, { type: 'turn/end', - seq: 3, + seq: SessionSeq(3), time: oneShotAt + oneShotDurationMs, data: { turn: 1, reason: { kind: 'completed' } }, }, - ] as SessionEvent[] + ] satisfies SessionEvent[] await scaffold.ctx.sessionPersistence.append(oneShotId, oneShotEvents) - scaffold.ctx.sessionProjectionCache.coldSnapshot(oneShotHeader, oneShotEvents) - await waitForCacheRow(scaffold, oneShotHeader) + scaffold.ctx.sessionProjectionCache.coldSnapshot(oneShotHeader, SessionLogOffset(0), oneShotEvents) + await waitForCacheRow(scaffold, oneShotHeader, SessionLogOffset(0)) grandchildId = sessionId('recorded-grandchild') const authoredAt = Date.now() const grandchildHeader: SessionHeader = { @@ -183,6 +188,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = createdAt: authoredAt, cwd: scaffold.workspaceCwd, parentSession: childId, + isSeeded: false, origin: 'subagent', delegationDepth: 2, } @@ -190,15 +196,17 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const grandchildEvents = [ { type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: authoredAt, - data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + data: { turn: 1 }, }, { type: 'user/message', - seq: 1, + seq: SessionSeq(1), time: authoredAt + 1, data: { + id: MessageId(`legacy-message:${grandchildId}:1`), + role: 'user', content: [{ type: 'text', text: NESTED_PROMPT }], source: { kind: 'user' }, }, @@ -206,7 +214,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = }, { type: 'subagent/descriptor', - seq: 2, + seq: SessionSeq(2), time: authoredAt + 2, data: snapshotSubagentDescriptor({ mode: 'continuable', provider: 'spawn', label: NESTED_LABEL, @@ -214,14 +222,14 @@ describe('web e2e: persisted subagent conversation and human continuation', () = }, { type: 'turn/end', - seq: 3, + seq: SessionSeq(3), time: authoredAt + 3, data: { turn: 1, reason: { kind: 'completed' } }, }, - ] as SessionEvent[] + ] satisfies SessionEvent[] await scaffold.ctx.sessionPersistence.append(grandchildId, grandchildEvents) - scaffold.ctx.sessionProjectionCache.coldSnapshot(grandchildHeader, grandchildEvents) - await waitForCacheRow(scaffold, grandchildHeader) + scaffold.ctx.sessionProjectionCache.coldSnapshot(grandchildHeader, SessionLogOffset(0), grandchildEvents) + await waitForCacheRow(scaffold, grandchildHeader, SessionLogOffset(0)) expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7075380d74..19563b4b27 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: f9eb7ae0bc634e655462412e7dd5d341b7a449a2 -config-catalog.zh.md: 827f39d068de067aefa4dfc77fc53c3db74b5336 +config-catalog.md: ec62912c76ad336a7bd7e966be7921c9938a211c +config-catalog.zh.md: e785cacb15652e4162066cbdae478870cf61bc4e diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f9eb7ae0bc..ec62912c76 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -215,7 +215,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:67`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:68`](../packages/api/session-controller/src/index.ts) @@ -457,7 +457,7 @@ export interface ToolResultPruneConfig { } ``` -Source: [`packages/compaction/compaction-tool-result-pruner/src/types.ts:4`](../packages/compaction/compaction-tool-result-pruner/src/types.ts) +Source: [`packages/compaction/compaction-tool-result-pruner/src/types.ts:5`](../packages/compaction/compaction-tool-result-pruner/src/types.ts) @@ -763,7 +763,7 @@ export interface Config { } ``` -Source: [`packages/goal/goal/src/index.ts:171`](../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:172`](../packages/goal/goal/src/index.ts) @@ -779,7 +779,7 @@ export interface Config { } ``` -Source: [`packages/bundle/headless/src/index.ts:33`](../packages/bundle/headless/src/index.ts) +Source: [`packages/bundle/headless/src/index.ts:34`](../packages/bundle/headless/src/index.ts) @@ -1355,7 +1355,7 @@ export interface ReplayModelConfig { Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/test-support/llm-replay/src/index.ts:919`](../packages/test-support/llm-replay/src/index.ts) +Source: [`packages/test-support/llm-replay/src/index.ts:924`](../packages/test-support/llm-replay/src/index.ts) @@ -1499,7 +1499,7 @@ export interface Config { } ``` -Source: [`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedback/message-feedback/src/index.ts) +Source: [`packages/feedback/message-feedback/src/index.ts:50`](../packages/feedback/message-feedback/src/index.ts) @@ -1800,7 +1800,7 @@ export interface Config { } ``` -Source: [`packages/session/session-log-deepseek/src/index.ts:23`](../packages/session/session-log-deepseek/src/index.ts) +Source: [`packages/session/session-log-deepseek/src/index.ts:36`](../packages/session/session-log-deepseek/src/index.ts) @@ -1858,7 +1858,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session/session-persistence-jsonl/src/index.ts:62`](../packages/session/session-persistence-jsonl/src/index.ts) +Source: [`packages/session/session-persistence-jsonl/src/index.ts:70`](../packages/session/session-persistence-jsonl/src/index.ts) @@ -1882,7 +1882,7 @@ export interface Config { } ``` -Source: [`packages/session/session-projection-cache/src/index.ts:48`](../packages/session/session-projection-cache/src/index.ts) +Source: [`packages/session/session-projection-cache/src/index.ts:55`](../packages/session/session-projection-cache/src/index.ts) @@ -1928,7 +1928,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts) -Source: [`packages/session-query/session-query-sqlite/src/index.ts:89`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:96`](../packages/session-query/session-query-sqlite/src/index.ts) @@ -2014,7 +2014,7 @@ export interface Config { } ``` -Source: [`packages/session/session-title/src/index.ts:55`](../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:56`](../packages/session/session-title/src/index.ts) @@ -2583,7 +2583,7 @@ export interface Config { } ``` -Source: [`packages/context/time-context/src/index.ts:48`](../packages/context/time-context/src/index.ts) +Source: [`packages/context/time-context/src/index.ts:49`](../packages/context/time-context/src/index.ts) @@ -2612,7 +2612,7 @@ Requires: `sessionProjections` export type TokenMeterConfig = Record ``` -Source: [`packages/llm/token-meter/src/types.ts:12`](../packages/llm/token-meter/src/types.ts) +Source: [`packages/llm/token-meter/src/types.ts:13`](../packages/llm/token-meter/src/types.ts) @@ -2959,7 +2959,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:46`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:47`](../packages/subagent/tool-subagent/src/index.ts) @@ -3128,7 +3128,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/interaction/user-approval/src/index.ts:126`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:127`](../packages/interaction/user-approval/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 827f39d068..e785cacb15 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -217,7 +217,7 @@ export interface Config { } ``` -来源:[`packages/api/session-controller/src/index.ts:67`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:68`](../packages/api/session-controller/src/index.ts) @@ -459,7 +459,7 @@ export interface ToolResultPruneConfig { } ``` -来源:[`packages/compaction/compaction-tool-result-pruner/src/types.ts:4`](../packages/compaction/compaction-tool-result-pruner/src/types.ts) +来源:[`packages/compaction/compaction-tool-result-pruner/src/types.ts:5`](../packages/compaction/compaction-tool-result-pruner/src/types.ts) @@ -765,7 +765,7 @@ export interface Config { } ``` -来源:[`packages/goal/goal/src/index.ts:171`](../packages/goal/goal/src/index.ts) +来源:[`packages/goal/goal/src/index.ts:172`](../packages/goal/goal/src/index.ts) @@ -1357,7 +1357,7 @@ export interface ReplayModelConfig { 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -来源:[`packages/test-support/llm-replay/src/index.ts:919`](../packages/test-support/llm-replay/src/index.ts) +来源:[`packages/test-support/llm-replay/src/index.ts:924`](../packages/test-support/llm-replay/src/index.ts) @@ -1501,7 +1501,7 @@ export interface Config { } ``` -来源:[`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedback/message-feedback/src/index.ts) +来源:[`packages/feedback/message-feedback/src/index.ts:50`](../packages/feedback/message-feedback/src/index.ts) @@ -1802,7 +1802,7 @@ export interface Config { } ``` -来源:[`packages/session/session-log-deepseek/src/index.ts:23`](../packages/session/session-log-deepseek/src/index.ts) +来源:[`packages/session/session-log-deepseek/src/index.ts:36`](../packages/session/session-log-deepseek/src/index.ts) @@ -1860,7 +1860,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -来源:[`packages/session/session-persistence-jsonl/src/index.ts:62`](../packages/session/session-persistence-jsonl/src/index.ts) +来源:[`packages/session/session-persistence-jsonl/src/index.ts:70`](../packages/session/session-persistence-jsonl/src/index.ts) @@ -1884,7 +1884,7 @@ export interface Config { } ``` -来源:[`packages/session/session-projection-cache/src/index.ts:48`](../packages/session/session-projection-cache/src/index.ts) +来源:[`packages/session/session-projection-cache/src/index.ts:55`](../packages/session/session-projection-cache/src/index.ts) @@ -1930,7 +1930,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' 依赖:[`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts) -来源:[`packages/session-query/session-query-sqlite/src/index.ts:89`](../packages/session-query/session-query-sqlite/src/index.ts) +来源:[`packages/session-query/session-query-sqlite/src/index.ts:96`](../packages/session-query/session-query-sqlite/src/index.ts) @@ -2016,7 +2016,7 @@ export interface Config { } ``` -来源:[`packages/session/session-title/src/index.ts:55`](../packages/session/session-title/src/index.ts) +来源:[`packages/session/session-title/src/index.ts:56`](../packages/session/session-title/src/index.ts) @@ -2614,7 +2614,7 @@ export interface Config { export type TokenMeterConfig = Record ``` -来源:[`packages/llm/token-meter/src/types.ts:12`](../packages/llm/token-meter/src/types.ts) +来源:[`packages/llm/token-meter/src/types.ts:13`](../packages/llm/token-meter/src/types.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 2ba3190cff..28dc25a1c8 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: f9606dcf399519f0c62b6403f300b66e80e47d36 -event-producer-consumer.zh.md: f9e4d35e6ed213046957b5e7fc1e03a334de9992 +event-producer-consumer.md: bdfb3f23e817dadebb090df154af8e1ebf018ee8 +event-producer-consumer.zh.md: 3fe1fe737cb7e99bb8e3a8641dc7bbd7b11b74a4 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f9606dcf39..bdfb3f23e8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,14 +21,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:542`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:522`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:549`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:528`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:535`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | -| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | @@ -45,7 +45,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index f9e4d35e6e..3fe1fe737c 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,14 +23,14 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:542`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:522`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:549`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:528`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:535`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | -| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | @@ -47,7 +47,7 @@ | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index c5520d4dd2..68394c63b7 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: fd5b3a4a377069fb7b1fbc37b78d11ec037073a4 -persistence-catalog.zh.md: 9d8fc88a44da1620e2de9aa066b9d6c6204b29b1 +persistence-catalog.md: 2fb2b020841a42da6f7505928a87868fc43bc559 +persistence-catalog.zh.md: 4014fd2c10e20193ea68a5f6c761c99b4569b032 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index fd5b3a4a37..2fb2b02084 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -40,7 +40,7 @@ export type SurfaceEventType = */ export type SurfaceOp = | 'append' - | { op: 'replace'; start: number; end: number } + | { op: 'replace'; start: SessionSeq; end: SessionSeq } /** * One immutable entry in the session log. @@ -59,7 +59,7 @@ export type SessionEvent = { [K in SessionEventType]: { type: K /** Monotonic sequence number within the session. */ - seq: number + seq: SessionSeq /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] @@ -83,14 +83,14 @@ export type SessionEvent = { * provider stream; when the field is absent, the event does not record which * earlier events produced the message. */ - sourceEventSeqs?: number[] + sourceEventSeqs?: SessionSeq[] /** How this event entered the surface; absent for non-surface events. */ surfaceOp?: SurfaceOp } : object) }[T] ``` -Sources: [`packages/core/session/src/types.ts:323`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:359`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:366`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:373`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:402`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:434`](../packages/core/session/src/types.ts) ## Events @@ -200,7 +200,7 @@ Source: [`packages/interaction/user-approval/src/types.ts:55`](../packages/inter } ``` -Source: [`packages/interaction/user-approval/src/index.ts:32`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:33`](../packages/interaction/user-approval/src/index.ts) ### `assistant/*` @@ -215,7 +215,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:32`](../packages/inter Types: [StreamChunk](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) @@ -237,7 +237,7 @@ Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) ### `command/*` @@ -256,11 +256,11 @@ Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/ commandId: CommandId kind: 'success' | 'error' text?: string - sourceEventSeq?: number + sourceEventSeq?: import('@deepseek-ai/dsh-session/types').SessionSeq } ``` -Source: [`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:104`](../packages/interaction/commands/src/types.ts) @@ -280,7 +280,7 @@ Source: [`packages/interaction/commands/src/types.ts:103`](../packages/interacti 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -Source: [`packages/interaction/commands/src/types.ts:96`](../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:97`](../packages/interaction/commands/src/types.ts) ### `compaction/*` @@ -296,7 +296,7 @@ Source: [`packages/interaction/commands/src/types.ts:96`](../packages/interactio 'compaction/end': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; error?: string } ``` -Source: [`packages/compaction/compaction/src/types.ts:71`](../packages/compaction/compaction/src/types.ts) +Source: [`packages/compaction/compaction/src/types.ts:72`](../packages/compaction/compaction/src/types.ts) @@ -314,15 +314,15 @@ Source: [`packages/compaction/compaction/src/types.ts:71`](../packages/compactio */ 'compaction/prune': { /** The replaced range's first and last surface-node seqs (a surface-position span, like {@link CompactionResult.shadowedRange}). */ - shadowedRange: { start: number; end: number } + shadowedRange: { start: SessionSeq; end: SessionSeq } /** The seqs of all shadowed surface nodes, in surface order. */ - shadowedSeqs: number[] + shadowedSeqs: SessionSeq[] /** Heuristic price of the shadowed content under the token-meter's fixed estimator. */ shadowedTokenCount: number } ``` -Source: [`packages/compaction/compaction/src/types.ts:81`](../packages/compaction/compaction/src/types.ts) +Source: [`packages/compaction/compaction/src/types.ts:82`](../packages/compaction/compaction/src/types.ts) @@ -337,7 +337,7 @@ Source: [`packages/compaction/compaction/src/types.ts:81`](../packages/compactio 'compaction/start': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null } ``` -Source: [`packages/compaction/compaction/src/types.ts:23`](../packages/compaction/compaction/src/types.ts) +Source: [`packages/compaction/compaction/src/types.ts:24`](../packages/compaction/compaction/src/types.ts) @@ -357,8 +357,8 @@ Source: [`packages/compaction/compaction/src/types.ts:23`](../packages/compactio compactionId: CompactionId sourceCommandId?: CommandId summary: ContentBlock[] - shadowedRange: { start: number; end: number } - shadowedSeqs: number[] + shadowedRange: { start: SessionSeq; end: SessionSeq } + shadowedSeqs: SessionSeq[] shadowedTokenCount: number /** The provider route that wrote the summary. */ provider: string @@ -391,7 +391,7 @@ Source: [`packages/compaction/compaction/src/types.ts:23`](../packages/compactio Types: [ContentBlock](subsystems/core.md) · [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/compaction/compaction/src/types.ts:33`](../packages/compaction/compaction/src/types.ts) +Source: [`packages/compaction/compaction/src/types.ts:34`](../packages/compaction/compaction/src/types.ts) ### `feedback/*` @@ -563,7 +563,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:46`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:339`](../packages/core/session/src/types.ts) @@ -582,7 +582,7 @@ Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/ } ``` -Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:329`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -657,7 +657,7 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) @@ -673,7 +673,7 @@ Source: [`packages/core/session/src/types.ts:319`](../packages/core/session/src/ Types: [SessionTitleEventData](subsystems/session-title.md) -Source: [`packages/session/session-title/src/index.ts:76`](../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:77`](../packages/session/session-title/src/index.ts) @@ -686,7 +686,7 @@ Source: [`packages/session/session-title/src/index.ts:76`](../packages/session/s Types: [SessionTitleLlmRequestEventData](subsystems/session-title.md) -Source: [`packages/session/session-title-llm/src/index.ts:44`](../packages/session/session-title-llm/src/index.ts) +Source: [`packages/session/session-title-llm/src/index.ts:45`](../packages/session/session-title-llm/src/index.ts) ### `session-log-deepseek/*` @@ -700,11 +700,11 @@ Source: [`packages/session/session-title-llm/src/index.ts:44`](../packages/sessi /** Session identity the accepted delivery carried; inherited fork markers retain the parent's id. */ sessionId: import('@deepseek-ai/dsh-session/types').SessionId /** Last canonical event included in the accepted request. */ - throughSeq: number + throughSeq: import('@deepseek-ai/dsh-session/types').SessionSeq } ``` -Source: [`packages/session/session-log-deepseek/src/types.ts:26`](../packages/session/session-log-deepseek/src/types.ts) +Source: [`packages/session/session-log-deepseek/src/types.ts:57`](../packages/session/session-log-deepseek/src/types.ts) ### `step/*` @@ -717,7 +717,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:26`](../packages/se 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) @@ -728,7 +728,7 @@ Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -859,7 +859,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s Types: [ToolCallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts) @@ -934,7 +934,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:318`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1014,7 +1014,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) @@ -1030,7 +1030,7 @@ Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts) ### `user/*` @@ -1049,7 +1049,7 @@ Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 9d8fc88a44..4014fd2c10 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -42,7 +42,7 @@ export type SurfaceEventType = */ export type SurfaceOp = | 'append' - | { op: 'replace'; start: number; end: number } + | { op: 'replace'; start: SessionSeq; end: SessionSeq } /** * One immutable entry in the session log. @@ -61,7 +61,7 @@ export type SessionEvent = { [K in SessionEventType]: { type: K /** Monotonic sequence number within the session. */ - seq: number + seq: SessionSeq /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] @@ -85,14 +85,14 @@ export type SessionEvent = { * provider stream; when the field is absent, the event does not record which * earlier events produced the message. */ - sourceEventSeqs?: number[] + sourceEventSeqs?: SessionSeq[] /** How this event entered the surface; absent for non-surface events. */ surfaceOp?: SurfaceOp } : object) }[T] ``` -来源:[`packages/core/session/src/types.ts:323`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:359`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:366`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:373`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:402`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:434`](../packages/core/session/src/types.ts) ## 事件 @@ -217,7 +217,7 @@ export type SessionEvent = { 类型:[StreamChunk](subsystems/llm-streaming.zh.md) -来源:[`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) @@ -239,7 +239,7 @@ export type SessionEvent = { 类型:[TokenUsage](subsystems/llm-streaming.zh.md) -来源:[`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) ### `command/*` @@ -258,11 +258,11 @@ export type SessionEvent = { commandId: CommandId kind: 'success' | 'error' text?: string - sourceEventSeq?: number + sourceEventSeq?: import('@deepseek-ai/dsh-session/types').SessionSeq } ``` -来源:[`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts) +来源:[`packages/interaction/commands/src/types.ts:104`](../packages/interaction/commands/src/types.ts) @@ -282,7 +282,7 @@ export type SessionEvent = { 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -来源:[`packages/interaction/commands/src/types.ts:96`](../packages/interaction/commands/src/types.ts) +来源:[`packages/interaction/commands/src/types.ts:97`](../packages/interaction/commands/src/types.ts) ### `compaction/*` @@ -298,7 +298,7 @@ export type SessionEvent = { 'compaction/end': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; error?: string } ``` -来源:[`packages/compaction/compaction/src/types.ts:71`](../packages/compaction/compaction/src/types.ts) +来源:[`packages/compaction/compaction/src/types.ts:72`](../packages/compaction/compaction/src/types.ts) @@ -316,15 +316,15 @@ export type SessionEvent = { */ 'compaction/prune': { /** The replaced range's first and last surface-node seqs (a surface-position span, like {@link CompactionResult.shadowedRange}). */ - shadowedRange: { start: number; end: number } + shadowedRange: { start: SessionSeq; end: SessionSeq } /** The seqs of all shadowed surface nodes, in surface order. */ - shadowedSeqs: number[] + shadowedSeqs: SessionSeq[] /** Heuristic price of the shadowed content under the token-meter's fixed estimator. */ shadowedTokenCount: number } ``` -来源:[`packages/compaction/compaction/src/types.ts:81`](../packages/compaction/compaction/src/types.ts) +来源:[`packages/compaction/compaction/src/types.ts:82`](../packages/compaction/compaction/src/types.ts) @@ -339,7 +339,7 @@ export type SessionEvent = { 'compaction/start': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null } ``` -来源:[`packages/compaction/compaction/src/types.ts:23`](../packages/compaction/compaction/src/types.ts) +来源:[`packages/compaction/compaction/src/types.ts:24`](../packages/compaction/compaction/src/types.ts) @@ -359,8 +359,8 @@ export type SessionEvent = { compactionId: CompactionId sourceCommandId?: CommandId summary: ContentBlock[] - shadowedRange: { start: number; end: number } - shadowedSeqs: number[] + shadowedRange: { start: SessionSeq; end: SessionSeq } + shadowedSeqs: SessionSeq[] shadowedTokenCount: number /** The provider route that wrote the summary. */ provider: string @@ -393,7 +393,7 @@ export type SessionEvent = { 类型:[ContentBlock](subsystems/core.zh.md) · [TokenUsage](subsystems/llm-streaming.zh.md) -来源:[`packages/compaction/compaction/src/types.ts:33`](../packages/compaction/compaction/src/types.ts) +来源:[`packages/compaction/compaction/src/types.ts:34`](../packages/compaction/compaction/src/types.ts) ### `feedback/*` @@ -565,7 +565,7 @@ export type SessionEvent = { 'request/context': RequestContext ``` -来源:[`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:339`](../packages/core/session/src/types.ts) @@ -584,7 +584,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:329`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -659,7 +659,7 @@ export type SessionEvent = { 'session/end-seed': Record ``` -来源:[`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) @@ -675,7 +675,7 @@ export type SessionEvent = { 类型:[SessionTitleEventData](subsystems/session-title.zh.md) -来源:[`packages/session/session-title/src/index.ts:76`](../packages/session/session-title/src/index.ts) +来源:[`packages/session/session-title/src/index.ts:77`](../packages/session/session-title/src/index.ts) @@ -688,7 +688,7 @@ export type SessionEvent = { 类型:[SessionTitleLlmRequestEventData](subsystems/session-title.zh.md) -来源:[`packages/session/session-title-llm/src/index.ts:44`](../packages/session/session-title-llm/src/index.ts) +来源:[`packages/session/session-title-llm/src/index.ts:45`](../packages/session/session-title-llm/src/index.ts) ### `session-log-deepseek/*` @@ -702,11 +702,11 @@ export type SessionEvent = { /** Session identity the accepted delivery carried; inherited fork markers retain the parent's id. */ sessionId: import('@deepseek-ai/dsh-session/types').SessionId /** Last canonical event included in the accepted request. */ - throughSeq: number + throughSeq: import('@deepseek-ai/dsh-session/types').SessionSeq } ``` -来源:[`packages/session/session-log-deepseek/src/types.ts:26`](../packages/session/session-log-deepseek/src/types.ts) +来源:[`packages/session/session-log-deepseek/src/types.ts:57`](../packages/session/session-log-deepseek/src/types.ts) ### `step/*` @@ -719,7 +719,7 @@ export type SessionEvent = { 'step/end': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) @@ -730,7 +730,7 @@ export type SessionEvent = { 'step/start': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -861,7 +861,7 @@ export type SessionEvent = { 类型:[ToolCallId](subsystems/core.zh.md) -来源:[`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts) @@ -936,7 +936,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:318`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1016,7 +1016,7 @@ export type SessionEvent = { 类型:[TurnEndReason](subsystems/session.zh.md) -来源:[`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) @@ -1032,7 +1032,7 @@ export type SessionEvent = { 'turn/start': { turn: number } ``` -来源:[`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts) ### `user/*` @@ -1051,7 +1051,7 @@ export type SessionEvent = { 'user/message': UserMessage ``` -来源:[`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/subsystems/commands.i18n.yaml b/docs/subsystems/commands.i18n.yaml index 17bf825901..50447d69ca 100644 --- a/docs/subsystems/commands.i18n.yaml +++ b/docs/subsystems/commands.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/commands.md -commands.md: f3760558b04253701915a240375f13e096bc0c51 -commands.zh.md: 2b4377b76b55203d84a956f66879ab2c50a9fd23 +commands.md: 46d3b736afd3a72d1119c9744cd592a86b9cbc29 +commands.zh.md: db8d6e722e3a1742887aaa4023ab67b9ed914552 diff --git a/docs/subsystems/commands.md b/docs/subsystems/commands.md index f3760558b0..46d3b736af 100644 --- a/docs/subsystems/commands.md +++ b/docs/subsystems/commands.md @@ -83,7 +83,7 @@ type CommandResult = readonly kind: 'success' readonly text?: string /** Earlier authoritative domain event that owns a richer presentation. */ - readonly sourceEventSeq?: number + readonly sourceEventSeq?: SessionSeq } | { readonly kind: 'error'; readonly text: string } ``` diff --git a/docs/subsystems/commands.zh.md b/docs/subsystems/commands.zh.md index 2b4377b76b..db8d6e722e 100644 --- a/docs/subsystems/commands.zh.md +++ b/docs/subsystems/commands.zh.md @@ -83,7 +83,7 @@ type CommandResult = readonly kind: 'success' readonly text?: string /** Earlier authoritative domain event that owns a richer presentation. */ - readonly sourceEventSeq?: number + readonly sourceEventSeq?: SessionSeq } | { readonly kind: 'error'; readonly text: string } ``` diff --git a/docs/subsystems/compaction.i18n.yaml b/docs/subsystems/compaction.i18n.yaml index 4a2a20d79a..93a8216e4b 100644 --- a/docs/subsystems/compaction.i18n.yaml +++ b/docs/subsystems/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 docs/subsystems/compaction.md -compaction.md: 03642b32cc3cb3caf743d908e9c988c1a3c8fa1e -compaction.zh.md: af37c4824cc4b01515400049525fbd7b91d018f3 +compaction.md: b49957a5f476a02ccd12b791f287a9675073c0ae +compaction.zh.md: 4b4c6845b8ff48bce019b352a811f968628cbdbf diff --git a/docs/subsystems/compaction.md b/docs/subsystems/compaction.md index 03642b32cc..b49957a5f4 100644 --- a/docs/subsystems/compaction.md +++ b/docs/subsystems/compaction.md @@ -34,11 +34,11 @@ interface CompactionResult { /** Human command that initiated this compaction, when it was manual. */ sourceCommandId?: CommandId /** The seq of the appended `compaction/start` event. */ - startSeq: number + startSeq: SessionSeq /** The seq of the appended `compaction/summary` event. */ - summarySeq: number + summarySeq: SessionSeq /** The seq of the appended `compaction/end` event. */ - endSeq: number + endSeq: SessionSeq /** The summary content blocks produced by the backend. */ summary: ContentBlock[] /** @@ -49,9 +49,9 @@ interface CompactionResult { * can be GREATER than `end`. {@link CompactionResult.shadowedSeqs} is the * authoritative set of shadowed nodes, in surface order. */ - shadowedRange: { start: number; end: number } + shadowedRange: { start: SessionSeq; end: SessionSeq } /** The seqs of all shadowed surface nodes, in surface order. */ - shadowedSeqs: number[] + shadowedSeqs: SessionSeq[] /** Estimated token count of the shadowed content. */ shadowedTokenCount: number } @@ -95,9 +95,9 @@ The optional tool-result pruning service reports each durable content replacemen /** Cited source event and size accounting for one landed surface replacement. */ interface PrunedEntry { /** Full-fidelity tool-result event shadowed by the replacement. */ - readonly originalSeq: number + readonly originalSeq: SessionSeq /** Newly appended pruned tool-result event. */ - readonly replacementSeq: number + readonly replacementSeq: SessionSeq /** Tool call shared by the original and replacement. */ readonly callId: ToolCallId /** Original text size in Unicode code points. */ @@ -187,10 +187,10 @@ abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sour * @throws when compaction is active or the range is missing, reversed, or unbalanced. * @returns the appended event seqs, summary, replaced range, and token accounting. */ -abstract compactRegion( start: number, end: number, agent: CompactionAgentContext, signal?: AbortSignal, ): Promise +abstract compactRegion( start: SessionSeq, end: SessionSeq, agent: CompactionAgentContext, signal?: AbortSignal, ): Promise ``` -Types: [CommandId](commands.md) +Types: [CommandId](commands.md) · [SessionSeq](session.md) Source: [`packages/compaction/compaction/src/index.ts`](../../packages/compaction/compaction/src/index.ts) diff --git a/docs/subsystems/compaction.zh.md b/docs/subsystems/compaction.zh.md index af37c4824c..4b4c6845b8 100644 --- a/docs/subsystems/compaction.zh.md +++ b/docs/subsystems/compaction.zh.md @@ -34,11 +34,11 @@ interface CompactionResult { /** Human command that initiated this compaction, when it was manual. */ sourceCommandId?: CommandId /** The seq of the appended `compaction/start` event. */ - startSeq: number + startSeq: SessionSeq /** The seq of the appended `compaction/summary` event. */ - summarySeq: number + summarySeq: SessionSeq /** The seq of the appended `compaction/end` event. */ - endSeq: number + endSeq: SessionSeq /** The summary content blocks produced by the backend. */ summary: ContentBlock[] /** @@ -49,9 +49,9 @@ interface CompactionResult { * can be GREATER than `end`. {@link CompactionResult.shadowedSeqs} is the * authoritative set of shadowed nodes, in surface order. */ - shadowedRange: { start: number; end: number } + shadowedRange: { start: SessionSeq; end: SessionSeq } /** The seqs of all shadowed surface nodes, in surface order. */ - shadowedSeqs: number[] + shadowedSeqs: SessionSeq[] /** Estimated token count of the shadowed content. */ shadowedTokenCount: number } @@ -95,9 +95,9 @@ type ManualCompactionErrorCode = /** Cited source event and size accounting for one landed surface replacement. */ interface PrunedEntry { /** Full-fidelity tool-result event shadowed by the replacement. */ - readonly originalSeq: number + readonly originalSeq: SessionSeq /** Newly appended pruned tool-result event. */ - readonly replacementSeq: number + readonly replacementSeq: SessionSeq /** Tool call shared by the original and replacement. */ readonly callId: ToolCallId /** Original text size in Unicode code points. */ @@ -187,10 +187,10 @@ abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sour * @throws when compaction is active or the range is missing, reversed, or unbalanced. * @returns the appended event seqs, summary, replaced range, and token accounting. */ -abstract compactRegion( start: number, end: number, agent: CompactionAgentContext, signal?: AbortSignal, ): Promise +abstract compactRegion( start: SessionSeq, end: SessionSeq, agent: CompactionAgentContext, signal?: AbortSignal, ): Promise ``` -Types: [CommandId](commands.zh.md) +Types: [CommandId](commands.zh.md) · [SessionSeq](session.zh.md) Source: [`packages/compaction/compaction/src/index.ts`](../../packages/compaction/compaction/src/index.ts) diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 6f08cfb825..a60f0c783f 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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/core.md -core.md: 124d30a5abeddd81edd2eb03a9bb213487b6a38e -core.zh.md: c2d67eabb3b223d3f1d20c42f01280f16656ebef +core.md: a59a692573c2e2756e60626268314a8ade6c546e +core.zh.md: acea4966a30ab03c24f1aea9bcd2d33a6271a28d diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 124d30a5ab..a59a692573 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -46,7 +46,7 @@ interface AgentHandle { } ``` -`CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: session metadata (`meta` — validated `cwd`, fork lineage, seed boundary, origin classification, delegation depth), an optional `seed` replay prefix for forks, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) composes the agent's scoped world while both ids are still unpublished — everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly — and may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal rolls the transaction back without publishing either id. +`CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: session metadata (`meta` — validated `cwd`, fork lineage, the `isSeeded` marker, origin classification, delegation depth, and `agentPreset`), the exact fork cut in sibling field `inheritedEventCount`, an optional `seed` replay prefix, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) composes the agent's scoped world while both ids are still unpublished — everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly — and may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal rolls the transaction back without publishing either id. `AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index c2d67eabb3..acea4966a3 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -48,7 +48,7 @@ interface AgentHandle { } ``` -`CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:会话元数据(`meta`——已校验的 `cwd`、fork 谱系、seed 边界、来源分类、委派深度)、fork 用的可选 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应项:`resumeSessionId`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在两个 id 都尚未发布时组装 agent 的作用域世界——凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在——并可返回一个在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose(资源释放)都会回滚事务,两个 id 均不发布。 +`CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:会话元数据(`meta`——已校验的 `cwd`、fork 谱系、`isSeeded` 标记、来源分类、委派深度与 `agentPreset`)、同级字段 `inheritedEventCount` 所表示的精确 fork cut、可选的 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应项:`resumeSessionId`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在两个 id 都尚未发布时组装 agent 的作用域世界——凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在——并可返回一个在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose(资源释放)都会回滚事务,两个 id 均不发布。 `AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。 diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 757092f100..ec456511f9 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/persistence.md -persistence.md: cd7eaa126ea16b17526223498d1fa1591792747c -persistence.zh.md: c96aa87f738e5607c699dfa97e6d2b237767b76d +persistence.md: b61ddee8d12854b330b967a09a1a685d763f0041 +persistence.zh.md: 08cc35d987360138ec096b70f8c6b11b5cba4c55 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index cd7eaa126e..b61ddee8d1 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -40,7 +40,7 @@ interface SessionLocation { ## `SessionHeader` — metadata beside the log -Per-session metadata travels **separately** from the event log: format version, cwd, lineage, and the seed boundary are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()`. The header is attached to a `Session` via `session.header`. +Per-session metadata travels **separately** from the event log: the header carries format version, cwd, and the `isSeeded` lineage bit, while body-bearing storage values carry the exact inherited cut beside it. Neither belongs to `SessionEventMap` or reaches `deriveMessages()`. The logical header is attached through `session.header`; the Session exposes its cut as `inheritedEventCount`. Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) @@ -64,10 +64,10 @@ interface SessionHeader { /** The session this one was forked from (seed lineage), if any. */ readonly parentSession?: SessionId /** - * How many leading events were inherited through a seed. Persisting this - * boundary lets resume and replay distinguish parent history from child work. + * Whether this Session contains a fork-inherited event prefix. The exact prefix + * length is Session state rather than ordinary header metadata. */ - readonly seedLength?: number + readonly isSeeded: boolean /** * Coarse product classification for a session created as a subagent child. * This is presentation metadata, not proof that the child is continuable. @@ -95,7 +95,7 @@ A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupp ## `CreateSessionOptions` — seeding and metadata -Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, the `agentPreset` the agent was composed from, and an existing `createdAt`. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. +Creating a `Session` through the store takes a `seed` (initial replay or fork history), an optional exact `inheritedEventCount`, and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, `parentSession` lineage, `isSeeded` lineage bit, optional coarse `origin`, `delegationDepth`, `agentPreset`, and an existing `createdAt`. A seeded creation requires both an explicit seed and exact cut because child-owned setup events may follow the inherited prefix. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. ```ts type-equiv /** @@ -107,14 +107,19 @@ interface CreateSessionOptions { /** Initial replay or fork history supplied at construction. */ readonly seed?: readonly SessionEvent[] /** - * Storage metadata read once before publication. `seedLength` is explicit - * because a resumed seed contains the full stored log, not only its inherited prefix. + * Exact fork-inherited prefix length when `meta.isSeeded` is true. A + * constructor seed may also contain child-owned setup events after this cut. + */ + readonly inheritedEventCount?: SessionLogOffset + /** + * Storage metadata read once before publication. `isSeeded` marks fork + * lineage; supplying replay history alone does not make it inherited. */ readonly meta?: { readonly cwd?: string readonly parentSession?: SessionId readonly createdAt?: number - readonly seedLength?: number + readonly isSeeded?: boolean readonly origin?: 'subagent' readonly delegationDepth?: number readonly agentPreset?: string @@ -122,7 +127,21 @@ interface CreateSessionOptions { } ``` -Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })`. +Plain replay is `ctx.sessions.create(id, { seed: seedEvents })`; a fork additionally supplies `inheritedEventCount` and `meta.isSeeded: true`. Resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })`. + +## `SessionStorageMetadata` — logical header and inherited cut + +Every persistence result that reads a Session body carries `SessionStorageMetadata`: the current logical header plus the separately validated inherited-event cut. Header-only listing intentionally returns only `SessionHeader`. + +```ts type-equiv +/** Logical Session header paired with its exact inherited cut for body-bearing storage operations. */ +interface SessionStorageMetadata { + /** Validated immutable Session header. */ + readonly meta: SessionHeader + /** Number of leading events inherited from the Session's fork parent. */ + readonly inheritedEventCount: SessionLogOffset +} +``` ## `SessionRawArtifact` — verbatim stored artifact text @@ -130,9 +149,7 @@ A backend's own artifact text for one session, byte-identical to what it durably ```ts type-equiv /** A backend's own raw artifact text for one session, verbatim. */ -interface SessionRawArtifact { - /** The session header parsed from the artifact's own first line. */ - readonly meta: SessionHeader +interface SessionRawArtifact extends SessionStorageMetadata { /** The artifact's base filename on disk, without any physical encoding suffix. */ readonly filename: string /** The artifact's full text content, decoded from the backend's physical encoding. */ @@ -154,6 +171,8 @@ interface RestoredSessionOptions { readonly seed: SessionEvent[] /** Fresh detached storage metadata to validate and freeze in place. */ readonly meta: SessionHeader + /** Exact number of fork-inherited leading events decoded from storage. */ + readonly inheritedEventCount: SessionLogOffset /** Select the persistence ownership-transfer path. */ readonly seedSource: 'persistence' } @@ -198,14 +217,26 @@ declare class SessionPreparation implements Disposable { ```ts type-equiv /** Immutable logical session prepared from persistence or a live owner. */ -interface SessionInspection { - /** Validated immutable session metadata. */ - readonly meta: SessionHeader +interface SessionInspection extends SessionStorageMetadata { /** Validated contiguous logical event log. */ readonly events: readonly SessionEvent[] } ``` +## Detached stored-log suffixes + +`readFrom` returns a detached `SessionEventSuffix` anchored by the requested `fromSeq`. Its event list may start above zero or be empty, so it is not a complete `SessionInspection` and must not be restored as a whole Session. + +```ts type-equiv +/** Detached logical suffix returned by one explicit stored-log offset read. */ +interface SessionEventSuffix extends SessionStorageMetadata { + /** First requested log offset; {@link events} contains only seqs at or after it. */ + readonly fromSeq: SessionLogOffset + /** Valid contiguous stored events at or after {@link fromSeq}; not a complete Session log when the offset is nonzero. */ + readonly events: readonly SessionEvent[] +} +``` + ## Lightweight source revisions Consumers of derived state compare a cheap opaque revision before loading a full event log. The persistence backend owns its representation and changes it transactionally with append or mutating load repair; callers compare it only for equality. @@ -281,8 +312,10 @@ readRaw(_id: SessionId, signal?: AbortSignal): Promise +abstract create(meta: SessionHeader, inheritedEventCount?: SessionLogOffset): Promise /** * Ensure a live session has a durable header even when it has no events. @@ -297,6 +330,8 @@ ensureMaterialized(_session: Session): Promise * seq contracts: the first event's `seq` MUST equal the stored next-seq * (after `load` has durably closed any interrupted turn). Rejects non-JSON- * serializable `event.data` with an error naming the offending event type. + * A seeded session's first materializing batch must reach its complete + * inherited prefix. * @param id - the session the batch belongs to. * @param events - the contiguous batch to persist, in seq order. */ @@ -371,11 +406,11 @@ abstract borrowSession(id: SessionId, signal?: AbortSignal): Promise= fromSeq`. + * @returns storage metadata, the requested offset, and stored events with `seq >= fromSeq`. */ -abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +abstract readFrom(id: SessionId, fromSeq: SessionLogOffset, signal?: AbortSignal): Promise /** * Lightweight listing from metadata, without a full-log parse. @@ -397,7 +432,7 @@ abstract list(signal?: AbortSignal): Promise abstract listSnapshots(signal?: AbortSignal): Promise ``` -Types: [Session](session.md) · [SessionEvent](session.md) · [SessionId](core.md) +Types: [Session](session.md) · [SessionEvent](session.md) · [SessionId](core.md) · [SessionLogOffset](session.md) Source: [`packages/session/session-persistence/src/index.ts`](../../packages/session/session-persistence/src/index.ts) diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index c96aa87f73..08cc35d987 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -40,7 +40,7 @@ interface SessionLocation { ## `SessionHeader`:日志旁的元数据 -每个会话的元数据与事件日志**分开**存储:格式版本、cwd、血统与 seed 边界是存储层关注点而非对话事件,因此不进入 `SessionEventMap`,也不会到达 `deriveMessages()`。header 通过 `session.header` 附加到 `Session` 上。 +每个会话的元数据与事件日志**分开**存储:header 携带格式版本、cwd 与 `isSeeded` 谱系 bit,含正文的存储值则在其旁边单独携带精确 inherited cut。二者都不进入 `SessionEventMap`,也不会到达 `deriveMessages()`。logical header 通过 `session.header` 附加,Session 则以 `inheritedEventCount` 暴露其 cut。 源码:[`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) @@ -64,10 +64,10 @@ interface SessionHeader { /** The session this one was forked from (seed lineage), if any. */ readonly parentSession?: SessionId /** - * How many leading events were inherited through a seed. Persisting this - * boundary lets resume and replay distinguish parent history from child work. + * Whether this Session contains a fork-inherited event prefix. The exact prefix + * length is Session state rather than ordinary header metadata. */ - readonly seedLength?: number + readonly isSeeded: boolean /** * Coarse product classification for a session created as a subagent child. * This is presentation metadata, not proof that the child is continuable. @@ -95,7 +95,7 @@ interface SessionHeader { ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 整合进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth`、用于组装该 agent(智能体)的 `agentPreset` 以及已有的 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 +通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)、可选的精确 `inheritedEventCount` 与 `meta`(store 整合进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`isSeeded` 谱系标记、可选的粗粒度 `origin`、`delegationDepth`、用于组装该 agent(智能体)的 `agentPreset` 以及已有的 `createdAt`。seeded 创建必须同时显式提供 seed 与精确 cut,因为继承前缀之后还可能存在 child-owned setup event。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 ```ts type-equiv /** @@ -107,14 +107,19 @@ interface CreateSessionOptions { /** Initial replay or fork history supplied at construction. */ readonly seed?: readonly SessionEvent[] /** - * Storage metadata read once before publication. `seedLength` is explicit - * because a resumed seed contains the full stored log, not only its inherited prefix. + * Exact fork-inherited prefix length when `meta.isSeeded` is true. A + * constructor seed may also contain child-owned setup events after this cut. + */ + readonly inheritedEventCount?: SessionLogOffset + /** + * Storage metadata read once before publication. `isSeeded` marks fork + * lineage; supplying replay history alone does not make it inherited. */ readonly meta?: { readonly cwd?: string readonly parentSession?: SessionId readonly createdAt?: number - readonly seedLength?: number + readonly isSeeded?: boolean readonly origin?: 'subagent' readonly delegationDepth?: number readonly agentPreset?: string @@ -122,7 +127,21 @@ interface CreateSessionOptions { } ``` -因此,回放/fork 的调用方式为 `ctx.sessions.create(id, { seed: seedEvents })`;将一个*持久化*会话恢复为活跃 agent 的调用方式为 `ctx.agents.resume({ resumeSessionId })`。 +因此,普通回放的调用方式为 `ctx.sessions.create(id, { seed: seedEvents })`;fork 还会提供 `inheritedEventCount` 与 `meta.isSeeded: true`。将一个*持久化*会话恢复为活跃 agent 的调用方式为 `ctx.agents.resume({ resumeSessionId })`。 + +## `SessionStorageMetadata`:逻辑 header 与继承 cut + +每个读取 Session 正文的持久化结果都携带 `SessionStorageMetadata`:当前逻辑 header,以及单独校验的继承事件 cut。仅 header 的列表操作有意只返回 `SessionHeader`。 + +```ts type-equiv +/** Logical Session header paired with its exact inherited cut for body-bearing storage operations. */ +interface SessionStorageMetadata { + /** Validated immutable Session header. */ + readonly meta: SessionHeader + /** Number of leading events inherited from the Session's fork parent. */ + readonly inheritedEventCount: SessionLogOffset +} +``` ## `SessionRawArtifact`——逐字存储工件文本 @@ -130,9 +149,7 @@ interface CreateSessionOptions { ```ts type-equiv /** A backend's own raw artifact text for one session, verbatim. */ -interface SessionRawArtifact { - /** The session header parsed from the artifact's own first line. */ - readonly meta: SessionHeader +interface SessionRawArtifact extends SessionStorageMetadata { /** The artifact's base filename on disk, without any physical encoding suffix. */ readonly filename: string /** The artifact's full text content, decoded from the backend's physical encoding. */ @@ -154,6 +171,8 @@ interface RestoredSessionOptions { readonly seed: SessionEvent[] /** Fresh detached storage metadata to validate and freeze in place. */ readonly meta: SessionHeader + /** Exact number of fork-inherited leading events decoded from storage. */ + readonly inheritedEventCount: SessionLogOffset /** Select the persistence ownership-transfer path. */ readonly seedSource: 'persistence' } @@ -198,14 +217,26 @@ declare class SessionPreparation implements Disposable { ```ts type-equiv /** Immutable logical session prepared from persistence or a live owner. */ -interface SessionInspection { - /** Validated immutable session metadata. */ - readonly meta: SessionHeader +interface SessionInspection extends SessionStorageMetadata { /** Validated contiguous logical event log. */ readonly events: readonly SessionEvent[] } ``` +## 分离的持久日志后缀 + +`readFrom` 返回以请求的 `fromSeq` 为锚点、与其他状态分离的 `SessionEventSuffix`。其事件列表可能从非零位置开始,也可能为空,因此它不是完整的 `SessionInspection`,不得作为完整 Session 恢复。 + +```ts type-equiv +/** Detached logical suffix returned by one explicit stored-log offset read. */ +interface SessionEventSuffix extends SessionStorageMetadata { + /** First requested log offset; {@link events} contains only seqs at or after it. */ + readonly fromSeq: SessionLogOffset + /** Valid contiguous stored events at or after {@link fromSeq}; not a complete Session log when the offset is nonzero. */ + readonly events: readonly SessionEvent[] +} +``` + ## 轻量源修订号 派生状态的消费方会在加载完整事件日志之前比较一个低开销的不透明修订号。其表示由持久化后端拥有,并随 append 或会修改数据的 load 修复以事务方式改变;调用方仅比较修订号是否相等。 @@ -281,8 +312,10 @@ readRaw(_id: SessionId, signal?: AbortSignal): Promise +abstract create(meta: SessionHeader, inheritedEventCount?: SessionLogOffset): Promise /** * Ensure a live session has a durable header even when it has no events. @@ -297,6 +330,8 @@ ensureMaterialized(_session: Session): Promise * seq contracts: the first event's `seq` MUST equal the stored next-seq * (after `load` has durably closed any interrupted turn). Rejects non-JSON- * serializable `event.data` with an error naming the offending event type. + * A seeded session's first materializing batch must reach its complete + * inherited prefix. * @param id - the session the batch belongs to. * @param events - the contiguous batch to persist, in seq order. */ @@ -371,11 +406,11 @@ abstract borrowSession(id: SessionId, signal?: AbortSignal): Promise= fromSeq`. + * @returns storage metadata, the requested offset, and stored events with `seq >= fromSeq`. */ -abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +abstract readFrom(id: SessionId, fromSeq: SessionLogOffset, signal?: AbortSignal): Promise /** * Lightweight listing from metadata, without a full-log parse. @@ -397,7 +432,7 @@ abstract list(signal?: AbortSignal): Promise abstract listSnapshots(signal?: AbortSignal): Promise ``` -Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionId](core.zh.md) +Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionId](core.zh.md) · [SessionLogOffset](session.zh.md) Source: [`packages/session/session-persistence/src/index.ts`](../../packages/session/session-persistence/src/index.ts) diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml index 37a91f5c75..547a308dd8 100644 --- a/docs/subsystems/schedule.i18n.yaml +++ b/docs/subsystems/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 docs/subsystems/schedule.md -schedule.md: f98cf319d588f805e4630ca572f10c3c45e94b79 -schedule.zh.md: bee816be9cc49943f5815d4d13869b27e30380d5 +schedule.md: 40aa3592dec89fe8daa45beb630076e0476d2b16 +schedule.zh.md: 53a36ee9b51545ca74ac6166ef31d1e002d1f1f2 diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md index f98cf319d5..40aa3592de 100644 --- a/docs/subsystems/schedule.md +++ b/docs/subsystems/schedule.md @@ -149,7 +149,7 @@ type ScheduleDispatchChange = OneShotScheduleDispatchChange | EveryScheduleDispa type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange ``` -The strict decoder and fold reject unknown versions, extra fields, reused ids, mismatched one-shot or Every dispatch shapes, and delete or dispatch transitions against inactive records. A normal Session folds its complete event stream. A fork folds only events at or after `SessionHeader.seedLength`, so it retains history without adopting the parent Session's active reminders. The Schedule projection derives that boundary from the immutable header passed to `init(header)`, uses the shared transition, and persists both active records and used-id history so cached restore preserves strict replay. The `schedule/change` declaration and source location are also indexed in the [persistence catalog](../persistence-catalog.md#schedulechange--log-only). +The strict decoder and fold reject unknown versions, extra fields, reused ids, mismatched one-shot or Every dispatch shapes, and delete or dispatch transitions against inactive records. A normal Session folds its complete event stream. A fork folds only events at or after its exact `inheritedEventCount`, so it retains history without adopting the parent Session's active reminders. Projection initialization receives that cut beside the immutable header, uses the shared transition, and persists both the cut, active records, and used-id history so cached restore preserves strict replay. The `schedule/change` declaration and source location are also indexed in the [persistence catalog](../persistence-catalog.md#schedulechange--log-only). ## Active views and management diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md index bee816be9c..53a36ee9b5 100644 --- a/docs/subsystems/schedule.zh.md +++ b/docs/subsystems/schedule.zh.md @@ -149,7 +149,7 @@ type ScheduleDispatchChange = OneShotScheduleDispatchChange | EveryScheduleDispa type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange ``` -严格 decoder 与 fold 会拒绝未知版本、额外字段、复用 id、不匹配的一次性提醒或 Every dispatch 形状,以及针对非活动记录的 delete 或 dispatch 转换。普通 Session 折叠完整事件流。fork 只折叠 `SessionHeader.seedLength` 位置及其后的事件,因此保留历史,但不会接管父 Session 的活动提醒。Schedule projection 从传给 `init(header)` 的不可变 header 派生该边界,复用共享 transition,并持久化活动记录与已使用 id 历史,使缓存恢复继续保持严格回放。`schedule/change` 声明和源码位置也编入[持久化目录](../persistence-catalog.zh.md#schedulechange--log-only)。 +严格 decoder 与 fold 会拒绝未知版本、额外字段、复用 id、不匹配的一次性提醒或 Every dispatch 形状,以及针对非活动记录的 delete 或 dispatch 转换。普通 Session 折叠完整事件流。fork 只折叠精确 `inheritedEventCount` 位置及其后的事件,因此保留历史,但不会接管父 Session 的活动提醒。Projection 初始化会在不可变 header 旁接收该 cut,复用共享 transition,并持久化 cut、活动记录与已使用 id 历史,使缓存恢复继续保持严格回放。`schedule/change` 声明和源码位置也编入[持久化目录](../persistence-catalog.zh.md#schedulechange--log-only)。 ## 活动视图与管理 diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 1cad3ee1f0..fd7e96441a 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: b5bacc4846a9a9709bb8c102aaa78810a2752110 -session-projection.zh.md: 36653721a125ceef13bff8b1a4562e7981a45696 +session-projection.md: c8a5d4c1b05d830be3224e2e04db1762fe4b3c8f +session-projection.zh.md: d46c4f1589f57e560d70871d011245a3547c7825 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index b5bacc4846..c8a5d4c1b0 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -30,9 +30,10 @@ interface ProjectionDefinition< /** * State for the empty log and its immutable Session metadata. * @param header - immutable metadata for the Session being projected. + * @param inheritedEventCount - exact fork-inherited prefix length. * @returns the initial state. */ - init(header: SessionHeader): NoInfer + init(header: SessionHeader, inheritedEventCount: SessionLogOffset): NoInfer /** * Pure transition: previous state + one committed event → next state. A * unit uninterested in an event MUST return the same state reference — an @@ -78,7 +79,7 @@ The whole-value event rule is load-bearing: a state-carrying log event carries t */ interface ProjectionSnapshot { /** Seq of the last event the values reflect; -1 for an empty log. */ - asOfSeq: number + asOfSeq: SessionSeqCursor /** Whole current client value per registered key. */ values: Partial } @@ -94,7 +95,7 @@ type ProjectionChangeListener = ( session: Session, key: Extract, value: unknown, - seq: number, + seq: SessionSeq, ) => void ``` @@ -128,11 +129,13 @@ The persisted projection cache service. Opens the `session_projcache` domain at * paths (the history tail baseline) supersede these values whenever a * session is actually opened. * @param meta - the listed session's header (identity witness; no log read). + * @param inheritedEventCount - exact inherited prefix length that completes + * the checkpoint identity. * @param keys - optional projection keys required by the caller's audience. * @returns the cut (`asOfSeq` = lowest served-row watermark), or * `undefined` when no usable row exists for this lifecycle. */ -cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined +cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys?: readonly Extract[], ): ProjectionSnapshot | undefined /** * Hydrate projection cells for an already-prepared Session without another @@ -140,11 +143,10 @@ cachedSnapshot( meta: SessionHeader, keys?: readonly Extract * The caller supplies the complete log in seq order: this service never * consults the persistence layer. * @param meta - the stored session header (identity witness). + * @param inheritedEventCount - exact inherited prefix length for projection initialization and identity. * @param events - the session's complete log, in seq order. * @returns the projection cut at the log end. */ -coldSnapshot(meta: SessionHeader, events: readonly SessionEvent[]): ProjectionSnapshot +coldSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, events: readonly SessionEvent[], ): ProjectionSnapshot ``` -Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md) +Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md) · [SessionLogOffset](session.md) Source: [`packages/session/session-projection-cache/src/index.ts`](../../packages/session/session-projection-cache/src/index.ts) @@ -272,7 +275,7 @@ checkpoint(session: Session): ProjectionCheckpoint * when no unit is registered (no read needed — {@link restore} would * serve empty values regardless). */ -restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined +restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined /** * View a checkpoint's rows without any log read: for every registered @@ -306,11 +309,12 @@ viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract= baseSeq`, in seq order. * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty). * @param header - immutable metadata for the Session being restored. + * @param inheritedEventCount - exact fork-inherited prefix length supplied to unit initialization. * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last * supplied event's seq, `baseSeq - 1` for an empty tail) plus the * refreshed checkpoint rows at that cut, ready for a durable write-back. */ -restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, header: SessionHeader, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } +restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, header: SessionHeader, inheritedEventCount: SessionLogOffset, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } /** * Restore an exact cut and install its states on the supplied prepared Session. @@ -322,10 +326,10 @@ restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], base * @param baseSeq - first supplied event sequence. * @returns all projection values at the supplied cut. */ -hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): ProjectionSnapshot +hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, ): ProjectionSnapshot ``` -Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md) +Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md) · [SessionLogOffset](session.md) Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 36653721a1..d46c4f1589 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -30,9 +30,10 @@ interface ProjectionDefinition< /** * State for the empty log and its immutable Session metadata. * @param header - immutable metadata for the Session being projected. + * @param inheritedEventCount - exact fork-inherited prefix length. * @returns the initial state. */ - init(header: SessionHeader): NoInfer + init(header: SessionHeader, inheritedEventCount: SessionLogOffset): NoInfer /** * Pure transition: previous state + one committed event → next state. A * unit uninterested in an event MUST return the same state reference — an @@ -78,7 +79,7 @@ interface ProjectionDefinition< */ interface ProjectionSnapshot { /** Seq of the last event the values reflect; -1 for an empty log. */ - asOfSeq: number + asOfSeq: SessionSeqCursor /** Whole current client value per registered key. */ values: Partial } @@ -94,7 +95,7 @@ type ProjectionChangeListener = ( session: Session, key: Extract, value: unknown, - seq: number, + seq: SessionSeq, ) => void ``` @@ -128,11 +129,13 @@ The persisted projection cache service. Opens the `session_projcache` domain at * paths (the history tail baseline) supersede these values whenever a * session is actually opened. * @param meta - the listed session's header (identity witness; no log read). + * @param inheritedEventCount - exact inherited prefix length that completes + * the checkpoint identity. * @param keys - optional projection keys required by the caller's audience. * @returns the cut (`asOfSeq` = lowest served-row watermark), or * `undefined` when no usable row exists for this lifecycle. */ -cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined +cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys?: readonly Extract[], ): ProjectionSnapshot | undefined /** * Hydrate projection cells for an already-prepared Session without another @@ -140,11 +143,10 @@ cachedSnapshot( meta: SessionHeader, keys?: readonly Extract * The caller supplies the complete log in seq order: this service never * consults the persistence layer. * @param meta - the stored session header (identity witness). + * @param inheritedEventCount - exact inherited prefix length for projection initialization and identity. * @param events - the session's complete log, in seq order. * @returns the projection cut at the log end. */ -coldSnapshot(meta: SessionHeader, events: readonly SessionEvent[]): ProjectionSnapshot +coldSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, events: readonly SessionEvent[], ): ProjectionSnapshot ``` -Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md) +Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md) · [SessionLogOffset](session.zh.md) Source: [`packages/session/session-projection-cache/src/index.ts`](../../packages/session/session-projection-cache/src/index.ts) @@ -272,7 +275,7 @@ checkpoint(session: Session): ProjectionCheckpoint * when no unit is registered (no read needed — {@link restore} would * serve empty values regardless). */ -restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined +restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined /** * View a checkpoint's rows without any log read: for every registered @@ -306,11 +309,12 @@ viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract= baseSeq`, in seq order. * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty). * @param header - immutable metadata for the Session being restored. + * @param inheritedEventCount - exact fork-inherited prefix length supplied to unit initialization. * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last * supplied event's seq, `baseSeq - 1` for an empty tail) plus the * refreshed checkpoint rows at that cut, ready for a durable write-back. */ -restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, header: SessionHeader, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } +restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, header: SessionHeader, inheritedEventCount: SessionLogOffset, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } /** * Restore an exact cut and install its states on the supplied prepared Session. @@ -322,10 +326,10 @@ restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], base * @param baseSeq - first supplied event sequence. * @returns all projection values at the supplied cut. */ -hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): ProjectionSnapshot +hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, ): ProjectionSnapshot ``` -Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md) +Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md) · [SessionLogOffset](session.zh.md) Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) diff --git a/docs/subsystems/session-query.i18n.yaml b/docs/subsystems/session-query.i18n.yaml index 1283d95440..6ade7bbe1b 100644 --- a/docs/subsystems/session-query.i18n.yaml +++ b/docs/subsystems/session-query.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-query.md -session-query.md: 74fb3e15514afd32c594b083732153a1f03b21d5 -session-query.zh.md: fee4c29f67bf850fa9ed07d869308844e84b2bb4 +session-query.md: 4317fa3d706dd40c596114a396ba746da67a979f +session-query.zh.md: 4b67706aea3d3f73304e4c8ad0c689a4c77a13a1 diff --git a/docs/subsystems/session-query.md b/docs/subsystems/session-query.md index 74fb3e1551..4317fa3d70 100644 --- a/docs/subsystems/session-query.md +++ b/docs/subsystems/session-query.md @@ -34,6 +34,8 @@ interface SessionRecord { interface SessionLogSnapshot { /** Cloned session header selected from the same observation as `events`. */ session: SessionHeader + /** Exact number of fork-inherited events in the observed log. */ + inheritedEventCount: SessionLogOffset /** Cloned contiguous raw events after persistence repair and replay validation. */ events: SessionEvent[] } @@ -44,8 +46,10 @@ interface SessionLogSnapshot { interface SessionSurfaceSnapshot { /** Cloned session header selected from the same corpus observation as `events`. */ session: SessionHeader + /** Exact number of fork-inherited events in the observed log. */ + inheritedEventCount: SessionLogOffset /** Highest raw-log seq included in the observation, or `null` for an empty log. */ - capturedThroughSeq: number | null + capturedThroughSeq: OptionalSessionSeq /** Cloned current surface events in model-history order. */ events: SurfaceEvent[] } @@ -90,7 +94,7 @@ interface SessionEventRecord { /** Session that owns the event. */ sessionId: SessionId /** Monotonic event seq within the session. */ - seq: number + seq: SessionSeq /** Discriminant of the session event. */ type: SessionEventType /** Event timestamp in Unix epoch milliseconds. */ @@ -266,7 +270,7 @@ interface SessionEventReadRequest { /** Session that owns the target event. */ sessionId: SessionId /** Target event seq. */ - seq: number + seq: SessionSeq /** Number of preceding raw events to include. */ before?: number /** Number of following raw events to include. */ @@ -279,14 +283,16 @@ interface SessionEventReadRequest { interface SessionEventWindow { /** Cloned header for the live-preferred source read. */ session: SessionHeader + /** Exact number of fork-inherited events in the observed log. */ + inheritedEventCount: SessionLogOffset /** Full cloned target event. */ target: SessionEvent /** Full cloned events from `startSeq` through `endSeq`. */ events: SessionEvent[] /** First seq included in `events`. */ - startSeq: number + startSeq: SessionSeq /** Last seq included in `events`. */ - endSeq: number + endSeq: SessionSeq } ``` @@ -300,7 +306,7 @@ interface SessionEventTraceRequest { /** Session that owns the target event. */ sessionId: SessionId /** Target event seq. */ - seq: number + seq: SessionSeq } ``` @@ -310,15 +316,15 @@ interface SessionEventTrace { /** Lightweight target record. */ target: SessionEventRecord /** Immediate positional replacement event, when the target was shadowed. */ - replacedBy?: number + replacedBy?: SessionSeq /** Positional replacers from the immediate replacement to the final replacement. */ - replacementChain: number[] + replacementChain: SessionSeq[] /** Surface nodes directly removed when the target itself performed a replacement. */ - replacedEventSeqs: number[] + replacedEventSeqs: SessionSeq[] /** Earlier events cited directly as sources, in their recorded order. */ - sourceEventSeqs: number[] + sourceEventSeqs: SessionSeq[] /** Later events that directly cite the target as a source, in log order. */ - derivedEventSeqs: number[] + derivedEventSeqs: SessionSeq[] } ``` diff --git a/docs/subsystems/session-query.zh.md b/docs/subsystems/session-query.zh.md index fee4c29f67..4b67706aea 100644 --- a/docs/subsystems/session-query.zh.md +++ b/docs/subsystems/session-query.zh.md @@ -34,6 +34,8 @@ interface SessionRecord { interface SessionLogSnapshot { /** Cloned session header selected from the same observation as `events`. */ session: SessionHeader + /** Exact number of fork-inherited events in the observed log. */ + inheritedEventCount: SessionLogOffset /** Cloned contiguous raw events after persistence repair and replay validation. */ events: SessionEvent[] } @@ -44,8 +46,10 @@ interface SessionLogSnapshot { interface SessionSurfaceSnapshot { /** Cloned session header selected from the same corpus observation as `events`. */ session: SessionHeader + /** Exact number of fork-inherited events in the observed log. */ + inheritedEventCount: SessionLogOffset /** Highest raw-log seq included in the observation, or `null` for an empty log. */ - capturedThroughSeq: number | null + capturedThroughSeq: OptionalSessionSeq /** Cloned current surface events in model-history order. */ events: SurfaceEvent[] } @@ -90,7 +94,7 @@ interface SessionEventRecord { /** Session that owns the event. */ sessionId: SessionId /** Monotonic event seq within the session. */ - seq: number + seq: SessionSeq /** Discriminant of the session event. */ type: SessionEventType /** Event timestamp in Unix epoch milliseconds. */ @@ -266,7 +270,7 @@ interface SessionEventReadRequest { /** Session that owns the target event. */ sessionId: SessionId /** Target event seq. */ - seq: number + seq: SessionSeq /** Number of preceding raw events to include. */ before?: number /** Number of following raw events to include. */ @@ -279,14 +283,16 @@ interface SessionEventReadRequest { interface SessionEventWindow { /** Cloned header for the live-preferred source read. */ session: SessionHeader + /** Exact number of fork-inherited events in the observed log. */ + inheritedEventCount: SessionLogOffset /** Full cloned target event. */ target: SessionEvent /** Full cloned events from `startSeq` through `endSeq`. */ events: SessionEvent[] /** First seq included in `events`. */ - startSeq: number + startSeq: SessionSeq /** Last seq included in `events`. */ - endSeq: number + endSeq: SessionSeq } ``` @@ -300,7 +306,7 @@ interface SessionEventTraceRequest { /** Session that owns the target event. */ sessionId: SessionId /** Target event seq. */ - seq: number + seq: SessionSeq } ``` @@ -310,15 +316,15 @@ interface SessionEventTrace { /** Lightweight target record. */ target: SessionEventRecord /** Immediate positional replacement event, when the target was shadowed. */ - replacedBy?: number + replacedBy?: SessionSeq /** Positional replacers from the immediate replacement to the final replacement. */ - replacementChain: number[] + replacementChain: SessionSeq[] /** Surface nodes directly removed when the target itself performed a replacement. */ - replacedEventSeqs: number[] + replacedEventSeqs: SessionSeq[] /** Earlier events cited directly as sources, in their recorded order. */ - sourceEventSeqs: number[] + sourceEventSeqs: SessionSeq[] /** Later events that directly cite the target as a source, in log order. */ - derivedEventSeqs: number[] + derivedEventSeqs: SessionSeq[] } ``` diff --git a/docs/subsystems/session-title.i18n.yaml b/docs/subsystems/session-title.i18n.yaml index 23a1ccdfa9..63292c20f0 100644 --- a/docs/subsystems/session-title.i18n.yaml +++ b/docs/subsystems/session-title.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-title.md -session-title.md: 05d20c2d7b278dd0663e50d86f1cd22b64852e81 -session-title.zh.md: 2eaa296871bd90e1708f2cf27298b7bbc1efa2c0 +session-title.md: 20ed32b4a08127e7193433673d22f0dea3a90c46 +session-title.zh.md: c01d5d4bf177169069bbe0a39ef533ada0f7c36a diff --git a/docs/subsystems/session-title.md b/docs/subsystems/session-title.md index 05d20c2d7b..20ed32b4a0 100644 --- a/docs/subsystems/session-title.md +++ b/docs/subsystems/session-title.md @@ -46,7 +46,7 @@ interface SessionTitleEventData { /** Normalized non-empty title text. */ readonly title: string /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */ - readonly messageSeqs: number[] + readonly messageSeqs: SessionSeq[] /** Whether the built-in fallback, a registered provider, or the user supplied the title. */ readonly source: SessionTitleSource } @@ -56,7 +56,7 @@ interface SessionTitleEventData { /** Latest folded title plus the title event's durable envelope facts. */ interface SessionTitleSnapshot extends SessionTitleEventData { /** Seq of the latest `session/title` event. */ - readonly eventSeq: number + readonly eventSeq: SessionSeq /** Timestamp of the latest `session/title` event. */ readonly updatedAt: number } @@ -72,7 +72,7 @@ interface SessionTitleLlmRequestEventData { /** Registered title-provider identity responsible for the request. */ readonly titleProvider: SessionTitleProviderId /** Exact human `user/message` seqs represented in `messages`. */ - readonly messageSeqs: number[] + readonly messageSeqs: SessionSeq[] /** Exact auxiliary LLM route. */ readonly route: SessionTitleModelProvenance /** Exact auxiliary system prompt. */ @@ -92,7 +92,7 @@ The service snapshots eligible messages through one revision. A provider returns /** One eligible human text message exposed to title providers. */ interface SessionTitleUserMessage { /** Source `user/message` event seq. */ - readonly seq: number + readonly seq: SessionSeq /** Exact concatenated text-block content. */ readonly text: string } @@ -123,7 +123,7 @@ interface SessionTitleProviderResult { /** Proposed title text. */ readonly title: string /** Exact seqs from `request.messages` used by this result. */ - readonly messageSeqs: readonly number[] + readonly messageSeqs: readonly SessionSeq[] /** Auxiliary LLM route, when generation used a model. */ readonly model?: SessionTitleModelProvenance } diff --git a/docs/subsystems/session-title.zh.md b/docs/subsystems/session-title.zh.md index 2eaa296871..c01d5d4bf1 100644 --- a/docs/subsystems/session-title.zh.md +++ b/docs/subsystems/session-title.zh.md @@ -46,7 +46,7 @@ interface SessionTitleEventData { /** Normalized non-empty title text. */ readonly title: string /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */ - readonly messageSeqs: number[] + readonly messageSeqs: SessionSeq[] /** Whether the built-in fallback, a registered provider, or the user supplied the title. */ readonly source: SessionTitleSource } @@ -56,7 +56,7 @@ interface SessionTitleEventData { /** Latest folded title plus the title event's durable envelope facts. */ interface SessionTitleSnapshot extends SessionTitleEventData { /** Seq of the latest `session/title` event. */ - readonly eventSeq: number + readonly eventSeq: SessionSeq /** Timestamp of the latest `session/title` event. */ readonly updatedAt: number } @@ -72,7 +72,7 @@ interface SessionTitleLlmRequestEventData { /** Registered title-provider identity responsible for the request. */ readonly titleProvider: SessionTitleProviderId /** Exact human `user/message` seqs represented in `messages`. */ - readonly messageSeqs: number[] + readonly messageSeqs: SessionSeq[] /** Exact auxiliary LLM route. */ readonly route: SessionTitleModelProvenance /** Exact auxiliary system prompt. */ @@ -92,7 +92,7 @@ interface SessionTitleLlmRequestEventData { /** One eligible human text message exposed to title providers. */ interface SessionTitleUserMessage { /** Source `user/message` event seq. */ - readonly seq: number + readonly seq: SessionSeq /** Exact concatenated text-block content. */ readonly text: string } @@ -123,7 +123,7 @@ interface SessionTitleProviderResult { /** Proposed title text. */ readonly title: string /** Exact seqs from `request.messages` used by this result. */ - readonly messageSeqs: readonly number[] + readonly messageSeqs: readonly SessionSeq[] /** Auxiliary LLM route, when generation used a model. */ readonly model?: SessionTitleModelProvenance } diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 6998ba0cbe..51e2da2af5 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: fbeb501b9ba6283a41c108fc07fc8787ed9e68a4 -session.zh.md: daf25d74bc5bd039a7d8b1a1882c2ec197136ce3 +session.md: 40012d1fc7b5be602dd3783f1df24f8f61f8a969 +session.zh.md: 9d4f6c3ce90913e5236b011e025d8299b3192a57 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index fbeb501b9b..40012d1fc7 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -179,6 +179,28 @@ interface RequestContext { A proper discriminated union over `type` (not independent `type`/`data` unions), so `switch (event.type)` narrows `event.data` without casts. `seq` is the monotonic position in the log (`seq = log.length`); `time` is epoch ms. +```ts type-equiv +/** Sequence number of one existing event in a Session log. */ +type SessionSeq = BrandedNumber<'SessionSeq'> +``` + +```ts type-equiv +/** A Session log gap, prefix length, or read offset, which may equal the event count. */ +type SessionLogOffset = BrandedNumber<'SessionLogOffset'> +``` + +```ts type-equiv +/** Inclusive Session event watermark, or `-1` before any event exists. */ +type SessionSeqCursor = SessionSeq | -1 +``` + +```ts type-equiv +/** One existing Session event position, or explicit absence. */ +type OptionalSessionSeq = SessionSeq | null +``` + +`SessionSeq(value)` and `SessionLogOffset(value)` admit only non-negative safe integers and reject negative zero. They add compile-time brands without changing the serialized number; arithmetic returns an ordinary `number` that callers must admit again through the constructor for its intended role. + ```ts type-equiv /** * One immutable entry in the session log. @@ -197,7 +219,7 @@ type SessionEvent = { [K in SessionEventType]: { type: K /** Monotonic sequence number within the session. */ - seq: number + seq: SessionSeq /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] @@ -221,7 +243,7 @@ type SessionEvent = { * provider stream; when the field is absent, the event does not record which * earlier events produced the message. */ - sourceEventSeqs?: number[] + sourceEventSeqs?: SessionSeq[] /** How this event entered the surface; absent for non-surface events. */ surfaceOp?: SurfaceOp } : object) @@ -268,7 +290,7 @@ type SurfaceEventType = */ type SurfaceOp = | 'append' - | { op: 'replace'; start: number; end: number } + | { op: 'replace'; start: SessionSeq; end: SessionSeq } ``` `'append'` is the normal tail-append path. `replace` shadows surface entries from `start` through `end` inclusive (both must be valid surface seqs; `start === end` replaces a single entry) and inserts the new event in their place. @@ -288,7 +310,7 @@ interface SurfaceIntent { * absent, the event does not record which earlier events produced the message. * Other surface events require a non-empty set when this field is present. */ - sourceEventSeqs?: number[] + sourceEventSeqs?: SessionSeq[] } ``` @@ -306,7 +328,7 @@ Only `assistant/message` may carry a present empty `sourceEventSeqs`; when the f /** Readonly live projection of the message-producing session events. */ interface SessionSurface { /** Current surface event sequences in model-visible order. */ - readonly nodes: readonly number[] + readonly nodes: readonly SessionSeq[] /** Monotonic count of committed positional replacements. */ readonly replaceGeneration: number } @@ -320,13 +342,13 @@ interface SessionSurface { /** One replacement operation observed while folding a session surface. */ interface SurfaceFoldReplacement { /** Seq of the event that replaced the prior surface range. */ - seq: number + seq: SessionSeq /** Declared inclusive start seq of the replaced surface range. */ - start: number + start: SessionSeq /** Declared inclusive end seq of the replaced surface range. */ - end: number + end: SessionSeq /** Actual surface entries removed by the operation, in surface order. */ - shadowedSeqs: number[] + shadowedSeqs: SessionSeq[] } ``` @@ -334,7 +356,7 @@ interface SurfaceFoldReplacement { /** Complete result of replaying the surface operations in a session log. */ interface SurfaceFoldResult { /** Current surface event sequences in model-visible order. */ - nodes: number[] + nodes: SessionSeq[] /** Replacement operations in event order. */ replacements: SurfaceFoldReplacement[] } @@ -358,13 +380,15 @@ declare class Session { get surface(): SessionSurface; /** * Detached, deep-frozen creation metadata (format version, cwd, lineage, - * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a + * and whether fork history exists). Supplied by the store via `ctx.sessions.create()`. When a * `Session` is created without a store-owned header, a minimal header is * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so * `session.header` is always present. Kept out of the event log — it is a * storage concern, not replayable conversation state. */ readonly header: SessionHeader; + /** Number of leading events inherited from this Session's fork parent. */ + readonly inheritedEventCount: SessionLogOffset; /** The session identity, derived from its durable header's single copy. */ get id(): SessionId; /** @@ -373,9 +397,9 @@ declare class Session { * construction — replay, fork, or resume — and were never published on the * `session/event` firehose (constructor seeds do not emit), so consumers * that replay the log as a publication substitute (telemetry adoption) - * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage - * boundary: a resumed session's constructor seed is its full stored log, - * while its header keeps the original fork value — this field is the + * start here. Distinct from {@link inheritedEventCount}, the DURABLE + * fork-lineage cut: a resumed session's constructor seed is its full stored + * log, while the inherited count keeps the original fork value — this field is the * in-process construction fact. * * Not persisted itself: a seeded session projects it into the log as the @@ -389,16 +413,22 @@ declare class Session { * store attaches and therefore does not publish either. Otherwise this seq * holds an ordinary published write. */ - readonly firstLiveSeq: number; + readonly firstLiveSeq: SessionLogOffset; /** * Create a detached session by validating and snapshotting borrowed seed * events and storage metadata. * @param id - session identity. * @param seed - optional borrowed replay or fork events. * @param header - optional borrowed storage metadata. + * @param inheritedEventCount - exact fork-inherited prefix length for a seeded header. * @returns a detached session. */ - static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session; + static create( + id: SessionId, + seed?: readonly SessionEvent[], + header?: SessionHeader, + inheritedEventCount?: SessionLogOffset, + ): Session; /** * Restore a detached session by taking ownership of fresh persistence values. * The storage format, event envelopes, sequence continuity, surface transitions, @@ -406,15 +436,21 @@ declare class Session { * @param id - restored session identity. * @param seed - fresh detached events whose ownership is transferred. * @param header - fresh detached metadata whose ownership is transferred. + * @param inheritedEventCount - exact fork-inherited prefix length decoded from storage. * @returns a restored detached session. */ - static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session; + static fromRestore( + id: SessionId, + seed: readonly SessionEvent[], + header: SessionHeader, + inheritedEventCount: SessionLogOffset, + ): Session; /** * 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. */ - eventAt(seq: number): SessionEvent | undefined; + eventAt(seq: SessionSeq): 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 @@ -423,9 +459,23 @@ declare class Session { * @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[]; + snapshotEvents( + fromSeq: SessionLogOffset = SessionLogOffset(0), + toSeqExclusive: SessionLogOffset = this.seq, + ): readonly SessionEvent[]; + /** + * Return this Session's events after its fork-inherited prefix. + * @returns a fresh array containing child-owned events in log order. + */ + ownEvents(): readonly SessionEvent[]; + /** + * Whether one existing event position is outside the fork-inherited prefix. + * @param seq - event position in this Session. + * @returns true when the event belongs to this Session rather than its parent. + */ + isOwnSeq(seq: SessionSeq): boolean; /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ - get seq(): number; + get seq(): SessionLogOffset; /** * Append one typed event to the log and synchronously notify observers via * the store-owned, module-private publication hooks. The hot path never blocks @@ -525,7 +575,7 @@ Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and `ctx.sessions.create(id, { seed, meta })` is the low-level replay/fork primitive. For ordinary live-session forks, `SessionStore` exposes one policy API: -- `fork(source, boundary?, childSessionId?)` accepts a live `Session` object or live `SessionId`, selects source events through the inclusive `boundary` seq (default: current last event), requires the selected prefix to end outside an open turn, then creates a live child session with deep-cloned seed events plus child metadata (`parentSession`, `seedLength`, and inherited `cwd`). +- `fork(source, boundary?, childSessionId?)` accepts a live `Session` object or live `SessionId`, selects source events through the inclusive `SessionSeq` boundary (default: current last event), requires the selected prefix to end outside an open turn, then creates a live child session with deep-cloned seed events, `parentSession`, `isSeeded: true`, the exact `inheritedEventCount`, and inherited `cwd`. An explicit `boundary` lets callers fork from any stable between-turn position, including a previous `turn/end` or a later standalone log-only event, even if the source has newer events or an open current turn. The API rejects a prefix that ends inside an open turn instead of clipping silently. Broader execution-relation sanity stays in the existing `dsh-invariants` plugin and persistence repair path rather than being duplicated in `fork()`. `dsh-subagent-fork-in-process` keeps its completed-prefix clipping because tool-time delegation usually starts while the parent turn is open; ordinary session branching should make the requested boundary explicit. @@ -574,7 +624,7 @@ The optional `dsh-session/invariant` companion enforces the relations owned by c ## The end-seed boundary: `session/end-seed` -A seeded session — resume, fork, or replay — appends this log-only event immediately after its constructor seed, as its first live write. Events before it have smaller seq values and came from the seed. It is the durable projection of `firstLiveSeq`: that field answers where this lifecycle's writes start for a consumer holding the object, while the event answers the same question for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. `Session`'s constructor is the only legitimate writer. +A Session constructed with an explicit seed — restore, fork, or replay — appends this log-only event immediately after that constructor seed, as its first live write. Events before it have smaller seq values and came through construction. It is the durable projection of `firstLiveSeq`: that field answers where this lifecycle's writes start for a consumer holding the object, while the event answers the same question for one holding only stored bytes. It does not define fork ownership; `isSeeded` plus `inheritedEventCount` do. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. `Session`'s constructor is the only legitimate writer. An explicitly supplied empty seed writes `session/end-seed` at seq 0, which distinguishes an empty resumed session from a fresh one. A seed already ending in `session/end-seed` is not re-marked, so reopening an untouched session does not grow its log per pickup. Locate the LAST `session/end-seed` in stored history rather than assuming one exists at `firstLiveSeq`: after a pickup with no work, the event has a smaller seq than the next lifecycle's `firstLiveSeq`. @@ -630,7 +680,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: readonly SessionEvent[] }> +inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** * Read all visible Session rows without resuming an Agent. @@ -750,7 +800,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH @Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable ``` -Types: [SessionHeader](persistence.md) · [SessionId](core.md) · [SessionSearchRequest](session-query.md) +Types: [SessionId](core.md) · [SessionInspection](persistence.md) · [SessionSearchRequest](session-query.md) Source: [`packages/api/session-controller/src/index.ts`](../../packages/api/session-controller/src/index.ts) @@ -883,7 +933,7 @@ list(): Session[] * `SessionStore`'s id policy. * @returns The created live child session. */ -fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session +fork(source: SessionForkSource, boundary?: SessionSeq, childSessionId?: SessionId): Session ``` Types: [CreateSessionOptions](persistence.md) · [PrepareSessionOptions](persistence.md) · [SessionId](core.md) diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index daf25d74bc..9d4f6c3ce9 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -179,6 +179,28 @@ interface RequestContext { 基于 `type` 的真正可辨识联合(而非独立的 `type`/`data` 联合),因此 `switch (event.type)` 能直接收窄 `event.data`,无需类型断言。`seq` 是日志中的单调递增位置(`seq = log.length`);`time` 为 epoch 毫秒。 +```ts type-equiv +/** Sequence number of one existing event in a Session log. */ +type SessionSeq = BrandedNumber<'SessionSeq'> +``` + +```ts type-equiv +/** A Session log gap, prefix length, or read offset, which may equal the event count. */ +type SessionLogOffset = BrandedNumber<'SessionLogOffset'> +``` + +```ts type-equiv +/** Inclusive Session event watermark, or `-1` before any event exists. */ +type SessionSeqCursor = SessionSeq | -1 +``` + +```ts type-equiv +/** One existing Session event position, or explicit absence. */ +type OptionalSessionSeq = SessionSeq | null +``` + +`SessionSeq(value)` 与 `SessionLogOffset(value)` 只接纳非负安全整数,并拒绝负零。它们仅添加编译期品牌,不改变序列化后的数值;算术会返回普通 `number`,调用方必须按结果的预期角色通过对应构造器重新接纳。 + ```ts type-equiv /** * One immutable entry in the session log. @@ -197,7 +219,7 @@ type SessionEvent = { [K in SessionEventType]: { type: K /** Monotonic sequence number within the session. */ - seq: number + seq: SessionSeq /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] @@ -221,7 +243,7 @@ type SessionEvent = { * provider stream; when the field is absent, the event does not record which * earlier events produced the message. */ - sourceEventSeqs?: number[] + sourceEventSeqs?: SessionSeq[] /** How this event entered the surface; absent for non-surface events. */ surfaceOp?: SurfaceOp } : object) @@ -270,7 +292,7 @@ type SurfaceEventType = */ type SurfaceOp = | 'append' - | { op: 'replace'; start: number; end: number } + | { op: 'replace'; start: SessionSeq; end: SessionSeq } ``` `'append'` 是常规的尾部追加路径。`replace` 会遮蔽从 `start` 到 `end`(含两端)的 surface 条目(两者都必须是有效的 surface seq;`start === end` 时仅替换单个条目),并在原位置插入新事件。 @@ -290,7 +312,7 @@ interface SurfaceIntent { * absent, the event does not record which earlier events produced the message. * Other surface events require a non-empty set when this field is present. */ - sourceEventSeqs?: number[] + sourceEventSeqs?: SessionSeq[] } ``` @@ -308,7 +330,7 @@ interface SurfaceIntent { /** Readonly live projection of the message-producing session events. */ interface SessionSurface { /** Current surface event sequences in model-visible order. */ - readonly nodes: readonly number[] + readonly nodes: readonly SessionSeq[] /** Monotonic count of committed positional replacements. */ readonly replaceGeneration: number } @@ -322,13 +344,13 @@ interface SessionSurface { /** One replacement operation observed while folding a session surface. */ interface SurfaceFoldReplacement { /** Seq of the event that replaced the prior surface range. */ - seq: number + seq: SessionSeq /** Declared inclusive start seq of the replaced surface range. */ - start: number + start: SessionSeq /** Declared inclusive end seq of the replaced surface range. */ - end: number + end: SessionSeq /** Actual surface entries removed by the operation, in surface order. */ - shadowedSeqs: number[] + shadowedSeqs: SessionSeq[] } ``` @@ -336,7 +358,7 @@ interface SurfaceFoldReplacement { /** Complete result of replaying the surface operations in a session log. */ interface SurfaceFoldResult { /** Current surface event sequences in model-visible order. */ - nodes: number[] + nodes: SessionSeq[] /** Replacement operations in event order. */ replacements: SurfaceFoldReplacement[] } @@ -360,13 +382,15 @@ declare class Session { get surface(): SessionSurface; /** * Detached, deep-frozen creation metadata (format version, cwd, lineage, - * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a + * and whether fork history exists). Supplied by the store via `ctx.sessions.create()`. When a * `Session` is created without a store-owned header, a minimal header is * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so * `session.header` is always present. Kept out of the event log — it is a * storage concern, not replayable conversation state. */ readonly header: SessionHeader; + /** Number of leading events inherited from this Session's fork parent. */ + readonly inheritedEventCount: SessionLogOffset; /** The session identity, derived from its durable header's single copy. */ get id(): SessionId; /** @@ -375,9 +399,9 @@ declare class Session { * construction — replay, fork, or resume — and were never published on the * `session/event` firehose (constructor seeds do not emit), so consumers * that replay the log as a publication substitute (telemetry adoption) - * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage - * boundary: a resumed session's constructor seed is its full stored log, - * while its header keeps the original fork value — this field is the + * start here. Distinct from {@link inheritedEventCount}, the DURABLE + * fork-lineage cut: a resumed session's constructor seed is its full stored + * log, while the inherited count keeps the original fork value — this field is the * in-process construction fact. * * Not persisted itself: a seeded session projects it into the log as the @@ -391,16 +415,22 @@ declare class Session { * store attaches and therefore does not publish either. Otherwise this seq * holds an ordinary published write. */ - readonly firstLiveSeq: number; + readonly firstLiveSeq: SessionLogOffset; /** * Create a detached session by validating and snapshotting borrowed seed * events and storage metadata. * @param id - session identity. * @param seed - optional borrowed replay or fork events. * @param header - optional borrowed storage metadata. + * @param inheritedEventCount - exact fork-inherited prefix length for a seeded header. * @returns a detached session. */ - static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session; + static create( + id: SessionId, + seed?: readonly SessionEvent[], + header?: SessionHeader, + inheritedEventCount?: SessionLogOffset, + ): Session; /** * Restore a detached session by taking ownership of fresh persistence values. * The storage format, event envelopes, sequence continuity, surface transitions, @@ -408,15 +438,21 @@ declare class Session { * @param id - restored session identity. * @param seed - fresh detached events whose ownership is transferred. * @param header - fresh detached metadata whose ownership is transferred. + * @param inheritedEventCount - exact fork-inherited prefix length decoded from storage. * @returns a restored detached session. */ - static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session; + static fromRestore( + id: SessionId, + seed: readonly SessionEvent[], + header: SessionHeader, + inheritedEventCount: SessionLogOffset, + ): Session; /** * 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. */ - eventAt(seq: number): SessionEvent | undefined; + eventAt(seq: SessionSeq): 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 @@ -425,9 +461,23 @@ declare class Session { * @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[]; + snapshotEvents( + fromSeq: SessionLogOffset = SessionLogOffset(0), + toSeqExclusive: SessionLogOffset = this.seq, + ): readonly SessionEvent[]; + /** + * Return this Session's events after its fork-inherited prefix. + * @returns a fresh array containing child-owned events in log order. + */ + ownEvents(): readonly SessionEvent[]; + /** + * Whether one existing event position is outside the fork-inherited prefix. + * @param seq - event position in this Session. + * @returns true when the event belongs to this Session rather than its parent. + */ + isOwnSeq(seq: SessionSeq): boolean; /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ - get seq(): number; + get seq(): SessionLogOffset; /** * Append one typed event to the log and synchronously notify observers via * the store-owned, module-private publication hooks. The hot path never blocks @@ -527,7 +577,7 @@ declare class Session { `ctx.sessions.create(id, { seed, meta })` 是底层的回放/fork 原语。对于普通的活跃会话 fork,`SessionStore` 暴露一个策略 API: -- `fork(source, boundary?, childSessionId?)` 接受一个活跃的 `Session` 对象或活跃的 `SessionId`,选取到 `boundary` seq(含)为止的源事件(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,然后创建一个活跃的子会话,包含深克隆的种子事件和子会话元数据(`parentSession`、`seedLength` 及继承的 `cwd`)。 +- `fork(source, boundary?, childSessionId?)` 接受一个活跃的 `Session` 对象或活跃的 `SessionId`,选取到 `SessionSeq` boundary(含)为止的源事件(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,然后创建一个活跃的子会话,包含深克隆的 seed event、`parentSession`、`isSeeded: true`、精确 `inheritedEventCount` 及继承的 `cwd`。 显式 `boundary` 允许调用者从任意稳定的轮次间位置 fork,包括之前的 `turn/end` 或更晚的独立纯日志事件,即使源会话有更新的事件或正在进行的轮次。API 拒绝结束于开放轮次内的前缀,而不是静默截断。更广泛的执行关系健全性检查留在既有的 `dsh-invariants` 插件和持久化修复路径中,不在 `fork()` 中重复。`dsh-subagent-fork-in-process` 保留其已完成前缀截断逻辑,因为工具调用时的委托通常在父轮次仍然打开时启动;普通的会话分支应显式指定请求的 boundary。 @@ -578,7 +628,7 @@ interface TurnEndReasonMap { ## 种子结束边界:`session/end-seed` -带种子的会话(恢复、fork 或回放)紧接构造种子之后追加这个仅日志事件,作为自己的第一次实时写入。在它之前的事件具有更小的 seq,且来自种子。它是 `firstLiveSeq` 的持久投影:该字段为持有对象的消费方回答本生命周期的写入从哪里开始,该事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。`Session` 的构造函数是唯一合法的写入方。 +用显式 seed 构造的 Session(restore、fork 或 replay)会紧接该 constructor seed 之后追加这个仅日志事件,作为自己的第一次实时写入。在它之前的事件具有更小的 seq,且经由构造进入。它是 `firstLiveSeq` 的持久投影:该字段为持有对象的 consumer 回答本 lifecycle 的写入从哪里开始,该事件则为只持有存储字节的 consumer 回答同一问题。它不定义 fork ownership;`isSeeded` 与 `inheritedEventCount` 才定义。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。`Session` 的构造函数是唯一合法的写入方。 显式传入的空种子会在 seq 0 写入 `session/end-seed`,从而把从空日志恢复的会话与全新会话区分开来。种子本身已以 `session/end-seed` 结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次拾起都增长日志。应定位存储历史中的最后一条 `session/end-seed`,而不是假定 `firstLiveSeq` 处一定有一条:在一次没有产生工作的拾起之后,该事件的 seq 会小于下一个生命周期的 `firstLiveSeq`。 @@ -634,7 +684,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: readonly SessionEvent[] }> +inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** * Read all visible Session rows without resuming an Agent. @@ -754,7 +804,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH @Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable ``` -Types: [SessionHeader](persistence.zh.md) · [SessionId](core.zh.md) · [SessionSearchRequest](session-query.zh.md) +Types: [SessionId](core.zh.md) · [SessionInspection](persistence.zh.md) · [SessionSearchRequest](session-query.zh.md) Source: [`packages/api/session-controller/src/index.ts`](../../packages/api/session-controller/src/index.ts) @@ -887,7 +937,7 @@ list(): Session[] * `SessionStore`'s id policy. * @returns The created live child session. */ -fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session +fork(source: SessionForkSource, boundary?: SessionSeq, childSessionId?: SessionId): Session ``` Types: [CreateSessionOptions](persistence.zh.md) · [PrepareSessionOptions](persistence.zh.md) · [SessionId](core.zh.md) diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index ef1494e405..e3b15dc2d8 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.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/subagent.md -subagent.md: 109f8569879c0578f804569d1105c03250ad3bf5 -subagent.zh.md: 649a3796f9f199306e45ea9653bb38df8683a518 +subagent.md: 5369ed85b5382dcb07df3e458a99124107ad5e51 +subagent.zh.md: 7495890531c6faa604a96edf443543840f69ae2c diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 109f856987..5369ed85b5 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -258,7 +258,7 @@ interface ContinuableCreateSpec { The descriptor (`SubagentDescriptorData` in [descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts)) is a mode-discriminated durable identity for every session-backed subagent. Both modes carry the provider name. A `one-shot` descriptor optionally carries a caller-owned display `label`; a `continuable` descriptor requires the delegation `description` as its durable creation label and additionally snapshots resolved child `agentOptions.provider`/`model`/`reasoningEffort` and optional `persona`/`toolFilter` for cold resume. It never snapshots the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation and a later composition input is a deliberate version change. It omits `subagentDepth` (cold resume trusts the persisted header's `delegationDepth` as the monotone floor) and `outputSchema` (one run or Activation's result contract, not durable identity). -A local one-shot provider appends the descriptor inside the child's initial turn before its first request. The continuation manager appends the descriptor after any provider-supplied lineage and before the initial prompt is admitted; `header.seedLength` remains the fork-lineage boundary: resume-time descriptor authority reads the child's own suffix, while the list-serving identity projection folds `subagent/descriptor` last-wins so the child's own descriptor overrides a fork-seeded ancestor's. The event is log-only: no `surfaceOp`, never in model history, and retained across compaction by the append-only log. Malformed current-version descriptors are corrupt; unsupported versions cannot be classified by this runtime. +A local one-shot provider appends the descriptor inside the child's initial turn before its first request. The continuation manager appends the descriptor after any provider-supplied lineage and before the initial prompt is admitted; `Session.inheritedEventCount` remains the fork-lineage boundary: resume-time descriptor authority reads the child's own suffix, while the list-serving identity projection folds `subagent/descriptor` last-wins so the child's own descriptor overrides a fork-seeded ancestor's. A seeded cold list skips a cache hint until an authoritative observation supplies that exact cut. The event is log-only: no `surfaceOp`, never in model history, and retained across compaction by the append-only log. Malformed current-version descriptors are corrupt; unsupported versions cannot be classified by this runtime. ## Durable enumeration: `listChildren()`, `listDescendants()`, and their entries diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 649a3796f9..7495890531 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -258,7 +258,7 @@ interface ContinuableCreateSpec { 描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)是每个由会话支撑的 subagent 所使用、按模式判别的持久化身份。两种模式都携带提供方名称。`one-shot` 描述符可以携带调用方拥有的可选显示 `label`;`continuable` 描述符要求以委派 `description` 作为持久化创建标签,并另外对已解析的子 agent `agentOptions.provider`/`model`/`reasoningEffort` 与可选的 `persona`/`toolFilter` 建立快照,用于冷恢复。它绝不会对可合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则是一次有意的版本更改。描述符省略 `subagentDepth`(冷恢复以持久化 header 中的 `delegationDepth` 作为单调下界)和 `outputSchema`(单次运行或 Activation 的结果约定,而非持久化身份)。 -本地一次性提供方会在子 agent 的初始轮次内、首次请求前追加描述符。继续执行管理器会在任何提供方提供的谱系之后、初始提示词获准之前追加描述符;`header.seedLength` 仍是 fork 谱系边界:恢复时的描述符权威读取子 agent 自身的后缀,而供列表使用的身份投影以 last-wins 折叠 `subagent/descriptor`,子 agent 自己的描述符会覆盖 fork seed 中祖先的描述符。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本描述符属于损坏;本运行时无法对不受支持的版本进行分类。 +本地一次性提供方会在子 agent 的初始轮次内、首次请求前追加描述符。继续执行管理器会在任何提供方提供的谱系之后、初始提示词获准之前追加描述符;`Session.inheritedEventCount` 仍是 fork 谱系边界:恢复时的描述符权威读取子 agent 自身的后缀,而供列表使用的身份投影以 last-wins 折叠 `subagent/descriptor`,子 agent 自己的描述符会覆盖 fork seed 中祖先的描述符。seeded cold list 会跳过 cache hint,直到权威 observation 提供该精确 cut。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本描述符属于损坏;本运行时无法对不受支持的版本进行分类。 ## 持久化枚举:`listChildren()`、`listDescendants()` 与其条目 diff --git a/docs/subsystems/token-meter.i18n.yaml b/docs/subsystems/token-meter.i18n.yaml index 4028e567f5..14e3c7a5a4 100644 --- a/docs/subsystems/token-meter.i18n.yaml +++ b/docs/subsystems/token-meter.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/token-meter.md -token-meter.md: 9c4a1e4b95ffd84f65f7a73e208be245378a3301 -token-meter.zh.md: d9e2e7f773041ccb6d1e4c3cc4d81a342db0cc01 +token-meter.md: 2265f5073efbd2953e4f399ad58a026772b60006 +token-meter.zh.md: b1d9d66dc804fecace15489e61c1d080c096932d diff --git a/docs/subsystems/token-meter.md b/docs/subsystems/token-meter.md index 9c4a1e4b95..2265f5073e 100644 --- a/docs/subsystems/token-meter.md +++ b/docs/subsystems/token-meter.md @@ -12,7 +12,7 @@ Source: [`packages/llm/token-meter/src/types.ts`](../../packages/llm/token-meter /** Detached immutable request-pressure and surface snapshot at one consumed log revision. */ interface TokenMeasurement { /** Number of durable events consumed; equal to the next unread event seq. */ - readonly logRevision: number + readonly logRevision: SessionLogOffset /** Provider or heuristic anchor used for this measurement. */ readonly baseline: TokenMeasurementBaseline /** Signed repricing of current surface content relative to the baseline anchor. */ @@ -34,7 +34,7 @@ Every measurement resolves the effective envelope's routed provider/model to tha /** One token-priced node in the current ordered session surface. */ interface TokenSurfaceNode { /** Durable sequence number of the surface event. */ - readonly seq: number + readonly seq: SessionSeq /** * Request-pressure tokens for the exact message projected by this node under * the measured route: image occurrences carry the route's declared visual diff --git a/docs/subsystems/token-meter.zh.md b/docs/subsystems/token-meter.zh.md index d9e2e7f773..b1d9d66dc8 100644 --- a/docs/subsystems/token-meter.zh.md +++ b/docs/subsystems/token-meter.zh.md @@ -12,7 +12,7 @@ /** Detached immutable request-pressure and surface snapshot at one consumed log revision. */ interface TokenMeasurement { /** Number of durable events consumed; equal to the next unread event seq. */ - readonly logRevision: number + readonly logRevision: SessionLogOffset /** Provider or heuristic anchor used for this measurement. */ readonly baseline: TokenMeasurementBaseline /** Signed repricing of current surface content relative to the baseline anchor. */ @@ -34,7 +34,7 @@ interface TokenMeasurement { /** One token-priced node in the current ordered session surface. */ interface TokenSurfaceNode { /** Durable sequence number of the surface event. */ - readonly seq: number + readonly seq: SessionSeq /** * Request-pressure tokens for the exact message projected by this node under * the measured route: image occurrences carry the route's declared visual diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts index 55e56a25b0..5dec9b3786 100644 --- a/packages/acp/acp/tests/bridge.spec.ts +++ b/packages/acp/acp/tests/bridge.spec.ts @@ -254,6 +254,7 @@ describe('automation-only ACP bridge', () => { id: sessionId, createdAt: 1, cwd: process.cwd(), + isSeeded: false, }]) const resume = vi.spyOn(harness.ctx.agents, 'resume') @@ -363,14 +364,14 @@ describe('automation-only ACP bridge', () => { const active = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const persistence = harness.ctx.get('sessionPersistence')! vi.spyOn(persistence, 'list').mockResolvedValue([ - { version: 0, id: SessionId(active.sessionId), createdAt: 9, cwd: process.cwd() }, - { version: 0, id: SessionId('subagent'), createdAt: 8, cwd: '/missing/filter', origin: 'subagent' }, - { version: 0, id: SessionId('fork'), createdAt: 7, cwd: '/missing/filter', parentSession: SessionId('parent') }, - { version: 0, id: SessionId('no-cwd'), createdAt: 6 }, - { version: 0, id: SessionId('relative'), createdAt: 5, cwd: 'relative' }, - { version: 0, id: SessionId('other'), createdAt: 4, cwd: '/missing/other' }, - { version: 0, id: SessionId('valid-b'), createdAt: 3, cwd: '/missing/filter' }, - { version: 0, id: SessionId('valid-a'), createdAt: 3, cwd: '/missing/filter' }, + { version: 0, id: SessionId(active.sessionId), createdAt: 9, cwd: process.cwd(), isSeeded: false }, + { version: 0, id: SessionId('subagent'), createdAt: 8, cwd: '/missing/filter', isSeeded: false, origin: 'subagent' }, + { version: 0, id: SessionId('fork'), createdAt: 7, cwd: '/missing/filter', isSeeded: true, parentSession: SessionId('parent') }, + { version: 0, id: SessionId('no-cwd'), createdAt: 6, isSeeded: false }, + { version: 0, id: SessionId('relative'), createdAt: 5, cwd: 'relative', isSeeded: false }, + { version: 0, id: SessionId('other'), createdAt: 4, cwd: '/missing/other', isSeeded: false }, + { version: 0, id: SessionId('valid-b'), createdAt: 3, cwd: '/missing/filter', isSeeded: false }, + { version: 0, id: SessionId('valid-a'), createdAt: 3, cwd: '/missing/filter', isSeeded: false }, ]) await expect(harness.client.listSessions({ cwd: 'relative' })).rejects.toThrow(/absolute path/) diff --git a/packages/acp/acp/tests/updates.spec.ts b/packages/acp/acp/tests/updates.spec.ts index 832403c6db..fe070086c1 100644 --- a/packages/acp/acp/tests/updates.spec.ts +++ b/packages/acp/acp/tests/updates.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { Context } from '@deepseek-ai/cordis' import { ToolCallId, MessageId } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionSeq, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import { assistantUpdates, toolCallUpdate, toolResultUpdate } from '../src/updates.ts' /** Minimal committed assistant event for pure update projection tests. */ @@ -11,7 +11,7 @@ function assistantEvent( ): SessionEvent<'assistant/message'> { return { type: 'assistant/message', - seq: 0, + seq: SessionSeq(0), time: 0, data: { turn: 1, @@ -57,13 +57,13 @@ describe('standard ACP update projection', () => { it('preserves malformed tool input and projects a failed result without hidden content', async () => { const call = toolCallUpdate({ type: 'tool/call', - seq: 0, + seq: SessionSeq(0), time: 0, data: { turn: 1, step: 1, callId: ToolCallId('call-bad'), name: 'broken', arguments: '{' }, }) const result = await toolResultUpdate({ get: () => undefined } as unknown as Context, { type: 'tool/result', - seq: 0, + seq: SessionSeq(0), time: 0, data: { turn: 1, diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index c17929af5c..b1f1872662 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -9,7 +9,8 @@ import type { import type {} from '@deepseek-ai/dsh-agent-default-model' import type {} from '@deepseek-ai/dsh-agent-presets' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-typert-registry' @@ -112,7 +113,7 @@ export async function inspectApiSession( ctx: Context, sessionId: SessionId, signal?: AbortSignal, -): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { +): Promise { try { using observation = await ctx.sessionQuery.observeSession(sessionId, { ...(signal === undefined ? {} : { signal }), @@ -121,7 +122,11 @@ export async function inspectApiSession( if (observation.header.cwd === undefined) { throw new ApiSessionNotFound(`session "${sessionId}" not found`) } - return { meta: observation.header, events: [...observation.events] } + return { + meta: observation.header, + inheritedEventCount: observation.inheritedEventCount, + events: [...observation.events], + } } catch (error: unknown) { if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 71cc72e0ab..43c0f2dfd6 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -9,7 +9,7 @@ */ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' -import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts' @@ -113,7 +113,7 @@ export interface ISession { * @param title - raw title text (the host normalizes acceptance). * @returns the normalized accepted title and its event seq, or the business error. */ - rename(title: string): Promise> + rename(title: string): Promise> /** * Extend the history window backwards (older messages pagination). * @returns completion; failures land in snapshot.openState/loadingOlder. @@ -127,7 +127,7 @@ export interface ISession { * @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 + loadThrough(seq: SessionSeq): 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/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 47392c86f3..fd39fefedf 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -3,7 +3,7 @@ // List data never enters zustand; React connects via subscribe/getListSnapshot. import type { SubagentAddress, SubagentCatalog } from '@deepseek-ai/dsh-subagent/client' -import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { SessionSeq, type SessionId, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { SessionControlBaseline, @@ -26,6 +26,10 @@ import { ProjectionValueStore } from './projection-store.ts' import { Session } from './session.ts' import type { SessionRemotes } from './remotes.ts' +function sessionSeqCursor(value: number): SessionSeqCursor { + return value === -1 ? -1 : SessionSeq(value) +} + /** * List arrival lifecycle, orthogonal to the pull-activity `state` axis: * `pending` (no successful pull yet — an empty items array means "nothing @@ -498,7 +502,7 @@ export class SessionManager { if (block === undefined) continue const store = this.projectionStore(s.sessionId) const values = block.values as Record - for (const key of Object.keys(values)) store.apply(key, values[key], block.asOfSeq) + for (const key of Object.keys(values)) store.apply(key, values[key], sessionSeqCursor(block.asOfSeq)) } } else { this.listState = 'error' @@ -590,7 +594,7 @@ export class SessionManager { * @returns the fork result (the child session id). */ async fork( - opts: { sessionId: SessionId; atSeq?: number }, + opts: { sessionId: SessionId; atSeq?: SessionSeq }, ): Promise> { const source = this.summaries.find(s => s.sessionId === opts.sessionId) const result = await this.remote.session.fork({ @@ -661,7 +665,7 @@ export class SessionManager { return } if (frame.type === 'projection') { - this.projectionStore(frame.sessionId).apply(frame.key, frame.value, frame.seq) + this.projectionStore(frame.sessionId).apply(frame.key, frame.value, SessionSeq(frame.seq)) this.notifier.markDirty() return } @@ -688,8 +692,9 @@ export class SessionManager { for (const [sessionId, block] of Object.entries(baseline.projections)) { const store = this.projectionStore(sessionId as SessionId) - store.truncate(block.asOfSeq) - store.seed(block) + const asOfSeq = sessionSeqCursor(block.asOfSeq) + store.truncate(asOfSeq) + store.seed({ ...block, asOfSeq }) } for (const [sessionId, session] of this.sessions) { session.replaceControl(this.queues.get(sessionId) ?? []) @@ -708,7 +713,7 @@ export class SessionManager { if (projections !== undefined) { const store = this.projectionStore(summary.sessionId) for (const [key, value] of Object.entries(projections.values)) { - store.apply(key, value, projections.asOfSeq) + store.apply(key, value, sessionSeqCursor(projections.asOfSeq)) } } if (summary.origin === 'subagent' && summary.parentSessionId !== undefined) { diff --git a/packages/api/session-controller/src/client/sessions/projection-store.ts b/packages/api/session-controller/src/client/sessions/projection-store.ts index fd0ad1b5d2..79ad1b0e62 100644 --- a/packages/api/session-controller/src/client/sessions/projection-store.ts +++ b/packages/api/session-controller/src/client/sessions/projection-store.ts @@ -9,6 +9,7 @@ * bare observable faces feed `useProjection` (ui-renderer binds them). */ import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import type { SessionSeqCursor } from '@deepseek-ai/dsh-session/types' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { Notifier } from './notifier.ts' @@ -46,7 +47,7 @@ export type UseProjection = { */ export interface ProjectionsBaseline { /** The consistent-cut seq (equals the window tail seq by construction). */ - asOfSeq: number + asOfSeq: SessionSeqCursor /** Whole current values by key; a registered key absent here means the capability is absent. */ values: Readonly> } @@ -54,7 +55,7 @@ export interface ProjectionsBaseline { /** One key's row: the latest finished value and the seq it is consistent with. */ interface Row { value: unknown - seq: number + seq: SessionSeqCursor } /** Per-key notification channel: the bare face plus its batching notifier. */ @@ -130,7 +131,7 @@ export class ProjectionValueStore { * @param value - whole value computed by the host unit. * @param seq - the unit's watermark at emission. */ - apply(key: string, value: unknown, seq: number): void { + apply(key: string, value: unknown, seq: SessionSeqCursor): void { const row = this.rows.get(key) if (row !== undefined && seq <= row.seq) return // higher seq wins; replays and stale frames drop this.rows.set(key, { value, seq }) @@ -165,7 +166,7 @@ export class ProjectionValueStore { * baseline immediately afterward. * @param lastSeq - highest durable sequence reflected by the baseline. */ - truncate(lastSeq: number): void { + truncate(lastSeq: SessionSeqCursor): void { for (const [key, row] of this.rows) { if (row.seq <= lastSeq) continue this.rows.delete(key) diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 3e6c8bf6e0..d1d840c513 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -16,7 +16,7 @@ */ import type { Context, Fiber } from '@deepseek-ai/cordis' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' -import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { SESSION_SEARCH_RESULT_LIMIT } from '../../types.ts' @@ -438,7 +438,7 @@ export class ClientSessions implements ISessions { // Flooring lands inside the anchor's own turn (every turn opens with a // turn/start), so the host's first-turn/end-at-or-after cut still ends // on that turn — never clipped back to the previous one. - ...(opts.atSeq === undefined ? {} : { atSeq: Math.floor(opts.atSeq) }), + ...(opts.atSeq === undefined ? {} : { atSeq: SessionSeq(Math.floor(opts.atSeq)) }), }) if (!result.ok) throw new SessionForkError(result.error, opts.sessionId) this.projectList() diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 6e33412cf9..d9d58d41e2 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -5,7 +5,7 @@ import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' -import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { SessionLogOffset, SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import { SessionEventStream } from '../transport.ts' import type { SessionJournalChange } from '../transport.ts' import type { @@ -13,6 +13,7 @@ import type { QueueAction, SessionAddress, SessionControlFrame, + SessionProjectionBaseline, SessionQueuedItem, SessionRequestId, } from '../../types.ts' @@ -35,6 +36,13 @@ import type { ProjectionsBaseline } from './projection-store.ts' import { resolvedClientTimeZone } from '../time-zone.ts' import { SessionQueueMirror } from './queue-mirror.ts' +function projectionsBaseline(value: SessionProjectionBaseline): ProjectionsBaseline { + return { + ...value, + asOfSeq: value.asOfSeq === -1 ? -1 : SessionSeq(value.asOfSeq), + } +} + /** Messages requested per history page. */ export const PAGE_MESSAGES = 50 @@ -72,7 +80,7 @@ export interface SessionOptions { */ export class Session implements SessionFace { // ---- Window and derived state (all private; the snapshot is the only read API) ---- - private baseSeq = 0 + private baseSeq = SessionLogOffset(0) private hasMore = false private openState: OpenState = 'cold' private openError: RemoteFailure | null = null @@ -82,7 +90,7 @@ export class Session implements SessionFace { 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 + private jumpTargetSeq: SessionSeq | 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. */ @@ -326,10 +334,12 @@ export class Session implements SessionFace { * @param title - raw title text (the host normalizes acceptance). * @returns the rename result (normalized accepted title + title event seq). */ - async rename(title: string): Promise> { + async rename(title: string): Promise> { const result = await this.remote.session.rename({ sessionId: this.sessionId, title }) - if (result.ok) this.projections.apply('title', result.value.title, result.value.seq) - return result + if (!result.ok) return result + const seq = SessionSeq(result.value.seq) + this.projections.apply('title', result.value.title, seq) + return { ok: true, value: { title: result.value.title, seq } } } /** @@ -377,11 +387,11 @@ export class Session implements SessionFace { } /** Jump loader: page backwards until the window covers seq (see ISession.loadThrough). */ - loadThrough(seq: number): Promise { + loadThrough(seq: SessionSeq): Promise { if (this.openState !== 'open' || !this.hasMore || this.baseSeq <= seq) return Promise.resolve() if (this.jumpPromise !== null) { // Retarget the running loop to the lowest requested seq. - this.jumpTargetSeq = Math.min(this.jumpTargetSeq ?? seq, seq) + this.jumpTargetSeq = SessionSeq(Math.min(this.jumpTargetSeq ?? seq, seq)) return this.jumpPromise } // A plain single-page pull owns the busy flag; the jump does not queue @@ -434,7 +444,7 @@ export class Session implements SessionFace { this.openPromise = null this.openState = 'cold' this.openError = null - this.baseSeq = 0 + this.baseSeq = SessionLogOffset(0) this.notifier.markDirty() await this.open() } @@ -606,7 +616,11 @@ export class Session implements SessionFace { private acceptEventChange(change: SessionJournalChange): void { switch (change.type) { case 'replace': - this.installWindow(change.entries, change.hasMore, change.page.projections) + this.installWindow( + change.entries, + change.hasMore, + change.page.projections === undefined ? undefined : projectionsBaseline(change.page.projections), + ) return case 'prepend': this.prependWindow(change.entries, change.hasMore) @@ -618,7 +632,7 @@ export class Session implements SessionFace { /** Replace the complete contiguous window and apply page-owned projection metadata. */ private installWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void { - this.baseSeq = entries[0]?.event.seq ?? 0 + this.baseSeq = SessionLogOffset(entries[0]?.event.seq ?? 0) this.hasMore = hasMore if (entries.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false if (projections !== undefined) this.projections.seed(projections) @@ -629,7 +643,7 @@ export class Session implements SessionFace { /** Prepend one stream-validated history page. */ private prependWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void { - this.baseSeq = entries[0]?.event.seq ?? this.baseSeq + this.baseSeq = entries[0] === undefined ? this.baseSeq : SessionLogOffset(entries[0].event.seq) this.hasMore = hasMore this.eventSource.prepend(entries, hasMore) } diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 701aedcc36..0e8015f79c 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -10,6 +10,7 @@ import { ReasoningEffortId, createUserMessage, freezeMessage, } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' +import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' @@ -185,9 +186,11 @@ export class SessionCommandController { * @returns the new Session identity. */ async fork(request: SessionForkRequest): Promise { - if (request.atSeq !== undefined - && (!Number.isInteger(request.atSeq) || request.atSeq < 0)) { - throw new RemoteError('gateway/bad-request', 'atSeq must be a non-negative integer', {}) + let atSeq: ReturnType | undefined + try { + atSeq = request.atSeq === undefined ? undefined : SessionSeq(request.atSeq) + } catch { + throw new RemoteError('gateway/bad-request', 'atSeq must be a non-negative safe integer', {}) } let observed: SessionObservation try { @@ -207,7 +210,6 @@ export class SessionCommandController { } using source = observed const lastSeq = source.events.at(-1)?.seq ?? -1 - const atSeq = request.atSeq const anchoredBoundary = atSeq === undefined ? undefined : source.events.find(event => event.type === 'turn/end' && event.seq >= atSeq) @@ -224,8 +226,10 @@ export class SessionCommandController { { sessionId: request.sessionId }, ) } - let cut = boundary.seq + 1 - while (cut < source.events.length && source.events[cut]?.type !== 'turn/start') cut++ + let cut = SessionLogOffset(boundary.seq + 1) + while (cut < source.events.length && source.events[cut]?.type !== 'turn/start') { + cut = SessionLogOffset(cut + 1) + } let workspace: Workspace | undefined try { workspace = await this.forkWorkspace(source.header) @@ -243,10 +247,11 @@ export class SessionCommandController { await this.ctx.agents.create({ sessionId: childId, seed: source.events.slice(0, cut), + inheritedEventCount: cut, meta: { ...(source.header.cwd === undefined ? {} : { cwd: source.header.cwd }), parentSession: source.header.id, - seedLength: cut, + isSeeded: true, ...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }), diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index 7264e8cb82..9609da3fb5 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -2,9 +2,19 @@ import type { Context } from '@deepseek-ai/cordis' import { Deque } from '@deepseek-ai/dsh-deque' -import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session' +import { + isAppendSurfaceEvent, + SessionLogOffset, + SessionSeq, +} from '@deepseek-ai/dsh-session' import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' -import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { + SessionEvent, + SessionHeader, + SessionId, + SessionLogOffset as SessionLogOffsetType, + SessionSeqCursor, +} from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import type {} from '@deepseek-ai/dsh-subagent' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' @@ -19,6 +29,7 @@ import type { SessionPageRequest, SessionProjectionBaseline, SessionProjectionValues, + SessionWireHeader, SessionWireEvent, } from './types.ts' @@ -51,26 +62,32 @@ export class SessionHistoryController { */ async page(request: SessionPageRequest, signal: AbortSignal): Promise { validatePageRequest(request) + const throughSeq: SessionSeqCursor = request.throughSeq === -1 + ? -1 + : SessionSeq(request.throughSeq) + const beforeSeq = request.beforeSeq === undefined + ? undefined + : SessionLogOffset(request.beforeSeq) using source = await this.sourceFor(request.address, signal, false) signal.throwIfAborted() const sourceLog = source.events - const sourceCursor = sourceLog.at(-1)?.seq ?? -1 - if (request.throughSeq > sourceCursor) { + const sourceCursor: SessionSeqCursor = sourceLog.at(-1)?.seq ?? -1 + if (throughSeq > sourceCursor) { throw new RemoteError( 'gateway/bad-request', - `session page through seq ${String(request.throughSeq)} is past cursor ${String(sourceCursor)}`, + `session page through seq ${String(throughSeq)} is past cursor ${String(sourceCursor)}`, {}, ) } /* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */ - if (request.throughSeq >= 0 && sourceLog[request.throughSeq]?.seq !== request.throughSeq) { - throw new RemoteError('gateway/internal', `session log does not contain through seq ${String(request.throughSeq)}`, {}) + if (throughSeq >= 0 && sourceLog[throughSeq]?.seq !== throughSeq) { + throw new RemoteError('gateway/internal', `session log does not contain through seq ${String(throughSeq)}`, {}) } const page = paginate( sourceLog, - request.beforeSeq, + beforeSeq, request.maxMessages ?? DEFAULT_MAX_MESSAGES, - request.throughSeq, + throughSeq, ) const records = pageRecords(page.events) return { @@ -90,7 +107,7 @@ export class SessionHistoryController { const { address } = request const target = addressId(address) const buffered = new Deque() - let snapshotCursor: number | undefined + let snapshotCursor: SessionSeqCursor | undefined let wake: (() => void) | undefined const notify = (): void => { const resume = wake @@ -115,7 +132,7 @@ export class SessionHistoryController { // opening observation, replay everything beyond that snapshot cursor. const suffix = session.snapshotEvents(snapshotCursor === undefined ? session.firstLiveSeq - : snapshotCursor + 1) + : SessionLogOffset(snapshotCursor + 1)) for (let index = suffix.length - 1; index >= 0; index -= 1) { buffered.pushFront(suffix[index] as SessionEvent) } @@ -132,7 +149,7 @@ export class SessionHistoryController { const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES) yield { type: 'snapshot', - header: source.header, + header: wireHeader(source.header, source.inheritedEventCount), cursor, records: pageRecords(page.events), hasMore: page.hasMore, @@ -149,18 +166,19 @@ export class SessionHistoryController { throw error } } - let nextSeq = cursor + 1 + let nextOffset = SessionLogOffset(cursor + 1) while (!follower.closed && !signal.aborted) { const item = buffered.popFront() if (item === undefined) { await new Promise((resolve) => { wake = resolve }) continue } - if (item.seq < nextSeq) continue - if (item.seq !== nextSeq) { - throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(nextSeq)}`, {}) + const expectedSeq = SessionSeq(nextOffset) + if (item.seq < expectedSeq) continue + if (item.seq !== expectedSeq) { + throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(expectedSeq)}`, {}) } - nextSeq++ + nextOffset = SessionLogOffset(nextOffset + 1) yield entryFor(item) } } finally { @@ -187,7 +205,12 @@ export class SessionHistoryController { rejectNotFound(address) } try { - validateAddress(address, observation.header, observation.projections) + validateAddress( + address, + observation.header, + observation.inheritedEventCount, + observation.projections, + ) } catch (error: unknown) { observation[Symbol.dispose]() throw error @@ -213,11 +236,15 @@ function projectionBlock( } function validatePageRequest(request: SessionPageRequest): void { - if (!Number.isSafeInteger(request.throughSeq) || request.throughSeq < -1) { + if (!Number.isSafeInteger(request.throughSeq) + || request.throughSeq < -1 + || Object.is(request.throughSeq, -0)) { throw new RemoteError('gateway/bad-request', 'throughSeq must be an integer greater than or equal to -1', {}) } if (request.beforeSeq !== undefined - && (!Number.isSafeInteger(request.beforeSeq) || request.beforeSeq < 0)) { + && (!Number.isSafeInteger(request.beforeSeq) + || request.beforeSeq < 0 + || Object.is(request.beforeSeq, -0))) { throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {}) } if (request.maxMessages !== undefined @@ -240,6 +267,7 @@ function addressId(address: SessionAddress): SessionId { function validateAddress( address: SessionAddress, header: SessionHeader, + inheritedEventCount: SessionLogOffsetType, projections: SessionObservation['projections'], ): void { if (address.kind === 'session') { @@ -263,7 +291,7 @@ function validateAddress( reason: 'corrupt', }) } - if (identity === undefined || identity.seq < (header.seedLength ?? 0)) { + if (identity === undefined || identity.seq < inheritedEventCount) { throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is unavailable', { parentSessionId: address.parentSessionId, childSessionId: address.childSessionId, @@ -289,30 +317,44 @@ function rejectNotFound(address: SessionAddress): never { function paginate( events: readonly SessionEvent[], - beforeSeq: number | undefined, + beforeSeq: SessionLogOffsetType | undefined, maxMessages: number, - throughSeq = events.at(-1)?.seq ?? -1, + throughSeq: SessionSeqCursor = events.at(-1)?.seq ?? -1, ): { readonly events: SessionEvent[]; readonly hasMore: boolean } { - const end = Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1) + const end = SessionLogOffset(Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1)) let count = 0 - let cut = 0 + let cut = SessionLogOffset(0) for (let index = end - 1; index >= 0; index--) { const event = events[index] as SessionEvent if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue count++ - const sources = (event as { readonly sourceEventSeqs?: readonly number[] }).sourceEventSeqs + const sources = event.sourceEventSeqs let groupStart = event.seq if (sources !== undefined) { - for (const source of sources) groupStart = Math.min(groupStart, source) + for (const source of sources) { + if (source < groupStart) groupStart = source + } } if (count >= maxMessages) { - cut = groupStart + cut = SessionLogOffset(groupStart) break } } return { events: events.slice(cut, end), hasMore: cut > 0 } } +/** Translate logical Session metadata to the unchanged v0 browser wire. */ +function wireHeader( + header: SessionHeader, + inheritedEventCount: SessionLogOffsetType, +): SessionWireHeader { + const { isSeeded, ...wire } = header + return { + ...wire, + ...isSeeded ? { seedLength: inheritedEventCount } : {}, + } +} + function entryFor(event: SessionEvent): SessionEventEntry { return { type: 'event', diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 4a103dbd22..759c6b7f89 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -4,7 +4,8 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain } from '@deepseek-ai/dsh-llm' import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command' -import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { @@ -191,10 +192,14 @@ export class SessionController extends TypertRemoteService { inspect( sessionId: SessionId, signal?: AbortSignal, - ): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> { + ): Promise { 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, + inheritedEventCount: attached.inheritedEventCount, + events: attached.snapshotEvents(), + }) } return inspectApiSession(this.ctx, sessionId, signal) } diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index ab72da1c92..145c77c63a 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -4,6 +4,7 @@ import { stat } from 'node:fs/promises' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent-presets' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-session-projection-cache' @@ -329,7 +330,9 @@ export class ApiSessionList { ): SessionProjectionHints | undefined { try { const block = session === undefined - ? this.ctx.get('sessionProjectionCache')?.cachedSnapshot(header) + ? header.isSeeded + ? undefined + : this.ctx.get('sessionProjectionCache')?.cachedSnapshot(header, SessionLogOffset(0)) : this.ctx.sessionProjections.cachedSnapshot(session) return block !== undefined && Object.keys(block.values).length > 0 ? { diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c0b25fcf2c..2e2a0d7c46 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -7,7 +7,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' -import type { SessionHeader, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { JsonValue } from '@deepseek-ai/dsh-util-values' @@ -384,6 +384,25 @@ export interface SessionEventEntry { readonly event: SessionWireEvent } +/** v0-compatible Session metadata carried on the browser wire. */ +export interface SessionWireHeader { + readonly version: number + readonly id: SessionId + readonly createdAt: number + readonly cwd?: string + readonly parentSession?: SessionId + /** Exact inherited prefix length; absent for an unseeded Session. */ + readonly seedLength?: number + readonly origin?: 'subagent' + readonly delegationDepth?: number + readonly agentPreset?: string +} + +/** Browser wire form of one Session surface operation. */ +export type SessionWireSurfaceOp = + | 'append' + | { readonly op: 'replace'; readonly start: number; readonly end: number } + /** Event-shaped wire representation of one packed chunk row. */ export type ChunkRowEvent = { [Kind in ChunkRow['type']]: { @@ -411,7 +430,7 @@ export interface SessionWireEvent { readonly data: JsonValue readonly ignorable?: true readonly sourceEventSeqs?: number[] - readonly surfaceOp?: SurfaceOp + readonly surfaceOp?: SessionWireSurfaceOp } /** One message-aligned backwards-history request. */ @@ -439,7 +458,7 @@ export interface SessionPage { export type SessionFollowFrame = | { readonly type: 'snapshot' - readonly header: SessionHeader + readonly header: SessionWireHeader readonly cursor: number readonly records: readonly SessionHistoryRecord[] readonly hasMore: boolean diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 922e0c8e2c..3d370bcf28 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -5,9 +5,10 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' +import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -47,10 +48,19 @@ function header(id: string, cwd: string | null = '/workspace'): SessionHeader { version: 0, id: SessionId(id), createdAt: 1, + isSeeded: false, ...(cwd === null ? {} : { cwd }), } } +function unseededInspection( + meta: SessionHeader, + events: readonly SessionEvent[] = [], +): SessionInspection { + if (meta.isSeeded) throw new Error('seeded inspection fixtures require an explicit inherited cut') + return { meta, inheritedEventCount: SessionLogOffset(0), events } +} + function providePersistence(ctx: Context, persistence: Record): () => void { return ctx.provide('sessionPersistence', testSessionPersistence(ctx, persistence) as never) } @@ -97,7 +107,7 @@ describe('ApiSession identity failures', () => { const listed = header('cwd-less-catalog', null) const disposeListed = providePersistence(ctx, { list: () => Promise.resolve([listed]), - inspect: () => Promise.resolve({ meta: listed, events: [] }), + inspect: () => Promise.resolve(unseededInspection(listed)), }) await expect(inspectApiSession(ctx, listed.id)).rejects.toBeInstanceOf(ApiSessionNotFound) disposeListed() @@ -106,7 +116,7 @@ describe('ApiSession identity failures', () => { const inspected = header('cwd-less-inspect', null) providePersistence(ctx, { list: () => Promise.resolve([catalog]), - inspect: () => Promise.resolve({ meta: inspected, events: [] }), + inspect: () => Promise.resolve(unseededInspection(inspected)), }) await expect(inspectApiSession(ctx, catalog.id)).rejects.toBeInstanceOf(ApiSessionNotFound) }) @@ -117,11 +127,11 @@ describe('ApiSession identity failures', () => { await ctx.plugin(SessionStore) installSessionReadTestServices(ctx) const meta = header('signalled-inspection') - const inspect = vi.fn(() => Promise.resolve({ meta, events: [] })) + const inspect = vi.fn(() => Promise.resolve(unseededInspection(meta))) providePersistence(ctx, { inspect }) const signal = new AbortController().signal - await expect(inspectApiSession(ctx, meta.id, signal)).resolves.toEqual({ meta, events: [] }) + await expect(inspectApiSession(ctx, meta.id, signal)).resolves.toEqual(unseededInspection(meta)) expect(inspect).toHaveBeenCalledWith(meta.id, signal) }) }) @@ -138,6 +148,7 @@ describe('ApiSession Agent lookup and recovery', () => { const observed = { source: 'prepared', header: meta, + inheritedEventCount: SessionLogOffset(0), events: [], cursor: -1, projections: { asOfSeq: -1, values: {} }, @@ -177,7 +188,7 @@ describe('ApiSession Agent lookup and recovery', () => { const ordinaryMeta = header('ordinary-race') providePersistence(ordinary.ctx, { list: () => Promise.resolve([ordinaryMeta]), - inspect: () => Promise.resolve({ meta: ordinaryMeta, events: [] }), + inspect: () => Promise.resolve(unseededInspection(ordinaryMeta)), }) const winner = agent(ordinary.ctx, ordinaryMeta) vi.spyOn(ordinary.ctx.agents, 'resume').mockImplementation(async () => { @@ -190,7 +201,7 @@ describe('ApiSession Agent lookup and recovery', () => { const childMeta = header('child-race') providePersistence(child.ctx, { list: () => Promise.resolve([childMeta]), - inspect: () => Promise.resolve({ meta: childMeta, events: [] }), + inspect: () => Promise.resolve(unseededInspection(childMeta)), }) vi.spyOn(child.ctx.agents, 'resume').mockImplementation(async () => { child.ctx.sessions.create(childMeta.id, { @@ -217,7 +228,7 @@ describe('ApiSession Agent lookup and recovery', () => { const meta = header('failed') providePersistence(failed.ctx, { list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ meta, events: [] }), + inspect: () => Promise.resolve(unseededInspection(meta)), }) vi.spyOn(failed.ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable')) await expect(failed.agents.resolveAgent(meta.id)).resolves.toMatchObject({ @@ -231,6 +242,7 @@ describe('ApiSession Agent lookup and recovery', () => { const observed = { source: 'prepared', header: meta, + inheritedEventCount: SessionLogOffset(0), events: [], cursor: -1, retain: vi.fn(), @@ -363,27 +375,27 @@ describe('ApiSession create or adoption', () => { const meta = { ...header('stored'), agentPreset: 'minimal' } const events = [{ type: 'agent-preset/selected', - seq: 0, + seq: SessionSeq(0), time: 1, data: { agentPreset: 'minimal' }, }] as SessionEvent[] providePersistence(ctx, { list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ meta, events }), + inspect: () => Promise.resolve(unseededInspection(meta, events)), }) ctx.provide('agentPresets', { resolve: (id?: string) => Promise.resolve({ id: id ?? 'minimal' }), mount: () => Promise.resolve(), } as never) + const resumedSession = ctx.sessions.prepare(meta.id, { + seed: structuredClone(events), + meta: structuredClone(meta), + inheritedEventCount: SessionLogOffset(0), + seedSource: 'persistence', + }) const resumed = { id: meta.id, - session: { - id: meta.id, - header: meta, - snapshotEvents: () => events, - eventAt: (seq: number) => events[seq], - seq: events.length, - }, + session: resumedSession, status: 'idle', ctx, } as unknown as Agent @@ -401,7 +413,7 @@ describe('ApiSession create or adoption', () => { const childMeta = header('resume-child-race') providePersistence(child.ctx, { list: () => Promise.resolve([childMeta]), - inspect: () => Promise.resolve({ meta: childMeta, events: [] }), + inspect: () => Promise.resolve(unseededInspection(childMeta)), }) child.ctx.provide('agentPresets', { resolve: () => { @@ -420,7 +432,7 @@ describe('ApiSession create or adoption', () => { const stored = header('stored-cwd-conflict', '/stored') providePersistence(conflict.ctx, { list: () => Promise.resolve([stored]), - inspect: () => Promise.resolve({ meta: stored, events: [] }), + inspect: () => Promise.resolve(unseededInspection(stored)), }) await expect(conflict.agents.ensureSession(stored.id, '/requested', true)) .rejects.toBeInstanceOf(ApiSessionCwdConflict) diff --git a/packages/api/session-controller/tests/client-contract.client.spec.ts b/packages/api/session-controller/tests/client-contract.client.spec.ts index bb9cd172ce..689d5a407b 100644 --- a/packages/api/session-controller/tests/client-contract.client.spec.ts +++ b/packages/api/session-controller/tests/client-contract.client.spec.ts @@ -1,11 +1,18 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import type { PromptContentPart as AttachmentPromptContentPart } from '@deepseek-ai/dsh-attachment/types' +import { SessionSeq, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types' import { MutableSessionEventSource, type SessionLiveEventEntry, } from '../src/client/contract/events.ts' -import type { PromptContentPart as SessionPromptContentPart } from '../src/types.ts' +import type { ISession } from '../src/client/contract/session.ts' +import type { ProjectionsBaseline } from '../src/client/sessions/projection-store.ts' +import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts' +import type { PromptContentPart as SessionPromptContentPart, SessionPageRequest } from '../src/types.ts' +import { ev, plainTurn } from './event-script.client.ts' -function entry(seq: number): SessionLiveEventEntry { +type RenameSuccess = Extract>, { readonly ok: true }> + +function entry(seq: SessionSeq): SessionLiveEventEntry { return { type: 'event', event: { @@ -18,6 +25,23 @@ function entry(seq: number): SessionLiveEventEntry { } describe('Client Session contracts', () => { + it('requires branded Session positions at internal event fixture boundaries', () => { + expectTypeOf(entry).parameter(0).toEqualTypeOf() + expectTypeOf(ev.user).parameter(0).toEqualTypeOf() + expectTypeOf(ev.commandDone).parameter(4).toEqualTypeOf() + expectTypeOf(ev.compactSummary).parameter(2).toEqualTypeOf() + expectTypeOf(ev.compactCheckpoint).parameter(1).toEqualTypeOf() + expectTypeOf(plainTurn).parameter(0).toEqualTypeOf() + }) + + it('brands same-process Session event positions while keeping the API wire numeric', () => { + expectTypeOf().parameter(0).toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().parameter(2).toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) + it('keeps its catalog-visible prompt parts identical to attachment intake', () => { expectTypeOf().toEqualTypeOf() }) @@ -26,9 +50,9 @@ describe('Client Session contracts', () => { const feed = new MutableSessionEventSource() const listener = vi.fn() const dispose = feed.subscribe(listener) - const first = entry(1) - const older = entry(0) - const live = entry(2) + const first = entry(SessionSeq(1)) + const older = entry(SessionSeq(0)) + const live = entry(SessionSeq(2)) feed.replace([first], true) expect(feed.getSnapshot()).toEqual({ @@ -56,13 +80,13 @@ describe('Client Session contracts', () => { expect(listener).toHaveBeenCalledTimes(3) dispose() - feed.append(entry(3)) + feed.append(entry(SessionSeq(3))) expect(listener).toHaveBeenCalledTimes(3) }) it('does not traverse the complete event window while appending', () => { const feed = new MutableSessionEventSource() - const first = entry(1) + const first = entry(SessionSeq(1)) const base = [first] const iterate = vi.fn(Array.prototype[Symbol.iterator].bind(base)) Object.defineProperty(base, Symbol.iterator, { value: iterate }) @@ -70,7 +94,7 @@ describe('Client Session contracts', () => { iterate.mockClear() const before = feed.getSnapshot() - const live = entry(2) + const live = entry(SessionSeq(2)) feed.append(live) const after = feed.getSnapshot() diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 97bd98e093..f437ed27a7 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -4,7 +4,7 @@ import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { describe, expect, it, vi } from 'vitest' import { ApiSessionAgentController } from '../src/agent.ts' @@ -131,7 +131,7 @@ function imageRef(id: string): ImageAttachmentRef { } } -function event(type: string, seq: number, data: unknown): SessionEvent { +function event(type: string, seq: SessionSeq, data: unknown): SessionEvent { return { type, seq, time: seq + 1, data } as SessionEvent } @@ -142,10 +142,20 @@ async function persistedController( const ctx = new Context() await ctx.plugin(SessionStore) const sessionId = SessionId('cold-attachment') - const meta: SessionHeader = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + const meta: SessionHeader = { + version: 0, + id: sessionId, + createdAt: 1, + cwd: '/workspace', + isSeeded: false, + } ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ meta, events }), + inspect: () => Promise.resolve({ + meta, + inheritedEventCount: SessionLogOffset(0), + events, + }), }) as never) installSessionReadTestServices(ctx) ctx.provide('attachments', { readImage } as never) @@ -160,12 +170,12 @@ describe('Session attachment authorization', () => { const inserted = imageRef('inserted') const streamed = imageRef('streamed') const events = [ - { ...event('fixture/direct', 0, { + { ...event('fixture/direct', SessionSeq(0), { content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, { type: 'tool-result', content: [{ type: 'image', attachment: nested }], }], }), ignorable: true as const }, - { ...event('assistant/message', 1, { + { ...event('assistant/message', SessionSeq(1), { turn: 1, step: 1, message: createAssistantMessage({ @@ -173,7 +183,7 @@ describe('Session attachment authorization', () => { source: { provider: 'fixture', model: 'fixture' }, }), }), surfaceOp: 'append' as const }, - event('agent/inbox/spliced', 2, { + event('agent/inbox/spliced', SessionSeq(2), { target: 'next-turn', start: 0, inserted: [createUserMessage({ @@ -181,7 +191,7 @@ describe('Session attachment authorization', () => { source: { kind: 'user' }, })], }), - event('assistant/chunk', 3, { + event('assistant/chunk', SessionSeq(3), { turn: 1, step: 1, chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: streamed } }, @@ -233,7 +243,7 @@ describe('Session attachment authorization', () => { ]) { const ref = imageRef(`failure-${thrown.name}`) const fixture = await persistedController( - [event('fixture/content', 0, { content: [{ type: 'image', attachment: ref }] })], + [event('fixture/content', SessionSeq(0), { content: [{ type: 'image', attachment: ref }] })], () => Promise.reject(thrown), ) await expectFailure(fixture.controller.attachment({ diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index f31c2cb3c7..ef5001cbe2 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { describe, expect, it, vi } from 'vitest' @@ -30,9 +30,14 @@ describe('SessionController facade', () => { id: sessionId, createdAt: 1, cwd: '/workspace', + isSeeded: false, } const events: SessionEvent[] = [] - const inspect = vi.fn(() => Promise.resolve({ meta: header, events })) + const inspect = vi.fn(() => Promise.resolve({ + meta: header, + inheritedEventCount: SessionLogOffset(0), + events, + })) ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve([header]), inspect, @@ -45,7 +50,11 @@ describe('SessionController facade', () => { ctx.on('api-session/error', failure) ctx.on('api-session/activity', activity) - await expect(controller.inspect(sessionId)).resolves.toEqual({ meta: header, events }) + await expect(controller.inspect(sessionId)).resolves.toEqual({ + meta: header, + inheritedEventCount: SessionLogOffset(0), + events, + }) expect(inspect).toHaveBeenCalledOnce() const session = ctx.sessions.create(sessionId, { meta: header }) @@ -62,7 +71,11 @@ describe('SessionController facade', () => { ) await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent }) - await expect(controller.inspect(sessionId)).resolves.toEqual({ meta: header, events }) + await expect(controller.inspect(sessionId)).resolves.toEqual({ + meta: header, + inheritedEventCount: SessionLogOffset(0), + events, + }) expect(inspect).toHaveBeenCalledOnce() ctx.emit('agent/status', { agent, status: 'running' }) ctx.emit('agent/error', { agent, turn: 1, step: 0, error: new Error('fixture failure') }) @@ -109,11 +122,15 @@ describe('SessionController facade', () => { await ctx.plugin(AgentRegistry) const sessionId = SessionId(`background-${outcome}`) const header: SessionHeader = { - version: 0, id: sessionId, createdAt: 1, cwd: '/workspace', + version: 0, id: sessionId, createdAt: 1, cwd: '/workspace', isSeeded: false, } ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve([header]), - inspect: () => Promise.resolve({ meta: header, events: [] }), + inspect: () => Promise.resolve({ + meta: header, + inheritedEventCount: SessionLogOffset(0), + events: [], + }), }) as never) const controller = createSessionTestController(ctx, defaults) const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents @@ -161,11 +178,15 @@ describe('SessionController facade', () => { await ctx.plugin(AgentRegistry) const sessionId = SessionId('background-disposal') const header: SessionHeader = { - version: 0, id: sessionId, createdAt: 1, cwd: '/workspace', + version: 0, id: sessionId, createdAt: 1, cwd: '/workspace', isSeeded: false, } ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve([header]), - inspect: () => Promise.resolve({ meta: header, events: [] }), + inspect: () => Promise.resolve({ + meta: header, + inheritedEventCount: SessionLogOffset(0), + events: [], + }), }) as never) const controller = createSessionTestController(ctx, defaults) const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents diff --git a/packages/api/session-controller/tests/event-script.client.ts b/packages/api/session-controller/tests/event-script.client.ts index 0d8deb5e27..17cd7775fb 100644 --- a/packages/api/session-controller/tests/event-script.client.ts +++ b/packages/api/session-controller/tests/event-script.client.ts @@ -1,6 +1,7 @@ import { ToolCallId, createMessage, createToolResultMessage, createUserMessage, } from '@deepseek-ai/dsh-llm' +import { SessionSeq } from '@deepseek-ai/dsh-session/types' // Minimal SessionEvent builders for orchestration tests (shape mirrors what the // host emits; only the fields the object layer reads). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' @@ -14,23 +15,23 @@ import type { /** One text content block (local helper). */ const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }] -const at = (seq: number, e: Record): SessionEvent => +const at = (seq: SessionSeq, e: Record): SessionEvent => ({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent export const ev = { - turnStart: (seq: number, turn: number): SessionEvent => + turnStart: (seq: SessionSeq, turn: number): SessionEvent => at(seq, { type: 'turn/start', data: { turn } }), - user: (seq: number, body: string): SessionEvent => + user: (seq: SessionSeq, body: string): SessionEvent => at(seq, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ content: text(body), source: { kind: 'user' }, }) }), - stepStart: (seq: number, turn: number, step = 0): SessionEvent => + stepStart: (seq: SessionSeq, turn: number, step = 0): SessionEvent => at(seq, { type: 'step/start', data: { turn, step } }), - chunkStart: (seq: number, turn: number, step = 0, index = 0): SessionEvent => + chunkStart: (seq: SessionSeq, turn: number, step = 0, index = 0): SessionEvent => at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index, blockType: 'text' } } }), - chunkText: (seq: number, turn: number, piece: string, step = 0, index = 0): SessionEvent => + chunkText: (seq: SessionSeq, turn: number, piece: string, step = 0, index = 0): SessionEvent => at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'text-delta', index, text: piece } } }), - assistant: (seq: number, turn: number, body: string, step = 0): SessionEvent => + assistant: (seq: SessionSeq, turn: number, body: string, step = 0): SessionEvent => at(seq, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, message: createMessage({ @@ -42,9 +43,9 @@ export const ev = { }, }), } }), - toolCall: (seq: number, turn: number, callId: string, name: string, args: string, step = 0): SessionEvent => + toolCall: (seq: SessionSeq, turn: number, callId: string, name: string, args: string, step = 0): SessionEvent => at(seq, { type: 'tool/call', data: { turn, step, callId, name, arguments: args } }), - toolResult: (seq: number, turn: number, callId: string, body: string, step = 0): SessionEvent => + toolResult: (seq: SessionSeq, turn: number, callId: string, body: string, step = 0): SessionEvent => at(seq, { type: 'tool/result', surfaceOp: 'append', @@ -58,20 +59,28 @@ export const ev = { }), }, }), - codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent => + codeDispatchStart: (seq: SessionSeq, parentCallId: string, n: number, name: string, args: unknown): SessionEvent => at(seq, { type: 'tool/code-dispatch-start', data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args }, }), - codeDispatch: (seq: number, parentCallId: string, n: number, name: string, args: unknown, body: string, isError = false): SessionEvent => + codeDispatch: ( + seq: SessionSeq, + parentCallId: string, + n: number, + name: string, + args: unknown, + body: string, + isError = false, + ): SessionEvent => at(seq, { type: 'tool/code-dispatch', data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) }, }), - stepEnd: (seq: number, turn: number, step = 0): SessionEvent => + stepEnd: (seq: SessionSeq, turn: number, step = 0): SessionEvent => at(seq, { type: 'step/end', data: { turn, step } }), retry: ( - seq: number, + seq: SessionSeq, turn: number, step = 0, retry = 1, @@ -88,23 +97,23 @@ export const ev = { failure: { code: 'TRANSPORT', message }, }, }), - turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent => + turnEnd: (seq: SessionSeq, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent => at(seq, { type: 'turn/end', data: { turn, reason: reason === 'completed' ? { kind: 'completed' } : { kind: 'aborted', reason: { kind: reason === 'disposed' ? 'disposed' : 'user' } }, } }), - commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent => + commandRun: (seq: SessionSeq, commandId: string, name: string, args = ''): SessionEvent => at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }), - commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent => + commandRunWithoutInput: (seq: SessionSeq, commandId: string, name: string): SessionEvent => at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }), commandDone: ( - seq: number, + seq: SessionSeq, commandId: string, kind: 'success' | 'error' = 'success', text?: string, - sourceEventSeq?: number, + sourceEventSeq?: SessionSeq, ): SessionEvent => at(seq, { type: 'command/done', data: { commandId, @@ -113,7 +122,7 @@ export const ev = { ...sourceEventSeq === undefined ? {} : { sourceEventSeq }, } }), /** A compaction's log-only `compaction/summary` record. */ - compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent => + compactSummary: (seq: SessionSeq, summary: string, start: SessionSeq, end: SessionSeq): SessionEvent => at(seq, { type: 'compaction/summary', data: { summary: text(summary), shadowedRange: { start, end }, @@ -123,7 +132,12 @@ export const ev = { model: 'compact-1', } }), /** The replacement user message a compaction backend lands (the checkpoint). */ - compactCheckpoint: (seq: number, summarySeq: number, start: number, end: number): SessionEvent => + compactCheckpoint: ( + seq: SessionSeq, + summarySeq: SessionSeq, + start: SessionSeq, + end: SessionSeq, + ): SessionEvent => at(seq, { type: 'user/message', surfaceOp: { op: 'replace', start, end }, @@ -136,14 +150,14 @@ export const ev = { } /** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */ -export function plainTurn(startSeq: number, turn: number, ask: string, answer: string): SessionEvent[] { +export function plainTurn(startSeq: SessionSeq, turn: number, ask: string, answer: string): SessionEvent[] { return [ ev.turnStart(startSeq, turn), - ev.user(startSeq + 1, ask), - ev.stepStart(startSeq + 2, turn), - ev.assistant(startSeq + 3, turn, answer), - ev.stepEnd(startSeq + 4, turn), - ev.turnEnd(startSeq + 5, turn), + ev.user(SessionSeq(startSeq + 1), ask), + ev.stepStart(SessionSeq(startSeq + 2), turn), + ev.assistant(SessionSeq(startSeq + 3), turn, answer), + ev.stepEnd(SessionSeq(startSeq + 4), turn), + ev.turnEnd(SessionSeq(startSeq + 5), turn), ] } diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 541c104a58..14f7c25fca 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { SessionSeq } from '@deepseek-ai/dsh-session/types' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' import type {} from '@deepseek-ai/dsh-session-title/client' @@ -751,7 +752,7 @@ describe('connected generation', () => { it('refreshes query baselines without rebuilding independently resumed Session sources', async () => { const api = new FakeApiClient() api.onHistory = () => Promise.resolve(ok({ - records: entries(plainTurn(0, 0, 'a', 'b')) as never[], + records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' }, })) diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index 64ee9d66f8..74f37c57cd 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -9,6 +9,7 @@ */ import { describe, expect, it } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { SessionSeq } from '@deepseek-ai/dsh-session/types' import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts' import { Session } from '../src/client/sessions/session.ts' import { SessionManager } from '../src/client/sessions/manager.ts' @@ -34,35 +35,35 @@ describe('Session projection value semantics', () => { it('applies frames last-wins by seq: replayed and stale frames drop', () => { const store = new ProjectionValueStore() - store.apply('test/marks', { marks: ['a'] }, 5) - store.apply('test/marks', { marks: ['a', 'b'] }, 9) + store.apply('test/marks', { marks: ['a'] }, SessionSeq(5)) + store.apply('test/marks', { marks: ['a', 'b'] }, SessionSeq(9)) expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] }) - store.apply('test/marks', { marks: ['stale'] }, 5) - store.apply('test/marks', { marks: ['equal'] }, 9) + store.apply('test/marks', { marks: ['stale'] }, SessionSeq(5)) + store.apply('test/marks', { marks: ['equal'] }, SessionSeq(9)) expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] }) }) it('a stale baseline can neither overwrite nor clear a newer frame; a fresh one reseeds and clears', () => { const store = new ProjectionValueStore() - store.apply('test/marks', { marks: ['frame-20'] }, 20) + store.apply('test/marks', { marks: ['frame-20'] }, SessionSeq(20)) // Stale cut: carried key loses to the newer frame; omitted key survives. - store.seed({ asOfSeq: 10, values: { 'test/marks': { marks: ['baseline-10'] } } }) + store.seed({ asOfSeq: SessionSeq(10), values: { 'test/marks': { marks: ['baseline-10'] } } }) expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] }) - store.seed({ asOfSeq: 15, values: {} }) + store.seed({ asOfSeq: SessionSeq(15), values: {} }) expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] }) // Fresh cut: carried key reseeds… - store.seed({ asOfSeq: 30, values: { 'test/marks': { marks: ['baseline-30'] } } }) + store.seed({ asOfSeq: SessionSeq(30), values: { 'test/marks': { marks: ['baseline-30'] } } }) expect(store.get('test/marks')).toEqual({ marks: ['baseline-30'] }) // …and an omitting fresh cut clears (capability absent as of the cut). - store.seed({ asOfSeq: 40, values: {} }) + store.seed({ asOfSeq: SessionSeq(40), values: {} }) expect(store.get('test/marks')).toBeUndefined() }) it('truncate drops rows past the durable baseline and keeps the rest', () => { const store = new ProjectionValueStore() - store.apply('test/marks', { marks: ['durable'] }, 5) - store.apply('other', 'phantom', 50) - store.truncate(10) + store.apply('test/marks', { marks: ['durable'] }, SessionSeq(5)) + store.apply('other', 'phantom', SessionSeq(50)) + store.truncate(SessionSeq(10)) expect(store.get('test/marks')).toEqual({ marks: ['durable'] }) expect(store.get('other')).toBeUndefined() }) @@ -73,11 +74,11 @@ describe('Session projection value semantics', () => { let anyTicks = 0 store.faceOf('test/marks').subscribe(() => { keyTicks += 1 }) store.subscribeAny(() => { anyTicks += 1 }) - store.apply('test/marks', { marks: ['a'] }, 5) + store.apply('test/marks', { marks: ['a'] }, SessionSeq(5)) await Promise.resolve() expect(keyTicks).toBe(1) expect(anyTicks).toBe(1) - store.apply('test/marks', { marks: ['replay'] }, 3) + store.apply('test/marks', { marks: ['replay'] }, SessionSeq(3)) await Promise.resolve() expect(keyTicks).toBe(1) expect(anyTicks).toBe(1) @@ -92,7 +93,7 @@ describe('Session projection value semantics', () => { const store = new ProjectionValueStore() const empty = store.values() expect(store.values()).toBe(empty) - store.apply('test/marks', { marks: ['a'] }, 1) + store.apply('test/marks', { marks: ['a'] }, SessionSeq(1)) const populated = store.values() expect(populated).toEqual({ 'test/marks': { marks: ['a'] } }) expect(populated).not.toBe(empty) @@ -105,7 +106,7 @@ describe('Session tail-page seeding', () => { const api = new FakeApiClient() const session = new Session(SID, fakeRemote(api)) api.onHistory = () => Promise.resolve(ok({ - records: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false, + records: entries(plainTurn(SessionSeq(0), 0, '问', '答')) as never[], hasMore: false, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } }, } as never)) await session.open() @@ -116,11 +117,11 @@ describe('Session tail-page seeding', () => { const api = new FakeApiClient() const session = new Session(SID, fakeRemote(api)) api.onHistory = () => Promise.resolve(ok({ - records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, + records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } }, } as never)) await session.open() - session.projections.apply('test/marks', { marks: ['pushed-9'] }, 9) + session.projections.apply('test/marks', { marks: ['pushed-9'] }, SessionSeq(9)) await session.resync() expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed-9'] }) }) @@ -128,9 +129,9 @@ describe('Session tail-page seeding', () => { it('treats a blockless response as no reset: pushed values survive', async () => { const api = new FakeApiClient() const session = new Session(SID, fakeRemote(api)) - api.onHistory = () => Promise.resolve(ok({ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false })) + api.onHistory = () => Promise.resolve(ok({ records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false })) await session.open() - session.projections.apply('test/marks', { marks: ['pushed'] }, 9) + session.projections.apply('test/marks', { marks: ['pushed'] }, SessionSeq(9)) await session.resync() expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed'] }) }) diff --git a/packages/api/session-controller/tests/queue-store.client.spec.ts b/packages/api/session-controller/tests/queue-store.client.spec.ts index 856a861d5a..722a06b090 100644 --- a/packages/api/session-controller/tests/queue-store.client.spec.ts +++ b/packages/api/session-controller/tests/queue-store.client.spec.ts @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types' import type { MessageId, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' import { Session } from '../src/client/sessions/session.ts' @@ -160,12 +160,12 @@ describe('Session queue snapshot intake', () => { { id: 's-second', body: '', placement: 'steering', message }, ])) const durable = { - seq: 0, + seq: SessionSeq(0), time: 1_700_000_000_000, type: 'user/message', surfaceOp: 'append', data: message, - } as SessionEvent + } satisfies SessionEvent await api.pushFollow(SID, { type: 'event', event: durable as never }) await vi.waitFor(() => { 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 20c374e8c3..a6cc76c8b5 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -9,7 +9,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' @@ -49,7 +49,7 @@ function promptRequest( } function header(id: string, createdAt: number, extra: Partial = {}): SessionHeader { - return { version: 0, id: sid(id), createdAt, cwd: '/proj', ...extra } + return { version: 0, id: sid(id), createdAt, cwd: '/proj', isSeeded: false, ...extra } } function providePersistence(ctx: Context, persistence: Record): () => void { @@ -70,29 +70,30 @@ describe('sessions.list cold merge', () => { header('small-conversation', 200), header('large-unknown', 300), header('cached-nonblank', 400), + header('seeded-cold', 450, { isSeeded: true }), header('locationless', 500, { parentSession: sid('session-parent'), origin: 'subagent' }), header('vanished', 600), header('read-failure', 700), - { version: 0, id: sid('missing-cwd'), createdAt: 800 }, + { version: 0, id: sid('missing-cwd'), createdAt: 800, isSeeded: false }, ] const inspect = vi.fn(async (id: SessionId) => { if (id === sid('small-blank')) { return { meta: metas[0]!, - events: [{ type: 'session/end-seed', seq: 0, time: 700, data: {} }] as SessionEvent[], + events: [{ type: 'session/end-seed', seq: SessionSeq(0), time: 700, data: {} }] satisfies SessionEvent[], } } if (id === sid('small-conversation')) { return { meta: metas[1]!, events: [ - { type: 'turn/start', seq: 0, time: 800, data: { turn: 1 } }, + { type: 'turn/start', seq: SessionSeq(0), time: 800, data: { turn: 1 } }, { - type: 'user/message', seq: 1, time: 1200, + type: 'user/message', seq: SessionSeq(1), time: 1200, data: createUserMessage({ content: [{ type: 'text', text: 'worked' }], source: { kind: 'user' } }), surfaceOp: 'append', }, - ] as SessionEvent[], + ] satisfies SessionEvent[], } } if (id === sid('read-failure')) throw new Error('simulated read failure') @@ -101,7 +102,9 @@ describe('sessions.list cold merge', () => { providePersistence(ctx, { list: () => Promise.resolve(metas), locate: (meta: SessionHeader) => { - if (meta.id === sid('large-unknown')) return { kind: 'jsonl', path: largePath } + if (meta.id === sid('large-unknown') || meta.id === sid('seeded-cold')) { + return { kind: 'jsonl', path: largePath } + } if (meta.id === sid('locationless')) return undefined if (meta.id === sid('vanished')) return { kind: 'jsonl', path: join(root, 'vanished.log') } return { kind: 'jsonl', path: smallPath } @@ -110,19 +113,20 @@ describe('sessions.list cold merge', () => { }) ctx.provide('sessionProjectionCache', { cachedSnapshot: (meta: SessionHeader) => { + if (meta.id === sid('seeded-cold')) throw new Error('seeded cold listing must not guess a body cut') if (meta.id === sid('small-blank')) { - return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: null } } } + return { asOfSeq: SessionSeq(0), values: { sessionListMetadata: { blank: true, lastPromptAt: null } } } } if (meta.id === sid('small-conversation')) { - return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: 900 } } } + return { asOfSeq: SessionSeq(0), values: { sessionListMetadata: { blank: true, lastPromptAt: 900 } } } } if (meta.id === sid('cached-nonblank')) { - return { asOfSeq: 1, values: { sessionListMetadata: { blank: false, lastPromptAt: 1000 } } } + return { asOfSeq: SessionSeq(1), values: { sessionListMetadata: { blank: false, lastPromptAt: 1000 } } } } return undefined }, - hydratePrepared: (session: Session, _meta: SessionHeader, events: readonly SessionEvent[]) => - ctx.sessionProjections.hydrate(session, {}, events, 0), + hydratePrepared: (session: Session, events: readonly SessionEvent[]) => + ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0)), } as never) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) @@ -134,6 +138,7 @@ describe('sessions.list cold merge', () => { expect(byId['small-conversation']).toMatchObject({ blank: false, updatedAt: 1200 }) expect(byId['large-unknown']).toMatchObject({ blank: false, updatedAt: 300 }) expect(byId['cached-nonblank']).toMatchObject({ blank: false, updatedAt: 1000 }) + expect(byId['seeded-cold']).toMatchObject({ blank: false, updatedAt: 450 }) expect(byId['locationless']).toMatchObject({ blank: false, updatedAt: 500, @@ -195,9 +200,9 @@ describe('sessions.list cold merge', () => { await started.promise const session = ctx.sessions.create(meta.id, { seed: [ - { type: 'turn/start', seq: 0, time: 200, data: { turn: 1 } }, + { type: 'turn/start', seq: SessionSeq(0), time: 200, data: { turn: 1 } }, { - type: 'user/message', seq: 1, time: 300, + type: 'user/message', seq: SessionSeq(1), time: 300, data: createUserMessage({ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }), surfaceOp: 'append', }, @@ -236,10 +241,14 @@ describe('sessions.list cold merge', () => { inspect: () => { const session = ctx.sessions.create(meta.id, { meta, - seed: [{ type: 'turn/start', seq: 0, time: 200, data: { turn: 1 } }], + seed: [{ type: 'turn/start', seq: SessionSeq(0), time: 200, data: { turn: 1 } }], }) ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent) - return Promise.resolve({ meta, events: [] }) + return Promise.resolve({ + meta, + inheritedEventCount: SessionLogOffset(0), + events: [], + }) }, }) const remote = createSessionTestRemote(ctx, { @@ -290,7 +299,11 @@ describe('sessions.list cold merge', () => { header: meta, live: false, persisted: true, }]) vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValue({ - source: 'prepared', header: meta, events: [], cursor: -1, + source: 'prepared', + header: meta, + inheritedEventCount: SessionLogOffset(0), + events: [], + cursor: -1, retain: vi.fn(), [Symbol.dispose]: vi.fn(), }) const list = new ApiSessionList(ctx, 1024) @@ -314,13 +327,13 @@ describe('attached updatedAt tracks human prompts', () => { const worked = 1_000_000 const resumed = ctx.sessions.create(sid('resumed-untouched'), { seed: [ - { type: 'turn/start', seq: 0, time: worked, data: { turn: 1 } }, + { type: 'turn/start', seq: SessionSeq(0), time: worked, data: { turn: 1 } }, { - type: 'user/message', seq: 1, time: worked, + type: 'user/message', seq: SessionSeq(1), time: worked, data: createUserMessage({ content: [{ type: 'text', text: 'worked' }], source: { kind: 'user' } }), surfaceOp: 'append', }, - { type: 'turn/end', seq: 2, time: worked + 1, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/end', seq: SessionSeq(2), time: worked + 1, data: { turn: 1, reason: { kind: 'completed' } } }, ], meta: { cwd: '/proj', createdAt: 500 }, }) @@ -360,7 +373,8 @@ describe('cold history recovery view', () => { const meta = header(sessionId, 1000) const stored: StoredPrefix = { meta, - events: [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }], + inheritedEventCount: SessionLogOffset(0), + events: [{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }], revision: SessionPersistenceRevision('history-recovery-test:1'), } const backend: PersistenceBackend = { @@ -425,7 +439,11 @@ describe('Remote Agent and Session lookup policy', () => { await ctx.plugin(AgentRegistry) const sessionId = sid('session-remote-cold') const meta = header(sessionId, 1000) - const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] })) + const inspect = vi.fn(() => Promise.resolve({ + meta, + inheritedEventCount: SessionLogOffset(0), + events: [] as SessionEvent[], + })) providePersistence(ctx, { list: () => Promise.resolve([meta]), inspect, @@ -469,7 +487,11 @@ describe('Remote Agent and Session lookup policy', () => { parentSession: sid('session-parent'), origin: 'subagent', }) - const inspect = vi.fn(() => Promise.resolve({ meta: coldMeta, events: [] as SessionEvent[] })) + const inspect = vi.fn(() => Promise.resolve({ + meta: coldMeta, + inheritedEventCount: SessionLogOffset(0), + events: [] as SessionEvent[], + })) providePersistence(ctx, { list: () => Promise.resolve([coldMeta]), inspect, @@ -513,21 +535,29 @@ describe('subagent ownership fence', () => { const sessionId = sid('session-child') const meta = header('session-child', 1000, { parentSession: sid('session-parent'), - seedLength: 0, + isSeeded: true, origin: 'subagent', }) const events = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { + type: 'turn/start', + seq: SessionSeq(0), + time: 1, + data: { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + } as SessionEvent<'turn/start'>['data'], + }, { type: 'user/message', - seq: 1, + seq: SessionSeq(1), time: 2, data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }), surfaceOp: 'append', }, { type: 'subagent/descriptor', - seq: 2, + seq: SessionSeq(2), time: 3, data: snapshotSubagentDescriptor({ mode: 'continuable', @@ -535,9 +565,13 @@ describe('subagent ownership fence', () => { label: 'child', }), }, - { type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, - ] as SessionEvent[] - const inspect = vi.fn(() => Promise.resolve({ meta, events })) + { type: 'turn/end', seq: SessionSeq(3), time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, + ] satisfies SessionEvent[] + const inspect = vi.fn(() => Promise.resolve({ + meta, + inheritedEventCount: SessionLogOffset(0), + events, + })) providePersistence(ctx, { list: () => Promise.resolve([meta]), inspect, @@ -591,19 +625,23 @@ describe('subagent ownership fence', () => { const sessionId = sid('session-legacy-child') const meta = header('session-legacy-child', 1000, { parentSession: sid('session-parent'), - seedLength: 0, + isSeeded: true, }) const events = [ { type: 'subagent/descriptor', - seq: 0, + seq: SessionSeq(0), time: 1, data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' }, }, - ] as SessionEvent[] + ] satisfies SessionEvent[] providePersistence(ctx, { list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ meta, events }), + inspect: () => Promise.resolve({ + meta, + inheritedEventCount: SessionLogOffset(0), + events, + }), locate: () => undefined, }) // Stores whose headers predate `origin` classify a child only through the @@ -690,11 +728,12 @@ describe('subagent ownership fence', () => { const session = ctx.sessions.create(sid('session-ordinary-fork'), { seed: [{ type: 'subagent/descriptor', - seq: 0, + seq: SessionSeq(0), time: 1, data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'ancestor' }, }], - meta: { cwd: '/proj', parentSession: sid('session-source'), seedLength: 1 }, + inheritedEventCount: SessionLogOffset(1), + meta: { cwd: '/proj', parentSession: sid('session-source'), isSeeded: true }, }) const followup = vi.fn() const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent @@ -862,7 +901,11 @@ describe('sessions.prompt synchronous rejection', () => { const meta: SessionHeader = header('race-resume', 1000) providePersistence(ctx, { list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }), + inspect: () => Promise.resolve({ + meta, + inheritedEventCount: SessionLogOffset(0), + events: [] as SessionEvent[], + }), locate: () => undefined, }) // The raced winner: a live parent-owned subagent publishes the identity 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 958e53f5ba..7942bb92f6 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -6,7 +6,7 @@ import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Workspace } from '@deepseek-ai/dsh-workspace' @@ -32,6 +32,9 @@ async function composed(workspaces: readonly Workspace[] = []): Promise const session = ctx.sessions.create(options.sessionId, { ...options.seed === undefined ? {} : { seed: [...options.seed] }, ...options.meta === undefined ? {} : { meta: options.meta }, + ...options.inheritedEventCount === undefined + ? {} + : { inheritedEventCount: options.inheritedEventCount }, }) const agent = {} as Agent const agentCtx = ownerCtx.extend({ agent }) @@ -89,7 +92,7 @@ describe('sessions.fork', () => { const ctx = await composed() const source = liveAgent(ctx, 'session-source', 2) const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: 1 })) - expect(response.ok).toBe(true) + expect(response.ok ? null : response.error).toBeNull() if (!response.ok) return const child = ctx.sessions.get(response.value.sessionId) expect(child?.snapshotEvents().map(event => event.type)).toEqual([ @@ -153,22 +156,35 @@ describe('sessions.fork', () => { createdAt: 1, cwd: '/proj', parentSession: parentId, + isSeeded: false, origin: 'subagent', } const events = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { + type: 'turn/start', + seq: SessionSeq(0), + time: 1, + data: { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + } as SessionEvent<'turn/start'>['data'], + }, { type: 'user/message', - seq: 1, + seq: SessionSeq(1), time: 2, data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }), surfaceOp: 'append', }, - { type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }, - ] as SessionEvent[] + { type: 'turn/end', seq: SessionSeq(2), time: 3, data: { turn: 1, reason: { kind: 'completed' } } }, + ] satisfies SessionEvent[] ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve([header]), - inspect: () => Promise.resolve({ meta: header, events }), + inspect: () => Promise.resolve({ + meta: header, + inheritedEventCount: SessionLogOffset(0), + events, + }), }) as never) const resume = vi.spyOn(ctx.agents, 'resume') 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 90b5851e08..4dcd66d8da 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 @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionSeq } from '@deepseek-ai/dsh-session' import { decodeStorageRecord, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' import { ToolCallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' @@ -93,11 +93,11 @@ function pageEvents(page: SessionPage): SessionWireEvent[] { function chunkRow(event: ChunkRowEvent): ChunkRow { switch (event.type) { case 'chunkrow/text-chunks': - return { type: 'text-chunks', seq0: event.seq, time0: event.time, data: event.data } + return { type: 'text-chunks', seq0: SessionSeq(event.seq), time0: event.time, data: event.data } case 'chunkrow/reasoning-chunks': - return { type: 'reasoning-chunks', seq0: event.seq, time0: event.time, data: event.data } + return { type: 'reasoning-chunks', seq0: SessionSeq(event.seq), time0: event.time, data: event.data } case 'chunkrow/tool-call-chunks': - return { type: 'tool-call-chunks', seq0: event.seq, time0: event.time, data: event.data } + return { type: 'tool-call-chunks', seq0: SessionSeq(event.seq), time0: event.time, data: event.data } } } @@ -210,6 +210,11 @@ describe('Session history raw journal', () => { const third = appendUserText(session, 'second prompt') appendAssistantText(session, 'second reply', 2) const shadowed = [...session.surface.nodes] + const shadowedStart = shadowed[0] + const shadowedEnd = shadowed.at(-1) + if (shadowedStart === undefined || shadowedEnd === undefined) { + throw new Error('expected a non-empty surface') + } // A compaction transaction: a log-only summary record immediately followed by the // replacement that shadows the range. const summary = appendExtension(session, 'compaction/summary', { @@ -224,7 +229,7 @@ describe('Session history raw journal', () => { content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' }, }), { - surfaceOp: { op: 'replace', start: shadowed[0] as number, end: shadowed.at(-1) as number }, + surfaceOp: { op: 'replace', start: shadowedStart, end: shadowedEnd }, sourceEventSeqs: [...shadowed, summary.seq], }) diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index e5aad4564b..c0bad5dabd 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import { SessionSeq, type SessionEvent, type SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Session } from '../src/client/sessions/session.ts' import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts' @@ -32,7 +32,7 @@ function imageRef(id: string): ImageAttachmentRef { } /** A durable browser-prompt user/message whose source echoes `rpcId`. */ -function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionEvent { +function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionEvent { return { seq, time: 1_700_000_000_000 + seq, @@ -160,7 +160,7 @@ describe('observed retirement', () => { onRetire: retirement => retirements.push(retirement), }) const refs = [imageRef('att-1')] - await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId, refs) as never }) + await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId, refs) as never }) // Synchronously after the append the echo is still in the snapshot; the // render-time dedupe owns the overlap frame. expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) @@ -191,7 +191,7 @@ describe('observed retirement', () => { it('a full-window install (reconnect resync) retires echoes observed in the window', async () => { const { api, session } = makeSession() const handle = session.beginSubmission({ mode: 'queue', text: '重连', images: [] }) - api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(12, handle.requestId)]))) + api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(SessionSeq(12), handle.requestId)]))) await session.open() await settleFrames() expect(session.getSnapshot().pendingSubmissions).toEqual([]) @@ -208,7 +208,7 @@ describe('observed retirement', () => { images: [], onRetire: retirement => retirements.push(retirement), }) - await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never }) + await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never }) handle.abandon() await settleFrames() expect(retirements).toEqual([{ reason: 'observed', attachments: [] }]) @@ -229,7 +229,7 @@ describe('observed retirement', () => { type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, [])], }) await api.pushFollow(SID, { - type: 'event', event: promptEvent(0, handle.requestId) as never, + type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never, }) await settleFrames() expect(retirements).toEqual([{ reason: 'observed', attachments: [] }]) @@ -246,7 +246,7 @@ describe('observed retirement', () => { api.onHistory = () => Promise.resolve(ok(historyValue([]))) await session.open() const handle = session.beginSubmission({ mode: 'queue', text: '帧', images: [] }) - await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never }) + await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never }) expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) expect(frames).toHaveLength(1) frames[0]?.(0) @@ -272,7 +272,7 @@ describe('disposal', () => { images: [], onRetire: retirement => retirements.push({ text: '未settle', retirement }), }) - await api.pushFollow(SID, { type: 'event', event: promptEvent(0, observed.requestId) as never }) + await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), observed.requestId) as never }) await session.dispose() await settleFrames() expect(retirements).toEqual([ 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 c2ac3216a5..3f57e1afe2 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -18,7 +18,7 @@ import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' @@ -132,6 +132,38 @@ function seedMessages(session: Session, count: number): void { const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) describe('session.history projections block', () => { + it('keeps the v0 numeric seed cut on the wire while logical headers expose only lineage', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) + const parent = ctx.sessions.create(SessionId('wire-seed-parent'), { meta: { cwd: '/workspace' } }) + parent.append('turn/start', { turn: 1 }) + parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const inheritedEventCount = parent.seq + const child = ctx.sessions.create(SessionId('wire-seed-child'), { + seed: parent.snapshotEvents(), + inheritedEventCount, + meta: { + cwd: '/workspace', + parentSession: parent.id, + isSeeded: true, + }, + }) + + const snapshot = await opening(remote(ctx), child.id) + + expect(snapshot.header).toEqual({ + version: 0, + id: child.id, + createdAt: child.header.createdAt, + cwd: '/workspace', + parentSession: parent.id, + seedLength: inheritedEventCount, + }) + expect(snapshot.header).not.toHaveProperty('isSeeded') + }) + it('tracks pending and used model selections across repeated request headers', async () => { const { ctx, session } = await harness(true) remote(ctx) @@ -318,7 +350,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.eventAt(session.seq - 1)?.time, + lastPromptAt: session.eventAt(SessionSeq(session.seq - 1))?.time, }) }) @@ -352,7 +384,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.eventAt(session.seq - 1)?.time, + lastPromptAt: session.eventAt(SessionSeq(session.seq - 1))?.time, }) expect(row?.projections?.asOfSeq).toBe(session.seq - 1) }) @@ -414,7 +446,7 @@ describe('session.list projections column', () => { cachedSnapshot: (meta: { id: unknown; createdAt: number }) => (meta.id === coldId && meta.createdAt === 5 ? { - asOfSeq: 7, + asOfSeq: SessionSeq(7), values: { 'test/last-user': { text: 'cached' }, sessionListMetadata: { blank: false, lastPromptAt: 6 }, diff --git a/packages/api/session-controller/tests/session-search.host.spec.ts b/packages/api/session-controller/tests/session-search.host.spec.ts index 0c4591556c..89d1a2f424 100644 --- a/packages/api/session-controller/tests/session-search.host.spec.ts +++ b/packages/api/session-controller/tests/session-search.host.spec.ts @@ -8,7 +8,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { @@ -32,6 +32,7 @@ function header(id: string, cwd: string | null = '/project'): SessionHeader { version: 0, id: sid(id), createdAt: 100, + isSeeded: false, ...(cwd === null ? {} : { cwd }), } } @@ -44,7 +45,7 @@ function hit(id: string, index = 0): SessionSearchHit { persisted: false, bestMatch: { sessionId: session.id, - seq: index, + seq: SessionSeq(index), type: 'user/message', time: 200 + index, surface: 'current', diff --git a/packages/api/session-controller/tests/session-skills.host.spec.ts b/packages/api/session-controller/tests/session-skills.host.spec.ts index 6fe4169b72..386d37e55c 100644 --- a/packages/api/session-controller/tests/session-skills.host.spec.ts +++ b/packages/api/session-controller/tests/session-skills.host.spec.ts @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import type {} from '@deepseek-ai/dsh-skill' import { describe, expect, it, vi } from 'vitest' @@ -18,9 +18,11 @@ function observation( version: 0, id: sessionId, createdAt: 1, + isSeeded: false, ...options.cwd === undefined ? {} : { cwd: options.cwd }, }, events, + inheritedEventCount: SessionLogOffset(0), cursor: -1, projections: { asOfSeq: -1, diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 899a3f7745..f91e574c77 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -1,7 +1,7 @@ /** Session object lifecycle, event-window transport, commands, and resync behavior. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { SessionSeq, 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' @@ -56,7 +56,7 @@ describe('Session open', () => { it('installs the tail page: cold → loading → open with window and nodes in place', async () => { const { api, session } = makeSession() - const page = plainTurn(10, 3, '问', '答') + const page = plainTurn(SessionSeq(10), 3, '问', '答') api.onHistory = () => histResponse(page, true) expect(session.getSnapshot().openState).toBe('cold') const opening = session.open() @@ -101,7 +101,7 @@ describe('Session open', () => { it('lands a packed live record in openState=error instead of crashing the stream loop', async () => { const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) await session.open() expect(session.getSnapshot().openState).toBe('open') @@ -136,10 +136,10 @@ describe('Session open', () => { api.onHistory = () => gate.promise const opening = session.open() // Three live frames land while the opening snapshot is pending; seq 15 overlaps its tail. - const page = plainTurn(10, 0, '早', '安') + const page = plainTurn(SessionSeq(10), 0, '早', '安') const deliveries = [ - follow(api, ev.turnStart(15, 1)), - follow(api, ev.user(16, '插进来的')), + follow(api, ev.turnStart(SessionSeq(15), 1)), + follow(api, ev.user(SessionSeq(16), '插进来的')), ] gate.resolve(ok({ records: entries(page) as never[], @@ -155,7 +155,7 @@ describe('Session open', () => { describe('live event path', () => { - async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) { + async function opened(events: SessionEvent[] = plainTurn(SessionSeq(0), 0, 'a', 'b')) { const { api, session } = makeSession() api.onHistory = () => histResponse(events) await session.open() @@ -165,7 +165,7 @@ describe('live event path', () => { it('drops replayed frames at or below the window tail', async () => { const { api, session } = await opened() const before = session.eventSource.getSnapshot() - await follow(api, ev.user(3, '重放')) + await follow(api, ev.user(SessionSeq(3), '重放')) expect(session.eventSource.getSnapshot()).toBe(before) }) @@ -173,8 +173,8 @@ describe('live event path', () => { const { api, session } = await opened([]) session.handleBlank(true) await Promise.all([ - follow(api, ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access')), - follow(api, ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access')), + follow(api, ev.commandRun(SessionSeq(0), 'cmd-perm', 'permission', ' danger-full-access')), + follow(api, ev.commandDone(SessionSeq(1), 'cmd-perm', 'success', 'preset danger-full-access')), ]) const snapshot = session.getSnapshot() expect(eventSeqs(session)).toEqual([0, 1]) @@ -182,11 +182,11 @@ describe('live event path', () => { }) it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => { - const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5 - const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')] + const { api, session } = await opened(plainTurn(SessionSeq(0), 0, 'a', 'b')) // tail seq = 5 + const repaired = [...plainTurn(SessionSeq(0), 0, 'a', 'b'), ...plainTurn(SessionSeq(6), 1, 'c', 'd')] api.onHistory = () => histResponse(repaired) // seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires. - await follow(api, ev.assistant(9, 1, 'd')) + await follow(api, ev.assistant(SessionSeq(9), 1, 'd')) await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(1) }) @@ -200,8 +200,8 @@ describe('live event path', () => { describe('paging', () => { it('prepends an older page and keeps seq continuity', async () => { - const older = plainTurn(0, 0, '旧问', '旧答') - const newer = plainTurn(6, 1, '新问', '新答') + const older = plainTurn(SessionSeq(0), 0, '旧问', '旧答') + const newer = plainTurn(SessionSeq(6), 1, '新问', '新答') const { api, session } = makeSession() api.onHistory = payload => payload.beforeSeq === undefined ? histResponse(newer, true) @@ -220,9 +220,9 @@ describe('paging', () => { it('installs a page without interpreting business replacement metadata', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse([ - ev.compactSummary(80, '窗外范围的摘要', 3, 40), - ev.compactCheckpoint(81, 80, 3, 40), - ev.user(82, '压缩后的新问题'), + ev.compactSummary(SessionSeq(80), '窗外范围的摘要', SessionSeq(3), SessionSeq(40)), + ev.compactCheckpoint(SessionSeq(81), SessionSeq(80), SessionSeq(3), SessionSeq(40)), + ev.user(SessionSeq(82), '压缩后的新问题'), ], true) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) try { @@ -239,8 +239,8 @@ describe('paging', () => { it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => { const { api, session } = makeSession() api.onHistory = payload => payload.beforeSeq === undefined - ? histResponse(plainTurn(10, 1, '新', '页'), true) - : histResponse(plainTurn(0, 0, '断', '层'), true) // tail seq 5, but baseSeq is 10 → hole + ? histResponse(plainTurn(SessionSeq(10), 1, '新', '页'), true) + : histResponse(plainTurn(SessionSeq(0), 0, '断', '层'), true) // tail seq 5, but baseSeq is 10 → hole const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) try { await session.open() @@ -255,9 +255,9 @@ 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 oldest = plainTurn(SessionSeq(0), 0, '最旧问', '最旧答') + const middle = plainTurn(SessionSeq(6), 1, '中问', '中答') + const newest = plainTurn(SessionSeq(12), 2, '新问', '新答') const { api, session } = makeSession() api.onHistory = (payload) => { if (payload.beforeSeq === undefined) return histResponse(newest, true) @@ -271,7 +271,7 @@ describe('paging', () => { void payload return gate.promise } - const jump = session.loadThrough(0) + const jump = session.loadThrough(SessionSeq(0)) expect(session.getSnapshot().loadingOlder).toBe(true) gate.resolve(ok(historyValue(middle, true))) await jump @@ -286,21 +286,21 @@ describe('paging', () => { 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 + await session.loadThrough(SessionSeq(0)) // cold: no-op expect(api.calls).toEqual([]) - api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true) + api.onHistory = () => histResponse(plainTurn(SessionSeq(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 + await session.loadThrough(SessionSeq(6)) // baseSeq is already 6 + await session.loadThrough(SessionSeq(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 oldest = plainTurn(SessionSeq(0), 0, 'a', 'b') + const middle = plainTurn(SessionSeq(6), 1, 'c', 'd') const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(12, 2, 'e', 'f'), true) + api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'e', 'f'), true) await session.open() const gate = deferred>>() @@ -308,25 +308,28 @@ describe('paging', () => { api.onHistory = () => histResponse(oldest, false) return gate.promise } - const first = session.loadThrough(6) - const second = session.loadThrough(0) + const first = session.loadThrough(SessionSeq(6)) + const second = session.loadThrough(SessionSeq(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(eventSeqs(session)).toEqual([ + ...[...oldest, ...middle].map(event => event.seq), + 12, 13, 14, 15, 16, 17, + ]) 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 middle = plainTurn(SessionSeq(6), 1, 'c', 'd') const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(12, 2, 'e', 'f'), true) + api.onHistory = () => histResponse(plainTurn(SessionSeq(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 + await session.loadThrough(SessionSeq(0)) // refused: must not park seq 0 anywhere gate.resolve(ok(historyValue(middle, true))) await older @@ -335,11 +338,11 @@ describe('paging', () => { 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)}`)], + [ev.user(SessionSeq(start), `u${String(start)}`), ev.user(SessionSeq(start + 1), `u${String(start + 1)}`), ev.user(SessionSeq(start + 2), `u${String(start + 2)}`)], start > 0, ) } - await session.loadThrough(4) + await session.loadThrough(SessionSeq(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)) @@ -349,16 +352,16 @@ describe('paging', () => { 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) + api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'x', 'y'), true) await session.open() const gate = deferred>>() api.onHistory = () => gate.promise - const jump = session.loadThrough(0) + const jump = session.loadThrough(SessionSeq(0)) // The address is rebuilt while the first page is in flight. - api.onHistory = () => histResponse(plainTurn(12, 2, 'x', 'y'), true) + api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'x', 'y'), true) const rebuilt = session.resync() - gate.resolve(ok(historyValue(plainTurn(6, 1, 'c', 'd'), true))) + gate.resolve(ok(historyValue(plainTurn(SessionSeq(6), 1, 'c', 'd'), true))) await jump await rebuilt // The stale loop must not page the new generation toward its old target: @@ -370,22 +373,22 @@ describe('paging', () => { 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(plainTurn(SessionSeq(12), 2, 'x', 'y'), true) : histResponse([], true) // empty page still claiming more history await session.open() - await session.loadThrough(0) + await session.loadThrough(SessionSeq(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) + api.onHistory = () => histResponse(plainTurn(SessionSeq(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) + await session.loadThrough(SessionSeq(0)) expect(errorSpy).toHaveBeenCalled() expect(session.getSnapshot().loadingOlder).toBe(false) } finally { @@ -395,14 +398,14 @@ describe('paging', () => { it('ignores loadOlder while one is in flight (single request)', async () => { const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true) + api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'x', 'y'), true) await session.open() const gate = deferred>>() api.onHistory = () => gate.promise const first = session.loadOlder() const second = session.loadOlder() gate.resolve(ok({ - records: entries(plainTurn(0, 0, 'a', 'b')) as never[], + records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, })) @@ -618,7 +621,7 @@ describe('rename', () => { expect(session.projections.faceOf('title').getSnapshot()).toBe('正名') // A stale lower-seq apply (the push-frame path routes into this same // store) must not roll the settled value back. - session.projections.apply('title', '旧名', 3) + session.projections.apply('title', '旧名', SessionSeq(3)) expect(session.projections.faceOf('title').getSnapshot()).toBe('正名') }) @@ -652,7 +655,7 @@ describe('remaining branches', () => { const { api, session } = makeSession() await session.loadOlder() // cold: no-op, zero calls expect(api.calls).toEqual([]) - api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true) + api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'x', 'y'), true) await session.open() // err result: window unchanged api.onHistory = () => Promise.resolve(err(new RemoteError('gateway/internal', 'x', {}))) @@ -671,7 +674,7 @@ describe('remaining branches', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) try { await session.resync() - api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true) + api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'x', 'y'), true) await session.resync() api.onHistory = () => Promise.reject(new Error('page wire down')) await session.loadOlder() @@ -684,7 +687,7 @@ describe('remaining branches', () => { it('subscribe delivers snapshot-change notifications and unsubscribes', async () => { const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) let notified = 0 const unsubscribe = session.subscribe(() => { notified++ }) await session.open() @@ -702,7 +705,7 @@ describe('remaining branches', () => { let call = 0 api.onHistory = () => { call++ - return histResponse(plainTurn(0, 0, 'a', 'b')) + return histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) } api.followCursor = 11 await session.open() @@ -726,17 +729,17 @@ describe('remaining branches', () => { it('drops live events while cold/error (no window upkeep)', async () => { const { api, session } = makeSession() - await follow(api, ev.user(0, '冷态帧')) + await follow(api, ev.user(SessionSeq(0), '冷态帧')) expect(eventSeqs(session)).toEqual([]) api.onHistory = () => Promise.resolve(err(new RemoteError('gateway/internal', 'x', {}))) await session.open() - await follow(api, ev.user(0, '错态帧')) + await follow(api, ev.user(SessionSeq(0), '错态帧')) expect(eventSeqs(session)).toEqual([]) }) it('preserves a Host-reported failure that terminates the live source', async () => { const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) await session.open() const failure = new RemoteError('session/not-found', 'session disappeared', { sessionId: SID }) @@ -750,7 +753,7 @@ describe('remaining branches', () => { it('coalesces queued gap frames behind one repair and exposes a failed repair', async () => { const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) await session.open() const gate = deferred>>() let repairs = 0 @@ -759,8 +762,8 @@ describe('remaining branches', () => { return gate.promise } const deliveries = Promise.all([ - follow(api, ev.user(9, '洞一')), - follow(api, ev.user(10, '洞二')), + follow(api, ev.user(SessionSeq(9), '洞一')), + follow(api, ev.user(SessionSeq(10), '洞二')), ]) await vi.waitFor(() => { expect(repairs).toBe(1) }) gate.reject(new RemoteError('gateway/internal', 'repair wire down', {})) @@ -775,7 +778,7 @@ describe('remaining branches', () => { const stale = deferred>>() api.onHistory = () => stale.promise const opening = session.open() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) const resynced = session.resync() stale.reject(new Error('stale wire')) await Promise.all([opening, resynced]) @@ -787,39 +790,39 @@ describe('remaining branches', () => { const stale = deferred>>() api.onHistory = () => stale.promise const opening = session.open() - api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代')) + api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, '新', '代')) const resynced = session.resync() stale.resolve(ok({ - records: entries(plainTurn(0, 0, '旧', '代')) as never[], + records: entries(plainTurn(SessionSeq(0), 0, '旧', '代')) as never[], hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'stale' }, })) // success, but its generation is gone await Promise.all([opening, resynced]) - expect(eventSeqs(session)).toEqual(plainTurn(6, 1, '新', '代').map(event => event.seq)) + expect(eventSeqs(session)).toEqual(plainTurn(SessionSeq(6), 1, '新', '代').map(event => event.seq)) }) it('drops a gap repair superseded by a full resync while its pull was in flight', async () => { const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) await session.open() const repairPull = deferred>>() api.onHistory = () => repairPull.promise - const delivery = follow(api, ev.user(9, '洞')) + const delivery = follow(api, ev.user(SessionSeq(9), '洞')) await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(1) }) - api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd')) + api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'c', 'd')) const resynced = session.resync() // bumps the generation repairPull.resolve(ok({ - records: entries(plainTurn(0, 0, '旧', '页')) as never[], + records: entries(plainTurn(SessionSeq(0), 0, '旧', '页')) as never[], hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'stale' }, })) // repair result: stale, dropped await Promise.all([delivery, resynced]) - expect(eventSeqs(session)).toEqual(plainTurn(6, 1, 'c', 'd').map(event => event.seq)) + expect(eventSeqs(session)).toEqual(plainTurn(SessionSeq(6), 1, 'c', 'd').map(event => event.seq)) }) it('successful cancel leaves no promptError', async () => { const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) await session.open() const result = await session.cancel() expect(result.ok).toBe(true) @@ -833,11 +836,11 @@ describe('remaining branches', () => { it('carries raw history and follow events through the event feed', async () => { const { api, session } = makeSession() - const historyCall = ev.toolCall(6, 1, 'h1', 'bash', '{"cmd":"pwd"}') - const historyResult = ev.toolResult(7, 1, 'h1', 'done') + const historyCall = ev.toolCall(SessionSeq(6), 1, 'h1', 'bash', '{"cmd":"pwd"}') + const historyResult = ev.toolResult(SessionSeq(7), 1, 'h1', 'done') api.onHistory = () => Promise.resolve(ok({ records: [ - ...entries(plainTurn(0, 0, 'a', 'b')), + ...entries(plainTurn(SessionSeq(0), 0, 'a', 'b')), { type: 'event', event: historyCall }, { type: 'event', event: historyResult }, ] as never[], @@ -849,10 +852,10 @@ describe('remaining branches', () => { { type: 'event', event: historyCall }, { type: 'event', event: historyResult }, ]) - const liveCall = ev.toolCall(8, 2, 'l1', 'write', '{"file_path":"a.ts"}') + const liveCall = ev.toolCall(SessionSeq(8), 2, 'l1', 'write', '{"file_path":"a.ts"}') await follow(api, liveCall) expect(windowEntries(session).at(-1)).toEqual({ type: 'event', event: liveCall }) - const liveResult = ev.toolResult(9, 2, 'l1', 'ok') + const liveResult = ev.toolResult(SessionSeq(9), 2, 'l1', 'ok') await follow(api, liveResult) expect(windowEntries(session).at(-1)).toEqual({ type: 'event', event: liveResult }) }) @@ -861,7 +864,7 @@ describe('remaining branches', () => { describe('resync', () => { it('keeps the old feed until the reconnect snapshot, then repairs queued live gaps', async () => { const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, '旧', '窗')) + api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, '旧', '窗')) await session.open() const oldWindow = session.eventSource.getSnapshot() const replacement = deferred>>() @@ -878,17 +881,17 @@ describe('resync', () => { expect(publications).toEqual([]) api.onHistory = () => histResponse([ - ...plainTurn(10, 2, '终', '页'), - ev.user(16, '后到低位'), - ev.user(17, '后到高位'), + ...plainTurn(SessionSeq(10), 2, '终', '页'), + ev.user(SessionSeq(16), '后到低位'), + ev.user(SessionSeq(17), '后到高位'), ]) const liveDeliveries = Promise.all([ - follow(api, ev.user(17, '后到高位')), - follow(api, ev.user(16, '后到低位')), + follow(api, ev.user(SessionSeq(17), '后到高位')), + follow(api, ev.user(SessionSeq(16), '后到低位')), ]) expect(session.eventSource.getSnapshot()).toBe(oldWindow) replacement.resolve(ok({ - records: entries(plainTurn(10, 2, '终', '页')) as never[], + records: entries(plainTurn(SessionSeq(10), 2, '终', '页')) as never[], hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, })) @@ -906,11 +909,11 @@ describe('resync', () => { it('rebuilds the window without clearing control state; cold instances no-op', async () => { const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) await session.open() session.handleRunning(true) session.handleAgentError('still visible') - api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) + api.onHistory = () => histResponse([...plainTurn(SessionSeq(0), 0, 'a', 'b'), ...plainTurn(SessionSeq(6), 1, 'c', 'd')]) await session.resync() const snapshot = session.getSnapshot() expect(snapshot.openState).toBe('open') @@ -928,14 +931,14 @@ describe('resync', () => { const stale = deferred>>() api.onHistory = () => stale.promise const firstOpen = session.open() - api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代')) + api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, '新', '代')) const resynced = session.resync() stale.reject(new Error('dead connection')) // the doomed pre-disconnect request fails late await firstOpen await resynced const snapshot = session.getSnapshot() expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error - expect(eventSeqs(session)).toEqual(plainTurn(6, 1, '新', '代').map(event => event.seq)) + expect(eventSeqs(session)).toEqual(plainTurn(SessionSeq(6), 1, '新', '代').map(event => event.seq)) }) }) @@ -943,12 +946,12 @@ describe('resync', () => { describe('snapshot ownership', () => { it('publishes event-window appends without changing an unrelated Session snapshot', async () => { const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定')) + api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, '稳', '定')) await session.open() const sessionBefore = session.getSnapshot() const windowBefore = session.eventSource.getSnapshot() const firstEntry = windowBefore.entries[0] - await follow(api, ev.user(6, '追加')) + await follow(api, ev.user(SessionSeq(6), '追加')) const windowAfter = session.eventSource.getSnapshot() expect(session.getSnapshot()).toBe(sessionBefore) expect(windowAfter).not.toBe(windowBefore) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 3d7d549a26..2794e52ffe 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -2,6 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' import { SessionPersistenceCorruptionError, @@ -111,15 +112,22 @@ export function testSessionPersistence( signal?.throwIfAborted() if (inspection === undefined) throw new SessionPersistenceNotFoundError(sessionId) try { + const inheritedEventCount = (inspection as Partial).inheritedEventCount + if (inspection.meta.isSeeded && inheritedEventCount === undefined) { + throw new Error('seeded test persistence must provide inheritedEventCount') + } + const cut = SessionLogOffset(inheritedEventCount ?? 0) const preparedSession = ctx.sessions.prepare(inspection.meta.id, { seed: [...inspection.events], meta: inspection.meta, + inheritedEventCount: cut, seedSource: 'persistence', }) return { source: 'prepared', inspection: { meta: preparedSession.header, + inheritedEventCount: preparedSession.inheritedEventCount, events: Object.freeze([...inspection.events]), }, revision: SessionPersistenceRevision(`test:${sessionId}:${String(preparedSession.seq)}`), diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 91bdcb67ee..b6eeda6b41 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { createScope } from '@deepseek-ai/dsh-scope' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SurfaceIntent } from '@deepseek-ai/dsh-session' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' @@ -15,7 +15,7 @@ function append( session: Session, type: string, data: unknown, - options?: { readonly surfaceOp?: unknown; readonly sourceEventSeqs?: readonly number[] }, + options?: Partial, ): SessionEvent { return (session.append as unknown as ( eventType: string, @@ -24,7 +24,7 @@ function append( ) => SessionEvent)(type, data, options) } -function event(type: string, seq: number, data: unknown = {}): SessionEvent { +function event(type: string, seq: SessionSeq, data: unknown = {}): SessionEvent { return { type, seq, @@ -38,6 +38,7 @@ function eventSession(header: SessionHeader, events: readonly SessionEvent[]): S return { id: header.id, header, + inheritedEventCount: SessionLogOffset(0), seq: events.length, eventAt: (seq: number) => events[seq], snapshotEvents: (fromSeq = 0, toSeqExclusive = events.length) => events.slice(fromSeq, toSeqExclusive), @@ -49,9 +50,14 @@ function cold( header: SessionHeader, events: readonly SessionEvent[], ): void { + if (header.isSeeded) throw new Error('seeded cold fixtures require an explicit inherited cut') ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: () => Promise.resolve([header]), - inspect: () => Promise.resolve({ meta: header, events }), + inspect: () => Promise.resolve({ + meta: header, + inheritedEventCount: SessionLogOffset(0), + events, + }), }) as never) } @@ -164,8 +170,18 @@ describe('SessionHistoryController', () => { it('subscribes before a cold read and ignores unrelated and replayed buffered events', async () => { const { ctx, transport } = await setup() const sessionId = SessionId('cold-race') - const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } - const inspected = deferred<{ meta: SessionHeader; events: readonly SessionEvent[] }>() + const header = { + version: 0, + id: sessionId, + createdAt: 1, + cwd: '/workspace', + isSeeded: false, + } + const inspected = deferred<{ + meta: SessionHeader + inheritedEventCount: SessionLogOffset + events: readonly SessionEvent[] + }>() ctx.provide('sessionPersistence', testSessionPersistence(ctx, { inspect: () => inspected.promise, }) as never) @@ -174,11 +190,15 @@ describe('SessionHistoryController', () => { [Symbol.asyncIterator]() const opening = iterator.next() - const unrelated = event('fixture/other', 0) - const start = event('fixture/start', 0) + const unrelated = event('fixture/other', SessionSeq(0)) + const start = event('fixture/start', SessionSeq(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)] }) + inspected.resolve({ + meta: header, + inheritedEventCount: SessionLogOffset(0), + events: [event('fixture/start', SessionSeq(0))], + }) await expect(opening).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) const waiting = iterator.next() @@ -190,7 +210,13 @@ describe('SessionHistoryController', () => { const ctx = new Context() await ctx.plugin(SessionStore) const sessionId = SessionId('created-during-observation') - const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + const header = { + version: 0, + id: sessionId, + createdAt: 1, + cwd: '/workspace', + isSeeded: false, + } const observed = deferred() ctx.provide('sessionQuery', { observeSession: () => observed.promise } as never) const transport = new SessionHistoryController(ctx, vi.fn()) @@ -199,7 +225,7 @@ describe('SessionHistoryController', () => { [Symbol.asyncIterator]() const opening = iterator.next() - const attached = ctx.sessions.create(sessionId, { meta: header, seed: [event('fixture/seed', 0)] }) + const attached = ctx.sessions.create(sessionId, { meta: header, seed: [event('fixture/seed', SessionSeq(0))] }) observed.resolve({ source: 'live', header: attached.header, @@ -242,8 +268,14 @@ describe('SessionHistoryController', () => { { inject: ['sessions'] }, )) const sessionId = SessionId('cold-attach') - const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } - const seed = [event('fixture/start', 0)] + const header = { + version: 0, + id: sessionId, + createdAt: 1, + cwd: '/workspace', + isSeeded: false, + } + const seed = [event('fixture/start', SessionSeq(0))] cold(ctx, header, seed) agentCtx.on('session/created', (session) => { if (session.id !== sessionId) return @@ -284,8 +316,14 @@ describe('SessionHistoryController', () => { it('rejects gaps in replayed and live event sequences', async () => { const replay = await setup() const replayId = SessionId('replay-gap') - const replayHeader = { version: 0, id: replayId, createdAt: 1, cwd: '/workspace' } - cold(replay.ctx, replayHeader, [event('fixture/start', 0), event('fixture/gap', 2)]) + const replayHeader = { + version: 0, + id: replayId, + createdAt: 1, + cwd: '/workspace', + isSeeded: false, + } + cold(replay.ctx, replayHeader, [event('fixture/start', SessionSeq(0)), event('fixture/gap', SessionSeq(2))]) const replayed = replay.transport.follow({ address: { kind: 'session', sessionId: replayId }, }, signal())[Symbol.asyncIterator]() @@ -299,9 +337,12 @@ describe('SessionHistoryController', () => { address: { kind: 'session', sessionId: session.id }, }, signal())[Symbol.asyncIterator]() 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', eventSession(session.header, [event('fixture/start', 0), skipped, gap]), gap) + const skipped = event('fixture/skipped', SessionSeq(1)) + const gap = event('fixture/gap', SessionSeq(2)) + live.ctx.emit('session/event', eventSession( + session.header, + [event('fixture/start', SessionSeq(0)), skipped, gap], + ), gap) await expect(followed.next()).rejects.toMatchObject({ code: 'gateway/internal' }) }) @@ -324,10 +365,20 @@ describe('SessionHistoryController', () => { const ctx = new Context() await ctx.plugin(SessionStore) const sessionId = SessionId('projectionless-follow') - const meta = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + const meta = { + version: 0, + id: sessionId, + createdAt: 1, + cwd: '/workspace', + isSeeded: false, + } ctx.provide('sessionQuery', { observeSession: () => Promise.resolve({ - source: 'live', header: meta, events: [], cursor: -1, + source: 'live', + header: meta, + inheritedEventCount: SessionLogOffset(0), + events: [], + cursor: -1, retain: vi.fn(), [Symbol.dispose]: vi.fn(), } satisfies SessionObservation), } as never) @@ -438,8 +489,10 @@ describe('SessionHistoryController', () => { const address = { kind: 'session' as const, sessionId: session.id } for (const request of [ { address, throughSeq: -2 }, + { address, throughSeq: -0 }, { address, throughSeq: 0.5 }, { address, throughSeq: -1, beforeSeq: -1 }, + { address, throughSeq: -1, beforeSeq: -0 }, { address, throughSeq: -1, beforeSeq: 1.5 }, { address, throughSeq: -1, maxMessages: 0 }, { address, throughSeq: -1, maxMessages: 1.5 }, @@ -453,8 +506,8 @@ describe('SessionHistoryController', () => { const corruptId = SessionId('missing-through-seq') cold( corrupt.ctx, - { version: 0, id: corruptId, createdAt: 1, cwd: '/workspace' }, - [event('fixture/start', 0), event('fixture/gap', 2)], + { version: 0, id: corruptId, createdAt: 1, cwd: '/workspace', isSeeded: false }, + [event('fixture/start', SessionSeq(0)), event('fixture/gap', SessionSeq(2))], ) await expect(corrupt.transport.page({ address: { kind: 'session', sessionId: corruptId }, throughSeq: 1, @@ -494,20 +547,28 @@ describe('SessionHistoryController', () => { const first = await setup() const sessionId = SessionId('incomplete') const address = { kind: 'session' as const, sessionId } - const firstHeader = { version: 0, id: sessionId, createdAt: 1 } + const firstHeader = { version: 0, id: sessionId, createdAt: 1, isSeeded: false } first.ctx.provide('sessionPersistence', testSessionPersistence(first.ctx, { list: () => Promise.resolve([firstHeader]), - inspect: () => Promise.resolve({ meta: firstHeader, events: [] }), + inspect: () => Promise.resolve({ + meta: firstHeader, + inheritedEventCount: SessionLogOffset(0), + events: [], + }), }) as never) await expect(first.transport.page({ address, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'session/not-found' }) const second = await setup() - const listed = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } - const inspected = { version: 0, id: sessionId, createdAt: 1 } + const listed = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace', isSeeded: false } + const inspected = { version: 0, id: sessionId, createdAt: 1, isSeeded: false } second.ctx.provide('sessionPersistence', testSessionPersistence(second.ctx, { list: () => Promise.resolve([listed]), - inspect: () => Promise.resolve({ meta: inspected, events: [] }), + inspect: () => Promise.resolve({ + meta: inspected, + inheritedEventCount: SessionLogOffset(0), + events: [], + }), }) as never) await expect(second.transport.page({ address, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'session/not-found' }) @@ -516,8 +577,14 @@ describe('SessionHistoryController', () => { it('serves cold ordinary history and validates every durable subagent descriptor state', async () => { const ordinaryBench = await setup() const ordinaryId = SessionId('cold-ordinary') - const ordinaryHeader = { version: 0, id: ordinaryId, createdAt: 1, cwd: '/workspace' } - cold(ordinaryBench.ctx, ordinaryHeader, [event('turn/start', 0, { turn: 1 })]) + const ordinaryHeader = { + version: 0, + id: ordinaryId, + createdAt: 1, + cwd: '/workspace', + isSeeded: false, + } + cold(ordinaryBench.ctx, ordinaryHeader, [event('turn/start', SessionSeq(0), { turn: 1 })]) await expect(ordinaryBench.transport.page({ address: { kind: 'session', sessionId: ordinaryId }, throughSeq: 0, @@ -532,6 +599,7 @@ describe('SessionHistoryController', () => { id: childSessionId, createdAt: 1, cwd: '/workspace', + isSeeded: false, origin: 'subagent' as const, parentSession: parentSessionId, } @@ -547,7 +615,7 @@ describe('SessionHistoryController', () => { .rejects.toMatchObject({ code: 'subagent/catalog-diagnostic', details: { reason: 'corrupt' } }) const corrupt = await setup() - cold(corrupt.ctx, childHeader, [event('subagent/descriptor', 0, { version: 'bad' })]) + cold(corrupt.ctx, childHeader, [event('subagent/descriptor', SessionSeq(0), { version: 'bad' })]) await expect(corrupt.transport.page({ address: childAddress, throughSeq: 0 }, signal())) .rejects.toMatchObject({ code: 'subagent/catalog-diagnostic', details: { reason: 'corrupt' } }) @@ -568,12 +636,17 @@ describe('SessionHistoryController', () => { id: childSessionId, createdAt: 1, cwd: '/workspace', + isSeeded: false, origin: 'subagent', parentSession: parentSessionId, } ctx.provide('sessionQuery', { observeSession: () => Promise.resolve({ - source: 'live', header: meta, events: [], cursor: -1, + source: 'live', + header: meta, + inheritedEventCount: SessionLogOffset(0), + events: [], + cursor: -1, projections: { asOfSeq: -1, values: {} }, retain: vi.fn(), [Symbol.dispose]: vi.fn(), } as unknown as SessionObservation), @@ -629,8 +702,8 @@ describe('SessionHistoryController', () => { append(session, 'assistant/message', { turn: 1, step: 2, message: {} }, { surfaceOp: 'append' }) const summary = append(session, 'fixture/summary', {}) const replacement = append(session, 'user/message', { content: [], source: { kind: 'plugin' } }, { - surfaceOp: { op: 'replace', start: 1, end: 4 }, - sourceEventSeqs: [1, firstReply.seq, 3, 4, summary.seq], + surfaceOp: { op: 'replace', start: SessionSeq(1), end: SessionSeq(4) }, + sourceEventSeqs: [SessionSeq(1), firstReply.seq, SessionSeq(3), SessionSeq(4), summary.seq], }) const page = await transport.page({ diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 5f2ad0e4b7..152576c67d 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -17,7 +17,8 @@ import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { assertNever } from '@deepseek-ai/dsh-util-values' -import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId, SessionLogOffset } 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' @@ -60,13 +61,13 @@ export const internals: { stdout: HeadlessIo['stdout']; stderr: HeadlessIo['stde } /** Aggregate the last assistant text and turn outcome in one owned interval. */ -function summarize(session: Session, firstSeq: number): RunOutcome { +function summarize(session: Session, firstSeq: SessionLogOffset): RunOutcome { let started = false let text = '' let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined const length = session.seq for (let seq = firstSeq; seq < length; seq++) { - const event = session.eventAt(seq) + const event = session.eventAt(SessionSeq(seq)) if (event === undefined) { throw new Error(`headless summary cannot read seq ${String(seq)} below captured length ${String(length)}`) } diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index c79b8caf20..dd5452015c 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -19,9 +19,9 @@ import type { import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionEvent, - SessionHeader, SessionId, } from '@deepseek-ai/dsh-session/types' +import { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import { isChunkRow, packChunkRuns } from '@deepseek-ai/dsh-session/chunk-rows' import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' @@ -135,10 +135,22 @@ interface FixturePageRequest { readonly maxMessages?: number } +interface FixtureSessionWireHeader { + readonly version: number + readonly id: SessionId + readonly createdAt: number + readonly cwd?: string + readonly parentSession?: SessionId + readonly seedLength?: number + readonly origin?: 'subagent' + readonly delegationDepth?: number + readonly agentPreset?: string +} + type FixtureFollowFrame = | { readonly type: 'snapshot' - readonly header: SessionHeader + readonly header: FixtureSessionWireHeader readonly cursor: number readonly records: readonly FixtureHistoryRecord[] readonly hasMore: boolean @@ -2048,7 +2060,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } const append = (id: SessionId, e: Record): void => { const log = logOf(id) - const event = { seq: log.length, time: Date.now(), ...e } as unknown as SessionEvent + const event = { seq: SessionSeq(log.length), time: Date.now(), ...e } as unknown as SessionEvent log.push(event) emitFollow(id, { type: 'event', event }) // Host eager-drive parallel: a unit-advancing event pushes its finished value. @@ -2672,7 +2684,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { /** Log append without follow delivery: a frame lost in transit that page repair must recover. */ appendSilent(id: string, msg: string): void { const log = logOf(sid(id)) - log.push({ type: 'user/message', surfaceOp: 'append', seq: log.length, time: Date.now(), data: userMessage(text(msg)) } as unknown as SessionEvent) + log.push({ type: 'user/message', surfaceOp: 'append', seq: SessionSeq(log.length), time: Date.now(), data: userMessage(text(msg)) } as unknown as SessionEvent) }, /** End every open stream generator (client sees both streams close -> reconnect + resync path). */ breakStreams(): void { diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index 7b5cbf0eca..ca80aaaa6c 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -9,6 +9,7 @@ import type { import { RpcId } from '../src/client/api.ts' import { decodeStorageRecord } from '@deepseek-ai/dsh-session/chunk-rows' import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' +import { SessionSeq } from '@deepseek-ai/dsh-session/types' import { createFixtureConnectionRpc, createFixtureFaces, @@ -72,11 +73,11 @@ function historyEvents(records: readonly FixtureHistoryRecord[]): SessionEvent[] function chunkRow(event: FixtureChunkRowEvent): ChunkRow { switch (event.type) { case 'chunkrow/text-chunks': - return { type: 'text-chunks', seq0: event.seq, time0: event.time, data: event.data } + return { type: 'text-chunks', seq0: SessionSeq(event.seq), time0: event.time, data: event.data } case 'chunkrow/reasoning-chunks': - return { type: 'reasoning-chunks', seq0: event.seq, time0: event.time, data: event.data } + return { type: 'reasoning-chunks', seq0: SessionSeq(event.seq), time0: event.time, data: event.data } case 'chunkrow/tool-call-chunks': - return { type: 'tool-call-chunks', seq0: event.seq, time0: event.time, data: event.data } + return { type: 'tool-call-chunks', seq0: SessionSeq(event.seq), time0: event.time, data: event.data } } } diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 42aada1017..75ff66b500 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import type { ConversationTimelineSnapshot, RenderMessageImages, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' @@ -304,7 +305,7 @@ export function ChatView({ * 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 pendingJumpRef = useRef<{ turn: number; seq: SessionSeq } | null>(null) /** Whether the in-flight jump already landed mid-paging (settle then only corrects an untouched landing). */ const jumpLandedRef = useRef(false) const [busyJumpTurn, setBusyJumpTurn] = useState(null) 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 98089e83c3..b1ab1e390a 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 @@ -7,6 +7,7 @@ */ import type {} from '@deepseek-ai/dsh-session-turn-outline/client' +import { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { TurnNavigationItem } from '../contract/snapshot.ts' /** One rail mark: a loaded Turn scrolls to its row; an unloaded one pages history through its seq first. */ @@ -19,7 +20,7 @@ export interface TurnRailItem { /** How the rail reaches the Turn. */ readonly anchor: | { readonly kind: 'loaded'; readonly key: string } - | { readonly kind: 'unloaded'; readonly seq: number } + | { readonly kind: 'unloaded'; readonly seq: SessionSeq } } const EMPTY_ITEMS: readonly TurnRailItem[] = [] @@ -31,14 +32,14 @@ const EMPTY_ITEMS: readonly TurnRailItem[] = [] * 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 { +function outlineEntry(value: unknown): { turn: number; seq: SessionSeq; 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.seq !== 'number' || !Number.isSafeInteger(entry.seq) || entry.seq < 0 || Object.is(entry.seq, -0)) return undefined return { turn: entry.turn, - seq: entry.seq, + seq: SessionSeq(entry.seq), prompt: typeof entry.prompt === 'string' ? entry.prompt : '', response: typeof entry.response === 'string' ? entry.response : '', } diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 14fe308321..2ff7122ea2 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -1,5 +1,6 @@ /** Chat-owned Slot declarations and composed component props. */ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { ConversationTurnDataMap, MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -119,7 +120,7 @@ export interface ChatViewInjected { openFile: (path: string) => Promise loadOlder: () => void /** Jump loader: page history back through seq; resolves when the window covers it. */ - loadThrough: (seq: number) => Promise + loadThrough: (seq: SessionSeq) => Promise loadImage: MessageImageLoader chatScroll: { save: (position: ChatScrollPosition | null) => void 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 98b477c57f..fa7cb20093 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -15,7 +15,7 @@ import { import { apply as applyChat, inject as injectChat, type ChatViewInjected, type DetailsInjected, } from '@deepseek-ai/dsh-client-ui-chat/client' -import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import { createChatStore } from '../src/client/stores.ts' usePinnedBrowserLanguages('zh-CN') @@ -93,7 +93,7 @@ describe('Chat inject API', () => { injected.loadOlder() expect(b.session.loadOlder).toHaveBeenCalledOnce() - void injected.loadThrough(42) + void injected.loadThrough(SessionSeq(42)) expect(b.session.loadThrough).toHaveBeenCalledWith(42) injected.forkAt(17) 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 8f9dee2b68..289ff3c0fd 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 @@ -62,6 +62,8 @@ describe('mergeTurnRailItems', () => { const items = mergeTurnRailItems([], [ { turn: -1, seq: 0, prompt: 'negative turn', response: '' }, { turn: 2, seq: 0.5, prompt: 'fractional seq', response: '' }, + { turn: 4, seq: -0, prompt: 'negative zero seq', response: '' }, + { turn: 5, seq: Number.MAX_SAFE_INTEGER + 1, prompt: 'unsafe seq', response: '' }, { turn: 3, seq: 4, prompt: 5, response: 6 }, { turn: 6, seq: 7, prompt: 'kept', response: 8 }, null, diff --git a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index 2c5fa7c17c..0e95e83465 100644 --- a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -4,6 +4,7 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/client' import type { ChunkRowEvent } from '@deepseek-ai/dsh-api-session-controller/types' import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' +import { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { ConversationNodeAssembler as RuntimeConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { @@ -114,7 +115,7 @@ function trackedView(target: string) { return { definition, create, replace, apply } } -function at(seq: number, type: string, data: unknown): SessionEvent { +function at(seq: SessionSeq, type: string, data: unknown): SessionEvent { return { seq, time: 1_700_000_000_000 + seq, type, data } as SessionEvent } @@ -210,7 +211,7 @@ describe('ConversationNodeAssembler', () => { new TestViewDefinitions([chat.definition, trajectory.definition]), ) - assembler.replaceWindow([input(at(1, 'active/start', {}))], false) + assembler.replaceWindow([input(at(SessionSeq(1), 'active/start', {}))], false) expect(assembler.flush()).toBe(false) expect(chat.create).not.toHaveBeenCalled() expect(trajectory.create).not.toHaveBeenCalled() @@ -223,7 +224,7 @@ describe('ConversationNodeAssembler', () => { expect(buildChat).toHaveBeenCalledOnce() expect(buildTrajectory).not.toHaveBeenCalled() - assembler.append(input(at(2, 'active/update', {}))) + assembler.append(input(at(SessionSeq(2), 'active/update', {}))) expect(assembler.flush()).toBe(true) expect(chat.apply).toHaveBeenCalledOnce() expect(trajectory.apply).not.toHaveBeenCalled() @@ -234,7 +235,7 @@ describe('ConversationNodeAssembler', () => { expect((assembler.snapshot('trajectory') as readonly ConversationViewNode[]) .map(node => node.data)).toEqual([{ updates: 1 }]) - assembler.append(input(at(3, 'active/update', {}))) + assembler.append(input(at(SessionSeq(3), 'active/update', {}))) expect(assembler.flush()).toBe(true) expect(chat.apply).toHaveBeenCalledTimes(2) expect(trajectory.apply).toHaveBeenCalledOnce() @@ -270,13 +271,13 @@ describe('ConversationNodeAssembler', () => { new TestViewDefinitions([testView()]), ) assembler.replaceWindow([ - input(at(1, 'tool/call', { turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}' })), - input(at(2, 'tool/call', { turn: 1, step: 1, callId: 'b', name: 'x', arguments: '{}' })), + input(at(SessionSeq(1), 'tool/call', { turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}' })), + input(at(SessionSeq(2), 'tool/call', { turn: 1, step: 1, callId: 'b', name: 'x', arguments: '{}' })), ], false) assembler.flush() starts.mockClear() - assembler.append(input(at(3, 'tool/result', { + assembler.append(input(at(SessionSeq(3), 'tool/result', { turn: 1, step: 1, message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false }, @@ -321,11 +322,11 @@ describe('ConversationNodeAssembler', () => { new TestEventDefinitions([definition]), new TestViewDefinitions([testView()]), ) - assembler.replaceWindow([input(at(1, 'linear/start', {}))], false) + assembler.replaceWindow([input(at(SessionSeq(1), 'linear/start', {}))], false) starts.mockClear() for (let seq = 2; seq <= 1_001; seq++) { - assembler.append(input(at(seq, 'linear/update', {}))) + assembler.append(input(at(SessionSeq(seq), 'linear/update', {}))) } assembler.flush() @@ -395,15 +396,15 @@ describe('ConversationNodeAssembler', () => { } const run = chunkInput({ type: 'text-chunks', - seq0: 12, + seq0: SessionSeq(12), time0: 1_700_000_000_012, data: { turn: 2, step: 3, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] }, }) const inputs: SessionEventLikeEntry[] = [ - input(at(10, 'step/start', { turn: 2, step: 3 })), - input(at(11, 'probe/update', { turn: 2, step: 3 })), + input(at(SessionSeq(10), 'step/start', { turn: 2, step: 3 })), + input(at(SessionSeq(11), 'probe/update', { turn: 2, step: 3 })), run, - input(at(15, 'probe/update', { turn: 2, step: 3 })), + input(at(SessionSeq(15), 'probe/update', { turn: 2, step: 3 })), ] const assembler = new ConversationNodeAssembler( new TestEventDefinitions([definition, passive]), @@ -430,7 +431,7 @@ describe('ConversationNodeAssembler', () => { ], }) - assembler.append(input(at(16, 'step/end', { turn: 2, step: 3 }))) + assembler.append(input(at(SessionSeq(16), 'step/end', { turn: 2, step: 3 }))) assembler.flush() expect(updates.mock.calls.filter(([, match]) => ( @@ -493,7 +494,7 @@ describe('ConversationNodeAssembler', () => { ) const run = chunkInput({ type: 'reasoning-chunks', - seq0: 21, + seq0: SessionSeq(21), time0: 1_700_000_000_021, data: { turn: 4, step: 5, index: 0, dt: [0, -1], texts: ['', ' ', 'x'] }, }) @@ -506,7 +507,7 @@ describe('ConversationNodeAssembler', () => { expect(testSnapshot(assembler)?.order).toEqual([]) assembler.prepend([ - input(at(20, 'step/start', { turn: 4, step: 5 })), + input(at(SessionSeq(20), 'step/start', { turn: 4, step: 5 })), ], false) assembler.flush() @@ -534,7 +535,7 @@ describe('ConversationNodeAssembler', () => { ) const run = chunkInput({ type: 'text-chunks', - seq0: 1, + seq0: SessionSeq(1), time0: 1_700_000_000_001, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] }, }) @@ -567,7 +568,7 @@ describe('ConversationNodeAssembler', () => { new TestViewDefinitions([testView()]), ) const current = Array.from({ length: 100 }, (_, index) => ( - input(at(index + 102, 'linear/update', {})) + input(at(SessionSeq(index + 102), 'linear/update', {})) )) assembler.replaceWindow(current, true) assembler.flush() @@ -575,9 +576,9 @@ describe('ConversationNodeAssembler', () => { expect(updates).not.toHaveBeenCalled() const older = [ - input(at(1, 'linear/start', {})), + input(at(SessionSeq(1), 'linear/start', {})), ...Array.from({ length: 100 }, (_, index) => ( - input(at(index + 2, 'linear/update', {})) + input(at(SessionSeq(index + 2), 'linear/update', {})) )), ] assembler.prepend(older, false) @@ -606,7 +607,7 @@ describe('ConversationNodeAssembler', () => { new TestEventDefinitions([definition]), new TestViewDefinitions([testView()]), ) - assembler.replaceWindow([input(at(10, 'tool/result', { + assembler.replaceWindow([input(at(SessionSeq(10), 'tool/result', { turn: 1, step: 1, message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false }, @@ -615,7 +616,7 @@ describe('ConversationNodeAssembler', () => { expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data) .toEqual({ pendingStart: true }) - assembler.prepend([input(at(5, 'tool/call', { + assembler.prepend([input(at(SessionSeq(5), 'tool/call', { turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}', }))], false) assembler.flush() @@ -642,8 +643,8 @@ describe('ConversationNodeAssembler', () => { ) expect(() => assembler.replaceWindow([ - input(at(1, 'turn/start', { turn: 1 })), - input(at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } })), + input(at(SessionSeq(1), 'turn/start', { turn: 1 })), + input(at(SessionSeq(2), 'turn/end', { turn: 1, reason: { kind: 'completed' } })), ], false)).toThrow('received an update before its start Match') }) @@ -677,13 +678,13 @@ describe('ConversationNodeAssembler', () => { new TestEventDefinitions([source, consumer]), new TestViewDefinitions([testView()]), ) - assembler.replaceWindow([input(at(10, 'assistant/message', { + assembler.replaceWindow([input(at(SessionSeq(10), 'assistant/message', { turn: 2, step: 1, message: { role: 'assistant', content: [] }, }))], true) assembler.flush() expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1) - assembler.prepend([input(at(5, 'user/message', { + assembler.prepend([input(at(SessionSeq(5), 'user/message', { id: 'm1', value: 7, content: [], source: { kind: 'user' }, }))], false) assembler.flush() @@ -718,22 +719,22 @@ describe('ConversationNodeAssembler', () => { new TestViewDefinitions([testView()]), ) assembler.replaceWindow([ - input(at(40, 'user/message', { id: 'm40', content: [], source: { kind: 'user' } })), - input(at(50, 'assistant/message', { + input(at(SessionSeq(40), 'user/message', { id: 'm40', content: [], source: { kind: 'user' } })), + input(at(SessionSeq(50), 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] }, })), ], true) assembler.flush() assembler.prepend([ - input(at(10, 'user/message', { id: 'm10', content: [], source: { kind: 'user' } })), - input(at(30, 'user/message', { id: 'm30', content: [], source: { kind: 'user' } })), + input(at(SessionSeq(10), 'user/message', { id: 'm10', content: [], source: { kind: 'user' } })), + input(at(SessionSeq(30), 'user/message', { id: 'm30', content: [], source: { kind: 'user' } })), ], false) assembler.flush() - assembler.append(input(at(60, 'user/message', { + assembler.append(input(at(SessionSeq(60), 'user/message', { id: 'm60', content: [], source: { kind: 'user' }, }))) - assembler.append(input(at(70, 'assistant/message', { + assembler.append(input(at(SessionSeq(70), 'assistant/message', { turn: 2, step: 1, message: { role: 'assistant', content: [] }, }))) assembler.flush() @@ -762,7 +763,7 @@ describe('ConversationNodeAssembler', () => { new TestEventDefinitions([consumer]), new TestViewDefinitions([testView()]), ) - assembler.replaceWindow([input(at(10, 'assistant/message', { + assembler.replaceWindow([input(at(SessionSeq(10), 'assistant/message', { turn: 2, step: 1, message: { role: 'assistant', content: [] }, }))], true) assembler.flush() @@ -807,12 +808,12 @@ describe('ConversationNodeAssembler', () => { new TestViewDefinitions([testView()]), ) assembler.replaceWindow([ - input(at(1, 'user/message', { id: 'source', content: [], source: { kind: 'user' } })), - input(at(2, 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })), + input(at(SessionSeq(1), 'user/message', { id: 'source', content: [], source: { kind: 'user' } })), + input(at(SessionSeq(2), 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })), ], false) assembler.flush() - expect(assembler.append(input(at(3, 'source/update', { value: 2 })))).toBe('immediate') + expect(assembler.append(input(at(SessionSeq(3), 'source/update', { value: 2 })))).toBe('immediate') assembler.flush() expect(consumerStart).toHaveBeenCalledTimes(2) @@ -875,14 +876,14 @@ describe('ConversationNodeAssembler', () => { new TestViewDefinitions([testView()]), ) assembler.replaceWindow([ - input(at(1, 'user/message', { id: 'source', content: [], source: { kind: 'user' } })), - input(at(2, 'turn/start', { turn: 1 })), - input(at(3, 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })), - input(at(4, 'tool/call', { turn: 1, step: 1, callId: 'call', name: 'x', arguments: '{}' })), + input(at(SessionSeq(1), 'user/message', { id: 'source', content: [], source: { kind: 'user' } })), + input(at(SessionSeq(2), 'turn/start', { turn: 1 })), + input(at(SessionSeq(3), 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })), + input(at(SessionSeq(4), 'tool/call', { turn: 1, step: 1, callId: 'call', name: 'x', arguments: '{}' })), ], false) - assembler.append(input(at(5, 'diamond/x', { value: 20 }))) - assembler.append(input(at(6, 'diamond/a', { value: 2 }))) + assembler.append(input(at(SessionSeq(5), 'diamond/x', { value: 20 }))) + assembler.append(input(at(SessionSeq(6), 'diamond/a', { value: 2 }))) assembler.flush() const value = [...testSnapshot(assembler)?.nodes.values() ?? []] @@ -911,13 +912,13 @@ describe('ConversationNodeAssembler', () => { new TestViewDefinitions([testView(apply)]), ) assembler.replaceWindow([ - input(at(1, 'turn/start', { turn: 1 })), - input(at(2, 'step/start', { turn: 1, step: 1 })), + input(at(SessionSeq(1), 'turn/start', { turn: 1 })), + input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })), ], false) assembler.flush() expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('open') - assembler.append(input(at(3, 'step/end', { turn: 1, step: 1 }))) + assembler.append(input(at(SessionSeq(3), 'step/end', { turn: 1, step: 1 }))) assembler.flush() expect(starts).toHaveBeenCalledTimes(2) @@ -990,14 +991,14 @@ describe('ConversationNodeAssembler', () => { new TestViewDefinitions([testView()]), ) assembler.replaceWindow([ - input(at(1, 'turn/start', { turn: 1 })), - input(at(2, 'step/start', { turn: 1, step: 1 })), + input(at(SessionSeq(1), 'turn/start', { turn: 1 })), + input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })), ], false) assembler.flush() expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data) .toEqual({ step: 1, turn: 1 }) - assembler.append(input(at(3, 'scope-probe/update', { turn: 1, step: 1, value: 2 }))) + assembler.append(input(at(SessionSeq(3), 'scope-probe/update', { turn: 1, step: 1, value: 2 }))) assembler.flush() expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data) @@ -1022,11 +1023,11 @@ describe('ConversationNodeAssembler', () => { new TestEventDefinitions([definition]), new TestViewDefinitions([testView(apply)]), ) - assembler.replaceWindow([input(at(1, 'turn/start', { turn: 1 }))], false) + assembler.replaceWindow([input(at(SessionSeq(1), 'turn/start', { turn: 1 }))], false) assembler.flush() expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(0) - assembler.append(input(at(2, 'step/start', { turn: 1, step: 1 }))) + assembler.append(input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 }))) assembler.flush() expect(apply).toHaveBeenCalledOnce() @@ -1042,7 +1043,7 @@ describe('ConversationNodeAssembler', () => { assembler.replaceWindow([], false) assembler.flush() - assembler.append(input(at(1, 'turn/start', { turn: 1 }))) + assembler.append(input(at(SessionSeq(1), 'turn/start', { turn: 1 }))) assembler.flush() expect(apply).toHaveBeenCalledOnce() @@ -1083,11 +1084,11 @@ describe('ConversationNodeAssembler', () => { new TestViewDefinitions([testView()]), ) assembler.replaceWindow([ - input(at(1, 'turn/start', { turn: 1 })), - input(at(2, 'step/start', { turn: 1, step: 1 })), - input(at(3, 'turn/start', { turn: 2 })), - input(at(4, 'command/run', { commandId: 'command', name: 'x' })), - input(at(5, 'compaction/start', { compactionId: 'compact', turn: null })), + input(at(SessionSeq(1), 'turn/start', { turn: 1 })), + input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })), + input(at(SessionSeq(3), 'turn/start', { turn: 2 })), + input(at(SessionSeq(4), 'command/run', { commandId: 'command', name: 'x' })), + input(at(SessionSeq(5), 'compaction/start', { compactionId: 'compact', turn: null })), ], false) assembler.flush() @@ -1111,12 +1112,12 @@ describe('ConversationNodeAssembler', () => { new TestViewDefinitions([testView()]), ) assembler.replaceWindow([ - input(at(1, 'turn/start', { turn: 1 })), - input(at(2, 'step/start', { turn: 1, step: 1 })), + input(at(SessionSeq(1), 'turn/start', { turn: 1 })), + input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })), ], false) assembler.flush() - assembler.append(input(at(3, 'turn/end', { turn: 1, reason: { kind: 'aborted' } }))) + assembler.append(input(at(SessionSeq(3), 'turn/end', { turn: 1, reason: { kind: 'aborted' } }))) assembler.flush() expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('turn') @@ -1143,12 +1144,12 @@ describe('ConversationNodeAssembler', () => { new TestViewDefinitions([testView()]), ) assembler.replaceWindow([ - input(at(10, 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })), - input(at(11, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'a' })), + input(at(SessionSeq(10), 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })), + input(at(SessionSeq(11), 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'a' })), ], true) assembler.flush() - assembler.append(input(at(12, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'b' }))) + assembler.append(input(at(SessionSeq(12), 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'b' }))) assembler.flush() expect([...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) @@ -1176,9 +1177,9 @@ describe('ConversationNodeAssembler', () => { new TestViewDefinitions([testView()]), ) assembler.replaceWindow([ - input(at(10, 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })), - input(at(11, 'step/end', { turn: 2, step: 3 })), - input(at(12, 'turn/end', { turn: 2, reason: { kind: 'completed' } })), + input(at(SessionSeq(10), 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })), + input(at(SessionSeq(11), 'step/end', { turn: 2, step: 3 })), + input(at(SessionSeq(12), 'turn/end', { turn: 2, reason: { kind: 'completed' } })), ], true) assembler.flush() @@ -1205,10 +1206,10 @@ describe('ConversationNodeAssembler', () => { new TestEventDefinitions([definition]), new TestViewDefinitions([testView()]), ) - assembler.replaceWindow([input(at(1, 'step/start', { turn: 1, step: 1 }))], false) + assembler.replaceWindow([input(at(SessionSeq(1), 'step/start', { turn: 1, step: 1 }))], false) assembler.flush() - assembler.append(input(at(2, 'step/end', { turn: 1, step: 1 }))) + assembler.append(input(at(SessionSeq(2), 'step/end', { turn: 1, step: 1 }))) assembler.flush() expect(seen).toHaveBeenCalledTimes(2) @@ -1229,7 +1230,7 @@ describe('ConversationNodeAssembler', () => { new TestViewDefinitions([testView()]), ) - assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) + assembler.replaceWindow([input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' }))], false) assembler.flush() expect(fallbackStart).toHaveBeenCalledOnce() @@ -1253,7 +1254,7 @@ describe('ConversationNodeAssembler', () => { new TestViewDefinitions([testView()]), ) - assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) + assembler.replaceWindow([input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' }))], false) assembler.flush() expect(fallbackStart).toHaveBeenCalledOnce() @@ -1274,7 +1275,7 @@ describe('ConversationNodeAssembler', () => { new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)), new TestViewDefinitions([testView()]), ) - assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) + assembler.replaceWindow([input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' }))], false) assembler.flush() expect(fallbackStart).not.toHaveBeenCalled() @@ -1298,11 +1299,11 @@ describe('ConversationNodeAssembler', () => { new TestEventDefinitions([definition]), new TestViewDefinitions([testView()]), ) - assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) + assembler.replaceWindow([input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' }))], false) assembler.flush() expect(testSnapshot(assembler)?.order).toHaveLength(1) - assembler.append(input(at(2, 'toggle/hide', {}))) + assembler.append(input(at(SessionSeq(2), 'toggle/hide', {}))) expect(() => assembler.flush()).toThrow(/withdrew materialized target "test"/) expect(testSnapshot(assembler)?.order).toHaveLength(1) @@ -1322,7 +1323,7 @@ describe('ConversationNodeAssembler', () => { new TestViewDefinitions([testView()]), ) expect(() => startAssembler.replaceWindow([ - input(at(1, 'command/run', { commandId: 'one', name: 'x' })), + input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' })), ], false)).toThrow(/Definition "undefined-start" returned undefined from start/) const updateUndefined: ConversationNodeDefinition = { @@ -1342,10 +1343,10 @@ describe('ConversationNodeAssembler', () => { new TestViewDefinitions([testView()]), ) updateAssembler.replaceWindow([ - input(at(1, 'command/run', { commandId: 'one', name: 'x' })), + input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' })), ], false) expect(() => updateAssembler.append( - input(at(2, 'command/done', { commandId: 'one', kind: 'success' })), + input(at(SessionSeq(2), 'command/done', { commandId: 'one', kind: 'success' })), )).toThrow(/Definition "undefined-update" returned undefined from update/) }) @@ -1363,12 +1364,12 @@ describe('ConversationNodeAssembler', () => { new TestViewDefinitions([testView()]), ) assembler.replaceWindow([ - input(at(1, 'command/run', { commandId: 'one', name: 'x' })), + input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' })), ], false) assembler.flush() expect(() => assembler.append( - input(at(2, 'command/run', { commandId: 'two', name: 'x' })), + input(at(SessionSeq(2), 'command/run', { commandId: 'two', name: 'x' })), )).toThrow(/received more than one start Match/) assembler.flush() expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) diff --git a/packages/client/ui-conversation/tests/history-transport.perf.client.ts b/packages/client/ui-conversation/tests/history-transport.perf.client.ts index 65f2b42893..c04457e08d 100644 --- a/packages/client/ui-conversation/tests/history-transport.perf.client.ts +++ b/packages/client/ui-conversation/tests/history-transport.perf.client.ts @@ -9,6 +9,7 @@ import { z } from 'zod' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { isChunkRow, packChunkRuns } from '@deepseek-ai/dsh-session/chunk-rows' import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' +import { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session/types' import type { ChunkRowEvent, @@ -672,7 +673,7 @@ it('reports compact folding cost for long whitespace-prefix runs', () => { } const start = wireEntry({ type: 'step/start', - seq: 0, + seq: SessionSeq(0), time: TIME_ZERO, data: { turn: 1, step: 1 }, }) diff --git a/packages/client/ui-conversation/tests/request-inspection.client.spec.ts b/packages/client/ui-conversation/tests/request-inspection.client.spec.ts index 1f0471ce8f..5fbf5cd271 100644 --- a/packages/client/ui-conversation/tests/request-inspection.client.spec.ts +++ b/packages/client/ui-conversation/tests/request-inspection.client.spec.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest' +import { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { inspectRequestPrompt } from '../src/client/contract/request-inspection.ts' const CONFIG = { provider: 'test', model: 'test' } function header( - seq: number, + seq: SessionSeq, reason: SessionEvent<'request/header'>['data']['reason'], value: SessionEvent<'request/header'>['data']['header'], ): SessionEvent<'request/header'> { @@ -19,7 +20,7 @@ function header( describe('inspectRequestPrompt', () => { it('classifies the first complete header as the initial prompt', () => { - expect(inspectRequestPrompt(undefined, header(1, 'initial', { + expect(inspectRequestPrompt(undefined, header(SessionSeq(1), 'initial', { config: CONFIG, system: '# System\n\nFollow instructions.', tools: [{ name: 'read', description: 'Read a file', parameters: { type: 'object' } }], @@ -34,7 +35,7 @@ describe('inspectRequestPrompt', () => { }) it('suppresses a resume header when the earlier prompt is outside the loaded window', () => { - expect(inspectRequestPrompt(undefined, header(2, 'resume', { + expect(inspectRequestPrompt(undefined, header(SessionSeq(2), 'resume', { config: CONFIG, system: 'same prompt', }))).toEqual({ @@ -43,22 +44,22 @@ describe('inspectRequestPrompt', () => { }) it('classifies system, tool, and combined changes against the previous prompt', () => { - const initial = inspectRequestPrompt(undefined, header(1, 'initial', { + const initial = inspectRequestPrompt(undefined, header(SessionSeq(1), 'initial', { config: CONFIG, system: 'first', tools: [{ name: 'read', description: 'Read', parameters: { type: 'object' } }], })).prompt - const system = inspectRequestPrompt(initial, header(2, 'change', { + const system = inspectRequestPrompt(initial, header(SessionSeq(2), 'change', { config: CONFIG, system: 'second', tools: [...initial.tools], })) - const tools = inspectRequestPrompt(system.prompt, header(3, 'change', { + const tools = inspectRequestPrompt(system.prompt, header(SessionSeq(3), 'change', { config: CONFIG, system: 'second', tools: [{ name: 'write', description: 'Write', parameters: { type: 'object' } }], })) - const combined = inspectRequestPrompt(tools.prompt, header(4, 'change', { + const combined = inspectRequestPrompt(tools.prompt, header(SessionSeq(4), 'change', { config: CONFIG, system: 'third', tools: [], @@ -71,12 +72,12 @@ describe('inspectRequestPrompt', () => { }) it('omits a change when the prompt and tools are unchanged', () => { - const previous = inspectRequestPrompt(undefined, header(1, 'initial', { + const previous = inspectRequestPrompt(undefined, header(SessionSeq(1), 'initial', { config: CONFIG, system: 'same', })).prompt - expect(inspectRequestPrompt(previous, header(2, 'resume', { + expect(inspectRequestPrompt(previous, header(SessionSeq(2), 'resume', { config: { ...CONFIG, maxTokens: 1_024 }, system: 'same', }))).toEqual({ diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx index 6338be9931..fb2d60c6bf 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -1,5 +1,6 @@ /** Test adapter for the production conversation.details.tool registration. */ import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client' +import { SessionSeq } from '@deepseek-ai/dsh-session/types' import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' import type { ChatConversationViewNode, ChatSnapshot, ConversationNode, DetailsSlotProps, @@ -74,7 +75,7 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se { type: 'event', event: { - seq: 1, + seq: SessionSeq(1), time: firstTime - 2, type: 'turn/start', data: { turn: 1 }, @@ -83,7 +84,7 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se { type: 'event', event: { - seq: 2, + seq: SessionSeq(2), time: firstTime - 1, type: 'step/start', data: { turn: 1, step: 1 }, @@ -96,7 +97,7 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se const callEntry: SessionLiveEventEntry = { type: 'event', event: { - seq: callSeq, + seq: SessionSeq(callSeq), time: node.callTime ?? node.time - 1, type: 'tool/call', data: { @@ -112,7 +113,7 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se const resultEntry: SessionLiveEventEntry = { type: 'event', event: { - seq: callSeq + 1, + seq: SessionSeq(callSeq + 1), time: node.time, type: 'tool/result', data: jsonFixture({ diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index 15b0afc345..d57a89ba8b 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -15,7 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, waitFor, within } from '@testing-library/react' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' -import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { RemoteError, SlotTestRuntime, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' @@ -55,7 +55,7 @@ describe('session rename through the assembled browser', () => { it('renames via the row menu: binding.session.rename fires, the dialog closes, the row re-labels from the list', async () => { const runtime = await createRuntime() const rename = vi.fn(async title => ({ - ok: true, value: { title: title.trim().replace(/\s+/g, ' '), seq: 7 }, + ok: true, value: { title: title.trim().replace(/\s+/g, ' '), seq: SessionSeq(7) }, })) await runtime.sessions.add({ id: SID, diff --git a/packages/compaction/command-compact/tests/command-compact.spec.ts b/packages/compaction/command-compact/tests/command-compact.spec.ts index 7cc9d3331e..f2ab167d69 100644 --- a/packages/compaction/command-compact/tests/command-compact.spec.ts +++ b/packages/compaction/command-compact/tests/command-compact.spec.ts @@ -12,19 +12,19 @@ import { type CompactionTrigger, type ManualCompactAgentContext, } from '@deepseek-ai/dsh-compaction' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import * as commandCompact from '@deepseek-ai/dsh-command-compact' const COMPACTION_ID = CompactionId('command-compact-test') const RESULT: CompactionResult = { compactionId: COMPACTION_ID, - startSeq: 1, - summarySeq: 2, - endSeq: 3, + startSeq: SessionSeq(1), + summarySeq: SessionSeq(2), + endSeq: SessionSeq(3), summary: [{ type: 'text', text: 'summary' }], - shadowedRange: { start: 1, end: 7 }, - shadowedSeqs: [1, 3, 7], + shadowedRange: { start: SessionSeq(1), end: SessionSeq(7) }, + shadowedSeqs: [SessionSeq(1), SessionSeq(3), SessionSeq(7)], shadowedTokenCount: 42, } diff --git a/packages/compaction/command-compact/tests/loader-composition.spec.ts b/packages/compaction/command-compact/tests/loader-composition.spec.ts index e4cbedd42a..442d8196dc 100644 --- a/packages/compaction/command-compact/tests/loader-composition.spec.ts +++ b/packages/compaction/command-compact/tests/loader-composition.spec.ts @@ -17,18 +17,18 @@ import { type ManualCompactAgentContext, } from '@deepseek-ai/dsh-compaction' import * as commandCompact from '@deepseek-ai/dsh-command-compact' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' const COMPACTION_ID = CompactionId('loader-command-compact-test') const RESULT: CompactionResult = { compactionId: COMPACTION_ID, - startSeq: 1, - summarySeq: 2, - endSeq: 3, + startSeq: SessionSeq(1), + summarySeq: SessionSeq(2), + endSeq: SessionSeq(3), summary: [{ type: 'text', text: 'loader summary' }], - shadowedRange: { start: 3, end: 8 }, - shadowedSeqs: [3, 5, 8], + shadowedRange: { start: SessionSeq(3), end: SessionSeq(8) }, + shadowedSeqs: [SessionSeq(3), SessionSeq(5), SessionSeq(8)], shadowedTokenCount: 99, } diff --git a/packages/compaction/compaction-basic/src/index.ts b/packages/compaction/compaction-basic/src/index.ts index dc7d7371bc..7a6ce3daba 100644 --- a/packages/compaction/compaction-basic/src/index.ts +++ b/packages/compaction/compaction-basic/src/index.ts @@ -9,7 +9,7 @@ import z from '@deepseek-ai/schemastery' import { CompactionEngine, ManualCompactionError } from '@deepseek-ai/dsh-compaction' import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compaction' import type { TokenMeter } from '@deepseek-ai/dsh-token-meter' -import type { Session } from '@deepseek-ai/dsh-session' +import type { Session, SessionSeq } from '@deepseek-ai/dsh-session' import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' import { assertNever } from '@deepseek-ai/dsh-util-values' @@ -342,8 +342,8 @@ export class BasicCompactionEngine extends CompactionEngine { * @returns the successful durable compaction result. */ override async compactRegion( - start: number, - end: number, + start: SessionSeq, + end: SessionSeq, agent: Agent, signal?: AbortSignal, ): Promise { diff --git a/packages/compaction/compaction-basic/src/region.ts b/packages/compaction/compaction-basic/src/region.ts index b609f949c8..c639814ce7 100644 --- a/packages/compaction/compaction-basic/src/region.ts +++ b/packages/compaction/compaction-basic/src/region.ts @@ -19,7 +19,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import type { Message, UserMessage } from '@deepseek-ai/dsh-llm' import type { TokenMeasurement, TokenMeter } from '@deepseek-ai/dsh-token-meter' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionSeq, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import { frameSummary } from './summarizer.ts' import type { SummarizationInput, SummaryResult } from './summarizer.ts' @@ -31,11 +31,11 @@ interface RegionDependencies { /** One validated inclusive span of current surface positions. */ interface SurfaceSelection { - readonly start: number - readonly end: number + readonly start: SessionSeq + readonly end: SessionSeq readonly startIdx: number readonly endIdx: number - readonly shadowedSeqs: readonly number[] + readonly shadowedSeqs: readonly SessionSeq[] } /** A selection with its priced snapshot and the replay input built from it. */ @@ -66,7 +66,7 @@ interface CompactionTransactionOptions { interface CompactionEntryState { readonly openTurn: number | null readonly unmatchedCompactionStart: SessionEvent<'compaction/start'> | undefined - readonly latestEndSeedSeq: number | undefined + readonly latestEndSeedSeq: SessionSeq | undefined } /** @@ -101,7 +101,7 @@ export function selectCompactableRange( session: Session, measurement: TokenMeasurement, retainTokens: number, -): { start: number; end: number } | null { +): { start: SessionSeq; end: SessionSeq } | null { const pricedNodes = measurement.nodes if (pricedNodes.length === 0) return null @@ -154,8 +154,8 @@ export function selectCompactableRange( export async function compactSurfaceRegion( dependencies: RegionDependencies, session: Session, - start: number, - end: number, + start: SessionSeq, + end: SessionSeq, agent: Agent, options: CompactionTransactionOptions, signal?: AbortSignal, @@ -287,7 +287,7 @@ function throwManualFailure(failure: TransactionFailure): never { */ function assertCompactionInactive( unmatchedCompactionStart: SessionEvent<'compaction/start'> | undefined, - latestEndSeedSeq: number | undefined, + latestEndSeedSeq: SessionSeq | undefined, stage: string, ): void { if (unmatchedCompactionStart === undefined @@ -314,7 +314,7 @@ export function assertNoActiveCompaction(session: Session, stage: string): void } /** Validate one requested surface-position span before asynchronous work begins. */ -function validateSurfaceRegion(session: Session, start: number, end: number): SurfaceSelection { +function validateSurfaceRegion(session: Session, start: SessionSeq, end: SessionSeq): SurfaceSelection { const nodes = session.surface.nodes const startIdx = nodes.indexOf(start) const endIdx = nodes.indexOf(end) @@ -507,7 +507,7 @@ function completeCompaction( */ function buildSummarizationInput( session: Session, - shadowedSeqs: readonly number[], + shadowedSeqs: readonly SessionSeq[], ): SummarizationInput { const header = session.requestHeader() const regionMessages = shadowedSeqs @@ -528,10 +528,10 @@ function inspectCompactionEntryState(session: Session): CompactionEntryState { let openTurnStateKnown = false let unmatchedCompactionStart: SessionEvent<'compaction/start'> | undefined let compactionEntryStateKnown = false - let latestEndSeedSeq: number | undefined + let latestEndSeedSeq: SessionSeq | undefined for (let seq = session.seq - 1; seq >= 0; seq -= 1) { // oxlint-disable-next-line typescript/no-non-null-assertion - const event = session.eventAt(seq)! + const event = session.eventAt(SessionSeq(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 20125c18c6..e6ed88274b 100644 --- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts @@ -23,7 +23,7 @@ import type { StreamChunk, TokenUsage, } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import { agentEvents, type Agent, type RequestErrorAction } from '@deepseek-ai/dsh-agent' @@ -911,8 +911,8 @@ describe('compaction region transaction', () => { const session = conversation(2) const nodes = session.surface.nodes await expect(compact.compactRegion( - startOverride ?? nodes[0]!, - endOverride ?? nodes[1]!, + startOverride === undefined ? nodes[0]! : SessionSeq(startOverride), + endOverride === undefined ? nodes[1]! : SessionSeq(endOverride), agent(session, MODEL), )).rejects.toThrow(pattern) }) @@ -1686,12 +1686,12 @@ describe('automatic listener and loader composition', () => { const session = conversation(2) const fakeResult: CompactionResult = { compactionId: CompactionId('fake-compaction'), - startSeq: 1, - summarySeq: 2, - endSeq: 3, + startSeq: SessionSeq(1), + summarySeq: SessionSeq(2), + endSeq: SessionSeq(3), summary: [{ type: 'text', text: 'fake' }], - shadowedRange: { start: 1, end: 2 }, - shadowedSeqs: [1, 2], + shadowedRange: { start: SessionSeq(1), end: SessionSeq(2) }, + shadowedSeqs: [SessionSeq(1), SessionSeq(2)], shadowedTokenCount: 10, } vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(fakeResult) diff --git a/packages/compaction/compaction-tool-result-pruner/src/index.ts b/packages/compaction/compaction-tool-result-pruner/src/index.ts index d3f3212c1e..d13303e0c9 100644 --- a/packages/compaction/compaction-tool-result-pruner/src/index.ts +++ b/packages/compaction/compaction-tool-result-pruner/src/index.ts @@ -8,7 +8,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { freezeMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent, ToolResultMessage } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionSeq, ToolResultMessage } from '@deepseek-ai/dsh-session' // Type-only: the `compaction/*` SessionEventMap merges (the shadow-price event). import type {} from '@deepseek-ai/dsh-compaction' // Type-only: the `ctx.tokenMeter` Context merge for the declared injection. @@ -36,7 +36,7 @@ declare module '@deepseek-ai/cordis' { } interface SnapshotCandidate { - readonly seq: number + readonly seq: SessionSeq readonly event: SessionEvent<'tool/result'> } diff --git a/packages/compaction/compaction-tool-result-pruner/src/types.ts b/packages/compaction/compaction-tool-result-pruner/src/types.ts index d8228765a3..8cc6934b10 100644 --- a/packages/compaction/compaction-tool-result-pruner/src/types.ts +++ b/packages/compaction/compaction-tool-result-pruner/src/types.ts @@ -1,4 +1,5 @@ import type { ToolCallId } from '@deepseek-ai/dsh-llm' +import type { SessionSeq } from '@deepseek-ai/dsh-session/types' /** Character-budget policy for deterministic tool-result pruning. */ export interface ToolResultPruneConfig { @@ -20,9 +21,9 @@ export interface ResolvedConfig { /** Cited source event and size accounting for one landed surface replacement. */ export interface PrunedEntry { /** Full-fidelity tool-result event shadowed by the replacement. */ - readonly originalSeq: number + readonly originalSeq: SessionSeq /** Newly appended pruned tool-result event. */ - readonly replacementSeq: number + readonly replacementSeq: SessionSeq /** Tool call shared by the original and replacement. */ readonly callId: ToolCallId /** Original text size in Unicode code points. */ diff --git a/packages/compaction/compaction/src/index.ts b/packages/compaction/compaction/src/index.ts index 13d8fb06b5..cfbdd42209 100644 --- a/packages/compaction/compaction/src/index.ts +++ b/packages/compaction/compaction/src/index.ts @@ -8,7 +8,7 @@ */ import { Context, Service } from '@deepseek-ai/cordis' -import type { Session } from '@deepseek-ai/dsh-session' +import type { Session, SessionSeq } from '@deepseek-ai/dsh-session' import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CompactionResult } from './types.ts' @@ -162,8 +162,8 @@ export abstract class CompactionEngine extends Service { * @returns the appended event seqs, summary, replaced range, and token accounting. */ abstract compactRegion( - start: number, - end: number, + start: SessionSeq, + end: SessionSeq, agent: CompactionAgentContext, signal?: AbortSignal, ): Promise diff --git a/packages/compaction/compaction/src/invariant.ts b/packages/compaction/compaction/src/invariant.ts index df82f348ee..ce19a6670f 100644 --- a/packages/compaction/compaction/src/invariant.ts +++ b/packages/compaction/compaction/src/invariant.ts @@ -1,8 +1,9 @@ /** Package-owned compaction log-stream invariants. @module @deepseek-ai/dsh-compaction/invariant */ import type { Context } from '@deepseek-ai/cordis' -import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session' +import { isReplacementSurfaceEvent, SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { SurfaceManager } from '@deepseek-ai/dsh-session/surface' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { CompactionId } from './brand.ts' import { isCompactCheckpointSource } from './checkpoint.ts' @@ -19,7 +20,7 @@ export const inject = ['invariants'] interface CompactionTrace { compactionId: CompactionId sourceCommandId: string | undefined - startSeq: number + startSeq: SessionSeq turn: number | null summarized: boolean } @@ -27,11 +28,13 @@ interface CompactionTrace { interface SessionTrace { openTurn: number | null compaction: CompactionTrace | undefined + surfaceEvents: SessionEvent[] + surface: SurfaceManager } type CompactionTransition = - | { kind: 'start'; compactionId: CompactionId; sourceCommandId: string | undefined; startSeq: number; turn: number | null } - | { kind: 'summary'; compactionId: CompactionId; sourceCommandId: string | undefined; startSeq: number; turn: number | null } + | { kind: 'start'; compactionId: CompactionId; sourceCommandId: string | undefined; startSeq: SessionSeq; turn: number | null } + | { kind: 'summary'; compactionId: CompactionId; sourceCommandId: string | undefined; startSeq: SessionSeq; turn: number | null } | { kind: 'end' } | { kind: 'end-seed' } @@ -40,6 +43,44 @@ function validateId(value: unknown, label: string, fail: InvariantFailure): asse if (typeof value !== 'string' || value.length === 0) fail(`${label} must be a non-empty string`) } +/** Validate a durable event-sequence identity at this package's event boundary. */ +function validateSeq(value: unknown, label: string, fail: InvariantFailure): SessionSeq { + if (typeof value !== 'number') return fail(`${label} must be a non-negative safe integer event seq`) + try { + return SessionSeq(value) + } catch { + return fail(`${label} must be a non-negative safe integer event seq`) + } +} + +/** Validate one shadowed surface span and its complete ordered identity list. */ +function validateShadowedSeqs( + trace: SessionTrace, + event: SessionEvent<'compaction/summary' | 'compaction/prune'>, + fail: InvariantFailure, +): void { + const eventType = event.type + const { data } = event + const start = validateSeq(data.shadowedRange.start, `${eventType} shadowedRange.start`, fail) + const end = validateSeq(data.shadowedRange.end, `${eventType} shadowedRange.end`, fail) + const seqs = data.shadowedSeqs.map((seq, index) => validateSeq(seq, `${eventType} shadowedSeqs[${index}]`, fail)) + if (seqs.length === 0) fail(`${eventType} shadowedSeqs must be non-empty`) + if (seqs[0] !== start || seqs.at(-1) !== end) { + fail(`${eventType} shadowedRange must match the first and last shadowedSeqs`) + } + const surface = trace.surface.nodes + const startIndex = surface.indexOf(start) + const endIndex = surface.indexOf(end) + if (startIndex < 0 || endIndex < startIndex) { + fail(`${eventType} shadowed seqs must name an earlier current surface span`) + } + const expected = surface.slice(startIndex, endIndex + 1) + if (expected.length !== seqs.length + || expected.some((seq, index) => seq !== seqs[index])) { + fail(`${eventType} shadowedSeqs must list every node in the current surface span`) + } +} + /** Keep the optional initiating command identity stable across one transaction. */ function validateSourceCommandId( eventType: string, @@ -75,9 +116,9 @@ function validateCheckpoint( /** Compaction starts still unmatched when a later seed boundary made them stale. */ function inheritedOrphanStartSeqs( events: readonly SessionEvent[], -): ReadonlySet { - const stale = new Set() - let openStartSeq: number | undefined +): ReadonlySet { + const stale = new Set() + let openStartSeq: SessionSeq | undefined for (const event of events) { if (event.type === 'compaction/start') { openStartSeq = event.seq @@ -142,6 +183,10 @@ function validateCompactionEvent( fail: InvariantFailure, ): CompactionTransition | undefined { if (event.type === 'session/end-seed') return { kind: 'end-seed' } + if (event.type === 'compaction/prune') { + validateShadowedSeqs(trace, event, fail) + return undefined + } if (event.type === 'user/message' && isReplacementSurfaceEvent(event) && isCompactCheckpointSource(event.data.source)) { @@ -182,11 +227,7 @@ function validateCompactionEvent( validateSourceCommandId('compaction/summary', event.data.sourceCommandId, open.sourceCommandId, fail) validateOwner(open.turn, trace.openTurn, event.type, fail) if (open.summarized) fail('compaction/summary repeated within one compaction') - const seqs = event.data.shadowedSeqs - if (seqs.length === 0) fail('compaction/summary shadowedSeqs must be non-empty') - if (seqs[0] !== event.data.shadowedRange.start || seqs.at(-1) !== event.data.shadowedRange.end) { - fail('compaction/summary shadowedRange must match the first and last shadowedSeqs') - } + validateShadowedSeqs(trace, event, fail) if (!Number.isSafeInteger(event.data.shadowedTokenCount) || event.data.shadowedTokenCount < 0) { fail('compaction/summary shadowedTokenCount must be a non-negative safe integer') } @@ -249,7 +290,13 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const traces = new WeakMap() const staged = new WeakMap() const seed = (session: Session): SessionTrace => { - const trace: SessionTrace = { openTurn: null, compaction: undefined } + const surfaceEvents: SessionEvent[] = [] + const trace: SessionTrace = { + openTurn: null, + compaction: undefined, + surfaceEvents, + surface: new SurfaceManager(surfaceEvents), + } traces.set(session, trace) const events = session.snapshotEvents() const staleOrphanStartSeqs = inheritedOrphanStartSeqs(events) @@ -266,6 +313,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const transition = validateCompactionEvent(trace, event, fail) if (transition !== undefined) trace.compaction = applyCompactionTransition(transition) applyTurnBoundary(trace, event) + trace.surfaceEvents.push(event) } return trace } @@ -276,16 +324,22 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant ctx.on('session/event', (session, event) => { const trace = traceFor(session) validateTurnBoundary(trace, event, fail) - if (applyTurnBoundary(trace, event)) return - if (event.type !== 'session/end-seed' + const changedTurn = applyTurnBoundary(trace, event) + if (!changedTurn && event.type !== 'session/end-seed' && event.type !== 'compaction/start' && event.type !== 'compaction/summary' - && event.type !== 'compaction/end') return - const candidate = staged.get(event) - /* v8 ignore next -- internal/dispatch stages every compaction event */ - if (candidate === undefined || candidate.session !== session) return fail('compaction event published without pre-commit validation') - staged.delete(event) - trace.compaction = applyCompactionTransition(candidate.transition) + && event.type !== 'compaction/end') { + trace.surfaceEvents.push(event) + return + } + if (!changedTurn) { + const candidate = staged.get(event) + /* v8 ignore next -- internal/dispatch stages every compaction event */ + if (candidate === undefined || candidate.session !== session) return fail('compaction event published without pre-commit validation') + staged.delete(event) + trace.compaction = applyCompactionTransition(candidate.transition) + } + trace.surfaceEvents.push(event) }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return diff --git a/packages/compaction/compaction/src/tool-pairing.ts b/packages/compaction/compaction/src/tool-pairing.ts index c492d53a70..56ec6a2368 100644 --- a/packages/compaction/compaction/src/tool-pairing.ts +++ b/packages/compaction/compaction/src/tool-pairing.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-compaction/tool-pairing */ -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionSeq } from '@deepseek-ai/dsh-session' /** Incremental balance state for one session surface generation. */ interface BalanceCache { @@ -18,7 +18,7 @@ interface BalanceCache { */ cutBalanced: readonly boolean[] /** Current surface position of each event seq, indexing {@link cutBalanced}. */ - indexBySeq: Map + indexBySeq: Map /** In-progress tool-call count after the processed surface tail. */ inProgressToolCalls: number } @@ -41,7 +41,7 @@ function eventDelta(event: SessionEvent): number { function extendCache( session: Session, cache: BalanceCache, - seqs: readonly number[], + seqs: readonly SessionSeq[], ): BalanceCache { const processed = cache.cutBalanced.length - 1 const tail = seqs.slice(processed) @@ -91,7 +91,7 @@ function balanceCache(session: Session): BalanceCache { } /** Balance of the cut at a sequence's position plus offset, rejecting seqs outside current membership. */ -function cutBalance(cache: BalanceCache, seq: number, offset: 0 | 1): boolean { +function cutBalance(cache: BalanceCache, seq: SessionSeq, offset: 0 | 1): boolean { const index = cache.indexBySeq.get(seq) const balanced = index === undefined ? undefined : cache.cutBalanced[index + offset] if (balanced === undefined) { @@ -108,7 +108,7 @@ function cutBalance(cache: BalanceCache, seq: number, offset: 0 | 1): boolean { * @throws when the seq is absent from the current surface, a surface sequence has no * matching log event, or a tool result has no preceding open call. */ -export function toolPairingBalancedBefore(session: Session, seq: number): boolean { +export function toolPairingBalancedBefore(session: Session, seq: SessionSeq): boolean { return cutBalance(balanceCache(session), seq, 0) } @@ -120,6 +120,6 @@ export function toolPairingBalancedBefore(session: Session, seq: number): boolea * @throws when the seq is absent from the current surface, a surface sequence has no * matching log event, or a tool result has no preceding open call. */ -export function toolPairingBalancedAfter(session: Session, seq: number): boolean { +export function toolPairingBalancedAfter(session: Session, seq: SessionSeq): boolean { return cutBalance(balanceCache(session), seq, 1) } diff --git a/packages/compaction/compaction/src/types.ts b/packages/compaction/compaction/src/types.ts index 0ad5796d93..5a7d12c1b2 100644 --- a/packages/compaction/compaction/src/types.ts +++ b/packages/compaction/compaction/src/types.ts @@ -9,6 +9,7 @@ import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm' import type { CommandId } from '@deepseek-ai/dsh-commands/brand' +import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { CompactionId } from './brand.ts' export type { CompactionId } @@ -34,8 +35,8 @@ declare module '@deepseek-ai/dsh-session/types' { compactionId: CompactionId sourceCommandId?: CommandId summary: ContentBlock[] - shadowedRange: { start: number; end: number } - shadowedSeqs: number[] + shadowedRange: { start: SessionSeq; end: SessionSeq } + shadowedSeqs: SessionSeq[] shadowedTokenCount: number /** The provider route that wrote the summary. */ provider: string @@ -80,9 +81,9 @@ declare module '@deepseek-ai/dsh-session/types' { */ 'compaction/prune': { /** The replaced range's first and last surface-node seqs (a surface-position span, like {@link CompactionResult.shadowedRange}). */ - shadowedRange: { start: number; end: number } + shadowedRange: { start: SessionSeq; end: SessionSeq } /** The seqs of all shadowed surface nodes, in surface order. */ - shadowedSeqs: number[] + shadowedSeqs: SessionSeq[] /** Heuristic price of the shadowed content under the token-meter's fixed estimator. */ shadowedTokenCount: number } @@ -96,11 +97,11 @@ export interface CompactionResult { /** Human command that initiated this compaction, when it was manual. */ sourceCommandId?: CommandId /** The seq of the appended `compaction/start` event. */ - startSeq: number + startSeq: SessionSeq /** The seq of the appended `compaction/summary` event. */ - summarySeq: number + summarySeq: SessionSeq /** The seq of the appended `compaction/end` event. */ - endSeq: number + endSeq: SessionSeq /** The summary content blocks produced by the backend. */ summary: ContentBlock[] /** @@ -111,9 +112,9 @@ export interface CompactionResult { * can be GREATER than `end`. {@link CompactionResult.shadowedSeqs} is the * authoritative set of shadowed nodes, in surface order. */ - shadowedRange: { start: number; end: number } + shadowedRange: { start: SessionSeq; end: SessionSeq } /** The seqs of all shadowed surface nodes, in surface order. */ - shadowedSeqs: number[] + shadowedSeqs: SessionSeq[] /** Estimated token count of the shadowed content. */ shadowedTokenCount: number } diff --git a/packages/compaction/compaction/tests/compaction.spec.ts b/packages/compaction/compaction/tests/compaction.spec.ts index 8b02f5f811..d0436da6d3 100644 --- a/packages/compaction/compaction/tests/compaction.spec.ts +++ b/packages/compaction/compaction/tests/compaction.spec.ts @@ -9,6 +9,7 @@ import { } from '@deepseek-ai/dsh-compaction' import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compaction' import { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionSeq } from '@deepseek-ai/dsh-session' import type { CompactionAgentContext } from '@deepseek-ai/dsh-compaction' import type { ManualCompactAgentContext } from '@deepseek-ai/dsh-compaction' @@ -40,8 +41,8 @@ class StubCompactionEngine extends CompactionEngine { } override async compactRegion( - start: number, - end: number, + start: SessionSeq, + end: SessionSeq, agent: CompactionAgentContext, signal?: AbortSignal, ): Promise { diff --git a/packages/compaction/compaction/tests/invariant.spec.ts b/packages/compaction/compaction/tests/invariant.spec.ts index 1aa8682e6a..6bae84418d 100644 --- a/packages/compaction/compaction/tests/invariant.spec.ts +++ b/packages/compaction/compaction/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compaction' import * as CompactionInvariant from '@deepseek-ai/dsh-compaction/invariant' @@ -20,16 +20,27 @@ const NEXT_COMPACTION_ID = CompactionId('next-test-compaction') const TEST_COMMAND_ID = CommandId('test-command') const NEXT_COMMAND_ID = CommandId('next-test-command') -const summary = (overrides: Record = {}) => ({ - compactionId: TEST_COMPACTION_ID, - summary: [{ type: 'text' as const, text: 'short' }], - shadowedRange: { start: 2, end: 4 }, - shadowedSeqs: [2, 3, 4], - shadowedTokenCount: 12, - provider: 'mock', - model: 'mock', - ...overrides, -}) +const summary = (session: Session, overrides: Record = {}) => { + const appendMessage = (text: string) => session.append( + 'user/message', + createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }), + { surfaceOp: 'append' }, + ) + const first = appendMessage('first') + const second = appendMessage('second') + const third = appendMessage('third') + const shadowedSeqs: [SessionSeq, SessionSeq, SessionSeq] = [first.seq, second.seq, third.seq] + return { + compactionId: TEST_COMPACTION_ID, + summary: [{ type: 'text' as const, text: 'short' }], + shadowedRange: { start: first.seq, end: third.seq }, + shadowedSeqs, + shadowedTokenCount: 12, + provider: 'mock', + model: 'mock', + ...overrides, + } +} function startTurn(session: ReturnType, turn = 1): void { session.append('turn/start', { turn }) @@ -41,7 +52,15 @@ describe('compaction invariants', () => { const success = ctx.sessions.create() startTurn(success) success.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) - success.append('compaction/summary', summary()) + const summaryData = summary(success) + success.append('compaction/summary', summaryData) + success.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'checkpoint' }], + source: compactCheckpointSource(TEST_COMPACTION_ID), + }), { + surfaceOp: { op: 'replace', ...summaryData.shadowedRange }, + sourceEventSeqs: summaryData.shadowedSeqs, + }) success.append('compaction/end', { compactionId: TEST_COMPACTION_ID, turn: 1 }) const failed = ctx.sessions.create() @@ -50,11 +69,54 @@ describe('compaction invariants', () => { failed.append('compaction/end', { compactionId: TEST_COMPACTION_ID, turn: 2, error: 'provider failed' }) }) + it('rejects shadow identities outside the exact earlier surface span', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + const turn = session.append('turn/start', { turn: 1 }) + session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + const valid = summary(session) + const current = SessionSeq(session.seq) + expect(() => session.append('compaction/summary', { + ...valid, + shadowedRange: { start: current, end: current }, + shadowedSeqs: [current], + })).toThrow(/must name an earlier current surface span/) + expect(() => session.append('compaction/summary', { + ...valid, + shadowedRange: { start: turn.seq, end: turn.seq }, + shadowedSeqs: [turn.seq], + })).toThrow(/must name an earlier current surface span/) + expect(() => session.append('compaction/summary', { + ...valid, + shadowedSeqs: [valid.shadowedSeqs[0], valid.shadowedSeqs[2]], + })).toThrow(/must list every node in the current surface span/) + expect(() => session.append('compaction/summary', { + ...valid, + shadowedRange: { start: valid.shadowedSeqs[2], end: valid.shadowedSeqs[0] }, + shadowedSeqs: [valid.shadowedSeqs[2], valid.shadowedSeqs[0]], + })).toThrow(/must name an earlier current surface span/) + expect(() => session.append('compaction/summary', { + ...valid, + shadowedSeqs: [valid.shadowedSeqs[0], valid.shadowedSeqs[2], valid.shadowedSeqs[2]], + })).toThrow(/must list every node in the current surface span/) + }) + + it('validates a model-free prune against the current surface', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + const shadow = summary(session) + expect(() => session.append('compaction/prune', { + shadowedRange: shadow.shadowedRange, + shadowedSeqs: shadow.shadowedSeqs, + shadowedTokenCount: shadow.shadowedTokenCount, + })).not.toThrow() + }) + it('accepts standalone successful and failed compaction lifecycles between turns', async () => { const ctx = await setup() const success = ctx.sessions.create() success.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: null }) - success.append('compaction/summary', summary()) + success.append('compaction/summary', summary(success)) success.append('compaction/end', { compactionId: TEST_COMPACTION_ID, turn: null }) const failed = ctx.sessions.create() @@ -168,14 +230,14 @@ describe('compaction invariants', () => { const session = Session.create(SessionId('bare-compaction-session')) expect(() => { ctx.emit('session/event', session, { - type: 'turn/start', seq: 0, time: 0, + type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 }, }) ctx.emit('session/event', session, { - type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 }, + type: 'step/start', seq: SessionSeq(1), time: 1, data: { turn: 1, step: 1 }, }) ctx.emit('session/event', session, { - type: 'compaction/start', seq: 2, time: 2, + type: 'compaction/start', seq: SessionSeq(2), time: 2, data: { compactionId: TEST_COMPACTION_ID, turn: 1 }, }) }).not.toThrow() @@ -257,7 +319,7 @@ describe('compaction invariants', () => { }), { surfaceOp: 'append' }) startTurn(session) session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) - session.append('compaction/summary', summary()) + session.append('compaction/summary', summary(session)) expect(() => session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'checkpoint' }], @@ -311,7 +373,7 @@ describe('compaction invariants', () => { }) }, /compaction\/start sourceCommandId must be a non-empty string/], ['summary without start', (session: ReturnType) => { - session.append('compaction/summary', summary()) + session.append('compaction/summary', summary(session)) }, /no matching compaction\/start/], ['nested start', (session: ReturnType) => { session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) @@ -319,12 +381,12 @@ describe('compaction invariants', () => { }, /still compacting/], ['repeated summary', (session: ReturnType) => { session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) - session.append('compaction/summary', summary()) - session.append('compaction/summary', summary()) + session.append('compaction/summary', summary(session)) + session.append('compaction/summary', summary(session)) }, /repeated within one compaction/], ['summary for another compaction', (session: ReturnType) => { session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) - session.append('compaction/summary', summary({ compactionId: NEXT_COMPACTION_ID })) + session.append('compaction/summary', summary(session, { compactionId: NEXT_COMPACTION_ID })) }, /compaction\/summary id .* does not match compaction\/start id/], ['summary for another source command', (session: ReturnType) => { session.append('compaction/start', { @@ -332,19 +394,31 @@ describe('compaction invariants', () => { sourceCommandId: TEST_COMMAND_ID, turn: 1, }) - session.append('compaction/summary', summary({ sourceCommandId: NEXT_COMMAND_ID })) + session.append('compaction/summary', summary(session, { sourceCommandId: NEXT_COMMAND_ID })) }, /compaction\/summary sourceCommandId .* does not match compaction\/start sourceCommandId/], ['empty shadow set', (session: ReturnType) => { session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) - session.append('compaction/summary', summary({ shadowedSeqs: [] })) + session.append('compaction/summary', summary(session, { shadowedSeqs: [] })) }, /shadowedSeqs must be non-empty/], ['wrong endpoints', (session: ReturnType) => { session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) - session.append('compaction/summary', summary({ shadowedRange: { start: 1, end: 4 } })) + session.append('compaction/summary', summary(session, { shadowedRange: { start: 1, end: 4 } })) }, /shadowedRange must match/], + ['non-numeric shadow seq', (session: ReturnType) => { + session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compaction/summary', summary(session, { + shadowedRange: { start: 'bad', end: 'bad' }, shadowedSeqs: ['bad'], + })) + }, /non-negative safe integer event seq/], + ['negative shadow seq', (session: ReturnType) => { + session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compaction/summary', summary(session, { + shadowedRange: { start: -1, end: -1 }, shadowedSeqs: [-1], + })) + }, /non-negative safe integer event seq/], ['invalid token count', (session: ReturnType) => { session.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) - session.append('compaction/summary', summary({ shadowedTokenCount: -1 })) + session.append('compaction/summary', summary(session, { shadowedTokenCount: -1 })) }, /non-negative safe integer/], ['end without start', (session: ReturnType) => { session.append('compaction/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'failed' }) diff --git a/packages/compaction/compaction/tests/tool-pairing.spec.ts b/packages/compaction/compaction/tests/tool-pairing.spec.ts index 8478f47edd..19b5a460c5 100644 --- a/packages/compaction/compaction/tests/tool-pairing.spec.ts +++ b/packages/compaction/compaction/tests/tool-pairing.spec.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from 'vitest' import { createUserMessage, ToolCallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compaction' -import { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionSeq as SessionSeqType } from '@deepseek-ai/dsh-session' const SURFACE = { surfaceOp: 'append' as const } -function seqOf(session: Session, type: SessionEvent['type'], nth = 0): number { +function seqOf(session: Session, type: SessionEvent['type'], nth = 0): SessionSeqType { return session.snapshotEvents().filter(event => event.type === type)[nth]!.seq } -function surfaceSeq(session: Session, seq: number): number { +function surfaceSeq(session: Session, seq: SessionSeqType): SessionSeqType { const current = session.surface.nodes.find(candidate => candidate === seq) if (current === undefined) throw new Error(`seq ${seq} is not on the surface`) return current @@ -188,7 +188,7 @@ describe('tool-pairing surface identity', () => { it('rejects missing seqs before and after, including an empty surface', () => { const session = Session.create(SessionId('missing-membership')) - const missing = 999 + const missing = SessionSeq(999) expect(() => toolPairingBalancedBefore(session, missing)).toThrow(/surface seq 999 not found/) expect(() => toolPairingBalancedAfter(session, missing)).toThrow(/surface seq 999 not found/) @@ -204,14 +204,14 @@ describe('tool-pairing cache refresh', () => { it('does no event reads for unchanged or log-only growth, folds only appended nodes, and rebuilds on replace', () => { const events: SessionEvent[] = [ { - type: 'user/message', seq: 0, time: 0, + type: 'user/message', seq: SessionSeq(0), time: 0, data: createUserMessage({ content: [{ type: 'text', text: 'user' }], source: { kind: 'user' }, }), surfaceOp: 'append', }, { - type: 'assistant/message', seq: 1, time: 1, + type: 'assistant/message', seq: SessionSeq(1), time: 1, data: { turn: 1, step: 1, @@ -227,7 +227,7 @@ describe('tool-pairing cache refresh', () => { surfaceOp: 'append', }, { - type: 'tool/result', seq: 2, time: 2, + type: 'tool/result', seq: SessionSeq(2), time: 2, data: { turn: 1, step: 1, message: createToolResultMessage({ @@ -239,7 +239,7 @@ describe('tool-pairing cache refresh', () => { surfaceOp: 'append', }, ] - const nodes: number[] = [0, 1, 2] + const nodes: SessionSeqType[] = [SessionSeq(0), SessionSeq(1), SessionSeq(2)] let generation = 0 let eventReads = 0 const surface = { @@ -262,25 +262,25 @@ describe('tool-pairing cache refresh', () => { expect(eventReads).toBe(3) events.push({ - type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } }, + type: 'turn/end', seq: SessionSeq(3), time: 3, data: { turn: 1, reason: { kind: 'completed' } }, }) expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true) expect(eventReads).toBe(3) events.push({ - type: 'user/message', seq: 4, time: 4, + type: 'user/message', seq: SessionSeq(4), time: 4, data: createUserMessage({ content: [{ type: 'text', text: 'tail' }], source: { kind: 'user' }, }), surfaceOp: 'append', }) - nodes.push(4) + nodes.push(SessionSeq(4)) expect(toolPairingBalancedAfter(session, nodes[3]!)).toBe(true) expect(eventReads).toBe(4) events.push( { - type: 'assistant/message', seq: 5, time: 5, + type: 'assistant/message', seq: SessionSeq(5), time: 5, data: { turn: 2, step: 1, @@ -296,7 +296,7 @@ describe('tool-pairing cache refresh', () => { surfaceOp: 'append', }, { - type: 'tool/result', seq: 6, time: 6, + type: 'tool/result', seq: SessionSeq(6), time: 6, data: { turn: 2, step: 1, message: createToolResultMessage({ @@ -308,18 +308,18 @@ describe('tool-pairing cache refresh', () => { surfaceOp: 'append', }, ) - nodes.push(5, 6) + nodes.push(SessionSeq(5), SessionSeq(6)) expect(toolPairingBalancedAfter(session, nodes[5]!)).toBe(true) expect(eventReads).toBe(6) events.push({ - type: 'user/message', seq: 7, time: 7, + type: 'user/message', seq: SessionSeq(7), time: 7, data: createUserMessage({ content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' }, }), - surfaceOp: { op: 'replace', start: 0, end: 6 }, + surfaceOp: { op: 'replace', start: SessionSeq(0), end: SessionSeq(6) }, }) - nodes.splice(0, nodes.length, 7) + nodes.splice(0, nodes.length, SessionSeq(7)) generation += 1 expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true) expect(eventReads).toBe(7) @@ -328,19 +328,19 @@ describe('tool-pairing cache refresh', () => { it('rebuilds defensively when a same-generation surface entry count regresses', () => { const events: SessionEvent[] = [ { - type: 'user/message', seq: 0, time: 0, + type: 'user/message', seq: SessionSeq(0), time: 0, data: createUserMessage({ content: [], source: { kind: 'user' }, }), surfaceOp: 'append', }, { - type: 'user/message', seq: 1, time: 1, + type: 'user/message', seq: SessionSeq(1), time: 1, data: createUserMessage({ content: [], source: { kind: 'user' }, }), surfaceOp: 'append', }, ] - const nodes: number[] = [0, 1] + const nodes: SessionSeqType[] = [SessionSeq(0), SessionSeq(1)] const session = { eventAt: (seq: number) => events[seq], surface: { nodes, replaceGeneration: 0 }, @@ -384,10 +384,10 @@ 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 missingSeq = SessionSeq(1) const missing = { eventAt: (seq: number) => [{ - type: 'user/message', seq: 0, time: 0, + type: 'user/message', seq: SessionSeq(0), time: 0, data: createUserMessage({ content: [], source: { kind: 'user' }, }), surfaceOp: 'append', @@ -396,10 +396,10 @@ describe('tool-pairing corrupt surfaces', () => { } as unknown as Session expect(() => toolPairingBalancedBefore(missing, missingSeq)).toThrow(/no matching session event/) - const mismatchedSeq = 0 + const mismatchedSeq = SessionSeq(0) const mismatched = { eventAt: (seq: number) => [{ - type: 'user/message', seq: 99, time: 0, + type: 'user/message', seq: SessionSeq(99), time: 0, data: createUserMessage({ content: [], source: { kind: 'user' }, }), surfaceOp: 'append', diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 37e9db46a9..af270437b1 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -6,7 +6,7 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-agent-instructions' import LlmRuntime, { createUserMessage, ToolCallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, SessionSeq, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop, { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' @@ -190,7 +190,9 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace 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 }) + const session = Session.create(id, seed, cwd === undefined + ? undefined + : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd, isSeeded: false }) return { ctx: new Context(), id: SessionId('a1'), @@ -253,9 +255,9 @@ function baselineEvents(agent: Agent): SessionEvent[] { && event.data.source.baseline === true) } -async function appendAdditionalContexts(ctx: Context, agent: Agent): Promise { +async function appendAdditionalContexts(ctx: Context, agent: Agent): Promise { await syncedWorkspaceContext(ctx, agent) - let lastSeq: number | undefined + let lastSeq: SessionSeq | undefined for (const claimed of agent.inbox.claim('next-step', 1)) { if (claimed.source.kind !== 'agent-instructions') continue const event = agent.session.append('user/message', claimed, { surfaceOp: 'append' }) @@ -3620,8 +3622,8 @@ describe('dynamic nested workspace context injection', () => { content: [{ type: 'text', text: 'compacted summary' }], source: { kind: 'plugin', plugin: 'compact' }, }), { - surfaceOp: { op: 'replace', start: contextSeq, end: contextSeq }, - sourceEventSeqs: [contextSeq], + surfaceOp: { op: 'replace', start: SessionSeq(contextSeq), end: SessionSeq(contextSeq) }, + sourceEventSeqs: [SessionSeq(contextSeq)], }) const afterCompact = await ctx.tools.execute({ diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 14ce656f94..fb7c6ca22a 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -11,6 +11,7 @@ import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' // Type-only: the `title` projection key plus the live registry and durable // cache Context merges — the two projection faces discovery labels from. @@ -229,7 +230,12 @@ export class SessionReferenceResolver extends TypertRemoteService { if (attached !== undefined && projections !== undefined) { return titleOf(projections.snapshot(attached, ['title'])) } - return titleOf(this.ctx.get('sessionProjectionCache')?.cachedSnapshot(record.header, ['title'])) + if (record.header.isSeeded) return undefined + return titleOf(this.ctx.get('sessionProjectionCache')?.cachedSnapshot( + record.header, + SessionLogOffset(0), + ['title'], + )) } /** diff --git a/packages/context/session-reference/src/projection.ts b/packages/context/session-reference/src/projection.ts index 83efaf5d87..06f0b83489 100644 --- a/packages/context/session-reference/src/projection.ts +++ b/packages/context/session-reference/src/projection.ts @@ -4,6 +4,8 @@ import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compaction' import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query' import { TextRetainer } from '@deepseek-ai/dsh-output-retention' import { assertNever } from '@deepseek-ai/dsh-util-values' +import { SessionSeq } from '@deepseek-ai/dsh-session' +import type { OptionalSessionSeq } from '@deepseek-ai/dsh-session' import { stringifyTagSafeJson } from './serialization.ts' import type { ReferencedConversationItem } from './types.ts' @@ -18,7 +20,7 @@ export interface ReferencedSessionData { sessionId: string label: string cwd: string | null - capturedThroughSeq: number | null + capturedThroughSeq: OptionalSessionSeq conversation: ReferencedConversationItem[] } @@ -79,7 +81,9 @@ export function retainReferencedSession( sessionId: snapshot.session.id, label, cwd: snapshot.session.cwd ?? null, - capturedThroughSeq: snapshot.capturedThroughSeq, + capturedThroughSeq: snapshot.capturedThroughSeq === null + ? null + : SessionSeq(snapshot.capturedThroughSeq), conversation: retained.map(({ role, text }) => ({ role, text })), }) const size = (): number => Buffer.byteLength(stringifyTagSafeJson(data()), 'utf8') diff --git a/packages/context/session-reference/src/types.ts b/packages/context/session-reference/src/types.ts index 87c11cd4c2..47965e9528 100644 --- a/packages/context/session-reference/src/types.ts +++ b/packages/context/session-reference/src/types.ts @@ -7,7 +7,7 @@ import type { UserMessage } from '@deepseek-ai/dsh-llm/message' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { OptionalSessionSeq, SessionId } from '@deepseek-ai/dsh-session/types' /** Durable source session, cited event seqs, and snapshot facts for prepared cross-session context. */ export interface SessionReferenceSource { @@ -18,7 +18,7 @@ export interface SessionReferenceSource { references: { sessionId: string label: string - capturedThroughSeq: number | null + capturedThroughSeq: OptionalSessionSeq compacted: boolean originalMessages: number retainedMessages: number diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index d2b2a205ba..07eb12113d 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compaction' import { createUserMessage, ToolCallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' import SessionTitleService from '@deepseek-ai/dsh-session-title' @@ -55,7 +55,7 @@ async function harness(config: Config = {}): Promise { function withProjectionCache(ctx: Context, rows: Record): void { ctx.provide('sessionProjectionCache', { cachedSnapshot: (meta: { id: SessionId }) => ( - meta.id in rows ? { asOfSeq: 0, values: { title: rows[meta.id] } } : undefined + meta.id in rows ? { asOfSeq: SessionSeq(0), values: { title: rows[meta.id] } } : undefined ), }) } @@ -343,9 +343,15 @@ describe('session reference discovery and preparation', () => { it('labels a session no projection answers for by its id, still without a log read', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) - const seeded = { id: SessionId('seeded'), createdAt: 10, cwd: '/same' } + const seeded = { + version: 0, + id: SessionId('seeded'), + createdAt: 10, + cwd: '/same', + isSeeded: true, + } // Persisted before the cache was composed: the title lives only in its log. - withProjectionCache(ctx, {}) + withProjectionCache(ctx, { seeded: 'Unsafe body-free title' }) vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([ { header: seeded, live: false, persisted: true }, ] as never) diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 87f09e2241..02cf81d738 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -11,6 +11,7 @@ import { z as zod } from 'zod' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-llm' +import { SessionSeq } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-projection' import { deriveBrowserTimeZoneContext, @@ -79,7 +80,7 @@ function formatDuration(elapsedMs: number): string { function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] { const entered: UserMessage[] = [] for (let seq = agent.session.seq - 1; seq >= 0; seq -= 1) { - const event = agent.session.eventAt(seq) + const event = agent.session.eventAt(SessionSeq(seq)) if (event?.type === 'turn/start' && event.data.turn === turn) { return [...entered.reverse(), ...proposed] } diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index c294890045..4abc2059a5 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -2,7 +2,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session' import * as TimeInvariant from '@deepseek-ai/dsh-time-context/invariant' import InvariantRegistry from '@deepseek-ai/dsh-invariants' @@ -24,7 +24,7 @@ function event( ): SessionEvent<'user/message'> { return { type: 'user/message', - seq: 0, + seq: SessionSeq(0), time, data: createUserMessage({ content: (content ?? [{ type: 'text', text }]) as ContentBlock[], @@ -322,9 +322,9 @@ describe('time-context invariants', () => { expect(() => { ctx.sessions.create(SessionId('time-invariant-created-invalid'), { seed: [ - { type: 'turn/start', seq: 0, time: SECOND, data: { turn: 1 } }, - { type: 'step/start', seq: 1, time: SECOND, data: { turn: 1, step: 1 } }, - { ...event(text), seq: 2, surfaceOp: 'append' }, + { type: 'turn/start', seq: SessionSeq(0), time: SECOND, data: { turn: 1 } }, + { type: 'step/start', seq: SessionSeq(1), time: SECOND, data: { turn: 1, step: 1 } }, + { ...event(text), seq: SessionSeq(2), surfaceOp: 'append' }, ], }) }).toThrow(/expected turn 1\/step 1/) @@ -345,7 +345,7 @@ describe('time-context invariants', () => { expect(() => { ctx.emit('session/event', preparing(1, 1), user) }).not.toThrow() expect(() => { ctx.emit('session/event', preparing(1, 1), { - type: 'turn/start', seq: 0, time: 0, data: { turn: 1 }, + type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 }, }) ctx.emit('tools/change') }).not.toThrow() diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index bf020b457a..1b8448e693 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -27,7 +27,7 @@ import { import { deepFreeze } from '@deepseek-ai/dsh-util-values' import type { Scope } from '@deepseek-ai/dsh-scope' import { createScope } from '@deepseek-ai/dsh-scope' -import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session' +import type { EpochHeader, RequestContext, Session, SessionId, SessionSeq, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session' import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' @@ -359,7 +359,7 @@ export class ReactLoopAgent implements Agent { ) startsRequestSeries = false const assembler = new BlockAssembler() - const chunkSeqs: number[] = [] + const chunkSeqs: SessionSeq[] = [] try { const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request) signal.throwIfAborted() diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index ba784df9d1..572f2bcc7b 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -24,7 +24,7 @@ import type { } from '@deepseek-ai/dsh-agent' import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-settings' -import { SessionPreparation } from '@deepseek-ai/dsh-session' +import { SessionPreparation, SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' @@ -42,11 +42,11 @@ const INACTIVE_STATES: ReadonlySet = new Set([ ]) const turnBoundaryProjectionSchema: zod.ZodType = zod.object({ - openTurnStartSeq: zod.number().int().nonnegative().nullable(), - lastStepStartSeq: zod.number().int().nonnegative().nullable(), + openTurnStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(), + lastStepStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(), lastStepBoundary: zod.object({ kind: zod.union([zod.literal('start'), zod.literal('end')]), - seq: zod.number().int().nonnegative(), + seq: zod.number().int().nonnegative().transform(SessionSeq), }).nullable(), lastTurn: zod.number().int().nonnegative(), }) @@ -670,6 +670,7 @@ export class AgentLoop extends Service implements AgentFactory { const preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(options.sessionId, { ...options.seed === undefined ? {} : { seed: options.seed }, ...options.meta === undefined ? {} : { meta: options.meta }, + ...options.inheritedEventCount === undefined ? {} : { inheritedEventCount: options.inheritedEventCount }, })) const published = this.setupAndPublish( ownerCtx, diff --git a/packages/core/agent-loop/src/runtime-context.ts b/packages/core/agent-loop/src/runtime-context.ts index e83f0dfc9d..19a3fd0110 100644 --- a/packages/core/agent-loop/src/runtime-context.ts +++ b/packages/core/agent-loop/src/runtime-context.ts @@ -6,7 +6,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextSnapshotSection } from '@deepseek-ai/dsh-llm' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' -import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session' +import { isReplacementSurfaceEvent, SessionSeq } from '@deepseek-ai/dsh-session' import type { Context } from '@deepseek-ai/cordis' const SOURCE = '@deepseek-ai/dsh-system-prompt' @@ -24,7 +24,7 @@ function textOf(message: UserMessage): string | undefined { /** Tracks the last retained runtime-context snapshot without owning its commit. */ export class RuntimeContextProjection { /** `undefined` means no snapshot ever existed; `null` means none is retained. */ - private retained: { seq: number; text: string | undefined } | null | undefined + private retained: { seq: SessionSeq; text: string | undefined } | null | undefined /** * Restore projection state once, then follow authoritative session events. @@ -34,7 +34,7 @@ export class RuntimeContextProjection { constructor(ctx: Context, session: Session) { const surface = new Set(session.surface.nodes) for (let index = session.seq - 1; index >= 0; index -= 1) { - const event = session.eventAt(index) + const event = session.eventAt(SessionSeq(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/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index bbf616f364..e6aca31f56 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -13,7 +13,7 @@ import type { Context } from '@deepseek-ai/cordis' import { createToolResultMessage, type ToolCallBlock } from '@deepseek-ai/dsh-llm' -import type { Session, UserMessage } from '@deepseek-ai/dsh-session' +import type { Session, SessionSeq, UserMessage } from '@deepseek-ai/dsh-session' import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_RUNTIME_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools' import { assertNever } from '@deepseek-ai/dsh-util-values' @@ -132,7 +132,7 @@ async function runGroup( const { maxParallelToolCalls } = ctx.agentLoop.config const slots: (Slot | undefined)[] = group.map(() => undefined) // Started slots retain their `tool/call` seq so the result can cite it. - const callSeqs: number[] = group.map(() => -1) + const callSeqs: Array = group.map(() => undefined) let nextToStart = 0 let committed = 0 let started = 0 @@ -260,7 +260,7 @@ function appendSkippedToolCall(session: Session, turn: number, step: number, blo } /** Append a started call and return the event seq that its result must cite. */ -function appendToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): number { +function appendToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): SessionSeq { const event = session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments }) return event.seq } @@ -272,7 +272,7 @@ function appendToolResult( step: number, block: ToolCallBlock, result: ToolExecutionResult, - callSeq: number, + callSeq: SessionSeq, ): void { const message = createToolResultMessage({ callId: block.id, diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 320d5ce81f..457de1a306 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -5,8 +5,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import LlmRuntime from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId, SessionPreparation } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionPreparation, SessionSeq } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' @@ -45,8 +45,8 @@ async function persistSession(sessionId: SessionId): Promise { // balanced completed turn is the smallest resumable log and avoids running // the model merely to construct this lifecycle fixture. const seed: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, ] const session = ctx.sessions.create(sessionId, { seed }) await ctx.sessions.flush(session) @@ -57,11 +57,16 @@ async function persistSession(sessionId: SessionId): Promise { /** Build a detached preparation for lifecycle-race test doubles. */ function preparationFromSnapshot( ctx: Context, - snapshot: { meta: SessionHeader; events: readonly SessionEvent[] }, + snapshot: { + meta: SessionHeader + inheritedEventCount: SessionLogOffsetType + events: readonly SessionEvent[] + }, ): SessionPreparation { return SessionPreparation.create(ctx.sessions.prepare(snapshot.meta.id, { seed: structuredClone(snapshot.events) as SessionEvent[], meta: structuredClone(snapshot.meta), + inheritedEventCount: snapshot.inheritedEventCount, seedSource: 'persistence', })) } @@ -98,6 +103,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, + isSeeded: false, }) await first.ctx.sessionPersistence.append(sessionId, [ { @@ -565,27 +571,26 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', }) it('resume of a forked session preserves the lineage, seed boundary, and delegation depth in the header', async () => { - // Lifecycle 1: persist a FORKED session (carries parentSession + seedLength - // in its header) by creating it with a complete-turn seed — the write path + // Lifecycle 1: persist a FORKED session (carries parentSession + isSeeded + // in its header and an exact Session-owned cut) with a complete-turn seed — the write path // materializes the fork (header + seed) on disk. const seed: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, ] const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const forked = ctx1.sessions.create(SessionId('forked-sess'), { seed, - meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length, delegationDepth: 1 }, + inheritedEventCount: SessionLogOffset(seed.length), + meta: { cwd: '/w', parentSession: SessionId('parent-sess'), isSeeded: true, delegationDepth: 1 }, }) await ctx1.sessions.flush(forked) await ctx1.fiber.dispose() - // Lifecycle 2: resume it; the parentSession + seedLength header survives the - // round-trip (exercises resume's parentSession- and seedLength-present - // branches). seedLength must come from the PERSISTED header, not from the - // resume seed length (which is the whole stored log, not the original - // boundary). + // Lifecycle 2: resume it; parentSession, isSeeded, and the exact cut survive + // the round-trip. The inherited count must come from persisted storage, + // not the resume seed length (the whole stored log). const adapter2 = new MockAdapter([textResponse('b')]) const ctx2 = new Context() await ctx2.plugin(LlmRuntime) @@ -600,7 +605,8 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent expect(a2.session.header.parentSession).toBe('parent-sess') expect(a2.session.header.cwd).toBe('/w') - expect(a2.session.header.seedLength).toBe(seed.length) + expect(a2.session.header.isSeeded).toBe(true) + expect(a2.session.inheritedEventCount).toBe(seed.length) // The recursion budget survives resume — a dropped depth would let a // resumed child delegate as if it were top-level. expect(a2.session.header.delegationDepth).toBe(1) diff --git a/packages/core/agent/src/inbox.ts b/packages/core/agent/src/inbox.ts index fccfdcba64..c5d50b222a 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.snapshotEvents(session.header.seedLength ?? 0)) { + for (const event of session.ownEvents()) { if (event.type !== 'agent/inbox/spliced') continue try { this.apply(event.data) diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index ef7371231b..6e3db1edb0 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -11,7 +11,7 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { isPromise } from 'node:util/types' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Agent } from './types.ts' import type { AgentOptions } from './runtime-types.ts' @@ -73,9 +73,9 @@ export interface CreateAgentOptions { readonly sessionId: SessionId /** * Session creation metadata: validated absolute `cwd`, `parentSession` - * fork lineage, the `seedLength` seed boundary, the coarse `origin` + * fork lineage, the `isSeeded` fork marker, the coarse `origin` * classification, and the `delegationDepth` recursion budget. Mirrors the - * `cwd`/`parentSession`/`seedLength`/`origin`/`delegationDepth` fields of + * `cwd`/`parentSession`/`isSeeded`/`origin`/`delegationDepth` fields of * {@link CreateSessionOptions.meta} in dsh-session (the internal-only * `createdAt`, used when reconstructing a persisted session, is deliberately * excluded — a factory caller never sets it). This is durable session data, @@ -85,11 +85,13 @@ export interface CreateAgentOptions { readonly meta?: { readonly cwd?: string readonly parentSession?: SessionId - readonly seedLength?: number + readonly isSeeded?: boolean readonly origin?: 'subagent' readonly delegationDepth?: number readonly agentPreset?: string } + /** Exact fork-inherited prefix length when the session metadata sets `isSeeded`. */ + readonly inheritedEventCount?: SessionLogOffset /** * Initial replay/fork history. A fork supplies a balanced completed-turn * prefix of the parent's log. The complete seed must be contiguous from seq diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index e570ccf7e7..c7b82e243e 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -5,7 +5,7 @@ */ import type { UserMessage } from '@deepseek-ai/dsh-llm/types' -import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { OptionalSessionSeq, SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' import type { TypertContext, TypertLookup } from '@deepseek-ai/dsh-typert-protocol' /** Public live-agent handle; the runtime face augments its live capabilities. */ @@ -39,11 +39,11 @@ export type InboxTarget = 'next-turn' | 'next-step' */ export interface TurnBoundaryProjection { /** Seq of the open turn's `turn/start`, or null between turns. */ - readonly openTurnStartSeq: number | null + readonly openTurnStartSeq: OptionalSessionSeq /** Seq of the latest `step/start` event, or null before the first step. */ - readonly lastStepStartSeq: number | null + readonly lastStepStartSeq: OptionalSessionSeq /** The latest step boundary (`step/start` or `step/end`) and its seq, or null before the first step boundary. */ - readonly lastStepBoundary: { readonly kind: 'start' | 'end'; readonly seq: number } | null + readonly lastStepBoundary: { readonly kind: 'start' | 'end'; readonly seq: SessionSeq } | null /** Turn number of the latest `turn/start`; 0 before the first turn. */ readonly lastTurn: number } diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 5475521b65..71c2828f9c 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: fde5d3846ef36dff56b7656068603ae9dd148fcf -README.zh.md: 78c52323fe6a541b2d73b55195aaf7b4274b1411 +README.md: f5cf910854203021a619cc786dfd13705927ffc1 +README.zh.md: 385d7fd63a6e4dec9c23c9d38a352942d7dbc7f9 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index fde5d3846e..f5cf910854 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -53,10 +53,14 @@ 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 log positions use two numeric types. `SessionSeq` identifies an existing event or inclusive event watermark; `SessionLogOffset` identifies a gap, prefix length, or read boundary and may equal the event count. `SessionSeqCursor` adds the `-1` “no event yet” value, while `OptionalSessionSeq` uses `null` when absence is data. The constructors validate non-negative safe integers, and the brands disappear at runtime, so durable JSON and wire values remain ordinary numbers. + ### 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. +The logical `SessionHeader.isSeeded` field reports whether fork history exists without exposing a positional integer. `Session.inheritedEventCount` retains the exact checked `SessionLogOffset`; `ownEvents()` returns events at and after that cut, and `isOwnSeq(seq)` accepts only an existing child-owned position. A low-level seeded constructor must supply an explicit `seed` and `inheritedEventCount` because the constructor seed can contain child-owned setup events after the inherited prefix. + ### Flush durable state `ctx.sessions.flush(session)` dispatches the awaited durability checkpoint: every persistence listener flushes and the call settles after all of them. A producer that needs an immediate durability barrier awaits it instead of assuming the write-behind drained. @@ -94,7 +98,7 @@ The package is built on event sourcing: a `Session` is an append-only log of typ ### Append validation -Every append uses the shared iterative `snapshotJsonValue()` pass, which reads, validates, and copies each nested value once, so a stateful getter cannot supply one value to validation and another to storage. Non-lossless-JSON payloads (BigInt, cycles, sparse arrays, `-0`, exotic prototypes) are rejected at the append site, before any backend flush. Surface events additionally validate marker shape, cited source-event seqs, and complete shadowed-node coverage for replacements. +Every append uses the shared iterative `snapshotJsonValue()` pass, which reads, validates, and copies each nested value once, so a stateful getter cannot supply one value to validation and another to storage. Non-lossless-JSON payloads (BigInt, cycles, sparse arrays, `-0`, exotic prototypes) are rejected at the append site, before any backend flush. The append path constructs each `SessionSeq`; surface events additionally validate marker shape, cited source-event sequences, and complete shadowed-node coverage for replacements. ### Derived history diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 78c52323fe..385d7fd63a 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -53,10 +53,14 @@ session.deriveMessages() // the derived model history `session.seq` 无需物化数组即可读取当前日志长度,`session.eventAt(seq)` 按序列号读取单个已接受且深度冻结的事件。`session.snapshotEvents(fromSeq?, toSeqExclusive?)` 会物化半开区间的冻结稳定快照;当前完整快照会缓存到下一次追加。只需要长度或单个事件的调用方使用 `seq` 或 `eventAt()`。 +会话日志位置使用两种数字类型。`SessionSeq` 标识已有事件或包含端点的事件水位;`SessionLogOffset` 标识间隙、前缀长度或读取边界,并且可以等于事件数量。`SessionSeqCursor` 添加 `-1` 这个“尚无事件”值,`OptionalSessionSeq` 则在缺失本身属于数据时使用 `null`。构造函数会校验非负安全整数,品牌在运行时会被擦除,因此持久 JSON 与 wire 值仍是普通数字。 + ### 派生会话的 fork `ctx.sessions.fork(source, boundary?, childSessionId?)` 选取截至 `boundary` 事件序号(含该事件)的源事件(默认:当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。必须在轮次中途分支的工具时委派会裁剪到已完成前缀。 +逻辑 `SessionHeader.isSeeded` 字段报告是否存在 fork 历史,而不公开位置整数。`Session.inheritedEventCount` 保留经过校验的精确 `SessionLogOffset`;`ownEvents()` 返回从该切点开始的事件,`isOwnSeq(seq)` 只接受已存在且由 child 拥有的位置。底层带 seed 构造必须显式提供 `seed` 与 `inheritedEventCount`,因为构造 seed 可以在继承前缀之后包含 child 自有的设置事件。 + ### 刷新持久状态 `ctx.sessions.flush(session)` 分发需等待完成的持久性检查点:每个持久化监听器都会刷新,调用在所有监听器结算后完成。需要立即持久性屏障的生产方应等待它,而不是假定写后刷新已完成。 @@ -94,7 +98,7 @@ session.deriveMessages() // the derived model history ### 追加校验 -每次追加都会使用共享的迭代式 `snapshotJsonValue()` 流程,对每个嵌套值只读取、校验并复制一次,因此有状态的 getter 无法给校验提供一个值、给存储提供另一个值。非无损 JSON 载荷(BigInt、循环、稀疏数组、`-0`、特殊原型)会在追加位置被拒绝,先于任何后端刷新。表层事件还会校验标记形态、被引用的源事件 seq,以及替换的完整遮蔽节点覆盖。 +每次追加都会使用共享的迭代式 `snapshotJsonValue()` 流程,对每个嵌套值只读取、校验并复制一次,因此有状态的 getter 无法给校验提供一个值、给存储提供另一个值。非无损 JSON 载荷(BigInt、循环、稀疏数组、`-0`、特殊原型)会在追加位置被拒绝,先于任何后端刷新。追加路径会构造每个 `SessionSeq`;surface 事件还会校验标记形态、被引用的源事件序号,以及替换的完整遮蔽节点覆盖。 ### 派生历史 diff --git a/packages/core/session/src/chunk-rows.ts b/packages/core/session/src/chunk-rows.ts index 0feda80880..8289b59b5a 100644 --- a/packages/core/session/src/chunk-rows.ts +++ b/packages/core/session/src/chunk-rows.ts @@ -22,7 +22,8 @@ import { brandString } from '@deepseek-ai/dsh-brand' import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from './types.ts' +import { SessionSeq } from './types.ts' +import type { SessionEvent, SessionSeq as SessionSeqType } from './types.ts' /** The chunk kinds that may pack; block boundaries, usage, and finish chunks always stay one event per line. */ type DeltaKind = 'text-delta' | 'reasoning-delta' | 'tool-call-delta' @@ -64,9 +65,9 @@ interface ToolCallRunData extends RunDataBase { * {@link TextRunData} payload, tool-call rows carry {@link ToolCallRunData}. */ export type ChunkRow = - | { type: 'text-chunks'; seq0: number; time0: number; data: TextRunData } - | { type: 'reasoning-chunks'; seq0: number; time0: number; data: TextRunData } - | { type: 'tool-call-chunks'; seq0: number; time0: number; data: ToolCallRunData } + | { type: 'text-chunks'; seq0: SessionSeqType; time0: number; data: TextRunData } + | { type: 'reasoning-chunks'; seq0: SessionSeqType; time0: number; data: TextRunData } + | { type: 'tool-call-chunks'; seq0: SessionSeqType; time0: number; data: ToolCallRunData } /** One durable log line's JSON value: a session event verbatim, or a packed chunk row. */ export type StorageRecord = SessionEvent | ChunkRow @@ -118,7 +119,8 @@ function hasExactKeys(value: object, keys: readonly string[]): boolean { function classify(event: SessionEvent): DeltaKind | undefined { if (event.type !== 'assistant/chunk') return undefined if (!hasExactKeys(event, ['type', 'seq', 'time', 'data'])) return undefined - if (!Number.isSafeInteger(event.seq) || event.seq < 0 || !Number.isSafeInteger(event.time)) return undefined + if (!Number.isSafeInteger(event.seq) || event.seq < 0 || Object.is(event.seq, -0) + || !Number.isSafeInteger(event.time)) return undefined const data: unknown = event.data if (!isRecord(data) || !hasExactKeys(data, ['turn', 'step', 'chunk'])) return undefined if (typeof data.turn !== 'number' || typeof data.step !== 'number') return undefined @@ -271,7 +273,7 @@ function validateRow(value: Record, tag: ChunkRow['type']): Chu if (!hasExactKeys(value, ['type', 'seq0', 'time0', 'data'])) { malformed(tag, 'envelope must be exactly {type, seq0, time0, data}') } - if (!Number.isSafeInteger(value.seq0) || (value.seq0 as number) < 0) { + if (!Number.isSafeInteger(value.seq0) || (value.seq0 as number) < 0 || Object.is(value.seq0, -0)) { malformed(tag, 'seq0 must be a non-negative safe integer') } if (!Number.isSafeInteger(value.time0)) { @@ -308,6 +310,7 @@ function validateRow(value: Record, tag: ChunkRow['type']): Chu time += gap if (!Number.isSafeInteger(time)) malformed(tag, 'member times must stay safe integers') } + SessionSeq(value.seq0 as number) return value as unknown as ChunkRow } @@ -343,7 +346,7 @@ function expandRow(row: ChunkRow): SessionEvent[] { } events.push({ type: 'assistant/chunk', - seq: row.seq0 + k, + seq: SessionSeq(row.seq0 + k), time, data: { turn: row.data.turn, step: row.data.step, chunk }, }) @@ -355,7 +358,8 @@ function expandRow(row: ChunkRow): SessionEvent[] { * Decode one parsed JSONL line value into the session event(s) it stores. * Chunk-row-tagged values validate and expand (a malformed row throws — it is * corrupt storage, and treating it as an event would silently drop a whole - * run); every other value passes through as a single event, unvalidated. + * run); every other value passes through as a single event after admitting a + * numeric `seq` through the Session-sequence constructor. * * @param value - one line's `JSON.parse` result. * @returns the stored events, in log order. @@ -364,7 +368,8 @@ export function decodeStorageRecord(value: unknown): SessionEvent[] { if (!isRecord(value)) return [value as SessionEvent] const tag = value.type if (tag !== 'text-chunks' && tag !== 'reasoning-chunks' && tag !== 'tool-call-chunks') { - return [value as SessionEvent] + if (typeof value.seq === 'number') SessionSeq(value.seq) + return [value as unknown as SessionEvent] } return expandRow(validateRow(value, tag)) } diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index bf08a17535..c55cc5fb6a 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -13,7 +13,7 @@ import { deepFreeze, snapshotJsonValue } from '@deepseek-ai/dsh-util-values' import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Message } from '@deepseek-ai/dsh-llm' -import { SESSION_FORMAT_VERSION } from './types.ts' +import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from './types.ts' import type { TypertLookup } from '@deepseek-ai/dsh-typert-protocol' import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SurfaceIntent, SurfaceEventType } from './types.ts' import { deriveEventMessage, SurfaceManager } from './surface.ts' @@ -96,6 +96,9 @@ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader { throw new Error('session header is not a plain JSON record') } const record = input as Record + if (Object.hasOwn(record, 'seedLength')) { + throw new Error('session header has invalid field "seedLength"') + } if (record.version !== SESSION_FORMAT_VERSION) { throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`) } @@ -116,9 +119,8 @@ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader { if (record.parentSession !== undefined && typeof record.parentSession !== 'string') { throw new Error('session header parentSession must be a string') } - if (record.seedLength !== undefined - && (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) { - throw new Error('session header seedLength must be a non-negative safe integer') + if (typeof record.isSeeded !== 'boolean') { + throw new Error('session header isSeeded must be a boolean') } if (record.origin !== undefined && record.origin !== 'subagent') { throw new Error('session header origin must be "subagent"') @@ -147,7 +149,7 @@ function validateRestoredSessionHeader(id: SessionId, input: unknown): SessionHe /** Detach, validate, and freeze the creation metadata published by a session. */ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader { const input: unknown = source === undefined - ? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() } + ? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now(), isSeeded: false } : source const snapshot = snapshotJsonValue(input) if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable') @@ -231,7 +233,7 @@ function assertSessionEventEnvelope(value: Record, index: numbe const seq = event['seq'] const time = event['time'] if (typeof type !== 'string' - || typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0 + || typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0 || Object.is(seq, -0) || typeof time !== 'number' || !Number.isSafeInteger(time) || event['data'] === undefined || (event['ignorable'] !== undefined && event['ignorable'] !== true)) { @@ -432,7 +434,7 @@ export class Session { /** * Detached, deep-frozen creation metadata (format version, cwd, lineage, - * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a + * and whether fork history exists). Supplied by the store via `ctx.sessions.create()`. When a * `Session` is created without a store-owned header, a minimal header is * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so * `session.header` is always present. Kept out of the event log — it is a @@ -440,6 +442,9 @@ export class Session { */ readonly header: SessionHeader + /** Number of leading events inherited from this Session's fork parent. */ + readonly inheritedEventCount: SessionLogOffset + /** The session identity, derived from its durable header's single copy. */ get id(): SessionId { return this.header.id @@ -451,9 +456,9 @@ export class Session { * construction — replay, fork, or resume — and were never published on the * `session/event` firehose (constructor seeds do not emit), so consumers * that replay the log as a publication substitute (telemetry adoption) - * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage - * boundary: a resumed session's constructor seed is its full stored log, - * while its header keeps the original fork value — this field is the + * start here. Distinct from {@link inheritedEventCount}, the DURABLE + * fork-lineage cut: a resumed session's constructor seed is its full stored + * log, while the inherited count keeps the original fork value — this field is the * in-process construction fact. * * Not persisted itself: a seeded session projects it into the log as the @@ -467,7 +472,7 @@ export class Session { * store attaches and therefore does not publish either. Otherwise this seq * holds an ordinary published write. */ - readonly firstLiveSeq: number + readonly firstLiveSeq: SessionLogOffset /** * Create a detached session by validating and snapshotting borrowed seed @@ -475,10 +480,16 @@ export class Session { * @param id - session identity. * @param seed - optional borrowed replay or fork events. * @param header - optional borrowed storage metadata. + * @param inheritedEventCount - exact fork-inherited prefix length for a seeded header. * @returns a detached session. */ - static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session { - return new Session(id, seed, header) + static create( + id: SessionId, + seed?: readonly SessionEvent[], + header?: SessionHeader, + inheritedEventCount?: SessionLogOffset, + ): Session { + return new Session(id, seed, header, 'snapshot', inheritedEventCount) } /** @@ -488,10 +499,16 @@ export class Session { * @param id - restored session identity. * @param seed - fresh detached events whose ownership is transferred. * @param header - fresh detached metadata whose ownership is transferred. + * @param inheritedEventCount - exact fork-inherited prefix length decoded from storage. * @returns a restored detached session. */ - static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session { - return new Session(id, seed, header, 'restore') + static fromRestore( + id: SessionId, + seed: readonly SessionEvent[], + header: SessionHeader, + inheritedEventCount: SessionLogOffset, + ): Session { + return new Session(id, seed, header, 'restore', inheritedEventCount) } private constructor( @@ -499,6 +516,7 @@ export class Session { seed?: readonly SessionEvent[], header?: SessionHeader, mode: 'snapshot' | 'restore' = 'snapshot', + suppliedInheritedEventCount?: SessionLogOffset, ) { const restoredHeader = mode === 'restore' ? validateRestoredSessionHeader(id, header) @@ -534,8 +552,22 @@ export class Session { this.log.push(mode === 'restore' ? freezeRestoredObject(snapshot) : deepFreeze(snapshot)) } } - this.firstLiveSeq = this.log.length + this.firstLiveSeq = SessionLogOffset(this.log.length) this.header = restoredHeader ?? snapshotSessionHeader(id, header) + if (this.header.isSeeded && seed === undefined) { + throw new Error('seeded session requires an explicit constructor seed') + } + if (this.header.isSeeded && suppliedInheritedEventCount === undefined) { + throw new Error('seeded session requires an inherited event count') + } + const inheritedEventCount = SessionLogOffset(suppliedInheritedEventCount ?? 0) + if (!this.header.isSeeded && inheritedEventCount !== 0) { + throw new Error('unseeded session inherited event count must be 0') + } + if (inheritedEventCount > this.log.length) { + throw new Error('session inherited event count exceeds its event log') + } + this.inheritedEventCount = inheritedEventCount // Appended here so the marker is already in `events` when a backend // captures the creation seed: no load-time write. Re-marking is skipped // because a cold session is resumed on first touch, so repeatedly opening @@ -553,7 +585,7 @@ export class Session { * @param seq - event sequence number. * @returns the accepted event, or undefined when the log does not contain it. */ - eventAt(seq: number): SessionEvent | undefined { + eventAt(seq: SessionSeq): SessionEvent | undefined { return this.log[seq] } @@ -565,7 +597,10 @@ export class Session { * @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[] { + snapshotEvents( + fromSeq: SessionLogOffset = SessionLogOffset(0), + toSeqExclusive: SessionLogOffset = this.seq, + ): readonly SessionEvent[] { if (fromSeq === 0 && toSeqExclusive === this.log.length) { this.eventsSnapshot ??= Object.freeze([...this.log]) return this.eventsSnapshot @@ -573,9 +608,26 @@ export class Session { return Object.freeze(this.log.slice(fromSeq, toSeqExclusive)) } + /** + * Return this Session's events after its fork-inherited prefix. + * @returns a fresh array containing child-owned events in log order. + */ + ownEvents(): readonly SessionEvent[] { + return this.snapshotEvents(this.inheritedEventCount) + } + + /** + * Whether one existing event position is outside the fork-inherited prefix. + * @param seq - event position in this Session. + * @returns true when the event belongs to this Session rather than its parent. + */ + isOwnSeq(seq: SessionSeq): boolean { + return seq >= this.inheritedEventCount && seq < this.seq + } + /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ - get seq(): number { - return this.log.length + get seq(): SessionLogOffset { + return SessionLogOffset(this.log.length) } /** @@ -638,7 +690,7 @@ export class Session { } const event = deepFreeze({ type, - seq: this.log.length, + seq: SessionSeq(this.log.length), time: Date.now(), data: dataSnapshot, ...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }), @@ -882,7 +934,7 @@ export class SessionStore extends Service { } if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`) if (options?.seedSource === 'persistence') { - return Session.fromRestore(sessionId, options.seed, options.meta) + return Session.fromRestore(sessionId, options.seed, options.meta, options.inheritedEventCount) } const seed = options?.seed const meta = options?.meta @@ -892,12 +944,12 @@ export class SessionStore extends Service { createdAt: meta?.createdAt ?? Date.now(), ...meta?.cwd === undefined ? {} : { cwd: meta.cwd }, ...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession }, - ...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength }, + isSeeded: meta?.isSeeded ?? false, ...meta?.origin === undefined ? {} : { origin: meta.origin }, ...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth }, ...meta?.agentPreset === undefined ? {} : { agentPreset: meta.agentPreset }, } - return Session.create(sessionId, seed, header) + return Session.create(sessionId, seed, header, options?.inheritedEventCount) } /** @@ -1090,7 +1142,7 @@ export class SessionStore extends Service { * `SessionStore`'s id policy. * @returns The created live child session. */ - fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session { + fork(source: SessionForkSource, boundary?: SessionSeq, childSessionId?: SessionId): Session { if (childSessionId !== undefined && this.get(childSessionId) !== undefined) { throw new SessionForkError(`session "${childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS') } @@ -1098,17 +1150,18 @@ export class SessionStore extends Service { const seed = this._forkSeed(liveSource, boundary) return this.create(childSessionId, { seed, + inheritedEventCount: SessionLogOffset(seed.length), meta: { ...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {}, parentSession: liveSource.id, - seedLength: seed.length, + isSeeded: true, }, }) } - private _forkSeed(session: Session, requestedBoundary: number | undefined): readonly SessionEvent[] { - const lastEvent = session.eventAt(session.seq - 1) - let boundary: number + private _forkSeed(session: Session, requestedBoundary: SessionSeq | undefined): readonly SessionEvent[] { + const lastEvent = session.snapshotEvents().at(-1) + let boundary: SessionSeq if (requestedBoundary !== undefined) { boundary = requestedBoundary } else { @@ -1136,7 +1189,7 @@ export class SessionStore extends Service { 'INVALID_BOUNDARY', ) } - const events = session.snapshotEvents(0, boundary + 1) + const events = session.snapshotEvents(SessionLogOffset(0), SessionLogOffset(boundary + 1)) const lastTurnBoundary = events .findLast(event => event.type === 'turn/start' || event.type === 'turn/end') if (lastTurnBoundary?.type === 'turn/start') { diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index ca95319afe..e479835d3f 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -8,7 +8,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ToolCallId } from '@deepseek-ai/dsh-llm' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionSeqCursor } from '@deepseek-ai/dsh-session' import { assertNever } from '@deepseek-ai/dsh-util-values' import { TOOL_NOT_STARTED } from './repair.ts' @@ -21,7 +21,7 @@ export const inject = ['invariants'] /** Per-session bookkeeping for relational log checks. */ interface SessionTrace { - lastSeq: number + lastSeq: SessionSeqCursor openTurn: number | null openStep: number | null nextTurn: number diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index d009ceb3f5..e78187df6f 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -8,7 +8,8 @@ import { brandString } from '@deepseek-ai/dsh-brand' import type { MessageId, ToolCallId, ToolResultMessage } from '@deepseek-ai/dsh-llm' import { deepFreeze } from '@deepseek-ai/dsh-util-values' -import type { SessionEvent } from './types.ts' +import { SessionSeq } from './types.ts' +import type { SessionEvent, SessionSeq as SessionSeqType } from './types.ts' /** Recovery code for an assistant tool request that never reached a recorded call start. */ export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED' @@ -30,7 +31,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session let openStep: number | null = null // Reset at each turn boundary so earlier calls cannot leak into tail repair. // Assistant blocks register calls; later `tool/call` events add their seqs to `sourceEventSeqs`. - const pendingCalls = new Map() + const pendingCalls = new Map() for (const event of events) { switch (event.type) { case 'turn/start': @@ -109,7 +110,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session }) closers.push({ type: 'tool/result', - seq: seq++, + seq: SessionSeq(seq++), time, data: { turn: openTurn, @@ -127,8 +128,8 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session // Close an open step next — a turn/end while a step is open is an invariant // violation, so the step's boundary must be synthesized before the turn's. if (openStep !== null) { - closers.push({ type: 'step/end', seq: seq++, time, data: { turn: openTurn, step: openStep } }) + closers.push({ type: 'step/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, step: openStep } }) } - closers.push({ type: 'turn/end', seq: seq++, time, data: { turn: openTurn, reason: { kind: 'interrupted' } } }) + closers.push({ type: 'turn/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, reason: { kind: 'interrupted' } } }) return closers } diff --git a/packages/core/session/src/seq-ranges.ts b/packages/core/session/src/seq-ranges.ts index 7b2605652c..fc77fc397e 100644 --- a/packages/core/session/src/seq-ranges.ts +++ b/packages/core/session/src/seq-ranges.ts @@ -1,10 +1,13 @@ /** Lossless range encoding for JSONL `sourceEventSeqs` arrays. */ +import { SessionSeq } from './types.ts' +import type { SessionSeq as SessionSeqType } from './types.ts' + /** A stored source sequence or inclusive consecutive range. */ export type EncodedSeq = number | [number, number] -function isStrictlyIncreasing(values: readonly number[]): boolean { - return values.every((value, index) => index === 0 || value > (values[index - 1] as number)) +function isStrictlyIncreasing(values: readonly SessionSeqType[]): boolean { + return values.every((value, index) => index === 0 || value > (values[index - 1] as SessionSeqType)) } /** @@ -12,7 +15,7 @@ function isStrictlyIncreasing(values: readonly number[]): boolean { * @param values - validated in-memory source sequences. * @returns a lossless JSON storage form. */ -export function encodeSeqRanges(values: readonly number[]): EncodedSeq[] { +export function encodeSeqRanges(values: readonly SessionSeqType[]): EncodedSeq[] { if (!isStrictlyIncreasing(values)) return [...values] const encoded: EncodedSeq[] = [] for (let start = 0; start < values.length;) { @@ -31,15 +34,15 @@ export function encodeSeqRanges(values: readonly number[]): EncodedSeq[] { * @param maxEntries - largest list permitted by the owning event. * @returns the in-memory source sequences. */ -export function decodeSeqRanges(value: unknown, maxEntries = Number.MAX_SAFE_INTEGER): number[] { +export function decodeSeqRanges(value: unknown, maxEntries = Number.MAX_SAFE_INTEGER): SessionSeqType[] { if (!Array.isArray(value)) throw new TypeError('sourceEventSeqs must be an array') - const decoded: number[] = [] + const decoded: SessionSeqType[] = [] let hasRange = false for (const entry of value) { if (typeof entry === 'number') { assertSeq(entry) if (decoded.length >= maxEntries) throw new TypeError('sourceEventSeqs exceeds its event sequence') - decoded.push(entry) + decoded.push(SessionSeq(entry)) continue } if (!Array.isArray(entry) || entry.length !== 2) { @@ -54,7 +57,7 @@ export function decodeSeqRanges(value: unknown, maxEntries = Number.MAX_SAFE_INT if (length > maxEntries - decoded.length) { throw new TypeError('sourceEventSeqs range exceeds its event sequence') } - for (let seq = start; seq <= end; seq += 1) decoded.push(seq) + for (let seq = start; seq <= end; seq += 1) decoded.push(SessionSeq(seq)) hasRange = true } if (hasRange && !isStrictlyIncreasing(decoded)) { diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index ba6c2dda80..bfb331eb22 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -9,7 +9,14 @@ */ import type { Message } from '@deepseek-ai/dsh-llm' -import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts' +import { SessionLogOffset, SessionSeq } from './types.ts' +import type { + SessionEvent, + SessionSeqCursor, + SurfaceEvent, + SurfaceEventType, + SurfaceOp, +} from './types.ts' /** Runtime counterpart of the message-producing event union. */ const SURFACE_EVENT_TYPES = new Set([ @@ -116,19 +123,19 @@ export function deriveEventMessage(event: SessionEvent): Message | null { /** One replacement operation observed while folding a session surface. */ export interface SurfaceFoldReplacement { /** Seq of the event that replaced the prior surface range. */ - seq: number + seq: SessionSeq /** Declared inclusive start seq of the replaced surface range. */ - start: number + start: SessionSeq /** Declared inclusive end seq of the replaced surface range. */ - end: number + end: SessionSeq /** Actual surface entries removed by the operation, in surface order. */ - shadowedSeqs: number[] + shadowedSeqs: SessionSeq[] } /** Complete result of replaying the surface operations in a session log. */ export interface SurfaceFoldResult { /** Current surface event sequences in model-visible order. */ - nodes: number[] + nodes: SessionSeq[] /** Replacement operations in event order. */ replacements: SurfaceFoldReplacement[] } @@ -136,14 +143,14 @@ export interface SurfaceFoldResult { /** Readonly live projection of the message-producing session events. */ export interface SessionSurface { /** Current surface event sequences in model-visible order. */ - readonly nodes: readonly number[] + readonly nodes: readonly SessionSeq[] /** Monotonic count of committed positional replacements. */ readonly replaceGeneration: number } /** Mutable state shared by complete and incremental folds. */ interface SurfaceFoldState { - nodes: number[] + nodes: SessionSeq[] replaceGeneration: number } @@ -156,7 +163,7 @@ interface SurfaceReplacePlan extends SurfaceFoldReplacement { /** One validated surface transition that has not mutated fold state yet. */ type SurfacePlan = - | { kind: 'append'; seq: number } + | { kind: 'append'; seq: SessionSeq } | SurfaceReplacePlan /** Create an empty surface fold state. */ @@ -165,8 +172,11 @@ function createFoldState(): SurfaceFoldState { } /** Whether a runtime value is a non-negative safe event sequence. */ -function isEventSeq(value: unknown): value is number { - return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +function isEventSeq(value: unknown): value is SessionSeq { + return typeof value === 'number' + && Number.isSafeInteger(value) + && value >= 0 + && !Object.is(value, -0) } /** Whether a runtime value is the exact positional-replacement shape. */ @@ -210,10 +220,10 @@ function surfaceOpOf(event: SessionEvent): SurfaceOp | undefined { /** Validate cited source-event seqs against prior log entries and the replacement range. */ function assertProvenance( event: SessionEvent, - shadowedSeqs: readonly number[], + shadowedSeqs: readonly SessionSeq[], ): void { const raw = (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs - const sources = new Set() + const sources = new Set() if (raw !== undefined) { if (!Array.isArray(raw)) { throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`) @@ -221,7 +231,7 @@ function assertProvenance( if (raw.length === 0 && event.type !== 'assistant/message') { throw new Error('sourceEventSeqs must not be empty except on assistant/message') } - let nonEarlierSource: number | undefined + let nonEarlierSource: SessionSeq | undefined for (const source of raw) { if (!isEventSeq(source)) { throw new Error(`session event "${event.type}" sourceEventSeqs must densely contain non-negative safe integers`) @@ -286,9 +296,9 @@ function isDeepEqualJson(a: unknown, b: unknown): boolean { /** Restrict a tool-result replacement to one current result's content. */ function assertToolResultRewrite( event: SessionEvent, - shadowedSeqs: readonly number[], + shadowedSeqs: readonly SessionSeq[], events: readonly SessionEvent[], - baseSeq: number, + baseSeq: SessionLogOffset, ): void { if (event.type !== 'tool/result') return if (shadowedSeqs.length !== 1) { @@ -321,9 +331,9 @@ function assertToolResultRewrite( function planSurfaceEvent( state: SurfaceFoldState, event: SessionEvent, - expectedSeq: number, + expectedSeq: SessionSeq, events: readonly SessionEvent[], - baseSeq: number, + baseSeq: SessionLogOffset, ): SurfacePlan | undefined { if (event.seq !== expectedSeq) { throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`) @@ -350,9 +360,9 @@ function planSurfaceEvent( function applySurfaceEvent( state: SurfaceFoldState, event: SessionEvent, - expectedSeq: number, + expectedSeq: SessionSeq, events: readonly SessionEvent[], - baseSeq: number, + baseSeq: SessionLogOffset, ): SurfaceFoldReplacement | undefined { const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq) return applySurfacePlan(state, plan) @@ -388,7 +398,13 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult const state = createFoldState() const replacements: SurfaceFoldReplacement[] = [] for (const [index, event] of events.entries()) { - const replacement = applySurfaceEvent(state, event, index, events, 0) + const replacement = applySurfaceEvent( + state, + event, + SessionSeq(index), + events, + SessionLogOffset(0), + ) if (replacement !== undefined) replacements.push(replacement) } return { nodes: [...state.nodes], replacements } @@ -399,9 +415,9 @@ export class SurfaceManager implements SessionSurface { /** Shared transition state; replacement history is not retained. */ private _state = createFoldState() /** Last processed absolute seq. */ - private _lastProcessedSeq: number + private _lastProcessedSeq: SessionSeqCursor /** Candidate already validated by `validateNext`, pending exact log admission. */ - private _pendingPlan: { event: SessionEvent; expectedSeq: number; plan: SurfacePlan | undefined } | undefined + private _pendingPlan: { event: SessionEvent; expectedSeq: SessionSeq; plan: SurfacePlan | undefined } | undefined /** * @param log - Contiguous complete log or loaded event window. @@ -409,9 +425,9 @@ export class SurfaceManager implements SessionSurface { */ constructor( private log: readonly SessionEvent[], - private readonly baseSeq = 0, + private readonly baseSeq: SessionLogOffset = SessionLogOffset(0), ) { - this._lastProcessedSeq = baseSeq - 1 + this._lastProcessedSeq = baseSeq === 0 ? -1 : SessionSeq(baseSeq - 1) } /** @@ -420,7 +436,7 @@ export class SurfaceManager implements SessionSurface { */ validateNext(event: SessionEvent): void { if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() - const expectedSeq = this.baseSeq + this.log.length + const expectedSeq = SessionSeq(this.baseSeq + this.log.length) this._pendingPlan = { event, expectedSeq, @@ -435,7 +451,7 @@ export class SurfaceManager implements SessionSurface { } /** Surface event sequences in model-visible order. */ - get nodes(): readonly number[] { + get nodes(): readonly SessionSeq[] { if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() return this._state.nodes } @@ -451,10 +467,10 @@ export class SurfaceManager implements SessionSurface { if (pending?.event === event && pending.expectedSeq === seq) { applySurfacePlan(this._state, pending.plan) } else { - applySurfaceEvent(this._state, event, seq, this.log, this.baseSeq) + applySurfaceEvent(this._state, event, SessionSeq(seq), this.log, this.baseSeq) } if (pending !== undefined && pending.expectedSeq <= seq) this._pendingPlan = undefined - this._lastProcessedSeq = seq + this._lastProcessedSeq = SessionSeq(seq) } } } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index d6ae6c5764..461c31ad36 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -1,4 +1,4 @@ -import { brandString, type Branded } from '@deepseek-ai/dsh-brand' +import { brandNumber, brandString, type Branded, type BrandedNumber } from '@deepseek-ai/dsh-brand' import type { AssistantMessage, ToolCallId, @@ -25,6 +25,42 @@ export function SessionId(id: string): SessionId { return brandString(id) } +/** Sequence number of one existing event in a Session log. */ +export type SessionSeq = BrandedNumber<'SessionSeq'> + +/** + * Admit a numeric value as an existing Session event position. + * @param value - non-negative safe integer admitted by the owning log operation. + * @returns the same number with the Session-sequence brand. + */ +export function SessionSeq(value: number): SessionSeq { + if (!Number.isSafeInteger(value) || value < 0 || Object.is(value, -0)) { + throw new TypeError(`SessionSeq must be a non-negative safe integer, got ${String(value)}`) + } + return brandNumber(value) +} + +/** A Session log gap, prefix length, or read offset, which may equal the event count. */ +export type SessionLogOffset = BrandedNumber<'SessionLogOffset'> + +/** + * Admit a numeric value as a Session log offset. + * @param value - non-negative safe integer used as a gap or prefix length. + * @returns the same number with the Session-log-offset brand. + */ +export function SessionLogOffset(value: number): SessionLogOffset { + if (!Number.isSafeInteger(value) || value < 0 || Object.is(value, -0)) { + throw new TypeError(`SessionLogOffset must be a non-negative safe integer, got ${String(value)}`) + } + return brandNumber(value) +} + +/** Inclusive Session event watermark, or `-1` before any event exists. */ +export type SessionSeqCursor = SessionSeq | -1 + +/** One existing Session event position, or explicit absence. */ +export type OptionalSessionSeq = SessionSeq | null + /** * The on-disk session format version, stamped into every newly-written {@link SessionHeader} * and enforced by every persistence backend on load. The single source of truth for the @@ -69,10 +105,10 @@ export interface SessionHeader { /** The session this one was forked from (seed lineage), if any. */ readonly parentSession?: SessionId /** - * How many leading events were inherited through a seed. Persisting this - * boundary lets resume and replay distinguish parent history from child work. + * Whether this Session contains a fork-inherited event prefix. The exact prefix + * length is Session state rather than ordinary header metadata. */ - readonly seedLength?: number + readonly isSeeded: boolean /** * Coarse product classification for a session created as a subagent child. * This is presentation metadata, not proof that the child is continuable. @@ -102,14 +138,19 @@ export interface CreateSessionOptions { /** Initial replay or fork history supplied at construction. */ readonly seed?: readonly SessionEvent[] /** - * Storage metadata read once before publication. `seedLength` is explicit - * because a resumed seed contains the full stored log, not only its inherited prefix. + * Exact fork-inherited prefix length when `meta.isSeeded` is true. A + * constructor seed may also contain child-owned setup events after this cut. + */ + readonly inheritedEventCount?: SessionLogOffset + /** + * Storage metadata read once before publication. `isSeeded` marks fork + * lineage; supplying replay history alone does not make it inherited. */ readonly meta?: { readonly cwd?: string readonly parentSession?: SessionId readonly createdAt?: number - readonly seedLength?: number + readonly isSeeded?: boolean readonly origin?: 'subagent' readonly delegationDepth?: number readonly agentPreset?: string @@ -125,6 +166,8 @@ export interface RestoredSessionOptions { readonly seed: SessionEvent[] /** Fresh detached storage metadata to validate and freeze in place. */ readonly meta: SessionHeader + /** Exact number of fork-inherited leading events decoded from storage. */ + readonly inheritedEventCount: SessionLogOffset /** Select the persistence ownership-transfer path. */ readonly seedSource: 'persistence' } @@ -358,7 +401,7 @@ export type SurfaceEvent = SessionEvent & { surfaceOp: Surface */ export type SurfaceOp = | 'append' - | { op: 'replace'; start: number; end: number } + | { op: 'replace'; start: SessionSeq; end: SessionSeq } /** * Surface placement and cited source-event seqs for {@link Session.append}. Required on @@ -372,7 +415,7 @@ export interface SurfaceIntent { * absent, the event does not record which earlier events produced the message. * Other surface events require a non-empty set when this field is present. */ - sourceEventSeqs?: number[] + sourceEventSeqs?: SessionSeq[] } /** @@ -392,7 +435,7 @@ export type SessionEvent = { [K in SessionEventType]: { type: K /** Monotonic sequence number within the session. */ - seq: number + seq: SessionSeq /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] @@ -416,7 +459,7 @@ export type SessionEvent = { * provider stream; when the field is absent, the event does not record which * earlier events produced the message. */ - sourceEventSeqs?: number[] + sourceEventSeqs?: SessionSeq[] /** How this event entered the surface; absent for non-surface events. */ surfaceOp?: SurfaceOp } : object) diff --git a/packages/core/session/tests/chunk-rows.spec.ts b/packages/core/session/tests/chunk-rows.spec.ts index 500355a4fe..b18046914b 100644 --- a/packages/core/session/tests/chunk-rows.spec.ts +++ b/packages/core/session/tests/chunk-rows.spec.ts @@ -8,19 +8,19 @@ import { describe, expect, it } from 'vitest' import fc from 'fast-check' import { ToolCallId } from '@deepseek-ai/dsh-llm' import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import { decodeStorageRecord, packChunkRuns } from '@deepseek-ai/dsh-session' +import { decodeStorageRecord, packChunkRuns, SessionSeq } from '@deepseek-ai/dsh-session' import { chunkRowLength, isChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' import type { ChunkRow, SessionEvent, StorageRecord } from '@deepseek-ai/dsh-session' /** Build an `assistant/chunk` event with the exact live-append shape. */ -function chunkEvent(seq: number, time: number, chunk: StreamChunk, turn = 1, step = 1): SessionEvent { +function chunkEvent(seq: SessionSeq, time: number, chunk: StreamChunk, turn = 1, step = 1): SessionEvent { return { type: 'assistant/chunk', seq, time, data: { turn, step, chunk } } } /** Sequential delta events (contiguous seqs, fixed 10ms gaps) of one kind. */ function deltaRun(kind: 'text-delta' | 'reasoning-delta', count: number, seq0 = 0, index = 0): SessionEvent[] { return Array.from({ length: count }, (_, k) => - chunkEvent(seq0 + k, 1000 + 10 * k, { type: kind, index, text: `t${k}` })) + chunkEvent(SessionSeq(seq0 + k), 1000 + 10 * k, { type: kind, index, text: `t${k}` })) } /** Decode a packed record list back to a flat event list. */ @@ -47,7 +47,7 @@ describe('packChunkRuns', () => { it('packs reasoning and tool-call runs under their own tags', () => { const reasoning = deltaRun('reasoning-delta', 3) const toolCall = [4, 5, 6].map(seq => - chunkEvent(seq, 1000 + seq, { type: 'tool-call-delta', index: 1, id: ToolCallId('c1'), name: 'write', argumentsDelta: `a${seq}` })) + chunkEvent(SessionSeq(seq), 1000 + seq, { type: 'tool-call-delta', index: 1, id: ToolCallId('c1'), name: 'write', argumentsDelta: `a${seq}` })) const packed = packChunkRuns([...reasoning, ...toolCall]) expect(packed.map(r => (r as ChunkRow).type)).toStrictEqual(['reasoning-chunks', 'tool-call-chunks']) const row = packed[1] as ChunkRow & { type: 'tool-call-chunks' } @@ -58,7 +58,7 @@ describe('packChunkRuns', () => { it('packs a name-less tool-call run and round-trips field absence', () => { const events = [0, 1, 2].map(seq => - chunkEvent(seq, 1000, { type: 'tool-call-delta', index: 0, id: ToolCallId('c1'), argumentsDelta: `a${seq}` })) + chunkEvent(SessionSeq(seq), 1000, { type: 'tool-call-delta', index: 0, id: ToolCallId('c1'), argumentsDelta: `a${seq}` })) const packed = packChunkRuns(events) expect(packed).toHaveLength(1) expect(Object.hasOwn((packed[0] as ChunkRow).data, 'name')).toBe(false) @@ -74,10 +74,10 @@ describe('packChunkRuns', () => { it('leaves non-delta chunks and non-chunk events verbatim between runs', () => { const events: SessionEvent[] = [ - chunkEvent(0, 1000, { type: 'block-start', index: 0, blockType: 'text' }), + chunkEvent(SessionSeq(0), 1000, { type: 'block-start', index: 0, blockType: 'text' }), ...deltaRun('text-delta', 3, 1), - chunkEvent(4, 1040, { type: 'block-end', index: 0, block: { type: 'text', text: 't0t1t2' } }), - { type: 'step/end', seq: 5, time: 1050, data: { turn: 1, step: 1 } }, + chunkEvent(SessionSeq(4), 1040, { type: 'block-end', index: 0, block: { type: 'text', text: 't0t1t2' } }), + { type: 'step/end', seq: SessionSeq(5), time: 1050, data: { turn: 1, step: 1 } }, ] const packed = packChunkRuns(events) expect(packed).toHaveLength(4) @@ -86,27 +86,27 @@ describe('packChunkRuns', () => { }) it.each([ - ['a seq gap', deltaRun('text-delta', 3).map((e, k) => ({ ...e, seq: k === 2 ? 9 : e.seq }))], + ['a seq gap', deltaRun('text-delta', 3).map((e, k) => ({ ...e, seq: k === 2 ? SessionSeq(9) : e.seq }))], ['a kind switch', [...deltaRun('text-delta', 2), ...deltaRun('reasoning-delta', 1, 2)]], ['a block-index switch', [...deltaRun('text-delta', 2), ...deltaRun('text-delta', 1, 2, 7)]], - ['a step switch', deltaRun('text-delta', 3).map((e, k) => k === 2 ? chunkEvent(e.seq, e.time, (e.data as { chunk: StreamChunk }).chunk, 1, 2) : e)], + ['a step switch', deltaRun('text-delta', 3).map((e, k) => k === 2 ? chunkEvent(SessionSeq(e.seq), e.time, (e.data as { chunk: StreamChunk }).chunk, 1, 2) : e)], ])('breaks a run on %s (both halves too short to pack)', (_label, events) => { expect(packChunkRuns(events)).toStrictEqual(events) }) it('breaks a tool-call run on call-id or name change', () => { - const call = (seq: number, id: string, name?: string): SessionEvent => + const call = (seq: SessionSeq, id: string, name?: string): SessionEvent => chunkEvent(seq, 1000, { type: 'tool-call-delta', index: 0, id: ToolCallId(id), ...name !== undefined ? { name } : {}, argumentsDelta: 'a' }) - const idSwitch = [call(0, 'c1', 'w'), call(1, 'c1', 'w'), call(2, 'c2', 'w')] + const idSwitch = [call(SessionSeq(0), 'c1', 'w'), call(SessionSeq(1), 'c1', 'w'), call(SessionSeq(2), 'c2', 'w')] expect(packChunkRuns(idSwitch)).toStrictEqual(idSwitch) - const namePresence = [call(0, 'c1', 'w'), call(1, 'c1', 'w'), call(2, 'c1')] + const namePresence = [call(SessionSeq(0), 'c1', 'w'), call(SessionSeq(1), 'c1', 'w'), call(SessionSeq(2), 'c1')] expect(packChunkRuns(namePresence)).toStrictEqual(namePresence) }) it('stores an off-whitelist delta verbatim (extra field, bad type, fractional time)', () => { - const extraField = { ...chunkEvent(0, 1000, { type: 'text-delta', index: 0, text: 'x' }), surfaceOp: 'append' } - const badText = chunkEvent(1, 1001, { type: 'text-delta', index: 0, text: 7 as unknown as string }) - const fractionalTime = chunkEvent(2, 1001.5, { type: 'text-delta', index: 0, text: 'y' }) + const extraField = { ...chunkEvent(SessionSeq(0), 1000, { type: 'text-delta', index: 0, text: 'x' }), surfaceOp: 'append' } + const badText = chunkEvent(SessionSeq(1), 1001, { type: 'text-delta', index: 0, text: 7 as unknown as string }) + const fractionalTime = chunkEvent(SessionSeq(2), 1001.5, { type: 'text-delta', index: 0, text: 'y' }) const events = [extraField, badText, fractionalTime] as SessionEvent[] expect(packChunkRuns(events)).toStrictEqual(events) }) @@ -119,43 +119,48 @@ describe('packChunkRuns', () => { const b = Number.MAX_SAFE_INTEGER - 1 expect(a + (b - a)).not.toBe(b) // the rounding this guard exists for const events = [ - chunkEvent(0, a, { type: 'text-delta', index: 0, text: 'x' }), - chunkEvent(1, b, { type: 'text-delta', index: 0, text: 'y' }), - chunkEvent(2, b + 1, { type: 'text-delta', index: 0, text: 'z' }), + chunkEvent(SessionSeq(0), a, { type: 'text-delta', index: 0, text: 'x' }), + chunkEvent(SessionSeq(1), b, { type: 'text-delta', index: 0, text: 'y' }), + chunkEvent(SessionSeq(2), b + 1, { type: 'text-delta', index: 0, text: 'z' }), ] expect(packChunkRuns(events)).toStrictEqual(events) // split at the gap; halves too short expect(decodeAll(packChunkRuns(events))).toStrictEqual(events) }) it('stores a delta with an off-whitelist data envelope verbatim (parsed-fixture shapes)', () => { - const mk = (seq: number, data: unknown): SessionEvent => + const mk = (seq: SessionSeq, data: unknown): SessionEvent => ({ type: 'assistant/chunk', seq, time: 1000, data } as SessionEvent) const events = [ - mk(0, 'not-an-object'), - mk(1, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' }, extra: 1 }), - mk(2, { turn: 'x', step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' } }), - mk(3, { turn: 1, step: 1, chunk: 'not-an-object' }), - mk(4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 'x', text: 'a' } }), - mk(5, { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 7, argumentsDelta: 'a' } }), - mk(6, { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 'c', name: 7, argumentsDelta: 'a' } }), + mk(SessionSeq(0), 'not-an-object'), + mk(SessionSeq(1), { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' }, extra: 1 }), + mk(SessionSeq(2), { turn: 'x', step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' } }), + mk(SessionSeq(3), { turn: 1, step: 1, chunk: 'not-an-object' }), + mk(SessionSeq(4), { turn: 1, step: 1, chunk: { type: 'text-delta', index: 'x', text: 'a' } }), + mk(SessionSeq(5), { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 7, argumentsDelta: 'a' } }), + mk(SessionSeq(6), { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 'c', name: 7, argumentsDelta: 'a' } }), ] expect(packChunkRuns(events)).toStrictEqual(events) }) }) describe('decodeStorageRecord', () => { - it('passes non-row values through as single events, unvalidated', () => { + it('passes non-row values through after sequence admission', () => { const event = { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } } - expect(decodeStorageRecord(event)).toStrictEqual([event]) + const decoded = decodeStorageRecord(event) + expect(decoded).toStrictEqual([event]) + expect(decoded[0]).toBe(event) expect(decodeStorageRecord('junk')).toStrictEqual(['junk']) expect(decodeStorageRecord(null)).toStrictEqual([null]) + const withoutSeq = { type: 'future/event', data: {} } + expect(decodeStorageRecord(withoutSeq)).toStrictEqual([withoutSeq]) + expect(() => decodeStorageRecord({ ...event, seq: -0 })).toThrow(/SessionSeq/) }) it('reconstructs timestamps through negative dt gaps (clock stepped back)', () => { const events = [ - chunkEvent(0, 1000, { type: 'text-delta', index: 0, text: 'a' }), - chunkEvent(1, 990, { type: 'text-delta', index: 0, text: 'b' }), - chunkEvent(2, 995, { type: 'text-delta', index: 0, text: 'c' }), + chunkEvent(SessionSeq(0), 1000, { type: 'text-delta', index: 0, text: 'a' }), + chunkEvent(SessionSeq(1), 990, { type: 'text-delta', index: 0, text: 'b' }), + chunkEvent(SessionSeq(2), 995, { type: 'text-delta', index: 0, text: 'c' }), ] expect(decodeAll(packChunkRuns(events))).toStrictEqual(events) }) @@ -164,6 +169,7 @@ describe('decodeStorageRecord', () => { ['a non-object data', { type: 'text-chunks', seq0: 0, time0: 1, data: 'x' }], ['an envelope with extra keys', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] }, extra: 1 }], ['a negative seq0', { type: 'text-chunks', seq0: -1, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] } }], + ['a negative-zero seq0', { type: 'text-chunks', seq0: -0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] } }], ['a non-finite time0', { type: 'text-chunks', seq0: 0, time0: Infinity, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] } }], ['a fractional time0', { type: 'text-chunks', seq0: 0, time0: 1.5, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] } }], ['a data shape mismatch', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], args: ['a'] } }], @@ -229,7 +235,7 @@ const batchArb: fc.Arbitrary = fc.array( // plain objects real log events are (the log is JSON), so equality compares // values, not prototypes. ).map(entries => JSON.parse(JSON.stringify( - entries.map((entry, k) => chunkEvent(k, entry.time, entry.chunk, entry.turn, entry.step)), + entries.map((entry, k) => chunkEvent(SessionSeq(k), entry.time, entry.chunk, entry.turn, entry.step)), )) as SessionEvent[]) describe('chunk-row codec properties', () => { diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 537bf9c3ee..b5f1c02cad 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createUserMessage, ToolCallId , createMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionForkError, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' declare module '@deepseek-ai/dsh-session/types' { @@ -46,18 +46,15 @@ function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/m return event } -function lastSeq(session: Session): number { +function lastSeq(session: Session): SessionSeq { 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. */ +/** A seeded child's fork-inherited prefix. */ function inherited(session: Session): readonly SessionEvent[] { - 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) + return session.snapshotEvents(SessionLogOffset(0), session.inheritedEventCount) } describe('SessionStore.fork', () => { @@ -72,8 +69,9 @@ describe('SessionStore.fork', () => { id: SessionId('empty-child'), cwd: '/workspace', parentSession: SessionId('empty-parent'), - seedLength: 0, + isSeeded: true, }) + expect(child.inheritedEventCount).toBe(0) }) it('forks the latest completed boundary by default into detached frozen seed events', async () => { @@ -95,8 +93,9 @@ describe('SessionStore.fork', () => { id: SessionId('child'), cwd: '/workspace', parentSession: SessionId('parent'), - seedLength: source.snapshotEvents().length, + isSeeded: true, }) + expect(child.inheritedEventCount).toBe(source.seq) }) it('includes stable log-only events appended after a closed turn', async () => { @@ -125,7 +124,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, firstBoundary, SessionId('child-from-first')) expect(inherited(child)).toEqual(source.snapshotEvents().slice(0, firstBoundary + 1)) - expect(child.header.seedLength).toBe(firstBoundary + 1) + expect(child.inheritedEventCount).toBe(firstBoundary + 1) expect(child.deriveMessages()).toEqual([{ id: expect.any(String) as unknown, role: 'user', @@ -152,7 +151,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.snapshotEvents().length) + expect(child.inheritedEventCount).toBe(source.seq) } }) @@ -180,19 +179,19 @@ describe('SessionStore.fork', () => { it('rejects invalid boundaries before creating a child', async () => { const { ctx, sessions } = await setup() const empty = ctx.sessions.create(SessionId('empty')) - expect(() => sessions.fork(empty, 0, SessionId('empty-child'))) + expect(() => sessions.fork(empty, SessionSeq(0), SessionId('empty-child'))) .toThrow(new SessionForkError('fork boundary 0 does not exist in session "empty" (last seq: none)', 'INVALID_BOUNDARY')) expect(ctx.sessions.get(SessionId('empty-child'))).toBeUndefined() const source = ctx.sessions.create(SessionId('parent')) appendClosedTurn(source, 1) - expect(() => sessions.fork(source, -1, SessionId('negative'))) + expect(() => sessions.fork(source, -1 as never, SessionId('negative'))) .toThrow(/non-negative safe integer/) - expect(() => sessions.fork(source, 0.5, SessionId('fraction'))) + expect(() => sessions.fork(source, 0.5 as never, SessionId('fraction'))) .toThrow(/non-negative safe integer/) - expect(() => sessions.fork(source, Number.MAX_SAFE_INTEGER + 1, SessionId('unsafe'))) + expect(() => sessions.fork(source, (Number.MAX_SAFE_INTEGER + 1) as never, SessionId('unsafe'))) .toThrow(/non-negative safe integer/) - expect(() => sessions.fork(source, source.seq, SessionId('past-end'))) + expect(() => sessions.fork(source, SessionSeq(source.seq), SessionId('past-end'))) .toThrow(new SessionForkError(`fork boundary ${source.seq} does not exist in session "parent" (last seq: ${source.seq - 1})`, 'INVALID_BOUNDARY')) }) @@ -201,9 +200,9 @@ describe('SessionStore.fork', () => { const source = ctx.sessions.create(SessionId('corrupt-parent')) appendClosedTurn(source, 1) const mutableLog = (source as unknown as { log: SessionEvent[] }).log - mutableLog[2] = { ...mutableLog[2]!, seq: 99 } + mutableLog[2] = { ...mutableLog[2]!, seq: SessionSeq(99) } - expect(() => sessions.fork(source, 2, SessionId('corrupt-child'))) + expect(() => sessions.fork(source, SessionSeq(2), SessionId('corrupt-child'))) .toThrow(new SessionForkError('fork boundary 2 does not match a contiguous event seq in session "corrupt-parent"', 'INVALID_BOUNDARY')) expect(ctx.sessions.get(SessionId('corrupt-child'))).toBeUndefined() }) @@ -292,7 +291,7 @@ describe('SessionStore.fork', () => { const source = ctx.sessions.create(SessionId(`open-${lastType}`)) const boundary = build(source) - expect(() => sessions.fork(source, boundary)) + expect(() => sessions.fork(source, SessionSeq(boundary))) .toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" ends inside open turn 1`, 'OPEN_TURN')) } }) diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index 26aede8930..8f6767411d 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope' import { createUserMessage, ToolCallId, createMessage, createToolResultMessage, freezeMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId, TOOL_NOT_STARTED } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionSeq, TOOL_NOT_STARTED } from '@deepseek-ai/dsh-session' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import InvariantRegistry, { InvariantError } from '@deepseek-ai/dsh-invariants' @@ -374,8 +374,8 @@ describe('session-log invariants', () => { it('replays seeded sessions and tracks each session independently', async () => { const { ctx } = await setup() const badSeed = [ - { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1 } }, - { type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2 } }, + { type: 'turn/start' as const, seq: SessionSeq(0), time: 0, data: { turn: 1 } }, + { type: 'turn/start' as const, seq: SessionSeq(1), time: 0, data: { turn: 2 } }, ] expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError) @@ -406,12 +406,12 @@ describe('session-log invariants', () => { const { ctx } = await setup() // Balanced seed: between turns. expect(() => ctx.sessions.create(SessionId('inherited-between-turns'), { seed: [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, ] })).not.toThrow() // Unbalanced seed: inside the open turn, which the relation permits. const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, ] }) expect(open.snapshotEvents().map(event => event.type)).toEqual(['turn/start', 'session/end-seed']) // Still open afterwards: the boundary moves no cursor. diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts index c671d079d1..d4955d0110 100644 --- a/packages/core/session/tests/repair.spec.ts +++ b/packages/core/session/tests/repair.spec.ts @@ -1,7 +1,20 @@ import { describe, expect, it } from 'vitest' import { ToolCallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' -import { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts' -import type { SessionEvent, SurfaceEvent } from '../src/index.ts' +import { interruptedTurnClosers as repairInterruptedTurn, SessionSeq, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts' +import type { SessionEvent as LogicalSessionEvent, SurfaceEvent } from '../src/index.ts' + +interface SessionEvent { + type: string + seq: number + time: number + data: unknown + [key: string]: unknown +} + +function interruptedTurnClosers(events: readonly SessionEvent[]): LogicalSessionEvent[] { + for (const event of events) SessionSeq(event.seq) + return repairInterruptedTurn(events as unknown as readonly LogicalSessionEvent[]) +} /** * Unit coverage for the crash-recovery closer synthesis. The persistence diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index 17d7ba80de..e3b7bdb92b 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -1,7 +1,7 @@ /** Request-header canonicalization, equality, snapshot folding, and format rejection. */ import { describe, expect, it } from 'vitest' -import { Session, SessionId, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session' +import { Session, SessionId, SessionSeq, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session' import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session' import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { ToolSchema } from '@deepseek-ai/dsh-llm' @@ -68,7 +68,7 @@ describe('foldRequestHeader', () => { it('returns the supplied baseline when no snapshot follows', () => { const from: EpochHeader = { config: CONFIG, system: 'baseline' } const unrelated: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, ] expect(foldRequestHeader(unrelated)).toBeUndefined() expect(foldRequestHeader(unrelated, from)).toBe(from) @@ -121,10 +121,10 @@ describe('Session.requestContext', () => { /** A turn-enclosed capacity record; the invariant rejects one outside a turn. */ function seedWith(...records: { provider: string; model: string; contextWindow?: number }[]): SessionEvent[] { const events: SessionEvent[] = [{ - type: 'turn/start', seq: 0, time: 1, data: { turn: 1 }, + type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 }, }] for (const data of records) { - events.push({ type: 'request/context', seq: events.length, time: 1, data }) + events.push({ type: 'request/context', seq: SessionSeq(events.length), time: 1, data }) } return events } diff --git a/packages/core/session/tests/seq-ranges.spec.ts b/packages/core/session/tests/seq-ranges.spec.ts index d86bb71e6c..cace9b53c3 100644 --- a/packages/core/session/tests/seq-ranges.spec.ts +++ b/packages/core/session/tests/seq-ranges.spec.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' -import { decodeSeqRanges, encodeSeqRanges } from '@deepseek-ai/dsh-session' +import { decodeSeqRanges, encodeSeqRanges, SessionSeq } from '@deepseek-ai/dsh-session' + +const seqs = (values: readonly number[]) => values.map(SessionSeq) describe('sourceEventSeqs ranges', () => { it.each([ @@ -10,17 +12,17 @@ describe('sourceEventSeqs ranges', () => { [3, 2], [Number.MAX_SAFE_INTEGER - 1, 0, Number.MAX_SAFE_INTEGER - 2], ].map(values => [values]))('round-trips %j', (values) => { - expect(decodeSeqRanges(encodeSeqRanges(values))).toEqual(values) + expect(decodeSeqRanges(encodeSeqRanges(seqs(values)))).toEqual(values) }) it('encodes only profitable increasing runs', () => { - expect(encodeSeqRanges([1, 3, 4, 5, 7])).toEqual([1, [3, 5], 7]) - expect(encodeSeqRanges([1, 3, 4, 7])).toEqual([1, 3, 4, 7]) - expect(encodeSeqRanges([3, 2])).toEqual([3, 2]) + expect(encodeSeqRanges(seqs([1, 3, 4, 5, 7]))).toEqual([1, [3, 5], 7]) + expect(encodeSeqRanges(seqs([1, 3, 4, 7]))).toEqual([1, 3, 4, 7]) + expect(encodeSeqRanges(seqs([3, 2]))).toEqual([3, 2]) }) it('does not impose a persistence-only provenance length limit', () => { - const values = Array.from({ length: 1_000_001 }, (_, index) => index) + const values = Array.from({ length: 1_000_001 }, (_, index) => SessionSeq(index)) expect(encodeSeqRanges(values)).toEqual([[0, 1_000_000]]) }) diff --git a/packages/core/session/tests/sequence-types.spec.ts b/packages/core/session/tests/sequence-types.spec.ts new file mode 100644 index 0000000000..11ac49b301 --- /dev/null +++ b/packages/core/session/tests/sequence-types.spec.ts @@ -0,0 +1,153 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { + Session, + SessionId, + SessionLogOffset, + SessionSeq, + type OptionalSessionSeq, + type SessionLogOffset as SessionLogOffsetType, + type SessionHeader, + type SessionSeq as SessionSeqType, + type SessionSeqCursor, +} from '@deepseek-ai/dsh-session' + +describe('Session log positions', () => { + it('admits non-negative safe integers into distinct sequence roles', () => { + const seq = SessionSeq(3) + const offset = SessionLogOffset(4) + + expect(seq).toBe(3) + expect(offset).toBe(4) + expectTypeOf(seq).toEqualTypeOf() + expectTypeOf(offset).toEqualTypeOf() + expectTypeOf(seq).not.toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) + + it.each([-1, -0, 0.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN])( + 'rejects invalid Session sequence positions (%s)', + (value) => { + expect(() => SessionSeq(value)).toThrow(/SessionSeq must be a non-negative safe integer/) + expect(() => SessionLogOffset(value)).toThrow(/SessionLogOffset must be a non-negative safe integer/) + }, + ) + + it('keeps event identities separate from log offsets', () => { + const session = Session.create(SessionId('typed-positions')) + const event = session.append('turn/start', { turn: 1 }) + + expect(event.seq).toBe(0) + expect(session.firstLiveSeq).toBe(0) + expect(session.seq).toBe(1) + expectTypeOf(event.seq).toEqualTypeOf() + expectTypeOf(session.firstLiveSeq).toEqualTypeOf() + expectTypeOf(session.seq).toEqualTypeOf() + }) + + it('rejects a negative-zero seq at the restored event boundary', () => { + const id = SessionId('negative-zero-event') + expect(() => Session.fromRestore(id, [{ + type: 'turn/start', seq: -0, time: 1, data: { turn: 1 }, + }] as never, { + version: 0, id, createdAt: 1, isSeeded: false, + }, SessionLogOffset(0))).toThrow(/invalid event envelope/) + }) + + it('keeps fork lineage outside the logical header integer fields', () => { + const source = Session.create(SessionId('source')) + source.append('turn/start', { turn: 1 }) + source.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const id = SessionId('child') + const header: SessionHeader = { + version: 0, + id, + createdAt: 1, + isSeeded: true, + } + + const child = Session.create(id, source.snapshotEvents(), header, source.seq) + + expect(child.header.isSeeded).toBe(true) + expect('seedLength' in child.header).toBe(false) + expect(child.inheritedEventCount).toBe(2) + expect(child.ownEvents().map(event => event.type)).toEqual(['session/end-seed']) + expect(child.isOwnSeq(SessionSeq(1))).toBe(false) + expect(child.isOwnSeq(SessionSeq(2))).toBe(true) + expect(child.isOwnSeq(SessionSeq(3))).toBe(false) + + const fresh = Session.create(SessionId('fresh')) + expect(fresh.header.isSeeded).toBe(false) + expect(fresh.inheritedEventCount).toBe(0) + }) + + it('retains a child-owned constructor-seed suffix after the inherited cut', () => { + const parent = Session.create(SessionId('suffix-parent')) + parent.append('turn/start', { turn: 1 }) + parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const assembled = Session.create(SessionId('assembled-seed'), parent.snapshotEvents()) + assembled.append('request/context', { provider: 'provider', model: 'model' }) + const id = SessionId('suffix-child') + + const child = Session.create(id, assembled.snapshotEvents(), { + version: 0, + id, + createdAt: 1, + isSeeded: true, + }, parent.seq) + + expect(child.ownEvents().map(event => event.type)).toEqual([ + 'session/end-seed', + 'request/context', + 'session/end-seed', + ]) + expect(child.isOwnSeq(SessionSeq(1))).toBe(false) + expect(child.isOwnSeq(SessionSeq(2))).toBe(true) + expect(child.isOwnSeq(SessionSeq(3))).toBe(true) + }) + + it('requires a separately supplied inherited cut for a seeded header', () => { + const id = SessionId('missing-cut') + expect(() => Session.create(id, [], { + version: 0, + id, + createdAt: 1, + isSeeded: true, + })).toThrow(/seeded session requires an inherited event count/) + }) + + it('requires an explicit constructor seed for seeded lineage', () => { + const id = SessionId('missing-seed') + expect(() => Session.create(id, undefined, { + version: 0, + id, + createdAt: 1, + isSeeded: true, + }, SessionLogOffset(0))).toThrow(/seeded session requires an explicit constructor seed/) + }) + + it('requires the exact cut to agree with lineage and log length', () => { + const unseededId = SessionId('unseeded-nonzero-cut') + expect(() => Session.create(unseededId, [], { + version: 0, id: unseededId, createdAt: 1, isSeeded: false, + }, SessionLogOffset(1))).toThrow(/unseeded session inherited event count must be 0/) + + const seededId = SessionId('seeded-oversized-cut') + expect(() => Session.create(seededId, [], { + version: 0, id: seededId, createdAt: 1, isSeeded: true, + }, SessionLogOffset(1))).toThrow(/inherited event count exceeds its event log/) + }) + + it.each([-1, 0.5, Number.MAX_SAFE_INTEGER + 1])( + 'revalidates a supplied inherited event count (%s)', + (value) => { + const id = SessionId(`bad-inherited-count-${value}`) + expect(() => Session.create(id, [], { + version: 0, + id, + createdAt: 1, + isSeeded: true, + }, value as SessionLogOffsetType)).toThrow(/SessionLogOffset must be a non-negative safe integer/) + }, + ) +}) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 09badf180f..790af32632 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -7,6 +7,8 @@ import SessionStore, { Session, SessionEvent, SessionId, + SessionLogOffset, + SessionSeq, snapshotSessionEvent, } from '@deepseek-ai/dsh-session' import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface } from '@deepseek-ai/dsh-session' @@ -305,20 +307,20 @@ describe('Session', () => { it('snapshots message events without validating plugin-owned block details', () => { const boundary = snapshotSessionEvent({ type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: 1, data: { turn: 1 }, }) expect(boundary).toEqual({ type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: 1, data: { turn: 1 }, }) const extended = snapshotSessionEvent({ type: 'user/message', - seq: 0, + seq: SessionSeq(0), time: 1, surfaceOp: 'append', data: { @@ -374,7 +376,7 @@ describe('Session', () => { it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => { const valid = { type: 'request/header', - seq: 0, + seq: SessionSeq(0), time: 1, data: { header: { @@ -403,7 +405,7 @@ describe('Session', () => { it('round-trips adapter-default markers and rejects invalid durable values', () => { const valid = { type: 'request/header', - seq: 0, + seq: SessionSeq(0), time: 1, data: { header: { @@ -639,7 +641,7 @@ describe('Session', () => { it('rejects an exotic seed event shell before spreading erases its prototype', () => { class SeedEvent { readonly type = 'turn/start' as const - readonly seq = 0 + readonly seq = SessionSeq(0) readonly time = 1 readonly data = { turn: 1 } } @@ -810,8 +812,8 @@ describe('Session', () => { it('rejects exotic surface metadata before cloning can erase its prototype', () => { class ReplaceOp { readonly op = 'replace' as const - readonly start = 0 - readonly end = 0 + readonly start = SessionSeq(0) + readonly end = SessionSeq(0) } const session = Session.create(SessionId('append-exotic-metadata')) @@ -892,7 +894,7 @@ describe('Session', () => { )).toThrow(/not surface-eligible and cannot carry surfaceOp/) expect(() => Session.create(SessionId('non-surface-metadata-seed'), [{ type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: 1, data: { turn: 1 }, surfaceOp: 'append', @@ -903,7 +905,7 @@ describe('Session', () => { it('deep-freezes seeded and appended event snapshots', () => { const seeded = Session.create(SessionId('seed-frozen'), [{ type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: 1, data: { turn: 1 }, }]) @@ -941,7 +943,8 @@ describe('Session', () => { version: SESSION_FORMAT_VERSION, id: SessionId('deep-restore'), createdAt: 1, - })).not.toThrow() + isSeeded: false, + }, SessionLogOffset(0))).not.toThrow() let current: unknown = event let frozenNodes = 0 @@ -979,11 +982,11 @@ describe('Session', () => { 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() + expect(session.eventAt(SessionSeq(0))).toBe(start) + expect(session.eventAt(SessionSeq(1))).toBe(end) + expect(session.eventAt(SessionSeq(2))).toBeUndefined() - const range = session.snapshotEvents(1, 2) + const range = session.snapshotEvents(SessionLogOffset(1), SessionLogOffset(2)) expect(range).toEqual([end]) expect(Object.isFrozen(range)).toBe(true) @@ -998,10 +1001,10 @@ describe('Session', () => { createdAt: 123, cwd: '/accepted', parentSession: SessionId('parent'), - seedLength: 2, + isSeeded: true, } - const session = Session.create(SessionId('header-owned'), undefined, input) + const session = Session.create(SessionId('header-owned'), [], input, SessionLogOffset(0)) input.cwd = '/caller-mutated' expect(session.header).toEqual({ @@ -1010,7 +1013,7 @@ describe('Session', () => { createdAt: 123, cwd: '/accepted', parentSession: 'parent', - seedLength: 2, + isSeeded: true, }) expect(session.header).not.toBe(input) expect(Object.isFrozen(session.header)).toBe(true) @@ -1024,29 +1027,33 @@ describe('Session', () => { readonly version = SESSION_FORMAT_VERSION readonly id = SessionId('header-invalid') readonly createdAt = 123 + readonly isSeeded = false } expect(() => Session.create(SessionId('header-invalid'), undefined, new ExoticHeader())) .toThrow(/not losslessly JSON-serializable/) - expect(() => Session.fromRestore(SessionId('header-invalid'), [], new ExoticHeader())) + expect(() => Session.fromRestore(SessionId('header-invalid'), [], new ExoticHeader(), SessionLogOffset(0))) .toThrow(/not a plain JSON record/) for (const header of [null, 1, []]) { expect(() => Session.fromRestore( SessionId('header-invalid'), [], header as unknown as SessionHeader, + SessionLogOffset(0), )).toThrow(/not a plain JSON record/) } expect(() => Session.create(SessionId('header-invalid'), undefined, { version: SESSION_FORMAT_VERSION, id: SessionId('header-invalid'), createdAt: 123, + isSeeded: false, parentSession: 1n, } as unknown as SessionHeader)).toThrow(/not losslessly JSON-serializable/) expect(() => Session.create(SessionId('header-invalid'), undefined, { version: SESSION_FORMAT_VERSION, id: SessionId('other'), createdAt: 123, + isSeeded: false, })).toThrow(/does not match session id/) }) @@ -1055,6 +1062,7 @@ describe('Session', () => { version: SESSION_FORMAT_VERSION, id: SessionId('header-shape'), createdAt: 123, + isSeeded: false, } const cases: Array<{ header: unknown; error: RegExp }> = [ { header: 1, error: /not a plain JSON record/ }, @@ -1064,9 +1072,8 @@ describe('Session', () => { { header: { ...base, cwd: 1 }, error: /header cwd must be a string/ }, { header: { ...base, cwd: 'relative' }, error: /header cwd must be an absolute path/ }, { header: { ...base, parentSession: 1 }, error: /header parentSession must be a string/ }, - { header: { ...base, seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ }, - { header: { ...base, seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ }, - { header: { ...base, seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ }, + { header: { ...base, isSeeded: 'yes' }, error: /isSeeded must be a boolean/ }, + { header: { ...base, seedLength: 1 }, error: /invalid field "seedLength"/ }, ] for (const { header, error } of cases) { @@ -1074,6 +1081,20 @@ describe('Session', () => { } }) + it('retains opaque logical header metadata except the physical v0 seed field', () => { + const id = SessionId('header-extension') + const session = Session.create(id, undefined, { + version: SESSION_FORMAT_VERSION, + id, + createdAt: 123, + isSeeded: false, + extension: { value: 'kept' }, + } as SessionHeader) + + expect((session.header as SessionHeader & { extension: { value: string } }).extension) + .toEqual({ value: 'kept' }) + }) + it('rejects seed records with invalid fixed-envelope fields', () => { const base = { type: 'turn/start', @@ -1275,7 +1296,7 @@ describe('SessionStore', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('plain')) - expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'plain' }) + expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'plain', isSeeded: false }) expect(Number.isSafeInteger(session.header.createdAt)).toBe(true) expect(session.header.cwd).toBeUndefined() expect(session.header.parentSession).toBeUndefined() @@ -1292,6 +1313,7 @@ describe('SessionStore', () => { id: 'child', cwd: '/work/project', parentSession: 'parent', + isSeeded: false, }) }) @@ -1320,9 +1342,7 @@ describe('SessionStore', () => { { meta: { createdAt: 1.5 }, error: /header createdAt must be a non-negative safe integer/ }, { meta: { createdAt: -1 }, error: /header createdAt must be a non-negative safe integer/ }, { meta: { createdAt: Number.MAX_SAFE_INTEGER + 1 }, error: /header createdAt must be a non-negative safe integer/ }, - { meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ }, - { meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ }, - { meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ }, + { meta: { isSeeded: 'yes' }, error: /isSeeded must be a boolean/ }, { meta: { origin: 'fork' }, error: /origin must be "subagent"/ }, { meta: { delegationDepth: '1' }, error: /delegationDepth must be a non-negative safe integer/ }, { meta: { delegationDepth: 0.5 }, error: /delegationDepth must be a non-negative safe integer/ }, @@ -1348,7 +1368,7 @@ describe('SessionStore', () => { it('a bare Session() constructed without the store still exposes a current-version header', () => { const session = Session.create(SessionId('bare')) - expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare' }) + expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare', isSeeded: false }) expect(typeof session.header.createdAt).toBe('number') }) @@ -1507,8 +1527,8 @@ describe('SessionStore', () => { }, }), }, { - surfaceOp: { op: 'replace', start: 2, end: 2 }, - sourceEventSeqs: [2], + surfaceOp: { op: 'replace', start: SessionSeq(2), end: SessionSeq(2) }, + sourceEventSeqs: [SessionSeq(2)], })).toThrow('reject surface candidate') expect(session.snapshotEvents()).toHaveLength(3) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 3a92246a85..d89c77d851 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -3,6 +3,8 @@ import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/ import { Session, SessionId, + SessionLogOffset, + SessionSeq, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, @@ -19,6 +21,18 @@ import { MessageId, } from '@deepseek-ai/dsh-llm' +type TestSurfaceOp = 'append' | { op: 'replace'; start: number; end: number } + +function surfaceOp(value: TestSurfaceOp): SurfaceEvent['surfaceOp'] { + return value === 'append' + ? value + : { op: 'replace', start: SessionSeq(value.start), end: SessionSeq(value.end) } +} + +function sourceSeqs(...values: number[]) { + return values.map(SessionSeq) +} + /** Build a minimal session with turn boundaries and a single user message. */ function surfaceSession(): Session { const s = Session.create(SessionId('ss')) @@ -41,7 +55,7 @@ function surfaceSession(): Session { return s } -function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent { +function provenanceEvent(seq: SessionSeq, sourceEventSeqs: unknown): SessionEvent { return { type: 'user/message', seq, @@ -55,9 +69,9 @@ function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent { } function toolResultEvent( - seq: number, + seq: SessionSeq, callId: string, - surfaceOp: SurfaceEvent['surfaceOp'] = 'append', + op: TestSurfaceOp = 'append', sourceEventSeqs?: number[], ): SessionEvent { return { @@ -73,19 +87,19 @@ function toolResultEvent( isError: false, }), }, - surfaceOp, - ...sourceEventSeqs === undefined ? {} : { sourceEventSeqs }, + surfaceOp: surfaceOp(op), + ...sourceEventSeqs === undefined ? {} : { sourceEventSeqs: sourceSeqs(...sourceEventSeqs) }, } } describe('foldSurface source-event references', () => { it('accepts absent or valid source-event references and complete replacement coverage', () => { const events = [ - provenanceEvent(0, undefined), - provenanceEvent(1, undefined), + provenanceEvent(SessionSeq(0), undefined), + provenanceEvent(SessionSeq(1), undefined), { - ...provenanceEvent(2, [0, 1]), - surfaceOp: { op: 'replace', start: 0, end: 1 }, + ...provenanceEvent(SessionSeq(2), [0, 1]), + surfaceOp: surfaceOp({ op: 'replace', start: 0, end: 1 }), }, ] as SessionEvent[] expect(() => foldSurface(events)).not.toThrow() @@ -94,10 +108,10 @@ describe('foldSurface source-event references', () => { it('rejects source-event references on a non-surface event', () => { const event = { type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: 1, data: { turn: 1 }, - sourceEventSeqs: [0], + sourceEventSeqs: sourceSeqs(0), } as unknown as SessionEvent expect(() => foldSurface([event])).toThrow(/cannot carry sourceEventSeqs/) }) @@ -105,7 +119,7 @@ describe('foldSurface source-event references', () => { it('accepts an explicit empty source-event list on an assistant message', () => { const event = { type: 'assistant/message', - seq: 0, + seq: SessionSeq(0), time: 0, data: { turn: 1, @@ -126,19 +140,19 @@ describe('foldSurface source-event references', () => { }) it.each([ - ['a non-array', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: 'invalid' }], /must be an array/], - ['an empty array', [provenanceEvent(0, [])], /must not be empty/], - ['duplicates', [provenanceEvent(0, undefined), provenanceEvent(1, [0, 0])], /must not contain duplicates/], - ['a sparse array', [provenanceEvent(0, Array(1))], /densely contain/], - ['a non-number', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: ['0'] }], /non-negative safe integers/], - ['a fractional number', [provenanceEvent(0, [0.5])], /non-negative safe integers/], - ['a negative number', [provenanceEvent(0, [-1])], /non-negative safe integers/], - ['a self reference', [provenanceEvent(0, [0])], /must reference earlier events/], - ['a non-contiguous event seq', [provenanceEvent(0, undefined), provenanceEvent(2, [1])], /seq 2 is not contiguous; expected 1/], + ['a non-array', [{ ...provenanceEvent(SessionSeq(0), undefined), sourceEventSeqs: 'invalid' }], /must be an array/], + ['an empty array', [provenanceEvent(SessionSeq(0), [])], /must not be empty/], + ['duplicates', [provenanceEvent(SessionSeq(0), undefined), provenanceEvent(SessionSeq(1), [0, 0])], /must not contain duplicates/], + ['a sparse array', [provenanceEvent(SessionSeq(0), Array(1))], /densely contain/], + ['a non-number', [{ ...provenanceEvent(SessionSeq(0), undefined), sourceEventSeqs: ['0'] }], /non-negative safe integers/], + ['a fractional number', [provenanceEvent(SessionSeq(0), [0.5])], /non-negative safe integers/], + ['a negative number', [provenanceEvent(SessionSeq(0), [-1])], /non-negative safe integers/], + ['a self reference', [provenanceEvent(SessionSeq(0), [0])], /must reference earlier events/], + ['a non-contiguous event seq', [provenanceEvent(SessionSeq(0), undefined), provenanceEvent(SessionSeq(2), [1])], /seq 2 is not contiguous; expected 1/], ['incomplete replacement coverage', [ - provenanceEvent(0, undefined), - provenanceEvent(1, undefined), - { ...provenanceEvent(2, [0]), surfaceOp: { op: 'replace', start: 0, end: 1 } }, + provenanceEvent(SessionSeq(0), undefined), + provenanceEvent(SessionSeq(1), undefined), + { ...provenanceEvent(SessionSeq(2), [0]), surfaceOp: { op: 'replace', start: 0, end: 1 } }, ], /missing 1/], ] as const)( 'rejects %s', @@ -151,25 +165,25 @@ describe('foldSurface source-event references', () => { describe('foldSurface tool-result rewrites', () => { it('rejects a replacement spanning multiple current nodes', () => { const events = [ - provenanceEvent(0, undefined), - provenanceEvent(1, undefined), - toolResultEvent(2, 'rewrite', { op: 'replace', start: 0, end: 1 }, [0, 1]), + provenanceEvent(SessionSeq(0), undefined), + provenanceEvent(SessionSeq(1), undefined), + toolResultEvent(SessionSeq(2), 'rewrite', { op: 'replace', start: 0, end: 1 }, [0, 1]), ] expect(() => foldSurface(events)).toThrow(/must rewrite exactly one current node/) }) it('rejects a replacement targeting a non-result node', () => { const events = [ - provenanceEvent(0, undefined), - toolResultEvent(1, 'rewrite', { op: 'replace', start: 0, end: 0 }, [0]), + provenanceEvent(SessionSeq(0), undefined), + toolResultEvent(SessionSeq(1), 'rewrite', { op: 'replace', start: 0, end: 0 }, [0]), ] expect(() => foldSurface(events)).toThrow(/must target a current tool\/result/) }) it('rejects changes outside tool-result content', () => { const events = [ - toolResultEvent(0, 'original'), - toolResultEvent(1, 'changed', { op: 'replace', start: 0, end: 0 }, [0]), + toolResultEvent(SessionSeq(0), 'original'), + toolResultEvent(SessionSeq(1), 'changed', { op: 'replace', start: 0, end: 0 }, [0]), ] expect(() => foldSurface(events)).toThrow(/may change only content/) }) @@ -178,12 +192,12 @@ describe('foldSurface tool-result rewrites', () => { ['toolCallId', { toolCallId: ToolCallId('changed') }], ['isError', { isError: true }], ] as const)('rejects a replacement that changes the result block %s', (_field, patch) => { - const original = toolResultEvent(0, 'original') + const original = toolResultEvent(SessionSeq(0), 'original') const data = original.data as Extract['data'] const result = data.message.content[0] const replacement = { ...original, - seq: 1, + seq: SessionSeq(1), time: 1, data: { ...data, @@ -192,15 +206,15 @@ describe('foldSurface tool-result rewrites', () => { content: [{ ...result, ...patch }] as [typeof result], }), }, - surfaceOp: { op: 'replace', start: 0, end: 0 }, - sourceEventSeqs: [0], + surfaceOp: surfaceOp({ op: 'replace', start: 0, end: 0 }), + sourceEventSeqs: sourceSeqs(0), } as SessionEvent expect(() => foldSurface([original, replacement])).toThrow(/may change only content/) }) it('compares array-valued rest fields structurally (meta arrays: equal accepted, drifted rejected)', () => { - const withMeta = (seq: number, meta: unknown, surfaceOp: SurfaceEvent['surfaceOp'] = 'append', sourceEventSeqs?: number[]): SessionEvent => { - const event = toolResultEvent(seq, 'c-meta', surfaceOp, sourceEventSeqs) + const withMeta = (seq: SessionSeq, meta: unknown, op: TestSurfaceOp = 'append', sourceEventSeqs?: number[]): SessionEvent => { + const event = toolResultEvent(SessionSeq(seq), 'c-meta', op, sourceEventSeqs) const data = event.data as Extract['data'] return { ...event, @@ -213,28 +227,28 @@ describe('foldSurface tool-result rewrites', () => { } // Structurally equal arrays (fresh references) pass the rest-field equality. expect(() => foldSurface([ - withMeta(0, { tags: ['a', { n: 1 }] }), - withMeta(1, { tags: ['a', { n: 1 }] }, { op: 'replace', start: 0, end: 0 }, [0]), + withMeta(SessionSeq(0), { tags: ['a', { n: 1 }] }), + withMeta(SessionSeq(1), { tags: ['a', { n: 1 }] }, { op: 'replace', start: 0, end: 0 }, [0]), ])).not.toThrow() // Same length, drifted element: the array branch must reject. expect(() => foldSurface([ - withMeta(0, { tags: ['a'] }), - withMeta(1, { tags: ['b'] }, { op: 'replace', start: 0, end: 0 }, [0]), + withMeta(SessionSeq(0), { tags: ['a'] }), + withMeta(SessionSeq(1), { tags: ['b'] }, { op: 'replace', start: 0, end: 0 }, [0]), ])).toThrow(/may change only content/) // Array vs non-array on one side: the mixed-shape guard rejects. expect(() => foldSurface([ - withMeta(0, { tags: ['a'] }), - withMeta(1, { tags: 'a' }, { op: 'replace', start: 0, end: 0 }, [0]), + withMeta(SessionSeq(0), { tags: ['a'] }), + withMeta(SessionSeq(1), { tags: 'a' }, { op: 'replace', start: 0, end: 0 }, [0]), ])).toThrow(/may change only content/) // Same key count, different key names: the hasOwn branch rejects. expect(() => foldSurface([ - withMeta(0, { left: 1 }), - withMeta(1, { right: 1 }, { op: 'replace', start: 0, end: 0 }, [0]), + withMeta(SessionSeq(0), { left: 1 }), + withMeta(SessionSeq(1), { right: 1 }, { op: 'replace', start: 0, end: 0 }, [0]), ])).toThrow(/may change only content/) // Different key counts: the key-length branch rejects. expect(() => foldSurface([ - withMeta(0, { one: 1 }), - withMeta(1, { one: 1, two: 2 }, { op: 'replace', start: 0, end: 0 }, [0]), + withMeta(SessionSeq(0), { one: 1 }), + withMeta(SessionSeq(1), { one: 1, two: 2 }, { op: 'replace', start: 0, end: 0 }, [0]), ])).toThrow(/may change only content/) }) }) @@ -243,47 +257,47 @@ describe('SurfaceManager', () => { it('folds a contiguous window without materializing earlier event sequences', () => { const baseSeq = 400_000 const events = [ - provenanceEvent(baseSeq, undefined), - provenanceEvent(baseSeq + 1, undefined), + provenanceEvent(SessionSeq(baseSeq), undefined), + provenanceEvent(SessionSeq(baseSeq + 1), undefined), { - ...provenanceEvent(baseSeq + 2, [baseSeq]), - surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq }, + ...provenanceEvent(SessionSeq(baseSeq + 2), [baseSeq]), + surfaceOp: surfaceOp({ op: 'replace', start: baseSeq, end: baseSeq }), }, ] as SessionEvent[] - const surface = new SurfaceManager(events, baseSeq) + const surface = new SurfaceManager(events, SessionLogOffset(baseSeq)) expect(surface.nodes).toEqual([baseSeq + 2, baseSeq + 1]) expect(surface.replaceGeneration).toBe(1) }) it('validates tool-result rewrites against a nonzero window offset', () => { const baseSeq = 400_000 - const original = toolResultEvent(baseSeq, 'call') + const original = toolResultEvent(SessionSeq(baseSeq), 'call') const events: SessionEvent[] = [ original, { ...original, - seq: baseSeq + 1, + seq: SessionSeq(baseSeq + 1), time: baseSeq + 1, - surfaceOp: { op: 'replace' as const, start: baseSeq, end: baseSeq }, - sourceEventSeqs: [baseSeq], + surfaceOp: surfaceOp({ op: 'replace', start: baseSeq, end: baseSeq }), + sourceEventSeqs: sourceSeqs(baseSeq), } as SessionEvent, ] - expect(new SurfaceManager(events, baseSeq).nodes).toEqual([baseSeq + 1]) + expect(new SurfaceManager(events, SessionLogOffset(baseSeq)).nodes).toEqual([baseSeq + 1]) }) it('rejects a replacement that crosses a loaded window head', () => { const baseSeq = 400_000 const events = [ - provenanceEvent(baseSeq, undefined), + provenanceEvent(SessionSeq(baseSeq), undefined), { - ...provenanceEvent(baseSeq + 1, [baseSeq - 1, baseSeq]), - surfaceOp: { op: 'replace', start: baseSeq - 1, end: baseSeq }, + ...provenanceEvent(SessionSeq(baseSeq + 1), [baseSeq - 1, baseSeq]), + surfaceOp: surfaceOp({ op: 'replace', start: baseSeq - 1, end: baseSeq }), }, ] as SessionEvent[] - expect(() => new SurfaceManager(events, baseSeq).nodes) + expect(() => new SurfaceManager(events, SessionLogOffset(baseSeq)).nodes) .toThrow(`surface replace: start seq ${baseSeq - 1} not found in surface`) }) @@ -305,7 +319,7 @@ describe('SurfaceManager', () => { ...{ provider: 'mock', model: 'mock' }, }, }), - }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] }) + }, { surfaceOp: surfaceOp({ op: 'replace', start: 0, end: 0 }), sourceEventSeqs: sourceSeqs(0) }) s.append('assistant/message', { turn: 1, step: 2, message: createMessage({ @@ -316,7 +330,7 @@ describe('SurfaceManager', () => { ...{ provider: 'mock', model: 'mock' }, }, }), - }, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2, 1] }) + }, { surfaceOp: surfaceOp({ op: 'replace', start: 2, end: 1 }), sourceEventSeqs: sourceSeqs(2, 1) }) const folded = foldSurface(s.snapshotEvents()) expect(folded.nodes).toEqual(s.surface.nodes) @@ -324,8 +338,8 @@ describe('SurfaceManager', () => { { seq: 2, start: 0, end: 0, shadowedSeqs: [0] }, { seq: 3, start: 2, end: 1, shadowedSeqs: [2, 1] }, ]) - folded.nodes[0] = 99 - folded.replacements[0]!.shadowedSeqs.push(99) + folded.nodes[0] = SessionSeq(99) + folded.replacements[0]!.shadowedSeqs.push(SessionSeq(99)) expect(s.surface.nodes).toEqual([3]) expect(foldSurface(s.snapshotEvents()).nodes).toEqual([3]) expect(foldSurface(s.snapshotEvents()).replacements[0]!.shadowedSeqs).toEqual([0]) @@ -346,7 +360,7 @@ describe('SurfaceManager', () => { ...{ provider: 'mock', model: 'mock' }, }, }), - }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] }) + }, { surfaceOp: surfaceOp({ op: 'replace', start: 0, end: 0 }), sourceEventSeqs: sourceSeqs(0) }) expect(s.surface.nodes).toEqual([1]) const manager = s.surface as unknown as { _state: object } @@ -358,8 +372,8 @@ describe('SurfaceManager', () => { it('foldSurface reports the same invalid replacement failures as the incremental manager', () => { const events = [ - provenanceEvent(0, undefined), - { ...provenanceEvent(1, [0]), surfaceOp: { op: 'replace', start: 42, end: 0 } }, + provenanceEvent(SessionSeq(0), undefined), + { ...provenanceEvent(SessionSeq(1), [0]), surfaceOp: { op: 'replace', start: 42, end: 0 } }, ] as SessionEvent[] expect(() => foldSurface(events)).toThrow(/start seq 42 not found/) @@ -367,6 +381,15 @@ describe('SurfaceManager', () => { .toThrow(/start seq 42 not found/) }) + it('rejects negative-zero replacement event sequences', () => { + const event = { + ...provenanceEvent(SessionSeq(0), [0]), + surfaceOp: { op: 'replace', start: -0, end: -0 }, + } as SessionEvent + + expect(() => foldSurface([event])).toThrow(/invalid replace surfaceOp/) + }) + it('leaves incremental state unchanged when candidate validation fails', () => { const s = Session.create(SessionId('atomic-validation')) s.append('user/message', createUserMessage({ @@ -391,7 +414,7 @@ describe('SurfaceManager', () => { }, }), }, - { surfaceOp: { op: 'replace', start: 0, end: 0 } }, + { surfaceOp: surfaceOp({ op: 'replace', start: 0, end: 0 }) }, )).toThrow(/missing 0/) expect(s.snapshotEvents()).toHaveLength(1) @@ -412,7 +435,7 @@ describe('SurfaceManager', () => { it('foldSurface rejects a surface-eligible event without its mandatory marker', () => { const malformed: SessionEvent = { type: 'user/message', - seq: 0, + seq: SessionSeq(0), time: 1, data: createUserMessage({ content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' }, @@ -426,7 +449,7 @@ describe('SurfaceManager', () => { it('foldSurface rejects surfaceOp on a non-surface event', () => { const malformed = { type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: 1, data: { turn: 1 }, surfaceOp: 'append', @@ -498,7 +521,7 @@ describe('SurfaceManager', () => { }, }), }, - { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] }, + { surfaceOp: surfaceOp({ op: 'replace', start: 1, end: 2 }), sourceEventSeqs: sourceSeqs(1, 2) }, ) expect(s.surface.nodes).toEqual([4]) }) @@ -527,7 +550,7 @@ describe('SurfaceManager', () => { }, }), }, - { surfaceOp: { op: 'replace', start: 0, end: 1 }, sourceEventSeqs: [0, 1] }, + { surfaceOp: surfaceOp({ op: 'replace', start: 0, end: 1 }), sourceEventSeqs: sourceSeqs(0, 1) }, ) // seq 3 expect(s.surface.nodes).toEqual([3, 2]) }) @@ -553,7 +576,7 @@ describe('SurfaceManager', () => { }, }), }, - { surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] }, + { surfaceOp: surfaceOp({ op: 'replace', start: 1, end: 1 }), sourceEventSeqs: sourceSeqs(1) }, ) // seq 2 expect(s.surface.nodes).toEqual([0, 2]) }) @@ -575,7 +598,7 @@ describe('SurfaceManager', () => { }, }), }, - { surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [0] }, + { surfaceOp: surfaceOp({ op: 'replace', start: 5, end: 0 }), sourceEventSeqs: sourceSeqs(0) }, )).toThrow(/surface replace: start seq 5 not found/) }) @@ -596,7 +619,7 @@ describe('SurfaceManager', () => { }, }), }, - { surfaceOp: { op: 'replace', start: 0, end: 99 }, sourceEventSeqs: [0] }, + { surfaceOp: surfaceOp({ op: 'replace', start: 0, end: 99 }), sourceEventSeqs: sourceSeqs(0) }, )).toThrow(/surface replace: end seq 99 not found/) }) @@ -621,7 +644,7 @@ describe('SurfaceManager', () => { }, }), }, - { surfaceOp: { op: 'replace', start: 1, end: 0 }, sourceEventSeqs: [1, 0] }, + { surfaceOp: surfaceOp({ op: 'replace', start: 1, end: 0 }), sourceEventSeqs: sourceSeqs(1, 0) }, )).toThrow(/start seq 1.*after end seq 0/) }) @@ -630,7 +653,7 @@ describe('SurfaceManager', () => { s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'source' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - const sources = [0] + const sources = sourceSeqs(0) s.append('assistant/message', { turn: 1, step: 1, message: createMessage({ @@ -643,8 +666,8 @@ describe('SurfaceManager', () => { }), }, { surfaceOp: 'append', sourceEventSeqs: sources }) // Mutate caller's array after append. - sources.push(1) - sources[0] = 99 + sources.push(SessionSeq(1)) + sources[0] = SessionSeq(99) const logged = s.snapshotEvents()[1]! as SurfaceEvent expect(logged.sourceEventSeqs).toEqual([0]) }) @@ -673,7 +696,7 @@ describe('SurfaceManager', () => { }, }), }, - { surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] }, + { surfaceOp: surfaceOp({ op: 'replace', start: 1, end: 1 }), sourceEventSeqs: sourceSeqs(1) }, ) // seq 3 expect(s.surface.nodes).toEqual([0, 3, 2]) }) @@ -683,7 +706,7 @@ describe('SurfaceManager', () => { s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'a' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - const op = { op: 'replace' as const, start: 0, end: 0 } + const op = { op: 'replace' as const, start: SessionSeq(0), end: SessionSeq(0) } s.append('assistant/message', { turn: 1, step: 1, message: createMessage({ @@ -694,9 +717,9 @@ describe('SurfaceManager', () => { ...{ provider: 'mock', model: 'mock' }, }, }), - }, { surfaceOp: op, sourceEventSeqs: [0] }) + }, { surfaceOp: op, sourceEventSeqs: sourceSeqs(0) }) // Mutate caller's object after append. - op.start = 99 + op.start = SessionSeq(99) const logged = s.snapshotEvents()[1]! as SurfaceEvent expect(logged.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 }) }) @@ -752,7 +775,7 @@ describe('deriveMessages with surface', () => { ...{ provider: 'mock', model: 'mock' }, }, }), - }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] }) + }, { surfaceOp: surfaceOp({ op: 'replace', start: 0, end: 0 }), sourceEventSeqs: sourceSeqs(0) }) // Only the compaction node is visible. const messages = s.deriveMessages() expect(messages).toHaveLength(1) @@ -792,7 +815,7 @@ describe('Session.append surface opts', () => { }, }), }, - { surfaceOp: 'append', sourceEventSeqs: [0, 1] }, + { surfaceOp: 'append', sourceEventSeqs: sourceSeqs(0, 1) }, ) expect(event.sourceEventSeqs).toEqual([0, 1]) expect(event.surfaceOp).toBe('append') @@ -806,9 +829,9 @@ describe('Session.append surface opts', () => { // but _deriveOneMessage returns null for it, so the surface derivation path's // null-check is exercised — the node is on the surface yet produces no message. const seed: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, - { type: 'assistant/message', seq: 2, time: 3, data: { + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'step/start', seq: SessionSeq(1), time: 2, data: { turn: 1, step: 1 } }, + { type: 'assistant/message', seq: SessionSeq(2), time: 3, data: { turn: 1, step: 1, message: createMessage({ role: 'assistant', @@ -819,8 +842,8 @@ describe('Session.append surface opts', () => { }, }), }, surfaceOp: 'append' }, - { type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } }, - { type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'step/end', seq: SessionSeq(3), time: 4, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: SessionSeq(4), time: 5, data: { turn: 1, reason: { kind: 'completed' } } }, ] const s = Session.create(SessionId('nomessage'), seed) // The empty assistant/message is on the surface but _deriveOneMessage returns null for it. @@ -856,14 +879,14 @@ describe('Session.append surface opts', () => { // surface-eligible type but with no surfaceOp must NOT narrow to a // SurfaceEvent — it would otherwise be silently dropped from the surface. const noMarker: SessionEvent = { - type: 'user/message', seq: 0, time: 1, + type: 'user/message', seq: SessionSeq(0), time: 1, data: createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), } expect(isSurfaceEvent(noMarker)).toBe(false) // A non-surface type is rejected too (the type gate). - const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1 } } + const boundary: SessionEvent = { type: 'turn/start', seq: SessionSeq(1), time: 1, data: { turn: 1 } } expect(isSurfaceEvent(boundary)).toBe(false) // A properly-marked surface event narrows. const marked = { ...noMarker, surfaceOp: 'append' } as SurfaceEvent @@ -898,7 +921,7 @@ describe('surface type guards', () => { // optional on SessionEvent, so this is a representable runtime value. const markerless: SessionEvent = { type: 'user/message', - seq: 0, + seq: SessionSeq(0), time: 0, data: createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, @@ -912,7 +935,7 @@ describe('surface type guards', () => { const s = surfaceSession() 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] }) + }), { surfaceOp: surfaceOp({ op: 'replace', start: 1, end: 2 }), sourceEventSeqs: sourceSeqs(1, 2) }) const appended = s.snapshotEvents().find(e => e.type === 'user/message')! const replacement = s.snapshotEvents().at(-1)! @@ -929,7 +952,7 @@ describe('surface type guards', () => { // all: it never entered the surface. const markerless: SessionEvent = { type: 'user/message', - seq: 0, + seq: SessionSeq(0), time: 0, data: createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, diff --git a/packages/experimental/agent-team/src/mailbox.ts b/packages/experimental/agent-team/src/mailbox.ts index 11f969edf8..0f71a4da37 100644 --- a/packages/experimental/agent-team/src/mailbox.ts +++ b/packages/experimental/agent-team/src/mailbox.ts @@ -308,8 +308,7 @@ export class TeamMailbox { /** Whether a target Session already contains the durable message identity. */ private targetRecorded(session: Session, messageId: TeamMessageId): boolean { - const suffix = session.snapshotEvents(session.header.seedLength ?? 0) - return messageAccepted(suffix, message => message.source.kind === 'team-message' + return messageAccepted(session.ownEvents(), message => message.source.kind === 'team-message' && message.source.messageId === messageId) } @@ -329,7 +328,7 @@ export class TeamMailbox { ): Promise { try { const stored = await this.ctx.sessionPersistence.inspect(targetId, signal) - const suffix = stored.events.slice(stored.meta.seedLength ?? 0) + const suffix = stored.events.slice(stored.inheritedEventCount) return messageAccepted(suffix, message => message.source.kind === 'team-message' && message.source.messageId === messageId) } catch (error: unknown) { diff --git a/packages/experimental/agent-team/src/roster.ts b/packages/experimental/agent-team/src/roster.ts index 802d0b4a2d..5035d2209a 100644 --- a/packages/experimental/agent-team/src/roster.ts +++ b/packages/experimental/agent-team/src/roster.ts @@ -346,7 +346,7 @@ export class TeamRoster { const session = this.ctx.sessions.get(childId) if (session === undefined) { const stored = await this.ctx.sessionPersistence.inspect(childId, signal) - const suffix = stored.events.slice(stored.meta.seedLength ?? 0) + const suffix = stored.events.slice(stored.inheritedEventCount) if (messageAccepted(suffix, message => message.id === messageId)) return throw new TeamError( `teammate "${childId}" initial prompt was not durably accepted`, @@ -374,7 +374,7 @@ export class TeamRoster { try { signal.throwIfAborted() await this.ctx.sessions.flush(session) - const suffix = session.snapshotEvents(session.header.seedLength ?? 0) + const suffix = session.ownEvents() if (messageAccepted(suffix, message => message.id === messageId)) return if (this.ctx.sessions.get(childId) !== session) continue await progress.promise @@ -398,7 +398,7 @@ export class TeamRoster { let failure = 'provisioning did not leave a resumable child Session' try { const loaded = await this.ctx.sessionPersistence.inspect(member.id, signal) - const suffix = loaded.events.slice(loaded.meta.seedLength ?? 0) + const suffix = loaded.events.slice(loaded.inheritedEventCount) const descriptor = foldSubagentDescriptor(suffix) const acceptedInitialPrompt = messageAccepted(suffix, message => message.source.kind === 'user') if (loaded.meta.parentSession === root.id @@ -481,6 +481,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.snapshotEvents(agent.session.header.seedLength ?? 0)) !== undefined + return foldSubagentDescriptor(agent.session.ownEvents()) !== undefined } } diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts index 9c85709e2e..fb115719aa 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -133,7 +133,7 @@ function persistedChild( })) const child = ctx.sessions.create(childId, { seed, - meta: { parentSession: rootId, seedLength: 0, origin: 'subagent' }, + meta: { parentSession: rootId, origin: 'subagent' }, }) child.append('agent/inbox/spliced', { target: 'next-turn', diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts index 3cd6b6103c..b2f9911d3a 100644 --- a/packages/experimental/agent-team/tests/projection-events.spec.ts +++ b/packages/experimental/agent-team/tests/projection-events.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionEventMap, SessionEventType } from '@deepseek-ai/dsh-session' import { teamProjectionDefinition } from '../src/projection.ts' import type { TeamProjectionState, TeamState } from '../src/projection.ts' @@ -10,12 +10,12 @@ const ROOT = SessionId('team-root') const TEAM = TeamId(ROOT) const CHILD = SessionId('child-a') -function event(type: T, data: SessionEventMap[T], seq: number): SessionEvent { +function event(type: T, data: SessionEventMap[T], seq: SessionSeq): SessionEvent { return { type, data, seq, time: seq } as SessionEvent } function project(rootId: SessionId, events: readonly SessionEvent[]): TeamProjectionState { - let state = teamProjectionDefinition.init({ version: 0, id: rootId, createdAt: 0 }) + let state = teamProjectionDefinition.init({ version: 0, id: rootId, createdAt: 0, isSeeded: false }) for (const event of events) state = teamProjectionDefinition.apply(state, event) return state } @@ -80,15 +80,15 @@ function message(overrides: Partial = {}): TeamMessageSnaps describe('Agent Teams projection events', () => { it('projects current-team records independently from inherited records', () => { const records: SessionEvent[] = [ - event('team/member', { version: 1, teamId: TeamId('ancestor'), member: member() }, 0), - event('team/member', { version: 1, teamId: TEAM, member: member() }, 1), + event('team/member', { version: 1, teamId: TeamId('ancestor'), member: member() }, SessionSeq(0)), + event('team/member', { version: 1, teamId: TEAM, member: member() }, SessionSeq(1)), event('team/member', { version: 1, teamId: TEAM, member: member({ phase: 'active' }), - }, 2), - event('team/task', { version: 1, teamId: TEAM, task: task({ id: TeamTaskId('task-7') }) }, 3), - event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, 4), + }, SessionSeq(2)), + event('team/task', { version: 1, teamId: TEAM, task: task({ id: TeamTaskId('task-7') }) }, SessionSeq(3)), + event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, SessionSeq(4)), ] const projected = project(ROOT, records) const state = teamState(projected) @@ -104,51 +104,51 @@ describe('Agent Teams projection events', () => { }) it('enforces teammate identity and lifecycle', () => { - const base = event('team/member', { version: 1, teamId: TEAM, member: member() }, 0) + const base = event('team/member', { version: 1, teamId: TEAM, member: member() }, SessionSeq(0)) expect(() => projectTeam(ROOT, [event('team/member', { version: 1, teamId: TEAM, member: member({ phase: 'active' }), - }, 0)])).toThrow(/must begin provisioning/) + }, SessionSeq(0))])).toThrow(/must begin provisioning/) expect(() => projectTeam(ROOT, [base, event('team/member', { version: 1, teamId: TEAM, member: member({ name: 'renamed', phase: 'active' }), - }, 1)])).toThrow(/immutable identity/) + }, SessionSeq(1))])).toThrow(/immutable identity/) expect(() => projectTeam(ROOT, [base, event('team/member', { version: 1, teamId: TEAM, member: member({ phase: 'active' }), - }, 1), event('team/member', { + }, SessionSeq(1)), event('team/member', { version: 1, teamId: TEAM, member: member({ phase: 'failed' }), - }, 2)])).toThrow(/invalid active -> failed/) + }, SessionSeq(2))])).toThrow(/invalid active -> failed/) const duplicateName = member({ id: SessionId('child-b') }) expect(() => projectTeam(ROOT, [base, event('team/member', { version: 1, teamId: TEAM, member: duplicateName, - }, 1)])).toThrow(/name .* reused/) + }, SessionSeq(1))])).toThrow(/name .* reused/) }) it('enforces task revision continuity', () => { - const first = event('team/task', { version: 1, teamId: TEAM, task: task() }, 0) + const first = event('team/task', { version: 1, teamId: TEAM, task: task() }, SessionSeq(0)) expect(() => projectTeam(ROOT, [event('team/task', { version: 1, teamId: TEAM, task: task({ revision: 2 }), - }, 0)])).toThrow(/begin at revision 1/) + }, SessionSeq(0))])).toThrow(/begin at revision 1/) expect(() => projectTeam(ROOT, [first, event('team/task', { version: 1, teamId: TEAM, task: task({ revision: 3 }), - }, 1)])).toThrow(/revision is not contiguous/) + }, SessionSeq(1))])).toThrow(/revision is not contiguous/) }) it('rejects every invalid persisted task dependency relation', () => { - const first = event('team/task', { version: 1, teamId: TEAM, task: task() }, 0) + const first = event('team/task', { version: 1, teamId: TEAM, task: task() }, SessionSeq(0)) const second = event('team/task', { version: 1, teamId: TEAM, @@ -156,14 +156,14 @@ describe('Agent Teams projection events', () => { id: TeamTaskId('task-2'), blockedBy: [TeamTaskId('task-1')], }), - }, 1) + }, SessionSeq(1)) const invalid: Array<{ records: SessionEvent[]; message: RegExp }> = [ { records: [event('team/task', { version: 1, teamId: TEAM, task: task({ blockedBy: [TeamTaskId('missing')] }), - }, 0)], + }, SessionSeq(0))], message: /blocker task "missing" .* is missing or deleted/, }, { @@ -171,14 +171,14 @@ describe('Agent Teams projection events', () => { version: 1, teamId: TEAM, task: task({ blockedBy: [TeamTaskId('task-1')] }), - }, 0)], + }, SessionSeq(0))], message: /cannot block itself/, }, { records: [first, event('team/task', { ...second.data, task: { ...second.data.task, blockedBy: [TeamTaskId('task-1'), TeamTaskId('task-1')] }, - }, 1)], + }, SessionSeq(1))], message: /repeats blocker/, }, { @@ -186,7 +186,7 @@ describe('Agent Teams projection events', () => { version: 1, teamId: TEAM, task: task({ revision: 2, blockedBy: [TeamTaskId('task-2')] }), - }, 2)], + }, SessionSeq(2))], message: /dependency cycle/, }, { @@ -194,7 +194,7 @@ describe('Agent Teams projection events', () => { version: 1, teamId: TEAM, task: task({ revision: 2, status: 'deleted' }), - }, 2)], + }, SessionSeq(2))], message: /blocker task "task-1" .* is missing or deleted/, }, ] @@ -209,7 +209,7 @@ describe('Agent Teams projection events', () => { version: 1, teamId: TEAM, task: task({ id: TeamTaskId('external-task') }), - }, 0)]) + }, SessionSeq(0))]) expect(state.nextTaskNumber).toBe(1) }) @@ -218,39 +218,39 @@ describe('Agent Teams projection events', () => { version: 1, teamId: TEAM, task: task({ id: TeamTaskId('task-9007199254740992') }), - }, 0)])).toThrow(/persisted Agent Teams team\/task payload is invalid/) + }, SessionSeq(0))])).toThrow(/persisted Agent Teams team\/task payload is invalid/) }) it('enforces mailbox queue and acknowledgement relations', () => { - const queued = event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, 0) + const queued = event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, SessionSeq(0)) const delivered = event('team/message/delivered', { version: 1, teamId: TEAM, messageId: TeamMessageId('message-1'), targetId: CHILD, - }, 1) + }, SessionSeq(1)) expect(pending(projectTeam(ROOT, [queued, delivered]))).toEqual([]) expect(() => projectTeam(ROOT, [queued, queued])).toThrow(/queued twice/) expect(() => projectTeam(ROOT, [delivered])).toThrow(/delivered before queueing/) expect(() => projectTeam(ROOT, [queued, event('team/message/delivered', { ...delivered.data, targetId: SessionId('other'), - }, 1)])).toThrow(/target changed/) - expect(() => projectTeam(ROOT, [queued, delivered, { ...delivered, seq: 2 }])).toThrow(/delivered twice/) + }, SessionSeq(1))])).toThrow(/target changed/) + expect(() => projectTeam(ROOT, [queued, delivered, { ...delivered, seq: SessionSeq(2) }])).toThrow(/delivered twice/) }) it('validates every current-version persisted payload before projecting it', () => { const malformed = [ { - ...event('team/member', { version: 1, teamId: TEAM, member: member() }, 0), + ...event('team/member', { version: 1, teamId: TEAM, member: member() }, SessionSeq(0)), data: { version: 1, teamId: TEAM, member: { ...member(), name: 42 } }, }, { - ...event('team/task', { version: 1, teamId: TEAM, task: task() }, 0), + ...event('team/task', { version: 1, teamId: TEAM, task: task() }, SessionSeq(0)), data: { version: 1, teamId: TEAM, task: { ...task(), blockedBy: [42] } }, }, { - ...event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, 0), + ...event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, SessionSeq(0)), data: { version: 1, teamId: TEAM, @@ -263,7 +263,7 @@ describe('Agent Teams projection events', () => { teamId: TEAM, messageId: TeamMessageId('message-1'), targetId: CHILD, - }, 0), + }, SessionSeq(0)), data: { version: 1, teamId: TEAM, @@ -272,11 +272,11 @@ describe('Agent Teams projection events', () => { }, }, { - ...event('team/member', { version: 1, teamId: TEAM, member: member() }, 0), + ...event('team/member', { version: 1, teamId: TEAM, member: member() }, SessionSeq(0)), data: { version: 1, teamId: TEAM, member: member(), unexpected: true }, }, { - ...event('team/task', { version: 1, teamId: TEAM, task: task() }, 0), + ...event('team/task', { version: 1, teamId: TEAM, task: task() }, SessionSeq(0)), data: { version: 1, teamId: 42, task: task() }, }, ] as unknown as SessionEvent[] @@ -293,7 +293,7 @@ describe('Agent Teams projection events', () => { version: 1, teamId: TEAM, message: message({ content: [extension] }), - }, 0)]) + }, SessionSeq(0))]) expect(pending(state)[0]?.content).toEqual([extension]) }) @@ -302,12 +302,12 @@ describe('Agent Teams projection events', () => { version: 2 as 1, teamId: TEAM, task: task(), - }, 0) + }, SessionSeq(0)) const later = event('team/task', { version: 1, teamId: TEAM, task: task(), - }, 1) + }, SessionSeq(1)) const state = project(ROOT, [invalid, later]) expect(state.failure).toMatch(/unsupported Agent Teams event version 2/) expect(isEmptyState(state)).toBe(true) @@ -318,7 +318,7 @@ describe('Agent Teams projection events', () => { version: 2 as 1, teamId: TeamId('ancestor'), task: task(), - }, 0) + }, SessionSeq(0)) const projected = project(ROOT, [inherited]) expect(projected.failure).toBeUndefined() expect(isEmptyState(teamState(projected))).toBe(true) @@ -330,7 +330,7 @@ describe('Agent Teams projection events', () => { version: 1, teamId: TeamId('ancestor'), task: task(), - }, 0), + }, SessionSeq(0)), data: { version: 1, teamId: TeamId('ancestor'), diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 291fd6b59d..013d6f4cce 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -210,8 +210,12 @@ describe('Team identity and provisioning', () => { const fresh = await spawn(ctx, lead, 'fresh-worker') await waitNoAgent(ctx, fresh.member.id) - expect((await ctx.sessionPersistence.inspect(forked.member.id)).meta.seedLength).toBeGreaterThan(0) - expect((await ctx.sessionPersistence.inspect(fresh.member.id)).meta.seedLength ?? 0).toBe(0) + const forkedInspection = await ctx.sessionPersistence.inspect(forked.member.id) + const freshInspection = await ctx.sessionPersistence.inspect(fresh.member.id) + expect(forkedInspection.meta.isSeeded).toBe(true) + expect(forkedInspection.inheritedEventCount).toBeGreaterThan(0) + expect(freshInspection.meta.isSeeded).toBe(false) + expect(freshInspection.inheritedEventCount).toBe(0) expect(ctx.agentTeams.listMembers(lead).map(row => [row.name, row.context, row.status])).toEqual([ ['lead', undefined, 'idle'], ['fork-worker', 'fork', 'inactive'], @@ -458,7 +462,8 @@ describe('Team identity and provisioning', () => { const handle = await ctx.agents.create({ sessionId: SessionId('ordinary-fork'), seed: lead.session.snapshotEvents(), - meta: { parentSession: lead.id, seedLength: lead.session.seq }, + inheritedEventCount: lead.session.seq, + meta: { parentSession: lead.id, isSeeded: true }, agentOptions: { provider: 'mock', model: 'mock' }, }) diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/storages/session_projcache.json b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/storages/session_projcache.json index 9248dd7ae7..e612124830 100644 --- a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/storages/session_projcache.json +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/storages/session_projcache.json @@ -1,7 +1,7 @@ { "unit": { "name": "session_projcache", - "version": 3 + "version": 5 }, "global": null, "tables": { @@ -9,7 +9,9 @@ "preview-showcase": { "identity": { "createdAt": 1787472000000, - "cwd": "/dsh/workspace" + "cwd": "/dsh/workspace", + "isSeeded": false, + "inheritedEventCount": 0 }, "rows": { "title": { diff --git a/packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts b/packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts index d4d1471d0d..bd7c0f6838 100644 --- a/packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts +++ b/packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts @@ -57,11 +57,21 @@ describe('WebWorker preview VFS example', () => { 'utf8', )) as { unit: { name: string; version: number } - tables: { sessions: Record } + tables: { + sessions: Record + } } - expect(cache.unit).toEqual({ name: 'session_projcache', version: 3 }) + expect(cache.unit).toEqual({ name: 'session_projcache', version: 5 }) expect(cache.tables.sessions[VFS_EXAMPLE_SESSION_IDS.main]).toMatchObject({ - identity: { createdAt: 1_787_472_000_000, cwd: '/dsh/workspace' }, + identity: { + createdAt: 1_787_472_000_000, + cwd: '/dsh/workspace', + isSeeded: false, + inheritedEventCount: 0, + }, rows: { title: { ver: 1, @@ -72,7 +82,7 @@ describe('WebWorker preview VFS example', () => { }) it('restores the main production log with paging and tool coverage', () => { - const { meta, events } = readSession(VFS_EXAMPLE_SESSION_IDS.main) + const { meta, inheritedEventCount, events } = readSession(VFS_EXAMPLE_SESSION_IDS.main) expect(meta).toMatchObject({ id: VFS_EXAMPLE_SESSION_IDS.main, cwd: '/dsh/workspace', @@ -81,7 +91,12 @@ describe('WebWorker preview VFS example', () => { }) expect(events.map(event => event.seq)).toEqual(events.map((_, index) => index)) expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) - expect(() => Session.fromRestore(SessionId(meta.id), events, meta)).not.toThrow() + expect(() => Session.fromRestore( + SessionId(meta.id), + events, + meta, + inheritedEventCount, + )).not.toThrow() const messages = events.filter(event => (event.type === 'user/message' || event.type === 'assistant/message') && event.surfaceOp === 'append') @@ -105,7 +120,7 @@ describe('WebWorker preview VFS example', () => { [VFS_EXAMPLE_SESSION_IDS.continuable, 'continuable'], ] as const for (const [id, mode] of expected) { - const { meta, events } = readSession(id) + const { meta, inheritedEventCount, events } = readSession(id) expect(meta).toMatchObject({ id, cwd: '/dsh/workspace', @@ -116,8 +131,13 @@ describe('WebWorker preview VFS example', () => { }) expect(events.map(event => event.seq)).toEqual(events.map((_, index) => index)) expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) - expect(foldSubagentDescriptor(events.slice(meta.seedLength ?? 0))).toMatchObject({ mode }) - expect(() => Session.fromRestore(SessionId(meta.id), events, meta)).not.toThrow() + expect(foldSubagentDescriptor(events.slice(inheritedEventCount))).toMatchObject({ mode }) + expect(() => Session.fromRestore( + SessionId(meta.id), + events, + meta, + inheritedEventCount, + )).not.toThrow() } }) }) diff --git a/packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts b/packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts index dbc151eb86..20ac1e3838 100644 --- a/packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts +++ b/packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts @@ -1,7 +1,15 @@ /** Deterministic source for the filesystem tree bundled into the WebWorker preview. */ import { fileURLToPath } from 'node:url' -import { SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' +import { + SessionId, + SessionLogOffset, + SessionSeq, + type SessionEvent, + type SessionHeader, + type SessionLogOffset as SessionLogOffsetType, + type SessionSeq as SessionSeqType, +} from '@deepseek-ai/dsh-session' import { eventLines, projectKey, toHeaderLine, } from '@deepseek-ai/dsh-session-persistence-jsonl/src/format.ts' @@ -71,7 +79,7 @@ interface EventDraft { readonly type: string readonly data: unknown readonly surfaceOp?: 'append' - readonly sourceEventSeqs?: number[] + readonly sourceEventSeqs?: SessionSeqType[] readonly ignorable?: true } @@ -84,8 +92,8 @@ class EventLog { this.nextTime = Math.max(time, (this.events.at(-1)?.time ?? time - 1) + 1) } - add(draft: EventDraft): number { - const seq = this.events.length + add(draft: EventDraft): SessionSeqType { + const seq = SessionSeq(this.events.length) this.events.push({ ...draft, seq, time: this.nextTime++ } as unknown as SessionEvent) return seq } @@ -265,10 +273,13 @@ function addClosedTextTurn(log: EventLog, turn: number): void { log.add({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } -function mainLog(): { readonly events: SessionEvent[]; readonly forkSeedLength: number } { +function mainLog(): { + readonly events: SessionEvent[] + readonly forkSeedLength: SessionLogOffsetType +} { const log = new EventLog(CREATED_AT) for (let turn = 1; turn <= HISTORICAL_TURNS; turn++) addClosedTextTurn(log, turn) - const forkSeedLength = log.events.length + const forkSeedLength = SessionLogOffset(log.events.length) const turn = HISTORICAL_TURNS + 1 const calls = galleryCalls() @@ -363,25 +374,36 @@ function continuableLog(): SessionEvent[] { function header( id: SessionHeader['id'], createdAt: number, - child?: { readonly parentSession: SessionHeader['id']; readonly mode: 'one-shot' | 'continuable'; readonly seedLength?: number }, -): SessionHeader { + child?: { + readonly parentSession: SessionHeader['id'] + readonly mode: 'one-shot' | 'continuable' + readonly seedLength?: SessionLogOffsetType + }, +): { readonly meta: SessionHeader; readonly inheritedEventCount: SessionLogOffsetType } { + const inheritedEventCount = child?.seedLength ?? SessionLogOffset(0) return { - version: 0, - id, - createdAt, - cwd: WORKSPACE, - delegationDepth: child === undefined ? 0 : 1, - agentPreset: 'standard', - ...child === undefined ? {} : { - parentSession: child.parentSession, - origin: 'subagent' as const, - ...child.seedLength === undefined ? {} : { seedLength: child.seedLength }, + meta: { + version: 0, + id, + createdAt, + cwd: WORKSPACE, + isSeeded: child?.seedLength !== undefined, + delegationDepth: child === undefined ? 0 : 1, + agentPreset: 'standard', + ...child === undefined ? {} : { + parentSession: child.parentSession, + origin: 'subagent' as const, + }, }, + inheritedEventCount, } } -function renderLog(meta: SessionHeader, events: readonly SessionEvent[]): string { - return `${JSON.stringify(toHeaderLine(meta))}\n${eventLines(events, true)}\n` +function renderLog( + storage: { readonly meta: SessionHeader; readonly inheritedEventCount: SessionLogOffsetType }, + events: readonly SessionEvent[], +): string { + return `${JSON.stringify(toHeaderLine(storage.meta, storage.inheritedEventCount))}\n${eventLines(events, true)}\n` } /** Build every committed fixture file as repository-relative UTF-8 text. */ @@ -390,12 +412,17 @@ export function buildVfsExampleFiles(): ReadonlyMap { const project = projectKey(WORKSPACE) const sessionPath = (id: string): string => `home/sessions/${project}/${id}/session.jsonl` const projectionCache = `${JSON.stringify({ - unit: { name: 'session_projcache', version: 3 }, + unit: { name: 'session_projcache', version: 5 }, global: null, tables: { sessions: { [VFS_EXAMPLE_SESSION_IDS.main]: { - identity: { createdAt: CREATED_AT, cwd: WORKSPACE }, + identity: { + createdAt: CREATED_AT, + cwd: WORKSPACE, + isSeeded: false, + inheritedEventCount: 0, + }, rows: { title: { ver: 1, seq: main.events.at(-1)?.seq ?? -1, val: VFS_EXAMPLE_TITLE }, }, diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 55c4f977ef..00c7b4a4b2 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -559,7 +559,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ISession', - declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n loadThrough(seq: number): Promise;\n command(line: string): Promise>;\n}', + declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n loadThrough(seq: SessionSeq): Promise;\n command(line: string): Promise>;\n}', }, { name: 'KeyPropsOf', diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 81890bfd98..dc9c78a9d5 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:204', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:205', }, { key: 'conversation.chat.commandview', @@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:192', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:193', }, { key: 'conversation.chat.node', @@ -313,7 +313,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:173', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:174', }, { key: 'conversation.chat.turnTail', @@ -358,7 +358,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:198', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:199', }, { key: 'conversation.composer', @@ -537,7 +537,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n { name: \'conversation.details.tool\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:210', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:211', }, { key: 'conversation.hero.agentPreset', @@ -1025,7 +1025,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:186', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:187', }, { key: 'conversation.session', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 7c6091b1e4..55761bf933 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -664,7 +664,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ throws: ['{@link ManualCompactionError} for expected busy, agent-cancellation, changed-span, summarization/shrink, commit-stage, or persistence failures; an aborted request preserves its exact abort reason. Failed attempts remain visible in the log.'], }, { - signature: 'abstract compactRegion( start: number, end: number, agent: CompactionAgentContext, signal?: AbortSignal, ): Promise', + signature: 'abstract compactRegion( start: SessionSeq, end: SessionSeq, agent: CompactionAgentContext, signal?: AbortSignal, ): Promise', description: 'Forcibly compact a range of surface nodes into a single summary node. `start` and `end` name an inclusive span by surface position, not numeric seq order; replacements can make visible seqs non-monotonic. Both edges must be balanced so assistant tool calls remain paired with their results. A model- backed implementation forwards cancellation and rejects active, missing, reversed, or unbalanced ranges. The target session is `agent.session`. Its replacement user message must use compactCheckpointSource with the transaction\'s `CompactionId`. Use toolPairingBalancedBefore and toolPairingBalancedAfter for the edge checks.', parameters: [{ name: 'start', description: 'first surface seq, inclusive.' }, { name: 'end', description: 'last surface seq, inclusive.' }, { name: 'agent', description: 'context whose session is mutated and whose routing options guide summarization.' }, { name: 'signal', description: 'optional cancellation; model-backed implementations must forward it.' }], returns: 'the appended event seqs, summary, replaced range, and token accounting.', @@ -1341,7 +1341,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: readonly SessionEvent[] }>', + signature: 'inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise', 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.', @@ -1482,9 +1482,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ throws: ['when this backend does not expose per-session raw artifacts.'], }, { - signature: 'abstract create(meta: SessionHeader): Promise', + signature: 'abstract create(meta: SessionHeader, inheritedEventCount?: SessionLogOffset): Promise', description: 'Register a new session\'s metadata. A backend MAY defer the physical write until the first append (lazy materialization), in which case a created-but-never-appended session is absent from list — abandoned sessions leave nothing behind.', - parameters: [{ name: 'meta', description: 'the immutable header (id, version, cwd, lineage) to record.' }], + parameters: [{ name: 'meta', description: 'the immutable header (id, version, cwd, lineage) to record.' }, { name: 'inheritedEventCount', description: 'exact fork-inherited prefix length. Required for a seeded header and omitted only for an unseeded header.' }], }, { signature: 'ensureMaterialized(_session: Session): Promise', @@ -1493,7 +1493,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract append(id: SessionId, events: readonly SessionEvent[]): Promise', - description: 'Durably persist a batch of events. Honors the append-only and contiguous- seq contracts: the first event\'s `seq` MUST equal the stored next-seq (after `load` has durably closed any interrupted turn). Rejects non-JSON- serializable `event.data` with an error naming the offending event type.', + description: 'Durably persist a batch of events. Honors the append-only and contiguous- seq contracts: the first event\'s `seq` MUST equal the stored next-seq (after `load` has durably closed any interrupted turn). Rejects non-JSON- serializable `event.data` with an error naming the offending event type. A seeded session\'s first materializing batch must reach its complete inherited prefix.', parameters: [{ name: 'id', description: 'the session the batch belongs to.' }, { name: 'events', description: 'the contiguous batch to persist, in seq order.' }], }, { @@ -1521,10 +1521,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'a disposable immutable observation.', }, { - signature: 'abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', + signature: 'abstract readFrom(id: SessionId, fromSeq: SessionLogOffset, signal?: AbortSignal): Promise', description: 'Read the stored events from `fromSeq` onward — the read-from-seq primitive for read models that resume from a watermark (e.g. a persisted projection cache folding only the tail past its checkpoint). Unlike inspect, it is a detached physical suffix read: no preparation cache, torn-tail truncation, synthetic closers, or coordinator-state publication. Only events from the valid contiguous stored prefix are returned, so a torn fragment never reaches the caller. `fromSeq` at or beyond the stored prefix returns an empty event list (never an error). A backend whose medium can seek by seq may read only the suffix; sequential media such as JSONL still parse the whole artifact and skip forward. The primitive bounds what is returned and refolded, not every backend\'s physical read.', - parameters: [{ name: 'id', description: 'the persisted session to read.' }, { name: 'fromSeq', description: 'first event seq to include; a non-negative safe integer.' }, { name: 'signal', description: 'optional cancellation for queued and backend read work.' }], - returns: 'the header and the stored events with `seq >= fromSeq`.', + parameters: [{ name: 'id', description: 'the persisted session to read.' }, { name: 'fromSeq', description: 'first event offset to include.' }, { name: 'signal', description: 'optional cancellation for queued and backend read work.' }], + returns: 'storage metadata, the requested offset, and stored events with `seq >= fromSeq`.', }, { signature: 'abstract list(signal?: AbortSignal): Promise', @@ -1546,15 +1546,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus three mandatory points — session creation, `turn/end`, and session disposal (the live-to-cold moment) — and serves the cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write.', methods: [ { - signature: 'cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined', + signature: 'cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys?: readonly Extract[], ): ProjectionSnapshot | undefined', description: 'The zero-I/O listing read: whole values viewed straight from the stored rows (version-matching keys only), each cut carried with its watermark so a client value store can seed under its higher-seq-wins rule — as stale as the last durable checkpoint but never wrong, and never from an unrelated log (the caller\'s header is the identity witness). Fresher paths (the history tail baseline) supersede these values whenever a session is actually opened.', - parameters: [{ name: 'meta', description: 'the listed session\'s header (identity witness; no log read).' }, { name: 'keys', description: 'optional projection keys required by the caller\'s audience.' }], + parameters: [{ name: 'meta', description: 'the listed session\'s header (identity witness; no log read).' }, { name: 'inheritedEventCount', description: 'exact inherited prefix length that completes the checkpoint identity.' }, { name: 'keys', description: 'optional projection keys required by the caller\'s audience.' }], returns: 'the cut (`asOfSeq` = lowest served-row watermark), or `undefined` when no usable row exists for this lifecycle.', }, { - signature: 'hydratePrepared( session: Session, meta: SessionHeader, events: readonly SessionEvent[], ): ProjectionSnapshot', + signature: 'hydratePrepared( session: Session, events: readonly SessionEvent[], ): ProjectionSnapshot', description: 'Hydrate projection cells for an already-prepared Session without another persistence read. The cache seeds matching rows; the supplied exact log advances every unit to the observation cut. No checkpoint is written because the logical observation may contain recovery events not yet durable.', - parameters: [{ name: 'session', description: 'exact unpublished Session retained by persistence.' }, { name: 'meta', description: 'observed lifecycle header.' }, { name: 'events', description: 'exact logical event prefix represented by the observation.' }], + parameters: [{ name: 'session', description: 'exact unpublished Session retained by persistence.' }, { name: 'events', description: 'exact logical event prefix represented by the observation.' }], returns: 'all projection values at the event cut.', }, { @@ -1564,9 +1564,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'resolution after durability and event emission.', }, { - signature: 'coldSnapshot(meta: SessionHeader, events: readonly SessionEvent[]): ProjectionSnapshot', + signature: 'coldSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, events: readonly SessionEvent[], ): ProjectionSnapshot', description: 'Cold-read one session\'s projections from its complete log. Each unit is seeded from the identity-checked cached rows — the registry skips `apply` for the already-folded prefix (events at or below the row\'s `seq`) — and the refreshed checkpoint is written back (fail-soft, fire-and-forget), so the first cold read creates the cache row and later ones seed from it. The caller supplies the complete log in seq order: this service never consults the persistence layer.', - parameters: [{ name: 'meta', description: 'the stored session header (identity witness).' }, { name: 'events', description: 'the session\'s complete log, in seq order.' }], + parameters: [{ name: 'meta', description: 'the stored session header (identity witness).' }, { name: 'inheritedEventCount', description: 'exact inherited prefix length for projection initialization and identity.' }, { name: 'events', description: 'the session\'s complete log, in seq order.' }], returns: 'the projection cut at the log end.', }, ], @@ -1619,7 +1619,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'one row per registered key.', }, { - signature: 'restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined', + signature: 'restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined', description: 'The stored seq a restore tail read over `checkpoint` must start at: one event BELOW the lowest usable watermark (a row is usable when its `ver` matches the live unit\'s `stateVersion`; an absent or mismatched row pulls the floor to `0` — that key must refold the full log). The one-below anchor is load-bearing: the tail then proves how far the stored log still extends, so restore can detect a log that shrank below a row\'s watermark (crash-repair truncation) instead of serving the stale row as current — an empty tail read from the anchor yields an end below every watermark and the restore rejects for a full re-read.', parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }], returns: 'the seq to hand the persistence `readFrom`, or `undefined` when no unit is registered (no read needed — {@link restore} would serve empty values regardless).', @@ -1631,13 +1631,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'whole values per key with a usable row; empty when none.', }, { - signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, header: SessionHeader, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }', + signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, header: SessionHeader, inheritedEventCount: SessionLogOffset, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }', description: 'Cold read: fold every persisted unit over a stored log suffix, seeding each from its checkpoint row when usable — the one read recipe (cached state + forward tail replay + `view`) applied without a live `Session`. Call with the events returned by a persistence `readFrom(id, restoreFloor(checkpoint))` and that same floor as `baseSeq`; the floor\'s one-below anchor makes the supplied end honest, so a shrunk log is detected here. A row is usable iff its `ver` matches the live unit\'s `stateVersion`, it does not predate `baseSeq` (`seq >= baseSeq - 1`), and it does not claim events past the supplied end (`seq <= endSeq`); an unusable row is discarded and its key refolds from `init` — which is only sound over the full log, so a discarded row with `baseSeq > 0` throws (the caller re-reads from seq 0, e.g. after a crash-repair truncation shrank the log below a row\'s watermark).', - parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'events', description: 'the stored events with `seq >= baseSeq`, in seq order.' }, { name: 'baseSeq', description: 'the seq `events` starts at (its first event\'s seq when non-empty).' }, { name: 'header', description: 'immutable metadata for the Session being restored.' }], + parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'events', description: 'the stored events with `seq >= baseSeq`, in seq order.' }, { name: 'baseSeq', description: 'the seq `events` starts at (its first event\'s seq when non-empty).' }, { name: 'header', description: 'immutable metadata for the Session being restored.' }, { name: 'inheritedEventCount', description: 'exact fork-inherited prefix length supplied to unit initialization.' }], returns: 'the snapshot cut at the supplied log end (`asOfSeq` is the last supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the refreshed checkpoint rows at that cut, ready for a durable write-back.', }, { - signature: 'hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): ProjectionSnapshot', + signature: 'hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, ): ProjectionSnapshot', description: 'Restore an exact cut and install its states on the supplied prepared Session. A later publication reuses these cells; ordinary live reads and event drive advance any constructor-owned suffix exactly once.', parameters: [{ name: 'session', description: 'exact prepared Session that owns the restored log prefix.' }, { name: 'checkpoint', description: 'persisted rows for this Session lifecycle.' }, { name: 'events', description: 'exact events at the observation cut.' }, { name: 'baseSeq', description: 'first supplied event sequence.' }], returns: 'all projection values at the supplied cut.', @@ -1822,7 +1822,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'a fresh array; mutating it does not affect the store.', }, { - signature: 'fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session', + signature: 'fork(source: SessionForkSource, boundary?: SessionSeq, childSessionId?: SessionId): Session', description: 'Create a live child session from a stable prefix of a live source. `boundary` is an inclusive source event seq; omitted means the source\'s current last event. The selected slice may end with a between-turn event but must not end inside an open turn.', parameters: [{ name: 'source', description: 'Live source session object or id.' }, { name: 'boundary', description: 'Inclusive source event seq to fork through; omitted means the source\'s current last event, and omitted on an empty source forks an empty child.' }, { name: 'childSessionId', description: 'Optional child session id; omitted delegates to `SessionStore`\'s id policy.' }], returns: 'The created live child session.', @@ -3614,9 +3614,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'Branded', declaration: 'export type Branded = string & {\n readonly [BRAND]: B;\n};', }, + { + name: 'BrandedNumber', + declaration: 'export type BrandedNumber = number & {\n readonly [BRAND]: B;\n};', + }, { name: 'ChunkRow', - declaration: 'export type ChunkRow = {\n type: \'text-chunks\';\n seq0: number;\n time0: number;\n data: TextRunData;\n} | {\n type: \'reasoning-chunks\';\n seq0: number;\n time0: number;\n data: TextRunData;\n} | {\n type: \'tool-call-chunks\';\n seq0: number;\n time0: number;\n data: ToolCallRunData;\n};', + declaration: 'export type ChunkRow = {\n type: \'text-chunks\';\n seq0: SessionSeqType;\n time0: number;\n data: TextRunData;\n} | {\n type: \'reasoning-chunks\';\n seq0: SessionSeqType;\n time0: number;\n data: TextRunData;\n} | {\n type: \'tool-call-chunks\';\n seq0: SessionSeqType;\n time0: number;\n data: ToolCallRunData;\n};', }, { name: 'ChunkRowEvent', @@ -3684,7 +3688,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CommandResult', - declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n readonly sourceEventSeq?: number;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};', + declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n readonly sourceEventSeq?: SessionSeq;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};', }, { name: 'CompactionAgentContext', @@ -3696,7 +3700,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CompactionResult', - declaration: 'export interface CompactionResult {\n compactionId: CompactionId;\n sourceCommandId?: CommandId;\n startSeq: number;\n summarySeq: number;\n endSeq: number;\n summary: ContentBlock[];\n shadowedRange: {\n start: number;\n end: number;\n };\n shadowedSeqs: number[];\n shadowedTokenCount: number;\n}', + declaration: 'export interface CompactionResult {\n compactionId: CompactionId;\n sourceCommandId?: CommandId;\n startSeq: SessionSeq;\n summarySeq: SessionSeq;\n endSeq: SessionSeq;\n summary: ContentBlock[];\n shadowedRange: {\n start: SessionSeq;\n end: SessionSeq;\n };\n shadowedSeqs: SessionSeq[];\n shadowedTokenCount: number;\n}', }, { name: 'CompactionTrigger', @@ -3820,7 +3824,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly isSeeded?: boolean;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n };\n readonly inheritedEventCount?: SessionLogOffset;\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, { name: 'CreateGoalRequest', @@ -3832,7 +3836,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateSessionOptions', - declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n };\n}', + declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly inheritedEventCount?: SessionLogOffset;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly isSeeded?: boolean;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n };\n}', }, { name: 'CreateTeamTaskRequest', @@ -4510,6 +4514,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'OneShotSubagentDescriptorData', declaration: 'export interface OneShotSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: \'one-shot\';\n readonly label?: string;\n}', }, + { + name: 'OptionalSessionSeq', + declaration: 'export type OptionalSessionSeq = SessionSeq | null;', + }, { name: 'PermissionSelect', declaration: 'export interface PermissionSelect {\n options: PresetOption[];\n currentValue: string;\n}', @@ -4564,7 +4572,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProjectionChangeListener', - declaration: 'export type ProjectionChangeListener = (session: Session, key: Extract, value: unknown, seq: number) => void;', + declaration: 'export type ProjectionChangeListener = (session: Session, key: Extract, value: unknown, seq: SessionSeq) => void;', }, { name: 'ProjectionCheckpoint', @@ -4572,15 +4580,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProjectionCheckpointRow', - declaration: 'export interface ProjectionCheckpointRow {\n ver: number;\n seq: number;\n val: unknown;\n}', + declaration: 'export interface ProjectionCheckpointRow {\n ver: number;\n seq: SessionSeqCursor;\n val: unknown;\n}', }, { 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, inheritedEventCount: SessionLogOffset): 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', - declaration: 'export interface ProjectionSnapshot {\n asOfSeq: number;\n values: Partial;\n}', + declaration: 'export interface ProjectionSnapshot {\n asOfSeq: SessionSeqCursor;\n values: Partial;\n}', }, { name: 'PromptAssembly', @@ -4608,7 +4616,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PrunedEntry', - declaration: 'export interface PrunedEntry {\n readonly originalSeq: number;\n readonly replacementSeq: number;\n readonly callId: ToolCallId;\n readonly charsBefore: number;\n readonly charsAfter: number;\n}', + declaration: 'export interface PrunedEntry {\n readonly originalSeq: SessionSeq;\n readonly replacementSeq: SessionSeq;\n readonly callId: ToolCallId;\n readonly charsBefore: number;\n readonly charsAfter: number;\n}', }, { name: 'PruneResult', @@ -4704,7 +4712,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RestoredSessionOptions', - declaration: 'export interface RestoredSessionOptions {\n readonly seed: SessionEvent[];\n readonly meta: SessionHeader;\n readonly seedSource: \'persistence\';\n}', + declaration: 'export interface RestoredSessionOptions {\n readonly seed: SessionEvent[];\n readonly meta: SessionHeader;\n readonly inheritedEventCount: SessionLogOffset;\n readonly seedSource: \'persistence\';\n}', }, { name: 'ResumeAgentOptions', @@ -4788,7 +4796,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 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 readonly inheritedEventCount: SessionLogOffset;\n get id(): SessionId;\n readonly firstLiveSeq: SessionLogOffset;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader, inheritedEventCount: SessionLogOffset): Session;\n eventAt(seq: SessionSeq): SessionEvent | undefined;\n snapshotEvents(fromSeq: SessionLogOffset = SessionLogOffset(0), toSeqExclusive: SessionLogOffset = this.seq): readonly SessionEvent[];\n ownEvents(): readonly SessionEvent[];\n isOwnSeq(seq: SessionSeq): boolean;\n get seq(): SessionLogOffset;\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', @@ -4836,7 +4844,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEvent', - declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n ignorable?: true;\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', + declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: SessionSeq;\n time: number;\n data: SessionEventMap[K];\n ignorable?: true;\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: SessionSeq[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', }, { name: 'SessionEventEntry', @@ -4852,11 +4860,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventReadRequest', - declaration: 'export interface SessionEventReadRequest {\n sessionId: SessionId;\n seq: number;\n before?: number;\n after?: number;\n}', + declaration: 'export interface SessionEventReadRequest {\n sessionId: SessionId;\n seq: SessionSeq;\n before?: number;\n after?: number;\n}', }, { name: 'SessionEventRecord', - declaration: 'export interface SessionEventRecord {\n sessionId: SessionId;\n seq: number;\n type: SessionEventType;\n time: number;\n surface: SessionEventSurface;\n}', + declaration: 'export interface SessionEventRecord {\n sessionId: SessionId;\n seq: SessionSeq;\n type: SessionEventType;\n time: number;\n surface: SessionEventSurface;\n}', }, { name: 'SessionEventResultFilter', @@ -4878,13 +4886,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventSearchRequest', declaration: 'export interface SessionEventSearchRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}', }, + { + name: 'SessionEventSuffix', + declaration: 'export interface SessionEventSuffix extends SessionStorageMetadata {\n readonly fromSeq: SessionLogOffset;\n readonly events: readonly SessionEvent[];\n}', + }, { name: 'SessionEventSurface', declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';', }, { name: 'SessionEventTrace', - declaration: 'export interface SessionEventTrace {\n target: SessionEventRecord;\n replacedBy?: number;\n replacementChain: number[];\n replacedEventSeqs: number[];\n sourceEventSeqs: number[];\n derivedEventSeqs: number[];\n}', + declaration: 'export interface SessionEventTrace {\n target: SessionEventRecord;\n replacedBy?: SessionSeq;\n replacementChain: SessionSeq[];\n replacedEventSeqs: SessionSeq[];\n sourceEventSeqs: SessionSeq[];\n derivedEventSeqs: SessionSeq[];\n}', }, { name: 'SessionEventTraceObservation', @@ -4892,7 +4904,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventTraceRequest', - declaration: 'export interface SessionEventTraceRequest {\n sessionId: SessionId;\n seq: number;\n}', + declaration: 'export interface SessionEventTraceRequest {\n sessionId: SessionId;\n seq: SessionSeq;\n}', }, { name: 'SessionEventType', @@ -4900,11 +4912,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventWindow', - declaration: 'export interface SessionEventWindow {\n session: SessionHeader;\n target: SessionEvent;\n events: SessionEvent[];\n startSeq: number;\n endSeq: number;\n}', + declaration: 'export interface SessionEventWindow {\n session: SessionHeader;\n inheritedEventCount: SessionLogOffset;\n target: SessionEvent;\n events: SessionEvent[];\n startSeq: SessionSeq;\n endSeq: SessionSeq;\n}', }, { name: 'SessionFollowFrame', - declaration: 'export type SessionFollowFrame = {\n readonly type: \'snapshot\';\n readonly header: SessionHeader;\n readonly cursor: number;\n readonly records: readonly SessionHistoryRecord[];\n readonly hasMore: boolean;\n readonly projections: SessionProjectionBaseline;\n} | SessionEventEntry;', + declaration: 'export type SessionFollowFrame = {\n readonly type: \'snapshot\';\n readonly header: SessionWireHeader;\n readonly cursor: number;\n readonly records: readonly SessionHistoryRecord[];\n readonly hasMore: boolean;\n readonly projections: SessionProjectionBaseline;\n} | SessionEventEntry;', }, { name: 'SessionFollowRequest', @@ -4924,7 +4936,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionHeader', - declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n}', + declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly isSeeded: boolean;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n}', }, { name: 'SessionHistoryRecord', @@ -4936,7 +4948,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionInspection', - declaration: 'export interface SessionInspection {\n readonly meta: SessionHeader;\n readonly events: readonly SessionEvent[];\n}', + declaration: 'export interface SessionInspection extends SessionStorageMetadata {\n readonly events: readonly SessionEvent[];\n}', }, { name: 'SessionJob', @@ -4962,13 +4974,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionLocation', declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}', }, + { + name: 'SessionLogOffset', + declaration: 'export type SessionLogOffset = BrandedNumber<\'SessionLogOffset\'>;', + }, { name: 'SessionLogSnapshot', - declaration: 'export interface SessionLogSnapshot {\n session: SessionHeader;\n events: SessionEvent[];\n}', + declaration: 'export interface SessionLogSnapshot {\n session: SessionHeader;\n inheritedEventCount: SessionLogOffset;\n events: SessionEvent[];\n}', }, { name: 'SessionObservation', - declaration: 'export interface SessionObservation extends Disposable {\n readonly source: \'live\' | \'prepared\';\n readonly header: SessionHeader;\n readonly events: readonly SessionEvent[];\n readonly cursor: number;\n readonly revision?: SessionPersistenceRevision;\n readonly projections?: ProjectionSnapshot;\n retain(): SessionObservation;\n}', + declaration: 'export interface SessionObservation extends Disposable {\n readonly source: \'live\' | \'prepared\';\n readonly header: SessionHeader;\n readonly events: readonly SessionEvent[];\n readonly inheritedEventCount: SessionLogOffsetType;\n readonly cursor: SessionSeqCursor;\n readonly revision?: SessionPersistenceRevision;\n readonly projections?: ProjectionSnapshot;\n retain(): SessionObservation;\n}', }, { name: 'SessionObservationOptions', @@ -5048,7 +5064,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionRawArtifact', - declaration: 'export interface SessionRawArtifact {\n readonly meta: SessionHeader;\n readonly filename: string;\n readonly content: string;\n}', + declaration: 'export interface SessionRawArtifact extends SessionStorageMetadata {\n readonly filename: string;\n readonly content: string;\n}', }, { name: 'SessionRecord', @@ -5118,21 +5134,33 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionSelectModelValue', declaration: 'export interface SessionSelectModelValue {\n readonly selected: ModelSelection;\n}', }, + { + name: 'SessionSeq', + declaration: 'export type SessionSeq = BrandedNumber<\'SessionSeq\'>;', + }, + { + name: 'SessionSeqCursor', + declaration: 'export type SessionSeqCursor = SessionSeq | -1;', + }, { name: 'SessionStartSource', declaration: 'export type SessionStartSource = \'startup\' | \'resume\' | \'clear\' | \'compact\';', }, + { + name: 'SessionStorageMetadata', + declaration: 'export interface SessionStorageMetadata {\n readonly meta: SessionHeader;\n readonly inheritedEventCount: SessionLogOffset;\n}', + }, { name: 'SessionSummary', declaration: 'export interface SessionSummary {\n readonly sessionId: SessionId;\n readonly updatedAt: number;\n readonly running: boolean;\n readonly blank: boolean;\n readonly parentSessionId?: SessionId;\n readonly origin?: \'subagent\';\n readonly cwd?: string;\n readonly projections?: SessionProjectionHints;\n}', }, { name: 'SessionSurface', - declaration: 'export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n}', + declaration: 'export interface SessionSurface {\n readonly nodes: readonly SessionSeq[];\n readonly replaceGeneration: number;\n}', }, { name: 'SessionSurfaceSnapshot', - declaration: 'export interface SessionSurfaceSnapshot {\n session: SessionHeader;\n capturedThroughSeq: number | null;\n events: SurfaceEvent[];\n}', + declaration: 'export interface SessionSurfaceSnapshot {\n session: SessionHeader;\n inheritedEventCount: SessionLogOffset;\n capturedThroughSeq: OptionalSessionSeq;\n events: SurfaceEvent[];\n}', }, { name: 'SessionTelemetryRecord', @@ -5152,7 +5180,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionTitleEventData', - declaration: 'export interface SessionTitleEventData {\n readonly title: string;\n readonly messageSeqs: number[];\n readonly source: SessionTitleSource;\n}', + declaration: 'export interface SessionTitleEventData {\n readonly title: string;\n readonly messageSeqs: SessionSeq[];\n readonly source: SessionTitleSource;\n}', }, { name: 'SessionTitleModelProvenance', @@ -5176,11 +5204,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionTitleProviderResult', - declaration: 'export interface SessionTitleProviderResult {\n readonly title: string;\n readonly messageSeqs: readonly number[];\n readonly model?: SessionTitleModelProvenance;\n}', + declaration: 'export interface SessionTitleProviderResult {\n readonly title: string;\n readonly messageSeqs: readonly SessionSeq[];\n readonly model?: SessionTitleModelProvenance;\n}', }, { name: 'SessionTitleSnapshot', - declaration: 'export interface SessionTitleSnapshot extends SessionTitleEventData {\n readonly eventSeq: number;\n readonly updatedAt: number;\n}', + declaration: 'export interface SessionTitleSnapshot extends SessionTitleEventData {\n readonly eventSeq: SessionSeq;\n readonly updatedAt: number;\n}', }, { name: 'SessionTitleSource', @@ -5188,7 +5216,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionTitleUserMessage', - declaration: 'export interface SessionTitleUserMessage {\n readonly seq: number;\n readonly text: string;\n}', + declaration: 'export interface SessionTitleUserMessage {\n readonly seq: SessionSeq;\n readonly text: string;\n}', }, { name: 'SessionUpdateQueueRequest', @@ -5200,7 +5228,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionWireEvent', - declaration: 'export interface SessionWireEvent {\n readonly type: string;\n readonly seq: number;\n readonly time: number;\n readonly data: JsonValue;\n readonly ignorable?: true;\n readonly sourceEventSeqs?: number[];\n readonly surfaceOp?: SurfaceOp;\n}', + declaration: 'export interface SessionWireEvent {\n readonly type: string;\n readonly seq: number;\n readonly time: number;\n readonly data: JsonValue;\n readonly ignorable?: true;\n readonly sourceEventSeqs?: number[];\n readonly surfaceOp?: SessionWireSurfaceOp;\n}', + }, + { + name: 'SessionWireHeader', + declaration: 'export interface SessionWireHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n}', + }, + { + name: 'SessionWireSurfaceOp', + declaration: 'export type SessionWireSurfaceOp = \'append\' | {\n readonly op: \'replace\';\n readonly start: number;\n readonly end: number;\n};', }, { name: 'SettingsApplies', @@ -5536,11 +5572,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SurfaceIntent', - declaration: 'export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n}', + declaration: 'export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: SessionSeq[];\n}', }, { name: 'SurfaceOp', - declaration: 'export type SurfaceOp = \'append\' | {\n op: \'replace\';\n start: number;\n end: number;\n};', + declaration: 'export type SurfaceOp = \'append\' | {\n op: \'replace\';\n start: SessionSeq;\n end: SessionSeq;\n};', }, { name: 'SystemPrompt', @@ -5680,7 +5716,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TokenMeasurement', - declaration: 'export interface TokenMeasurement {\n readonly logRevision: number;\n readonly baseline: TokenMeasurementBaseline;\n readonly surfaceDeltaTokens: number;\n readonly totalTokens: number;\n readonly surfaceTokens: number;\n readonly nodes: readonly TokenSurfaceNode[];\n}', + declaration: 'export interface TokenMeasurement {\n readonly logRevision: SessionLogOffset;\n readonly baseline: TokenMeasurementBaseline;\n readonly surfaceDeltaTokens: number;\n readonly totalTokens: number;\n readonly surfaceTokens: number;\n readonly nodes: readonly TokenSurfaceNode[];\n}', }, { name: 'TokenMeasurementBaseline', @@ -5688,7 +5724,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TokenSurfaceNode', - declaration: 'export interface TokenSurfaceNode {\n readonly seq: number;\n readonly tokens: number;\n readonly heuristicTokens: number;\n}', + declaration: 'export interface TokenSurfaceNode {\n readonly seq: SessionSeq;\n readonly tokens: number;\n readonly heuristicTokens: number;\n}', }, { name: 'TokenUsage', diff --git a/packages/feedback/message-feedback/src/index.ts b/packages/feedback/message-feedback/src/index.ts index 65ffd0c279..e07a8b809c 100644 --- a/packages/feedback/message-feedback/src/index.ts +++ b/packages/feedback/message-feedback/src/index.ts @@ -7,6 +7,7 @@ import { Buffer } from 'node:buffer' import { randomUUID } from 'node:crypto' import { Context, Service } from '@deepseek-ai/cordis' import s from '@deepseek-ai/schemastery' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' @@ -333,9 +334,15 @@ export class MessageFeedbackService extends TypertRemoteService { `message-feedback: no durability listener participated for live session '${inspection.meta.id}'`, ) } - return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0) + return await this.ctx.sessionPersistence.readFrom( + inspection.meta.id, + SessionLogOffset(0), + ) } - return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0) + return await this.ctx.sessionPersistence.readFrom( + inspection.meta.id, + SessionLogOffset(0), + ) } /** Validate optional-note semantics and the configured complete UTF-8 byte bound. */ diff --git a/packages/feedback/message-feedback/tests/helpers.ts b/packages/feedback/message-feedback/tests/helpers.ts index 748373856d..3ae1363506 100644 --- a/packages/feedback/message-feedback/tests/helpers.ts +++ b/packages/feedback/message-feedback/tests/helpers.ts @@ -8,11 +8,14 @@ import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId, + SessionLogOffset, type SessionEvent, type SessionHeader, + type SessionLogOffset as SessionLogOffsetType, } from '@deepseek-ai/dsh-session' import SessionPersistence, { SessionPersistenceRevision, + type SessionEventSuffix, type SessionInspection, type SessionLocation, type SessionPersistenceSnapshot, @@ -101,6 +104,7 @@ export function messageFixture( version: SESSION_FORMAT_VERSION, id, createdAt: options.createdAt ?? 1_700_000_000_000, + isSeeded: false, ...(options.cwd === undefined ? {} : { cwd: options.cwd }), } const session = Session.create(id, [], header) @@ -126,7 +130,7 @@ class TestPersistence extends SessionPersistence { append(_id: SessionId, _events: readonly SessionEvent[]): Promise { return Promise.resolve() } load(id: SessionId): Promise { - return this.readFrom(id, 0) + return this.readFrom(id, SessionLogOffset(0)) } inspect(id: SessionId): Promise { @@ -135,7 +139,13 @@ 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.snapshotEvents() }) + if (live !== undefined) { + return Promise.resolve({ + meta: live.header, + inheritedEventCount: live.inheritedEventCount, + events: live.snapshotEvents(), + }) + } const stored = this.durable.get(id) return stored === undefined ? Promise.reject(new Error(`test persistence: session '${id}' not found`)) @@ -148,14 +158,19 @@ class TestPersistence extends SessionPersistence { async readFrom( id: SessionId, - fromSeq: number, - ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + fromSeq: SessionLogOffsetType, + ): Promise { this.readFromCalls += 1 await this.onReadFrom?.() const stored = this.durable.get(id) return stored === undefined ? Promise.reject(new Error(`test persistence: session '${id}' not found`)) - : { meta: stored.meta, events: stored.events.filter(event => event.seq >= fromSeq) } + : { + meta: stored.meta, + inheritedEventCount: stored.inheritedEventCount, + fromSeq, + events: stored.events.filter(event => event.seq >= fromSeq), + } } list(): Promise { @@ -171,7 +186,11 @@ class TestPersistence extends SessionPersistence { } persist(session: Session): void { - this.durable.set(session.id, { meta: session.header, events: session.snapshotEvents() }) + this.durable.set(session.id, { + meta: session.header, + inheritedEventCount: session.inheritedEventCount, + events: session.snapshotEvents(), + }) } setDurable(inspection: SessionInspection): void { diff --git a/packages/feedback/message-feedback/tests/loader-composition.spec.ts b/packages/feedback/message-feedback/tests/loader-composition.spec.ts index b433532adc..a682dfd3d9 100644 --- a/packages/feedback/message-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/message-feedback/tests/loader-composition.spec.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Include from '@deepseek-ai/cordis-plugin-include' import Loader from '@deepseek-ai/cordis-plugin-loader' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import Storage from '@deepseek-ai/dsh-storage' import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' @@ -97,7 +97,7 @@ describe('message feedback through a real Loader composition', () => { ifVersion: null, }) if (!put.ok) throw new Error(`expected put success, got ${put.error.code}`) - const durable = await first.sessionPersistence.readFrom(session.id, 0) + const durable = await first.sessionPersistence.readFrom(session.id, SessionLogOffset(0)) expect(durable.events.some(event => event.type === 'assistant/message' && event.data.message.id === fixture.assistantMessageIds[0])).toBe(true) diff --git a/packages/feedback/message-feedback/tests/message-feedback.spec.ts b/packages/feedback/message-feedback/tests/message-feedback.spec.ts index 82c49a93b7..92b65ce2d4 100644 --- a/packages/feedback/message-feedback/tests/message-feedback.spec.ts +++ b/packages/feedback/message-feedback/tests/message-feedback.spec.ts @@ -62,7 +62,11 @@ describe('MessageFeedbackService public contract', () => { }) const fixture = messageFixture('corrupt-session') - persistence.setDurable({ meta: fixture.session.header, events: fixture.session.snapshotEvents() }) + persistence.setDurable({ + meta: fixture.session.header, + inheritedEventCount: fixture.session.inheritedEventCount, + 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) @@ -518,9 +522,14 @@ describe('MessageFeedbackService durability ordering', () => { const fixture = messageFixture('cold-prefix') persistence.logical.set(fixture.session.id, { meta: fixture.session.header, + inheritedEventCount: fixture.session.inheritedEventCount, events: fixture.session.snapshotEvents(), }) - persistence.setDurable({ meta: fixture.session.header, events: [] }) + persistence.setDurable({ + meta: fixture.session.header, + inheritedEventCount: fixture.session.inheritedEventCount, + events: [], + }) await expect(ctx.messageFeedback.put({ sessionId: fixture.session.id, diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index bb14264f6d..8f75a0b513 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -32,6 +32,7 @@ import { sessionCwd } from '../src/session-cwd.ts' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' const testToolSignal = new AbortController().signal @@ -805,19 +806,45 @@ 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 })), + function escalationAgent(records: Array<{ type: string; data?: Record }> = []): object { + const id = SessionId('sess-fs-esc') + const events: Array<{ + type: string + seq: ReturnType + time: number + data: Record + }> = [ + { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } }, + ...records.map((record, index) => ({ + type: record.type, + seq: SessionSeq(index + 1), + time: index + 1, + data: record.data ?? {}, + })), ] return { - id: 'agent-fs-esc', + id, session: { - header: { version: 0, id: 'sess-fs-esc', createdAt: 0, cwd: '/session-project' }, - get seq() { return log.length }, - eventAt: (seq: number) => log[seq], - snapshotEvents: () => log, - append: (type: string, data: Record) => { log.push({ type, data, seq: log.length }) }, + id, + header: { version: 0, id, createdAt: 0, cwd: '/session-project', isSeeded: false }, + inheritedEventCount: SessionLogOffset(0), + firstLiveSeq: SessionLogOffset(0), + get seq() { return SessionLogOffset(events.length) }, + eventAt: (seq: ReturnType) => events[seq], + snapshotEvents: ( + fromSeq = SessionLogOffset(0), + toSeqExclusive = SessionLogOffset(events.length), + ) => events.slice(fromSeq, toSeqExclusive), + append: (type: string, data: Record) => { + const event = { + type, + seq: SessionSeq(events.length), + time: events.length, + data, + } + events.push(event) + return event + }, }, } } @@ -858,13 +885,21 @@ describe('sandbox escalation API (write/edit)', () => { it('a plain write stamps the default mode with the calling session root', async () => { const { ctx, fs } = await setupConfining() await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent()) - expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: resolve('/session-project') }]) + expect(fs.stamped).toEqual([{ + mode: 'workspace-write', + workspaceRoot: resolve('/session-project'), + sessionId: SessionId('sess-fs-esc'), + }]) }) it('a standing session override folds onto the stamp', async () => { const { ctx, fs } = await setupConfining() await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }])) - expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: resolve('/session-project') }]) + expect(fs.stamped).toEqual([{ + mode: 'read-only', + workspaceRoot: resolve('/session-project'), + sessionId: SessionId('sess-fs-esc'), + }]) }) it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => { @@ -897,7 +932,11 @@ describe('sandbox escalation API (write/edit)', () => { agent: escalationAgent() as never, signal: new AbortController().signal, }) - expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: resolve('/session-project') }]) + expect(fs.stamped).toEqual([{ + mode: 'danger-full-access', + workspaceRoot: resolve('/session-project'), + sessionId: SessionId('sess-fs-esc'), + }]) }) it('a rejected escalation fails closed with its own text and never mutates', async () => { diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 5d1faafea3..0965db21d2 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -29,7 +29,7 @@ afterEach(async () => { function agent(ctx: Context, cwd: string): Agent { const id = SessionId(`str-replace-editor-owner-${callNumber}`) const scope = ctx.plugin(() => {}) - const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd }) + const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd, isSeeded: false }) const value: Agent = { id, options: {}, diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 238e0c4ae5..77a8a2495e 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -11,7 +11,8 @@ import { z as zod } from 'zod' import type { ZodType } from 'zod' import { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionLogOffset } from '@deepseek-ai/dsh-session' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' @@ -182,7 +183,10 @@ export interface ResolvedConfig { /** Process-local activation state crossing the synchronous append boundary. */ interface GoalRuntimeState { activation: GoalActivation - pendingActivation: { readonly seq: number; readonly activation: GoalActivation } | undefined + pendingActivation: { + readonly offset: SessionLogOffset + readonly activation: GoalActivation + } | undefined } /** Validated create input with every deployment default materialized. */ @@ -255,7 +259,8 @@ export class GoalService extends TypertRemoteService { ctx.on('session/event', (session, event) => { if (event.type !== 'goal/change') return const runtime = this.runtimeState(session) - runtime.activation = runtime.pendingActivation?.seq === event.seq + runtime.activation = runtime.pendingActivation !== undefined + && SessionSeq(runtime.pendingActivation.offset) === event.seq ? runtime.pendingActivation.activation : 'disarmed' }) @@ -579,11 +584,11 @@ export class GoalService extends TypertRemoteService { /** Commit one mutation into the goal log and live event stream. */ private commit(agent: Agent, runtime: GoalRuntimeState, change: GoalChangeMeta, activation: GoalActivation): void { const ref = goalChangeRef(change) - runtime.pendingActivation = { seq: agent.session.seq, activation } + runtime.pendingActivation = { offset: agent.session.seq, activation } try { const event = agent.session.append('goal/change', change) /* v8 ignore next -- Session.append returns the event committed at the pre-append seq. */ - if (runtime.pendingActivation.seq === event.seq) runtime.activation = activation + if (SessionSeq(runtime.pendingActivation.offset) === event.seq) runtime.activation = activation } finally { runtime.pendingActivation = undefined } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 9bc98bee74..d72ac7c751 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -199,7 +199,8 @@ describe('GoalService creation and replay', () => { activation: 'disarmed', }) expect(child.session.header.parentSession).toBe(parent.session.id) - expect(child.session.header.seedLength).toBe(parent.session.seq) + expect(child.session.header.isSeeded).toBe(true) + expect(child.session.inheritedEventCount).toBe(parent.session.seq) }) it('disarms live activation on every session-start edge', async () => { diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index 29a820c8df..34d3108e5d 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -4,7 +4,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { GoalView } from '@deepseek-ai/dsh-goal' import { HarnessError } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionSeq } from '@deepseek-ai/dsh-session' import type { ToolRunContext } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-session-projection' @@ -12,7 +12,7 @@ import type {} from '@deepseek-ai/dsh-session-projection' export interface GoalToolExecution { readonly agent: Agent readonly events: readonly SessionEvent[] - readonly openTurnStartSeq: number + readonly openTurnStartSeq: SessionSeq } /** Hard authority granted to one state-changing call. */ diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index ab544d1b9c..1839a9ecdf 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -8,7 +8,12 @@ import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' -import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import { + SESSION_FORMAT_VERSION, + Session, + SessionId, + SessionLogOffset, +} from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -267,8 +272,8 @@ describe('goal tool execution authority', () => { id: forkId, createdAt: Date.now(), parentSession: root.session.id, - seedLength: root.session.seq, - }) + isSeeded: true, + }, SessionLogOffset(root.session.seq)) const fork = stubAgent(forkId, forkSession) ctx.agents.register(fork.agent) expect(ctx.goals.get(fork.agent)).toMatchObject({ id: created.id, activation: 'disarmed' }) diff --git a/packages/hooks/hook-protocol/tests/invariant.spec.ts b/packages/hooks/hook-protocol/tests/invariant.spec.ts index bcfef7261e..449ce4c6ae 100644 --- a/packages/hooks/hook-protocol/tests/invariant.spec.ts +++ b/packages/hooks/hook-protocol/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import * as HookInvariant from '@deepseek-ai/dsh-hook-protocol/invariant' import InvariantRegistry from '@deepseek-ai/dsh-invariants' @@ -62,14 +62,14 @@ describe('hook-protocol invariants', () => { const session = Session.create(SessionId('bare-hook-session')) expect(() => { ctx.emit('session/event', session, { - type: 'turn/start', seq: 0, time: 0, + type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 }, }) ctx.emit('session/event', session, { - type: 'hook/invoked', seq: 1, time: 1, data: invoked(), + type: 'hook/invoked', seq: SessionSeq(1), time: 1, data: invoked(), }) ctx.emit('session/event', session, { - type: 'hook/result', seq: 2, time: 2, data: result(), + type: 'hook/result', seq: SessionSeq(2), time: 2, data: result(), }) }).not.toThrow() }) diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index b0b3a0dd45..b7cdeb0b4a 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -11,6 +11,7 @@ import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' import type { ImageBlock } from '@deepseek-ai/dsh-llm' import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' +import { SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import { CommandId } from './brand.ts' @@ -225,13 +226,15 @@ function normalizeResult(command: string, value: unknown): CommandResult { throw new TypeError(`command "${command}" success text must be a string when supplied`) } if (result.sourceEventSeq !== undefined - && (!Number.isSafeInteger(result.sourceEventSeq) || (result.sourceEventSeq as number) < 0)) { + && (!Number.isSafeInteger(result.sourceEventSeq) + || (result.sourceEventSeq as number) < 0 + || Object.is(result.sourceEventSeq, -0))) { throw new TypeError(`command "${command}" success sourceEventSeq must be a non-negative safe integer when supplied`) } return Object.freeze({ kind: 'success', ...result.text === undefined ? {} : { text: result.text }, - ...result.sourceEventSeq === undefined ? {} : { sourceEventSeq: result.sourceEventSeq as number }, + ...result.sourceEventSeq === undefined ? {} : { sourceEventSeq: SessionSeq(result.sourceEventSeq as number) }, }) } if (result.kind === 'error') { diff --git a/packages/interaction/commands/src/types.ts b/packages/interaction/commands/src/types.ts index 621a5971ba..0ca87c778d 100644 --- a/packages/interaction/commands/src/types.ts +++ b/packages/interaction/commands/src/types.ts @@ -7,6 +7,7 @@ * @module @deepseek-ai/dsh-commands/types */ +import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { CommandId } from './brand.ts' /** Immutable metadata for a command's optional unstructured input. */ @@ -29,7 +30,7 @@ export type CommandResult = readonly kind: 'success' readonly text?: string /** Earlier authoritative domain event that owns a richer presentation. */ - readonly sourceEventSeq?: number + readonly sourceEventSeq?: SessionSeq } | { readonly kind: 'error'; readonly text: string } @@ -104,7 +105,7 @@ declare module '@deepseek-ai/dsh-session/types' { commandId: CommandId kind: 'success' | 'error' text?: string - sourceEventSeq?: number + sourceEventSeq?: import('@deepseek-ai/dsh-session/types').SessionSeq } } } diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index 655cdc5462..01a31e205c 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -448,6 +448,7 @@ describe('CommandRuntime', () => { [{}, /CommandResult/], [{ kind: 'success', text: 1 }, /success text/], [{ kind: 'success', sourceEventSeq: -1 }, /sourceEventSeq/], + [{ kind: 'success', sourceEventSeq: -0 }, /sourceEventSeq/], [{ kind: 'success', sourceEventSeq: 1.5 }, /sourceEventSeq/], [{ kind: 'success', sourceEventSeq: '1' }, /sourceEventSeq/], [{ kind: 'error', text: '' }, /error text/], diff --git a/packages/interaction/commands/tests/invariant.spec.ts b/packages/interaction/commands/tests/invariant.spec.ts index 68124082b4..f6dbd9c997 100644 --- a/packages/interaction/commands/tests/invariant.spec.ts +++ b/packages/interaction/commands/tests/invariant.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import * as CommandInvariant from '@deepseek-ai/dsh-commands/invariant' import InvariantRegistry, { InvariantError } from '@deepseek-ai/dsh-invariants' -import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionSeq, type Session } from '@deepseek-ai/dsh-session' import { CommandId } from '@deepseek-ai/dsh-commands' async function mount(installCompanion = true): Promise<{ ctx: Context; session: Session }> { @@ -46,7 +46,7 @@ describe('command lifecycle invariants', () => { session.append('command/done', { commandId: CommandId('cmd-invalid'), kind: 'success', - sourceEventSeq, + sourceEventSeq: sourceEventSeq as never, }) }).toThrow(expect.objectContaining>({ code: 'INVARIANT', @@ -78,7 +78,7 @@ describe('command lifecycle invariants', () => { session.append('command/done', { commandId: CommandId('cmd-late'), kind: 'success', - sourceEventSeq: 0, + sourceEventSeq: SessionSeq(0), }) await expect(ctx.plugin(CommandInvariant)).rejects.toMatchObject({ diff --git a/packages/interaction/permission-presets/tests/invariant.spec.ts b/packages/interaction/permission-presets/tests/invariant.spec.ts index fd5c8aa9e9..2d821482f3 100644 --- a/packages/interaction/permission-presets/tests/invariant.spec.ts +++ b/packages/interaction/permission-presets/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context, Service } from '@deepseek-ai/cordis' -import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionSeq, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import * as PermissionInvariant from '@deepseek-ai/dsh-permission-presets/invariant' import InvariantRegistry from '@deepseek-ai/dsh-invariants' @@ -22,7 +22,7 @@ async function setup(): Promise { } function presetEvent(preset: string): SessionEvent { - return { type: 'permission/preset', seq: 0, time: 0, data: { preset } } + return { type: 'permission/preset', seq: SessionSeq(0), time: 0, data: { preset } } } describe('permission invariants', () => { @@ -30,7 +30,7 @@ describe('permission invariants', () => { const ctx = await setup() expect(() => { ctx.emit('session/event', {} as Session, presetEvent('safe')) }).not.toThrow() expect(() => { ctx.emit('session/event', {} as Session, { - type: 'turn/end', seq: 0, time: 0, data: {}, + type: 'turn/end', seq: SessionSeq(0), time: 0, data: {}, } as SessionEvent) }).not.toThrow() expect(() => { ctx.emit('tools/change') }).not.toThrow() }) diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts index c59295d6e4..8e1a8d05d4 100644 --- a/packages/interaction/user-approval/src/index.ts +++ b/packages/interaction/user-approval/src/index.ts @@ -11,6 +11,7 @@ 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 } from '@deepseek-ai/dsh-session' +import { SessionSeq } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/cordis' { @@ -75,7 +76,7 @@ const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may */ function hasOpenTurn(session: Session): boolean { for (let seq = session.seq - 1; seq >= 0; seq -= 1) { - const type = session.eventAt(seq)?.type + const type = session.eventAt(SessionSeq(seq))?.type if (type === 'turn/start') return true if (type === 'turn/end') return false } @@ -242,7 +243,7 @@ export class ApprovalService extends Service { */ overrideOf(session: Session): ApprovalPolicy | undefined { for (let seq = session.seq - 1; seq >= 0; seq -= 1) { - const event = session.eventAt(seq) + const event = session.eventAt(SessionSeq(seq)) if (event?.type === 'approval/policy') return event.data.policy } return undefined diff --git a/packages/interaction/user-approval/tests/invariant.spec.ts b/packages/interaction/user-approval/tests/invariant.spec.ts index 0fe77440e3..d5c845cc21 100644 --- a/packages/interaction/user-approval/tests/invariant.spec.ts +++ b/packages/interaction/user-approval/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' import * as ApprovalInvariant from '@deepseek-ai/dsh-user-approval/invariant' import InvariantRegistry from '@deepseek-ai/dsh-invariants' @@ -46,14 +46,14 @@ describe('approval invariants', () => { const session = Session.create(SessionId('bare-approval-session')) const id = ApprovalRequestId('bare-ask') const asked = { - type: 'approval/asked', seq: 0, time: 0, data: { id, toolName: 'bash' }, + type: 'approval/asked', seq: SessionSeq(0), time: 0, data: { id, toolName: 'bash' }, } as const const decided = { - type: 'approval/decided', seq: 1, time: 1, data: { id, outcome: 'rejected' as const }, + type: 'approval/decided', seq: SessionSeq(1), time: 1, data: { id, outcome: 'rejected' as const }, } as const expect(() => { ctx.emit('session/event', session, { - type: 'turn/start', seq: 0, time: 0, + type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 }, }) ctx.emit('session/event', session, asked) diff --git a/packages/llm/token-meter/src/breakdown-projection.ts b/packages/llm/token-meter/src/breakdown-projection.ts index ab67c0600c..bfd6f20aae 100644 --- a/packages/llm/token-meter/src/breakdown-projection.ts +++ b/packages/llm/token-meter/src/breakdown-projection.ts @@ -6,7 +6,7 @@ */ import { z } from 'zod' -import { canonicalHeader } from '@deepseek-ai/dsh-session' +import { canonicalHeader, SessionSeq } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import { estimateSystemTokens, estimateToolsTokens } from './estimate.ts' import { foldSurfaceProjection } from './surface-projection.ts' @@ -21,6 +21,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { /** Non-negative integer token count (the shared figure shape). */ const tokenCount = z.number().int().nonnegative() +const sessionSeq = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionSeq) /** The context-breakdown state schema and source of its inferred type. */ const contextBreakdownStateSchema = z.object({ @@ -28,8 +29,8 @@ const contextBreakdownStateSchema = z.object({ toolsTokens: tokenCount, messageTokens: tokenCount, claim: z.object({ - start: tokenCount, - end: tokenCount, + start: sessionSeq, + end: sessionSeq, tokens: tokenCount, }).optional(), }).strict() diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index d97d9d3fc2..5e045223e3 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -9,8 +9,14 @@ import z from '@deepseek-ai/schemastery' import { BlockAssembler } from '@deepseek-ai/dsh-llm' import type { LlmImageRequestPricing, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import { deepFreeze } from '@deepseek-ai/dsh-util-values' -import type { EpochHeader, Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session' +import type { + EpochHeader, + Session, + SessionEvent, + SessionLogOffset as SessionLogOffsetType, + SessionSeq as SessionSeqType, +} from '@deepseek-ai/dsh-session' +import { canonicalHeader, headerEquals, isSurfaceEvent, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' // Type-only: activates the `ctx.sessionProjections` Context declaration. import type {} from '@deepseek-ai/dsh-session-projection' import type { @@ -48,7 +54,7 @@ interface MeasurementAnchor { } interface ReplayState { - consumedEvents: number + consumedEvents: SessionLogOffsetType header: EpochHeader | undefined surface: MeterSurfaceNode[] stepStart: { turn: number; step: number; nodes: readonly MeterSurfaceNode[] } | undefined @@ -198,7 +204,7 @@ export class TokenMeter extends Service { let state = this.states.get(session) if (state === undefined) { state = { - consumedEvents: 0, + consumedEvents: SessionLogOffset(0), header: undefined, surface: [], stepStart: undefined, @@ -209,9 +215,9 @@ export class TokenMeter extends Service { while (state.consumedEvents < session.seq) { // oxlint-disable-next-line typescript/no-non-null-assertion -- contiguous session seqs index the durable log - const event = session.eventAt(state.consumedEvents)! + const event = session.eventAt(SessionSeq(state.consumedEvents))! this._foldEvent(session, state, event) - state.consumedEvents += 1 + state.consumedEvents = SessionLogOffset(state.consumedEvents + 1) } return state } @@ -304,7 +310,7 @@ export class TokenMeter extends Service { if (sourceSeqs === undefined) return durableEventTokens const assembler = new BlockAssembler() - const seen = new Set() + const seen = new Set() for (const seq of sourceSeqs) { if (seq >= event.seq) { throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} is not earlier`) diff --git a/packages/llm/token-meter/src/surface-fold.ts b/packages/llm/token-meter/src/surface-fold.ts index 66824a2974..ae293a7f57 100644 --- a/packages/llm/token-meter/src/surface-fold.ts +++ b/packages/llm/token-meter/src/surface-fold.ts @@ -17,7 +17,7 @@ */ import { deriveEventMessage } from '@deepseek-ai/dsh-session' -import type { SurfaceEvent } from '@deepseek-ai/dsh-session' +import type { SessionSeq, SurfaceEvent } from '@deepseek-ai/dsh-session' import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { estimateMessage, estimateStructuralBlock } from './estimate.ts' @@ -25,7 +25,7 @@ import { estimateMessage, estimateStructuralBlock } from './estimate.ts' /** One priced surface node with the image occurrences route pricing replaces. */ export interface MeterSurfaceNode { /** Durable sequence number of the surface event. */ - readonly seq: number + readonly seq: SessionSeq /** Fixed-heuristic price of the node's exact message. */ readonly heuristicTokens: number /** Fixed-heuristic price with every image occurrence's structural price removed. */ @@ -61,7 +61,7 @@ function collectImages(blocks: readonly ContentBlock[], images: ImageAttachmentR } /** Build one priced node from a surface event's derived message. */ -function analyzeNode(seq: number, message: Message | null): MeterSurfaceNode { +function analyzeNode(seq: SessionSeq, message: Message | null): MeterSurfaceNode { if (message === null) return { seq, heuristicTokens: 0, imageFreeTokens: 0, images: [] } const heuristicTokens = estimateMessage(message) const images: ImageAttachmentRef[] = [] diff --git a/packages/llm/token-meter/src/surface-projection.ts b/packages/llm/token-meter/src/surface-projection.ts index 0dcc232a8c..84d5759376 100644 --- a/packages/llm/token-meter/src/surface-projection.ts +++ b/packages/llm/token-meter/src/surface-projection.ts @@ -17,7 +17,7 @@ * @module @deepseek-ai/dsh-token-meter/surface-projection */ -import { deriveEventMessage, isSurfaceEvent } from '@deepseek-ai/dsh-session' +import { deriveEventMessage, isSurfaceEvent, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' // Type-only: the `compaction/*` SessionEventMap merges (shadow-price events). import type {} from '@deepseek-ai/dsh-compaction' @@ -30,9 +30,9 @@ import { estimateMessage } from './estimate.ts' */ export interface ShadowPriceClaim { /** Declared inclusive first surface-node seq of the priced range. */ - start: number + start: SessionSeq /** Declared inclusive last surface-node seq of the priced range. */ - end: number + end: SessionSeq /** Heuristic tokens of the priced range under the fixed estimator. */ tokens: number } @@ -71,7 +71,11 @@ export function foldSurfaceProjection( const { shadowedRange, shadowedTokenCount } = event.data return { deltaTokens: 0, - claim: { start: shadowedRange.start, end: shadowedRange.end, tokens: shadowedTokenCount }, + claim: { + start: SessionSeq(shadowedRange.start), + end: SessionSeq(shadowedRange.end), + tokens: shadowedTokenCount, + }, } } if (!isSurfaceEvent(event)) return { deltaTokens: 0, claim: undefined } diff --git a/packages/llm/token-meter/src/types.ts b/packages/llm/token-meter/src/types.ts index e6ccc8b071..487e031385 100644 --- a/packages/llm/token-meter/src/types.ts +++ b/packages/llm/token-meter/src/types.ts @@ -5,6 +5,7 @@ */ import type { TokenUsage } from '@deepseek-ai/dsh-llm' +import type { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session/types' export type { ContextBreakdownProjection, ContextPressureProjection, TokenUsageProjection } from './projection.ts' @@ -20,7 +21,7 @@ export type TokenMeasurementBaseline = /** Detached immutable request-pressure and surface snapshot at one consumed log revision. */ export interface TokenMeasurement { /** Number of durable events consumed; equal to the next unread event seq. */ - readonly logRevision: number + readonly logRevision: SessionLogOffset /** Provider or heuristic anchor used for this measurement. */ readonly baseline: TokenMeasurementBaseline /** Signed repricing of current surface content relative to the baseline anchor. */ @@ -36,7 +37,7 @@ export interface TokenMeasurement { /** One token-priced node in the current ordered session surface. */ export interface TokenSurfaceNode { /** Durable sequence number of the surface event. */ - readonly seq: number + readonly seq: SessionSeq /** * Request-pressure tokens for the exact message projected by this node under * the measured route: image occurrences carry the route's declared visual diff --git a/packages/llm/token-meter/src/usage-projection.ts b/packages/llm/token-meter/src/usage-projection.ts index 50f336c61c..b98b48c3e1 100644 --- a/packages/llm/token-meter/src/usage-projection.ts +++ b/packages/llm/token-meter/src/usage-projection.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import type { TokenUsage } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry/types' +import { SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { ContextPressureProjection, TokenUsageProjection } from './projection.ts' @@ -99,8 +100,8 @@ const contextPressureStateSchema = z.object({ surfaceTokens: z.number().int().nonnegative(), sampledSurfaceTokens: z.number().int().nonnegative().optional(), claim: z.object({ - start: z.number().int().nonnegative(), - end: z.number().int().nonnegative(), + start: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionSeq), + end: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionSeq), tokens: z.number().int().nonnegative(), }).optional(), }).strict() diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index 6ab566d085..3f36926f57 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -5,8 +5,8 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionSeq as SessionSeqType } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client' @@ -42,7 +42,7 @@ const projected = (ctx: Context, session: Session): ContextBreakdownProjection = return value } -function appendUser(session: Session, text: string): number { +function appendUser(session: Session, text: string): SessionSeqType { return session.append('user/message', createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' }, @@ -54,7 +54,7 @@ function appendUser(session: Session, text: string): number { * replaced span from the measurement service's own nodes and log the * shadow-price event directly before the replace. */ -function appendSummaryMeter(ctx: Context, session: Session, start: number, end: number): void { +function appendSummaryMeter(ctx: Context, session: Session, start: SessionSeqType, end: SessionSeqType): void { const nodes = ctx.tokenMeter.measure(session).nodes const startIdx = nodes.findIndex(node => node.seq === start) const endIdx = nodes.findIndex(node => node.seq === end) @@ -184,44 +184,51 @@ describe('contextBreakdown session projection', () => { it('folds a replacement without a claim at zero and fails on a mismatched claim', () => { const definition = contextBreakdownProjectionDefinition - const replace = (start: number, end: number): SessionEvent => ({ + const replace = (start: SessionSeq, end: SessionSeq): SessionEvent => ({ type: 'user/message', - seq: 9, + seq: SessionSeq(9), time: 0, data: createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }), surfaceOp: { op: 'replace', start, end }, sourceEventSeqs: [start, end], } as unknown as SessionEvent) - const append = (seq: number): SessionEvent => ({ + const append = (seq: SessionSeq): SessionEvent => ({ type: 'user/message', seq, time: 0, data: createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }), surfaceOp: 'append', } as unknown as SessionEvent) - const meter = (start: number, end: number, seq: number): SessionEvent => ({ + const meter = (start: SessionSeq, end: SessionSeq, seq: SessionSeq): SessionEvent => ({ type: 'compaction/prune', seq, time: 0, - data: { shadowedRange: { start, end }, shadowedSeqs: [start, end], shadowedTokenCount: 5 }, + data: { + shadowedRange: { start, end }, + shadowedSeqs: [start, end], + shadowedTokenCount: 5, + }, } as unknown as SessionEvent) let state = definition.init() - state = definition.apply(state, append(1)) - state = definition.apply(state, append(3)) + state = definition.apply(state, append(SessionSeq(1))) + state = definition.apply(state, append(SessionSeq(3))) // No metering event: the replacement contributes zero instead of throwing. - expect(definition.wire.view(definition.apply(state, replace(1, 3))).messageTokens) + expect(definition.wire.view(definition.apply(state, replace(SessionSeq(1), SessionSeq(3)))).messageTokens) .toBe(definition.wire.view(state).messageTokens) // An adjacent claim for another range contradicts the replacement. - const mismatched = definition.apply(state, meter(1, 1, 8)) - expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price') + const mismatched = definition.apply(state, meter(SessionSeq(1), SessionSeq(1), SessionSeq(8))) + expect(() => definition.apply(mismatched, replace(SessionSeq(1), SessionSeq(3)))) + .toThrow('no adjacent shadow price') // A claim expires after one intervening event, so replacement delta is zero. - let expired = definition.apply(state, meter(1, 3, 8)) - expired = definition.apply(expired, { type: 'session/end-seed', seq: 9, time: 0, data: {} }) - expect(definition.wire.view(definition.apply(expired, replace(1, 3))).messageTokens) + let expired = definition.apply(state, meter(SessionSeq(1), SessionSeq(3), SessionSeq(8))) + expired = definition.apply(expired, { + type: 'session/end-seed', seq: SessionSeq(9), time: 0, data: {}, + }) + expect(definition.wire.view(definition.apply(expired, replace(SessionSeq(1), SessionSeq(3)))).messageTokens) .toBe(definition.wire.view(state).messageTokens) // The armed claim prices exactly the next event's matching replacement. - const armed = definition.apply(state, meter(1, 3, 8)) - expect(definition.wire.view(definition.apply(armed, replace(1, 3))).messageTokens) + const armed = definition.apply(state, meter(SessionSeq(1), SessionSeq(3), SessionSeq(8))) + expect(definition.wire.view(definition.apply(armed, replace(SessionSeq(1), SessionSeq(3)))).messageTokens) .toBe(definition.wire.view(state).messageTokens - 5 + estimateMessage( createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }), )) diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 198a660992..615a12c9dd 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -2,8 +2,8 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createUserMessage, ToolCallId, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session' -import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, SessionSeq, canonicalHeader } from '@deepseek-ai/dsh-session' +import type { EpochHeader, SessionEvent, SessionSeq as SessionSeqType } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import type { TokenMeasurement, TokenMeterConfig } from '@deepseek-ai/dsh-token-meter' @@ -54,7 +54,7 @@ function appendSuccessfulCall( session.append('step/start', { turn, step }) appendHeader(session, value) - const sources: number[] = [] + const sources: SessionSeqType[] = [] if (provenance === 'exact') { const chunks = [ { type: 'block-start' as const, index: 0, blockType: 'text' as const }, @@ -185,8 +185,8 @@ describe('TokenMeter pricing', () => { expect(Object.isFrozen(snapshot.nodes[0])).toBe(true) expectSurfaceTotal(snapshot) expect(() => { - ;(snapshot.nodes as Array<{ seq: number; tokens: number; heuristicTokens: number }>) - .push({ seq: 99, tokens: 1, heuristicTokens: 1 }) + ;(snapshot.nodes as Array<{ seq: SessionSeqType; tokens: number; heuristicTokens: number }>) + .push({ seq: SessionSeq(99), tokens: 1, heuristicTokens: 1 }) }).toThrow(TypeError) expect(() => { ;(snapshot.nodes[0] as { seq: number; tokens: number }).tokens = 1 @@ -539,7 +539,7 @@ describe('malformed replay and listener lifecycle', () => { it('rejects invalid assistant source-event references', () => { const cases: Array<{ name: string - appendSource(session: Session): number[] + appendSource(session: Session): SessionSeqType[] pattern: RegExp }> = [ { @@ -597,7 +597,7 @@ describe('malformed replay and listener lifecycle', () => { }).seq appendUnchecked(duplicate, { type: 'assistant/message', - seq: duplicate.seq, + seq: SessionSeq(duplicate.seq), time: 0, data: { turn: 1, @@ -622,7 +622,7 @@ describe('malformed replay and listener lifecycle', () => { appendHeader(future, header('deepseek-v4-flash')) appendUnchecked(future, { type: 'assistant/message', - seq: future.seq, + seq: SessionSeq(future.seq), time: 0, data: { turn: 1, @@ -638,7 +638,7 @@ describe('malformed replay and listener lifecycle', () => { usage: { inputTokens: 1, outputTokens: 0 }, }, surfaceOp: 'append', - sourceEventSeqs: [99], + sourceEventSeqs: [SessionSeq(99)], }) expect(() => meter().measure(future)).toThrow(/is not earlier/) }) @@ -678,13 +678,13 @@ describe('malformed replay and listener lifecycle', () => { }), { surfaceOp: 'append' }).seq appendUnchecked(session, { type: 'user/message', - seq: session.seq, + seq: SessionSeq(session.seq), time: 0, data: createUserMessage({ content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, }), - surfaceOp: { op: 'replace', start: 99, end: 99 }, + surfaceOp: { op: 'replace', start: SessionSeq(99), end: SessionSeq(99) }, sourceEventSeqs: [head], }) expectRepeatedFailure(meter(), session, /invalid current range/) @@ -703,7 +703,7 @@ describe('malformed replay and listener lifecycle', () => { activeMeter = ctx.tokenMeter const session = ctx.sessions.create(SessionId('listener-order'), { seed: [{ type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: 1, data: { turn: 1 }, }] }) diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index f9ce9015e3..3ffee99021 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { TokenUsage } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' +import type { Session, SessionSeq } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client' @@ -38,7 +38,7 @@ function usageChunk( usage: TokenUsage, turn: number, step: number, -): number { +): SessionSeq { return session.append('assistant/chunk', { turn, step, @@ -51,7 +51,7 @@ function finalUsage( usage: TokenUsage, turn: number, step: number, - sourceSeqs: number[], + sourceSeqs: SessionSeq[], ): void { session.append('assistant/message', { turn, @@ -77,7 +77,7 @@ const projected = (ctx: Context, session: Session): TokenUsageProjection => { * replaced span from the measurement service's own nodes and log the * shadow-price event directly before the replace. */ -function appendSummaryMeter(ctx: Context, session: Session, start: number, end: number): void { +function appendSummaryMeter(ctx: Context, session: Session, start: SessionSeq, end: SessionSeq): void { const nodes = ctx.tokenMeter.measure(session).nodes const startIdx = nodes.findIndex(node => node.seq === start) const endIdx = nodes.findIndex(node => node.seq === end) @@ -319,7 +319,7 @@ function recordContext(session: Session, model: string, contextWindow?: number): } /** Append one model-visible user turn and return its surface seq. */ -function appendUser(session: Session, text: string): number { +function appendUser(session: Session, text: string): SessionSeq { return session.append('user/message', createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' }, @@ -333,7 +333,7 @@ function appendAssistant( usage: TokenUsage, turn: number, step: number, -): number { +): SessionSeq { return session.append('assistant/message', { turn, step, diff --git a/packages/plan/plan-mode/tests/invariant.spec.ts b/packages/plan/plan-mode/tests/invariant.spec.ts index 9f7ed2233f..d44cadc67a 100644 --- a/packages/plan/plan-mode/tests/invariant.spec.ts +++ b/packages/plan/plan-mode/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session' import * as PlanModeInvariant from '@deepseek-ai/dsh-plan-mode/invariant' import InvariantRegistry from '@deepseek-ai/dsh-invariants' @@ -13,12 +13,12 @@ async function setup(): Promise { } function event(active: unknown): SessionEvent { - return { type: 'plan/mode', seq: 0, time: 0, data: { active } } as SessionEvent + return { type: 'plan/mode', seq: SessionSeq(0), time: 0, data: { active } } as SessionEvent } function emitTurnStart(ctx: Context, session: Session): void { ctx.emit('session/event', session, { - type: 'turn/start', seq: 0, time: 0, + type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 }, }) } @@ -31,7 +31,7 @@ describe('plan-mode stream invariants', () => { expect(() => { ctx.emit('session/event', session, event(true)) }).not.toThrow() expect(() => { ctx.emit('session/event', session, event(false)) }).not.toThrow() ctx.emit('session/event', session, { - type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } }, + type: 'turn/end', seq: SessionSeq(3), time: 3, data: { turn: 1, reason: { kind: 'completed' } }, }) }) @@ -55,7 +55,7 @@ describe('plan-mode stream invariants', () => { expect(() => { ctx.emit('tools/change') ctx.emit('session/event', session, { - type: 'turn/start', seq: 0, time: 0, data: { turn: 1 }, + type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 }, }) }).not.toThrow() }) diff --git a/packages/preset/agent-presets/tests/session.spec.ts b/packages/preset/agent-presets/tests/session.spec.ts index 6b6da3d540..255fe0cfa8 100644 --- a/packages/preset/agent-presets/tests/session.spec.ts +++ b/packages/preset/agent-presets/tests/session.spec.ts @@ -1,7 +1,7 @@ /** The Session projection that records which preset a Session runs. */ import { describe, expect, it } from 'vitest' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { agentPresetProjectionDefinition } from '../src/session.ts' @@ -11,13 +11,14 @@ function header(agentPreset?: string): SessionHeader { version: 0, id: SessionId('s'), createdAt: 1, + isSeeded: false, delegationDepth: 0, ...agentPreset === undefined ? {} : { agentPreset }, } } /** One logged selection, as `agentPreset.select` appends it. */ -function selected(agentPreset: string, seq: number): SessionEvent { +function selected(agentPreset: string, seq: SessionSeq): SessionEvent { return { type: 'agent-preset/selected', seq, time: seq, data: { agentPreset } } } @@ -32,11 +33,11 @@ describe('agent preset selection projection', () => { let state = definition.init(header('standard')) expect(state).toBe('standard') - state = definition.apply(state, selected('minimal', 0)) + state = definition.apply(state, selected('minimal', SessionSeq(0))) state = definition.apply(state, { - type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, + type: 'turn/end', seq: SessionSeq(1), time: 1, data: { turn: 1, reason: { kind: 'completed' } }, }) - state = definition.apply(state, selected('cordis', 2)) + state = definition.apply(state, selected('cordis', SessionSeq(2))) expect(definition.wire.view(state)).toBe('cordis') expect(definition.stateSchema.parse(state)).toBe('cordis') diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index a680373163..7ba166354b 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -28,6 +28,7 @@ function session(id: string, cwd?: string): Session { version: 0, id: sessionId, createdAt: 0, + isSeeded: false, ...cwd === undefined ? {} : { cwd }, }) } diff --git a/packages/schedule/AGENTS.md b/packages/schedule/AGENTS.md index 23561f9705..aea5557cd6 100644 --- a/packages/schedule/AGENTS.md +++ b/packages/schedule/AGENTS.md @@ -3,7 +3,7 @@ These rules supplement the repository and package instructions for `packages/schedule/*`. - The owning Session's versioned `schedule/change` stream is the only durable Schedule state. Folds validate every durable JSON boundary and derive active records; timers, idle waiters, and tool values remain disposable projections. -- A normal Session folds its complete log. A fork derives active Schedule state only from events at or after `SessionHeader.seedLength`; it never inherits an active parent reminder. +- A normal Session folds its complete log. A fork derives active Schedule state only from events at or after the Session's exact `inheritedEventCount`; it never inherits an active parent reminder. - Every Schedule management operation that reads or decides from the fold first awaits `ctx.sessions.flush(session)`. Create and an actual delete await a second barrier after append; a failed barrier returns the stable uncertainty result instead of inferring durability from the live log. - Runtime owners attach only to future live root Agents while the plugin is loaded. They do not scan persisted Sessions, adopt already-published roots, wake cold Sessions, register global tools, or delete durable records during teardown. - Due handling rechecks the wall clock and exact live owner, claims the idle maintenance phase through the public Agent seam, constructs the complete escaped framing before `followup()`, appends dispatch only after synchronous enqueue returns, releases maintenance, and then awaits durability. A synchronous framing/enqueue failure appends no dispatch; a later model failure does not roll one back. diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 406b4dceec..2722f1e2a3 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: 69ee726bb25aaa8c2da0c53471089d2a255998c9 -README.zh.md: 60d57055ca4ab5e9fa18ae1996d31ccd3e704495 +README.md: 2b4c000e8bcbbe5fd8b9379db602c358dc5c192e +README.zh.md: 8ab9e847aacda95620820a8a67a9760285a01b1c diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index 69ee726bb2..2b4c000e8b 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -98,11 +98,11 @@ 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.snapshotEvents(session.header.seedLength ?? 0)`, so a child never inherits its parent's reminders. The Schedule projection derives that boundary from the immutable `SessionHeader` passed to `init(header)` and applies the same transition function to the same owned suffix. 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.ownEvents()`, so a child never inherits its parent's reminders. The Schedule projection receives the Session's exact `inheritedEventCount` from the projection registry and applies the same transition function after that cut. 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. ### Client projection -The optional `schedule` projection checkpoints `{ seedLength, active, seenIds }` as strict plain JSON and publishes only the complete `active` array. Its schema reuses the durable Schedule decoder, rejects duplicate or inconsistent ids, and propagates corrupt durable events through the existing Session read failure instead of publishing a partial catalog. Live lazy build, event-driven build, cold restore, history reads, and detached Subagent reads all use the immutable Session header and the same owned-suffix transition. +The optional `schedule` projection checkpoints `{ inheritedEventCount, active, seenIds }` as strict plain JSON and publishes only the complete `active` array. Its schema reuses the durable Schedule decoder, rejects duplicate or inconsistent ids, and propagates corrupt durable events through the existing Session read failure instead of publishing a partial catalog. Live lazy build, event-driven build, cold restore, history reads, and detached Subagent reads all use the exact Session cut and the same owned-suffix transition. The projection carries durable records only. It does not persist or transmit scheduled-versus-overdue status, localized text, relative time, browser-local time, sorting state, popover state, runtime liveness, or delivery receipts. [`dsh-client-ui-schedule`](../../client/ui-schedule/README.md) derives catalog presentation from the complete array and the viewing browser's clock. [`dsh-client-ui-workspace`](../../client/ui-workspace/README.md) derives only whether the list value is a non-empty array, so ordinary and search rows may briefly omit or retain the alarm when the durable projection cache is missing or stale. diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index 60d57055ca..8ab9e847aa 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -98,11 +98,11 @@ Session projection 是可选能力。`ctx.sessionProjections` 存在时,插件 ### 持久状态与回放 -普通会话折叠完整事件流。fork 只折叠 `session.snapshotEvents(session.header.seedLength ?? 0)`,因此子会话永远不会继承父会话的提醒。Schedule projection 从传给 `init(header)` 的不可变 `SessionHeader` 派生该边界,并对同一自有后缀应用同一个 transition 函数。每条 create 记录都携带稳定的会话本地 `ScheduleId`、已 trim 的提示词与四位年份 RFC 3339 UTC `scheduledAt`;`after` 记录还存储 `afterSeconds`,`at` 记录不保留所提交的偏移量或本地字段,`every` 记录存储 `everySeconds`,并把 `scheduledAt` 视为尚未 dispatch 的最早创建锚点对齐发生时点。delete 与一次性 dispatch 只携带 id;`every` dispatch 会附加 `acceptedAt`,回放直接推进到该决策时点之后的第一个锚点对齐目标。 +普通会话折叠完整事件流。fork 只折叠 `session.ownEvents()`,因此子会话永远不会继承父会话的提醒。Schedule projection 从投影注册表接收 Session 的精确 `inheritedEventCount`,并在该切点之后应用同一个 transition 函数。每条 create 记录都携带稳定的会话本地 `ScheduleId`、已 trim 的提示词与四位年份 RFC 3339 UTC `scheduledAt`;`after` 记录还存储 `afterSeconds`,`at` 记录不保留所提交的偏移量或本地字段,`every` 记录存储 `everySeconds`,并把 `scheduledAt` 视为尚未 dispatch 的最早创建锚点对齐发生时点。delete 与一次性 dispatch 只携带 id;`every` dispatch 会附加 `acceptedAt`,回放直接推进到该决策时点之后的第一个锚点对齐目标。 ### 客户端 projection -可选的 `schedule` projection 将 `{ seedLength, active, seenIds }` 作为严格的纯 JSON 检查点,并且只发布完整的 `active` 数组。其 schema 复用持久 Schedule decoder,拒绝重复或不一致的 id,并让损坏的持久事件通过既有 Session 读取失败传播,而不是发布部分目录。live 惰性构建、事件驱动构建、cold restore、history 读取与 detached Subagent 读取都使用不可变 Session header 与同一套自有后缀 transition。 +可选的 `schedule` projection 将 `{ inheritedEventCount, active, seenIds }` 作为严格的纯 JSON 检查点,并且只发布完整的 `active` 数组。其 schema 复用持久 Schedule decoder,拒绝重复或不一致的 id,并让损坏的持久事件通过既有 Session 读取失败传播,而不是发布部分目录。live 惰性构建、事件驱动构建、cold restore、history 读取与 detached Subagent 读取都使用精确 Session 切点与同一套自有后缀 transition。 projection 只携带持久记录。它不持久化或传输 scheduled/overdue 状态、本地化文本、相对时间、浏览器本地时间、排序状态、popover 状态、runtime 存活或交付回执。[`dsh-client-ui-schedule`](../../client/ui-schedule/README.zh.md) 从完整数组与查看方浏览器时钟派生目录呈现。[`dsh-client-ui-workspace`](../../client/ui-workspace/README.zh.md) 只派生列表值是否为非空数组,因此持久 projection cache 缺失或陈旧时,普通行与搜索结果的闹钟可能短暂漏显或残留。 diff --git a/packages/schedule/schedule/src/domain.ts b/packages/schedule/schedule/src/domain.ts index e4ffd8ef7f..a23340d446 100644 --- a/packages/schedule/schedule/src/domain.ts +++ b/packages/schedule/schedule/src/domain.ts @@ -3,7 +3,8 @@ * @module @deepseek-ai/dsh-schedule */ -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session' import type { AfterScheduleRecord, AtInput, @@ -622,22 +623,24 @@ export function applyScheduleChanges( /** * Fold the package-owned stream after the durable fork seed boundary. * @param events - Complete ordered session log or candidate-extended log. - * @param seedLength - Inherited prefix length excluded from child ownership. + * @param inheritedEventCount - Inherited prefix length excluded from child ownership. * @returns Active records and all previously used ids. */ export function foldScheduleEvents( events: readonly SessionEvent[], - seedLength = 0, + inheritedEventCount: SessionLogOffsetType = SessionLogOffset(0), ): FoldedSchedules { - if (!Number.isSafeInteger(seedLength) || seedLength < 0 || seedLength > events.length) { - throw new ScheduleLogError('schedule seedLength must be within the supplied event log') + if (!Number.isSafeInteger(inheritedEventCount) + || inheritedEventCount < 0 + || inheritedEventCount > events.length) { + throw new ScheduleLogError('schedule inheritedEventCount must be within the supplied event log') } const initial: FoldedSchedules = Object.freeze({ active: Object.freeze([]), seenIds: Object.freeze([]), }) const changes = function* (): Generator { - for (const event of events.slice(seedLength)) { + for (const event of events.slice(inheritedEventCount)) { if (event.type === 'schedule/change') yield decodeScheduleChange(event.data) } } diff --git a/packages/schedule/schedule/src/invariant.ts b/packages/schedule/schedule/src/invariant.ts index 5af5245ade..248e7df710 100644 --- a/packages/schedule/schedule/src/invariant.ts +++ b/packages/schedule/schedule/src/invariant.ts @@ -16,9 +16,9 @@ export const name = 'tool-schedule-invariant' export const inject = ['invariants'] /** Validate a complete exact-session stream under its fork suffix policy. */ -function validate(events: readonly SessionEvent[], seedLength: number, fail: InvariantFailure): void { +function validate(events: readonly SessionEvent[], fail: InvariantFailure): void { try { - foldScheduleEvents(events, seedLength) + foldScheduleEvents(events) } catch (error: unknown) { /* v8 ignore next -- foldScheduleEvents normalizes every rejected stream to ScheduleLogError. */ if (!(error instanceof ScheduleLogError)) throw error @@ -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.snapshotEvents(), session.header.seedLength ?? 0, fail) + validate(session.ownEvents(), fail) } ctx.on('session/created', (session) => { - validate(session.snapshotEvents(), session.header.seedLength ?? 0, fail) + validate(session.ownEvents(), 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.snapshotEvents(), event], session.header.seedLength ?? 0, fail) + validate([...session.ownEvents(), event], fail) }, { global: true }) }, { inject: ['sessions'] }) /* jscpd:ignore-end */ diff --git a/packages/schedule/schedule/src/projection.ts b/packages/schedule/schedule/src/projection.ts index 5703e50609..67116dda21 100644 --- a/packages/schedule/schedule/src/projection.ts +++ b/packages/schedule/schedule/src/projection.ts @@ -4,14 +4,16 @@ */ import { z } from 'zod' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import { applyScheduleChanges, decodeScheduleChange } from './domain.ts' import type { FoldedSchedules } from './domain.ts' import type { ScheduleChange, ScheduleId, ScheduleRecord } from './types.ts' -/** Persisted projection state: the immutable fork boundary plus the complete Schedule fold. */ +/** Persisted projection state: the immutable inherited cut plus the complete Schedule fold. */ export interface ScheduleProjectionState extends FoldedSchedules { - readonly seedLength: number + readonly inheritedEventCount: SessionLogOffsetType } const scheduleId = z.unknown().transform((value, context): ScheduleId => { @@ -43,7 +45,7 @@ const scheduleRecord = z.unknown().transform((value, context): ScheduleRecord => const scheduleRecords = z.array(scheduleRecord) as unknown as z.ZodType const scheduleProjectionStateSchema = z.object({ - seedLength: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + inheritedEventCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionLogOffset), active: scheduleRecords, seenIds: z.array(scheduleId), }).strict().superRefine((state, context) => { @@ -67,11 +69,11 @@ const scheduleProjectionStateSchema = z.object({ export const scheduleProjectionDefinition = { key: 'schedule', stateSchema: scheduleProjectionStateSchema, - init: header => ({ seedLength: header.seedLength ?? 0, active: [], seenIds: [] }), + init: (_header, inheritedEventCount) => ({ inheritedEventCount, active: [], seenIds: [] }), apply: (state, event) => { - if (event.seq < state.seedLength || event.type !== 'schedule/change') return state + if (event.seq < state.inheritedEventCount || event.type !== 'schedule/change') return state return { - seedLength: state.seedLength, + inheritedEventCount: state.inheritedEventCount, ...applyScheduleChanges(state, [decodeScheduleChange(event.data)]), } }, @@ -79,7 +81,7 @@ export const scheduleProjectionDefinition = { viewSchema: scheduleRecords, view: state => state.active, }, - stateVersion: 1, + stateVersion: 2, } satisfies ProjectionDefinition<'schedule', ScheduleProjectionState> declare module '@deepseek-ai/dsh-session-projection/types' { diff --git a/packages/schedule/schedule/src/runtime.ts b/packages/schedule/schedule/src/runtime.ts index b7f45848c5..5b141f5728 100644 --- a/packages/schedule/schedule/src/runtime.ts +++ b/packages/schedule/schedule/src/runtime.ts @@ -205,10 +205,7 @@ export class ScheduleRuntime { /** Fold the current exact runtime suffix and contain a corrupt durable stream. */ private readFolded(): FoldedSchedules | undefined { try { - return foldScheduleEvents( - this.agent.session.snapshotEvents(), - this.agent.session.header.seedLength ?? 0, - ) + return foldScheduleEvents(this.agent.session.ownEvents()) } catch (error: unknown) { this.faulted = true const detail = error instanceof ScheduleLogError ? error.message : renderThrown(error) diff --git a/packages/schedule/schedule/src/tools.ts b/packages/schedule/schedule/src/tools.ts index 616d8650fb..c4ac651634 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.snapshotEvents(), agent.session.header.seedLength ?? 0) + return foldScheduleEvents(agent.session.ownEvents()) } catch (error: unknown) { return error instanceof ScheduleLogError ? corruptLogError() : internalError() } diff --git a/packages/schedule/schedule/tests/domain.spec.ts b/packages/schedule/schedule/tests/domain.spec.ts index f07b0823f2..650c38af37 100644 --- a/packages/schedule/schedule/tests/domain.spec.ts +++ b/packages/schedule/schedule/tests/domain.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { ScheduleId, @@ -137,13 +138,13 @@ describe('version-1 Schedule decoding and folding', () => { it('folds only the fork-owned suffix and validates its boundary', () => { const parentCreate = scheduleEvent(createData('parent'), 0) const childCreate = scheduleEvent(createData('child'), 1) - expect(foldScheduleEvents([parentCreate, childCreate], 1)).toEqual({ + expect(foldScheduleEvents([parentCreate, childCreate], SessionLogOffset(1))).toEqual({ active: [expect.objectContaining({ id: 'child' })], seenIds: ['child'], }) - expect(() => foldScheduleEvents([], -1)).toThrow(/seedLength/) - expect(() => foldScheduleEvents([], 1)).toThrow(/seedLength/) - expect(() => foldScheduleEvents([], 0.5)).toThrow(/seedLength/) + expect(() => foldScheduleEvents([], -1 as never)).toThrow(/inheritedEventCount/) + expect(() => foldScheduleEvents([], SessionLogOffset(1))).toThrow(/inheritedEventCount/) + expect(() => foldScheduleEvents([], 0.5 as never)).toThrow(/inheritedEventCount/) }) it('allocates a readable id without reusing ended or colliding ids', () => { diff --git a/packages/schedule/schedule/tests/invariant.spec.ts b/packages/schedule/schedule/tests/invariant.spec.ts index 0896a8b6f9..f595bfa081 100644 --- a/packages/schedule/schedule/tests/invariant.spec.ts +++ b/packages/schedule/schedule/tests/invariant.spec.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import InvariantRegistry, { InvariantError } from '@deepseek-ai/dsh-invariants' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import * as scheduleInvariant from '../src/invariant.ts' import { ScheduleId } from '../src/domain.ts' import type { ScheduleChange } from '../src/types.ts' -function event(data: unknown, seq: number): SessionEvent { +function event(data: unknown, seq: SessionSeq): SessionEvent { return { type: 'schedule/change', seq, time: 1, data } as SessionEvent } @@ -91,7 +91,7 @@ describe('Schedule package invariant', () => { await ctx.plugin(SessionStore) await ctx.plugin(InvariantRegistry) ctx.sessions.create(SessionId('schedule-invalid-seed'), { - seed: [event({ version: 9, operation: 'delete', id: 'schedule-1' }, 0)], + seed: [event({ version: 9, operation: 'delete', id: 'schedule-1' }, SessionSeq(0))], }) await expect(ctx.plugin(scheduleInvariant).then(() => undefined)).rejects.toThrow(InvariantError) await ctx.fiber.dispose() @@ -101,7 +101,7 @@ describe('Schedule package invariant', () => { const { ctx } = await harness() const id = SessionId('schedule-invalid-future-seed') expect(() => ctx.sessions.create(id, { - seed: [event({ version: 9, operation: 'delete', id: 'schedule-1' }, 0)], + seed: [event({ version: 9, operation: 'delete', id: 'schedule-1' }, SessionSeq(0))], })).toThrow(InvariantError) expect(ctx.sessions.get(id)).toBeUndefined() await ctx.fiber.dispose() @@ -112,8 +112,9 @@ describe('Schedule package invariant', () => { await ctx.plugin(SessionStore) await ctx.plugin(InvariantRegistry) const child = ctx.sessions.create(SessionId('schedule-fork'), { - seed: [event({ version: 9, operation: 'delete', id: 'parent' }, 0)], - meta: { parentSession: SessionId('parent'), seedLength: 1 }, + seed: [event({ version: 9, operation: 'delete', id: 'parent' }, SessionSeq(0))], + inheritedEventCount: SessionLogOffset(1), + meta: { parentSession: SessionId('parent'), isSeeded: true }, }) const fiber = await ctx.plugin(scheduleInvariant) child.append('schedule/change', create('child')) diff --git a/packages/schedule/schedule/tests/jsonl-restart.spec.ts b/packages/schedule/schedule/tests/jsonl-restart.spec.ts index 879b16d0ad..ea67d764c1 100644 --- a/packages/schedule/schedule/tests/jsonl-restart.spec.ts +++ b/packages/schedule/schedule/tests/jsonl-restart.spec.ts @@ -108,7 +108,7 @@ describe('Schedule production JSONL restart', () => { await handle.agent.whenIdle() await expect(restarted.sessions.flush(handle.agent.session)).resolves.toBe(true) const dispatchedStored = await restarted.sessionPersistence.inspect(sessionId) - expect(foldScheduleEvents(dispatchedStored.events, dispatchedStored.meta.seedLength ?? 0).active) + expect(foldScheduleEvents(dispatchedStored.events, dispatchedStored.inheritedEventCount).active) .toEqual([]) const dispatches = dispatchedStored.events.filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch') diff --git a/packages/schedule/schedule/tests/projection.spec.ts b/packages/schedule/schedule/tests/projection.spec.ts index 73e0965f3c..a6221e323b 100644 --- a/packages/schedule/schedule/tests/projection.spec.ts +++ b/packages/schedule/schedule/tests/projection.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { apply as applySchedule } from '../src/index.ts' @@ -13,6 +13,7 @@ const RESTORE_HEADER: SessionHeader = { version: 0, id: SessionId('schedule-projection'), createdAt: 0, + isSeeded: false, } afterEach(async () => { @@ -48,32 +49,32 @@ function everyRecord(id: string): ScheduleRecord { } } -function change(data: unknown, seq: number): SessionEvent { +function change(data: unknown, seq: SessionSeq): SessionEvent { return { type: 'schedule/change', seq, time: seq, data } as SessionEvent } -function created(record: ScheduleRecord, seq: number): SessionEvent { +function created(record: ScheduleRecord, seq: SessionSeq): SessionEvent { return change({ version: 1, operation: 'create', schedule: record }, seq) } describe('Schedule Session projection', () => { it('matches an empty replay, preserves creation order, and applies every terminal transition', () => { - let projected: ScheduleProjectionState = scheduleProjectionDefinition.init(RESTORE_HEADER) - expect(projected).toEqual({ seedLength: 0, active: [], seenIds: [] }) + let projected: ScheduleProjectionState = scheduleProjectionDefinition.init(RESTORE_HEADER, SessionLogOffset(0)) + expect(projected).toEqual({ inheritedEventCount: 0, active: [], seenIds: [] }) expect(scheduleProjectionDefinition.wire.view(projected)).toEqual(foldScheduleEvents([]).active) const events: SessionEvent[] = [ - created(afterRecord('after'), 0), - created(atRecord('at'), 1), - created(everyRecord('every'), 2), - change({ version: 1, operation: 'delete', id: 'at' }, 3), - change({ version: 1, operation: 'dispatch', id: 'after' }, 4), + created(afterRecord('after'), SessionSeq(0)), + created(atRecord('at'), SessionSeq(1)), + created(everyRecord('every'), SessionSeq(2)), + change({ version: 1, operation: 'delete', id: 'at' }, SessionSeq(3)), + change({ version: 1, operation: 'dispatch', id: 'after' }, SessionSeq(4)), change({ version: 1, operation: 'dispatch', id: 'every', acceptedAt: '2026-08-25T14:02:00.000Z', - }, 5), + }, SessionSeq(5)), ] for (const event of events.slice(0, 3)) { projected = scheduleProjectionDefinition.apply(projected, event) @@ -83,33 +84,36 @@ describe('Schedule Session projection', () => { projected = scheduleProjectionDefinition.apply(projected, event) } - expect(projected).toEqual({ seedLength: 0, ...foldScheduleEvents(events) }) + expect(projected).toEqual({ inheritedEventCount: 0, ...foldScheduleEvents(events) }) expect(projected.active).toEqual([{ ...everyRecord('every'), scheduledAt: '2026-08-25T14:05:00.000Z' }]) }) it('shares strict transitions with full replay and excludes the inherited fork prefix', () => { const events: SessionEvent[] = [ - created(afterRecord('parent'), 0), - created(atRecord('child-at'), 1), - created(everyRecord('child-every'), 2), + created(afterRecord('parent'), SessionSeq(0)), + created(atRecord('child-at'), SessionSeq(1)), + created(everyRecord('child-every'), SessionSeq(2)), change({ version: 1, operation: 'dispatch', id: 'child-every', acceptedAt: '2026-08-25T14:02:00.000Z', - }, 3), + }, SessionSeq(3)), ] - let projected: ScheduleProjectionState = scheduleProjectionDefinition.init({ - ...RESTORE_HEADER, - seedLength: 1, - }) + let projected: ScheduleProjectionState = scheduleProjectionDefinition.init( + { ...RESTORE_HEADER, isSeeded: true }, + SessionLogOffset(1), + ) for (const event of events) projected = scheduleProjectionDefinition.apply(projected, event) const beforeUnrelated = projected - const unrelated = { type: 'turn/start', seq: 4, time: 4, data: { turn: 1 } } as SessionEvent + const unrelated = { type: 'turn/start', seq: SessionSeq(4), time: 4, data: { turn: 1 } } as SessionEvent projected = scheduleProjectionDefinition.apply(projected, unrelated) expect(projected).toBe(beforeUnrelated) - expect(projected).toEqual({ seedLength: 1, ...foldScheduleEvents([...events, unrelated], 1) }) + expect(projected).toEqual({ + inheritedEventCount: 1, + ...foldScheduleEvents([...events, unrelated], SessionLogOffset(1)), + }) expect(scheduleProjectionDefinition.wire.view(projected)).toEqual(projected.active) expect(projected.active.map(record => record.id)).toEqual(['child-at', 'child-every']) }) @@ -120,26 +124,30 @@ describe('Schedule Session projection', () => { await ctx.plugin(SessionProjectionRegistry) ctx.sessionProjections.register(scheduleProjectionDefinition) - const first = created(afterRecord('one'), 0) - const second = created(atRecord('two'), 1) - const initial = ctx.sessionProjections.restore({}, [first, second], 0, RESTORE_HEADER) + const first = created(afterRecord('one'), SessionSeq(0)) + const second = created(atRecord('two'), SessionSeq(1)) + const initial = ctx.sessionProjections.restore( + {}, [first, second], SessionLogOffset(0), RESTORE_HEADER, SessionLogOffset(0), + ) expect(initial.snapshot.values.schedule?.map(record => record.id)).toEqual(['one', 'two']) - const removed = change({ version: 1, operation: 'delete', id: 'one' }, 2) + const removed = change({ version: 1, operation: 'delete', id: 'one' }, SessionSeq(2)) const resumed = ctx.sessionProjections.restore( initial.checkpoint, [second, removed], - 1, + SessionLogOffset(1), RESTORE_HEADER, + SessionLogOffset(0), ) expect(resumed.snapshot.values.schedule?.map(record => record.id)).toEqual(['two']) - expect(resumed.checkpoint.schedule).toMatchObject({ ver: 1, seq: 2 }) + expect(resumed.checkpoint.schedule).toMatchObject({ ver: 2, seq: 2 }) expect(() => ctx.sessionProjections.restore( {}, - [change({ version: 1, operation: 'delete', id: 'missing' }, 0)], - 0, + [change({ version: 1, operation: 'delete', id: 'missing' }, SessionSeq(0))], + SessionLogOffset(0), RESTORE_HEADER, + SessionLogOffset(0), )).toThrow(ScheduleLogError) }) @@ -148,25 +156,25 @@ describe('Schedule Session projection', () => { contexts.push(ctx) await ctx.plugin(SessionProjectionRegistry) ctx.sessionProjections.register(scheduleProjectionDefinition) - const row = (val: unknown) => ({ schedule: { ver: 1, seq: 0, val } }) + const row = (val: unknown) => ({ schedule: { ver: 2, seq: SessionSeq(0), val } }) expect(ctx.sessionProjections.viewCheckpoint(row({ - seedLength: 0, + inheritedEventCount: 0, active: [{ ...afterRecord('bad-time'), scheduledAt: 'not-an-instant' }], seenIds: ['bad-time'], }))).toEqual({}) expect(ctx.sessionProjections.viewCheckpoint(row({ - seedLength: 0, + inheritedEventCount: 0, active: [afterRecord('missing')], seenIds: [], }))).toEqual({}) expect(ctx.sessionProjections.viewCheckpoint(row({ - seedLength: 0, + inheritedEventCount: 0, active: [afterRecord('duplicate'), afterRecord('duplicate')], seenIds: ['duplicate', 'duplicate'], }))).toEqual({}) expect(ctx.sessionProjections.viewCheckpoint(row({ - seedLength: 0, + inheritedEventCount: 0, active: [], seenIds: [' bad-id'], }))).toEqual({}) diff --git a/packages/session-query/session-log-export/tests/archive.host.spec.ts b/packages/session-query/session-log-export/tests/archive.host.spec.ts index c41846039a..66ebe197c0 100644 --- a/packages/session-query/session-log-export/tests/archive.host.spec.ts +++ b/packages/session-query/session-log-export/tests/archive.host.spec.ts @@ -10,6 +10,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { unzipSync, strFromU8 } from 'fflate' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query' import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' @@ -25,6 +26,7 @@ function header(id: string, parentSession?: SessionId): SessionHeader { id: sid(id), createdAt: 1000, cwd: '/proj', + isSeeded: false, ...parentSession === undefined ? {} : { parentSession }, delegationDepth: parentSession === undefined ? 0 : 1, } @@ -33,6 +35,7 @@ function header(id: string, parentSession?: SessionId): SessionHeader { function artifact(id: string, parentSession?: SessionId, content?: string): SessionRawArtifact { return { meta: header(id, parentSession), + inheritedEventCount: SessionLogOffset(0), filename: 'session.jsonl', content: content ?? `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`, } diff --git a/packages/session-query/session-log-export/tests/route.host.spec.ts b/packages/session-query/session-log-export/tests/route.host.spec.ts index 44d4b6c476..19d07bb9de 100644 --- a/packages/session-query/session-log-export/tests/route.host.spec.ts +++ b/packages/session-query/session-log-export/tests/route.host.spec.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' import { strFromU8, unzipSync } from 'fflate' @@ -20,12 +21,21 @@ function artifact(id: string): SessionRawArtifact { id: sid(id), createdAt: 1, cwd: '/workspace', + isSeeded: false, delegationDepth: 0, } return { meta: header, + inheritedEventCount: SessionLogOffset(0), filename: 'session.jsonl', - content: `${JSON.stringify({ type: 'session', ...header })}\n`, + content: `${JSON.stringify({ + type: 'session', + version: header.version, + id: header.id, + createdAt: header.createdAt, + cwd: header.cwd, + delegationDepth: header.delegationDepth, + })}\n`, } } @@ -64,7 +74,9 @@ describe('Session log export Fetch route', () => { expect(response.status).toBe(200) expect(response.headers.get('content-type')).toBe('application/zip') const files = unzipSync(new Uint8Array(await response.arrayBuffer())) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toContain('"id":"session-1"') + const exported = strFromU8(files['session.jsonl'] as Uint8Array) + expect(exported).toContain('"id":"session-1"') + expect(exported).not.toContain('isSeeded') const head = await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, { method: 'HEAD' }, diff --git a/packages/session-query/session-query-sqlite/README.i18n.yaml b/packages/session-query/session-query-sqlite/README.i18n.yaml index 084c4a9abf..23e7b38fac 100644 --- a/packages/session-query/session-query-sqlite/README.i18n.yaml +++ b/packages/session-query/session-query-sqlite/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-query/session-query-sqlite/README.md -README.md: cac104a9b624e9709b47329e766f6bd97a85f4c6 -README.zh.md: cdbc10c53b701384af4b20311813670df87244b8 +README.md: 1db3999186beebc10a6a3c6874122fa65f2787a3 +README.zh.md: f6ef419293ab0df3a3edfd171cc60bc62ee4bcb0 diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index cac104a9b6..1db3999186 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -101,7 +101,7 @@ The design history lives in the [SQLite FTS5 session search note](../../../.agen ### Index lifecycle -Persisted FTS rows live in a dedicated derived database and survive restarts; live sessions use connection-local TEMP tables that shadow the durable base for the same session and reveal it again when the live owner detaches. Each search runs one serialized observation: list persistence snapshots, compare per-session revisions with the indexed rows, inspect only new or changed logs, extract semantic documents, and commit the reconciliation in one transaction before running the query. Repeated queries and unchanged reopens inspect nothing; switching stores or observing new, changed, deleted, or externally repaired sources reconciles on the next stable observation. Source or transaction failure commits nothing and the next search retries. +Persisted FTS rows live in a dedicated derived database and survive restarts; live sessions use connection-local TEMP tables that shadow the durable base for the same session and reveal it again when the live owner detaches. Both tables retain the exact inherited cut in numeric `seed_length`; reconstructed headers expose only `isSeeded`, while the cut participates in live fingerprints and persisted source revisions. Each search runs one serialized observation: list persistence snapshots, compare per-session revisions with the indexed rows, inspect only new or changed logs, extract semantic documents, and commit the reconciliation in one transaction before running the query. Repeated queries and unchanged reopens inspect nothing; switching stores or observing new, changed, deleted, or externally repaired sources reconciles on the next stable observation. Source or transaction failure commits nothing and the next search retries. ### Schema ownership diff --git a/packages/session-query/session-query-sqlite/README.zh.md b/packages/session-query/session-query-sqlite/README.zh.md index cdbc10c53b..f6ef419293 100644 --- a/packages/session-query/session-query-sqlite/README.zh.md +++ b/packages/session-query/session-query-sqlite/README.zh.md @@ -101,7 +101,7 @@ kind: "package-reference" ### 索引生命周期 -持久化 FTS 行存放在专用派生数据库中并跨重启保留;实时会话使用连接本地 TEMP 表,遮蔽同一会话的持久化基库,并在实时所有者脱离后再次显示基库。每次搜索执行一次串行化观察:列出持久化快照、把逐会话修订与已索引行比较、只检查新增或已更改日志、提取语义文档,并在运行查询前于一个事务中提交对账。重复查询与不变的重新打开不会检查任何内容;切换存储或观察到新增、已更改、已删除或经外部修复的来源时,会在下次稳定观察时对账。来源或事务失败不提交任何内容,下一次搜索重试。 +持久化 FTS 行存放在专用派生数据库中并跨重启保留;实时会话使用连接本地 TEMP 表,遮蔽同一会话的持久化基库,并在实时所有者脱离后再次显示基库。两类表都在数字 `seed_length` 中保留精确继承切点;重建的 header 只公开 `isSeeded`,而切点参与实时 fingerprint 与持久来源修订。每次搜索执行一次串行化观察:列出持久化快照、把逐会话修订与已索引行比较、只检查新增或已更改日志、提取语义文档,并在运行查询前于一个事务中提交对账。重复查询与不变的重新打开不会检查任何内容;切换存储或观察到新增、已更改、已删除或经外部修复的来源时,会在下次稳定观察时对账。来源或事务失败不提交任何内容,下一次搜索重试。 ### Schema 归属 diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index f7e4ce043d..4722557a65 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -8,7 +8,14 @@ import { createHash, randomUUID } from 'node:crypto' import type { DatabaseSync } from 'node:sqlite' import { Context, Service, type Fiber } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { SessionSeq } from '@deepseek-ai/dsh-session' +import type { + Session, + SessionEvent, + SessionHeader, + SessionId, + SessionLogOffset, +} from '@deepseek-ai/dsh-session' import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' import type { SessionPersistenceRevision, @@ -126,6 +133,7 @@ interface ResolvedConfig { interface ObservedSession { header: SessionHeader + inheritedEventCount: SessionLogOffset documents: SessionEventSearchDocument[] fingerprint: string } @@ -508,7 +516,11 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine { const loaded = await persistence.inspect(entry.header.id, signal) assertNotAborted(signal) assertSessionHeadersCompatible(entry.header, loaded.meta) - entry.loaded = observeSession(loaded.meta, loaded.events) + entry.loaded = observeSession( + loaded.meta, + loaded.inheritedEventCount, + loaded.events, + ) } assertNotAborted(signal) const afterSnapshots = await persistence.listSnapshots(signal) @@ -577,7 +589,7 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine { (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, revision, generation) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - ...headerBindings(entry.header), + ...headerBindings(entry.header, entry.inheritedEventCount), revision, generation, ) @@ -607,7 +619,7 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine { (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, fingerprint, persisted, generation) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - ...headerBindings(entry.header), + ...headerBindings(entry.header, entry.inheritedEventCount), entry.fingerprint, persisted ? 1 : 0, generation, @@ -741,7 +753,7 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine { private _eventHit(row: SearchRow): SessionEventSearchHit { return { sessionId: row.session_id as SessionId, - seq: row.seq, + seq: SessionSeq(row.seq), type: row.type as SessionEventSearchHit['type'], time: row.time, surface: row.surface as SessionEventSearchHit['surface'], @@ -766,14 +778,17 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine { * @param header - the session header being written. * @returns one bound value per header column. */ -function headerBindings(header: SessionHeader): (string | number | null)[] { +function headerBindings( + header: SessionHeader, + inheritedEventCount: SessionLogOffset, +): (string | number | null)[] { return [ header.id, header.version, header.createdAt, header.cwd ?? null, header.parentSession ?? null, - header.seedLength ?? null, + header.isSeeded ? inheritedEventCount : null, header.delegationDepth ?? null, header.agentPreset ?? null, ] @@ -854,17 +869,22 @@ function selectedDocumentsParams(query: string, persistenceVisible: boolean): Ar } function observeLive(session: Session): ObservedSession { - return observeSession(session.header, session.snapshotEvents()) + return observeSession(session.header, session.inheritedEventCount, session.snapshotEvents()) } -function observeSession(header: SessionHeader, events: readonly SessionEvent[]): ObservedSession { +function observeSession( + header: SessionHeader, + inheritedEventCount: SessionLogOffset, + events: readonly SessionEvent[], +): ObservedSession { const detachedHeader = structuredClone(header) const detachedEvents = events.map(event => structuredClone(event)) return { header: detachedHeader, + inheritedEventCount, documents: buildSessionEventSearchDocuments(detachedHeader.id, detachedEvents), fingerprint: createHash('sha256') - .update(JSON.stringify({ header: detachedHeader, events: detachedEvents })) + .update(JSON.stringify({ header: detachedHeader, inheritedEventCount, events: detachedEvents })) .digest('base64url'), } } @@ -920,7 +940,7 @@ function sameHeader(a: SessionHeader, b: SessionHeader): boolean { && a.createdAt === b.createdAt && a.cwd === b.cwd && a.parentSession === b.parentSession - && a.seedLength === b.seedLength + && a.isSeeded === b.isSeeded && (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0) && a.agentPreset === b.agentPreset } @@ -932,7 +952,7 @@ function rowHeader(row: SessionHeaderRow): SessionHeader { createdAt: row.created_at, ...row.cwd === null ? {} : { cwd: row.cwd }, ...row.parent_session === null ? {} : { parentSession: row.parent_session as SessionId }, - ...row.seed_length === null ? {} : { seedLength: row.seed_length }, + isSeeded: row.seed_length !== null, ...row.delegation_depth === null ? {} : { delegationDepth: row.delegation_depth }, ...row.agent_preset === null ? {} : { agentPreset: row.agent_preset }, } diff --git a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts index 5306221d0a..29d37915fb 100644 --- a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts +++ b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts @@ -8,7 +8,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SqliteSessionQueryEngine, * as queryModule from '@deepseek-ai/dsh-session-query-sqlite' @@ -48,10 +48,15 @@ describe('dsh-session-query-sqlite real Loader path', () => { const query = await ctx.plugin(unwrapped, { path: searchPath }) const id = SessionId('loader-path') - await ctx.sessionPersistence.create({ version: SESSION_FORMAT_VERSION, id, createdAt: 10 }) + await ctx.sessionPersistence.create({ + version: SESSION_FORMAT_VERSION, + id, + createdAt: 10, + isSeeded: false, + }) await ctx.sessionPersistence.append(id, [{ type: 'user/message', - seq: 0, + seq: SessionSeq(0), time: 10, data: createUserMessage({ content: [{ type: 'text', text: 'real Loader needle' }], source: { kind: 'user' }, diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 248151580d..e09d74ba12 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -5,11 +5,16 @@ import { DatabaseSync } from 'node:sqlite' import { chmod, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { + SESSION_FORMAT_VERSION, + SessionId, + SessionLogOffset, + SessionSeq, +} from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' -import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' +import type { SessionEventSuffix, SessionInspection, SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SqliteSessionQueryEngine, { SESSION_QUERY_SQLITE_SCHEMA_VERSION, @@ -38,13 +43,13 @@ async function temporaryPath(name = 'search.db'): Promise { } function header(id: string, createdAt = 1, extra: Partial = {}): SessionHeader { - return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra } + return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, isSeeded: false, ...extra } } -function messageEvents(text: string, time = 1): SessionEvent[] { +function messageEvents(text: string, time = 1): SessionEvent<'user/message'>[] { return [{ type: 'user/message', - seq: 0, + seq: SessionSeq(0), time, data: createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' }, @@ -70,7 +75,11 @@ function replaceCursorOffset( class TestPersistence extends SessionPersistence { override readonly supportsRawArtifacts = false - static entries = new Map() + static entries = new Map() static revisions = new Map() static nextRevision = 0 static loads = new Map() @@ -96,7 +105,11 @@ class TestPersistence extends SessionPersistence { return Promise.reject(new Error('not used')) } - static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { + static reset(entries: readonly { + meta: SessionHeader + inheritedEventCount?: SessionLogOffset + events: SessionEvent[] + }[] = []): void { this.entries = new Map() this.revisions = new Map() this.loads = new Map() @@ -113,13 +126,21 @@ class TestPersistence extends SessionPersistence { this.failure = undefined } - static set(entry: { meta: SessionHeader; events: SessionEvent[] }): void { + static set(entry: { + meta: SessionHeader + inheritedEventCount?: SessionLogOffset + events: SessionEvent[] + }): void { this.entries.set(entry.meta.id, structuredClone(entry)) this.revisions.set(entry.meta.id, ++this.nextRevision) } - create(meta: SessionHeader): Promise { - TestPersistence.set({ meta, events: [] }) + create(meta: SessionHeader, inheritedEventCount?: SessionLogOffset): Promise { + TestPersistence.set({ + meta, + ...inheritedEventCount === undefined ? {} : { inheritedEventCount }, + events: [], + }) return Promise.resolve() } @@ -131,7 +152,7 @@ class TestPersistence extends SessionPersistence { return Promise.resolve() } - async load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + async load(id: SessionIdType): Promise { TestPersistence.loads.set(id, (TestPersistence.loads.get(id) ?? 0) + 1) if (TestPersistence.failure !== undefined) throw TestPersistence.failure const entry = TestPersistence.entries.get(id) @@ -142,10 +163,13 @@ class TestPersistence extends SessionPersistence { effect(entry) TestPersistence.revisions.set(id, ++TestPersistence.nextRevision) } - return structuredClone(entry) + return { + ...structuredClone(entry), + inheritedEventCount: entry.inheritedEventCount ?? SessionLogOffset(0), + } } - async inspect(id: SessionIdType, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + async inspect(id: SessionIdType, signal?: AbortSignal): Promise { TestPersistence.inspections.set(id, (TestPersistence.inspections.get(id) ?? 0) + 1) TestPersistence.inspectSignals.push(signal) if (TestPersistence.failure !== undefined) throw TestPersistence.failure @@ -153,12 +177,19 @@ class TestPersistence extends SessionPersistence { if (entry === undefined) throw new Error('missing test session') await TestPersistence.inspectEffect?.(entry, signal) TestPersistence.inspectEffect = undefined - return structuredClone(entry) + return { + ...structuredClone(entry), + inheritedEventCount: entry.inheritedEventCount ?? SessionLogOffset(0), + } } - async readFrom(id: SessionIdType, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + async readFrom( + id: SessionIdType, + fromSeq: SessionLogOffset, + signal?: AbortSignal, + ): Promise { const whole = await this.inspect(id, signal) - return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } + return { ...whole, fromSeq, events: whole.events.filter(event => event.seq >= fromSeq) } } async list(): Promise { @@ -322,10 +353,12 @@ describe('SQLite session search', () => { it('searches two-character Unicode61 tokens in live-only sessions', async () => { const ctx = await liveContext({ path: ':memory:', snippetChars: 20 }) const session = ctx.sessions.create(SessionId('live'), { + seed: messageEvents('inherited context'), + inheritedEventCount: SessionLogOffset(1), // agentPreset rides along: the index rebuilds the header a caller reads, // and a session listed under the wrong composition is a lie about what it // ran. The full-header comparison below is what pins every column. - meta: { cwd: '/work', createdAt: 10, seedLength: 1, delegationDepth: 2, agentPreset: 'minimal' }, + meta: { cwd: '/work', createdAt: 10, isSeeded: true, delegationDepth: 2, agentPreset: 'minimal' }, }) session.append( 'user/message', @@ -337,11 +370,44 @@ describe('SQLite session search', () => { await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'AI' })) .resolves.toMatchObject({ - session: { ...session.header, seedLength: 1 }, - items: [{ sessionId: session.id, seq: 0, snippet: 'An AI helper' }], + session: session.header, + items: [{ sessionId: session.id, seq: 2, snippet: 'An AI helper' }], }) await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })) - .resolves.toMatchObject({ items: [{ header: { ...session.header, seedLength: 1 }, live: true, persisted: false }] }) + .resolves.toMatchObject({ items: [{ header: session.header, live: true, persisted: false }] }) + const db = (ctx.sessionQuery as unknown as { _db: DatabaseSync })._db + expect(db.prepare('SELECT seed_length FROM temp.live_sessions WHERE id = ?').get(session.id)) + .toEqual({ seed_length: 1 }) + }) + + it('retains a persisted inherited cut and reindexes when that source identity changes', async () => { + const meta = header('persisted-seed-cut', 10, { isSeeded: true }) + const events: SessionEvent[] = [ + ...messageEvents('persisted cut needle'), + { ...messageEvents('second inherited event')[0]!, seq: SessionSeq(1) }, + ] + TestPersistence.reset([{ + meta, + inheritedEventCount: SessionLogOffset(1), + events, + }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const db = (ctx.sessionQuery as unknown as { _db: DatabaseSync })._db + + await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) + .resolves.toMatchObject({ items: [{ header: { ...meta, isSeeded: true } }] }) + expect(db.prepare('SELECT seed_length FROM persisted_sessions WHERE id = ?').get(meta.id)) + .toEqual({ seed_length: 1 }) + + TestPersistence.set({ + meta, + inheritedEventCount: SessionLogOffset(2), + events, + }) + await ctx.sessionQuery.searchSessions({ query: 'needle' }) + expect(db.prepare('SELECT seed_length FROM persisted_sessions WHERE id = ?').get(meta.id)) + .toEqual({ seed_length: 2 }) }) it('excludes assistant reasoning while indexing visible answer text', async () => { @@ -378,14 +444,14 @@ describe('SQLite session search', () => { const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 20 }) const parent = SessionId('parent') const events: SessionEvent[] = [ - { type: 'user/message', seq: 0, time: 10, data: createUserMessage({ + { type: 'user/message', seq: SessionSeq(0), time: 10, data: createUserMessage({ content: [{ type: 'text', text: 'needle original' }], source: { kind: 'user' }, }), surfaceOp: 'append' }, - { type: 'assistant/chunk', seq: 1, time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'needle raw' } } }, - { type: 'user/message', seq: 2, time: 12, data: createUserMessage({ + { type: 'assistant/chunk', seq: SessionSeq(1), time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'needle raw' } } }, + { type: 'user/message', seq: SessionSeq(2), time: 12, data: createUserMessage({ content: [{ type: 'text', text: 'needle summary' }], source: { kind: 'plugin', plugin: 'test' }, - }), surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] }, - { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'error', error: { message: 'needle failure', code: 'UNKNOWN' } } } }, + }), surfaceOp: { op: 'replace', start: SessionSeq(0), end: SessionSeq(0) }, sourceEventSeqs: [SessionSeq(0)] }, + { type: 'turn/end', seq: SessionSeq(3), time: 13, data: { turn: 1, reason: { kind: 'error', error: { message: 'needle failure', code: 'UNKNOWN' } } } }, ] ctx.sessions.create(SessionId('a'), { seed: events, meta: { cwd: '/a', parentSession: parent, createdAt: 20 } }) ctx.sessions.create(SessionId('b'), { seed: messageEvents('needle peer', 12), meta: { createdAt: 20 } }) @@ -542,8 +608,8 @@ describe('SQLite session search', () => { const target = ctx.sessions.create(SessionId('target'), { seed: [ ...messageEvents('needle one', 10), - { ...messageEvents('needle two', 11)[0]!, seq: 1 }, - { ...messageEvents('needle three', 12)[0]!, seq: 2 }, + { ...messageEvents('needle two', 11)[0]!, seq: SessionSeq(1) }, + { ...messageEvents('needle three', 12)[0]!, seq: SessionSeq(2) }, ], }) ctx.sessions.create(SessionId('other'), { seed: messageEvents('needle other', 10) }) @@ -885,7 +951,7 @@ describe('SQLite reconciliation and source lifecycle', () => { const durable = header('post-reconcile-unmount') TestPersistence.reset([{ meta: durable, events: [ ...messageEvents('durable needle', 1), - { ...messageEvents('durable needle again', 2)[0]!, seq: 1 }, + { ...messageEvents('durable needle again', 2)[0]!, seq: SessionSeq(1) }, ] }]) const ctx = await liveContext({ path: ':memory:', defaultLimit: 1, maxLimit: 2 }) const persistence = await ctx.plugin(TestPersistence) diff --git a/packages/session-query/session-query/README.i18n.yaml b/packages/session-query/session-query/README.i18n.yaml index 46ba6f606f..c6092ee220 100644 --- a/packages/session-query/session-query/README.i18n.yaml +++ b/packages/session-query/session-query/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-query/session-query/README.md -README.md: e5729e95785df03ffa1d83ab99123f7fac681753 -README.zh.md: 52f0816256a641c9c94375c745c90dca8df24264 +README.md: 549ec7344d53a09e82568be4d1a21778367c38cc +README.zh.md: d2161b9528306ead31d709c4d4f6fab20feec8fd diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index e5729e9578..549ec7344d 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -42,6 +42,8 @@ Use `ctx.sessionQuery` from application code when you need to read or search ses | `traceEvent(request)` | One event's positional replacements and cited source-event relationships | | `searchSessions(request)` / `searchEvents(request)` | Full-text search pages, implemented by the mounted backend | +Body-free records expose only `SessionHeader.isSeeded`. Reads that return event bodies (`readSession`, `readSurface`, `readEvent`) and retained `SessionObservation` values also carry the exact `inheritedEventCount`, so callers can distinguish inherited and owned events without inferring a cut from the log. + ### Filters `SessionResultFilter` narrows sessions by id, nullable cwd, created-at range, nullable parent, or source availability; `SessionEventResultFilter` narrows events by seq/time range, event type, surface, or literal text. Filter arrays are ANDed and list values within one clause are ORed; empty list values match nothing, ranges are inclusive, and malformed ranges or unknown closed-union values fail with `SESSION_QUERY_INVALID_FILTER`. diff --git a/packages/session-query/session-query/README.zh.md b/packages/session-query/session-query/README.zh.md index 52f0816256..d2161b9528 100644 --- a/packages/session-query/session-query/README.zh.md +++ b/packages/session-query/session-query/README.zh.md @@ -42,6 +42,8 @@ kind: "package-reference" | `traceEvent(request)` | 一个事件的位置替换与被引用源事件关系 | | `searchSessions(request)` / `searchEvents(request)` | 全文搜索分页结果,由挂载的后端实现 | +不带正文的记录只公开 `SessionHeader.isSeeded`。返回事件正文的读取(`readSession`、`readSurface`、`readEvent`)与保留的 `SessionObservation` 值还携带精确 `inheritedEventCount`,因此调用方无需从日志推断切点即可区分继承事件与自有事件。 + ### 过滤器 `SessionResultFilter` 按 id、可空 cwd、创建时间范围、可空父级或来源可用性缩小会话范围;`SessionEventResultFilter` 按 seq/时间范围、事件类型、表层或字面文本缩小事件范围。过滤器数组使用 AND 连接,同一子句内的列表值使用 OR;空列表值不匹配任何内容,范围包含端点,格式错误的范围或未知的封闭联合值以 `SESSION_QUERY_INVALID_FILTER` 失败。 diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 487b6a9702..a9746b1793 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -1,7 +1,13 @@ /** Live/persisted logical-corpus resolution for session-query. */ import type { Context, Fiber } from '@deepseek-ai/cordis' -import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { + Session, + SessionEvent, + SessionHeader, + SessionId, + SessionLogOffset, +} from '@deepseek-ai/dsh-session' import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' import type { SessionRecord } from './types.ts' import { SessionQueryError } from './config.ts' @@ -11,6 +17,8 @@ import { assertSessionHeadersCompatible } from './sources.ts' export interface LogicalSession { /** Cloned source header. */ header: SessionHeader + /** Exact fork-inherited event count paired with {@link header}. */ + inheritedEventCount: SessionLogOffset /** Cloned raw event log. */ events: SessionEvent[] } @@ -19,6 +27,8 @@ export interface LogicalSession { export interface LogicalSessionSource { /** Header selected with `events`; callers must clone retained output. */ readonly header: SessionHeader + /** Exact fork-inherited event count paired with {@link header}. */ + readonly inheritedEventCount: SessionLogOffset /** Raw events selected with `header`; valid only for the projection call. */ readonly events: readonly SessionEvent[] } @@ -109,6 +119,7 @@ export class SessionCorpus { assertSessionHeadersCompatible(loaded.meta, listed) const snapshot = { header: structuredClone(loaded.meta), + inheritedEventCount: loaded.inheritedEventCount, events: loaded.events.map(event => structuredClone(event)), } signal?.throwIfAborted() @@ -185,6 +196,7 @@ export class SessionCorpus { assertSessionHeadersCompatible(loaded.meta, listed) resolved.set(sessionId, projectSource(sessionId, { header: loaded.meta, + inheritedEventCount: loaded.inheritedEventCount, events: loaded.events, }, project, signal)) } catch (error: unknown) { @@ -239,7 +251,11 @@ function projectSource( } function sourceLive(session: Session): LogicalSessionSource { - return { header: session.header, events: session.snapshotEvents() } + return { + header: session.header, + inheritedEventCount: session.inheritedEventCount, + events: session.snapshotEvents(), + } } function orderedResults( @@ -292,6 +308,7 @@ async function inspectPersisted( function snapshotLive(session: Session): LogicalSession { return { header: structuredClone(session.header), + inheritedEventCount: session.inheritedEventCount, events: session.snapshotEvents().map(event => structuredClone(event)), } } diff --git a/packages/session-query/session-query/src/documents.ts b/packages/session-query/session-query/src/documents.ts index 7b22a2c26d..f51ae6b733 100644 --- a/packages/session-query/session-query/src/documents.ts +++ b/packages/session-query/session-query/src/documents.ts @@ -1,7 +1,7 @@ /** Shared event metadata and semantic-document projection. */ import { foldSurface } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEventRecord, SessionEventSearchDocument, SessionEventSurface } from './types.ts' import { SessionQueryError } from './config.ts' import { extractSessionEventText } from './extraction.ts' @@ -53,7 +53,7 @@ export function buildSessionEventSearchDocuments( return documents } -function classifySurface(events: readonly SessionEvent[]): Map { +function classifySurface(events: readonly SessionEvent[]): Map { let folded: ReturnType try { folded = foldSurface(events) @@ -65,7 +65,7 @@ function classifySurface(events: readonly SessionEvent[]): Map() + const result = new Map() for (const seq of folded.nodes) result.set(seq, 'current') for (const replacement of folded.replacements) { for (const seq of replacement.shadowedSeqs) result.set(seq, 'shadowed') diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 7be87e2750..8d533f66c9 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -5,7 +5,13 @@ */ import { Context, Service } from '@deepseek-ai/cordis' -import { Session, snapshotSessionEvent, type SessionId } from '@deepseek-ai/dsh-session' +import { + Session, + SessionSeq, + snapshotSessionEvent, + type SessionId, + type SessionSeq as SessionSeqType, +} from '@deepseek-ai/dsh-session' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title' import type { @@ -164,9 +170,15 @@ export abstract class SessionQueryEngine extends Service { */ async readSession(sessionId: SessionId): Promise { const loaded = await this._corpus.load(sessionId) - Session.create(sessionId, loaded.events, loaded.header) + Session.create( + sessionId, + loaded.events, + loaded.header, + loaded.inheritedEventCount, + ) return { session: structuredClone(loaded.header), + inheritedEventCount: loaded.inheritedEventCount, events: loaded.events.map(snapshotSessionEvent), } } @@ -285,6 +297,7 @@ export abstract class SessionQueryEngine extends Service { const loaded = await this._corpus.load(sessionId) return { session: structuredClone(loaded.header), + inheritedEventCount: loaded.inheritedEventCount, capturedThroughSeq: loaded.events.at(-1)?.seq ?? null, events: tracing.currentSurfaceEvents(sessionId, loaded.events), } @@ -335,7 +348,7 @@ export abstract class SessionQueryEngine extends Service { private async _readEvent( sessionId: SessionId, - seq: number, + seq: SessionSeqType, before: number, after: number, signal?: AbortSignal, @@ -349,8 +362,8 @@ export abstract class SessionQueryEngine extends Service { 'SESSION_QUERY_EVENT_NOT_FOUND', ) } - const startSeq = Math.max(0, seq - before) - const endSeq = Math.min(loaded.events.length - 1, seq + after) + const startSeq = SessionSeq(Math.max(0, seq - before)) + const endSeq = SessionSeq(Math.min(loaded.events.length - 1, seq + after)) const targetSnapshot = snapshotSessionEvent(target) const events = loaded.events.slice(startSeq, endSeq + 1) .map(event => event === target @@ -358,6 +371,7 @@ export abstract class SessionQueryEngine extends Service { : snapshotSessionEvent(event)) return { session: structuredClone(loaded.header), + inheritedEventCount: loaded.inheritedEventCount, target: targetSnapshot, events, startSeq, diff --git a/packages/session-query/session-query/src/observation.ts b/packages/session-query/session-query/src/observation.ts index 4d56007d63..f2aebc9503 100644 --- a/packages/session-query/session-query/src/observation.ts +++ b/packages/session-query/session-query/src/observation.ts @@ -1,7 +1,15 @@ /** Shared live/prepared observations for Session page and lifecycle consumers. */ import type { Context } from '@deepseek-ai/cordis' -import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { + Session, + SessionEvent, + SessionHeader, + SessionId, + SessionLogOffset as SessionLogOffsetType, + SessionSeqCursor, +} from '@deepseek-ai/dsh-session' import type { BorrowedSessionSource, SessionPersistenceRevision, @@ -18,8 +26,10 @@ export interface SessionObservation extends Disposable { readonly header: SessionHeader /** Immutable contiguous events at {@link cursor}. */ readonly events: readonly SessionEvent[] + /** Exact number of fork-inherited events in this Session lifecycle. */ + readonly inheritedEventCount: SessionLogOffsetType /** Last observed event seq, or -1 for an empty log. */ - readonly cursor: number + readonly cursor: SessionSeqCursor /** Durable source revision for a cold prepared observation. */ readonly revision?: SessionPersistenceRevision /** Exact projection baseline at {@link cursor}, when the registry is mounted. */ @@ -123,6 +133,7 @@ export class SessionObservationReader { source: 'prepared', header: prepared.inspection.meta, events, + inheritedEventCount: prepared.inspection.inheritedEventCount, cursor: events.at(-1)?.seq ?? -1, revision: prepared.revision, ...projections === undefined ? {} : { projections }, @@ -161,6 +172,7 @@ export class SessionObservationReader { source: 'live', header: session.header, events, + inheritedEventCount: session.inheritedEventCount, cursor: events.at(-1)?.seq ?? -1, ...projections === undefined ? {} : { projections }, retain: () => { @@ -182,8 +194,8 @@ export class SessionObservationReader { const prepared = observation.preparedSession const cache = this.ctx.get('sessionProjectionCache') return cache === undefined - ? registry.hydrate(prepared, {}, events, 0) - : cache.hydratePrepared(prepared, observation.inspection.meta, events) + ? registry.hydrate(prepared, {}, events, SessionLogOffset(0)) + : cache.hydratePrepared(prepared, events) } } diff --git a/packages/session-query/session-query/src/sources.ts b/packages/session-query/session-query/src/sources.ts index f1bb64275f..efcd83c349 100644 --- a/packages/session-query/session-query/src/sources.ts +++ b/packages/session-query/session-query/src/sources.ts @@ -15,7 +15,7 @@ export function assertSessionHeadersCompatible(a: SessionHeader, b: SessionHeade || a.createdAt !== b.createdAt || a.cwd !== b.cwd || a.parentSession !== b.parentSession - || a.seedLength !== b.seedLength + || a.isSeeded !== b.isSeeded || (a.delegationDepth ?? 0) !== (b.delegationDepth ?? 0) ) { throw new SessionQueryError( diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts index 681e6ce59a..a7fd789b7b 100644 --- a/packages/session-query/session-query/src/tracing.ts +++ b/packages/session-query/session-query/src/tracing.ts @@ -1,7 +1,13 @@ /** One-shot session-lineage and event-relationship tracing helpers. */ import { foldSurface, isSurfaceEvent, snapshotSessionEvent } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' +import type { + SessionEvent, + SessionId, + SessionSeq, + SurfaceEvent, + SurfaceEventType, +} from '@deepseek-ai/dsh-session' import { SessionQueryError } from './config.ts' import type { SessionEventRecord, @@ -13,9 +19,9 @@ import type { interface EventLogAnalysis { records: SessionEventRecord[] - replacedBy: Map - replacedEventSeqs: Map - currentSeqs: number[] + replacedBy: Map + replacedEventSeqs: Map + currentSeqs: SessionSeq[] } /** @@ -65,7 +71,7 @@ export function currentSurfaceEvents( export function traceEvent( sessionId: SessionId, events: readonly SessionEvent[], - seq: number, + seq: SessionSeq, ): SessionEventTrace { const target = events[seq] if (target === undefined || target.seq !== seq) { @@ -77,14 +83,14 @@ export function traceEvent( const analysis = analyzeEventLog(sessionId, events) - const replacementChain: number[] = [] + const replacementChain: SessionSeq[] = [] let replacement = analysis.replacedBy.get(seq) while (replacement !== undefined) { replacementChain.push(replacement) replacement = analysis.replacedBy.get(replacement) } - const derivedEventSeqs: number[] = [] + const derivedEventSeqs: SessionSeq[] = [] for (const event of events) { if (event.seq <= seq) continue if (eventSources(event).includes(seq)) derivedEventSeqs.push(event.seq) @@ -188,8 +194,8 @@ function analyzeEventLog( ) } const current = new Set(folded.nodes) - const replacedBy = new Map() - const replacedEventSeqs = new Map() + const replacedBy = new Map() + const replacedEventSeqs = new Map() for (const replacement of folded.replacements) { const removed = replacement.shadowedSeqs replacedEventSeqs.set(replacement.seq, removed) @@ -213,7 +219,7 @@ function analyzeEventLog( } } -function eventSources(event: SessionEvent): readonly number[] { +function eventSources(event: SessionEvent): readonly SessionSeq[] { return (event as SessionEvent).sourceEventSeqs ?? [] } diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index c98b5a749e..01c196aa1f 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -10,6 +10,9 @@ import type { SessionEventType, SessionHeader, SessionId, + SessionLogOffset, + SessionSeq, + OptionalSessionSeq, SurfaceEvent, } from '@deepseek-ai/dsh-session' import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title' @@ -34,8 +37,10 @@ export interface SessionRecord { export interface SessionSurfaceSnapshot { /** Cloned session header selected from the same corpus observation as `events`. */ session: SessionHeader + /** Exact number of fork-inherited events in the observed log. */ + inheritedEventCount: SessionLogOffset /** Highest raw-log seq included in the observation, or `null` for an empty log. */ - capturedThroughSeq: number | null + capturedThroughSeq: OptionalSessionSeq /** Cloned current surface events in model-history order. */ events: SurfaceEvent[] } @@ -44,6 +49,8 @@ export interface SessionSurfaceSnapshot { export interface SessionLogSnapshot { /** Cloned session header selected from the same observation as `events`. */ session: SessionHeader + /** Exact number of fork-inherited events in the observed log. */ + inheritedEventCount: SessionLogOffset /** Cloned contiguous raw events after persistence repair and replay validation. */ events: SessionEvent[] } @@ -53,7 +60,7 @@ export interface SessionEventRecord { /** Session that owns the event. */ sessionId: SessionId /** Monotonic event seq within the session. */ - seq: number + seq: SessionSeq /** Discriminant of the session event. */ type: SessionEventType /** Event timestamp in Unix epoch milliseconds. */ @@ -98,7 +105,7 @@ export interface SessionEventTraceRequest { /** Session that owns the target event. */ sessionId: SessionId /** Target event seq. */ - seq: number + seq: SessionSeq } /** Direct surface replacements and relationships to cited source events for one event. */ @@ -106,15 +113,15 @@ export interface SessionEventTrace { /** Lightweight target record. */ target: SessionEventRecord /** Immediate positional replacement event, when the target was shadowed. */ - replacedBy?: number + replacedBy?: SessionSeq /** Positional replacers from the immediate replacement to the final replacement. */ - replacementChain: number[] + replacementChain: SessionSeq[] /** Surface nodes directly removed when the target itself performed a replacement. */ - replacedEventSeqs: number[] + replacedEventSeqs: SessionSeq[] /** Earlier events cited directly as sources, in their recorded order. */ - sourceEventSeqs: number[] + sourceEventSeqs: SessionSeq[] /** Later events that directly cite the target as a source, in log order. */ - derivedEventSeqs: number[] + derivedEventSeqs: SessionSeq[] } /** Event relationships bound to the same session-header observation. */ @@ -128,7 +135,7 @@ export interface SessionEventReadRequest { /** Session that owns the target event. */ sessionId: SessionId /** Target event seq. */ - seq: number + seq: SessionSeq /** Number of preceding raw events to include. */ before?: number /** Number of following raw events to include. */ @@ -139,14 +146,16 @@ export interface SessionEventReadRequest { export interface SessionEventWindow { /** Cloned header for the live-preferred source read. */ session: SessionHeader + /** Exact number of fork-inherited events in the observed log. */ + inheritedEventCount: SessionLogOffset /** Full cloned target event. */ target: SessionEvent /** Full cloned events from `startSeq` through `endSeq`. */ events: SessionEvent[] /** First seq included in `events`. */ - startSeq: number + startSeq: SessionSeq /** Last seq included in `events`. */ - endSeq: number + endSeq: SessionSeq } /** Latest folded title bound to the same session-header observation. */ diff --git a/packages/session-query/session-query/tests/observation.spec.ts b/packages/session-query/session-query/tests/observation.spec.ts index 7151714b67..ef2460e7a2 100644 --- a/packages/session-query/session-query/tests/observation.spec.ts +++ b/packages/session-query/session-query/tests/observation.spec.ts @@ -1,5 +1,5 @@ import { Context } from '@deepseek-ai/cordis' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' import type { BorrowedSessionSource } from '@deepseek-ai/dsh-session-persistence' @@ -8,17 +8,21 @@ import { describe, expect, it, vi } from 'vitest' import { SessionObservationReader } from '../src/observation.ts' function header(id: string): SessionHeader { - return { version: 0, id: SessionId(id), createdAt: 1, cwd: '/workspace' } + return { version: 0, id: SessionId(id), createdAt: 1, cwd: '/workspace', isSeeded: false } } function preparedSource( meta: SessionHeader, dispose = vi.fn(), ): BorrowedSessionSource { - const preparedSession = Session.create(meta.id, [], meta) + const preparedSession = Session.create(meta.id, [], meta, SessionLogOffset(0)) return { source: 'prepared', - inspection: { meta: preparedSession.header, events: preparedSession.snapshotEvents() }, + inspection: { + meta: preparedSession.header, + inheritedEventCount: preparedSession.inheritedEventCount, + events: preparedSession.snapshotEvents(), + }, revision: SessionPersistenceRevision(`fixture:${meta.id}`), preparedSession, [Symbol.dispose]: dispose, @@ -76,7 +80,9 @@ describe('SessionObservationReader', () => { const prepared = preparedSource(meta) const borrowSession = vi.fn() .mockResolvedValueOnce({ - source: 'live', inspection: { meta, events: [] }, [Symbol.dispose]: disposeLive, + source: 'live', + inspection: { meta, inheritedEventCount: SessionLogOffset(0), events: [] }, + [Symbol.dispose]: disposeLive, } satisfies BorrowedSessionSource) .mockResolvedValueOnce(prepared) ctx.provide('sessionPersistence', { borrowSession } as never) @@ -139,6 +145,25 @@ describe('SessionObservationReader', () => { await ctx.fiber.dispose() }) + it('carries the exact inherited cut on a live observation', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const inherited = [ + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] as const + const session = ctx.sessions.create(SessionId('seeded-observation'), { + seed: inherited, + inheritedEventCount: SessionLogOffset(inherited.length), + meta: { cwd: '/workspace', isSeeded: true }, + }) + + using observed = await new SessionObservationReader(ctx).read(session.id, { projectionMode: 'none' }) + + expect(observed.inheritedEventCount).toBe(SessionLogOffset(inherited.length)) + await ctx.fiber.dispose() + }) + it('contains a non-Error persistence rejection', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts index 3cc4fe5321..0e528ec7f5 100644 --- a/packages/session-query/session-query/tests/search-helpers.spec.ts +++ b/packages/session-query/session-query/tests/search-helpers.spec.ts @@ -4,6 +4,7 @@ import { createUserMessage, ToolCallId , createMessage, createToolResultMessage import SessionStore, { SESSION_FORMAT_VERSION, SessionId, + SessionSeq, } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' @@ -23,7 +24,7 @@ import { TestSessionQueryEngine } from './test-service.ts' const id = SessionId('session') function header(value: string, extra: Partial = {}): SessionHeader { - return { version: SESSION_FORMAT_VERSION, id: SessionId(value), createdAt: 10, ...extra } + return { version: SESSION_FORMAT_VERSION, id: SessionId(value), createdAt: 10, isSeeded: false, ...extra } } function expectCode(code: SessionQueryErrorCode): Error { @@ -46,10 +47,10 @@ describe('session-query semantic extraction', () => { { type: 'future-content', payload: 'hidden' } as never, ] const events: SessionEvent[] = [ - { type: 'user/message', seq: 0, time: 1, data: createUserMessage({ + { type: 'user/message', seq: SessionSeq(0), time: 1, data: createUserMessage({ content: messageContent, source: { kind: 'user' }, }), surfaceOp: 'append' }, - { type: 'assistant/message', seq: 1, time: 2, data: { + { type: 'assistant/message', seq: SessionSeq(1), time: 2, data: { turn: 1, step: 1, message: createMessage({ role: 'assistant', @@ -60,13 +61,13 @@ describe('session-query semantic extraction', () => { }, }), }, surfaceOp: 'append' }, - { type: 'user/message', seq: 2, time: 3, data: createUserMessage({ + { type: 'user/message', seq: SessionSeq(2), time: 3, data: createUserMessage({ content: messageContent, source: { kind: 'plugin', plugin: 'test' }, }), surfaceOp: 'append' }, - { type: 'tool/call', seq: 3, time: 5, data: { turn: 1, step: 1, callId, name: 'bash', arguments: '{"cmd":"pwd"}' } }, + { type: 'tool/call', seq: SessionSeq(3), time: 5, data: { turn: 1, step: 1, callId, name: 'bash', arguments: '{"cmd":"pwd"}' } }, { type: 'tool/result', - seq: 4, + seq: SessionSeq(4), time: 6, data: { turn: 1, @@ -82,7 +83,7 @@ describe('session-query semantic extraction', () => { }, { type: 'tool/result', - seq: 5, + seq: SessionSeq(5), time: 7, data: { turn: 1, @@ -91,7 +92,7 @@ describe('session-query semantic extraction', () => { }, surfaceOp: 'append', }, - { type: 'todo/write', seq: 6, time: 8, data: { todos: [{ status: 'in_progress', content: 'ship search' }] } }, + { type: 'todo/write', seq: SessionSeq(6), time: 8, data: { todos: [{ status: 'in_progress', content: 'ship search' }] } }, ] for (const event of events.slice(0, 3)) { @@ -99,7 +100,7 @@ describe('session-query semantic extraction', () => { } expect(extractSessionEventText({ type: 'assistant/message', - seq: 9, + seq: SessionSeq(9), time: 10, data: { turn: 1, @@ -130,15 +131,15 @@ describe('session-query semantic extraction', () => { [{ kind: 'future-status' } as never, ''], ] for (const [reason, text] of reasons) { - expect(extractSessionEventText({ type: 'turn/end', seq: 0, time: 1, data: { turn: 1, reason } })).toBe(text) + expect(extractSessionEventText({ type: 'turn/end', seq: SessionSeq(0), time: 1, data: { turn: 1, reason } })).toBe(text) } const structural: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, - { type: 'step/end', seq: 2, time: 1, data: { turn: 1, step: 1 } }, - { type: 'assistant/chunk', seq: 3, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } }, - { type: 'request/header', seq: 4, time: 1, data: { header: { config: { provider: 'test', model: 'test' } }, reason: 'initial' } }, - { type: 'future/event', seq: 5, time: 1, data: { text: 'hidden' } } as never, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'step/start', seq: SessionSeq(1), time: 1, data: { turn: 1, step: 1 } }, + { type: 'step/end', seq: SessionSeq(2), time: 1, data: { turn: 1, step: 1 } }, + { type: 'assistant/chunk', seq: SessionSeq(3), time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } }, + { type: 'request/header', seq: SessionSeq(4), time: 1, data: { header: { config: { provider: 'test', model: 'test' } }, reason: 'initial' } }, + { type: 'future/event', seq: SessionSeq(5), time: 1, data: { text: 'hidden' } } as never, ] expect(structural.map(extractSessionEventText)).toEqual(['', '', '', '', '', '']) }) @@ -146,11 +147,11 @@ describe('session-query semantic extraction', () => { describe('session-query document and filter helpers', () => { const events: SessionEvent[] = [ - { type: 'user/message', seq: 0, time: 10, data: createUserMessage({ + { type: 'user/message', seq: SessionSeq(0), time: 10, data: createUserMessage({ content: [{ type: 'text', text: 'Hello\n(AI)+' }], source: { kind: 'user' }, }), surfaceOp: 'append' }, - { type: 'assistant/chunk', seq: 1, time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } }, - { type: 'assistant/message', seq: 2, time: 12, data: { + { type: 'assistant/chunk', seq: SessionSeq(1), time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } }, + { type: 'assistant/message', seq: SessionSeq(2), time: 12, data: { turn: 1, step: 1, message: createMessage({ role: 'assistant', @@ -160,8 +161,8 @@ describe('session-query document and filter helpers', () => { ...{ provider: 'mock', model: 'mock' }, }, }), - }, surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] }, - { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'interrupted' } } }, + }, surfaceOp: { op: 'replace', start: SessionSeq(0), end: SessionSeq(0) }, sourceEventSeqs: [SessionSeq(0)] }, + { type: 'turn/end', seq: SessionSeq(3), time: 13, data: { turn: 1, reason: { kind: 'interrupted' } } }, ] it('classifies every event and omits non-semantic documents', () => { @@ -229,7 +230,7 @@ describe('session-query document and filter helpers', () => { ])).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) const malformed: SessionEvent[] = [{ type: 'assistant/message', - seq: 0, + seq: SessionSeq(0), time: 1, data: { turn: 1, step: 1, @@ -242,7 +243,7 @@ describe('session-query document and filter helpers', () => { }, }), }, - surfaceOp: { op: 'replace', start: 9, end: 9 }, + surfaceOp: { op: 'replace', start: SessionSeq(9), end: SessionSeq(9) }, }] expect(() => buildSessionEventRecords(id, malformed)).toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) }) 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 77161b935f..f011df923d 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -1,10 +1,16 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { + SESSION_FORMAT_VERSION, + SessionId, + SessionLogOffset, + SessionSeq, +} from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence, { SessionPersistenceCorruptionError, SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' +import type { SessionEventSuffix, SessionInspection } from '@deepseek-ai/dsh-session-persistence' import SessionQueryEngine, { SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY, type SessionEventSurface, @@ -16,13 +22,13 @@ import { TestSessionQueryEngine } from './test-service.ts' const TITLE_SERVICE_CONFIG = { fallbackMaxWords: 8, fallbackMaxBytes: 64, maxTitleBytes: 256 } function header(id: string, createdAt = 1, extra: Partial = {}): SessionHeader { - return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra } + return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, isSeeded: false, ...extra } } -function eventLog(text = 'hello'): SessionEvent[] { +function eventLog(text = 'hello'): SessionEvent<'user/message'>[] { return [{ type: 'user/message', - seq: 0, + seq: SessionSeq(0), time: 10, data: createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' }, @@ -42,7 +48,7 @@ class TestPersistence extends SessionPersistence { static inspectOverride: (( id: SessionIdType, signal?: AbortSignal, - ) => Promise<{ meta: SessionHeader; events: SessionEvent[] }>) | undefined + ) => Promise) | undefined static afterList: (() => void) | undefined static listCalls = 0 static inspectCalls: SessionIdType[] = [] @@ -83,14 +89,14 @@ class TestPersistence extends SessionPersistence { return Promise.resolve() } - load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + load(id: SessionIdType): Promise { return this.inspect(id) } inspect( id: SessionIdType, signal?: AbortSignal, - ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + ): Promise { TestPersistence.inspectCalls.push(id) TestPersistence.inspectSignals.push(signal) if (TestPersistence.inspectOverride !== undefined) { @@ -99,15 +105,22 @@ class TestPersistence extends SessionPersistence { if (TestPersistence.inspectFailure !== undefined) return rejectUnknown(TestPersistence.inspectFailure) const entry = TestPersistence.entries.get(id) if (entry === undefined) return Promise.reject(new Error('missing test session')) - const result = structuredClone(entry) + const result: SessionInspection = { + ...structuredClone(entry), + inheritedEventCount: SessionLogOffset(0), + } TestPersistence.inspectEffect?.() TestPersistence.inspectEffect = undefined return Promise.resolve(result) } - async readFrom(id: SessionIdType, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + async readFrom( + id: SessionIdType, + fromSeq: SessionLogOffset, + signal?: AbortSignal, + ): Promise { const whole = await this.inspect(id, signal) - return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } + return { ...whole, fromSeq, events: whole.events.filter(event => event.seq >= fromSeq) } } list(signal?: AbortSignal): Promise { @@ -176,12 +189,12 @@ const cancellableExactReads: readonly CancellableExactRead[] = [ { name: 'traceEvent', inspects: true, - run: (ctx, sessionId, signal) => ctx.sessionQuery.traceEvent({ sessionId, seq: 0 }, signal), + run: (ctx, sessionId, signal) => ctx.sessionQuery.traceEvent({ sessionId, seq: SessionSeq(0) }, signal), }, { name: 'readEvent', inspects: true, - run: (ctx, sessionId, signal) => ctx.sessionQuery.readEvent({ sessionId, seq: 0 }, signal), + run: (ctx, sessionId, signal) => ctx.sessionQuery.readEvent({ sessionId, seq: SessionSeq(0) }, signal), }, ] as const @@ -344,7 +357,10 @@ describe.each(cancellableExactReads)('$name cancellation', ({ inspects, run }) = started.resolve(undefined) await release.promise active = false - return structuredClone(entry) + return { + ...structuredClone(entry), + inheritedEventCount: SessionLogOffset(0), + } } } else { TestPersistence.listOverride = async () => { @@ -432,7 +448,7 @@ describe('session-query exact reads', () => { const valid = header('valid-log', 2) const corrupt = header('corrupt-log', 1) const validEvents = eventLog('valid') - const corruptEvents = [{ ...eventLog('bad')[0]!, seq: 1 }] + const corruptEvents = [{ ...eventLog('bad')[0]!, seq: SessionSeq(1) }] TestPersistence.reset([ { meta: valid, events: validEvents }, { meta: corrupt, events: corruptEvents }, @@ -441,7 +457,11 @@ describe('session-query exact reads', () => { await ctx.plugin(TestPersistence) const snapshot = await ctx.sessionQuery.readSession(valid.id) - expect(snapshot).toEqual({ session: valid, events: validEvents }) + expect(snapshot).toEqual({ + session: valid, + inheritedEventCount: SessionLogOffset(0), + events: validEvents, + }) Object.assign(snapshot.events[0]!, { time: 999 }) expect(TestPersistence.entries.get(valid.id)?.events[0]?.time).toBe(10) await expect(ctx.sessionQuery.readSession(corrupt.id)).rejects.toThrow('seed event at index 0 has seq 1') @@ -471,11 +491,11 @@ describe('session-query exact reads', () => { meta: persistedHeader, events: [{ type: 'session/title', - seq: 0, + seq: SessionSeq(0), time: 20, data: { title: 'Persisted title', - messageSeqs: [4], + messageSeqs: [SessionSeq(4)], source: { kind: 'fallback' }, }, }], @@ -484,11 +504,11 @@ describe('session-query exact reads', () => { meta: sharedHeader, events: [{ type: 'session/title', - seq: 0, + seq: SessionSeq(0), time: 30, data: { title: 'Stale durable title', - messageSeqs: [1], + messageSeqs: [SessionSeq(1)], source: { kind: 'fallback' }, }, }], @@ -499,7 +519,7 @@ describe('session-query exact reads', () => { const shared = ctx.sessions.create(sharedHeader.id, { meta: { createdAt: 3 } }) shared.append('session/title', { title: 'Live title', - messageSeqs: [7], + messageSeqs: [SessionSeq(7)], source: { kind: 'provider', provider: SessionTitleProviderId('query-test'), @@ -521,7 +541,7 @@ describe('session-query exact reads', () => { const second = header('batch-title-second', 2) const titleEvent = (title: string, time: number): SessionEvent => ({ type: 'session/title', - seq: 0, + seq: SessionSeq(0), time, data: { title, @@ -574,7 +594,10 @@ describe('session-query exact reads', () => { active -= 1 const entry = TestPersistence.entries.get(id) if (entry === undefined) throw new Error('missing bounded test session') - return structuredClone(entry) + return { + ...structuredClone(entry), + inheritedEventCount: SessionLogOffset(0), + } } const results = await ctx.sessionQuery.readTitleSnapshots(entries.map(entry => entry.meta.id)) @@ -602,7 +625,7 @@ describe('session-query exact reads', () => { const marker = `full-log-marker:${id}` const titleEvent = { type: 'session/title', - seq: 1, + seq: SessionSeq(1), time: 20, data: { title: `Projected ${id}`, @@ -615,6 +638,7 @@ describe('session-query exact reads', () => { } as unknown as SessionEvent resolve({ meta: entries.find(entry => entry.meta.id === id)!.meta, + inheritedEventCount: SessionLogOffset(0), events: [...eventLog(marker), titleEvent], }) }) @@ -745,7 +769,7 @@ describe('session-query exact reads', () => { const inspectFailure = new Error('one title inspect failed') const malformedTitle = { type: 'session/title', - seq: 0, + seq: SessionSeq(0), time: 30, data: { title: 'malformed', @@ -772,7 +796,10 @@ describe('session-query exact reads', () => { source: { kind: 'fallback' }, }) } - return Promise.resolve(structuredClone(entry)) + return Promise.resolve({ + ...structuredClone(entry), + inheritedEventCount: SessionLogOffset(0), + }) } const results = await ctx.sessionQuery.readTitleSnapshots([ @@ -971,7 +998,10 @@ describe('session-query exact reads', () => { createUserMessage({ content: [{ type: 'text', text: 'latest checkpoint' }], source: { kind: 'plugin', plugin: 'compact' }, }), - { surfaceOp: { op: 'replace', start: 2, end: retained.seq }, sourceEventSeqs: [2, retained.seq] }, + { + surfaceOp: { op: 'replace', start: SessionSeq(2), end: retained.seq }, + sourceEventSeqs: [SessionSeq(2), retained.seq], + }, ) session.append( 'assistant/message', @@ -1002,7 +1032,7 @@ describe('session-query exact reads', () => { }).toThrow() Object.assign(snapshot.session, { cwd: '/mutated' }) - const logged = session.eventAt(4) + const logged = session.eventAt(SessionSeq(4)) expect(logged?.type === 'user/message' && logged.data.content).toHaveLength(1) expect(session.header.cwd).toBe('/work') }) @@ -1030,7 +1060,12 @@ describe('session-query exact reads', () => { ) } - const result = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 2, before: 1, after: 1 }) + const result = await ctx.sessionQuery.readEvent({ + sessionId: session.id, + seq: SessionSeq(2), + before: 1, + after: 1, + }) expect([result.startSeq, result.endSeq, result.target.seq]).toEqual([1, 3, 2]) expect(result.session).toEqual(session.header) Object.assign(result.session, { createdAt: -1 }) @@ -1039,15 +1074,15 @@ describe('session-query exact reads', () => { (result.events[0]!.data as { content: unknown[] }).content = [] }).toThrow() expect(session.header.createdAt).not.toBe(-1) - const logged = session.eventAt(1) + const logged = session.eventAt(SessionSeq(1)) expect(logged?.type === 'user/message' && logged.data.content).toHaveLength(1) - await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 9 })) + await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: SessionSeq(9) })) .rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) for (const request of [ - { sessionId: session.id, seq: 0, before: -1 }, - { sessionId: session.id, seq: 0, before: 2 }, - { sessionId: session.id, seq: 0, after: 0.5 }, + { sessionId: session.id, seq: SessionSeq(0), before: -1 }, + { sessionId: session.id, seq: SessionSeq(0), before: 2 }, + { sessionId: session.id, seq: SessionSeq(0), after: 0.5 }, ]) { await expect(ctx.sessionQuery.readEvent(request)).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW')) } @@ -1074,13 +1109,13 @@ describe('session-query exact reads', () => { expect((await ctx.sessionQuery.listSessions()).map(record => [record.header.id, record.live, record.persisted])) .toEqual([[shared.id, true, true], [durable.id, false, true]]) - const liveRead = await ctx.sessionQuery.readEvent({ sessionId: shared.id, seq: 1 }) + const liveRead = await ctx.sessionQuery.readEvent({ sessionId: shared.id, seq: SessionSeq(1) }) expect(liveRead.target.type === 'user/message' && liveRead.target.data.content[0]) .toMatchObject({ text: 'live' }) await expect(ctx.sessionQuery.readSurface(shared.id)).resolves.toMatchObject({ events: [{ data: { content: [{ text: 'live' }] } }], }) - await expect(ctx.sessionQuery.readEvent({ sessionId: durable.id, seq: 0 })) + await expect(ctx.sessionQuery.readEvent({ sessionId: durable.id, seq: SessionSeq(0) })) .resolves.toMatchObject({ session: durable }) await expect(ctx.sessionQuery.readSurface(durable.id)).resolves.toMatchObject({ session: durable, @@ -1116,9 +1151,9 @@ describe('session-query exact reads', () => { const signal = new AbortController().signal await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(2) - await expect(ctx.sessionQuery.traceEvent({ sessionId: live.id, seq: 1 }, signal)) + await expect(ctx.sessionQuery.traceEvent({ sessionId: live.id, seq: SessionSeq(1) }, signal)) .resolves.toMatchObject({ session: { id: live.id }, target: { seq: 1 } }) - await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 }, signal)) + await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: SessionSeq(1) }, signal)) .resolves.toMatchObject({ target: { seq: 1 } }) expect(TestPersistence.listSignals).toEqual([]) expect(TestPersistence.inspectSignals).toEqual([]) @@ -1175,7 +1210,7 @@ describe('session-query exact reads', () => { meta: persisted, events: [{ type: 'user/message', - seq: 0, + seq: SessionSeq(0), time: 1, data: createUserMessage({ content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' }, diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index d90328bbca..e40af94f68 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -1,10 +1,16 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { + SESSION_FORMAT_VERSION, + SessionId, + SessionLogOffset, + SessionSeq, +} from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Session, SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import type { SessionEventSuffix, SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' import { TestSessionQueryEngine } from './test-service.ts' @@ -16,10 +22,10 @@ function mutableHeader(value: SessionHeader): MutableSessionHeader { } function header(id: string, createdAt = 1, extra: Partial = {}): SessionHeader { - return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra } + return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, isSeeded: false, ...extra } } -function appendEvent(seq: number, sources?: number[]): SessionEvent { +function appendEvent(seq: SessionSeq, sources?: number[]): SessionEvent { return { type: 'user/message', seq, @@ -28,7 +34,7 @@ function appendEvent(seq: number, sources?: number[]): SessionEvent { content: [{ type: 'text', text: `event ${seq}` }], source: { kind: 'user' }, }), surfaceOp: 'append', - ...sources === undefined ? {} : { sourceEventSeqs: sources }, + ...sources === undefined ? {} : { sourceEventSeqs: sources.map(SessionSeq) }, } } @@ -71,21 +77,24 @@ class TracePersistence extends SessionPersistence { return Promise.resolve() } - load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + load(id: SessionIdType): Promise { return this.inspect(id) } - inspect(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + inspect(id: SessionIdType): Promise { TracePersistence.inspectCalls += 1 if (TracePersistence.inspectFailure !== undefined) return Promise.reject(TracePersistence.inspectFailure) const entry = TracePersistence.entries.get(id) if (entry === undefined) return Promise.reject(new Error('missing test session')) - return Promise.resolve(structuredClone(entry)) + return Promise.resolve({ + ...structuredClone(entry), + inheritedEventCount: SessionLogOffset(0), + }) } - async readFrom(id: SessionIdType, fromSeq: number): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + async readFrom(id: SessionIdType, fromSeq: SessionLogOffset): Promise { const whole = await this.inspect(id) - return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } + return { ...whole, fromSeq, events: whole.events.filter(event => event.seq >= fromSeq) } } list(): Promise { @@ -126,7 +135,7 @@ function appendTraceEvents(session: Session): void { createUserMessage({ content: [{ type: 'text', text: 'original' }], source: { kind: 'user' }, }), - { surfaceOp: 'append', sourceEventSeqs: [2] }, + { surfaceOp: 'append', sourceEventSeqs: [SessionSeq(2)] }, ) session.append( 'assistant/message', @@ -141,7 +150,10 @@ function appendTraceEvents(session: Session): void { }, }), }, - { surfaceOp: { op: 'replace', start: 3, end: 3 }, sourceEventSeqs: [3, 2] }, + { + surfaceOp: { op: 'replace', start: SessionSeq(3), end: SessionSeq(3) }, + sourceEventSeqs: [SessionSeq(3), SessionSeq(2)], + }, ) session.append( 'user/message', @@ -165,7 +177,10 @@ function appendTraceEvents(session: Session): void { }, }), }, - { surfaceOp: { op: 'replace', start: 4, end: 4 }, sourceEventSeqs: [2, 4] }, + { + surfaceOp: { op: 'replace', start: SessionSeq(4), end: SessionSeq(4) }, + sourceEventSeqs: [SessionSeq(2), SessionSeq(4)], + }, ) } @@ -244,7 +259,7 @@ describe('session lineage tracing', () => { it('uses one cross-corpus observation and preserves persistence failure semantics', async () => { const durable = header('durable') - TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }]) + TracePersistence.reset([{ meta: durable, events: [appendEvent(SessionSeq(0))] }]) const ctx = await queryContext() await ctx.plugin(TracePersistence) @@ -288,7 +303,7 @@ describe('session event tracing', () => { const session = ctx.sessions.create(SessionId('trace')) appendTraceEvents(session) - const original = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 3 }) + const original = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: SessionSeq(3) }) expect(original.target).toMatchObject({ sessionId: session.id, seq: 3, @@ -302,7 +317,7 @@ describe('session event tracing', () => { sourceEventSeqs: [2], derivedEventSeqs: [4], }) - await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 })) + await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: SessionSeq(4) })) .resolves.toMatchObject({ replacedBy: 8, replacementChain: [8], @@ -310,14 +325,14 @@ describe('session event tracing', () => { sourceEventSeqs: [3, 2], derivedEventSeqs: [8], }) - await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 })) + await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: SessionSeq(2) })) .resolves.toMatchObject({ target: { surface: 'log-only' }, replacementChain: [], sourceEventSeqs: [], derivedEventSeqs: [3, 4, 8], }) - await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 8 })) + await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: SessionSeq(8) })) .resolves.toMatchObject({ replacementChain: [], replacedEventSeqs: [4], @@ -331,13 +346,13 @@ describe('session event tracing', () => { const session = ctx.sessions.create(SessionId('detached')) appendTraceEvents(session) - const first = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 }) + const first = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: SessionSeq(4) }) first.target.time = -1 - first.replacementChain.push(99) - first.replacedEventSeqs.push(99) - first.sourceEventSeqs.push(99) - first.derivedEventSeqs.push(99) - const repeated = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 }) + first.replacementChain.push(SessionSeq(99)) + first.replacedEventSeqs.push(SessionSeq(99)) + first.sourceEventSeqs.push(SessionSeq(99)) + first.derivedEventSeqs.push(SessionSeq(99)) + const repeated = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: SessionSeq(4) }) expect(repeated.target.time).not.toBe(-1) expect(repeated.replacementChain).toEqual([8]) expect(repeated.replacedEventSeqs).toEqual([3]) @@ -347,11 +362,11 @@ describe('session event tracing', () => { it('inspects persisted logs once, prefers live logs, and preserves failures and conflicts', async () => { const durable = header('shared', 1, { cwd: '/same' }) - TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }]) + TracePersistence.reset([{ meta: durable, events: [appendEvent(SessionSeq(0))] }]) const ctx = await queryContext() await ctx.plugin(TracePersistence) - await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: SessionSeq(0) })) .resolves.toMatchObject({ target: { type: 'user/message', surface: 'current' } }) expect([TracePersistence.listCalls, TracePersistence.inspectCalls]).toEqual([1, 1]) @@ -366,33 +381,33 @@ describe('session event tracing', () => { ) TracePersistence.listFailure = new Error('list unavailable') TracePersistence.inspectFailure = new Error('inspect unavailable') - await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 1 })) + await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: SessionSeq(1) })) .resolves.toMatchObject({ target: { type: 'user/message' } }) expect([TracePersistence.listCalls, TracePersistence.inspectCalls]).toEqual([1, 1]) - TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }]) + TracePersistence.reset([{ meta: durable, events: [appendEvent(SessionSeq(0))] }]) const failedCtx = await queryContext() await failedCtx.plugin(TracePersistence) TracePersistence.listFailure = new Error('list unavailable') - await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: SessionSeq(0) })) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) TracePersistence.listFailure = undefined TracePersistence.inspectFailure = new Error('inspect unavailable') - await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: SessionSeq(0) })) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) TracePersistence.inspectFailure = undefined TracePersistence.afterList = () => { mutableHeader(TracePersistence.entries.get(durable.id)!.meta).cwd = '/changed' } - await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: SessionSeq(0) })) .rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) }) it('checks target existence before surface or source-event analysis', async () => { const bad = header('bad-target') - const malformed: SessionEvent[] = [appendEvent(0), { + const malformed: SessionEvent[] = [appendEvent(SessionSeq(0)), { type: 'assistant/message', - seq: 1, + seq: SessionSeq(1), time: 2, data: { turn: 1, step: 1, @@ -405,16 +420,16 @@ describe('session event tracing', () => { }, }), }, - surfaceOp: { op: 'replace', start: 9, end: 9 }, + surfaceOp: { op: 'replace', start: SessionSeq(9), end: SessionSeq(9) }, sourceEventSeqs: [], }] TracePersistence.reset([{ meta: bad, events: malformed }]) const ctx = await queryContext() await ctx.plugin(TracePersistence) - await expect(ctx.sessionQuery.traceEvent({ sessionId: bad.id, seq: 9 })) + await expect(ctx.sessionQuery.traceEvent({ sessionId: bad.id, seq: SessionSeq(9) })) .rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) - await expect(ctx.sessionQuery.traceEvent({ sessionId: bad.id, seq: 0 })) + await expect(ctx.sessionQuery.traceEvent({ sessionId: bad.id, seq: SessionSeq(0) })) .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) }) @@ -423,34 +438,34 @@ describe('session event tracing', () => { { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 }, sourceEventSeqs: [0] }, ]], ['invalid source array', [ - { ...appendEvent(0), sourceEventSeqs: 'invalid' }, + { ...appendEvent(SessionSeq(0)), sourceEventSeqs: 'invalid' }, ]], ['empty sources', [ - appendEvent(0, []), + appendEvent(SessionSeq(0), []), ]], ['sparse sources', [ - appendEvent(0, Array(1)), + appendEvent(SessionSeq(0), Array(1)), ]], ['duplicate sources', [ - appendEvent(0), - appendEvent(1, [0, 0]), + appendEvent(SessionSeq(0)), + appendEvent(SessionSeq(1), [0, 0]), ]], ['missing earlier source', [ - appendEvent(0), - appendEvent(1, [-1]), + appendEvent(SessionSeq(0)), + { ...appendEvent(SessionSeq(1)), sourceEventSeqs: [-1] } as unknown as SessionEvent, ]], ['future source', [ - appendEvent(0, [1]), - appendEvent(1), + appendEvent(SessionSeq(0), [1]), + appendEvent(SessionSeq(1)), ]], ['replacement without sources', [ - appendEvent(0), - { ...appendEvent(1), surfaceOp: { op: 'replace', start: 0, end: 0 } }, + appendEvent(SessionSeq(0)), + { ...appendEvent(SessionSeq(1)), surfaceOp: { op: 'replace', start: 0, end: 0 } }, ]], ['replacement missing a shadowed source', [ { type: 'assistant/chunk', seq: 0, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'draft' } } }, - appendEvent(1), - { ...appendEvent(2, [0]), surfaceOp: { op: 'replace', start: 1, end: 1 } }, + appendEvent(SessionSeq(1)), + { ...appendEvent(SessionSeq(2), [0]), surfaceOp: { op: 'replace', start: 1, end: 1 } }, ]], ] as const)('rejects an invalid surface log: %s', async (_name, rawEvents) => { const durable = header('invalid-provenance') @@ -459,7 +474,7 @@ describe('session event tracing', () => { const ctx = await queryContext() await ctx.plugin(TracePersistence) - await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: SessionSeq(0) })) .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) }) @@ -476,13 +491,13 @@ describe('session event tracing', () => { const ctx = await queryContext() await ctx.plugin(TracePersistence) - await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: SessionSeq(0) })) .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) }) it('applies the same surface contract to listEvents', async () => { const durable = header('list-regression') - TracePersistence.reset([{ meta: durable, events: [appendEvent(0), appendEvent(1, [0, 0])] }]) + TracePersistence.reset([{ meta: durable, events: [appendEvent(SessionSeq(0)), appendEvent(SessionSeq(1), [0, 0])] }]) const ctx = await queryContext() await ctx.plugin(TracePersistence) diff --git a/packages/session-query/tool-session-query/src/operations.ts b/packages/session-query/tool-session-query/src/operations.ts index 88107783cb..3d5fa17f31 100644 --- a/packages/session-query/tool-session-query/src/operations.ts +++ b/packages/session-query/tool-session-query/src/operations.ts @@ -6,6 +6,7 @@ import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' +import { SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' import { SessionQueryError, @@ -205,11 +206,12 @@ async function executeEventTrace( exec: ToolRunContext, ): Promise { toolInput.assertNonNegativeSafeInteger('seq', args.seq) + const seq = SessionSeq(args.seq) const caller = workspaceAccess.callerOf(exec, ctx) const sessionId = workspaceAccess.targetId(args, caller) await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) const trace = await serviceBoundary.call(ctx, exec.signal, 'event trace', () => - ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq }, exec.signal)) + ctx.sessionQuery.traceEvent({ sessionId, seq }, exec.signal)) workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.session) const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) return presentation.formatEventTrace(sessionId, title, trace) @@ -221,6 +223,7 @@ async function executeEventRead( exec: ToolRunContext, ): Promise { toolInput.assertNonNegativeSafeInteger('seq', args.seq) + const seq = SessionSeq(args.seq) if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before) if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after) const caller = workspaceAccess.callerOf(exec, ctx) @@ -229,7 +232,7 @@ async function executeEventRead( const window = await serviceBoundary.call(ctx, exec.signal, 'event read', () => ctx.sessionQuery.readEvent({ sessionId, - seq: args.seq, + seq, ...args.before === undefined ? {} : { before: args.before }, ...args.after === undefined ? {} : { after: args.after }, }, exec.signal)) diff --git a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts index 821b94c323..5e60ac5118 100644 --- a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts +++ b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts @@ -8,6 +8,7 @@ import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, + SessionSeq, type Session, } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -57,10 +58,11 @@ describe('tool-session-query with the real SQLite provider', () => { id: persisted, createdAt: 1, cwd: '/work', + isSeeded: false, }) await ctx.sessionPersistence.append(persisted, [{ type: 'user/message', - seq: 0, + seq: SessionSeq(0), time: 2, data: createUserMessage({ content: [{ type: 'text', text: 'persisted integration needle' }], @@ -129,11 +131,12 @@ describe('tool-session-query with the real SQLite provider', () => { id: persisted, createdAt: base, cwd: '/work', + isSeeded: false, }) await ctx.sessionPersistence.append(persisted, [ { type: 'user/message', - seq: 0, + seq: SessionSeq(0), time: base + 123, data: createUserMessage({ content: [{ type: 'text', text: 'fractional integration needle' }], @@ -143,7 +146,7 @@ describe('tool-session-query with the real SQLite provider', () => { }, { type: 'user/message', - seq: 1, + seq: SessionSeq(1), time: base + 124, data: createUserMessage({ content: [{ type: 'text', text: 'fractional integration needle' }], @@ -153,7 +156,7 @@ describe('tool-session-query with the real SQLite provider', () => { }, { type: 'user/message', - seq: 2, + seq: SessionSeq(2), time: -124, data: createUserMessage({ content: [{ type: 'text', text: 'pre-epoch fractional needle' }], @@ -163,7 +166,7 @@ describe('tool-session-query with the real SQLite provider', () => { }, { type: 'user/message', - seq: 3, + seq: SessionSeq(3), time: -123, data: createUserMessage({ content: [{ type: 'text', text: 'pre-epoch fractional needle' }], 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 eaa4ce7cfe..f79c5a1b2e 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 @@ -7,6 +7,7 @@ import * as TimeoutPolicy from '@deepseek-ai/dsh-tool-call-timeout-policy' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, + SessionSeq, type Session, type SessionEvent, type SessionHeader, @@ -45,6 +46,7 @@ function header(id: string, cwd: string | undefined, createdAt = 1, parentSessio version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, + isSeeded: false, ...cwd === undefined ? {} : { cwd }, ...parentSession === undefined ? {} : { parentSession }, } @@ -94,7 +96,7 @@ function sessionHit( persisted: false, bestMatch: { sessionId: SessionId(id), - seq: 4, + seq: SessionSeq(4), type: 'assistant/message', time: 200, surface: 'current', @@ -103,7 +105,7 @@ function sessionHit( } } -function eventHit(sessionId: SessionIdValue, seq: number, text = 'needle excerpt'): SessionEventSearchHit { +function eventHit(sessionId: SessionIdValue, seq: SessionSeq, text = 'needle excerpt'): SessionEventSearchHit { return { sessionId, seq, @@ -182,7 +184,7 @@ class FakeQuery extends SessionQueryEngine { title: value, messageSeqs: [], source: { kind: 'fallback' }, - eventSeq: 0, + eventSeq: SessionSeq(0), updatedAt: 1, }, }, @@ -1154,7 +1156,7 @@ describe('workspace authority and lineage redaction', () => { FakeQuery.eventSearch = () => Promise.resolve({ session: movedHeader, - items: [eventHit(target.id, 0, 'secret event hit')], + items: [eventHit(target.id, SessionSeq(0), 'secret event hit')], }) const search = await mounted.call('session_event_search', { session_id: target.id, @@ -1171,7 +1173,7 @@ describe('workspace authority and lineage redaction', () => { expect(errorCode(await mounted.call('session_trace', { session_id: target.id }))) .toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') - const eventTrace = await mounted.ctx.sessionQuery.traceEvent({ sessionId: target.id, seq: 0 }) + const eventTrace = await mounted.ctx.sessionQuery.traceEvent({ sessionId: target.id, seq: SessionSeq(0) }) vi.spyOn(mounted.ctx.sessionQuery, 'traceEvent').mockResolvedValueOnce({ ...eventTrace, session: movedHeader, @@ -1179,7 +1181,7 @@ describe('workspace authority and lineage redaction', () => { expect(errorCode(await mounted.call('session_event_trace', { session_id: target.id, seq: 0 }))) .toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') - const eventWindow = await mounted.ctx.sessionQuery.readEvent({ sessionId: target.id, seq: 0 }) + const eventWindow = await mounted.ctx.sessionQuery.readEvent({ sessionId: target.id, seq: SessionSeq(0) }) vi.spyOn(mounted.ctx.sessionQuery, 'readEvent').mockResolvedValueOnce({ ...eventWindow, session: movedHeader, @@ -1199,7 +1201,7 @@ describe('workspace authority and lineage redaction', () => { title: 'secret moved title', messageSeqs: [], source: { kind: 'fallback' }, - eventSeq: 0, + eventSeq: SessionSeq(0), updatedAt: 1, }, }, @@ -1583,7 +1585,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => const mounted = await mount() FakeQuery.eventSearch = request => Promise.resolve({ session: header(request.sessionId, '/work'), - items: [eventHit(request.sessionId, 1)], + items: [eventHit(request.sessionId, SessionSeq(1))], }) await mounted.call('session_event_search', { query: 'prior', @@ -1628,12 +1630,12 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => FakeQuery.eventSearch = request => request.cursor === undefined ? Promise.resolve({ session: header(other.id, '/work'), - items: [eventHit(other.id, 1)], + items: [eventHit(other.id, SessionSeq(1))], nextCursor: cursor, }) : Promise.resolve({ session: header(other.id, '/work'), - items: [eventHit(other.id, 2), eventHit(other.id, 3)], + items: [eventHit(other.id, SessionSeq(2)), eventHit(other.id, SessionSeq(3))], }) const result = await mounted.call('session_event_search', { session_id: other.id, @@ -2003,7 +2005,10 @@ describe('trace and exact read rendering', () => { }, }), }, - { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] }, + { + surfaceOp: { op: 'replace', start: SessionSeq(0), end: SessionSeq(0) }, + sourceEventSeqs: [SessionSeq(0)], + }, ) const result = await mounted.call('session_event_trace', { session_id: session.id, seq: 0 }) expect(text(result)).toContain('Replacement chain: 1') diff --git a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index 64b1c3cf3c..670d410ee3 100644 --- a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -4,8 +4,8 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import LlmRuntime, { ToolCallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' -import SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import type { SessionEvent, SessionHeader, SessionLogOffset } from '@deepseek-ai/dsh-session' +import SessionPersistence, { type SessionEventSuffix, type SessionInspection } from '@deepseek-ai/dsh-session-persistence' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import * as checkpointPolicy from '../src/index.ts' @@ -18,16 +18,16 @@ class TestPersistence extends SessionPersistence { locate(_meta: SessionHeader): undefined { return undefined } create(_meta: SessionHeader): Promise { return Promise.resolve() } append(_id: SessionId, _events: readonly SessionEvent[]): Promise { return Promise.resolve() } - load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + load(_id: SessionId): Promise { return Promise.reject(new Error('not used')) } - inspect(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + inspect(_id: SessionId): Promise { return Promise.reject(new Error('not used')) } borrowSession(_id: SessionId, _signal?: AbortSignal): ReturnType { return Promise.reject(new Error('not used')) } - readFrom(_id: SessionId, _fromSeq: number): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + readFrom(_id: SessionId, _fromSeq: SessionLogOffset): Promise { return Promise.reject(new Error('not used')) } list(): Promise { return Promise.resolve([]) } diff --git a/packages/session/session-log-deepseek/README.i18n.yaml b/packages/session/session-log-deepseek/README.i18n.yaml index 444c646041..06cd142858 100644 --- a/packages/session/session-log-deepseek/README.i18n.yaml +++ b/packages/session/session-log-deepseek/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-log-deepseek/README.md -README.md: e76a4ba7d48deafb13f5dfcc1e7ea6532a4452d7 -README.zh.md: f2bf91418430fee960350fa0feb6053704dad543 +README.md: b08b2d3cde78e7a3cf7050e90ff57655ad70d409 +README.zh.md: 0ee216c6c69178695eb629b2a81faef73103b13d diff --git a/packages/session/session-log-deepseek/README.md b/packages/session/session-log-deepseek/README.md index e76a4ba7d4..b08b2d3cde 100644 --- a/packages/session/session-log-deepseek/README.md +++ b/packages/session/session-log-deepseek/README.md @@ -34,7 +34,7 @@ Shipped profiles mount the plugin so an overlay can enable it, but the default c ## Request field -For a request carrying a live `sessionId`, the plugin folds the greatest accepted watermark for that exact Session identity, snapshots `Session.events`, and sends the contiguous suffix after the watermark. A process-local fold scans each event once and consumes later appends incrementally; restart and HMR rebuild it from the durable log. The version-1 field contains the immutable `SessionHeader`, `afterSeq`, `throughSeq`, and every complete canonical `SessionEvent` in that range as a direct array element. Forked sessions ignore inherited parent watermarks because each watermark records the Session id sent on the accepted request. +For a request carrying a live `sessionId`, the plugin folds the greatest accepted watermark for that exact Session identity, snapshots `Session.events`, and sends the contiguous suffix after the watermark. A process-local fold scans each event once and consumes later appends incrementally; restart and HMR rebuild it from the durable log. The version-1 field contains a v0-compatible raw session header (`seedLength` is present only for a seeded Session), numeric `afterSeq` and `throughSeq`, and every complete canonical event translated to raw-number envelope fields. Forked sessions ignore inherited parent watermarks because each watermark records the Session id sent on the accepted request. ## Acceptance and retry diff --git a/packages/session/session-log-deepseek/README.zh.md b/packages/session/session-log-deepseek/README.zh.md index f2bf914184..0ee216c6c6 100644 --- a/packages/session/session-log-deepseek/README.zh.md +++ b/packages/session/session-log-deepseek/README.zh.md @@ -34,7 +34,7 @@ kind: "package-reference" ## 请求字段 -对于携带存活 `sessionId` 的请求,插件会折叠该确切会话身份的最大已接受水位,对 `Session.events` 取快照,并发送水位之后的连续后缀。进程内 fold 会让每条事件只被扫描一次并增量消费后续追加;重启与 HMR 会从持久日志重建它。版本 1 字段包含不可变的 `SessionHeader`、`afterSeq`、`throughSeq`,以及作为数组直接元素的该范围内每个完整权威 `SessionEvent`。每个水位都会记录已接受请求发送的会话 id,因此 fork 会话会忽略从父会话继承的水位。 +对于携带存活 `sessionId` 的请求,插件会折叠该确切会话身份的最大已接受水位,对 `Session.events` 取快照,并发送水位之后的连续后缀。进程内 fold 会让每条事件只被扫描一次并增量消费后续追加;重启与 HMR 会从持久日志重建它。版本 1 字段包含兼容 v0 的原始会话 header(仅 seeded Session 携带 `seedLength`)、数值型 `afterSeq` 与 `throughSeq`,以及把完整权威事件翻译为原始数值 envelope 字段后的数组元素。每个水位都会记录已接受请求发送的会话 id,因此 fork 会话会忽略从父会话继承的水位。 ## 接受与重试 diff --git a/packages/session/session-log-deepseek/package.json b/packages/session/session-log-deepseek/package.json index b557ba69f1..4746305509 100644 --- a/packages/session/session-log-deepseek/package.json +++ b/packages/session/session-log-deepseek/package.json @@ -38,6 +38,7 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-util-values": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { diff --git a/packages/session/session-log-deepseek/src/index.ts b/packages/session/session-log-deepseek/src/index.ts index b56e0225a1..5fde932581 100644 --- a/packages/session/session-log-deepseek/src/index.ts +++ b/packages/session/session-log-deepseek/src/index.ts @@ -9,8 +9,21 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { brandString } from '@deepseek-ai/dsh-brand' import type {} from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import type { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { DeepSeekSessionLogExtension } from './types.ts' +import { isSurfaceEvent, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' +import type { + Session, + SessionEvent, + SessionId, + SessionLogOffset as SessionLogOffsetType, + SessionSeq as SessionSeqType, + SessionSeqCursor, +} from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import type { + DeepSeekSessionLogExtension, + DeepSeekSessionLogWireEvent, + DeepSeekSessionLogWireHeader, +} from './types.ts' export type * from './types.ts' @@ -31,35 +44,77 @@ export const Config: z = z.object({ }) interface AcceptanceFold { - readonly scannedEvents: number - readonly throughSeq: number + readonly scannedEvents: SessionLogOffsetType + readonly throughSeq: SessionSeqCursor } const acceptanceFolds = new WeakMap() +/** Translate logical Session metadata back to the stable version-0 wire header. */ +function wireHeader(session: Session): DeepSeekSessionLogWireHeader { + const header = session.header + return { + version: header.version, + id: String(header.id), + createdAt: header.createdAt, + ...header.cwd === undefined ? {} : { cwd: header.cwd }, + ...header.parentSession === undefined ? {} : { parentSession: String(header.parentSession) }, + ...header.isSeeded ? { seedLength: Number(session.inheritedEventCount) } : {}, + ...header.origin === undefined ? {} : { origin: header.origin }, + ...header.delegationDepth === undefined ? {} : { delegationDepth: header.delegationDepth }, + ...header.agentPreset === undefined ? {} : { agentPreset: header.agentPreset }, + } +} + +/** Translate compile-time sequence brands to raw numeric request fields. */ +function wireEvent(event: SessionEvent): DeepSeekSessionLogWireEvent { + const surfaceEvent = isSurfaceEvent(event) ? event : undefined + const surfaceOp = surfaceEvent?.surfaceOp + return { + type: event.type, + seq: Number(event.seq), + time: event.time, + data: event.data as JsonValue, + ...event.ignorable === undefined ? {} : { ignorable: event.ignorable }, + ...surfaceEvent?.sourceEventSeqs === undefined + ? {} + : { sourceEventSeqs: surfaceEvent.sourceEventSeqs.map(Number) }, + ...surfaceOp === undefined + ? {} + : surfaceOp === 'append' + ? { surfaceOp } + : { surfaceOp: { op: 'replace' as const, start: Number(surfaceOp.start), end: Number(surfaceOp.end) } }, + } +} + /** * Highest confirmed sequence for this exact session identity. * @param session - canonical log whose matching acceptance events are folded. * @returns greatest accepted sequence, or `-1` before any accepted request. */ -export function acceptedThrough(session: Session): number { +export function acceptedThrough(session: Session): SessionSeqCursor { const previous = acceptanceFolds.get(session) let throughSeq = previous?.throughSeq ?? -1 const length = session.seq - const start = previous?.scannedEvents ?? 0 + const start = previous?.scannedEvents ?? SessionLogOffset(0) for (let index = start; index < length; index++) { - const event = session.eventAt(index) + const event = session.eventAt(SessionSeq(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 + let acceptedSeq: SessionSeqType + try { + acceptedSeq = SessionSeq(event.data.throughSeq) + } catch { + throw new Error(`session-log-deepseek: malformed acceptance watermark at seq ${event.seq}`) + } if (typeof event.data.sessionId !== 'string' || event.data.sessionId.length === 0 - || !Number.isSafeInteger(event.data.throughSeq) || event.data.throughSeq < 0 - || event.data.throughSeq >= event.seq) { + || acceptedSeq >= event.seq) { throw new Error(`session-log-deepseek: malformed acceptance watermark at seq ${event.seq}`) } if (event.data.sessionId !== session.id) continue - throughSeq = Math.max(throughSeq, event.data.throughSeq) + if (acceptedSeq > throughSeq) throughSeq = acceptedSeq } acceptanceFolds.set(session, { scannedEvents: length, throughSeq }) return throughSeq @@ -80,15 +135,16 @@ export function apply(ctx: Context, config: Config): void { if (session === undefined) return undefined const afterSeq = acceptedThrough(session) - const throughSeq = session.seq - 1 - if (throughSeq < 0) return undefined - const suffix = session.snapshotEvents(afterSeq + 1) + const snapshot = session.snapshotEvents() + const throughSeq = snapshot.at(-1)?.seq + if (throughSeq === undefined) return undefined + const suffix = session.snapshotEvents(SessionLogOffset(afterSeq + 1)) const value: DeepSeekSessionLogExtension = { version: 1, - session: session.header, - afterSeq, - throughSeq, - events: suffix, + session: wireHeader(session), + afterSeq: Number(afterSeq), + throughSeq: Number(throughSeq), + events: suffix.map(wireEvent), } return { value, diff --git a/packages/session/session-log-deepseek/src/invariant.ts b/packages/session/session-log-deepseek/src/invariant.ts index b7c2c848f9..66a309322b 100644 --- a/packages/session/session-log-deepseek/src/invariant.ts +++ b/packages/session/session-log-deepseek/src/invariant.ts @@ -1,6 +1,7 @@ /** Package-owned invariants for DeepSeek session-log acceptance watermarks. */ import type { Context } from '@deepseek-ai/cordis' +import { SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type {} from './types.ts' @@ -16,12 +17,17 @@ export const inject = ['invariants'] function validateDeliveryAccepted(session: Session, event: SessionEvent<'session-log-deepseek/delivery-accepted'>, fail: InvariantFailure): void { const { sessionId, throughSeq } = event.data const inherited = session.header.parentSession !== undefined - && session.header.seedLength !== undefined - && event.seq < session.header.seedLength + && !session.isOwnSeq(event.seq) if (sessionId !== session.id && !inherited) { fail('a non-inherited session-log-deepseek/delivery-accepted event must name its containing session') } - if (!Number.isSafeInteger(throughSeq) || throughSeq < 0 || throughSeq >= event.seq) { + let acceptedSeq: ReturnType + try { + acceptedSeq = SessionSeq(throughSeq) + } catch { + fail(`session-log-deepseek/delivery-accepted throughSeq must identify an earlier event, got ${throughSeq} at seq ${event.seq}`) + } + if (acceptedSeq >= event.seq) { fail(`session-log-deepseek/delivery-accepted throughSeq must identify an earlier event, got ${throughSeq} at seq ${event.seq}`) } } diff --git a/packages/session/session-log-deepseek/src/types.ts b/packages/session/session-log-deepseek/src/types.ts index d3281bf327..3205880a44 100644 --- a/packages/session/session-log-deepseek/src/types.ts +++ b/packages/session/session-log-deepseek/src/types.ts @@ -1,17 +1,48 @@ /** Wire types for lossless incremental DeepSeek session-log upload. */ -import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' + +/** Version-0 Session header fields serialized on the external request wire. */ +export interface DeepSeekSessionLogWireHeader { + readonly version: number + readonly id: string + readonly createdAt: number + readonly cwd?: string + readonly parentSession?: string + /** Exact inherited prefix length; absent for an unseeded Session. */ + readonly seedLength?: number + readonly origin?: 'subagent' + readonly delegationDepth?: number + readonly agentPreset?: string +} + +/** Raw-number surface mutation serialized on the external request wire. */ +export type DeepSeekSessionLogWireSurfaceOp = + | 'append' + | { readonly op: 'replace'; readonly start: number; readonly end: number } + +/** One complete canonical event translated to raw JSON primitives for upload. */ +export interface DeepSeekSessionLogWireEvent { + readonly type: SessionEvent['type'] + readonly seq: number + readonly time: number + readonly data: JsonValue + readonly ignorable?: true + readonly sourceEventSeqs?: readonly number[] + readonly surfaceOp?: DeepSeekSessionLogWireSurfaceOp +} /** Versioned incremental session-log field carried by an official DeepSeek request. */ export interface DeepSeekSessionLogExtension { readonly version: 1 - readonly session: SessionHeader + readonly session: DeepSeekSessionLogWireHeader /** Highest sequence durably recorded as accepted before this request, or `-1`. */ readonly afterSeq: number /** Highest sequence represented by {@link events}. */ readonly throughSeq: number /** Complete canonical event envelopes for every sequence from `afterSeq + 1` through `throughSeq`. */ - readonly events: readonly SessionEvent[] + readonly events: readonly DeepSeekSessionLogWireEvent[] } declare module '@deepseek-ai/dsh-deepseek-llm-api-extensions/types' { @@ -27,7 +58,7 @@ declare module '@deepseek-ai/dsh-session/types' { /** Session identity the accepted delivery carried; inherited fork markers retain the parent's id. */ sessionId: import('@deepseek-ai/dsh-session/types').SessionId /** Last canonical event included in the accepted request. */ - throughSeq: number + throughSeq: import('@deepseek-ai/dsh-session/types').SessionSeq } } } diff --git a/packages/session/session-log-deepseek/tests/invariant.spec.ts b/packages/session/session-log-deepseek/tests/invariant.spec.ts index f39c4aec45..e9f2f9d3d7 100644 --- a/packages/session/session-log-deepseek/tests/invariant.spec.ts +++ b/packages/session/session-log-deepseek/tests/invariant.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import InvariantRegistry, { InvariantError } from '@deepseek-ai/dsh-invariants' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import * as SessionLogInvariant from '../src/invariant.ts' import type {} from '../src/types.ts' @@ -25,7 +25,7 @@ describe('DeepSeek session-log acceptance invariant', () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('valid')) session.append('turn/start', { turn: 1 }) - expect(() => session.append('session-log-deepseek/delivery-accepted', { sessionId: session.id, throughSeq: 0 })) + expect(() => session.append('session-log-deepseek/delivery-accepted', { sessionId: session.id, throughSeq: SessionSeq(0) })) .not.toThrow() }) @@ -35,7 +35,7 @@ describe('DeepSeek session-log acceptance invariant', () => { wrongId.append('turn/start', { turn: 1 }) expect(() => wrongId.append('session-log-deepseek/delivery-accepted', { sessionId: SessionId('other'), - throughSeq: 0, + throughSeq: SessionSeq(0), })).toThrow(expect.objectContaining>({ code: 'INVARIANT', packageName: '@deepseek-ai/dsh-session-log-deepseek', @@ -45,7 +45,14 @@ describe('DeepSeek session-log acceptance invariant', () => { wrongSeq.append('turn/start', { turn: 1 }) expect(() => wrongSeq.append('session-log-deepseek/delivery-accepted', { sessionId: wrongSeq.id, - throughSeq: 1, + throughSeq: SessionSeq(1), + })).toThrow(expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-session-log-deepseek', + })) + expect(() => wrongSeq.append('session-log-deepseek/delivery-accepted', { + sessionId: wrongSeq.id, + throughSeq: -1 as never, })).toThrow(expect.objectContaining>({ code: 'INVARIANT', packageName: '@deepseek-ai/dsh-session-log-deepseek', @@ -59,8 +66,13 @@ describe('DeepSeek session-log acceptance invariant', () => { await ctx.plugin(InvariantRegistry, { enabled: true }) const id = SessionId('late-invalid') ctx.sessions.create(id, { seed: [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2, data: { sessionId: id, throughSeq: 1 } }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { + type: 'session-log-deepseek/delivery-accepted', + seq: SessionSeq(1), + time: 2, + data: { sessionId: id, throughSeq: SessionSeq(1) }, + }, ] }) let failure: unknown @@ -84,10 +96,16 @@ describe('DeepSeek session-log acceptance invariant', () => { const childId = SessionId('fork-child') ctx.sessions.create(childId, { seed: [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2, data: { sessionId: parentId, throughSeq: 0 } }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { + type: 'session-log-deepseek/delivery-accepted', + seq: SessionSeq(1), + time: 2, + data: { sessionId: parentId, throughSeq: SessionSeq(0) }, + }, ], - meta: { parentSession: parentId, seedLength: 2 }, + inheritedEventCount: SessionLogOffset(2), + meta: { parentSession: parentId, isSeeded: true }, }) await expect(ctx.plugin(SessionLogInvariant)).resolves.toBeDefined() diff --git a/packages/session/session-log-deepseek/tests/upload.spec.ts b/packages/session/session-log-deepseek/tests/upload.spec.ts index 38526420e0..a3663867f1 100644 --- a/packages/session/session-log-deepseek/tests/upload.spec.ts +++ b/packages/session/session-log-deepseek/tests/upload.spec.ts @@ -1,8 +1,11 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, expectTypeOf, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import SessionStore, { Session, SessionId, type CreateSessionOptions, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, SessionLogOffset, SessionSeq, type CreateSessionOptions, type SessionEvent } from '@deepseek-ai/dsh-session' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' import * as SessionLogDeepSeek from '../src/index.ts' +import type { DeepSeekSessionLogExtension } from '../src/types.ts' const contexts: Context[] = [] const SIGNAL = new AbortController().signal @@ -11,7 +14,11 @@ afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) }) -async function harness(id: string, seed?: readonly SessionEvent[], meta?: CreateSessionOptions['meta']): Promise<{ +async function harness( + id: string, + seed?: readonly SessionEvent[], + creation?: Omit, +): Promise<{ ctx: Context session: Session disposeUpload: () => Promise @@ -24,7 +31,7 @@ async function harness(id: string, seed?: readonly SessionEvent[], meta?: Create await upload const options = seed === undefined ? undefined - : { seed, ...meta === undefined ? {} : { meta } } + : { seed, ...creation } const session = ctx.sessions.create(SessionId(id), options) return { ctx, session, disposeUpload: () => upload.dispose() } } @@ -34,6 +41,15 @@ function body(text = 'x'.repeat(300)) { } describe('incremental DeepSeek session-log upload', () => { + it('publishes raw numeric sequence fields on its external wire DTO', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf() + .toEqualTypeOf() + }) + it('does not contribute the session log under its default configuration', async () => { const ctx = new Context() contexts.push(ctx) @@ -85,7 +101,10 @@ describe('incremental DeepSeek session-log upload', () => { }) expect(resumedPayload.fields.dsh_session_log?.afterSeq).toBe(0) - const fork = await harness('child', seed, { parentSession: first.session.id, seedLength: seed.length }) + const fork = await harness('child', seed, { + inheritedEventCount: SessionLogOffset(seed.length), + meta: { parentSession: first.session.id, isSeeded: true }, + }) expect(SessionLogDeepSeek.acceptedThrough(fork.session)).toBe(-1) const forkPayload = await fork.ctx.deepseekLlmApiExtensions.prepare({ body: body(), signal: SIGNAL, sessionId: fork.session.id }) expect(forkPayload.fields.dsh_session_log).toMatchObject({ afterSeq: -1, throughSeq: fork.session.seq - 1 }) @@ -106,8 +125,13 @@ describe('incremental DeepSeek session-log upload', () => { it('folds only events appended after the cached acceptance scan', () => { const id = SessionId('incremental-fold') const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2, data: { sessionId: id, throughSeq: 0 } }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { + type: 'session-log-deepseek/delivery-accepted', + seq: SessionSeq(1), + time: 2, + data: { sessionId: id, throughSeq: SessionSeq(0) }, + }, ] let reads = 0 const session = { @@ -126,8 +150,13 @@ describe('incremental DeepSeek session-log upload', () => { expect(reads).toBe(0) events.push( - { type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } }, - { type: 'session-log-deepseek/delivery-accepted', seq: 3, time: 4, data: { sessionId: id, throughSeq: 2 } }, + { type: 'step/start', seq: SessionSeq(2), time: 3, data: { turn: 1, step: 1 } }, + { + type: 'session-log-deepseek/delivery-accepted', + seq: SessionSeq(3), + time: 4, + data: { sessionId: id, throughSeq: SessionSeq(2) }, + }, ) expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(2) expect(reads).toBe(2) @@ -170,15 +199,88 @@ describe('incremental DeepSeek session-log upload', () => { expect(prepared.fields.dsh_session_log?.events).toEqual(session.snapshotEvents()) }) - it('fails closed on a malformed persisted acceptance watermark', async () => { - const malformed = [{ - type: 'session-log-deepseek/delivery-accepted', + it('translates logical brands and isSeeded into the raw v0 upload DTO', async () => { + const firstMessage = createUserMessage({ + content: [{ type: 'text', text: 'first' }], + source: { kind: 'user' }, + }) + const replacementMessage = createUserMessage({ + content: [{ type: 'text', text: 'replacement' }], + source: { kind: 'user' }, + }) + const seed = [ + { + type: 'user/message', + seq: SessionSeq(0), + time: 1, + data: firstMessage, + ignorable: true, + surfaceOp: 'append', + }, + { + type: 'user/message', + seq: SessionSeq(1), + time: 2, + data: replacementMessage, + sourceEventSeqs: [SessionSeq(0)], + surfaceOp: { op: 'replace', start: SessionSeq(0), end: SessionSeq(0) }, + }, + ] satisfies SessionEvent[] + const { ctx, session } = await harness('wire-child', seed, { + inheritedEventCount: SessionLogOffset(0), + meta: { + cwd: '/wire-workspace', + parentSession: SessionId('wire-parent'), + isSeeded: true, + origin: 'subagent', + delegationDepth: 1, + agentPreset: 'minimal', + }, + }) + + const prepared = await ctx.deepseekLlmApiExtensions.prepare({ + body: body(), signal: SIGNAL, sessionId: session.id, + }) + const wire = JSON.parse(JSON.stringify(prepared.fields.dsh_session_log)) as Record + expect(wire.session).toMatchObject({ + version: 0, + id: 'wire-child', + parentSession: 'wire-parent', + cwd: '/wire-workspace', + seedLength: 0, + origin: 'subagent', + delegationDepth: 1, + agentPreset: 'minimal', + }) + expect(wire.session).not.toHaveProperty('isSeeded') + expect(typeof wire.afterSeq).toBe('number') + expect(typeof wire.throughSeq).toBe('number') + expect(Array.isArray(wire.events)).toBe(true) + const events = Array.isArray(wire.events) ? wire.events : [] + expect(events[0]).toMatchObject({ seq: 0, - time: 1, - data: { sessionId: 'malformed', throughSeq: 0 }, - }] as unknown as SessionEvent[] - const session = Session.create(SessionId('malformed'), malformed) - expect(() => SessionLogDeepSeek.acceptedThrough(session)).toThrow(/malformed acceptance watermark/) + ignorable: true, + surfaceOp: 'append', + }) + expect(events[0]).not.toHaveProperty('sourceEventSeqs') + expect(events[1]).toMatchObject({ + seq: 1, + sourceEventSeqs: [0], + surfaceOp: { op: 'replace', start: 0, end: 0 }, + }) + }) + + it('fails closed on a malformed persisted acceptance watermark', async () => { + for (const [id, throughSeq] of [['current', 0], ['negative', -1]] as const) { + const malformed = [{ + type: 'session-log-deepseek/delivery-accepted', + seq: 0, + time: 1, + data: { sessionId: `malformed-${id}`, throughSeq }, + }] as unknown as SessionEvent[] + const session = Session.create(SessionId(`malformed-${id}`), malformed) + expect(() => SessionLogDeepSeek.acceptedThrough(session)).toThrow(/malformed acceptance watermark/) + } }) it('withdraws its request field when the contributing plugin reloads', async () => { diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index c2f2bba957..0a44d29f0c 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence-jsonl/README.md -README.md: 6d61130522a928cbbbfeef90cb6bd63e968c5b92 -README.zh.md: 90c91f49a8c2ff67336cd2eac4477a6823ab6d31 +README.md: d3dfaf8affb142979ede69d86bbf0b1587e8c39c +README.zh.md: 15fc815523355674a1f325b604dd2655f56f7f58 diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 6d61130522..d3dfaf8aff 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -54,7 +54,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### On-disk layout -Each session gets a session-owned directory under a readable project directory; the first logical line of the log is the immutable `SessionHeader`, followed by one storage record per logical event (or one packed chunk row per eligible run). Storage records use the lossless provenance representation described below: +Each session gets a session-owned directory under a readable project directory; the first logical line is the private v0 physical header, followed by one storage record per logical event (or one packed chunk row per eligible run). Its optional numeric `seedLength` remains byte-compatible: absence decodes to `SessionHeader.isSeeded: false`, while zero or a positive value decodes to `isSeeded: true` plus the exact `inheritedEventCount`. Storage records use the lossless provenance representation described below: ```text / @@ -72,7 +72,7 @@ A session is materialized lazily: `create(meta)` writes nothing, and the first ` ### Reading the logs -`inspect(id)` returns an immutable balanced view without committing recovery. `readFrom(id, fromSeq)` returns stored events at or past a sequence number for watermark consumers; sequential media like JSONL parse the whole artifact and skip forward. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend. +`inspect(id)` returns an immutable balanced view with its exact inherited cut without committing recovery. `readFrom(id, fromOffset)` accepts a `SessionLogOffset`, returns stored events at or past that offset, and retains the same cut beside the suffix; sequential media like JSONL parse the whole artifact and skip forward. Header-only listing exposes `isSeeded` without reading event bodies. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend. ----- diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 90c91f49a8..15fc815523 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -54,7 +54,7 @@ kind: "package-reference" ### 磁盘布局 -每个会话在可读项目目录下获得一个会话自有目录;日志第一个逻辑行是不可变 `SessionHeader`,之后每个逻辑事件一条存储记录(或每个符合条件的连续段一条打包分片行)。存储记录使用下文所述的无损来源序列表示: +每个会话在可读项目目录下获得一个会话自有目录;第一个逻辑行是私有 v0 物理 header,之后每个逻辑事件一条存储记录(或每个符合条件的连续段一条打包分片行)。其可选数字 `seedLength` 保持字节兼容:缺席解码为 `SessionHeader.isSeeded: false`,零或正值解码为 `isSeeded: true` 加精确 `inheritedEventCount`。存储记录使用下文所述的无损来源序列表示: ```text / @@ -72,7 +72,7 @@ kind: "package-reference" ### 读取日志 -`inspect(id)` 返回不可变的平衡视图,不提交恢复。`readFrom(id, fromSeq)` 为水位消费方返回该序列号及之后的已存储事件;JSONL 这类顺序介质解析整个产物并向前跳过。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。 +`inspect(id)` 返回带精确继承切点的不可变平衡视图,不提交恢复。`readFrom(id, fromOffset)` 接受 `SessionLogOffset`,返回该偏移及之后的已存储事件,并在后缀旁保留同一切点;JSONL 这类顺序介质解析整个产物并向前跳过。仅 header 的列表读取不读事件正文即可公开 `isSeeded`。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。 ----- diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index 9f1ca0d4f4..29431963ec 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -11,9 +11,20 @@ import { join } from 'node:path' import { decodeSeqRanges, decodeStorageRecord, encodeSeqRanges, packChunkRuns, SESSION_FORMAT_VERSION, + SessionLogOffset, } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionHeader, SessionId, StorageRecord } from '@deepseek-ai/dsh-session' -import { SessionFormatUnsupportedError, sessionFormatVersionRefusal } from '@deepseek-ai/dsh-session-persistence' +import type { + SessionEvent, + SessionHeader, + SessionId, + SessionLogOffset as SessionLogOffsetType, + StorageRecord, +} from '@deepseek-ai/dsh-session' +import { + SessionFormatUnsupportedError, + sessionFormatVersionRefusal, + type SessionStorageMetadata, +} from '@deepseek-ai/dsh-session-persistence' /** Physical encoding selected for JSONL session artifacts. */ export type JsonlCompression = 'zstd' | 'none' @@ -28,11 +39,11 @@ export function logSuffix(compression: JsonlCompression): '.jsonl.zstd' | '.json } /** - * The first JSONL record of a session artifact: the immutable - * {@link SessionHeader} tagged as a `session` record so a reader can tell it - * apart from an event line. + * The private version-0 physical header stored as the first JSONL record. + * Its optional numeric `seedLength` translates to logical lineage metadata + * plus a separately carried exact inherited cut. */ -export interface HeaderLine { +interface HeaderLine { type: 'session' version: number id: SessionId @@ -48,9 +59,21 @@ export interface HeaderLine { /** * Build the header line object from a {@link SessionHeader}. * @param header - the immutable session metadata to serialize. + * @param inheritedEventCount - exact inherited prefix length; required for a + * seeded header and omitted only for an unseeded header. * @returns the `type: 'session'`-tagged line object, absent optional fields omitted (never null). */ -export function toHeaderLine(header: SessionHeader): HeaderLine { +export function toHeaderLine( + header: SessionHeader, + inheritedEventCount?: SessionLogOffsetType, +): HeaderLine { + if (header.isSeeded && inheritedEventCount === undefined) { + throw new Error('seeded session header requires an inherited event count') + } + const cut = SessionLogOffset(inheritedEventCount ?? 0) + if (!header.isSeeded && cut !== 0) { + throw new Error('unseeded session header inherited event count must be 0') + } return { type: 'session', version: header.version, @@ -58,7 +81,7 @@ export function toHeaderLine(header: SessionHeader): HeaderLine { createdAt: header.createdAt, ...header.cwd !== undefined ? { cwd: header.cwd } : {}, ...header.parentSession !== undefined ? { parentSession: header.parentSession } : {}, - ...header.seedLength !== undefined ? { seedLength: header.seedLength } : {}, + ...header.isSeeded ? { seedLength: cut } : {}, ...header.origin !== undefined ? { origin: header.origin } : {}, delegationDepth: header.delegationDepth ?? 0, ...header.agentPreset !== undefined ? { agentPreset: header.agentPreset } : {}, @@ -66,24 +89,27 @@ export function toHeaderLine(header: SessionHeader): HeaderLine { } /** - * Parse a header line back into a {@link SessionHeader}. + * Translate one version-0 physical header into logical metadata and its cut. * @param line - the shape-checked first line of a log (see the `isHeaderLine` guard). - * @returns the header, absent optional fields omitted. + * @returns logical Session metadata paired with the exact inherited prefix length. */ -export function fromHeaderLine(line: HeaderLine): SessionHeader { +function fromHeaderLine(line: HeaderLine): SessionStorageMetadata { if (Object.hasOwn(line, 'sandboxMode') || Object.hasOwn(line, 'approvalPolicy')) { throw new Error('session header uses retired policy baseline fields') } return { - version: line.version, - id: line.id, - createdAt: line.createdAt, - ...line.cwd !== undefined ? { cwd: line.cwd } : {}, - ...line.parentSession !== undefined ? { parentSession: line.parentSession } : {}, - ...line.seedLength !== undefined ? { seedLength: line.seedLength } : {}, - ...line.origin !== undefined ? { origin: line.origin } : {}, - delegationDepth: line.delegationDepth, - ...line.agentPreset !== undefined ? { agentPreset: line.agentPreset } : {}, + meta: { + version: line.version, + id: line.id, + createdAt: line.createdAt, + ...line.cwd !== undefined ? { cwd: line.cwd } : {}, + ...line.parentSession !== undefined ? { parentSession: line.parentSession } : {}, + isSeeded: line.seedLength !== undefined, + ...line.origin !== undefined ? { origin: line.origin } : {}, + delegationDepth: line.delegationDepth, + ...line.agentPreset !== undefined ? { agentPreset: line.agentPreset } : {}, + }, + inheritedEventCount: SessionLogOffset(line.seedLength ?? 0), } } @@ -102,6 +128,11 @@ function isHeaderLine(value: unknown): value is HeaderLine { && Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth) && (value as { delegationDepth: number }).delegationDepth >= 0 && !Object.is((value as { delegationDepth: number }).delegationDepth, -0) + && ((value as { seedLength?: unknown }).seedLength === undefined + || (typeof (value as { seedLength?: unknown }).seedLength === 'number' + && Number.isSafeInteger((value as { seedLength: number }).seedLength) + && (value as { seedLength: number }).seedLength >= 0 + && !Object.is((value as { seedLength: number }).seedLength, -0))) && ((value as { origin?: unknown }).origin === undefined || (value as { origin?: unknown }).origin === 'subagent') && ((value as { agentPreset?: unknown }).agentPreset === undefined @@ -231,7 +262,7 @@ export function eventLines(events: readonly SessionEvent[], packChunks: boolean) * stays verbatim. * @param record - one stored record (event or packed row). * @returns the record with its provenance in storage form (widened from the - * in-memory `number[]`; {@link expandProvenanceFromStorage} restores it). + * in-memory `SessionSeq[]`; {@link expandProvenanceFromStorage} restores it). */ function encodeProvenanceForStorage(record: StorageRecord): unknown { if (!('sourceEventSeqs' in record)) return record @@ -239,7 +270,7 @@ function encodeProvenanceForStorage(record: StorageRecord): unknown { } /** - * Expand a parsed line's storage-form provenance back to `number[]`. + * Expand a parsed line's storage-form provenance back to `SessionSeq[]`. * @param parsed - the JSON-parsed value of one stored line. * @returns the value with provenance expanded. * @throws when the record or its storage-form provenance is malformed. @@ -258,6 +289,7 @@ function expandProvenanceFromStorage(parsed: unknown): unknown { interface SessionLogScan { meta: SessionHeader + inheritedEventCount: SessionLogOffsetType events: SessionEvent[] committedBytes: number } @@ -279,7 +311,7 @@ function refuseForeignFormatVersion(parsed: unknown): void { ) } -function parseHeaderRecord(record: Buffer): SessionHeader { +function parseHeaderRecord(record: Buffer): ReturnType { if (record.length === 0 || record.at(-1) !== 0x0A || record.indexOf(0x0A) !== record.length - 1) { throw new Error('empty or header-less session log') } @@ -304,6 +336,7 @@ function parseHeaderRecord(record: Buffer): SessionHeader { */ export class SessionLogScanner { private readonly meta: SessionHeader + private readonly inheritedEventCount: SessionLogOffsetType private readonly events: SessionEvent[] = [] private fragments: Buffer[] = [] private fragmentBytes = 0 @@ -318,7 +351,9 @@ export class SessionLogScanner { * @param headerRecord - the complete first JSONL record, including its newline. */ constructor(headerRecord: Buffer) { - this.meta = parseHeaderRecord(headerRecord) + const parsed = parseHeaderRecord(headerRecord) + this.meta = parsed.meta + this.inheritedEventCount = parsed.inheritedEventCount this.inputBytes = headerRecord.length this.committedBytes = headerRecord.length } @@ -359,11 +394,15 @@ export class SessionLogScanner { * Snapshot progress before appending a recoverable torn-frame prefix. * @returns byte, committed-prefix, and expanded-event cursors. */ - checkpoint(): { inputBytes: number; committedBytes: number; eventCount: number } { + checkpoint(): { + inputBytes: number + committedBytes: number + eventCount: SessionLogOffsetType + } { return { inputBytes: this.inputBytes, committedBytes: this.committedBytes, - eventCount: this.events.length, + eventCount: SessionLogOffset(this.events.length), } } @@ -373,7 +412,12 @@ export class SessionLogScanner { */ finish(): SessionLogScan { this.finished = true - return { meta: this.meta, events: this.events, committedBytes: this.committedBytes } + return { + meta: this.meta, + inheritedEventCount: this.inheritedEventCount, + events: this.events, + committedBytes: this.committedBytes, + } } /** Decode one complete event row and update the contiguous prefix. */ @@ -427,20 +471,28 @@ export function scanLog(buffer: Buffer): SessionLogScan { } /** - * Parse just the header line of a log into a {@link SessionHeader}, or - * `undefined` if it is missing/not a header. Used by `list()` to read session - * metadata WITHOUT parsing the whole log: a session picker scales with the - * number of sessions, not the total size of every conversation. + * Parse just the header line of a log into logical metadata plus its exact + * inherited cut, or `undefined` if it is missing/not a header. * @param firstLine - the first line of a log file (without its trailing newline). - * @returns the parsed header, or `undefined` when the line is not a well-formed session header. + * @returns parsed storage metadata, or `undefined` for a malformed header. */ -export function parseHeaderMeta(firstLine: string): SessionHeader | undefined { +export function parseHeader(firstLine: string): SessionStorageMetadata | undefined { let parsed: unknown try { parsed = JSON.parse(firstLine) } catch { return undefined } + refuseForeignFormatVersion(parsed) if (!isHeaderLine(parsed)) return undefined return fromHeaderLine(parsed) } + +/** + * Parse only the logical header fields needed by lightweight listing. + * @param firstLine - first JSONL line without its trailing newline. + * @returns the logical Session header, or `undefined` for a malformed line. + */ +export function parseHeaderMeta(firstLine: string): SessionHeader | undefined { + return parseHeader(firstLine)?.meta +} diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 111af6dc08..8ccebf0be0 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -19,13 +19,21 @@ import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, SessionFormatUnsupportedError, type BorrowedSessionSource, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type SessionInspection, + type SessionEventSuffix, type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type SessionRawArtifact, + type SessionStorageMetadata, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' -import type { Session, SessionEvent, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' +import type { + Session, + SessionEvent, + SessionId, + SessionHeader, + SessionLogOffset, + SessionPreparation, +} from '@deepseek-ai/dsh-session' import { - encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, projectDir, scanLog, sessionDir, + encodeSegment, eventLines, logPath, logSuffix, parseHeader, parseHeaderMeta, projectDir, scanLog, sessionDir, SessionLogScanner, toHeaderLine, type JsonlCompression, } from './format.ts' @@ -175,8 +183,8 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi return { kind: 'jsonl', path: logPath(this.root, meta.cwd, meta.id, this.compression) } } - create(meta: SessionHeader): Promise { - return this.coordinator.create(meta) + create(meta: SessionHeader, inheritedEventCount?: SessionLogOffset): Promise { + return this.coordinator.create(meta, inheritedEventCount) } override ensureMaterialized(session: Session): Promise { @@ -205,7 +213,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi // JSONL is sequential media: no loadStoredFrom hook, so the coordinator // parses the stored prefix (both encodings) and skips forward to fromSeq. - readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + readFrom(id: SessionId, fromSeq: SessionLogOffset, signal?: AbortSignal): Promise { return this.coordinator.readFrom(id, fromSeq, signal) } @@ -282,13 +290,13 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi } else { content = buffer.toString('utf8') } - const meta = parseHeaderMeta(content.split('\n', 1)[0] as string) - if (meta === undefined || meta.id !== id) { + const storage = parseHeader(content.split('\n', 1)[0] as string) + if (storage === undefined || storage.meta.id !== id) { throw new Error(`corrupt session log: invalid header line in "${path}"`) } // The logical artifact name is `session.jsonl` regardless of the physical // encoding suffix (`.jsonl.zstd` marks compression only). - return { meta, filename: 'session.jsonl', content } + return { ...storage, filename: 'session.jsonl', content } } /** @@ -329,10 +337,11 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi prefix = await this.readZstdPrefix(buffer, signal) } else { signal?.throwIfAborted() - const { meta, events, committedBytes } = scanLog(buffer) + const { meta, inheritedEventCount, events, committedBytes } = scanLog(buffer) signal?.throwIfAborted() prefix = { meta, + inheritedEventCount, events, ...committedBytes < buffer.byteLength ? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } } @@ -394,7 +403,11 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi } if (tornStart === undefined) { const prefix = scanner.finish() - return { meta: prefix.meta, events: prefix.events } + return { + meta: prefix.meta, + inheritedEventCount: prefix.inheritedEventCount, + events: prefix.events, + } } let recoveredPlaintext: Buffer = Buffer.alloc(0) @@ -413,6 +426,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi signal?.throwIfAborted() return { meta: recoveredPrefix.meta, + inheritedEventCount: recoveredPrefix.inheritedEventCount, events: recoveredPrefix.events, tornMarker: { truncateTo: tornStart, @@ -429,18 +443,22 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi } /** Durably append a batch, lazily materializing the file when not yet present. */ - async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise { + async appendBatch( + storage: SessionStorageMetadata, + events: readonly SessionEvent[], + isMaterialized: boolean, + ): Promise { await this.ensureRootEncoding() if (isMaterialized) { - await this.appendLines(meta, events) + await this.appendLines(storage.meta, events) } else { - await this.materialize(meta, events) + await this.materialize(storage, events) } } /** Materialize a header-only JSONL artifact for an explicitly durable empty session. */ - async materializeHeader(meta: SessionHeader): Promise { - await this.materialize(meta, []) + async materializeHeader(storage: SessionStorageMetadata): Promise { + await this.materialize(storage, []) } /** @@ -449,10 +467,11 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi * does not require this to be atomic. */ async commitRepair( - meta: SessionHeader, + storage: SessionStorageMetadata, tornMarker: JsonlTornMarker | undefined, closers: readonly SessionEvent[], ): Promise { + const { meta } = storage if (tornMarker !== undefined) await this.repair(meta, tornMarker.truncateTo) const repairedEvents = [...(tornMarker?.recoveredEvents ?? []), ...closers] if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents) @@ -527,12 +546,13 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi // --- materialization / append / repair (file mechanics) --- /** Atomically write the header line + first batch (temp-write, fsync, publish). */ - private async materialize(meta: SessionHeader, events: readonly SessionEvent[]): Promise { + private async materialize(storage: SessionStorageMetadata, events: readonly SessionEvent[]): Promise { + const { meta } = storage const project = projectDir(this.root, meta.cwd) const dir = sessionDir(this.root, meta.cwd, meta.id) const finalPath = logPath(this.root, meta.cwd, meta.id, this.compression) await this.rejectOppositeArtifact(meta.cwd, meta.id) - const content = await this.encodeMaterialization(meta, events) + const content = await this.encodeMaterialization(storage, events) /* v8 ignore next -- native Windows coverage exercises this platform dispatch; Linux covers the POSIX peer */ if (process.platform === 'win32') { await this.materializeWin32(project, dir, finalPath, meta.id, content) @@ -632,8 +652,11 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi } /** Encode the header and first batch without combining their frame boundaries. */ - private async encodeMaterialization(meta: SessionHeader, events: readonly SessionEvent[]): Promise { - const header = JSON.stringify(toHeaderLine(meta)) + '\n' + private async encodeMaterialization( + storage: SessionStorageMetadata, + events: readonly SessionEvent[], + ): Promise { + const header = JSON.stringify(toHeaderLine(storage.meta, storage.inheritedEventCount)) + '\n' if (events.length === 0) { return this.compression === 'none' ? header : compressZstdFrame(header) } diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index e08e2c628c..363ec28212 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -4,11 +4,12 @@ import { Context } from '@deepseek-ai/cordis' import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, isAbsolute, join, relative, resolve } from 'node:path' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { - encodeSegment, eventLines, logPath, projectDir, projectKey, scanLog, sessionDir, SessionLogScanner, toHeaderLine, + encodeSegment, eventLines, logPath, parseHeader, projectDir, projectKey, scanLog, sessionDir, SessionLogScanner, + toHeaderLine, } from '../src/format.ts' import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' @@ -133,6 +134,48 @@ runCoordinatorContract('jsonl-none', async (): Promise => { }) describe('JsonlSessionPersistence: format helpers', () => { + it.each([ + ['absent', undefined, false, 0], + ['zero', 0, true, 0], + ['nonzero', 3, true, 3], + ] as const)('round-trips the v0 physical seedLength when it is %s', ( + _case, + seedLength, + isSeeded, + inheritedEventCount, + ) => { + const line = { + type: 'session', + version: 0, + id: SessionId(`physical-seed-${_case}`), + createdAt: 1000, + ...seedLength === undefined ? {} : { seedLength }, + delegationDepth: 0, + } + const bytes = `${JSON.stringify(line)}\n` + + const scanned = scanLog(Buffer.from(bytes)) + + expect(scanned.meta.isSeeded).toBe(isSeeded) + expect(scanned.inheritedEventCount).toBe(SessionLogOffset(inheritedEventCount)) + expect(`${JSON.stringify(toHeaderLine(scanned.meta, scanned.inheritedEventCount))}\n`).toBe(bytes) + }) + + it('requires logical lineage and the physical inherited cut to agree', () => { + const unseeded = meta('lineage-cut') + expect(() => toHeaderLine({ ...unseeded, isSeeded: true })) + .toThrow('seeded session header requires an inherited event count') + expect(() => toHeaderLine(unseeded, SessionLogOffset(1))) + .toThrow('unseeded session header inherited event count must be 0') + }) + + it('round-trips the subagent origin and rejects other physical values', () => { + const line = toHeaderLine({ ...meta('subagent-origin'), origin: 'subagent' }) + expect(scanLog(Buffer.from(`${JSON.stringify(line)}\n`)).meta.origin).toBe('subagent') + expect(() => scanLog(Buffer.from(`${JSON.stringify({ ...line, origin: 'worker' })}\n`))) + .toThrow(/session header/) + }) + it('encodeSegment neutralizes traversal, separators, and absolute paths', () => { expect(encodeSegment('..')).toBe('~002E~002E') expect(encodeSegment('.')).toBe('~002E') @@ -239,6 +282,11 @@ describe('JsonlSessionPersistence: format helpers', () => { await fiber.dispose() }) + it('refuses a foreign version on the header-only read path', () => { + expect(() => parseHeader(JSON.stringify({ version: 42, id: 'future', futureOnly: true }))) + .toThrow(expect.objectContaining({ name: 'SessionFormatUnsupportedError' })) + }) + it('points a format refusal at the raw log path', async () => { const absoluteRoot = await freshRoot() const ctx = new Context() @@ -247,8 +295,8 @@ describe('JsonlSessionPersistence: format helpers', () => { const m = { ...meta('newer-format', '/work'), version: 7 } await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, ]) const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error) expect(failure?.name).toBe('SessionFormatUnsupportedError') @@ -286,6 +334,25 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id) }) + it('lists a seeded header without reading an event body', async () => { + const id = SessionId('header-only-seeded') + const path = rawLogPath(root, '/work', id) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${JSON.stringify({ + type: 'session', + version: 0, + id, + createdAt: 1000, + cwd: '/work', + seedLength: 0, + delegationDepth: 0, + })}\n{not-valid-json`) + + await expect(ctx.sessionPersistence.list()).resolves.toEqual([ + expect.objectContaining({ id, isSeeded: true }), + ]) + }) + it('materializes an explicitly durable empty live session without an event row', async () => { const id = SessionId('durable-empty') const session = ctx.sessions.create(id, { meta: { cwd: '/work' } }) @@ -293,7 +360,11 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { await ctx.sessionPersistence.ensureMaterialized(session) expect(await readFile(rawLogPath(root, '/work', id), 'utf8')).toBe(`${JSON.stringify(toHeaderLine(session.header))}\n`) - await expect(ctx.sessionPersistence.load(id)).resolves.toEqual({ meta: session.header, events: [] }) + await expect(ctx.sessionPersistence.load(id)).resolves.toEqual({ + meta: session.header, + inheritedEventCount: SessionLogOffset(0), + events: [], + }) }) it('delegates direct preparation through the JSONL provider', async () => { @@ -370,11 +441,11 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { it('round-trip is byte-identical (incl. assistant/chunk verbatim)', async () => { const m = meta('chunks') const log: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, - { type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'he' } } }, - { type: 'assistant/chunk', seq: 3, time: 4, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'llo' } } }, - { type: 'assistant/message', seq: 4, time: 5, data: { + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'step/start', seq: SessionSeq(1), time: 2, data: { turn: 1, step: 1 } }, + { type: 'assistant/chunk', seq: SessionSeq(2), time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'he' } } }, + { type: 'assistant/chunk', seq: SessionSeq(3), time: 4, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'llo' } } }, + { type: 'assistant/message', seq: SessionSeq(4), time: 5, data: { turn: 1, step: 1, message: createMessage({ role: 'assistant', @@ -384,9 +455,9 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { ...{ provider: 'mock', model: 'mock' }, }, }), - }, surfaceOp: 'append', sourceEventSeqs: [2, 3] }, - { type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } }, - { type: 'turn/end', seq: 6, time: 7, data: { turn: 1, reason: { kind: 'completed' } } }, + }, surfaceOp: 'append', sourceEventSeqs: [SessionSeq(2), SessionSeq(3)] }, + { type: 'step/end', seq: SessionSeq(5), time: 6, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: SessionSeq(6), time: 7, data: { turn: 1, reason: { kind: 'completed' } } }, ] await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, log) @@ -598,7 +669,12 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { id: SessionId('persist-child'), cwd: '/workspace', parentSession: SessionId('persist-parent'), - seedLength: source.snapshotEvents().length, + isSeeded: true, + }) + expect(loaded.inheritedEventCount).toBe(source.seq) + await expect(ctx.sessionPersistence.readRaw(child.id)).resolves.toMatchObject({ + meta: { isSeeded: true }, + inheritedEventCount: source.seq, }) }) @@ -754,7 +830,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { await ctx.sessionPersistence.create(a) await ctx.sessionPersistence.append(a.id, [{ type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: 1, data: { turn: 1 }, }]) @@ -782,7 +858,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { it('path-traversal session ids are neutralized (no escape from root)', async () => { const evil = SessionId('../../etc/pwn') - const m = { version: 0, id: evil, createdAt: 1 } + const m = { version: 0, id: evil, createdAt: 1, isSeeded: false } await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(evil, oneTurnLog()) // The file lives UNDER root, not at ../../etc. @@ -952,6 +1028,7 @@ describe('JsonlSessionPersistence: scanLog unit', () => { version: 0, id: SessionId('composed'), createdAt: 1, + isSeeded: false, delegationDepth: 0, agentPreset: 'minimal', }) @@ -1055,15 +1132,15 @@ describe('JsonlSessionPersistence: default packed chunk rows', () => { function chunkRunLog(): SessionEvent[] { const deltas: SessionEvent[] = Array.from({ length: 5 }, (_, k) => ({ type: 'assistant/chunk', - seq: 2 + k, + seq: SessionSeq(2 + k), time: 3 + k, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: `t${k}` } }, })) return [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'step/start', seq: SessionSeq(1), time: 2, data: { turn: 1, step: 1 } }, ...deltas, - { type: 'assistant/message', seq: 7, time: 8, data: { + { type: 'assistant/message', seq: SessionSeq(7), time: 8, data: { turn: 1, step: 1, message: createMessage({ role: 'assistant', @@ -1073,9 +1150,9 @@ describe('JsonlSessionPersistence: default packed chunk rows', () => { ...{ provider: 'mock', model: 'mock' }, }, }), - }, surfaceOp: 'append', sourceEventSeqs: [2, 3, 4, 5, 6] }, - { type: 'step/end', seq: 8, time: 9, data: { turn: 1, step: 1 } }, - { type: 'turn/end', seq: 9, time: 10, data: { turn: 1, reason: { kind: 'completed' } } }, + }, surfaceOp: 'append', sourceEventSeqs: [2, 3, 4, 5, 6].map(SessionSeq) }, + { type: 'step/end', seq: SessionSeq(8), time: 9, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: SessionSeq(9), time: 10, data: { turn: 1, reason: { kind: 'completed' } } }, ] } diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index 27ab56540a..9d311d2b39 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -5,7 +5,7 @@ import type { FileHandle } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { performance } from 'node:perf_hooks' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { logPath, scanLog, sessionDir, toHeaderLine, type JsonlCompression } from '../src/format.ts' @@ -332,6 +332,22 @@ describe('Zstandard frame structure', () => { }) describe('JsonlSessionPersistence: default Zstandard encoding', () => { + it('lists seeded metadata from the header frame without decoding the event body', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = { ...meta('zstd-header-only-seeded', '/work'), isSeeded: true } + const path = logPath(root, '/work', header.id, 'zstd') + await mkdir(sessionDir(root, '/work', header.id), { recursive: true }) + const headerFrame = await compressZstdFrame( + `${JSON.stringify(toHeaderLine(header, SessionLogOffset(0)))}\n`, + ) + await writeFile(path, Buffer.concat([headerFrame, Buffer.from('invalid event frame')])) + + await expect(ctx.sessionPersistence.list()).resolves.toEqual([ + expect.objectContaining({ id: header.id, isSeeded: true }), + ]) + }) + it('materializes an explicitly durable empty session as one header frame', async () => { const root = await freshRoot() const ctx = await mount(root) @@ -342,7 +358,11 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { const buffer = await readFile(logPath(root, '/work', session.id, 'zstd')) expect(scanZstdFrames(buffer).frames).toHaveLength(1) expect((await decodeCompleteFrames(buffer)).toString()).toBe(`${JSON.stringify(toHeaderLine(session.header))}\n`) - await expect(ctx.sessionPersistence.load(session.id)).resolves.toEqual({ meta: session.header, events: [] }) + await expect(ctx.sessionPersistence.load(session.id)).resolves.toEqual({ + meta: session.header, + inheritedEventCount: 0, + events: [], + }) }) it('writes .jsonl.zstd by default with one header frame and one first-batch frame', async () => { @@ -423,13 +443,13 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { ...base.slice(0, 3), ...Array.from({ length: 3 }, (_, index): SessionEvent => ({ type: 'assistant/chunk', - seq: 3 + index, + seq: SessionSeq(3 + index), time: 4 + index, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: `part-${index}` } }, })), ...base.slice(3).map((event): SessionEvent => ({ ...event, - seq: event.seq + 3, + seq: SessionSeq(event.seq + 3), time: event.time + 3, })), ] diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index 6e5028bf57..6cb47067fa 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md -README.md: 7165590fec9c1f017cf97786fbef61f5fb294a45 -README.zh.md: 007a59c6159835b31143a3a2cb7e0f4caf1f2674 +README.md: 6d430173b985d7e32b615e0b42713a925bc8b344 +README.zh.md: a3b6cd90fa4ccba2b28f0e81517bbabab0ce047e diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 7165590fec..6d430173b9 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-session-persistence` stores a session's event log durably, reloads it on resume, and lists stored sessions through the backend-neutral `ctx.sessionPersistence` service. The persisted unit is the existing `SessionEvent` log — there is no parallel stored message type — and non-replayable metadata (format version, working directory, lineage, seed boundary) travels separately as `SessionHeader`. A backend owns its storage, while the service owns append-only logs, contiguous sequence numbers, crash recovery that preserves an interrupted turn instead of truncating it, and durable writes that resolve only after the batch is safe. The shipped JSONL provider implements this service with one artifact per Session; third-party providers may implement the same contract without changing the loop or model. +`dsh-session-persistence` stores a session's event log durably, reloads it on resume, and lists stored sessions through the backend-neutral `ctx.sessionPersistence` service. The persisted unit is the existing `SessionEvent` log — there is no parallel stored message type. `SessionHeader.isSeeded` makes lineage visible to lightweight listing, while the exact `inheritedEventCount` accompanies every body-bearing storage read and prepared Session. A backend owns its storage, while the service owns append-only logs, contiguous sequence numbers, crash recovery that preserves an interrupted turn instead of truncating it, and durable writes that resolve only after the batch is safe. The shipped JSONL provider implements this service with one artifact per Session; third-party providers may implement the same contract without changing the loop or model. ## Table of Contents @@ -36,18 +36,18 @@ The seam ships the [JSONL](../session-persistence-jsonl/README.md) backend. It s With a backend mounted, you can store a session's events durably, reload the stored log, and list what is stored: ```text -await ctx.sessionPersistence.create(meta) // register a session +await ctx.sessionPersistence.create(meta, inheritedEventCount) // cut required when meta.isSeeded await ctx.sessionPersistence.ensureMaterialized(session) // persist an empty resumable session await ctx.sessionPersistence.append(id, events) // durably persist a batch -const { meta, events } = await ctx.sessionPersistence.load(id) // reload on resume +const { meta, inheritedEventCount, events } = await ctx.sessionPersistence.load(id) const headers = await ctx.sessionPersistence.list() // every stored session ``` -`append` resolves only after the batch is durable, so a resolved write survives an OS crash or power loss. Ordinary `create` remains lazy; a lifecycle frontend calls `ensureMaterialized` only when an empty session must itself appear in durable listing without inventing an event. `load` returns an immutable balanced log and commits any needed crash recovery; `inspect` reads the same view without committing recovery. Consumers that resume from a watermark can read only the events at or past a sequence number, and a session's artifact location (`locate`) resolves without filesystem I/O. +`append` resolves only after the batch is durable, so a resolved write survives an OS crash or power loss. Ordinary `create(meta, inheritedEventCount)` remains lazy; `meta.isSeeded: true` requires the sibling exact cut, while unseeded metadata may omit it and rejects a nonzero value. The first materializing batch for a seeded session must reach the complete inherited prefix, so storage never exposes metadata whose cut exceeds its log. A lifecycle frontend calls `ensureMaterialized` only when an empty session must itself appear in durable listing without inventing an event. `load` returns an immutable balanced log and commits any needed crash recovery; `inspect` reads the same complete view without committing recovery. `readFrom` accepts a `SessionLogOffset` and returns a detached `SessionEventSuffix` carrying that `fromSeq`, the unchanged inherited cut, and only stored events at or after the cut. A session's artifact location (`locate`) resolves without filesystem I/O. ### Resuming and crash recovery -Resume is `load` plus session preparation: the stored log comes back with its header lineage intact, so a resumed agent sees the same history and composition. A session that crashed mid-turn reloads with its interrupted final turn preserved and balanced: `load` appends synthetic `tool/result` and `turn/end {interrupted}` closers for unanswered calls instead of dropping the events — a single turn can be large, and those events were durably written before the crash. Only a never-fully-written torn tail fragment is discarded. +Resume is `load` plus session preparation: the stored log comes back with its header lineage and exact inherited cut intact, so ownership checks do not infer the cut from a marker or the full restore length. A session that crashed mid-turn reloads with its interrupted final turn preserved and balanced: `load` appends synthetic `tool/result` and `turn/end {interrupted}` closers for unanswered calls instead of dropping the events — a single turn can be large, and those events were durably written before the crash. Only a never-fully-written torn tail fragment is discarded. ### Failures and recovery diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 007a59c615..a3b6cd90fa 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-session-persistence` 通过后端无关的 `ctx.sessionPersistence` 服务持久存储会话的事件日志、在恢复时重新加载并列出已存储会话。持久化单元就是现有 `SessionEvent` 日志——不存在另一套并行的存储消息类型——不可回放的元数据(格式版本、工作目录、血缘、种子边界)作为 `SessionHeader` 单独传输。后端拥有自己的存储,而服务拥有仅追加日志、连续序列号、保留中断轮次而非截断的崩溃恢复,以及只在批次安全后才返回的持久写入。随产品交付的 JSONL provider 用每个 Session 一份产物实现该服务;第三方 provider 可以实现同一约定,而不改变 loop 或模型。 +`dsh-session-persistence` 通过后端无关的 `ctx.sessionPersistence` 服务持久存储会话的事件日志、在恢复时重新加载并列出已存储会话。持久化单元就是现有 `SessionEvent` 日志——不存在另一套并行的存储消息类型。`SessionHeader.isSeeded` 让轻量列表可见血缘,而精确的 `inheritedEventCount` 随每次带正文的存储读取与 prepared Session 一同传输。后端拥有自己的存储,而服务拥有仅追加日志、连续序列号、保留中断轮次而非截断的崩溃恢复,以及只在批次安全后才返回的持久写入。随产品交付的 JSONL provider 用每个 Session 一份产物实现该服务;第三方 provider 可以实现同一约定,而不改变 loop 或模型。 ## 目录 @@ -36,18 +36,18 @@ seam 随产品交付 [JSONL](../session-persistence-jsonl/README.zh.md) 后端 挂载后端后,你可以持久存储会话事件、重新加载已存储日志并列出已存储内容: ```text -await ctx.sessionPersistence.create(meta) // register a session +await ctx.sessionPersistence.create(meta, inheritedEventCount) // cut required when meta.isSeeded await ctx.sessionPersistence.ensureMaterialized(session) // persist an empty resumable session await ctx.sessionPersistence.append(id, events) // durably persist a batch -const { meta, events } = await ctx.sessionPersistence.load(id) // reload on resume +const { meta, inheritedEventCount, events } = await ctx.sessionPersistence.load(id) const headers = await ctx.sessionPersistence.list() // every stored session ``` -`append` 只在批次持久后返回,因此成功返回的写入在操作系统崩溃或断电后依然存在。普通 `create` 保持惰性;只有当空会话本身必须出现在持久列表中时,生命周期前端才调用 `ensureMaterialized`,且不会虚构事件。`load` 返回不可变的平衡日志并提交任何需要的崩溃恢复;`inspect` 读取同一视图但不提交恢复。从水位恢复的消费方可以只读取该序列号及之后的已存储事件,会话的产物位置(`locate`)不经文件系统 I/O 即可解析。 +`append` 只在批次持久后返回,因此成功返回的写入在操作系统崩溃或断电后依然存在。普通 `create(meta, inheritedEventCount)` 保持惰性;`meta.isSeeded: true` 要求单独的精确 cut,unseeded metadata 可以省略它并拒绝非零值。seeded 会话的首个物化批次必须到达完整继承前缀,因此存储绝不公开 cut 超过日志的 metadata。只有当空会话本身必须出现在持久列表中时,生命周期前端才调用 `ensureMaterialized`,且不会虚构事件。`load` 返回不可变的平衡日志并提交任何需要的崩溃恢复;`inspect` 读取同一份完整视图但不提交恢复。`readFrom` 接受 `SessionLogOffset`,并返回分离的 `SessionEventSuffix`,其中携带该 `fromSeq`、不变的继承 cut,以及 cut 位置或之后的存储事件。会话的产物位置(`locate`)不经文件系统 I/O 即可解析。 ### 恢复与崩溃恢复 -恢复就是 `load` 加会话准备:存储日志连同其头部血缘一起返回,因此恢复后的 agent(智能体)看到相同的历史与组装。中途崩溃的会话重新加载时,其被中断的最终轮次会保留并保持平衡:`load` 为未获回答的调用追加合成 `tool/result` 与 `turn/end {interrupted}` closer,而不是丢弃事件——单个轮次可能很大,而这些事件在崩溃前已持久写入。只有从未完整写入的撕裂尾部碎片会被丢弃。 +恢复就是 `load` 加会话准备:存储日志连同其 header 血缘与精确继承切点一起返回,因此所有权检查不从标记或完整恢复长度推断切点。中途崩溃的会话重新加载时,其被中断的最终轮次会保留并保持平衡:`load` 为未获回答的调用追加合成 `tool/result` 与 `turn/end {interrupted}` closer,而不是丢弃事件——单个轮次可能很大,而这些事件在崩溃前已持久写入。只有从未完整写入的撕裂尾部碎片会被丢弃。 ### 失败与恢复 diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index 4075b0dc26..c16fae6d40 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -11,13 +11,28 @@ import { interruptedTurnClosers, KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, + SessionLogOffset, SessionPreparation, + SessionSeq, snapshotSessionEvent, } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' +import type { + Session, + SessionEvent, + SessionId, + SessionHeader, + SessionLogOffset as SessionLogOffsetType, + SessionSeq as SessionSeqType, +} from '@deepseek-ai/dsh-session' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values' -import type { BorrowedSessionSource, SessionInspection, SessionLocation } from './index.ts' +import type { + BorrowedSessionSource, + SessionEventSuffix, + SessionInspection, + SessionLocation, + SessionStorageMetadata, +} from './index.ts' import { SessionPersistenceNotFoundError } from './errors.ts' import type { SessionPersistenceRevision } from './revision.ts' import { observeQueuedAbort, SessionPreparations } from './preparations.ts' @@ -96,8 +111,7 @@ export interface PersistenceCoordinatorOptions { * returns its value to {@link PersistenceBackend.commitRepair}; each backend * owns the marker type. */ -export interface StoredPrefix { - meta: SessionHeader +export interface StoredPrefix extends SessionStorageMetadata { events: SessionEvent[] /** Revision observed for exactly this detached prefix. */ revision: SessionPersistenceRevision @@ -110,8 +124,7 @@ export interface StoredPrefix { * {@link PersistenceBackend.loadStoredFrom} hook. Non-mutating reads carry no * torn marker: there is nothing to repair. */ -export interface StoredSuffix { - meta: SessionHeader +export interface StoredSuffix extends SessionStorageMetadata { events: SessionEvent[] } @@ -174,18 +187,24 @@ export interface PersistenceBackend { * validated by the coordinator before this hook runs). * @param signal - optional cancellation for backend read work. */ - loadStoredFrom?(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise + loadStoredFrom?(id: SessionId, fromSeq: SessionLogOffsetType, signal?: AbortSignal): Promise /** Durably create an empty header-only session artifact. */ - materializeHeader?(meta: SessionHeader): Promise + materializeHeader?(storage: SessionStorageMetadata): Promise /** * Durably append a CONTIGUOUS batch, lazily materializing the session first * when `!isMaterialized`. The materialize-write and the first event batch MUST * commit ATOMICALLY (a crash between them must not leave a materialized-but- * empty session). Returns once the batch is durable. + * The coordinator calls this only after a first batch reaches the declared + * inherited prefix length. */ - appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise + appendBatch( + storage: SessionStorageMetadata, + events: readonly SessionEvent[], + isMaterialized: boolean, + ): Promise /** * Make a crash repair durable: truncate the torn tail (iff @@ -194,7 +213,11 @@ export interface PersistenceBackend { * Used by load (truncate + synthetic closers) and by live-adoption (truncate * only, `closers = []`). */ - commitRepair(meta: SessionHeader, tornMarker: TornMarker | undefined, closers: readonly SessionEvent[]): Promise + commitRepair( + storage: SessionStorageMetadata, + tornMarker: TornMarker | undefined, + closers: readonly SessionEvent[], + ): Promise /** * List all stored (materialized) sessions' metadata. @@ -220,9 +243,9 @@ export interface PersistenceBackend { /** Per-session write state held by the coordinator's in-memory bookkeeping. */ interface SessionState { - meta: SessionHeader + storage: SessionStorageMetadata /** The next seq the backend expects to append (the stored log length). */ - cursor: number + cursor: SessionLogOffsetType /** * Whether lazy creation has produced a durable artifact. The first append * atomically materializes the header with events; reclaim logic uses this to @@ -250,7 +273,7 @@ interface PreparedSessionSource { readonly session: Session readonly revision: SessionPersistenceRevision /** Session length after constructor-owned seed markers were appended. */ - readonly sessionLength: number + readonly sessionLength: SessionLogOffsetType readonly tornMarker: TornMarker | undefined readonly closers: readonly SessionEvent[] } @@ -274,6 +297,26 @@ function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly Sessio }) } +/** Normalize the exact fork cut paired with one logical Session header. */ +function storageMetadata( + meta: SessionHeader, + inheritedEventCount?: SessionLogOffsetType, +): SessionStorageMetadata { + if (meta.isSeeded && inheritedEventCount === undefined) { + throw new TypeError('seeded session metadata requires an inherited event count') + } + const cut = SessionLogOffset(inheritedEventCount ?? 0) + if (!meta.isSeeded && cut !== 0) { + throw new TypeError('unseeded session metadata inherited event count must be 0') + } + return { meta, inheritedEventCount: cut } +} + +/** Exact storage metadata owned by one live Session. */ +function sessionStorageMetadata(session: Session): SessionStorageMetadata { + return storageMetadata(session.header, session.inheritedEventCount) +} + /** Reject events from an obsolete v0 vocabulary that this build cannot replay. */ function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId): void { const legacyType: string = 'request/header-delta' @@ -314,16 +357,19 @@ function hasOnlyKeys( type PersistedMessageId = SessionEvent<'user/message'>['data']['id'] /** Mint the stable import identity for a message persisted before identities existed. */ -function legacyMessageId(id: SessionId, seq: number): PersistedMessageId { +function legacyMessageId(id: SessionId, seq: SessionSeqType): PersistedMessageId { return `legacy-message:${id}:${seq}` as PersistedMessageId } /** Read a replacement target while leaving malformed surface metadata to the session validator. */ -function replacementStart(event: SessionEvent): number | undefined { +function replacementStart(event: SessionEvent): SessionSeqType | undefined { const op = asRecord((event as SessionEvent & { surfaceOp?: unknown }).surfaceOp) - return op?.['op'] === 'replace' && typeof op['start'] === 'number' - ? op['start'] - : undefined + if (op?.['op'] !== 'replace' || typeof op['start'] !== 'number') return undefined + try { + return SessionSeq(op['start']) + } catch { + return undefined + } } /** Whether one suffix event needs facts available only from the preceding stored prefix. */ @@ -466,7 +512,7 @@ function migrateLegacyTurnEndEvent(event: SessionEvent, id: SessionId): SessionE function migrateLegacyMessageEvent( event: SessionEvent, id: SessionId, - messageIds: ReadonlyMap, + messageIds: ReadonlyMap, ): SessionEvent { const data = asRecord(event.data) if (data === undefined) return event @@ -548,7 +594,7 @@ function eventMessageId(event: SessionEvent): PersistedMessageId | undefined { /** Materialize stored events as upgraded, validated snapshots with immutable messages. */ function snapshotStoredEvents(events: readonly SessionEvent[], id: SessionId): SessionEvent[] { assertSupportedEvents(events, id) - const messageIds = new Map() + const messageIds = new Map() return events.map((event) => { const migratedStart = migrateLegacyTurnStartEvent(event, id) const migratedTurn = migrateLegacyTurnEndEvent(migratedStart, id) @@ -563,7 +609,7 @@ function snapshotStoredEvents(events: readonly SessionEvent[], id: SessionId): S /** Upgrade and validate an exclusively owned backend result without copying it. */ function adoptStoredEvents(events: SessionEvent[], id: SessionId): SessionEvent[] { assertSupportedEvents(events, id) - const messageIds = new Map() + const messageIds = new Map() for (const [index, event] of events.entries()) { const migratedStart = migrateLegacyTurnStartEvent(event, id) const migratedTurn = migrateLegacyTurnEndEvent(migratedStart, id) @@ -633,8 +679,10 @@ export class PersistenceCoordinator { /** * Register detached session metadata for lazy creation on the first append. * @param meta - header to snapshot; duplicate tracked or persisted ids reject. + * @param inheritedEventCount - exact inherited prefix length; required for + * a seeded header and omitted only for an unseeded header. */ - create(meta: SessionHeader): Promise { + create(meta: SessionHeader, inheritedEventCount?: SessionLogOffsetType): Promise { // Snapshot before queueing so caller mutation cannot diverge the key and header. const snapshot = snapshotJsonValue(meta) if (snapshot === undefined) { @@ -643,7 +691,16 @@ export class PersistenceCoordinator { if (!Number.isSafeInteger(snapshot.createdAt) || snapshot.createdAt < 0) { return Promise.reject(new TypeError('session metadata createdAt must be a non-negative safe integer')) } - return this.serialize(snapshot.id, () => this.createCore(snapshot)) + let storage: SessionStorageMetadata + try { + storage = storageMetadata(snapshot, inheritedEventCount) + } catch (error: unknown) { + /* v8 ignore next -- Session storage validation only throws Error instances. */ + return Promise.reject(error instanceof Error + ? error + : new TypeError('invalid session storage metadata', { cause: error })) + } + return this.serialize(snapshot.id, () => this.createCore(storage)) } /** @@ -660,13 +717,14 @@ export class PersistenceCoordinator { if (this.backend.materializeHeader === undefined) { throw new Error('session persistence backend cannot materialize an empty session') } - await this.backend.materializeHeader(state.meta) + await this.backend.materializeHeader(state.storage) state.materialized = true this.preparations.invalidate(session.id) }) } - private async createCore(meta: SessionHeader): Promise { + private async createCore(storage: SessionStorageMetadata): Promise { + const { meta } = storage // Do NOT clobber an existing session: the SessionId IS the identity. if (this.states.has(meta.id) || this.preparations.has(meta.id)) { throw new Error(`session "${meta.id}" already exists in this backend`) @@ -678,7 +736,11 @@ export class PersistenceCoordinator { throw new Error(`session "${meta.id}" already has a persisted log on disk; load/resume it instead of creating`) } // Pure lazy: record intent only. No artifact until the first append. - this.states.set(meta.id, { meta, cursor: 0, materialized: false }) + this.states.set(meta.id, { + storage, + cursor: SessionLogOffset(0), + materialized: false, + }) } // `async` so synchronous materialization failures below reject (not throw) per @@ -725,11 +787,16 @@ export class PersistenceCoordinator { } } - await this.backend.appendBatch(state.meta, events, state.materialized) + const nextCursor = SessionLogOffset(state.cursor + events.length) + if (!state.materialized && nextCursor < state.storage.inheritedEventCount) { + throw new Error(`session "${id}" cannot materialize before its inherited prefix is complete`) + } + + await this.backend.appendBatch(state.storage, events, state.materialized) // The durable write is the transaction: mark materialized + advance the // cursor as soon as it commits (uniform across backends). state.materialized = true - state.cursor += events.length + state.cursor = nextCursor this.preparations.invalidate(id) } @@ -909,11 +976,20 @@ export class PersistenceCoordinator { * @param id - persisted session to read. * @param fromSeq - first event seq to include; a non-negative safe integer. * @param signal - optional cancellation for queued and backend read work. - * @returns stored header and the valid stored events with `seq >= fromSeq`. + * @returns stored metadata, the requested offset, and valid events with `seq >= fromSeq`. */ - readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - if (!Number.isSafeInteger(fromSeq) || fromSeq < 0) { - return Promise.reject(new TypeError(`readFrom fromSeq must be a non-negative safe integer, got ${String(fromSeq)}`)) + readFrom( + id: SessionId, + fromSeq: SessionLogOffsetType, + signal?: AbortSignal, + ): Promise { + try { + SessionLogOffset(fromSeq) + } catch (error: unknown) { + /* v8 ignore next -- Session log-offset validation only throws Error instances. */ + return Promise.reject(error instanceof Error + ? error + : new TypeError('invalid session read offset', { cause: error })) } const retired = Promise.resolve(this.retirements.get(id)) const waited = signal === undefined ? retired : observeQueuedAbort(retired, signal, () => false) @@ -922,9 +998,9 @@ export class PersistenceCoordinator { private async readFromCore( id: SessionId, - fromSeq: number, + fromSeq: SessionLogOffsetType, signal?: AbortSignal, - ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + ): Promise { signal?.throwIfAborted() if (this.backend.loadStoredFrom !== undefined) { let suffix: StoredSuffix | undefined @@ -940,22 +1016,37 @@ export class PersistenceCoordinator { this.assertVersion(suffix.meta) if (suffix.events.some(needsLegacyPrefix)) { const whole = await this.readStoredPrefix(id, signal) - return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } + return { + meta: whole.meta, + inheritedEventCount: whole.inheritedEventCount, + fromSeq, + events: whole.events.filter(event => event.seq >= fromSeq), + } } const events = snapshotStoredEvents(suffix.events, id) this.assertEventsSupported(suffix.meta, events) - return { meta: structuredClone(suffix.meta), events } + return { + meta: structuredClone(suffix.meta), + inheritedEventCount: SessionLogOffset(suffix.inheritedEventCount), + fromSeq, + events, + } } const whole = await this.readStoredPrefix(id, signal) // Sequential fallback: contiguous seqs from 0 make the suffix an index slice. - return { meta: whole.meta, events: whole.events.slice(fromSeq) } + return { + meta: whole.meta, + inheritedEventCount: whole.inheritedEventCount, + fromSeq, + events: whole.events.slice(fromSeq), + } } /** Read one detached physical prefix without logical recovery or caching. */ private async readStoredPrefix( id: SessionId, signal?: AbortSignal, - ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + ): Promise { signal?.throwIfAborted() const stored = await this.backend.loadStored(id, signal) signal?.throwIfAborted() @@ -966,6 +1057,7 @@ export class PersistenceCoordinator { this.assertEventsSupported(stored.meta, events) return { meta: structuredClone(stored.meta), + inheritedEventCount: SessionLogOffset(stored.inheritedEventCount), events, } } @@ -975,11 +1067,14 @@ export class PersistenceCoordinator { const stored = await this.backend.loadStored(id) if (stored === undefined) throw new SessionPersistenceNotFoundError(id) try { - const { meta, events, revision, tornMarker } = stored + const { meta, inheritedEventCount, events, revision, tornMarker } = stored this.assertStoredId(id, meta) this.assertVersion(meta) const storedEvents = adoptStoredEvents(events, id) this.assertEventsSupported(meta, storedEvents) + if (inheritedEventCount > storedEvents.length) { + throw new Error(`session "${id}" inherited event count exceeds its stored event count`) + } // Preserve complete interrupted events and synthesize only missing closers. const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent) @@ -987,10 +1082,12 @@ export class PersistenceCoordinator { const session = this.ctx.sessions.prepare(id, { seed: balanced, meta, + inheritedEventCount, seedSource: 'persistence', }) const inspection: SessionInspection = Object.freeze({ meta: session.header, + inheritedEventCount: session.inheritedEventCount, events: Object.freeze(balanced), }) return { @@ -1017,24 +1114,24 @@ export class PersistenceCoordinator { source: PreparedSessionSource, ): Promise<{ source: PreparedSessionSource; state: SessionState } | undefined> { const id = source.inspection.meta.id - const cursor = source.inspection.events.length + const cursor = SessionLogOffset(source.inspection.events.length) const existing = this.states.get(id) if (existing?.owner !== undefined) { throw new Error(`session "${id}" already has a live persistence owner`) } if (!await this.isPreparedSourceCurrent(source)) return undefined if (source.tornMarker !== undefined || source.closers.length > 0) { - await this.backend.commitRepair(source.inspection.meta, source.tornMarker, source.closers) + await this.backend.commitRepair(source.inspection, source.tornMarker, source.closers) // The repair changed the durable revision. Reload the exact committed // graph instead of associating the old in-memory view with a newer revision. return undefined } const state = existing ?? { - meta: source.inspection.meta, + storage: source.inspection, cursor, materialized: true, } - state.meta = source.inspection.meta + state.storage = source.inspection state.cursor = cursor state.materialized = true this.states.set(id, state) @@ -1063,12 +1160,20 @@ export class PersistenceCoordinator { if (interruptedTurnClosers(events).length > 0) { throw new Error(`cannot load session "${session.id}" while its live turn is open; use the live Session or wait for the turn to close`) } - return Object.freeze({ meta: state.meta, events }) + return Object.freeze({ + meta: state.storage.meta, + inheritedEventCount: state.storage.inheritedEventCount, + events, + }) } /** Borrow one immutable view from an already-live Session. */ private inspectLive(session: Session): SessionInspection { - return Object.freeze({ meta: session.header, events: session.snapshotEvents() }) + return Object.freeze({ + meta: session.header, + inheritedEventCount: session.inheritedEventCount, + events: session.snapshotEvents(), + }) } /** Await one retiring lifecycle with caller cancellation. */ @@ -1293,7 +1398,11 @@ export class PersistenceCoordinator { * events. A `cursor` of 0 (nothing persisted yet) trivially matches. Used when * a live session claims ownerless state left by a prior `load()`/`create()`. */ - private async seedMatchesPersisted(id: SessionId, seed: readonly SessionEvent[], cursor: number): Promise { + private async seedMatchesPersisted( + id: SessionId, + seed: readonly SessionEvent[], + cursor: SessionLogOffsetType, + ): Promise { if (cursor === 0) return true const stored = await this.backend.loadStored(id) /* v8 ignore next -- a cursor > 0 means the session was materialized, so it exists */ @@ -1330,8 +1439,11 @@ export class PersistenceCoordinator { // the stored header's cwd. The seed guard then ensures the live events // reproduce the persisted prefix; otherwise a fresh session reusing the // id could have its leading events filtered as already written. - if (tracked.meta.cwd !== session.header.cwd) { - throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) + if (tracked.storage.meta.cwd !== session.header.cwd) { + throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.storage.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) + } + if (tracked.storage.inheritedEventCount !== session.inheritedEventCount) { + throw new Error(`session "${id}" is already persisted with a different inherited event count (id collision)`) } if (!await this.seedMatchesPersisted(id, seed, tracked.cursor)) { throw new Error(`session "${id}" is already persisted with ${tracked.cursor} event(s) that do not match this live session (id collision)`) @@ -1364,8 +1476,11 @@ export class PersistenceCoordinator { // case 4: a genuinely new session. Register its meta (lazy), then persist its // seed (events present at creation time) once. - const meta: SessionHeader = { ...session.header } - await this.createCore(meta) + const storage = sessionStorageMetadata(session) + await this.createCore({ + meta: { ...storage.meta }, + inheritedEventCount: storage.inheritedEventCount, + }) // Bind this state to the live session so a later DIFFERENT session reusing // the id is detected as a collision (case 1) rather than silently no-opped. const created = this.states.get(id) @@ -1381,11 +1496,14 @@ export class PersistenceCoordinator { * live suffix that was ahead of the stored prefix. */ private async adoptLivePrefix(session: Session, seed: readonly SessionEvent[], stored: StoredPrefix): Promise { - const { meta, events, tornMarker } = stored + const { meta, inheritedEventCount, events, tornMarker } = stored this.assertStoredId(session.header.id, meta) if (meta.cwd !== session.header.cwd) { throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) } + if (inheritedEventCount !== session.inheritedEventCount) { + throw new Error(`session "${session.header.id}" is already persisted with a different inherited event count (id collision)`) + } this.assertVersion(meta) const storedEvents = snapshotStoredEvents(events, session.header.id) this.assertEventsSupported(meta, storedEvents) @@ -1393,10 +1511,13 @@ export class PersistenceCoordinator { throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`) } // Truncate-only repair (no closers): the open turn is NOT closed here. - if (tornMarker !== undefined) await this.backend.commitRepair(meta, tornMarker, []) + if (tornMarker !== undefined) await this.backend.commitRepair(stored, tornMarker, []) this.states.set(session.header.id, { - meta: { ...meta }, - cursor: storedEvents.length, + storage: { + meta: { ...meta }, + inheritedEventCount, + }, + cursor: SessionLogOffset(storedEvents.length), materialized: true, owner: session, }) diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 5cb2311239..d110703d3a 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -6,8 +6,13 @@ */ import { Context, Service } from '@deepseek-ai/cordis' -import { SessionPreparation } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' +import { SessionPreparation, SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { + Session, + SessionEvent, + SessionId, + SessionHeader, +} from '@deepseek-ai/dsh-session' import type { SessionPersistenceRevision } from './revision.ts' // Re-export the metadata vocabulary so Consumers import it from the Service Definition. @@ -23,14 +28,28 @@ export interface SessionPersistenceSnapshot { revision: SessionPersistenceRevision } -/** Immutable logical session prepared from persistence or a live owner. */ -export interface SessionInspection { - /** Validated immutable session metadata. */ +/** Logical Session header paired with its exact inherited cut for body-bearing storage operations. */ +export interface SessionStorageMetadata { + /** Validated immutable Session header. */ readonly meta: SessionHeader + /** Number of leading events inherited from the Session's fork parent. */ + readonly inheritedEventCount: SessionLogOffset +} + +/** Immutable logical session prepared from persistence or a live owner. */ +export interface SessionInspection extends SessionStorageMetadata { /** Validated contiguous logical event log. */ readonly events: readonly SessionEvent[] } +/** Detached logical suffix returned by one explicit stored-log offset read. */ +export interface SessionEventSuffix extends SessionStorageMetadata { + /** First requested log offset; {@link events} contains only seqs at or after it. */ + readonly fromSeq: SessionLogOffset + /** Valid contiguous stored events at or after {@link fromSeq}; not a complete Session log when the offset is nonzero. */ + readonly events: readonly SessionEvent[] +} + /** A borrowed exact Session source returned from a cold materialization or concurrent live owner. */ export type BorrowedSessionSource = Disposable & ( | { @@ -52,9 +71,7 @@ export type BorrowedSessionSource = Disposable & ( ) /** A backend's own raw artifact text for one session, verbatim. */ -export interface SessionRawArtifact { - /** The session header parsed from the artifact's own first line. */ - readonly meta: SessionHeader +export interface SessionRawArtifact extends SessionStorageMetadata { /** The artifact's base filename on disk, without any physical encoding suffix. */ readonly filename: string /** The artifact's full text content, decoded from the backend's physical encoding. */ @@ -150,8 +167,10 @@ export abstract class SessionPersistence extends Service { * created-but-never-appended session is absent from {@link list} * — abandoned sessions leave nothing behind. * @param meta - the immutable header (id, version, cwd, lineage) to record. + * @param inheritedEventCount - exact fork-inherited prefix length. Required + * for a seeded header and omitted only for an unseeded header. */ - abstract create(meta: SessionHeader): Promise + abstract create(meta: SessionHeader, inheritedEventCount?: SessionLogOffset): Promise /** * Ensure a live session has a durable header even when it has no events. @@ -168,6 +187,8 @@ export abstract class SessionPersistence extends Service { * seq contracts: the first event's `seq` MUST equal the stored next-seq * (after `load` has durably closed any interrupted turn). Rejects non-JSON- * serializable `event.data` with an error naming the offending event type. + * A seeded session's first materializing batch must reach its complete + * inherited prefix. * @param id - the session the batch belongs to. * @param events - the contiguous batch to persist, in seq order. */ @@ -194,6 +215,7 @@ export abstract class SessionPersistence extends Service { return SessionPreparation.create(sessions.prepare(id, { seed: loaded.events.map(event => structuredClone(event)), meta: structuredClone(loaded.meta), + inheritedEventCount: SessionLogOffset(loaded.inheritedEventCount), seedSource: 'persistence', })) } @@ -255,12 +277,12 @@ export abstract class SessionPersistence extends Service { * forward. The primitive bounds what is returned and refolded, not every * backend's physical read. * @param id - the persisted session to read. - * @param fromSeq - first event seq to include; a non-negative safe integer. + * @param fromSeq - first event offset to include. * @param signal - optional cancellation for queued and backend read work. - * @returns the header and the stored events with `seq >= fromSeq`. + * @returns storage metadata, the requested offset, and stored events with `seq >= fromSeq`. */ - abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): - Promise<{ meta: SessionHeader; events: SessionEvent[] }> + abstract readFrom(id: SessionId, fromSeq: SessionLogOffset, signal?: AbortSignal): + Promise /** * Lightweight listing from metadata, without a full-log parse. diff --git a/packages/session/session-persistence/tests/contract.ts b/packages/session/session-persistence/tests/contract.ts index 5be7076bd7..b8f611eebb 100644 --- a/packages/session/session-persistence/tests/contract.ts +++ b/packages/session/session-persistence/tests/contract.ts @@ -9,8 +9,22 @@ */ import { describe, expect, it } from 'vitest' -import { SESSION_FORMAT_VERSION, Session, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionHeader, SurfaceEventType, SurfaceIntent } from '@deepseek-ai/dsh-session' +import { + SESSION_FORMAT_VERSION, + Session, + SessionId, + SessionLogOffset, + SessionSeq, + TOOL_NOT_STARTED, + TOOL_OUTCOME_UNKNOWN, +} from '@deepseek-ai/dsh-session' +import type { + SessionEvent, + SessionHeader, + SessionLogOffset as SessionLogOffsetType, + SurfaceEventType, + SurfaceIntent, +} from '@deepseek-ai/dsh-session' import { ToolCallId, MessageId, createMessage, freezeMessage } from '@deepseek-ai/dsh-llm' import type { SessionPersistence } from '../src/index.ts' @@ -27,20 +41,21 @@ export function meta(id: string, cwd?: string): SessionHeader { id: SessionId(id), createdAt: 1000, ...cwd !== undefined ? { cwd } : {}, + isSeeded: false, } } /** A well-formed one-turn event log (contiguous seqs from 0). */ export function oneTurnLog(): SessionEvent[] { return [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'user/message', seq: 1, time: 2, data: freezeMessage({ + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'user/message', seq: SessionSeq(1), time: 2, data: freezeMessage({ id: MessageId('one-turn-user'), role: 'user', content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), surfaceOp: 'append' }, - { type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } }, - { type: 'assistant/message', seq: 3, time: 4, data: { + { type: 'step/start', seq: SessionSeq(2), time: 3, data: { turn: 1, step: 1 } }, + { type: 'assistant/message', seq: SessionSeq(3), time: 4, data: { turn: 1, step: 1, message: freezeMessage({ id: MessageId('one-turn-assistant'), @@ -52,8 +67,8 @@ export function oneTurnLog(): SessionEvent[] { }, }), }, surfaceOp: 'append' }, - { type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } }, - { type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'step/end', seq: SessionSeq(4), time: 5, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: SessionSeq(5), time: 6, data: { turn: 1, reason: { kind: 'completed' } } }, ] } @@ -115,6 +130,44 @@ export function runPersistenceContract(name: string, make: () => Promise { + const { persistence, dispose } = await make() + try { + const seeded = { ...meta('seeded-create'), isSeeded: true } + await expect(persistence.create(seeded)) + .rejects.toThrow('seeded session metadata requires an inherited event count') + await expect(persistence.create(meta('unseeded-nonzero'), SessionLogOffset(1))) + .rejects.toThrow('unseeded session metadata inherited event count must be 0') + + await persistence.create(seeded, SessionLogOffset(0)) + await persistence.append(seeded.id, oneTurnLog()) + await expect(persistence.load(seeded.id)).resolves.toMatchObject({ + meta: { isSeeded: true }, + inheritedEventCount: 0, + }) + } finally { + await dispose() + } + }) + + it('does not materialize a seeded artifact before its complete inherited prefix', async () => { + const { persistence, dispose } = await make() + try { + const seeded = { ...meta('seeded-partial-create'), isSeeded: true } + await persistence.create(seeded, SessionLogOffset(2)) + await expect(persistence.append(seeded.id, oneTurnLog().slice(0, 1))) + .rejects.toThrow('cannot materialize before its inherited prefix is complete') + expect((await persistence.list()).map(header => header.id)).not.toContain(seeded.id) + + await persistence.append(seeded.id, oneTurnLog()) + await expect(persistence.load(seeded.id)).resolves.toMatchObject({ + inheritedEventCount: 2, + }) + } finally { + await dispose() + } + }) + it('crash recovery: load preserves an interrupted (unclosed) turn and closes it with turn/end {interrupted}', async () => { const { persistence, dispose } = await make() try { @@ -124,8 +177,8 @@ export function runPersistenceContract(name: string, make: () => Promise snapshot.header.id === m.id)?.revision @@ -157,8 +210,8 @@ export function runPersistenceContract(name: string, make: () => Promise e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) @@ -177,9 +230,9 @@ export function runPersistenceContract(name: string, make: () => Promise Promise Promise Promise Promise event.type)).toEqual(['turn/start']) - await expect(persistence.readFrom(SessionId('absent-read-from'), 0)).rejects.toThrow('not found') - await expect(persistence.readFrom(m.id, -1)).rejects.toThrow('non-negative safe integer') - await expect(persistence.readFrom(m.id, 1.5)).rejects.toThrow('non-negative safe integer') + await expect(persistence.readFrom(SessionId('absent-read-from'), SessionLogOffset(0))) + .rejects.toThrow('not found') + await expect(persistence.readFrom(m.id, -1 as SessionLogOffsetType)) + .rejects.toThrow('non-negative safe integer') + await expect(persistence.readFrom(m.id, 1.5 as SessionLogOffsetType)) + .rejects.toThrow('non-negative safe integer') } finally { await dispose() } @@ -345,7 +409,7 @@ export function runPersistenceContract(name: string, make: () => Promise Promise Promise Promise< const header = meta(id, WORK) const start: SessionEvent = { type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: 1, data: { turn: 1 }, } @@ -322,23 +328,49 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) - it('round-trips the seed boundary (seedLength) through persistence', async () => { - // A forked child records how many leading events were inherited via the seed; the - // boundary must survive a reload (so a resume/replay can tell the inherited prefix from - // the child's own events). The backend must preserve it in stored metadata. + it('preserves the exact inherited cut through load, inspect, suffix read, prepare, and reopen', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) + const inherited = oneTurnLog() try { let session!: Session const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create(SessionId('forked-child'), { meta: { cwd: WORK, seedLength: 3 } }) + session = inner.sessions.create(SessionId('forked-child'), { + seed: inherited, + inheritedEventCount: SessionLogOffset(inherited.length), + meta: { cwd: WORK, isSeeded: true }, + }) }, { inject: ['sessions'] })) - send(session, oneTurnLog()) await ctx.sessions.flush(session) await sessionFiber.dispose() const loaded = await ctx.sessionPersistence.load(SessionId('forked-child')) - expect(loaded.meta.seedLength).toBe(3) + expect(loaded.meta.isSeeded).toBe(true) + expect(loaded.inheritedEventCount).toBe(SessionLogOffset(inherited.length)) + expect((await ctx.sessionPersistence.inspect(SessionId('forked-child'))).inheritedEventCount) + .toBe(SessionLogOffset(inherited.length)) + const suffix = await ctx.sessionPersistence.readFrom( + SessionId('forked-child'), + SessionLogOffset(3), + ) + expect(suffix.inheritedEventCount).toBe(SessionLogOffset(inherited.length)) + expect(suffix.fromSeq).toBe(3) + expect(suffix.events[0]?.seq).toBe(3) + const preparation = await ctx.sessionPersistence.prepare(SessionId('forked-child')) + expect(preparation.session.inheritedEventCount).toBe(SessionLogOffset(inherited.length)) + preparation[Symbol.dispose]() + + await fiber.dispose() + const reopened = await freshCtx(fix) + try { + const reopenedLoad = await reopened.ctx.sessionPersistence.load(SessionId('forked-child')) + expect(reopenedLoad.inheritedEventCount).toBe(SessionLogOffset(inherited.length)) + const reopenedPreparation = await reopened.ctx.sessionPersistence.prepare(SessionId('forked-child')) + expect(reopenedPreparation.session.inheritedEventCount).toBe(SessionLogOffset(inherited.length)) + reopenedPreparation[Symbol.dispose]() + } finally { + await reopened.fiber.dispose() + } } finally { await fiber.dispose() await fix.cleanup() @@ -458,7 +490,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< ]) } - const replacementSuffix = await ctx.sessionPersistence.readFrom(id, 6) + const replacementSuffix = await ctx.sessionPersistence.readFrom(id, SessionLogOffset(6)) expect(replacementSuffix.events[0]).toMatchObject({ type: 'tool/result', seq: 6, @@ -482,7 +514,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const snapshots = [ await ctx.sessionPersistence.inspect(id), - await ctx.sessionPersistence.readFrom(id, 0), + await ctx.sessionPersistence.readFrom(id, SessionLogOffset(0)), await ctx.sessionPersistence.load(id), ] for (const snapshot of snapshots) { @@ -529,7 +561,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< ]) } - const suffix = await ctx.sessionPersistence.readFrom(id, 3) + const suffix = await ctx.sessionPersistence.readFrom(id, SessionLogOffset(3)) expect(suffix.events[0]).toMatchObject({ type: 'user/message', seq: 3, @@ -563,7 +595,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const extendedId = SessionId('current-extended-turn-end') await ctx.sessionPersistence.create(meta(extendedId, WORK)) await ctx.sessionPersistence.append(extendedId, [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'extension-reason' } }, @@ -689,9 +721,28 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.sessionPersistence.create(meta(malformedId, WORK)) await ctx.sessionPersistence.append(malformedId, [malformed.event]) await expect(ctx.sessionPersistence.inspect(malformedId)).rejects.toThrow(malformed.message) - await expect(ctx.sessionPersistence.readFrom(malformedId, 0)).rejects.toThrow(malformed.message) + await expect(ctx.sessionPersistence.readFrom(malformedId, SessionLogOffset(0))) + .rejects.toThrow(malformed.message) } + const malformedReplacementId = SessionId('invalid-old-tool-result-replacement') + await ctx.sessionPersistence.create(meta(malformedReplacementId, WORK)) + await ctx.sessionPersistence.append(malformedReplacementId, [{ + type: 'tool/result', + seq: 0, + time: 1, + surfaceOp: { op: 'replace', start: -1, end: -1 }, + data: { + turn: 1, + step: 1, + callId: 'call', + content: [{ type: 'text', text: 'result' }], + isError: false, + }, + } as unknown as SessionEvent]) + await expect(ctx.sessionPersistence.inspect(malformedReplacementId)) + .rejects.toThrow('invalid replace surfaceOp') + for (const type of ['tool/result'] as const) { const malformedId = SessionId(`invalid-${type}`) await ctx.sessionPersistence.create(meta(malformedId, WORK)) @@ -720,7 +771,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } as unknown as SessionEvent]) await expect(ctx.sessionPersistence.inspect(pluginId)) .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] }) - await expect(ctx.sessionPersistence.readFrom(pluginId, 0)) + await expect(ctx.sessionPersistence.readFrom(pluginId, SessionLogOffset(0))) .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] }) for (const type of ['user/message', 'assistant/message'] as const) { @@ -733,7 +784,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< surfaceOp: 'append', data: {}, } as unknown as SessionEvent]) - await expect(ctx.sessionPersistence.readFrom(missingContentId, 0)) + await expect(ctx.sessionPersistence.readFrom(missingContentId, SessionLogOffset(0))) .rejects.toThrow('lacks an identified message') } } finally { @@ -753,7 +804,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const p = ctx.sessionPersistence.append(m.id, events) // Mutate the caller's array AND an event object after the call but before // the queued op runs: the snapshot taken at call time must shield the copy. - events.push({ type: 'turn/start', seq: 6, time: 99, data: { turn: 2 } }) + events.push({ type: 'turn/start', seq: SessionSeq(6), time: 99, data: { turn: 2 } }) if (userMsg?.type === 'user/message') { (userMsg.data as { content: unknown[] }).content = [{ type: 'text', text: 'MUTATED' }] } @@ -1170,8 +1221,8 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const contFiber = await ctx.plugin(Object.assign((inner: Context) => { cont = inner.sessions.create(SessionId('claim'), { seed: [ ...events, - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, - { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: SessionSeq(7), time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ], meta: { cwd: WORK, createdAt: 2000 } }) }, { inject: ['sessions'] })) await ctx.sessions.flush(cont) @@ -1210,6 +1261,59 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) + it('a live session cannot claim ownerless state with a different inherited cut', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const id = SessionId('wrong-cut-claim') + await ctx.sessionPersistence.create( + { ...meta(id, WORK), isSeeded: true }, + SessionLogOffset(0), + ) + const live = ctx.sessions.create(id, { + seed: oneTurnLog(), + inheritedEventCount: SessionLogOffset(1), + meta: { cwd: WORK, isSeeded: true }, + }) + + await expect(ctx.sessions.flush(live)) + .rejects.toThrow(/different inherited event count|id collision/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('a live session cannot adopt a stored prefix with a different inherited cut', async () => { + const fix = await makeFixture() + const id = SessionId('wrong-cut-adoption') + const first = await freshCtx(fix) + try { + await first.ctx.sessionPersistence.create( + { ...meta(id, WORK), isSeeded: true }, + SessionLogOffset(0), + ) + await first.ctx.sessionPersistence.append(id, oneTurnLog()) + } finally { + await first.fiber.dispose() + } + + const second = await freshCtx(fix) + try { + const live = second.ctx.sessions.create(id, { + seed: oneTurnLog(), + inheritedEventCount: SessionLogOffset(1), + meta: { cwd: WORK, isSeeded: true }, + }) + + await expect(second.ctx.sessions.flush(live)) + .rejects.toThrow(/different inherited event count|id collision/) + } finally { + await second.fiber.dispose() + await fix.cleanup() + } + }) + it('a live session at a DIFFERENT cwd cannot claim loaded-prefix ownerless state (cwd scope)', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) @@ -1262,8 +1366,8 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const second = await freshCtx(fix) try { await second.ctx.sessionPersistence.append(SessionId('adopt-append'), [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, - { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: SessionSeq(7), time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ]) const loaded = await second.ctx.sessionPersistence.load(SessionId('adopt-append')) expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) @@ -1329,7 +1433,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { - const m = { version: 99, id: SessionId('v99'), createdAt: 1, cwd: WORK } + const m = { version: 99, id: SessionId('v99'), createdAt: 1, cwd: WORK, isSeeded: false } await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error) @@ -1345,7 +1449,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { - const m = { version: -1, id: SessionId('v-older'), createdAt: 1, cwd: WORK } + const m = { version: -1, id: SessionId('v-older'), createdAt: 1, cwd: WORK, isSeeded: false } await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error) @@ -1389,7 +1493,14 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { - const m = { version: SESSION_FORMAT_VERSION, id: SessionId('forked-child'), createdAt: 1, cwd: WORK, parentSession: SessionId('the-parent') } + const m = { + version: SESSION_FORMAT_VERSION, + id: SessionId('forked-child'), + createdAt: 1, + cwd: WORK, + parentSession: SessionId('the-parent'), + isSeeded: false, + } await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) const loaded = await ctx.sessionPersistence.load(m.id) @@ -1441,8 +1552,8 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await first.ctx.sessionPersistence.append(m.id, oneTurnLog()) // committed 0..5 (balanced) // A second turn whose real events are durable but never closed (open turn). await first.ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, - { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, + { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, + { type: 'step/start', seq: SessionSeq(7), time: 8, data: { turn: 2, step: 1 } }, ]) } finally { await first.fiber.dispose() @@ -1468,8 +1579,8 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // The repair is durable: the next append continues at the balanced length // (seq 10) and a reload round-trips identically. await second.ctx.sessionPersistence.append(SessionId('torn'), [ - { type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } }, - { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(10), time: 9, data: { turn: 3 } }, + { type: 'turn/end', seq: SessionSeq(11), time: 10, data: { turn: 3, reason: { kind: 'completed' } } }, ]) const reloaded = await second.ctx.sessionPersistence.load(SessionId('torn')) expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index afb7ab59b9..dd002882f7 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -1,12 +1,22 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { + Session, + SessionId, + SessionLogOffset, + SessionSeq, +} from '@deepseek-ai/dsh-session' import { isJsonValue } from '@deepseek-ai/dsh-util-values' -import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import type { + SessionEvent, + SessionHeader, + SessionLogOffset as SessionLogOffsetType, +} from '@deepseek-ai/dsh-session' import { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, type StoredSuffix, + type PersistenceBackend, type SessionEventSuffix, type SessionInspection, type SessionPersistenceSnapshot, + type SessionStorageMetadata, type StoredPrefix, type StoredSuffix, } from '../src/index.ts' import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' import { @@ -14,10 +24,18 @@ import { } from './coordinator-contract.ts' /** The durable store shape: materialized sessions only (no lazy entries). */ -type MemoryStore = Map +type MemoryStore = Map /** Test-store revision that changes for any metadata or event mutation. */ -function memoryRevision(entry: { meta: SessionHeader; events: SessionEvent[] }): SessionPersistenceRevision { +function memoryRevision(entry: { + meta: SessionHeader + inheritedEventCount?: SessionLogOffsetType + events: SessionEvent[] +}): SessionPersistenceRevision { return SessionPersistenceRevision(JSON.stringify(entry)) } @@ -96,8 +114,8 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend return undefined } - create(m: SessionHeader): Promise { - return this.coordinator.create(m) + create(m: SessionHeader, inheritedEventCount?: SessionLogOffsetType): Promise { + return this.coordinator.create(m, inheritedEventCount) } override ensureMaterialized(session: Session): Promise { @@ -112,20 +130,28 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend return this.coordinator.prepare(id, signal) } - load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - return this.coordinator.load(id).then(loaded => ({ meta: loaded.meta, events: [...loaded.events] })) + load(id: SessionId): Promise { + return this.coordinator.load(id).then(loaded => ({ + meta: loaded.meta, + inheritedEventCount: loaded.inheritedEventCount, + events: [...loaded.events], + })) } - inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + inspect(id: SessionId, signal?: AbortSignal): Promise { return this.coordinator.inspect(id, signal) - .then(loaded => ({ meta: loaded.meta, events: [...loaded.events] })) + .then(loaded => ({ + meta: loaded.meta, + inheritedEventCount: loaded.inheritedEventCount, + events: [...loaded.events], + })) } borrowSession(id: SessionId, signal?: AbortSignal): ReturnType { return this.coordinator.borrowSession(id, signal) } - readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + readFrom(id: SessionId, fromSeq: SessionLogOffsetType, signal?: AbortSignal): Promise { return this.coordinator.readFrom(id, fromSeq, signal) } @@ -137,6 +163,7 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend if (!entry) return undefined return { meta: structuredClone(entry.meta), + inheritedEventCount: SessionLogOffset(entry.inheritedEventCount ?? 0), events: structuredClone(entry.events), revision: memoryRevision(entry), } @@ -147,31 +174,45 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend return entry === undefined ? undefined : memoryRevision(entry) } - async appendBatch(m: SessionHeader, events: readonly SessionEvent[], _isMaterialized: boolean): Promise { + async appendBatch( + storage: SessionStorageMetadata, + events: readonly SessionEvent[], + _isMaterialized: boolean, + ): Promise { // Defense-in-depth: the coordinator already validates serializability, but a // durable store must reject non-JSON data at its own boundary too. for (const e of events) { if (!isJsonValue(e.data)) throw new Error(`event "${e.type}" carries non-JSON-serializable data`) } + const { meta: m, inheritedEventCount } = storage const existing = this.store.get(m.id) if (!existing) { // The coordinator sends the first batch for materialization; later batches append. - this.store.set(m.id, { meta: structuredClone(m), events: structuredClone(events) as SessionEvent[] }) + this.store.set(m.id, { + meta: structuredClone(m), + inheritedEventCount, + events: structuredClone(events) as SessionEvent[], + }) } else { existing.events.push(...structuredClone(events) as SessionEvent[]) } } - materializeHeader(m: SessionHeader): Promise { - this.store.set(m.id, { meta: structuredClone(m), events: [] }) + materializeHeader(storage: SessionStorageMetadata): Promise { + const { meta: m, inheritedEventCount } = storage + this.store.set(m.id, { meta: structuredClone(m), inheritedEventCount, events: [] }) return Promise.resolve() } - async commitRepair(m: SessionHeader, _tornMarker: undefined, closers: readonly SessionEvent[]): Promise { + async commitRepair( + storage: SessionStorageMetadata, + _tornMarker: undefined, + closers: readonly SessionEvent[], + ): Promise { // No torn tails in a Map store, so `_tornMarker` is always undefined; only the // synthetic closers are appended (the same DELETE+INSERT a DB backend does, // minus the truncate). - const entry = this.store.get(m.id) + const entry = this.store.get(storage.meta.id) /* v8 ignore next -- commitRepair only runs for a materialized (stored) session */ if (!entry) return if (closers.length > 0) entry.events.push(...structuredClone(closers) as SessionEvent[]) @@ -203,9 +244,17 @@ class ControlledBackend implements PersistenceBackend { beforeAppend?: (attempt: number) => Promise beforeLoadStored?: (attempt: number, signal?: AbortSignal) => Promise /** When set, the declared seek hook delegates here so readFrom exercises it; unset throws (tests set it first). */ - seekHook?: (id: SessionId, fromSeq: number, signal?: AbortSignal) => Promise + seekHook?: ( + id: SessionId, + fromSeq: SessionLogOffsetType, + signal?: AbortSignal, + ) => Promise - loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise { + loadStoredFrom( + id: SessionId, + fromSeq: SessionLogOffsetType, + signal?: AbortSignal, + ): Promise { if (this.seekHook === undefined) throw new Error('seekHook not configured for this test') return this.seekHook(id, fromSeq, signal) } @@ -217,6 +266,7 @@ class ControlledBackend implements PersistenceBackend { if (entry === undefined) return undefined return { meta: structuredClone(entry.meta), + inheritedEventCount: SessionLogOffset(entry.inheritedEventCount ?? 0), events: structuredClone(entry.events), revision: memoryRevision(entry), } @@ -228,21 +278,34 @@ class ControlledBackend implements PersistenceBackend { return entry === undefined ? undefined : memoryRevision(entry) } - async appendBatch(m: SessionHeader, events: readonly SessionEvent[], _isMaterialized: boolean): Promise { + async appendBatch( + storage: SessionStorageMetadata, + events: readonly SessionEvent[], + _isMaterialized: boolean, + ): Promise { + const { meta: m, inheritedEventCount } = storage this.lastAppendedBatch = events const attempt = ++this.appendAttempts await this.beforeAppend?.(attempt) const entry = this.store.get(m.id) if (entry === undefined) { - this.store.set(m.id, { meta: structuredClone(m), events: structuredClone(events) as SessionEvent[] }) + this.store.set(m.id, { + meta: structuredClone(m), + inheritedEventCount, + events: structuredClone(events) as SessionEvent[], + }) } else { entry.events.push(...structuredClone(events) as SessionEvent[]) } } - async commitRepair(m: SessionHeader, _tornMarker: undefined, closers: readonly SessionEvent[]): Promise { + async commitRepair( + storage: SessionStorageMetadata, + _tornMarker: undefined, + closers: readonly SessionEvent[], + ): Promise { this.repairAttempts += 1 - const entry = this.store.get(m.id) + const entry = this.store.get(storage.meta.id) if (entry !== undefined) entry.events.push(...structuredClone(closers) as SessionEvent[]) } @@ -445,7 +508,7 @@ describe('PersistenceCoordinator stored identity', () => { meta: meta('different'), events: [{ type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: 1, data: { turn: 1 }, }], @@ -464,6 +527,35 @@ describe('PersistenceCoordinator stored identity', () => { } }) + it('rejects an inherited cut beyond the stored prefix before repair', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('invalid-inherited-cut') + backend.store.set(id, { + meta: { ...meta(id), isSeeded: true }, + inheritedEventCount: SessionLogOffset(2), + events: [{ + type: 'turn/start', + seq: SessionSeq(0), + time: 1, + data: { turn: 1 }, + }], + }) + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + try { + await expect(coordinator.load(id)).rejects.toThrow(/inherited event count exceeds its stored event count/) + expect(backend.repairAttempts).toBe(0) + expect((coordinator as unknown as CoordinatorInternals).states.size).toBe(0) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + it('reserves a cold id across asynchronous storage repair', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -472,7 +564,7 @@ describe('PersistenceCoordinator stored identity', () => { const header = meta(id) const start: SessionEvent = { type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: 1, data: { turn: 1 }, } @@ -634,14 +726,14 @@ describe('PersistenceCoordinator session preparations', () => { const states = (coordinator as unknown as { states: Map }).states states.set(id, { meta: owner.header, - cursor: oneTurnLog().length, + cursor: SessionLogOffset(oneTurnLog().length), materialized: true, owner, }) @@ -667,12 +759,12 @@ describe('PersistenceCoordinator session preparations', () => { const preparation = await coordinator.prepare(id) const preparations = (coordinator as unknown as { preparations: { - reservationFor: (session: Session) => { state: { cursor: number } } | undefined + reservationFor: (session: Session) => { state: { cursor: SessionLogOffsetType } } | undefined } }).preparations const reservation = preparations.reservationFor(preparation.session) if (reservation === undefined) throw new Error('test preparation must stay reserved') - reservation.state.cursor += 1 + reservation.state.cursor = SessionLogOffset(reservation.state.cursor + 1) const detach = ctx.sessions.enter(preparation.session) try { @@ -795,8 +887,8 @@ describe('PersistenceCoordinator session preparations', () => { try { const first = await coordinator.inspect(id) backend.store.get(id)!.events.push( - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, - { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: SessionSeq(7), time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ) const refreshed = await coordinator.inspect(id) @@ -824,8 +916,8 @@ describe('PersistenceCoordinator session preparations', () => { try { const inspected = await coordinator.inspect(id) backend.store.get(id)!.events.push( - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, - { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: SessionSeq(7), time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ) preparation = await coordinator.prepare(id) @@ -856,8 +948,8 @@ describe('PersistenceCoordinator session preparations', () => { const cached = await coordinator.inspect(id) preparation = await coordinator.prepare(id) backend.store.get(id)!.events.push( - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, - { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(6), time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: SessionSeq(7), time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ) await expect(coordinator.inspect(id)).resolves.toBe(cached) @@ -892,7 +984,7 @@ describe('PersistenceCoordinator session preparations', () => { const inspection = coordinator.inspect(id) const append = coordinator.append(id, [{ type: 'turn/start', - seq: oneTurnLog().length, + seq: SessionSeq(oneTurnLog().length), time: 7, data: { turn: 2 }, }]) @@ -924,7 +1016,7 @@ describe('PersistenceCoordinator session preparations', () => { try { const append = coordinator.append(id, [{ type: 'turn/start', - seq: oneTurnLog().length, + seq: SessionSeq(oneTurnLog().length), time: 7, data: { turn: 2 }, }]) @@ -961,7 +1053,7 @@ describe('PersistenceCoordinator session preparations', () => { try { await coordinator.append(id, [{ type: 'turn/start', - seq: oneTurnLog().length, + seq: SessionSeq(oneTurnLog().length), time: 7, data: { turn: 2 }, }]) @@ -1007,7 +1099,7 @@ describe('PersistenceCoordinator session preparations', () => { meta: meta(id), events: [{ type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: 1, data: { turn: 1 }, }], @@ -1049,7 +1141,7 @@ describe('PersistenceCoordinator session preparations', () => { const id = SessionId('repair-external-append') backend.store.set(id, { meta: meta(id), - events: [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }], + events: [{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }], }) const commitRepair = backend.commitRepair.bind(backend) vi.spyOn(backend, 'commitRepair').mockImplementation(async (header, tornMarker, closers) => { @@ -1058,8 +1150,8 @@ describe('PersistenceCoordinator session preparations', () => { if (entry === undefined) throw new Error('test repair must keep storage materialized') const seq = entry.events.length entry.events.push( - { type: 'turn/start', seq, time: 3, data: { turn: 2 } }, - { type: 'turn/end', seq: seq + 1, time: 4, data: { turn: 2, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(seq), time: 3, data: { turn: 2 } }, + { type: 'turn/end', seq: SessionSeq(seq + 1), time: 4, data: { turn: 2, reason: { kind: 'completed' } } }, ) }) let coordinator!: PersistenceCoordinator @@ -1094,7 +1186,7 @@ describe('PersistenceCoordinator session preparations', () => { const id = SessionId('repair-disappeared') backend.store.set(id, { meta: meta(id), - events: [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }], + events: [{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }], }) const commitRepair = backend.commitRepair.bind(backend) vi.spyOn(backend, 'commitRepair').mockImplementation(async (header, tornMarker, closers) => { @@ -1194,7 +1286,7 @@ describe('PersistenceCoordinator session preparations', () => { preparation = await coordinator.prepare(id) await expect(coordinator.append(id, [{ type: 'turn/start', - seq: oneTurnLog().length, + seq: SessionSeq(oneTurnLog().length), time: 7, data: { turn: 2 }, }])).rejects.toThrow(/persisted preparation is reserved/) @@ -1219,13 +1311,17 @@ describe('PersistenceCoordinator seek reads', () => { backend.seekHook = async (hookId, fromSeq) => { const entry = backend.store.get(hookId) if (entry === undefined) return undefined - return { meta: structuredClone(entry.meta), events: entry.events.filter(e => e.seq >= fromSeq) } + return { + meta: structuredClone(entry.meta), + inheritedEventCount: SessionLogOffset(entry.inheritedEventCount ?? 0), + events: entry.events.filter(e => e.seq >= fromSeq), + } } try { const assertLegacySuffixUsesWholePrefix = async ( events: SessionEvent[], - fromSeq: number, + fromSeq: SessionLogOffsetType, firstType: SessionEvent['type'], ): Promise => { backend.store.set(id, { meta: meta(id), events }) @@ -1235,18 +1331,18 @@ describe('PersistenceCoordinator seek reads', () => { expect(backend.loadAttempts).toBe(loadsBefore + 1) } const legacyMessages = legacyMessageLog() - await assertLegacySuffixUsesWholePrefix(legacyMessages, 1, 'user/message') - await assertLegacySuffixUsesWholePrefix(legacyMessages, 3, 'assistant/message') - await assertLegacySuffixUsesWholePrefix(legacyMessages, 5, 'tool/result') - await assertLegacySuffixUsesWholePrefix(preReactLoopLog(), 3, 'user/message') + await assertLegacySuffixUsesWholePrefix(legacyMessages, SessionLogOffset(1), 'user/message') + await assertLegacySuffixUsesWholePrefix(legacyMessages, SessionLogOffset(3), 'assistant/message') + await assertLegacySuffixUsesWholePrefix(legacyMessages, SessionLogOffset(5), 'tool/result') + await assertLegacySuffixUsesWholePrefix(preReactLoopLog(), SessionLogOffset(3), 'user/message') backend.store.set(id, { meta: meta(id), events: legacyMessages }) - const directCurrent = await coordinator.readFrom(id, 0) - backend.store.set(id, { meta: meta(id), events: directCurrent.events }) + const directCurrent = await coordinator.readFrom(id, SessionLogOffset(0)) + backend.store.set(id, { meta: meta(id), events: [...directCurrent.events] }) const loadsBeforeCurrent = backend.loadAttempts - await coordinator.readFrom(id, 1) - await coordinator.readFrom(id, 3) - await coordinator.readFrom(id, 5) + await coordinator.readFrom(id, SessionLogOffset(1)) + await coordinator.readFrom(id, SessionLogOffset(3)) + await coordinator.readFrom(id, SessionLogOffset(5)) expect(backend.loadAttempts).toBe(loadsBeforeCurrent) for (const [type, data] of [ @@ -1259,7 +1355,7 @@ describe('PersistenceCoordinator seek reads', () => { events: [{ type, seq: 0, time: 1, data } as unknown as SessionEvent], }) const loadsBefore = backend.loadAttempts - await expect(coordinator.readFrom(id, 0)).rejects.toThrow('lacks an identified message') + await expect(coordinator.readFrom(id, SessionLogOffset(0))).rejects.toThrow('lacks an identified message') expect(backend.loadAttempts).toBe(loadsBefore) } backend.store.set(id, { @@ -1269,7 +1365,7 @@ describe('PersistenceCoordinator seek reads', () => { } as unknown as SessionEvent], }) const loadsBeforeNullData = backend.loadAttempts - expect((await coordinator.readFrom(id, 0)).events[0]?.data).toBeNull() + expect((await coordinator.readFrom(id, SessionLogOffset(0))).events[0]?.data).toBeNull() expect(backend.loadAttempts).toBe(loadsBeforeNullData) } finally { await fiber.dispose() @@ -1565,17 +1661,22 @@ describe('PersistenceCoordinator observation cancellation', () => { backend.seekHook = async (hookId, fromSeq) => { const entry = backend.store.get(hookId) if (entry === undefined) return undefined - return { meta: structuredClone(entry.meta), events: entry.events.filter(e => e.seq >= fromSeq) } + return { + meta: structuredClone(entry.meta), + inheritedEventCount: SessionLogOffset(entry.inheritedEventCount ?? 0), + events: entry.events.filter(e => e.seq >= fromSeq), + } } - const suffix = await coordinator.readFrom(id, 3) + const suffix = await coordinator.readFrom(id, SessionLogOffset(3)) expect(suffix.events).toEqual(log.slice(3)) // The hook's `undefined` is the backend contract's not-found result. - await expect(coordinator.readFrom(SessionId('missing-seek'), 0)).rejects.toThrow('not found') + await expect(coordinator.readFrom(SessionId('missing-seek'), SessionLogOffset(0))) + .rejects.toThrow('not found') // A hook failure with no cancellation in play propagates as-is. const hookFailure = new Error('seek backend exploded') backend.seekHook = () => Promise.reject(hookFailure) - await expect(coordinator.readFrom(id, 0)).rejects.toBe(hookFailure) + await expect(coordinator.readFrom(id, SessionLogOffset(0))).rejects.toBe(hookFailure) // A hook failure after cancellation surfaces the caller's abort reason, // not the backend's internal teardown error. The abort fires only once @@ -1589,7 +1690,7 @@ describe('PersistenceCoordinator observation cancellation', () => { await new Promise((resolve) => { signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) throw new Error('backend teardown after abort') } - const pending = coordinator.readFrom(id, 0, controller.signal) + const pending = coordinator.readFrom(id, SessionLogOffset(0), controller.signal) const observed = pending.catch((error: unknown) => error) await vi.waitFor(() => { expect(hookEntered).toBe(true) }) controller.abort(reason) @@ -1715,7 +1816,7 @@ describe('PersistenceCoordinator retirement', () => { await readGate.promise return undefined } - const parked = coordinator.readFrom(id, 0).catch((error: unknown) => error) + const parked = coordinator.readFrom(id, SessionLogOffset(0)).catch((error: unknown) => error) await readEntered.promise // First retirement queues behind the gate and stays pending. @@ -1861,13 +1962,13 @@ describe('PersistenceCoordinator retirement', () => { await coordinator.create(meta(id)) const firstAppend = coordinator.append(id, [{ type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: 1, data: { turn: 1 }, }]) const secondAppend = coordinator.append(id, [{ type: 'turn/end', - seq: 1, + seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } }, }]) @@ -2001,7 +2102,7 @@ describe('PersistenceCoordinator retirement', () => { await coordinator.create(meta(id)) const append = coordinator.append(id, [{ type: 'turn/start', - seq: 0, + seq: SessionSeq(0), time: 1, data: { turn: 1 }, }]) @@ -2034,7 +2135,11 @@ describe('SessionPersistence service registration', () => { await ctx.sessionPersistence.ensureMaterialized(session) await expect(ctx.sessionPersistence.list()).resolves.toEqual([session.header]) - await expect(ctx.sessionPersistence.load(session.id)).resolves.toEqual({ meta: session.header, events: [] }) + await expect(ctx.sessionPersistence.load(session.id)).resolves.toEqual({ + meta: session.header, + inheritedEventCount: SessionLogOffset(0), + events: [], + }) await ctx.fiber.dispose() }) @@ -2063,11 +2168,11 @@ describe('SessionPersistence service registration', () => { await ctx.plugin(SessionStore) const endings: SessionEvent[] = [ { - type: 'turn/end', seq: 5, time: 6, + type: 'turn/end', seq: SessionSeq(5), time: 6, data: { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } }, }, { - type: 'turn/end', seq: 5, time: 6, + type: 'turn/end', seq: SessionSeq(5), time: 6, data: { turn: 1, reason: { kind: 'error', error: { message: 'failed', code: 'UNKNOWN' } } }, }, ] diff --git a/packages/session/session-persistence/tests/write-behind.spec.ts b/packages/session/session-persistence/tests/write-behind.spec.ts index df445e0cf7..8b33dc34d0 100644 --- a/packages/session/session-persistence/tests/write-behind.spec.ts +++ b/packages/session/session-persistence/tests/write-behind.spec.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session' import { SessionWriteBehind } from '../src/write-behind.ts' /** Minimal ordered event fixture; batching does not interpret event vocabulary. */ -function event(seq: number): SessionEvent<'turn/start'> { +function event(seq: SessionSeq): SessionEvent<'turn/start'> { return { type: 'turn/start', seq, @@ -25,12 +25,12 @@ describe('SessionWriteBehind', () => { write: async (events) => { batches.push(structuredClone(events) as SessionEvent[]) }, reportBackgroundFailure: vi.fn(), }) - const first = event(0) + const first = event(SessionSeq(0)) controller.enqueue(first) first.data.turn = 99 await vi.advanceTimersByTimeAsync(150) - controller.enqueue(event(1)) + controller.enqueue(event(SessionSeq(1))) await vi.advanceTimersByTimeAsync(49) expect(batches).toEqual([]) @@ -51,10 +51,10 @@ describe('SessionWriteBehind', () => { reportBackgroundFailure: vi.fn(), }) - controller.enqueue(event(0)) + controller.enqueue(event(SessionSeq(0))) for (let seq = 1; seq < 20; seq += 1) { await vi.advanceTimersByTimeAsync(10) - controller.enqueue(event(seq)) + controller.enqueue(event(SessionSeq(seq))) } expect(batches).toEqual([]) @@ -76,14 +76,14 @@ describe('SessionWriteBehind', () => { reportBackgroundFailure: vi.fn(), }) - controller.enqueue(event(0)) + controller.enqueue(event(SessionSeq(0))) const first = controller.flush() const second = controller.flush() expect(second).toBe(first) await Promise.resolve() expect(batches).toEqual([[0]]) - controller.enqueue(event(1)) + controller.enqueue(event(SessionSeq(1))) gate.resolve(true) await first expect(batches).toEqual([[0], [1]]) @@ -101,7 +101,7 @@ describe('SessionWriteBehind', () => { }) const barrier = controller.flush() - controller.enqueue(event(0)) + controller.enqueue(event(SessionSeq(0))) await barrier expect(batches).toEqual([]) expect(vi.getTimerCount()).toBe(1) @@ -124,10 +124,10 @@ describe('SessionWriteBehind', () => { reportBackgroundFailure: vi.fn(), }) - controller.enqueue(event(0)) + controller.enqueue(event(SessionSeq(0))) await vi.advanceTimersByTimeAsync(200) expect(batches).toEqual([[0]]) - controller.enqueue(event(1)) + controller.enqueue(event(SessionSeq(1))) await vi.advanceTimersByTimeAsync(200) expect(batches).toEqual([[0]]) @@ -150,9 +150,9 @@ describe('SessionWriteBehind', () => { reportBackgroundFailure: vi.fn(), }) - controller.enqueue(event(0)) + controller.enqueue(event(SessionSeq(0))) await vi.advanceTimersByTimeAsync(200) - controller.enqueue(event(1)) + controller.enqueue(event(SessionSeq(1))) await vi.advanceTimersByTimeAsync(50) gate.resolve(true) await vi.advanceTimersByTimeAsync(0) @@ -180,14 +180,14 @@ describe('SessionWriteBehind', () => { reportBackgroundFailure: report, }) - controller.enqueue(event(0)) + controller.enqueue(event(SessionSeq(0))) await vi.advanceTimersByTimeAsync(200) expect(report).toHaveBeenCalledWith(failure) expect(controller.hasWork).toBe(true) await vi.advanceTimersByTimeAsync(1_000) expect(batches).toEqual([[0]]) - controller.enqueue(event(1)) + controller.enqueue(event(SessionSeq(1))) await vi.advanceTimersByTimeAsync(199) expect(batches).toEqual([[0]]) await vi.advanceTimersByTimeAsync(1) @@ -212,7 +212,7 @@ describe('SessionWriteBehind', () => { reportBackgroundFailure: report, }) - controller.enqueue(event(0)) + controller.enqueue(event(SessionSeq(0))) await vi.advanceTimersByTimeAsync(200) const first = controller.flush() const second = controller.flush() @@ -239,12 +239,12 @@ describe('SessionWriteBehind', () => { reportBackgroundFailure: report, }) - controller.enqueue(event(0)) + controller.enqueue(event(SessionSeq(0))) await expect(controller.flush()).rejects.toBe(failure) expect(report).not.toHaveBeenCalled() expect(controller.hasWork).toBe(true) - controller.enqueue(event(1)) + controller.enqueue(event(SessionSeq(1))) await vi.advanceTimersByTimeAsync(200) expect(batches).toEqual([[0], [0, 1]]) await controller.flush() @@ -264,7 +264,7 @@ describe('SessionWriteBehind', () => { reportBackgroundFailure: vi.fn(), }) - for (let seq = 0; seq < batchSize; seq += 1) controller.enqueue(event(seq)) + for (let seq = 0; seq < batchSize; seq += 1) controller.enqueue(event(SessionSeq(seq))) await expect(controller.flush()).rejects.toBe(failure) expect(controller.hasWork).toBe(true) diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index 3bab27725d..e4fe8490a2 100644 --- a/packages/session/session-projection-cache/README.i18n.yaml +++ b/packages/session/session-projection-cache/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-cache/README.md -README.md: 9d6c7fef235013dbeb72d7b3d8fbf7e8a3beb8ba -README.zh.md: b3fbc8a4ef48687a7c353dc4c00a5b731011979d +README.md: 51b9d86724af96304cf09a5c7c1b61b7394d336a +README.zh.md: bb84b67bdde678884fd4f2be1b14b2161da8c2a0 diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index 9d6c7fef23..51b9d86724 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -58,11 +58,11 @@ Three mandatory points always write: session creation persists the seed-derived ### Reading cached values -`cachedSnapshot(meta)` synchronously serves client values from the storage domain's in-memory tables with zero I/O. It accepts only an identity-matching record and version- and schema-matching keys, then returns a `{ asOfSeq, values }` cut at the lowest served-row watermark. It returns `undefined` for an unknown id, unrelated lifecycle, absent or foreign record document, or no usable rows. `coldSnapshot(meta, events)` accepts a complete ordered log, skips the checkpointed prefix while folding, and refreshes the record without reading the persistence layer itself. +`cachedSnapshot(meta, inheritedEventCount)` synchronously serves client values from the storage domain's in-memory tables with zero I/O. It accepts only an identity-matching record and version- and schema-matching keys, then returns a `{ asOfSeq, values }` cut at the lowest served-row watermark. An unseeded listing knows that its cut is zero; a seeded header-only listing does not know the numeric cut and must skip this fast path until an authoritative body read supplies it. `coldSnapshot(meta, inheritedEventCount, events)` accepts the exact cut with a complete ordered log, skips the checkpointed prefix while folding, and refreshes the record without reading persistence itself. ### What the cache guarantees -The log leads and the cache follows: a live checkpoint flushes the session's buffered events durably before the cache row lands, so a crash can leave the cache behind the log but never ahead of it. Reads and writes share the storage domain's coherent in-memory state; the per-unit write chain mutates memory only after durability. Each version-stamped record must match the live unit schema and session header identity (`createdAt`, `cwd`), so malformed, stale, or unrelated records read as absent. The JSON backend stores each record at `/session_projcache/sessions/.json` in an owner-only directory tree. +The log leads and the cache follows: a live checkpoint flushes the session's buffered events durably before the cache row lands, so a crash can leave the cache behind the log but never ahead of it. Reads and writes share the storage domain's coherent in-memory state; the per-unit write chain mutates memory only after durability. Each version-stamped record must match the live unit schema and complete lifecycle identity (`createdAt`, `cwd`, `isSeeded`, and `inheritedEventCount`), so a row initialized under one fork cut cannot seed another. The JSON backend stores each record at `/session_projcache/sessions/.json` in an owner-only directory tree. ----- diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index b3fbc8a4ef..bb84b67bdd 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -58,11 +58,11 @@ kind: "package-reference" ### 读取缓存值 -`cachedSnapshot(meta)` 以零 I/O 从存储域的内存表同步提供客户端值。它只接受身份匹配的记录以及版本和 schema 均匹配的 key,再按所服务行的最低水位返回 `{ asOfSeq, values }` 切面。对于未知 id、无关生命周期、缺失或外来的记录文档,或没有可用行的情况,它返回 `undefined`。`coldSnapshot(meta, events)` 接受完整有序日志,在折叠时跳过已检查点化的前缀,并在自身不读取持久化层的情况下刷新记录。 +`cachedSnapshot(meta, inheritedEventCount)` 以零 I/O 从存储域的内存表同步提供客户端值。它只接受身份匹配的记录以及版本和 schema 均匹配的 key,再按所服务行的最低水位返回 `{ asOfSeq, values }` 切面。未 seeded 的列表知道切点为零;仅 header 的 seeded 列表不知道数字切点,必须跳过该快速路径,直到权威正文读取提供它。`coldSnapshot(meta, inheritedEventCount, events)` 接受精确切点与完整有序日志,在折叠时跳过已检查点化的前缀,并在自身不读取持久化层的情况下刷新记录。 ### 缓存保证什么 -日志领先,缓存跟随:实时检查点先把会话的缓冲事件持久化,然后才保存缓存记录。因此崩溃可能让缓存落后于日志,但绝不会让缓存领先。读取和写入共享存储域内一致的内存状态;逐单元写入链只在持久化成功后修改内存。每个带版本戳的记录必须匹配实时单元 schema 与会话 header 身份(`createdAt`、`cwd`),因此畸形、陈旧或无关的记录都会读作不存在。JSON 后端把每条记录存于仅所有者可访问的 `/session_projcache/sessions/.json` 目录树中。 +日志领先,缓存跟随:实时检查点先把会话的缓冲事件持久化,然后才保存缓存记录。因此崩溃可能让缓存落后于日志,但绝不会让缓存领先。读取和写入共享存储域内一致的内存状态;逐单元写入链只在持久化成功后修改内存。每个带版本戳的记录必须匹配实时单元 schema 与完整生命周期身份(`createdAt`、`cwd`、`isSeeded` 和 `inheritedEventCount`),因此在一个 fork 切点下初始化的行不能播种另一个切点。JSON 后端把每条记录存于仅所有者可访问的 `/session_projcache/sessions/.json` 目录树中。 ----- diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts index 4e067241da..35bf5d9eed 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -19,7 +19,14 @@ import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values' -import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { + Session, + SessionEvent, + SessionHeader, + SessionId, + SessionSeqCursor, +} from '@deepseek-ai/dsh-session' import type { ProjectionCheckpoint, ProjectionSnapshot, @@ -121,15 +128,18 @@ export class SessionProjectionCache extends Service { * paths (the history tail baseline) supersede these values whenever a * session is actually opened. * @param meta - the listed session's header (identity witness; no log read). + * @param inheritedEventCount - exact inherited prefix length that completes + * the checkpoint identity. * @param keys - optional projection keys required by the caller's audience. * @returns the cut (`asOfSeq` = lowest served-row watermark), or * `undefined` when no usable row exists for this lifecycle. */ cachedSnapshot( meta: SessionHeader, + inheritedEventCount: SessionLogOffset, keys?: readonly Extract[], ): ProjectionSnapshot | undefined { - const record = this.recordFor(meta.id, identityOf(meta)) + const record = this.recordFor(meta.id, identityOf(meta, inheritedEventCount)) if (record === undefined) return undefined const values = this.ctx.sessionProjections.viewCheckpoint(record.rows, keys) const servedKeys = Object.keys(values) @@ -137,7 +147,15 @@ export class SessionProjectionCache extends Service { // The block carries ONE cut: the lowest served watermark is the seq every // value is at least current as of (under-claiming is safe under // higher-seq-wins; over-claiming would let a stale value outrank pushes). - const asOfSeq = Math.min(...servedKeys.map(key => (record.rows[key] as { seq: number }).seq)) + let asOfSeq: SessionSeqCursor | undefined + for (const key of servedKeys) { + const row = record.rows[key] + if (row !== undefined && (asOfSeq === undefined || row.seq < asOfSeq)) { + asOfSeq = row.seq + } + } + /* v8 ignore next -- A nonempty checkpoint view contains a stored row for every returned key. */ + if (asOfSeq === undefined) return undefined return { asOfSeq, values } } @@ -147,25 +165,31 @@ export class SessionProjectionCache extends Service { * advances every unit to the observation cut. No checkpoint is written * because the logical observation may contain recovery events not yet durable. * @param session - exact unpublished Session retained by persistence. - * @param meta - observed lifecycle header. * @param events - exact logical event prefix represented by the observation. * @returns all projection values at the event cut. */ hydratePrepared( session: Session, - meta: SessionHeader, events: readonly SessionEvent[], ): ProjectionSnapshot { - const record = this.recordFor(meta.id, identityOf(meta)) + const record = this.recordFor( + session.id, + identityOf(session.header, session.inheritedEventCount), + ) if (record === undefined) { - return this.ctx.sessionProjections.hydrate(session, {}, events, 0) + return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0)) } try { - return this.ctx.sessionProjections.hydrate(session, record.rows, events, 0) + return this.ctx.sessionProjections.hydrate( + session, + record.rows, + events, + SessionLogOffset(0), + ) } catch { // Cached rows are disposable derived data. Retry from the exact log so a // stale schema cannot make a valid Session unreadable. - return this.ctx.sessionProjections.hydrate(session, {}, events, 0) + return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0)) } } @@ -189,7 +213,11 @@ export class SessionProjectionCache extends Service { // already gone; persistence's own retirement drain covers that path and // any residual overreach is caught by the cold read's anchored floor. if (this.ctx.sessions.get(session.id) === session) await this.ctx.sessions.flush(session) - await this.put(session.id, identityOf(session.header), rows) + await this.put( + session.id, + identityOf(session.header, session.inheritedEventCount), + rows, + ) } /** @@ -201,14 +229,26 @@ export class SessionProjectionCache extends Service { * The caller supplies the complete log in seq order: this service never * consults the persistence layer. * @param meta - the stored session header (identity witness). + * @param inheritedEventCount - exact inherited prefix length for projection initialization and identity. * @param events - the session's complete log, in seq order. * @returns the projection cut at the log end. */ - coldSnapshot(meta: SessionHeader, events: readonly SessionEvent[]): ProjectionSnapshot { - const restored = this.ctx.sessionProjections.restore(this.recordFor(meta.id, identityOf(meta))?.rows ?? {}, events, 0, meta) + coldSnapshot( + meta: SessionHeader, + inheritedEventCount: SessionLogOffset, + events: readonly SessionEvent[], + ): ProjectionSnapshot { + const identity = identityOf(meta, inheritedEventCount) + const restored = this.ctx.sessionProjections.restore( + this.recordFor(meta.id, identity)?.rows ?? {}, + events, + SessionLogOffset(0), + meta, + inheritedEventCount, + ) // Refresh the row so the next cold read seeds from it; fail-soft and // fire-and-forget — a failed write-back only costs a longer tail replay. - void this.put(meta.id, identityOf(meta), restored.checkpoint).catch((error: unknown) => { + void this.put(meta.id, identity, restored.checkpoint).catch((error: unknown) => { this.ctx.logger.warn(`session projection cache: cold-read write-back for "${meta.id}" failed (cache stays stale): ${String(error)}`) }) return restored.snapshot @@ -311,13 +351,28 @@ export class SessionProjectionCache extends Service { } /** Project a header onto the identity fields a record is bound to. */ -function identityOf(header: SessionHeader): CheckpointIdentity { - return { createdAt: header.createdAt, ...header.cwd === undefined ? {} : { cwd: header.cwd } } +function identityOf( + header: SessionHeader, + inheritedEventCount: SessionLogOffset, +): CheckpointIdentity { + const cut = SessionLogOffset(inheritedEventCount) + if (!header.isSeeded && cut !== 0) { + throw new Error('unseeded projection-cache identity inherited event count must be 0') + } + return { + createdAt: header.createdAt, + ...header.cwd === undefined ? {} : { cwd: header.cwd }, + isSeeded: header.isSeeded, + inheritedEventCount: cut, + } } /** Whether a stored record's bound identity names the caller's lifecycle. */ function identityMatches(stored: CheckpointIdentity, expected: CheckpointIdentity): boolean { - return stored.createdAt === expected.createdAt && stored.cwd === expected.cwd + return stored.createdAt === expected.createdAt + && stored.cwd === expected.cwd + && stored.isSeeded === expected.isSeeded + && stored.inheritedEventCount === expected.inheritedEventCount } export default SessionProjectionCache diff --git a/packages/session/session-projection-cache/src/spec.ts b/packages/session/session-projection-cache/src/spec.ts index 8564ec710c..35351917a5 100644 --- a/packages/session/session-projection-cache/src/spec.ts +++ b/packages/session/session-projection-cache/src/spec.ts @@ -11,7 +11,8 @@ */ import { z } from 'zod' -import type { SessionId } from '@deepseek-ai/dsh-session' +import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' +import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session' import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' /** @@ -24,7 +25,8 @@ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' */ export const checkpointRow = z.object({ ver: z.number().int().nonnegative(), - seq: z.number().int().gte(-1), + seq: z.number().int().gte(-1).transform((value): SessionSeqCursor => + value === -1 ? -1 : SessionSeq(value)), val: z.json(), }) @@ -40,6 +42,8 @@ export const checkpointRow = z.object({ export const checkpointIdentity = z.object({ createdAt: z.number().int().nonnegative(), cwd: z.string().optional(), + isSeeded: z.boolean(), + inheritedEventCount: z.number().int().nonnegative().transform(SessionLogOffset), }) /** The identity fields a record is bound to, inferred from {@link checkpointIdentity}. */ @@ -68,7 +72,7 @@ export type CheckpointRecord = z.infer */ export const projectionCacheDomainSpec = defineDomain({ name: 'session_projcache', - version: 4, + version: 5, layout: 'per-record', tables: { sessions: domainTable(checkpointRecord) }, }) diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 6fe271871e..bfdca18567 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -16,7 +16,12 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { + Session, + SessionId, + SessionLogOffset, + SessionSeq, +} from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' @@ -80,7 +85,7 @@ const recordPath = (root: string, id: Session['id']): string => /** Header shape for cachedSnapshot calls. */ const headerOf = (id: SessionId, createdAt = 0, cwd?: string) => - ({ version: 0, id, createdAt, ...cwd === undefined ? {} : { cwd } }) + ({ version: 0, id, createdAt, isSeeded: false, ...cwd === undefined ? {} : { cwd } }) interface HarnessOptions { root?: string @@ -134,7 +139,11 @@ async function seedRecord( root: string, id: string, rows: CheckpointRecord['rows'], - identity: CheckpointRecord['identity'] = { createdAt: 0 }, + identity: CheckpointRecord['identity'] = { + createdAt: 0, + isSeeded: false, + inheritedEventCount: SessionLogOffset(0), + }, ): Promise { const path = recordPath(root, SessionId(id)) await mkdir(dirname(path), { recursive: true }) @@ -294,36 +303,77 @@ describe('SessionProjectionCache write policy', () => { }) describe('SessionProjectionCache listing read', () => { + it('refuses a checkpoint created for a different inherited cut', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) + roots.push(root) + const id = SessionId('cut-identity') + await seedRecord( + root, + id, + { 'cache-test/marks': { ver: 1, seq: SessionSeq(1), val: { marks: ['seed'] } } }, + { + createdAt: 0, + isSeeded: true, + inheritedEventCount: SessionLogOffset(2), + }, + ) + const { cache } = await harness({ root }) + const seededHeader = { ...headerOf(id), isSeeded: true } + + expect(cache.cachedSnapshot(seededHeader, SessionLogOffset(2))?.values['cache-test/marks']) + .toEqual({ marks: ['seed'] }) + expect(cache.cachedSnapshot(seededHeader, SessionLogOffset(1))).toBeUndefined() + expect(() => cache.cachedSnapshot(headerOf(id), SessionLogOffset(1))) + .toThrow('unseeded projection-cache identity inherited event count must be 0') + }) + + it('serves a creation-time checkpoint at the before-first-event cursor', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) + roots.push(root) + await seedRecord(root, 'before-first-event', { + 'cache-test/marks': { ver: 1, seq: -1, val: null }, + }) + const { cache } = await harness({ root }) + + expect(cache.cachedSnapshot(headerOf(SessionId('before-first-event')), SessionLogOffset(0))) + .toEqual({ asOfSeq: -1, values: { 'cache-test/marks': { marks: [] } } }) + }) + it('keeps host-only checkpoint state out of cached wire snapshots', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) roots.push(root) await seedRecord(root, 'host-state', { - 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['wire'] } }, - 'cache-test/secret': { ver: 1, seq: 4, val: 'private prompt text' }, + 'cache-test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['wire'] } }, + 'cache-test/secret': { ver: 1, seq: SessionSeq(4), val: 'private prompt text' }, }) const { ctx, cache } = await harness({ root }) ctx.sessionProjections.register(secretUnit) const header = headerOf(SessionId('host-state')) - expect(cache.cachedSnapshot(header)).toEqual({ + expect(cache.cachedSnapshot(header, SessionLogOffset(0))).toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['wire'] } }, }) - expect(JSON.stringify(cache.cachedSnapshot(header))).not.toContain('private prompt text') + expect(JSON.stringify(cache.cachedSnapshot(header, SessionLogOffset(0)))) + .not.toContain('private prompt text') }) it('serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) roots.push(root) - await seedRecord(root, 'listed', { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['t'] } } }) + await seedRecord(root, 'listed', { + 'cache-test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['t'] } }, + }) const { cache } = await harness({ root }) const id = SessionId('listed') // Matching header: values plus the watermark the client seeds under. - expect(cache.cachedSnapshot(headerOf(id))).toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } }) + expect(cache.cachedSnapshot(headerOf(id), SessionLogOffset(0))) + .toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } }) // A recreated id (different createdAt): the record is unrelated — no block. - expect(cache.cachedSnapshot(headerOf(id, 777))).toBeUndefined() + expect(cache.cachedSnapshot(headerOf(id, 777), SessionLogOffset(0))).toBeUndefined() // Unknown id: no block. - expect(cache.cachedSnapshot(headerOf(SessionId('never-cached')))).toBeUndefined() + expect(cache.cachedSnapshot(headerOf(SessionId('never-cached')), SessionLogOffset(0))) + .toBeUndefined() }) it('returns undefined when the stored record is version-mismatched', async () => { @@ -334,10 +384,14 @@ describe('SessionProjectionCache listing read', () => { await mkdir(dirname(path), { recursive: true }) await writeFile(path, JSON.stringify({ version: projectionCacheDomainSpec.version + 1, - record: { identity: { createdAt: 0 }, rows: { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['old'] } } } }, + record: { + identity: { createdAt: 0, isSeeded: false, inheritedEventCount: 0 }, + rows: { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['old'] } } }, + }, })) const { cache } = await harness({ root }) - expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')))).toBeUndefined() + expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')), SessionLogOffset(0))) + .toBeUndefined() }) it('returns undefined when every stored row is version-mismatched', async () => { @@ -345,20 +399,31 @@ describe('SessionProjectionCache listing read', () => { roots.push(root) // A current document whose rows all fail the live unit's stateVersion: // the listing view is empty, so no block is served. - await seedRecord(root, 'row-stale', { 'cache-test/marks': { ver: 99, seq: 4, val: { marks: ['old'] } } }) + await seedRecord(root, 'row-stale', { + 'cache-test/marks': { ver: 99, seq: SessionSeq(4), val: { marks: ['old'] } }, + }) const { cache } = await harness({ root }) - expect(cache.cachedSnapshot(headerOf(SessionId('row-stale')))).toBeUndefined() + expect(cache.cachedSnapshot(headerOf(SessionId('row-stale')), SessionLogOffset(0))) + .toBeUndefined() }) it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) roots.push(root) - await seedRecord(root, 'homed', { 'cache-test/marks': { ver: 1, seq: 2, val: { marks: ['w'] } } }, { createdAt: 0, cwd: '/work' }) + await seedRecord(root, 'homed', { + 'cache-test/marks': { ver: 1, seq: SessionSeq(2), val: { marks: ['w'] } }, + }, { + createdAt: 0, + cwd: '/work', + isSeeded: false, + inheritedEventCount: SessionLogOffset(0), + }) const { cache } = await harness({ root }) const id = SessionId('homed') - expect(cache.cachedSnapshot(headerOf(id, 0, '/work'))?.values['cache-test/marks']).toEqual({ marks: ['w'] }) - expect(cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'))).toBeUndefined() - expect(cache.cachedSnapshot(headerOf(id, 0))).toBeUndefined() + expect(cache.cachedSnapshot(headerOf(id, 0, '/work'), SessionLogOffset(0))?.values['cache-test/marks']) + .toEqual({ marks: ['w'] }) + expect(cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'), SessionLogOffset(0))).toBeUndefined() + expect(cache.cachedSnapshot(headerOf(id, 0), SessionLogOffset(0))).toBeUndefined() }) it('returns undefined for a malformed record document (refold from the log on the caller side)', async () => { @@ -368,7 +433,8 @@ describe('SessionProjectionCache listing read', () => { await mkdir(dirname(path), { recursive: true }) await writeFile(path, 'not json at all') const { cache } = await harness({ root }) - expect(cache.cachedSnapshot(headerOf(SessionId('malformed')))).toBeUndefined() + expect(cache.cachedSnapshot(headerOf(SessionId('malformed')), SessionLogOffset(0))) + .toBeUndefined() }) }) @@ -376,12 +442,22 @@ describe('SessionProjectionCache cold-read seeding', () => { /** One session's event log: turn/start, one mark per group, turn/end. */ const storedLog = (marks: string[][]): SessionEvent[] => { const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, + { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } }, ] for (const m of marks) { - events.push({ type: 'cache-test/mark', seq: events.length, time: events.length, data: { marks: m } }) + events.push({ + type: 'cache-test/mark', + seq: SessionSeq(events.length), + time: events.length, + data: { marks: m }, + }) } - events.push({ type: 'turn/end', seq: events.length, time: events.length, data: { turn: 1, reason: { kind: 'completed' } } }) + events.push({ + type: 'turn/end', + seq: SessionSeq(events.length), + time: events.length, + data: { turn: 1, reason: { kind: 'completed' } }, + }) return events } @@ -391,10 +467,10 @@ describe('SessionProjectionCache cold-read seeding', () => { // Records land on disk before the domain opens, so the in-memory table // picks them up at init. await seedRecord(root, 'prepared-seeded', { - 'cache-test/marks': { ver: 1, seq: 1, val: { marks: ['cached'] } }, + 'cache-test/marks': { ver: 1, seq: SessionSeq(1), val: { marks: ['cached'] } }, }) await seedRecord(root, 'prepared-fallback', { - 'cache-test/marks': { ver: 1, seq: 1, val: { marks: 'malformed' } }, + 'cache-test/marks': { ver: 1, seq: SessionSeq(1), val: { marks: 'malformed' } }, }) const { cache } = await harness({ root }) const events = storedLog([['fresh']]) @@ -402,7 +478,7 @@ describe('SessionProjectionCache cold-read seeding', () => { // A matching row hydrates the prepared Session without a persistence read. const seeded = headerOf(SessionId('prepared-seeded')) const seededSession = Session.create(seeded.id, events, seeded) - expect(cache.hydratePrepared(seededSession, seeded, events)).toEqual({ + expect(cache.hydratePrepared(seededSession, events)).toEqual({ asOfSeq: 2, values: { 'cache-test/marks': { marks: ['cached'] } }, }) @@ -411,7 +487,7 @@ describe('SessionProjectionCache cold-read seeding', () => { // exact log so a valid Session stays readable. const fallback = headerOf(SessionId('prepared-fallback')) const fallbackSession = Session.create(fallback.id, events, fallback) - expect(cache.hydratePrepared(fallbackSession, fallback, events)).toEqual({ + expect(cache.hydratePrepared(fallbackSession, events)).toEqual({ asOfSeq: 2, values: { 'cache-test/marks': { marks: ['fresh'] } }, }) @@ -419,7 +495,7 @@ describe('SessionProjectionCache cold-read seeding', () => { // No row at all: hydrate from init over the exact log. const bare = headerOf(SessionId('prepared-bare')) const bareSession = Session.create(bare.id, events, bare) - expect(cache.hydratePrepared(bareSession, bare, events)).toEqual({ + expect(cache.hydratePrepared(bareSession, events)).toEqual({ asOfSeq: 2, values: { 'cache-test/marks': { marks: ['fresh'] } }, }) @@ -430,8 +506,12 @@ describe('SessionProjectionCache cold-read seeding', () => { roots.push(root) // A cached row covering the prefix through seq 2 (three applies folded). await seedRecord(root, 'cold-snap', { - 'cache-test/count': { ver: 1, seq: 2, val: 3 }, - }, { createdAt: 9 }) + 'cache-test/count': { ver: 1, seq: SessionSeq(2), val: 3 }, + }, { + createdAt: 9, + isSeeded: false, + inheritedEventCount: SessionLogOffset(0), + }) const { cache, ctx } = await harness({ root }) const apply = vi.fn((_state: number, _event: SessionEvent) => 1) ctx.sessionProjections.register({ @@ -443,9 +523,9 @@ describe('SessionProjectionCache cold-read seeding', () => { } satisfies ProjectionDefinition<'cache-test/count', number>) const meta = headerOf(SessionId('cold-snap'), 9) const events = Array.from({ length: 5 }, (_, seq) => ({ - type: 'cache-test/mark', seq, time: seq, data: { marks: [`m${seq}`] }, + type: 'cache-test/mark', seq: SessionSeq(seq), time: seq, data: { marks: [`m${seq}`] }, })) as SessionEvent[] - const snapshot = cache.coldSnapshot(meta, events) + const snapshot = cache.coldSnapshot(meta, SessionLogOffset(0), events) // The full log was traversed, but the fold applied only seqs 3 and 4. expect(apply).toHaveBeenCalledTimes(2) expect(apply.mock.calls.map(call => call[1].seq)).toEqual([3, 4]) @@ -459,7 +539,7 @@ describe('SessionProjectionCache cold-read seeding', () => { // No cached row yet: the first cold read folds from init over the full // log and creates the cache row (the `?? {}` seed path). const fresh = headerOf(SessionId('cold-fresh'), 10) - cache.coldSnapshot(fresh, events) + cache.coldSnapshot(fresh, SessionLogOffset(0), events) expect(apply).toHaveBeenCalledTimes(7) // 2 tail + 5 full await vi.waitFor(async () => { expect((await storedRows(root, fresh.id))?.['cache-test/count']?.seq).toBe(4) @@ -483,7 +563,7 @@ describe('SessionProjectionCache cold-read seeding', () => { // fail; the cold read itself still succeeds and never throws. const meta = headerOf(SessionId('cold-fail')) await mkdir(recordPath(root, meta.id), { recursive: true }) - expect(ctx.sessionProjectionCache.coldSnapshot(meta, [])).toBeDefined() + expect(ctx.sessionProjectionCache.coldSnapshot(meta, SessionLogOffset(0), [])).toBeDefined() // The failed write-back is fire-and-forget: poll for the warn instead of // assuming a fixed settle window (slow runners exceed it). await vi.waitFor(() => { diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 1a7555e5c6..b740065e58 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: 85c4813cee8807912b1fe3abb25412408c824df6 -README.zh.md: ba2f8f71258dc86a09bcfc5c4c3b5a5b16c127ff +README.md: 79902cca815da1ee93916ece82cf89bb3624a3a8 +README.zh.md: 749934419cfc94a83abcb59013776fbb8b55ac19 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 85c4813cee..79902cca81 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -40,7 +40,7 @@ const definition = { key: 'todo', stateSchema: todoStateSchema, stateVersion: 1, - init: () => ({ items: [] }), + init: (_header, _inheritedEventCount) => ({ items: [] }), apply: (state, event) => event.type === 'todo/upsert' ? { items: event.data.items } : state, @@ -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. 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. +`init(header, inheritedEventCount)` receives both lightweight metadata and the exact fork-inherited cut; it must not infer that cut from `firstLiveSeq` or `session/end-seed`. `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 @@ -64,7 +64,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ### Persisted checkpoints -Every unit's state is checkpointed — client-visible and host-only alike — through `checkpoint(session)`, and the sibling [session-projection-cache](../session-projection-cache/README.md) persists those checkpoints so cold reads skip full log loads. `restoreFloor` and `restore` implement the read recipe (cached state plus a forward tail replay) without a live session. +Every unit's state is checkpointed — client-visible and host-only alike — through `checkpoint(session)`, and the sibling [session-projection-cache](../session-projection-cache/README.md) persists those checkpoints so cold reads skip full log loads. Checkpoint watermarks use `SessionSeqCursor` (`-1` for an empty log), while replay starts use `SessionLogOffset`; `restoreFloor` and `restore` implement the read recipe without conflating an existing event with a log gap. ----- diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index ba2f8f7125..749934419c 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -40,7 +40,7 @@ const definition = { key: 'todo', stateSchema: todoStateSchema, stateVersion: 1, - init: () => ({ items: [] }), + init: (_header, _inheritedEventCount) => ({ items: [] }), apply: (state, event) => event.type === 'todo/upsert' ? { items: event.data.items } : state, @@ -51,7 +51,7 @@ const definition = { } ``` -`apply` 必须同步,且对与单元无关的事件必须返回同一个状态引用——引用不变意味着零下游工作。注册表用 `Object.is` 比较相邻的 `wire.view` 原始结果;对象或数组 view 若要在仅内部 state 变化时抑制发布,就必须复用引用,结构相同的新对象仍算变化。携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量。 +`init(header, inheritedEventCount)` 同时接收轻量元数据与精确的 fork 继承切点;它不得从 `firstLiveSeq` 或 `session/end-seed` 推断该切点。`apply` 必须同步,且对与单元无关的事件必须返回同一个状态引用——引用不变意味着零下游工作。注册表用 `Object.is` 比较相邻的 `wire.view` 原始结果;对象或数组 view 若要在仅内部 state 变化时抑制发布,就必须复用引用,结构相同的新对象仍算变化。携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量。 ### 注册与读取 @@ -64,7 +64,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ### 持久检查点 -每个单元的状态都会被检查点化——client-visible 与 host-only 一视同仁——通过 `checkpoint(session)`,同级包 [session-projection-cache](../session-projection-cache/README.zh.md) 持久化这些检查点,使冷读跳过全量日志加载。`restoreFloor` 与 `restore` 在无活动会话的情况下实现读取配方(缓存状态加正向尾部回放)。 +每个单元的状态都会被检查点化——client-visible 与 host-only 一视同仁——通过 `checkpoint(session)`,同级包 [session-projection-cache](../session-projection-cache/README.zh.md) 持久化这些检查点,使冷读跳过全量日志加载。检查点水位使用 `SessionSeqCursor`(空日志为 `-1`),回放起点使用 `SessionLogOffset`;`restoreFloor` 与 `restore` 在无活动会话的情况下实现读取配方,且不会混淆已有事件与日志间隙。 ----- diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 488cd72914..edfa4820b3 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -19,7 +19,13 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { ZodType } from 'zod' -import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' +import type { + Session, + SessionEvent, + SessionHeader, + SessionSeqCursor, +} from '@deepseek-ai/dsh-session' declare module '@deepseek-ai/cordis' { interface Context { @@ -50,9 +56,10 @@ export interface ProjectionDefinition< /** * State for the empty log and its immutable Session metadata. * @param header - immutable metadata for the Session being projected. + * @param inheritedEventCount - exact fork-inherited prefix length. * @returns the initial state. */ - init(header: SessionHeader): NoInfer + init(header: SessionHeader, inheritedEventCount: SessionLogOffset): NoInfer /** * Pure transition: previous state + one committed event → next state. A * unit uninterested in an event MUST return the same state reference — an @@ -94,7 +101,7 @@ export type ProjectionChangeListener = ( session: Session, key: Extract, value: unknown, - seq: number, + seq: SessionSeq, ) => void /** @@ -104,7 +111,7 @@ export type ProjectionChangeListener = ( */ export interface ProjectionSnapshot { /** Seq of the last event the values reflect; -1 for an empty log. */ - asOfSeq: number + asOfSeq: SessionSeqCursor /** Whole current client value per registered key. */ values: Partial } @@ -121,7 +128,7 @@ export interface ProjectionCheckpointRow { /** The registering unit's `stateVersion` at fold time. */ ver: number /** Seq of the last event folded into `val`; -1 for the empty log. */ - seq: number + seq: SessionSeqCursor /** The unit's internal state — plain JSON per the unit contract. */ val: unknown } @@ -133,7 +140,7 @@ export type ProjectionCheckpoint = Record interface ErasedDefinition { key: string stateSchema: { parse(value: unknown): unknown } - init(header: SessionHeader): unknown + init(header: SessionHeader, inheritedEventCount: SessionLogOffset): unknown apply(state: unknown, event: SessionEvent): unknown wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined stateVersion: number @@ -143,7 +150,7 @@ interface ErasedDefinition { interface UnitCell { state: unknown /** Seq of the last event passed through `apply` (regardless of change). */ - observedSeq: number + observedSeq: SessionSeqCursor /** `[previousView, currentView]`; undefined slots mean no cached comparison. */ readonly views: [unknown, unknown] } @@ -166,6 +173,11 @@ interface Registration { refs: number } +/** Convert a log offset to the inclusive cursor immediately before it. */ +function cursorBefore(offset: SessionLogOffset): SessionSeqCursor { + return offset === 0 ? -1 : SessionSeq(offset - 1) +} + /** * `ctx.sessionProjections`: the projection unit table and its drive. The * service subscribes to `session/event` once; every committed event passes @@ -199,7 +211,7 @@ export class SessionProjectionRegistry extends Service { for (const registration of this.registrations.values()) { if (registration.cells.has(session)) continue registration.cells.set(session, { - state: registration.def.init(session.header), + state: registration.def.init(session.header, session.inheritedEventCount), observedSeq: -1, views: [undefined, undefined], }) @@ -248,7 +260,7 @@ export class SessionProjectionRegistry extends Service { const erased: ErasedDefinition = { key: definition.key, stateSchema: definition.stateSchema, - init: header => definition.init(header), + init: (header, inheritedEventCount) => definition.init(header, inheritedEventCount), apply: (state, event) => definition.apply(state as S, event), wire: wire === undefined ? undefined @@ -336,7 +348,7 @@ export class SessionProjectionRegistry extends Service { const cell = this.cellFor(registration, session) values[registration.def.key] = this.viewCell(registration, cell) } - return { asOfSeq: session.seq - 1, values } + return { asOfSeq: cursorBefore(session.seq), values } } /** @@ -352,7 +364,7 @@ export class SessionProjectionRegistry extends Service { keys?: readonly Extract[], ): ProjectionSnapshot | undefined { const values: Record = {} - let asOfSeq: number | undefined + let asOfSeq: SessionSeqCursor | undefined const selected = keys === undefined ? undefined : new Set(keys) for (const registration of this.registrations.values()) { if (registration.def.wire === undefined) continue @@ -360,9 +372,9 @@ export class SessionProjectionRegistry extends Service { const cell = registration.cells.get(session) if (cell === undefined) continue values[registration.def.key] = this.viewCell(registration, cell) - asOfSeq = asOfSeq === undefined - ? cell.observedSeq - : Math.min(asOfSeq, cell.observedSeq) + if (asOfSeq === undefined || cell.observedSeq < asOfSeq) { + asOfSeq = cell.observedSeq + } } return asOfSeq === undefined ? undefined : { asOfSeq, values } } @@ -410,7 +422,7 @@ export class SessionProjectionRegistry extends Service { * when no unit is registered (no read needed — {@link restore} would * serve empty values regardless). */ - restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined { + restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined { let floor: number | undefined for (const registration of this.registrations.values()) { const row = checkpoint[registration.def.key] @@ -419,7 +431,7 @@ export class SessionProjectionRegistry extends Service { : 0 floor = floor === undefined ? need : Math.min(floor, need) } - return floor === undefined ? undefined : Math.max(floor - 1, 0) + return floor === undefined ? undefined : SessionLogOffset(Math.max(floor - 1, 0)) } /** @@ -475,6 +487,7 @@ export class SessionProjectionRegistry extends Service { * @param events - the stored events with `seq >= baseSeq`, in seq order. * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty). * @param header - immutable metadata for the Session being restored. + * @param inheritedEventCount - exact fork-inherited prefix length supplied to unit initialization. * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last * supplied event's seq, `baseSeq - 1` for an empty tail) plus the * refreshed checkpoint rows at that cut, ready for a durable write-back. @@ -482,11 +495,13 @@ export class SessionProjectionRegistry extends Service { restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], - baseSeq: number, + baseSeq: SessionLogOffset, header: SessionHeader, + inheritedEventCount: SessionLogOffset, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } { - const endSeq = events.at(-1)?.seq ?? baseSeq - 1 + const endSeq: SessionSeqCursor = events.at(-1)?.seq ?? cursorBefore(baseSeq) + const beforeBase = cursorBefore(baseSeq) const values: Record = {} const refreshed: ProjectionCheckpoint = {} for (const registration of this.registrations.values()) { @@ -494,7 +509,7 @@ export class SessionProjectionRegistry extends Service { const row = checkpoint[def.key] const usable = row !== undefined && row.ver === def.stateVersion - && row.seq >= baseSeq - 1 + && row.seq >= beforeBase && row.seq <= endSeq if (!usable && baseSeq > 0) { throw new Error( @@ -502,12 +517,14 @@ export class SessionProjectionRegistry extends Service { + 'its checkpoint row is missing, version-mismatched, or beyond the supplied log end; re-read from seq 0', ) } - let state = usable ? def.stateSchema.parse(row.val) : def.init(header) - const from = usable ? row.seq : baseSeq - 1 + let state = usable + ? def.stateSchema.parse(row.val) + : def.init(header, inheritedEventCount) + const from = usable ? row.seq : beforeBase const startIndex = from - baseSeq + 1 for (let index = startIndex; index < events.length; index++) { const event = events[index] - const expectedSeq = baseSeq + index + const expectedSeq = SessionSeq(baseSeq + index) if (event === undefined || event.seq !== expectedSeq) { throw new Error(`session projection ${JSON.stringify(def.key)} cannot restore across missing seq ${String(expectedSeq)}`) } @@ -536,9 +553,9 @@ export class SessionProjectionRegistry extends Service { session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], - baseSeq: number, + baseSeq: SessionLogOffset, ): ProjectionSnapshot { - const endSeq = events.at(-1)?.seq ?? baseSeq - 1 + const endSeq: SessionSeqCursor = events.at(-1)?.seq ?? cursorBefore(baseSeq) let complete = true for (const registration of this.registrations.values()) { const current = registration.cells.get(session) @@ -556,7 +573,13 @@ export class SessionProjectionRegistry extends Service { } return { asOfSeq: endSeq, values } } - const restored = this.restore(checkpoint, events, baseSeq, session.header) + const restored = this.restore( + checkpoint, + events, + baseSeq, + session.header, + session.inheritedEventCount, + ) for (const registration of this.registrations.values()) { const row = restored.checkpoint[registration.def.key] if (row === undefined) continue @@ -580,9 +603,10 @@ export class SessionProjectionRegistry extends Service { private buildCell( def: ErasedDefinition, header: SessionHeader, + inheritedEventCount: SessionLogOffset, events: readonly SessionEvent[], ): UnitCell { - let state = def.init(header) + let state = def.init(header, inheritedEventCount) for (const event of events) state = def.apply(state, event) return { state, observedSeq: (events.at(-1)?.seq ?? -1), views: [undefined, undefined] } } @@ -591,10 +615,15 @@ 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.snapshotEvents()) + cell = this.buildCell( + registration.def, + session.header, + session.inheritedEventCount, + session.snapshotEvents(), + ) registration.cells.set(session, cell) } else { - this.advanceCell(registration.def, cell, session, session.seq - 1) + this.advanceCell(registration.def, cell, session, cursorBefore(session.seq)) } return cell } @@ -604,11 +633,11 @@ export class SessionProjectionRegistry extends Service { def: ErasedDefinition, cell: UnitCell, session: Session, - throughSeq: number, + throughSeq: SessionSeqCursor, ): void { if (cell.observedSeq >= throughSeq) return for (let seq = cell.observedSeq + 1; seq <= throughSeq; seq++) { - const event = session.eventAt(seq) + const event = session.eventAt(SessionSeq(seq)) if (event === undefined || event.seq !== seq) { throw new Error(`session projection ${JSON.stringify(def.key)} cannot advance across missing seq ${String(seq)}`) } @@ -618,7 +647,7 @@ export class SessionProjectionRegistry extends Service { cell.views[1] = undefined } cell.state = next - cell.observedSeq = seq + cell.observedSeq = SessionSeq(seq) } } @@ -630,10 +659,20 @@ 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.snapshotEvents(0, event.seq)) + cell = this.buildCell( + registration.def, + session.header, + session.inheritedEventCount, + session.snapshotEvents(SessionLogOffset(0), SessionLogOffset(event.seq)), + ) registration.cells.set(session, cell) } else { - this.advanceCell(registration.def, cell, session, event.seq - 1) + this.advanceCell( + registration.def, + cell, + session, + event.seq === 0 ? -1 : SessionSeq(event.seq - 1), + ) } const previousState = cell.state const next = registration.def.apply(previousState, event) diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index b5e32f41c4..91106860c4 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -10,8 +10,13 @@ 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' -import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import SessionStore, { + Session, + SessionId, + SessionLogOffset, + SessionSeq, +} from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' @@ -20,6 +25,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { 'test/marks': MarksState 'test/count': number 'test/stable-view': StableViewState + 'test/cut': number } interface SessionProjectionMap { @@ -47,6 +53,7 @@ const RESTORE_HEADER: SessionHeader = { version: 0, id: SessionId('projection-restore'), createdAt: 0, + isSeeded: false, } /** Whole-value unit: latest test/mark event wins; unrelated events return the same reference. */ const marksUnit = (): Omit, 'wire'> @@ -92,6 +99,15 @@ const stableViewUnit = ( stateVersion: 1, }) satisfies ProjectionDefinition<'test/stable-view', StableViewState> +/** Host-only unit whose initial state proves the exact inherited cut. */ +const cutUnit = (): ProjectionDefinition<'test/cut', number> => ({ + key: 'test/cut', + stateSchema: z.number().int().nonnegative(), + init: (_header, inheritedEventCount) => inheritedEventCount, + apply: state => state, + stateVersion: 1, +}) + async function harness(): Promise<{ ctx: Context; session: Session }> { const ctx = new Context() await ctx.plugin(SessionStore) @@ -144,6 +160,45 @@ function sequenceName(sequence: readonly number[], prefix: string): string { } describe('SessionProjectionRegistry drive', () => { + it('supplies the exact inherited cut to live, restored, and hydrated projection initialization', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(cutUnit()) + const inherited: SessionEvent[] = [ + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + const session = ctx.sessions.create(SessionId('projection-cut'), { + seed: inherited, + inheritedEventCount: SessionLogOffset(inherited.length), + meta: { isSeeded: true }, + }) + + expect(ctx.sessionProjections.stateOf(session, 'test/cut')).toBe(inherited.length) + const restored = ctx.sessionProjections.restore( + {}, + inherited, + SessionLogOffset(0), + session.header, + session.inheritedEventCount, + ) + expect(restored.checkpoint['test/cut']?.val).toBe(inherited.length) + const prepared = Session.create( + SessionId('projection-cut-prepared'), + inherited, + { ...session.header, id: SessionId('projection-cut-prepared') }, + session.inheritedEventCount, + ) + expect(ctx.sessionProjections.hydrate( + prepared, + {}, + inherited, + SessionLogOffset(0), + ).asOfSeq).toBe(1) + expect(ctx.sessionProjections.stateOf(prepared, 'test/cut')).toBe(inherited.length) + }) + it('drives a registered unit over committed events and snapshots the current value', async () => { const { ctx, session } = await harness() ctx.sessionProjections.register(marksUnit()) @@ -175,7 +230,7 @@ describe('SessionProjectionRegistry drive', () => { it('notifies onChanged with the validated view and the causing seq, and skips same-reference applies', async () => { const { ctx, session } = await harness() ctx.sessionProjections.register(marksUnit()) - const seen: { key: string; value: unknown; seq: number; sessionId: string }[] = [] + const seen: { key: string; value: unknown; seq: SessionSeq; sessionId: string }[] = [] ctx.sessionProjections.onChanged((changedSession, key, value, seq) => { seen.push({ key, value, seq, sessionId: String(changedSession.id) }) }) @@ -516,13 +571,13 @@ describe('SessionProjectionRegistry drive', () => { // Lowest usable watermark is count's 5 → the anchored tail starts AT 5 // (one below the first needed seq 6), so the read proves seq 5 still exists. expect(ctx.sessionProjections.restoreFloor({ - 'test/marks': { ver: 1, seq: 10, val: { marks: [] } }, - 'test/count': { ver: 1, seq: 5, val: 6 }, + 'test/marks': { ver: 1, seq: SessionSeq(10), val: { marks: [] } }, + 'test/count': { ver: 1, seq: SessionSeq(5), val: 6 }, })).toBe(5) // A version-mismatched row forces that key back to a full refold. expect(ctx.sessionProjections.restoreFloor({ - 'test/marks': { ver: 2, seq: 10, val: { marks: [] } }, - 'test/count': { ver: 1, seq: 5, val: 6 }, + 'test/marks': { ver: 2, seq: SessionSeq(10), val: { marks: [] } }, + 'test/count': { ver: 1, seq: SessionSeq(5), val: 6 }, })).toBe(0) // A fresh (-1) row still needs the whole tail from 0. expect(ctx.sessionProjections.restoreFloor({ @@ -536,26 +591,27 @@ describe('SessionProjectionRegistry drive', () => { ctx.sessionProjections.register(marksUnit()) ctx.sessionProjections.register(countUnit()) const tail: SessionEvent[] = [ - { type: 'test/mark', seq: 3, time: 3, data: { marks: ['new'] } }, - { type: 'turn/end', seq: 4, time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'test/mark', seq: SessionSeq(3), time: 3, data: { marks: ['new'] } }, + { type: 'turn/end', seq: SessionSeq(4), time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, ] // marks row usable (watermark 2, tail starts at 3); count row mismatched — but // a mismatch with baseSeq > 0 cannot silently refold: it throws for a re-read. expect(() => ctx.sessionProjections.restore({ - 'test/marks': { ver: 1, seq: 2, val: { marks: ['old'] } }, - 'test/count': { ver: 99, seq: 2, val: 3 }, - }, tail, 3, RESTORE_HEADER)).toThrow(/re-read from seq 0/) + 'test/marks': { ver: 1, seq: SessionSeq(2), val: { marks: ['old'] } }, + 'test/count': { ver: 99, seq: SessionSeq(2), val: 3 }, + }, tail, SessionLogOffset(3), RESTORE_HEADER, SessionLogOffset(0))) + .toThrow(/re-read from seq 0/) // The full-log re-read (baseSeq 0) refolds the mismatched key from init. const full: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, - { type: 'test/mark', seq: 1, time: 1, data: { marks: ['old'] } }, - { type: 'test/mark', seq: 2, time: 2, data: { marks: ['old', '2'] } }, + { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } }, + { type: 'test/mark', seq: SessionSeq(1), time: 1, data: { marks: ['old'] } }, + { type: 'test/mark', seq: SessionSeq(2), time: 2, data: { marks: ['old', '2'] } }, ...tail, ] const { snapshot, checkpoint } = ctx.sessionProjections.restore({ - 'test/marks': { ver: 1, seq: 2, val: { marks: ['old', '2'] } }, - 'test/count': { ver: 99, seq: 2, val: 3 }, - }, full, 0, RESTORE_HEADER) + 'test/marks': { ver: 1, seq: SessionSeq(2), val: { marks: ['old', '2'] } }, + 'test/count': { ver: 99, seq: SessionSeq(2), val: 3 }, + }, full, SessionLogOffset(0), RESTORE_HEADER, SessionLogOffset(0)) expect(snapshot.asOfSeq).toBe(4) expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] }) expect('test/count' in snapshot.values).toBe(false) @@ -569,14 +625,20 @@ describe('SessionProjectionRegistry drive', () => { ctx.sessionProjections.register(marksUnit()) ctx.sessionProjections.register(countUnit()) const rows = { - 'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } }, - 'test/count': { ver: 1, seq: 2, val: 3 }, + 'test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['done'] } }, + 'test/count': { ver: 1, seq: SessionSeq(2), val: 3 }, } const tail: SessionEvent[] = [ - { type: 'turn/start', seq: 3, time: 3, data: { turn: 2 } }, - { type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(3), time: 3, data: { turn: 2 } }, + { type: 'turn/end', seq: SessionSeq(4), time: 4, data: { turn: 2, reason: { kind: 'completed' } } }, ] - const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, tail, 3, RESTORE_HEADER) + const { snapshot, checkpoint } = ctx.sessionProjections.restore( + rows, + tail, + SessionLogOffset(3), + RESTORE_HEADER, + SessionLogOffset(0), + ) expect(snapshot.asOfSeq).toBe(4) // marks already covers the tail (watermark 4): nothing re-applied. expect(snapshot.values['test/marks']).toEqual({ marks: ['done'] }) @@ -586,9 +648,9 @@ describe('SessionProjectionRegistry drive', () => { // Empty tail (checkpoint is current): the cut sits at baseSeq - 1. const { snapshot: current, checkpoint: currentCheckpoint } = ctx.sessionProjections.restore({ - 'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } }, - 'test/count': { ver: 1, seq: 4, val: 5 }, - }, [], 5, RESTORE_HEADER) + 'test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['done'] } }, + 'test/count': { ver: 1, seq: SessionSeq(4), val: 5 }, + }, [], SessionLogOffset(5), RESTORE_HEADER, SessionLogOffset(0)) expect(current.asOfSeq).toBe(4) expect('test/count' in current.values).toBe(false) expect(currentCheckpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 }) @@ -599,8 +661,8 @@ describe('SessionProjectionRegistry drive', () => { ctx.sessionProjections.register(marksUnit()) ctx.sessionProjections.register(countUnit()) const values = ctx.sessionProjections.viewCheckpoint({ - 'test/marks': { ver: 1, seq: 4, val: { marks: ['stored'] } }, - 'test/count': { ver: 99, seq: 4, val: 5 }, // mismatched: absent + 'test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['stored'] } }, + 'test/count': { ver: 99, seq: SessionSeq(4), val: 5 }, // mismatched: absent }) expect(values['test/marks']).toEqual({ marks: ['stored'] }) expect('test/count' in values).toBe(false) @@ -612,14 +674,20 @@ describe('SessionProjectionRegistry drive', () => { ctx.sessionProjections.register(marksUnit()) ctx.sessionProjections.register(countUnit()) const rows = { - 'test/marks': { ver: 1, seq: 4, val: { marks: ['stored'] } }, - 'test/count': { ver: 1, seq: 4, val: 5 }, + 'test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['stored'] } }, + 'test/count': { ver: 1, seq: SessionSeq(4), val: 5 }, } expect(ctx.sessionProjections.viewCheckpoint(rows)).toEqual({ 'test/marks': { marks: ['stored'] }, }) - const restored = ctx.sessionProjections.restore(rows, [], 5, RESTORE_HEADER) + const restored = ctx.sessionProjections.restore( + rows, + [], + SessionLogOffset(5), + RESTORE_HEADER, + SessionLogOffset(0), + ) expect(restored.snapshot.values).toEqual({ 'test/marks': { marks: ['stored'] }, }) @@ -630,35 +698,59 @@ describe('SessionProjectionRegistry drive', () => { const { ctx } = await harness() ctx.sessionProjections.register(marksUnit()) const drifted = { - 'test/marks': { ver: 1, seq: 2, val: { marks: 'not-an-array' } }, + 'test/marks': { ver: 1, seq: SessionSeq(2), val: { marks: 'not-an-array' } }, } expect(ctx.sessionProjections.viewCheckpoint(drifted)).toEqual({}) - expect(() => ctx.sessionProjections.restore(drifted, [], 3, RESTORE_HEADER)).toThrow() + expect(() => ctx.sessionProjections.restore( + drifted, + [], + SessionLogOffset(3), + RESTORE_HEADER, + SessionLogOffset(0), + )).toThrow() }) it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => { const { ctx } = await harness() ctx.sessionProjections.register(countUnit()) - const rows = { 'test/count': { ver: 1, seq: 9, val: 10 } } + const rows = { 'test/count': { ver: 1, seq: SessionSeq(9), val: 10 } } // The anchored floor sits ON the watermark, so the tail read must return // at least seq 9 from an intact log… const floor = ctx.sessionProjections.restoreFloor(rows) expect(floor).toBe(9) // …an intact log serves the anchor event and the checkpoint stands as-is. - const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, reason: { kind: 'completed' } } } - const anchored = ctx.sessionProjections.restore(rows, [anchor], 9, RESTORE_HEADER) + const anchor: SessionEvent = { type: 'turn/end', seq: SessionSeq(9), time: 9, data: { turn: 2, reason: { kind: 'completed' } } } + const anchored = ctx.sessionProjections.restore( + rows, + [anchor], + SessionLogOffset(9), + RESTORE_HEADER, + SessionLogOffset(0), + ) expect(anchored.snapshot.values).toEqual({}) expect(anchored.checkpoint['test/count']).toEqual({ ver: 1, seq: 9, val: 10 }) // …while a log crash-repaired down to fewer events returns an empty tail: // the row overreaches the proven end and a tail read cannot fix this key. - expect(() => ctx.sessionProjections.restore(rows, [], 9, RESTORE_HEADER)).toThrow(/re-read from seq 0/) + expect(() => ctx.sessionProjections.restore( + rows, + [], + SessionLogOffset(9), + RESTORE_HEADER, + SessionLogOffset(0), + )).toThrow(/re-read from seq 0/) // The full re-read discards the overreaching row and refolds from init. const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, - { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(1), time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, ] - const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, events, 0, RESTORE_HEADER) + const { snapshot, checkpoint } = ctx.sessionProjections.restore( + rows, + events, + SessionLogOffset(0), + RESTORE_HEADER, + SessionLogOffset(0), + ) expect(snapshot.asOfSeq).toBe(1) expect(snapshot.values).toEqual({}) expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 1, val: 2 }) diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts index 822203abb8..0888cf2d05 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -16,7 +16,7 @@ import { Context } from '@deepseek-ai/cordis' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import Loader from '@deepseek-ai/cordis-plugin-loader' import { recordFeedback } from '@deepseek-ai/dsh-command-feedback' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import OpenTelemetrySessionBackend, { Config, DEFAULT_TELEMETRY_MODE, SessionTelemetryMode } from '../src/index.ts' interface Capture { @@ -325,7 +325,7 @@ describe('OpenTelemetrySessionBackend wire', () => { }) ctx.emit('session/event', session, { type: 'feedback/record', - seq: session.snapshotEvents().length, + seq: SessionSeq(session.seq), 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 b558cedf8a..24150051fb 100644 --- a/packages/session/session-telemetry/src/coordinator.ts +++ b/packages/session/session-telemetry/src/coordinator.ts @@ -15,7 +15,8 @@ */ import type { Context } from '@deepseek-ai/cordis' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionSeq as SessionSeqType, SessionSeqCursor } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionTelemetrySink, SessionTelemetryRecord, SessionTelemetrySeverity } from './index.ts' @@ -26,7 +27,7 @@ export type SessionTelemetryCapture = 'live' | 'on-demand' interface ProjectedRecord { readonly record: SessionTelemetryRecord /** Ledger cursor advanced only after the backend accepts this record. */ - readonly seq?: number + readonly seq?: SessionSeqType } /** @@ -40,7 +41,7 @@ interface ProjectedRecord { * "re-hand everything". Advanced only at emit time — the cursor marks * handed-off, not delivered. */ -const handoffCursor = new WeakMap() +const handoffCursor = new WeakMap() /** * Install the telemetry capture side onto a context for one backend. @@ -135,8 +136,9 @@ export class SessionTelemetryCoordinator { * @param session - session whose current canonical-log prefix may be handed over. * @param throughSeq - optional last sequence included in this capture. */ - captureSession(session: Session, throughSeq?: number): void { - const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1 + captureSession(session: Session, throughSeq?: SessionSeqType): void { + const cursor = handoffCursor.get(session) + ?? (session.firstLiveSeq === 0 ? -1 : SessionSeq(session.firstLiveSeq - 1)) // Containment is PER EVENT: one rejected record is withheld fail-closed // while the rest of the historical replay proceeds. for (const event of session.snapshotEvents()) { @@ -309,11 +311,11 @@ function identityOf(session: Session, event: SessionEvent): Record { expect(ofResumed()).toEqual([2, 4]) }) - it('stamps session.seed_length from the header so receivers can stitch fork streams', async () => { + it('stamps session.seed_length from the exact Session cut so receivers can stitch fork streams', async () => { const backend = new FakeBackend() const ctx = new Context() await ctx.plugin(SessionStore) @@ -336,7 +336,8 @@ describe('SessionTelemetryCoordinator adoption', () => { appendTurn(parent) const child = ctx.sessions.create(SessionId('stitch-child'), { seed: parent.snapshotEvents(), - meta: { parentSession: SessionId('stitch-parent'), seedLength: 2 }, + inheritedEventCount: parent.seq, + meta: { parentSession: SessionId('stitch-parent'), isSeeded: true }, }) await ctx.plugin({ name: 'fake-telemetry', 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 a16b415b72..429f1f5bd3 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 @@ -54,7 +54,8 @@ describe('all-messages LLM title provider', () => { await ctx.plugin(providerPlugin, LLM_CONFIG) const session = ctx.sessions.create(SessionId('all-plugin'), { seed: seeded.snapshotEvents(), - meta: { parentSession: seeded.id, seedLength: seeded.seq }, + inheritedEventCount: seeded.seq, + meta: { parentSession: seeded.id, isSeeded: true }, }) session.append('turn/start', { turn: 2 }) const latest = session.append('user/message', createUserMessage({ diff --git a/packages/session/session-title-llm/src/index.ts b/packages/session/session-title-llm/src/index.ts index 04330ae9c0..c8ccadcde5 100644 --- a/packages/session/session-title-llm/src/index.ts +++ b/packages/session/session-title-llm/src/index.ts @@ -10,6 +10,7 @@ import { createUserMessage, BlockAssembler } from '@deepseek-ai/dsh-llm' import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { deepFreeze } from '@deepseek-ai/dsh-util-values' +import type { SessionSeq } from '@deepseek-ai/dsh-session' import { normalizeSessionTitle, SessionTitleProviderId, @@ -27,7 +28,7 @@ export interface SessionTitleLlmRequestEventData { /** Registered title-provider identity responsible for the request. */ readonly titleProvider: SessionTitleProviderId /** Exact human `user/message` seqs represented in `messages`. */ - readonly messageSeqs: number[] + readonly messageSeqs: SessionSeq[] /** Exact auxiliary LLM route. */ readonly route: SessionTitleModelProvenance /** Exact auxiliary system prompt. */ diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 4e1c1e378a..3e36f1d588 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -15,6 +15,7 @@ import type { Session, SessionEvent, } from '@deepseek-ai/dsh-session' +import { SessionSeq } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-agent' @@ -107,7 +108,7 @@ export interface SessionTitleProviderResult { /** Proposed title text. */ readonly title: string /** Exact seqs from `request.messages` used by this result. */ - readonly messageSeqs: readonly number[] + readonly messageSeqs: readonly SessionSeq[] /** Auxiliary LLM route, when generation used a model. */ readonly model?: SessionTitleModelProvenance } @@ -170,7 +171,7 @@ interface ProviderRegistration { interface PendingAutomaticWork { readonly registration: ProviderRegistration readonly revision: number - readonly throughSeq: number + readonly throughSeq: SessionSeq } /** Provider call currently allowed to commit for one session. */ @@ -212,14 +213,14 @@ function titleSnapshotFromState(state: TitleProjection): SessionTitleSnapshot { const EMPTY_TITLE_INPUT: TitleInputState = { first: null, count: 0, lastSeq: null } const sessionTitleUserMessageSchema: ZodType = zod.object({ - seq: zod.number().int().nonnegative(), + seq: zod.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionSeq), text: zod.string(), }).strict() const titleInputStateSchema: ZodType = zod.object({ first: sessionTitleUserMessageSchema.nullable(), count: zod.number().int().nonnegative(), - lastSeq: zod.number().int().nonnegative().nullable(), + lastSeq: zod.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionSeq).nullable(), }).strict().superRefine((state, context) => { const empty = state.first === null && state.lastSeq === null && state.count === 0 const populated = state.first !== null @@ -245,7 +246,7 @@ const titleInputStateSchema: ZodType = zod.object({ */ function collectSessionTitleMessages( events: readonly SessionEvent[], - throughSeq?: number, + throughSeq?: SessionSeq, ): SessionTitleUserMessage[] { const messages: SessionTitleUserMessage[] = [] for (const event of events) { @@ -632,18 +633,19 @@ export class SessionTitleService extends Service { if (!Array.isArray(candidate.messageSeqs) || candidate.messageSeqs.length === 0) { throw new Error('session-title provider must identify at least one source message seq') } - const messageSeqs: number[] = [] + const messageSeqs: SessionSeq[] = [] const order = new Map(messages.map((message, index) => [message.seq, index])) let previous = -1 for (const seq of candidate.messageSeqs as unknown[]) { - if (typeof seq !== 'number') { + if (typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0) { throw new Error('session-title provider messageSeqs must be unique, ordered seqs from the request') } - const index = order.get(seq) - if (!Number.isSafeInteger(seq) || seq < 0 || index === undefined || index <= previous) { + const sessionSeq = SessionSeq(seq) + const index = order.get(sessionSeq) + if (index === undefined || index <= previous) { throw new Error('session-title provider messageSeqs must be unique, ordered seqs from the request') } - messageSeqs.push(seq) + messageSeqs.push(sessionSeq) previous = index } const modelCandidate = candidate.model diff --git a/packages/session/session-title/src/invariant.ts b/packages/session/session-title/src/invariant.ts index 11337fbf34..e12cd6ca25 100644 --- a/packages/session/session-title/src/invariant.ts +++ b/packages/session/session-title/src/invariant.ts @@ -6,7 +6,8 @@ /* jscpd:ignore-start */ import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' const PACKAGE_NAME = '@deepseek-ai/dsh-session-title' @@ -23,18 +24,49 @@ export const inject = ['invariants'] * relationship every appended `session/title` event must keep, whichever * writer produced it. */ +function validate( + session: Session, + event: SessionEvent<'session/title'>, + fail: InvariantFailure, +): void { + const { source, messageSeqs } = event.data + if ((messageSeqs.length === 0) !== (source.kind === 'user')) { + const requirement = source.kind === 'user' ? 'cite no message seqs' : 'cite at least one message seq' + fail(`session/title event ${String(event.seq)} with source "${source.kind}" must ${requirement}; got ${String(messageSeqs.length)}`) + } + const seen = new Set>() + for (const seq of messageSeqs) { + let checked: ReturnType + try { + checked = SessionSeq(seq) + } catch { + fail(`session/title event ${String(event.seq)} has an invalid message seq ${String(seq)}`) + } + if (seen.has(checked)) { + fail(`session/title event ${String(event.seq)} repeats message seq ${checked}`) + } + seen.add(checked) + const cited = checked < event.seq ? session.eventAt(checked) : undefined + if (cited?.type !== 'user/message' || cited.data.source.kind !== 'user') { + fail(`session/title event ${String(event.seq)} message seq ${checked} must name an earlier human user/message`) + } + } +} + const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + const validateExisting = (session: Session): void => { + for (const event of session.snapshotEvents()) { + if (event.type === 'session/title') validate(session, event, fail) + } + } + ctx.sessions.list().forEach(validateExisting) + ctx.on('session/created', validateExisting, { global: true }) // internal/dispatch interception rejects the append before publication // (the session/event listener would only observe the already-committed log). ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return - const [, event] = args as [unknown, SessionEvent] - if (event.type !== 'session/title') return - const { source, messageSeqs } = event.data - if ((messageSeqs.length === 0) !== (source.kind === 'user')) { - const requirement = source.kind === 'user' ? 'cite no message seqs' : 'cite at least one message seq' - fail(`session/title event ${String(event.seq)} with source "${source.kind}" must ${requirement}; got ${String(messageSeqs.length)}`) - } + const [session, event] = args as [Session, SessionEvent] + if (event.type === 'session/title') validate(session, event, fail) }, { global: true }) }, { inject: ['sessions'] }) diff --git a/packages/session/session-title/src/types.ts b/packages/session/session-title/src/types.ts index bdc04c3e6c..c7d913f9e3 100644 --- a/packages/session/session-title/src/types.ts +++ b/packages/session/session-title/src/types.ts @@ -11,6 +11,7 @@ export {} import type { Branded } from '@deepseek-ai/dsh-brand' +import type { OptionalSessionSeq, SessionSeq } from '@deepseek-ai/dsh-session/types' /** Identifies one session-title provider registration. */ export type SessionTitleProviderId = Branded<'SessionTitleProviderId'> @@ -41,7 +42,7 @@ export interface SessionTitleEventData { /** Normalized non-empty title text. */ readonly title: string /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */ - readonly messageSeqs: number[] + readonly messageSeqs: SessionSeq[] /** Whether the built-in fallback, a registered provider, or the user supplied the title. */ readonly source: SessionTitleSource } @@ -49,7 +50,7 @@ export interface SessionTitleEventData { /** Latest folded title plus the title event's durable envelope facts. */ export interface SessionTitleSnapshot extends SessionTitleEventData { /** Seq of the latest `session/title` event. */ - readonly eventSeq: number + readonly eventSeq: SessionSeq /** Timestamp of the latest `session/title` event. */ readonly updatedAt: number } @@ -60,7 +61,7 @@ export type TitleProjection = SessionTitleSnapshot /** One eligible human text message exposed to title providers. */ export interface SessionTitleUserMessage { /** Source `user/message` event seq. */ - readonly seq: number + readonly seq: SessionSeq /** Exact concatenated text-block content. */ readonly text: string } @@ -72,7 +73,7 @@ export interface TitleInputState { /** Total eligible messages folded so far. */ readonly count: number /** Seq of the newest eligible message, or null before any. */ - readonly lastSeq: number | null + readonly lastSeq: OptionalSessionSeq } declare module '@deepseek-ai/dsh-session-projection/types' { diff --git a/packages/session/session-title/tests/invariant.spec.ts b/packages/session/session-title/tests/invariant.spec.ts index 75dadcf7c1..5903c63250 100644 --- a/packages/session/session-title/tests/invariant.spec.ts +++ b/packages/session/session-title/tests/invariant.spec.ts @@ -4,7 +4,8 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import * as SessionTitleInvariantCompanion from '@deepseek-ai/dsh-session-title/invariant' import InvariantRegistry, { InvariantError } from '@deepseek-ai/dsh-invariants' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { createUserMessage } from '@deepseek-ai/dsh-llm' async function setup(): Promise { const ctx = new Context() @@ -18,8 +19,11 @@ describe('session-title source invariant', () => { it('accepts cited automatic titles and citation-free user renames', async () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('title-invariant-valid')) + const source = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'title me' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) expect(() => { - session.append('session/title', { title: 'auto', messageSeqs: [1], source: { kind: 'fallback' } }) + session.append('session/title', { title: 'auto', messageSeqs: [source.seq], source: { kind: 'fallback' } }) session.append('session/title', { title: 'named', messageSeqs: [], source: { kind: 'user' } }) }).not.toThrow() }) @@ -27,6 +31,9 @@ describe('session-title source invariant', () => { it('rejects a citation-free automatic title and a user rename that cites messages', async () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('title-invariant-invalid')) + const source = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'title me' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) expect(() => { session.append('session/title', { title: 'auto', messageSeqs: [], source: { kind: 'fallback' } }) }).toThrow(expect.objectContaining>({ @@ -34,11 +41,52 @@ describe('session-title source invariant', () => { packageName: '@deepseek-ai/dsh-session-title', })) expect(() => { - session.append('session/title', { title: 'named', messageSeqs: [1], source: { kind: 'user' } }) + session.append('session/title', { title: 'named', messageSeqs: [source.seq], source: { kind: 'user' } }) }).toThrow(expect.objectContaining>({ code: 'INVARIANT', packageName: '@deepseek-ai/dsh-session-title', })) - expect(session.seq).toBe(0) + expect(session.seq).toBe(1) + }) + + it('requires automatic-title citations to name distinct earlier human messages', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('title-invariant-sources')) + const boundary = session.append('turn/start', { turn: 1 }) + expect(() => session.append('session/title', { + title: 'wrong source', messageSeqs: [boundary.seq], source: { kind: 'fallback' }, + })).toThrow(/must name an earlier human user\/message/) + expect(() => session.append('session/title', { + title: 'future source', messageSeqs: [SessionSeq(session.seq)], source: { kind: 'fallback' }, + })).toThrow(/must name an earlier human user\/message/) + expect(() => session.append('session/title', { + title: 'malformed source', messageSeqs: [-1 as never], source: { kind: 'fallback' }, + })).toThrow(/invalid message seq/) + const pluginMessage = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'plugin context' }], + source: { kind: 'plugin', plugin: 'test' }, + }), { surfaceOp: 'append' }) + expect(() => session.append('session/title', { + title: 'plugin source', messageSeqs: [pluginMessage.seq], source: { kind: 'fallback' }, + })).toThrow(/must name an earlier human user\/message/) + const source = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'title me' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + expect(() => session.append('session/title', { + title: 'duplicate source', messageSeqs: [source.seq, source.seq], source: { kind: 'fallback' }, + })).toThrow(/repeats message seq/) + }) + + it('validates title relations when the companion loads after a Session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('title-invariant-existing')) + const boundary = session.append('turn/start', { turn: 1 }) + session.append('session/title', { + title: 'wrong source', messageSeqs: [boundary.seq], source: { kind: 'fallback' }, + }) + await ctx.plugin(InvariantRegistry, { enabled: true }) + await expect(ctx.plugin(SessionTitleInvariantCompanion).then(() => undefined)) + .rejects.toThrow(/must name an earlier human user\/message/) }) }) diff --git a/packages/session/session-title/tests/projection.spec.ts b/packages/session/session-title/tests/projection.spec.ts index 26226b3552..df2b5f1f91 100644 --- a/packages/session/session-title/tests/projection.spec.ts +++ b/packages/session/session-title/tests/projection.spec.ts @@ -1,8 +1,8 @@ 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 } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionSeq as SessionSeqType } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService from '@deepseek-ai/dsh-session-title' @@ -16,8 +16,16 @@ async function harness(withTitleService: boolean): Promise<{ ctx: Context; sessi return { ctx, session: ctx.sessions.create(SessionId('titled')) } } -function appendTitle(session: Session, title: string): number { - return session.append('session/title', { title, messageSeqs: [1], source: { kind: 'fallback' } }).seq +function appendTitle(session: Session, title: string): SessionSeqType { + const messageSeq = session.snapshotEvents().find(event => + event.type === 'user/message' && event.data.source.kind === 'user')?.seq + ?? session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Title source' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }).seq + return session.append('session/title', { + title, messageSeqs: [messageSeq], source: { kind: 'fallback' }, + }).seq } describe('title projection unit', () => { @@ -30,7 +38,7 @@ describe('title projection unit', () => { it('serves the latest title last-wins and notifies the change feed with the causing seq', async () => { const { ctx, session } = await harness(true) - const changes: { key: string; value: unknown; seq: number }[] = [] + const changes: { key: string; value: unknown; seq: SessionSeqType }[] = [] ctx.sessionProjections.onChanged((_session, key, value, seq) => { changes.push({ key, value, seq }) }) @@ -50,7 +58,7 @@ describe('title projection unit', () => { const { ctx } = await harness(true) expect(ctx.sessionProjections.viewCheckpoint({ - title: { ver: 1, seq: 8, val: 'Cached title' }, + title: { ver: 1, seq: SessionSeq(8), val: 'Cached title' }, })).toEqual({ title: 'Cached title' }) }) @@ -105,7 +113,9 @@ describe('title projection unit', () => { ...checkpoint, titleInput: { ...row!, val: state }, } - expect(() => ctx.sessionProjections.restore(malformed, [], 0, session.header)) + expect(() => ctx.sessionProjections.restore( + malformed, [], SessionLogOffset(0), session.header, session.inheritedEventCount, + )) .toThrow(/title input state must pair its count with first and last message seqs/) } @@ -115,6 +125,6 @@ describe('title projection unit', () => { ...row!, val: { first: { seq: 1, text: 'first' }, count: 1, lastSeq: 1 }, }, - }, [], 0, session.header)).not.toThrow() + }, [], SessionLogOffset(0), session.header, session.inheritedEventCount)).not.toThrow() }) }) diff --git a/packages/session/session-title/tests/provider.spec.ts b/packages/session/session-title/tests/provider.spec.ts index ecd2e69ec5..d9632ea3b6 100644 --- a/packages/session/session-title/tests/provider.spec.ts +++ b/packages/session/session-title/tests/provider.spec.ts @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import LlmRuntime, { createUserMessage, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' import { deepFreeze } from '@deepseek-ai/dsh-util-values' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionTitleService, { @@ -256,7 +256,7 @@ describe('SessionTitleService Provider lifecycle', () => { expect(() => ctx.sessionTitle.register({ id: SessionTitleProviderId('duplicate'), automatic: 'first-prompt', - generate: async () => ({ title: 'duplicate', messageSeqs: [0] }), + generate: async () => ({ title: 'duplicate', messageSeqs: [SessionSeq(0)] }), })).toThrow(/already registered/) const session = ctx.sessions.create(SessionId('dispose-provider')) diff --git a/packages/session/session-title/tests/service-contracts.spec.ts b/packages/session/session-title/tests/service-contracts.spec.ts index ce3e4997bb..c6e0dc93c3 100644 --- a/packages/session/session-title/tests/service-contracts.spec.ts +++ b/packages/session/session-title/tests/service-contracts.spec.ts @@ -1,7 +1,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { Context, type Fiber } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService, { SessionTitleProviderId, @@ -72,7 +72,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { const withProvider = await setup() const generate = vi.fn(async (): Promise => ({ title: 'unused', - messageSeqs: [0], + messageSeqs: [SessionSeq(0)], })) withProvider.sessionTitle.register({ id: SessionTitleProviderId('empty-provider'), @@ -362,7 +362,9 @@ describe('SessionTitleService configuration and refresh boundaries', () => { describe('SessionTitleService Provider validation and stale scheduling', () => { it('rejects malformed provider registrations before publishing them', async () => { const ctx = await setup() - const generate = async (): Promise => ({ title: 'title', messageSeqs: [0] }) + const generate = async (): Promise => ({ + title: 'title', messageSeqs: [SessionSeq(0)], + }) expect(() => ctx.sessionTitle.register(null as never)).toThrow(/must be an object/) expect(() => ctx.sessionTitle.register('provider' as never)).toThrow(/must be an object/) expect(() => ctx.sessionTitle.register({ diff --git a/packages/session/session-title/tests/session-title.spec.ts b/packages/session/session-title/tests/session-title.spec.ts index 0254ff5826..69edbb84ff 100644 --- a/packages/session/session-title/tests/session-title.spec.ts +++ b/packages/session/session-title/tests/session-title.spec.ts @@ -1,7 +1,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService, { SessionTitleProviderId, @@ -147,12 +147,12 @@ describe('SessionTitleService', () => { const seed = Session.create(SessionId('source')) seed.append('session/title', { title: 'Earlier', - messageSeqs: [1], + messageSeqs: [SessionSeq(1)], source: { kind: 'fallback' }, }) seed.append('session/title', { title: 'Later', - messageSeqs: [1, 4], + messageSeqs: [SessionSeq(1), SessionSeq(4)], source: { kind: 'provider', provider: SessionTitleProviderId('test-provider'), diff --git a/packages/session/session-turn-outline/src/projection.ts b/packages/session/session-turn-outline/src/projection.ts index 68ae2bbf0c..992273e72a 100644 --- a/packages/session/session-turn-outline/src/projection.ts +++ b/packages/session/session-turn-outline/src/projection.ts @@ -21,7 +21,7 @@ import { z } from 'zod' import type { ZodType } from 'zod' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { TurnOutlineEntry, TurnOutlineState } from './types.ts' @@ -60,7 +60,7 @@ function preview(content: MessageContent, limit: number): string { const turnOutlineEntriesSchema: ZodType = z.array(z.object({ turn: z.number().int().nonnegative(), - seq: z.number().int().nonnegative(), + seq: z.number().int().nonnegative().transform(SessionSeq), prompt: z.string().max(PROMPT_PREVIEW_LIMIT), response: z.string().max(RESPONSE_PREVIEW_LIMIT), }).strict()).superRefine((turns, context) => { diff --git a/packages/session/session-turn-outline/src/types.ts b/packages/session/session-turn-outline/src/types.ts index 11e0b9c295..c4ccbcdab3 100644 --- a/packages/session/session-turn-outline/src/types.ts +++ b/packages/session/session-turn-outline/src/types.ts @@ -7,6 +7,8 @@ * @module @deepseek-ai/dsh-session-turn-outline/types */ +import type { SessionSeq } from '@deepseek-ai/dsh-session/types' + export {} /** One started turn's outline facts, independent of what a client has paged in. */ @@ -14,7 +16,7 @@ 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 + readonly seq: SessionSeq /** 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. */ diff --git a/packages/session/session-turn-outline/tests/projection.spec.ts b/packages/session/session-turn-outline/tests/projection.spec.ts index eb22ea571d..eced03f664 100644 --- a/packages/session/session-turn-outline/tests/projection.spec.ts +++ b/packages/session/session-turn-outline/tests/projection.spec.ts @@ -9,10 +9,10 @@ * regressive turn numbers) run against the exported definition directly. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionLogOffset, SessionSeq } 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' @@ -28,7 +28,7 @@ async function harness(withOutlinePlugin: boolean): Promise<{ ctx: Context; sess } /** Append one human prompt; returns its seq. */ -function appendPrompt(session: Session, text: string): number { +function appendPrompt(session: Session, text: string): SessionSeq { return session.append('user/message', createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' }, @@ -47,7 +47,7 @@ function appendAssistant(session: Session, turn: number, step: number, text: str }, { surfaceOp: 'append' }) } -function endTurn(session: Session, turn: number): number { +function endTurn(session: Session, turn: number): SessionSeq { return session.append('turn/end', { turn, reason: { kind: 'completed' } }).seq } @@ -56,6 +56,10 @@ function outlineOf(ctx: Context, session: Session): readonly TurnOutlineEntry[] } describe('turn outline projection unit', () => { + it('exposes the turn boundary as a branded event identity', () => { + expectTypeOf().toEqualTypeOf>() + }) + it('serves an empty outline before any turn starts', async () => { const { ctx, session } = await harness(true) expect(outlineOf(ctx, session)).toEqual([]) @@ -140,7 +144,7 @@ describe('turn outline projection unit', () => { it('pushes at most three times per turn: boundary, prompt, and settled response', async () => { const { ctx, session } = await harness(true) - const changes: { seq: number; last: TurnOutlineEntry | undefined }[] = [] + const changes: { seq: SessionSeq; last: TurnOutlineEntry | undefined }[] = [] ctx.sessionProjections.onChanged((_session, key, value, seq) => { if (key !== 'turnOutline') return changes.push({ seq, last: (value as readonly TurnOutlineEntry[]).at(-1) }) @@ -170,11 +174,11 @@ describe('turn outline projection unit', () => { const def = turnOutlineProjectionDefinition const assistant = (blocks: readonly unknown[]): SessionEvent => ({ type: 'assistant/message', - seq: 9, + seq: SessionSeq(9), time: 0, data: { message: { content: blocks } }, }) as unknown as SessionEvent - const base: TurnOutlineState = { turns: [{ turn: 1, seq: 0, prompt: 'p', response: '' }], draft: '' } + const base: TurnOutlineState = { turns: [{ turn: 1, seq: SessionSeq(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. @@ -189,23 +193,23 @@ describe('turn outline projection unit', () => { // A draft with no entry to commit into clears itself at the boundary… const end = { type: 'turn/end', - seq: 11, + seq: SessionSeq(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 settled: TurnOutlineState = { turns: [{ turn: 1, seq: SessionSeq(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 state: TurnOutlineState = { turns: [{ turn: 2, seq: SessionSeq(5), prompt: 'kept', response: '' }], draft: '' } const regressive = { type: 'turn/start', - seq: 9, + seq: SessionSeq(9), time: 0, data: { turn: 2 }, } as unknown as SessionEvent @@ -247,7 +251,7 @@ describe('turn outline projection unit', () => { draft: '', }, }, - }, [], 0, session.header)).toThrow(/strictly increasing/) + }, [], SessionLogOffset(0), session.header, session.inheritedEventCount)).toThrow(/strictly increasing/) expect(() => ctx.sessionProjections.restore({ ...checkpoint, turnOutline: { @@ -260,6 +264,6 @@ describe('turn outline projection unit', () => { draft: '', }, }, - }, [], 0, session.header)).not.toThrow() + }, [], SessionLogOffset(0), session.header, session.inheritedEventCount)).not.toThrow() }) }) diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index 2f2eab801f..c2aeac1606 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ class PassthroughSandbox extends SandboxProvider { function agent(ctx: Context, cwd: string): Agent { const id = SessionId('persistent-bash-loader-agent') const scope = ctx.plugin(() => {}) - const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd }) + const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd, isSeeded: false }) const value: Agent = { id, options: {}, diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index 921ff1f265..97d48a4cc5 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -33,6 +33,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { version: 0, id, createdAt: 0, + isSeeded: false, ...cwd === undefined ? {} : { cwd }, }) const value: Agent = { diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index fd6bc51b16..ca8ce99c89 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -11,7 +11,7 @@ import ToolRuntime, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepse import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' @@ -205,20 +205,37 @@ function sandboxAgent( ctx?: Context, onAppend?: (type: string) => void, ): Agent { - 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 events: Array<{ + type: string + seq: ReturnType + time: number + data: Record + }> = [{ type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } }] + if (mode !== undefined) { + events.push({ type: 'sandbox/mode', seq: SessionSeq(1), time: 1, data: { mode } }) + } const id = SessionId('sandbox-session') return { id, ...ctx === undefined ? {} : { ctx: ctx.plugin(() => {}).ctx }, session: { id, - header: { version: 0, id, createdAt: 0 }, - get seq() { return events.length }, - eventAt: (seq: number) => events[seq], - snapshotEvents: () => events, + header: { version: 0, id, createdAt: 0, isSeeded: false }, + inheritedEventCount: SessionLogOffset(0), + firstLiveSeq: SessionLogOffset(0), + get seq() { return SessionLogOffset(events.length) }, + eventAt: (seq: ReturnType) => events[seq], + snapshotEvents: ( + fromSeq = SessionLogOffset(0), + toSeqExclusive = SessionLogOffset(events.length), + ) => events.slice(fromSeq, toSeqExclusive), append: (type: string, data: Record) => { - const event = { type, data, seq: events.length } + const event = { + type, + seq: SessionSeq(events.length), + time: events.length, + data, + } events.push(event) onAppend?.(type) return event @@ -627,11 +644,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 }; seq: number }>).push({ - type: 'sandbox/mode', - data: { mode: 'unknown-mode' }, - seq: malformed.session.seq, - }) + ;(malformed.session.append as unknown as ( + type: string, + data: Record, + ) => unknown)('sandbox/mode', { mode: 'unknown-mode' }) expect(text(await call(ctx, 'bash', escalate, malformed))).toContain('not strictly wider') }) diff --git a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index 1ee4f0c2c5..58b05082d0 100644 --- a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -47,7 +47,7 @@ class PassthroughSandbox extends SandboxProvider { function agent(ctx: Context, cwd: string): Agent { const id = SessionId('persistent-pwsh-loader-agent') const scope = ctx.plugin(() => {}) - const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd }) + const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd, isSeeded: false }) const value: Agent = { id, options: {}, diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index 8d9c55e39c..d5d9218171 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -33,6 +33,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { version: 0, id, createdAt: 0, + isSeeded: false, ...cwd === undefined ? {} : { cwd }, }) const value: Agent = { diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index c50af1a02f..5e27e3c8e0 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -22,7 +22,7 @@ import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { ShellExecutor } from '@deepseek-ai/dsh-shell' @@ -236,22 +236,39 @@ function sandboxAgent( ctx?: Context, onAppend?: (type: string) => void, ): Agent { - const events: Array<{ type: string; data?: Record; seq?: number }> = [ - { type: 'turn/start', seq: 0, data: { turn: 1 } }, + const events: Array<{ + type: string + seq: ReturnType + time: number + data: Record + }> = [ + { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } }, ] - if (mode !== undefined) events.push({ type: 'sandbox/mode', seq: 1, data: { mode } }) + if (mode !== undefined) { + events.push({ type: 'sandbox/mode', seq: SessionSeq(1), time: 1, data: { mode } }) + } const id = SessionId('sandbox-session') return { id, ...ctx === undefined ? {} : { ctx: ctx.plugin(() => {}).ctx }, session: { id, - header: { version: 0, id, createdAt: 0 }, - get seq() { return events.length }, - eventAt: (seq: number) => events[seq], - snapshotEvents: () => events, + header: { version: 0, id, createdAt: 0, isSeeded: false }, + inheritedEventCount: SessionLogOffset(0), + firstLiveSeq: SessionLogOffset(0), + get seq() { return SessionLogOffset(events.length) }, + eventAt: (seq: ReturnType) => events[seq], + snapshotEvents: ( + fromSeq = SessionLogOffset(0), + toSeqExclusive = SessionLogOffset(events.length), + ) => events.slice(fromSeq, toSeqExclusive), append: (type: string, data: Record) => { - const event = { type, data, seq: events.length } + const event = { + type, + seq: SessionSeq(events.length), + time: events.length, + data, + } events.push(event) onAppend?.(type) return event @@ -274,8 +291,10 @@ function registerFakeAgent(ctx: Context, sessionId: string): Agent { ctx: scopeFiber.ctx, session: { id, - header: { version: 0, id, createdAt: 0 }, - seq: 0, + header: { version: 0, id, createdAt: 0, isSeeded: false }, + inheritedEventCount: SessionLogOffset(0), + firstLiveSeq: SessionLogOffset(0), + seq: SessionLogOffset(0), eventAt: () => undefined, snapshotEvents: () => [], }, @@ -610,11 +629,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 }; seq: number }>).push({ - type: 'sandbox/mode', - data: { mode: 'unknown-mode' }, - seq: malformed.session.seq, - }) + ;(malformed.session.append as unknown as ( + type: string, + data: Record, + ) => unknown)('sandbox/mode', { mode: 'unknown-mode' }) expect(text(await call(ctx, 'pwsh', escalate, malformed))).toContain('not strictly wider') }) diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index c620afd9c5..3465cd843c 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -10,7 +10,7 @@ import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { UserMessage } from '@deepseek-ai/dsh-session' +import { SessionSeq, type UserMessage } from '@deepseek-ai/dsh-session' import { escapeText, isModelInvocable, @@ -362,7 +362,7 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool const visible = new Set(agent.session.surface.nodes) let published = false for (let index = agent.session.seq - 1; index >= 0; index -= 1) { - const event = agent.session.eventAt(index) + const event = agent.session.eventAt(SessionSeq(index)) if (event === undefined) { throw new Error(`skill catalog cannot read seq ${String(index)} below the current Session length`) } diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 7a488ba2e0..858367134a 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -38,7 +38,7 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise { const child = ctx.agents.get(run.id)! // Only the child's own turn — no seeded parent turns. expect(child.session.snapshotEvents().filter(e => e.type === 'turn/end')).toHaveLength(1) - expect(child.session.header.seedLength).toBeUndefined() + expect(child.session.header.isSeeded).toBe(false) + expect(child.session.inheritedEventCount).toBe(0) await run.dispose() }) @@ -101,7 +102,8 @@ describe('dsh-subagent-fork-in-process', () => { 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.header.isSeeded).toBe(true) + expect(child.session.inheritedEventCount).toBe(parentPrefixLen) 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() @@ -126,10 +128,10 @@ describe('dsh-subagent-fork-in-process', () => { expect(seededUser).toBeDefined() // Lineage stamped. expect(child.session.header.parentSession).toBe(parent.session.header.id) - // The seed boundary is recorded on the header (= the seeded prefix length), - // so a reload / replay harness can tell the inherited prefix from the - // child's own events. - expect(child.session.header.seedLength).toBe(parentPrefixLen) + // Logical metadata records lineage while Session state retains the exact + // inherited cut for reload and replay. + expect(child.session.header.isSeeded).toBe(true) + expect(child.session.inheritedEventCount).toBe(parentPrefixLen) await run.dispose() }) diff --git a/packages/subagent/subagent-in-process-driver/src/index.ts b/packages/subagent/subagent-in-process-driver/src/index.ts index 53fd8f3783..4ca07b8436 100644 --- a/packages/subagent/subagent-in-process-driver/src/index.ts +++ b/packages/subagent/subagent-in-process-driver/src/index.ts @@ -16,7 +16,8 @@ import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' import { foldConsumedWork } from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' -import type { SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId, SessionLogOffset as SessionLogOffsetType, TurnEndReason } from '@deepseek-ai/dsh-session' import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import { appendDelegatedPolicyOverrides, @@ -111,7 +112,7 @@ export async function startInProcessRun( const childId = brandString(randomUUID()) const seed = options.seed - const activationBoundary = seed?.length ?? 0 + const activationBoundary = SessionLogOffset(seed?.length ?? 0) // Capture before the first await: a later parent switch belongs to the // parent's future. @@ -132,8 +133,9 @@ export async function startInProcessRun( const handle = await parent.ctx.agents.create({ sessionId: childId, - meta: childSessionMeta(parent, childDepth, activationBoundary), + meta: childSessionMeta(parent, childDepth, seed !== undefined), ...seed !== undefined ? { seed } : {}, + ...seed === undefined ? {} : { inheritedEventCount: activationBoundary }, agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth), signal: request.signal, setup, @@ -157,7 +159,7 @@ function drivePublishedRun( signal: AbortSignal, prompt: ContentBlock[], childId: SessionId, - boundary: number, + boundary: SessionLogOffsetType, structured: StructuredAttachment | undefined, ): SubagentRun { const child = handle.agent @@ -208,7 +210,7 @@ function drivePublishedRun( /** Read one settled child's result from events after its activation boundary. */ function readResult( child: Agent, - boundary: number, + boundary: SessionLogOffsetType, cancelled: boolean, structured?: { captured?: { value: unknown } | undefined }, ): SubagentResult { 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 e9e1a1dda2..d5f26b8558 100644 --- a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts @@ -107,7 +107,8 @@ describe('in-process policy inheritance', () => { { type: 'approval/policy', seq: 1, data: { policy: 'never', source: 'delegation' } }, ]) expect(child.session.firstLiveSeq).toBe(0) - expect(child.session.header.seedLength).toBeUndefined() + expect(child.session.header.isSeeded).toBe(false) + expect(child.session.inheritedEventCount).toBe(0) expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') expect(ctx.approval.overrideOf(child.session)).toBe('never') const request = child.session.snapshotEvents().find( @@ -153,7 +154,8 @@ describe('in-process policy inheritance', () => { await run.result const child = run.localAgent as Agent - expect(child.session.header.seedLength).toBe(1) + expect(child.session.header.isSeeded).toBe(true) + expect(child.session.inheritedEventCount).toBe(1) expect(child.session.firstLiveSeq).toBe(seed.length) // seq 1 is the constructor's end-seed marker. expect(child.session.snapshotEvents().filter(event => event.type === 'sandbox/mode')).toMatchObject([ 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 cadddf8814..df0250cf3f 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 @@ -206,7 +206,8 @@ describe('startInProcessRun', () => { 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.header.isSeeded).toBe(true) + expect(child.session.inheritedEventCount).toBe(seed.length) expect(child.session.snapshotEvents().slice(0, seed.length)).toEqual(seed) await run.dispose() }) diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index b03d869df0..228b9de2f2 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -132,13 +132,13 @@ export function resolveChildAgentOptions( * child never had. * @param parent - the delegating parent agent. * @param childDepth - the resolved delegation depth to persist. - * @param lineageSeedLength - how many leading events came from the parent's log. + * @param isSeeded - whether this child inherits a parent-log prefix, including an explicitly empty one. * @returns the `meta` for `ctx.agents.create()`. */ export function childSessionMeta( parent: Agent, childDepth: number, - lineageSeedLength: number, + isSeeded: boolean, ): NonNullable { const parentHeader = parent.session.header const agentPreset = parent.ctx.get('agentPresets')?.composedPreset(parent.ctx) @@ -146,12 +146,12 @@ export function childSessionMeta( ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, ...agentPreset === undefined ? {} : { agentPreset }, parentSession: parentHeader.id, + isSeeded, // Navigation classification only; the descriptor remains the authority // for mode and continuation capability. origin: 'subagent', // Durable: the recursion budget must survive persistence and resume. delegationDepth: childDepth, - ...lineageSeedLength > 0 ? { seedLength: lineageSeedLength } : {}, } } diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 0ccf961407..2103ce2270 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -32,7 +32,8 @@ import type { } from '@deepseek-ai/dsh-agent' import { ReasoningEffortId, boundContextSummary, contentHasImage, createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type { SessionObservation, SessionQueryEngine } from '@deepseek-ai/dsh-session-query' import type { ToolRestriction } from '@deepseek-ai/dsh-tools' @@ -240,6 +241,8 @@ interface MaterializeInputs { create?: { seed: readonly SessionEvent[] meta: NonNullable + /** Exact parent-log prefix length inside {@link seed}. */ + inheritedEventCount: SessionLogOffsetType /** Policy captured at the delegation boundary: the parent's sandbox override plus the approval pin. */ delegatedPolicies: DelegatedPolicyOverrides } @@ -460,7 +463,7 @@ export class SubagentContinuationManager { spec.signal.throwIfAborted() this.assertAdmitting(parent) - const lineageSeedLength = prepared.seed?.length ?? 0 + const inheritedEventCount = SessionLogOffset(prepared.seed?.length ?? 0) const seed = seedDescriptorTurn(childId, prepared.seed, descriptor) const messageId = await this.locks.run(childId, async () => { spec.signal.throwIfAborted() @@ -479,7 +482,12 @@ export class SubagentContinuationManager { childId, provider: spec.provider, parent, - create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), delegatedPolicies }, + create: { + seed, + meta: childSessionMeta(parent, childDepth, prepared.seed !== undefined), + inheritedEventCount, + delegatedPolicies, + }, agentOptions, composition: { persona: request.persona, toolFilter: request.toolFilter }, signal: spec.signal, @@ -987,9 +995,7 @@ export class SubagentContinuationManager { // Fold only the child's own suffix: a fork seed replays the parent's log, // which may carry an ANCESTOR's descriptor when the parent is itself a // continuable child. - const descriptor = foldSubagentDescriptor( - source.events.slice(source.header.seedLength ?? 0), - ) + const descriptor = foldSubagentDescriptor(source.events.slice(source.inheritedEventCount)) if (descriptor === undefined || descriptor.mode !== 'continuable') { throw new SubagentError( `subagent "${childId}" has no supported continuation state and cannot be resumed; choose a different target`, @@ -1143,6 +1149,7 @@ export class SubagentContinuationManager { sessionId: childId, meta: create.meta, seed: create.seed, + inheritedEventCount: create.inheritedEventCount, agentOptions: inputs.agentOptions, signal: inputs.signal, setup, diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index 2f4e6d5392..ba2db8e62c 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -19,7 +19,8 @@ import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { foldConsumedWork } from '@deepseek-ai/dsh-agent' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session' import { finalAssistantOutput } from './assistant-output.ts' import { SubagentRunId } from './types.ts' import type { SubagentResult, SubagentRun, SubagentRunEndInfo, SubagentRunInfo } from './types.ts' @@ -182,7 +183,7 @@ export function createActivationObserver( // A cold resume replays earlier turns, so this epoch's telemetry must come // from the suffix it actually produced — never the whole session, which // would report a previous epoch's answer when this one opened no turn. - let boundary = 0 + let boundary: SessionLogOffsetType = SessionLogOffset(0) // Assigned by `capture()`, which the disposal path always runs before // `settle()`; a resident epoch therefore always has its facts by then. let captured: ActivationTerminal = { stopReason: 'completed' } diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 6935067e50..1d2c58ba6d 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -4,9 +4,10 @@ * corpus; each child's mode/label is the registered `subagent` projection * unit's value, resolved * down a three-rung ladder: the registry's watermark cache for a live child, - * a durable projection-cache row when it serves an own-suffix identity (the - * seq gate), and one shared Session observation otherwise, validated against - * the enumerated lifecycle. The projection fold is the single classification + * an unseeded durable projection-cache row, and one shared Session observation + * otherwise. A seeded header deliberately lacks its exact inherited cut, so + * it takes the body-bearing observation path before classifying an identity. + * The projection fold is the single classification * authority — this module parses no descriptor * itself. Absent persistence, enumeration is live-only: a cold child is * unreachable for resume anyway, so its absence is capability absence, not an @@ -17,6 +18,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection' import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache' @@ -67,8 +69,8 @@ interface PositionedCandidate { * live-preferred merge of `ctx.sessions` and optional session persistence, * serving each identity from the `subagent` projection unit: the registry's * watermark snapshot for a live child; for a cold one, a durable - * projection-cache read when it serves an own-suffix identity (the seq gate), - * else one bounded-concurrency shared Session observation. + * projection-cache read for an unseeded lifecycle, else one bounded-concurrency + * shared Session observation carrying the exact inherited cut. * @see SubagentRuntime.listChildren for the public cancellation and failure contract. * @param ctx - context carrying the session store, the projection registry, * optional persistence, and the optional projection cache. @@ -220,7 +222,7 @@ async function resolveCandidateRows( // The unit's serializable no-value sentinel is `null`; `undefined` can // only mean the key was dropped at a JSON boundary. Both are no value. if (identity === undefined || identity === null - || identity.seq < (candidate.header.seedLength ?? 0)) return + || !candidate.live.isOwnSeq(identity.seq)) return rows[index] = childRow(childId, identity, 'running', subagentParents.has(childId)) }) @@ -285,9 +287,8 @@ function compareCorpusRecords(a: CorpusRecord, b: CorpusRecord): number { } /** - * Resolve one cold candidate down the remaining ladder: a durable - * projection-cache row when it serves an own-suffix identity (the seq gate), - * otherwise one shared Session observation. An absent or transiently failed + * Resolve one cold candidate down the remaining ladder: an unseeded durable + * projection-cache row, otherwise one shared Session observation. An absent or transiently failed * observation is one `unavailable` row retried on the next listing; an observation * source naming another lifecycle, and a * settled log the fold cannot identify — or that makes any registered unit @@ -301,25 +302,25 @@ async function resolveColdIdentity( signal: AbortSignal | undefined, ): Promise { const childId = header.id - if (cache !== undefined) { + // A header deliberately exposes only whether a fork cut exists, not its + // integer. An unseeded lifecycle has the exact cut 0 and may use the cache; + // a seeded lifecycle must read the body before an identity seq can be + // classified as inherited or owned. + if (cache !== undefined && !header.isSeeded) { let cached: SubagentIdentityProjection | null | undefined try { - cached = cache.cachedSnapshot(header, ['subagent'])?.values.subagent + cached = cache.cachedSnapshot(header, SessionLogOffset(0), ['subagent'])?.values.subagent } catch { // Unlike the preparation fold below, a throwing cache read renders no // verdict: the cache is derived data, so its damage (a poisoned stored // row of ANY unit) silently falls through to the authoritative re-fold. cached = undefined } - // A child's OWN descriptor is immutable once appended, so a cached - // identity is final only when the seq gate proves it was folded from the - // own suffix: a creation-window checkpoint may instead carry a fork - // seed's replayed ANCESTOR descriptor (seq below `seedLength`), which - // must not outrank the re-fold. Everything else also falls through to - // preparation: an absent key (a cut before any descriptor) and the - // `null` sentinel, whose verdict belongs to the authoritative re-fold, - // not to a derived row. - if (cached !== undefined && cached !== null && cached.seq >= (header.seedLength ?? 0)) { + // An unseeded child's descriptor is owned at every valid seq. Everything + // else falls through to preparation: an absent key and the `null` + // sentinel, whose verdict belongs to the authoritative re-fold, not to a + // derived row. + if (cached !== undefined && cached !== null) { return childRow(childId, cached, 'inactive', hasChildren) } } @@ -352,7 +353,7 @@ async function resolveColdIdentity( } const identity = ownedObservation.projections?.values.subagent if (identity === undefined || identity === null - || identity.seq < (header.seedLength ?? 0)) { + || identity.seq < ownedObservation.inheritedEventCount) { return { kind: 'diagnostic', id: childId, reason: 'corrupt' } } return childRow(childId, identity, 'inactive', hasChildren) @@ -386,7 +387,7 @@ function childRow( /** Immutable header fields that distinguish one session lifecycle from another under the same id. */ const LIFECYCLE_WITNESS_KEYS = [ - 'version', 'id', 'createdAt', 'cwd', 'parentSession', 'seedLength', 'delegationDepth', + 'version', 'id', 'createdAt', 'cwd', 'parentSession', 'isSeeded', 'delegationDepth', 'origin', 'agentPreset', ] as const diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts index 046c32e91d..bf31d8b0aa 100644 --- a/packages/subagent/subagent/src/projection-types.ts +++ b/packages/subagent/subagent/src/projection-types.ts @@ -4,6 +4,8 @@ * @module @deepseek-ai/dsh-subagent/projection-types */ +import type { SessionSeq } from '@deepseek-ai/dsh-session/types' + /** Durable active-turn timing for one descriptor-backed child session. */ export interface SubagentTimingProjection { /** Milliseconds accumulated across completed turns after the child's own descriptor. */ @@ -31,11 +33,11 @@ export type SubagentIdentityProjection = label?: string /** * Seq of the `subagent/descriptor` event this identity was folded from. - * `seq >= header.seedLength` proves the identity comes from the child's + * `session.isOwnSeq(seq)` proves the identity comes from the child's * OWN log suffix — where a descriptor is immutable once appended — and * not from a fork seed's replayed ancestor descriptor. */ - seq: number + seq: SessionSeq } | { /** A resumable conversation. */ @@ -43,7 +45,7 @@ export type SubagentIdentityProjection = /** Durable creation label from the child's descriptor. */ label: string /** Seq of the folded descriptor event; see the one-shot arm for the own-suffix proof. */ - seq: number + seq: SessionSeq } declare module '@deepseek-ai/dsh-session-projection/types' { diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts index c225f71f75..6a7367d369 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -6,6 +6,7 @@ */ import { z } from 'zod' +import { SessionSeq } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { foldSubagentDescriptor } from './descriptor.ts' @@ -119,12 +120,12 @@ const identityValueSchema = z.discriminatedUnion('mode', [ z.object({ mode: z.literal('one-shot'), label: z.string().optional(), - seq: z.number().int().nonnegative(), + seq: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionSeq), }).strict(), z.object({ mode: z.literal('continuable'), label: z.string(), - seq: z.number().int().nonnegative(), + seq: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionSeq), }).strict(), ]) as unknown as z.ZodType diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index 15ee9c2b96..97b05c98af 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -166,7 +166,8 @@ describe('continuable policy inheritance', () => { await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - expect(loaded.meta.seedLength).toBeGreaterThan(0) + expect(loaded.meta.isSeeded).toBe(true) + expect(loaded.inheritedEventCount).toBeGreaterThan(0) expect(policyEvents(loaded.events)).toMatchObject([ { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, ]) @@ -238,7 +239,8 @@ describe('continuable policy inheritance', () => { await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - expect(loaded.meta.seedLength).toBeGreaterThan(0) + expect(loaded.meta.isSeeded).toBe(true) + expect(loaded.inheritedEventCount).toBeGreaterThan(0) expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ { data: { mode: 'workspace-write' } }, { data: { mode: 'read-only', source: 'delegation' } }, diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 904ee91180..851720f228 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -513,7 +513,8 @@ describe('SubagentRuntime.startContinuable', () => { // rather than restarting at 1, so the replayed child log stays balanced. expect(descriptorIndex).toBeGreaterThanOrEqual(0) expect(childTurn?.type === 'turn/start' && childTurn.data.turn).toBe(2) - expect(loaded.meta.seedLength).toBeGreaterThan(0) + expect(loaded.meta.isSeeded).toBe(true) + expect(loaded.inheritedEventCount).toBeGreaterThan(0) }) it('records the declared persona in the descriptor and reapplies it on cold resume', async () => { diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 31109f1396..54d34f8c95 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -7,7 +7,7 @@ import { Context } from '@deepseek-ai/cordis' import { createUserMessage } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -103,14 +103,16 @@ async function authorChild( id: string, header: Partial, events: SessionEvent[], + inheritedEventCount = SessionLogOffset(0), ): Promise { const sessionId = SessionId(id) await ctx.sessionPersistence.create({ version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, + isSeeded: false, ...header, - }) + }, header.isSeeded === true ? inheritedEventCount : undefined) await ctx.sessionPersistence.append(sessionId, events) return sessionId } @@ -277,6 +279,7 @@ describe('SubagentRuntime.listChildren', () => { version: SESSION_FORMAT_VERSION, id: coldParent, createdAt: 1, + isSeeded: false, }) await ctx.sessionPersistence.append(coldParent, [ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, @@ -448,11 +451,11 @@ describe('SubagentRuntime.listChildren', () => { const events = childEvents(descriptorPayload('twice')) events.splice(3, 0, { type: 'subagent/descriptor', - seq: 3, + seq: SessionSeq(3), time: 3, data: descriptorPayload('twice again'), } as SessionEvent) - events[4] = { ...events[4]!, seq: 4 } + events[4] = { ...events[4]!, seq: SessionSeq(4) } const doubled = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', { parentSession: parent.id, origin: 'subagent', @@ -501,11 +504,11 @@ describe('SubagentRuntime.listChildren', () => { const events = childEvents(descriptorPayload('was valid')) events.splice(3, 0, { type: 'subagent/descriptor', - seq: 3, + seq: SessionSeq(3), time: 3, data: { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 }, } as SessionEvent) - events[4] = { ...events[4]!, seq: 4 } + events[4] = { ...events[4]!, seq: SessionSeq(4) } const invalidated = await authorChild(ctx, '00000000-0000-4000-8000-00000000ad01', { parentSession: parent.id, origin: 'subagent', @@ -521,12 +524,12 @@ describe('SubagentRuntime.listChildren', () => { parentSession: parent.id, origin: 'subagent', }, childEvents(descriptorPayload('disk label'))) - // seq 2 >= seedLength 0: the cached identity provably comes from the - // child's own suffix, so it is final and the log is never re-read — the + // The unseeded child's cut is exactly 0, so its cached identity is final + // and the log is never re-read — the // divergent label proves the row, not the log, produced the entry. ctx.sessionProjectionCache.cachedSnapshot = () => ({ - asOfSeq: 2, - values: { subagent: { mode: 'continuable', label: 'cached own', seq: 2 } }, + asOfSeq: SessionSeq(2), + values: { subagent: { mode: 'continuable', label: 'cached own', seq: SessionSeq(2) } }, }) const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ @@ -549,14 +552,14 @@ describe('SubagentRuntime.listChildren', () => { ] as SessionEvent[] const forkChild = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae02', { parentSession: parent.id, - seedLength: seed.length, + isSeeded: true, origin: 'subagent', - }, events) - // A creation-window checkpoint carried the ANCESTOR identity: its seq 2 - // fails the own-suffix gate (< seedLength 4), so preparation rules. + }, events, SessionLogOffset(seed.length)) + // A seeded header does not expose the integer cut, so a misleading cached + // ANCESTOR identity is bypassed and authoritative preparation rules. ctx.sessionProjectionCache.cachedSnapshot = () => ({ - asOfSeq: 2, - values: { subagent: { mode: 'continuable', label: 'ancestor label', seq: 2 } }, + asOfSeq: SessionSeq(2), + values: { subagent: { mode: 'continuable', label: 'ancestor label', seq: SessionSeq(2) } }, }) const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ @@ -572,7 +575,7 @@ describe('SubagentRuntime.listChildren', () => { ['createdAt', (meta: SessionHeader): SessionHeader => ({ ...meta, createdAt: meta.createdAt + 1 })], ['cwd', (meta: SessionHeader): SessionHeader => ({ ...meta, cwd: '/elsewhere' })], ['parentSession', (meta: SessionHeader): SessionHeader => ({ ...meta, parentSession: SessionId('another-parent') })], - ['seedLength', (meta: SessionHeader): SessionHeader => ({ ...meta, seedLength: (meta.seedLength ?? 0) + 1 })], + ['isSeeded', (meta: SessionHeader): SessionHeader => ({ ...meta, isSeeded: !meta.isSeeded })], ['delegationDepth', (meta: SessionHeader): SessionHeader => ({ ...meta, delegationDepth: (meta.delegationDepth ?? 0) + 1 })], ] as const)('diagnoses an inspection returning another lifecycle (%s) as corrupt', async (_field, mutate) => { const { ctx, parent } = await setup([textResponse('done')]) @@ -603,7 +606,7 @@ describe('SubagentRuntime.listChildren', () => { origin: 'subagent', }, childEvents(descriptorPayload('actually valid'))) // A stale cached sentinel must not out-rank the authoritative re-fold. - ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: { subagent: null } }) + ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: SessionSeq(0), values: { subagent: null } }) const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ kind: 'child', id: healthy, label: 'actually valid', mode: 'continuable', @@ -663,9 +666,9 @@ describe('SubagentRuntime.listChildren', () => { const seed = childEvents(descriptorPayload('ancestor label')) const forkChild = await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', { parentSession: parent.id, - seedLength: seed.length, + isSeeded: true, origin: 'subagent', - }, seed) + }, seed, SessionLogOffset(seed.length)) const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toEqual([{ kind: 'diagnostic', id: forkChild, reason: 'corrupt' }]) }) @@ -786,14 +789,14 @@ describe('SubagentRuntime.listChildren', () => { const compactedEvents = childEvents(descriptorPayload('twin child')) compactedEvents.push({ type: 'user/message', - seq: 4, + seq: SessionSeq(4), time: 5, data: createUserMessage({ content: [{ type: 'text', text: 'summary of everything' }], source: { kind: 'plugin', plugin: 'compact' }, }), - surfaceOp: { op: 'replace', start: 1, end: 1 }, - sourceEventSeqs: [1], + surfaceOp: { op: 'replace', start: SessionSeq(1), end: SessionSeq(1) }, + sourceEventSeqs: [SessionSeq(1)], }) const compacted = await authorChild(ctx, '00000000-0000-4000-8000-00000000c1de', { parentSession: parent.id, @@ -874,7 +877,7 @@ describe('SubagentRuntime.listChildren', () => { // points; both writes are fail-soft asynchronous, so wait for the row. const header = (await ctx.sessionPersistence.list()).find(meta => meta.id === childId) await vi.waitFor(() => { - expect(ctx.sessionProjectionCache.cachedSnapshot(header!)?.values.subagent).toBeDefined() + expect(ctx.sessionProjectionCache.cachedSnapshot(header!, SessionLogOffset(0))?.values.subagent).toBeDefined() }, { timeout: 5_000 }) const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ @@ -900,7 +903,7 @@ describe('SubagentRuntime.listChildren', () => { expect(inspect).toHaveBeenCalledTimes(1) // A stored row whose cut predates the descriptor: the subagent key is // absent from the served values, and preparation still rules. - ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: {} }) + ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: SessionSeq(0), values: {} }) await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual(expected) expect(inspect).toHaveBeenCalledTimes(2) }) diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index b719c1e205..082dbde76e 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -154,9 +154,8 @@ describe('dsh-tool-subagent-control', () => { const loaded = await ctx.sessionPersistence.load(started.childId) const promptIndex = loaded.events.findIndex(event => event.type === 'user/message' && event.data.content.some(block => block.type === 'text' && block.text === 'fork task')) - const seedLength = loaded.meta.seedLength - if (seedLength === undefined) throw new Error('expected a forked Session seed') - expect(promptIndex).toBeGreaterThanOrEqual(seedLength) + expect(loaded.meta.isSeeded).toBe(true) + expect(promptIndex).toBeGreaterThanOrEqual(loaded.inheritedEventCount) const prompt = loaded.events[promptIndex] if (prompt?.type !== 'user/message') throw new Error('expected the initial fork task') const texts = prompt.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 4516dafacc..03b56a3c9d 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -16,6 +16,7 @@ import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import { SessionSeq } from '@deepseek-ai/dsh-session' import { assertSubagentMaxDepth, parentAgentOptionsForDelegation, @@ -616,7 +617,7 @@ 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' + && agent.session.eventAt(SessionSeq(0))?.type !== 'session/end-seed' let allowedModels = subagentModelSelectionPolicy(ctx.sessionProjections, agent.session) if (allowedModels === undefined) { const parentId = agent.session.header.origin === 'subagent' diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 4799709b3f..a3edc02058 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -50,7 +50,9 @@ function config(): ResolvedConfig { function agent(ctx: Context, cwd?: string): Agent { const id = SessionId('agent') - const session = Session.create(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } }) + const session = Session.create(id, undefined, { + version: 0, id, createdAt: 0, isSeeded: false, ...cwd === undefined ? {} : { cwd }, + }) return { id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', diff --git a/packages/test-support/llm-replay/src/index.ts b/packages/test-support/llm-replay/src/index.ts index bea88ebfce..7be893cc95 100644 --- a/packages/test-support/llm-replay/src/index.ts +++ b/packages/test-support/llm-replay/src/index.ts @@ -12,7 +12,8 @@ import { delimiter as pathDelimiter } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-compaction' import type {} from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import { decodeSeqRanges, decodeStorageRecord, type SessionEvent } from '@deepseek-ai/dsh-session' +import { decodeSeqRanges, decodeStorageRecord, SessionLogOffset, type SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session' import type { ContentBlock, GenerateOptions, @@ -180,7 +181,7 @@ export interface SessionScript { */ export function parseSessionLog(text: string): SessionEvent[] { const events: SessionEvent[] = [] - let nextSeq = 0 + let nextSeq: SessionLogOffsetType = SessionLogOffset(0) let headerSkipped = false // The JSONL backend guarantees line 0 is the session header. Projected // fixtures omit event envelopes; synthesize them while decoding so callers @@ -218,7 +219,7 @@ export function parseSessionLog(text: string): SessionEvent[] { throw new Error(`session snapshot line ${index + 1}: ${detail}`, { cause: error }) } events.push(...decoded) - nextSeq += decoded.length + nextSeq = SessionLogOffset(nextSeq + decoded.length) } return events } @@ -227,15 +228,19 @@ export function parseSessionLog(text: string): SessionEvent[] { * Read replay identity, ordering, and fork-seed facts from the JSONL header. * * @param text - the raw `.jsonl` file contents (only the header line is read). - * @returns the header's `id`, `createdAt`, and `seedLength`, defaulted when absent. + * @returns the header's `id`, `createdAt`, and inherited-event count, defaulted when absent. */ -export function parseSessionHeader(text: string): { id: string; createdAt: number; seedLength: number } { +export function parseSessionHeader(text: string): { + id: string + createdAt: number + inheritedEventCount: SessionLogOffsetType +} { const firstLine = text.split('\n').find(line => line.trim().length > 0) ?? '{}' const parsed = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown; seedLength?: unknown } return { id: typeof parsed.id === 'string' ? parsed.id : '', createdAt: typeof parsed.createdAt === 'number' ? parsed.createdAt : 0, - seedLength: typeof parsed.seedLength === 'number' ? parsed.seedLength : 0, + inheritedEventCount: SessionLogOffset(typeof parsed.seedLength === 'number' ? parsed.seedLength : 0), } } @@ -606,7 +611,7 @@ function deriveScriptFromFile(file: string): ReplayEntry[] { /** * Load the primary and child scripts in bind order. Child derivation begins at - * `seedLength` so inherited parent chunks are never replayed as child calls. + * the v0 header's inherited-event cut so parent chunks are never replayed as child calls. * * @param config - the fixture paths: the primary log plus any recorded child logs. * @returns the primary script first, then the child scripts in bind order. @@ -631,7 +636,7 @@ export function loadSessionScripts(config: ReplayConfig): SessionScript[] { const header = parseSessionHeader(text) // Derive the child's script from its own events only — events AT OR after the seed // boundary. - const ownEvents = parseSessionLog(text).slice(header.seedLength) + const ownEvents = parseSessionLog(text).slice(header.inheritedEventCount) children.push({ recordedId: header.id, createdAt: header.createdAt, diff --git a/packages/test-support/llm-replay/tests/llm-replay.spec.ts b/packages/test-support/llm-replay/tests/llm-replay.spec.ts index 6cdf8c4c7e..8f2a6dd059 100644 --- a/packages/test-support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/test-support/llm-replay/tests/llm-replay.spec.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import { SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { CompactionId } from '@deepseek-ai/dsh-compaction' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' @@ -59,7 +60,7 @@ function sessionJsonl(events: SessionEvent[], header?: { id?: string; createdAt? } /** A SessionEvent of type assistant/chunk for (turn, step). */ -function chunkEvent(seq: number, turn: number, step: number, chunk: StreamChunk): SessionEvent { +function chunkEvent(seq: SessionSeq, turn: number, step: number, chunk: StreamChunk): SessionEvent { return { type: 'assistant/chunk', seq, time: 0, data: { turn, step, chunk } } } @@ -70,7 +71,7 @@ let file: string function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string { let seq = 1 const events: SessionEvent[] = [] - calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) }) + calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(SessionSeq(seq++), 1, step + 1, c)) }) const path = join(dir, filename) writeFileSync(path, sessionJsonl(events, header), 'utf8') return path @@ -93,20 +94,20 @@ async function drain(iter: AsyncIterable): Promise { describe('parseSessionLog', () => { it('skips the header line and parses each event', () => { - const events = [chunkEvent(1, 1, 1, TEXT_CHUNKS[0] as StreamChunk)] + const events = [chunkEvent(SessionSeq(1), 1, 1, TEXT_CHUNKS[0] as StreamChunk)] expect(parseSessionLog(sessionJsonl(events))).toEqual(events) }) it('ignores blank lines', () => { const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 }) - const ev = chunkEvent(1, 1, 1, TEXT_CHUNKS[0] as StreamChunk) + const ev = chunkEvent(SessionSeq(1), 1, 1, TEXT_CHUNKS[0] as StreamChunk) expect(parseSessionLog(`${header}\n\n${JSON.stringify(ev)}\n\n`)).toEqual([ev]) }) it('expands range-encoded source provenance', () => { const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 }) const event = { - ...chunkEvent(4, 1, 1, TEXT_CHUNKS[0] as StreamChunk), + ...chunkEvent(SessionSeq(4), 1, 1, TEXT_CHUNKS[0] as StreamChunk), sourceEventSeqs: [[1, 3], 5], } expect(parseSessionLog(`${header}\n${JSON.stringify(event)}\n`)).toEqual([{ @@ -118,7 +119,7 @@ describe('parseSessionLog', () => { it('reports malformed range provenance with its source line', () => { const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 }) const event = { - ...chunkEvent(4, 1, 1, TEXT_CHUNKS[0] as StreamChunk), + ...chunkEvent(SessionSeq(4), 1, 1, TEXT_CHUNKS[0] as StreamChunk), sourceEventSeqs: [[3, 1]], } expect(() => parseSessionLog(`${header}\n${JSON.stringify(event)}\n`)) @@ -138,9 +139,9 @@ describe('parseSessionLog', () => { data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['a', 'b', 'c'] }, }) expect(parseSessionLog(`${header}\n${row}\n`)).toEqual([ - chunkEvent(1, 1, 1, { type: 'text-delta', index: 0, text: 'a' }), - chunkEvent(2, 1, 1, { type: 'text-delta', index: 0, text: 'b' }), - chunkEvent(3, 1, 1, { type: 'text-delta', index: 0, text: 'c' }), + chunkEvent(SessionSeq(1), 1, 1, { type: 'text-delta', index: 0, text: 'a' }), + chunkEvent(SessionSeq(2), 1, 1, { type: 'text-delta', index: 0, text: 'b' }), + chunkEvent(SessionSeq(3), 1, 1, { type: 'text-delta', index: 0, text: 'c' }), ]) }) @@ -153,15 +154,15 @@ describe('parseSessionLog', () => { }) expect(parseSessionLog(`${header}\n${ordinary}\n${packed}\n`)).toEqual([ { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, - chunkEvent(1, 1, 1, { type: 'text-delta', index: 0, text: 'a' }), - { ...chunkEvent(2, 1, 1, { type: 'text-delta', index: 0, text: 'b' }), time: 3 }, + chunkEvent(SessionSeq(1), 1, 1, { type: 'text-delta', index: 0, text: 'a' }), + { ...chunkEvent(SessionSeq(2), 1, 1, { type: 'text-delta', index: 0, text: 'b' }), time: 3 }, ]) }) }) describe('deriveReplayScript', () => { it('groups one finished assistant/chunk stream into one replay entry', () => { - const events: SessionEvent[] = TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c)) + const events: SessionEvent[] = TEXT_CHUNKS.map((c, i) => chunkEvent(SessionSeq(i + 1), 1, 1, c)) expect(deriveReplayScript(events)).toEqual([{ kind: 'chunks', chunks: TEXT_CHUNKS }]) }) @@ -172,8 +173,8 @@ describe('deriveReplayScript', () => { ] let seq = 1 const events: SessionEvent[] = [ - ...failed.map(chunk => chunkEvent(seq++, 1, 1, chunk)), - ...TEXT_CHUNKS.map(chunk => chunkEvent(seq++, 1, 1, chunk)), + ...failed.map(chunk => chunkEvent(SessionSeq(seq++), 1, 1, chunk)), + ...TEXT_CHUNKS.map(chunk => chunkEvent(SessionSeq(seq++), 1, 1, chunk)), ] expect(deriveReplayScript(events)).toEqual([ { kind: 'chunks', chunks: failed }, @@ -190,8 +191,8 @@ describe('deriveReplayScript', () => { ] let seq = 1 const events: SessionEvent[] = [ - ...callA.map(c => chunkEvent(seq++, 1, 1, c)), - ...callB.map(c => chunkEvent(seq++, 1, 2, c)), // same turn, next step + ...callA.map(c => chunkEvent(SessionSeq(seq++), 1, 1, c)), + ...callB.map(c => chunkEvent(SessionSeq(seq++), 1, 2, c)), // same turn, next step ] expect(deriveReplayScript(events)).toEqual([ { kind: 'chunks', chunks: callA }, @@ -202,8 +203,8 @@ describe('deriveReplayScript', () => { it('separates calls across turns too', () => { let seq = 1 const events: SessionEvent[] = [ - ...TEXT_CHUNKS.map(c => chunkEvent(seq++, 1, 1, c)), - ...TEXT_CHUNKS.map(c => chunkEvent(seq++, 2, 1, c)), // new turn, step resets to 1 + ...TEXT_CHUNKS.map(c => chunkEvent(SessionSeq(seq++), 1, 1, c)), + ...TEXT_CHUNKS.map(c => chunkEvent(SessionSeq(seq++), 2, 1, c)), // new turn, step resets to 1 ] expect(deriveReplayScript(events)).toHaveLength(2) }) @@ -211,9 +212,9 @@ describe('deriveReplayScript', () => { it('ignores non-assistant/chunk events', () => { let seq = 1 const events: SessionEvent[] = [ - { type: 'turn/start', seq: seq++, time: 0, data: { turn: 1 } }, - ...TEXT_CHUNKS.map(c => chunkEvent(seq++, 1, 1, c)), - { type: 'turn/end', seq: seq++, time: 0, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/start', seq: SessionSeq(seq++), time: 0, data: { turn: 1 } }, + ...TEXT_CHUNKS.map(c => chunkEvent(SessionSeq(seq++), 1, 1, c)), + { type: 'turn/end', seq: SessionSeq(seq++), time: 0, data: { turn: 1, reason: { kind: 'completed' } } }, ] expect(deriveReplayScript(events)).toEqual([{ kind: 'chunks', chunks: TEXT_CHUNKS }]) }) @@ -227,7 +228,7 @@ describe('deriveReplayScript', () => { { type: 'block-start', index: 0, blockType: 'text' }, { type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'X' } } }, ] - const events = errChunks.map((c, i) => chunkEvent(i + 1, 1, 1, c)) + const events = errChunks.map((c, i) => chunkEvent(SessionSeq(i + 1), 1, 1, c)) expect(deriveReplayScript(events)).toEqual([{ kind: 'chunks', chunks: errChunks }]) }) @@ -246,31 +247,31 @@ describe('deriveReplayScript', () => { ] let seq = 1 const events: SessionEvent[] = [ - ...overflow.map(chunk => chunkEvent(seq++, 1, 2, chunk)), + ...overflow.map(chunk => chunkEvent(SessionSeq(seq++), 1, 2, chunk)), { type: 'compaction/start', - seq: seq++, + seq: SessionSeq(seq++), time: 0, data: { compactionId: COMPACTION_ID, turn: 1 }, }, { type: 'compaction/summary', - seq: seq++, + seq: SessionSeq(seq++), time: 0, data: { compactionId: COMPACTION_ID, summary: rawOutput, rawOutput, llmStreamCall: true, - shadowedRange: { start: 1, end: 1 }, - shadowedSeqs: [1], + shadowedRange: { start: SessionSeq(1), end: SessionSeq(1) }, + shadowedSeqs: [SessionSeq(1)], shadowedTokenCount: 20, provider: 'mock', model: 'mock', usage, }, }, - ...TEXT_CHUNKS.map(chunk => chunkEvent(seq++, 1, 2, chunk)), + ...TEXT_CHUNKS.map(chunk => chunkEvent(SessionSeq(seq++), 1, 2, chunk)), ] expect(deriveReplayScript(events)).toEqual([ @@ -283,13 +284,13 @@ describe('deriveReplayScript', () => { it('does not infer an LLM call from compaction/summary without raw output', () => { const event: SessionEvent<'compaction/summary'> = { type: 'compaction/summary', - seq: 1, + seq: SessionSeq(1), time: 0, data: { compactionId: COMPACTION_ID, summary: [{ type: 'text', text: 'template result' }], - shadowedRange: { start: 1, end: 1 }, - shadowedSeqs: [1], + shadowedRange: { start: SessionSeq(1), end: SessionSeq(1) }, + shadowedSeqs: [SessionSeq(1)], shadowedTokenCount: 20, provider: 'template', model: 'template', @@ -303,14 +304,14 @@ describe('deriveReplayScript', () => { const block = { type: 'text' as const, text: 'remote summary' } const event: SessionEvent<'compaction/summary'> = { type: 'compaction/summary', - seq: 1, + seq: SessionSeq(1), time: 0, data: { compactionId: COMPACTION_ID, summary: [block], rawOutput: [block], - shadowedRange: { start: 1, end: 1 }, - shadowedSeqs: [1], + shadowedRange: { start: SessionSeq(1), end: SessionSeq(1) }, + shadowedSeqs: [SessionSeq(1)], shadowedTokenCount: 20, provider: 'remote', model: 'remote', @@ -348,15 +349,15 @@ describe('deriveReplayScript', () => { const block = { type: 'text' as const, text: 'summary without usage' } const event: SessionEvent<'compaction/summary'> = { type: 'compaction/summary', - seq: 1, + seq: SessionSeq(1), time: 0, data: { compactionId: COMPACTION_ID, summary: [block], rawOutput: [block], llmStreamCall: true, - shadowedRange: { start: 1, end: 1 }, - shadowedSeqs: [1], + shadowedRange: { start: SessionSeq(1), end: SessionSeq(1) }, + shadowedSeqs: [SessionSeq(1)], shadowedTokenCount: 20, provider: 'mock', model: 'mock', @@ -376,40 +377,40 @@ describe('deriveReplayScript', () => { it('throws on a group that lacks a terminal finish chunk (a thrown stream)', () => { // A thrown stream(): prefix chunks logged, then turn/end (error reason), NO finish. const events: SessionEvent[] = [ - chunkEvent(1, 1, 1, { type: 'block-start', index: 0, blockType: 'text' }), - chunkEvent(2, 1, 1, { type: 'text-delta', index: 0, text: 'par' }), - { type: 'turn/end', seq: 3, time: 0, data: { turn: 1, reason: { kind: 'error', error: { message: 'x', code: 'UNKNOWN' } } } }, + chunkEvent(SessionSeq(1), 1, 1, { type: 'block-start', index: 0, blockType: 'text' }), + chunkEvent(SessionSeq(2), 1, 1, { type: 'text-delta', index: 0, text: 'par' }), + { type: 'turn/end', seq: SessionSeq(3), time: 0, data: { turn: 1, reason: { kind: 'error', error: { message: 'x', code: 'UNKNOWN' } } } }, ] expect(() => deriveReplayScript(events)).toThrow(/without a finish chunk.*replay\.override\.json/s) }) it('names the offending (turn, step) when a group is incomplete', () => { const events: SessionEvent[] = [ - chunkEvent(1, 2, 3, { type: 'block-start', index: 0, blockType: 'text' }), + chunkEvent(SessionSeq(1), 2, 3, { type: 'block-start', index: 0, blockType: 'text' }), ] expect(() => deriveReplayScript(events)).toThrow(/2\/3/) }) it('rejects an unfinished call before consuming chunks from a new step', () => { const events: SessionEvent[] = [ - chunkEvent(1, 1, 1, { type: 'block-start', index: 0, blockType: 'text' }), - chunkEvent(2, 1, 2, { type: 'finish', reason: { kind: 'stop' } }), + chunkEvent(SessionSeq(1), 1, 1, { type: 'block-start', index: 0, blockType: 'text' }), + chunkEvent(SessionSeq(2), 1, 2, { type: 'finish', reason: { kind: 'stop' } }), ] expect(() => deriveReplayScript(events)).toThrow(/model call 1\/1 ended without a finish chunk/) }) it('rejects an unfinished call at a compact summary boundary', () => { const events: SessionEvent[] = [ - chunkEvent(1, 1, 1, { type: 'block-start', index: 0, blockType: 'text' }), + chunkEvent(SessionSeq(1), 1, 1, { type: 'block-start', index: 0, blockType: 'text' }), { type: 'compaction/summary', - seq: 2, + seq: SessionSeq(2), time: 0, data: { compactionId: COMPACTION_ID, summary: [{ type: 'text', text: 'external checkpoint' }], - shadowedRange: { start: 1, end: 1 }, - shadowedSeqs: [1], + shadowedRange: { start: SessionSeq(1), end: SessionSeq(1) }, + shadowedSeqs: [SessionSeq(1)], shadowedTokenCount: 20, provider: 'external', model: 'external', @@ -423,7 +424,7 @@ describe('deriveReplayScript', () => { describe('loadReplayScript', () => { it('derives from the session JSONL when no override is present', () => { - writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8') + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(SessionSeq(i + 1), 1, 1, c))), 'utf8') expect(loadReplayScript({ file })).toEqual([{ kind: 'chunks', chunks: TEXT_CHUNKS }]) }) @@ -436,7 +437,7 @@ describe('loadReplayScript', () => { }) it('falls back to the JSONL when the override path is set but absent', () => { - writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8') + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(SessionSeq(i + 1), 1, 1, c))), 'utf8') expect(loadReplayScript({ file, overrideFile: join(dir, 'nope.json') })) .toEqual([{ kind: 'chunks', chunks: TEXT_CHUNKS }]) }) @@ -460,8 +461,8 @@ describe('loadReplayScript', () => { ] let seq = 1 writeFileSync(file, sessionJsonl([ - ...TEXT_CHUNKS.map(c => chunkEvent(seq++, 1, 1, c)), - ...callB.map(c => chunkEvent(seq++, 1, 2, c)), + ...TEXT_CHUNKS.map(c => chunkEvent(SessionSeq(seq++), 1, 1, c)), + ...callB.map(c => chunkEvent(SessionSeq(seq++), 1, 2, c)), ]), 'utf8') const overrideFile = join(dir, 'replay.override.json') writeFileSync(overrideFile, JSON.stringify({ @@ -474,7 +475,7 @@ describe('loadReplayScript', () => { }) it('patches form: at == derived length appends (the retry-attempt slot)', () => { - writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8') + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(SessionSeq(i + 1), 1, 1, c))), 'utf8') const overrideFile = join(dir, 'replay.override.json') writeFileSync(overrideFile, JSON.stringify({ patches: [ @@ -489,7 +490,7 @@ describe('loadReplayScript', () => { }) it('patches form: an out-of-range index fails loud with the derived length', () => { - writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8') + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(SessionSeq(i + 1), 1, 1, c))), 'utf8') const overrideFile = join(dir, 'replay.override.json') writeFileSync(overrideFile, JSON.stringify({ patches: [{ at: 2, entry: { kind: 'hang' } }] }), 'utf8') expect(() => loadReplayScript({ file, overrideFile })).toThrow(/patch index 2 out of range.*1 call/s) @@ -521,7 +522,7 @@ describe('loadReplayScript', () => { }) it('rejects duplicate patch indexes instead of silently taking the last one', () => { - writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8') + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(SessionSeq(i + 1), 1, 1, c))), 'utf8') const overrideFile = join(dir, 'replay.override.json') writeFileSync(overrideFile, JSON.stringify({ patches: [ @@ -538,7 +539,7 @@ describe('installLlmReplay (through the real LlmRuntime)', () => { let seq = 1 const events: SessionEvent[] = [] calls.forEach((chunks, step) => { - for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) + for (const c of chunks) events.push(chunkEvent(SessionSeq(seq++), 1, step + 1, c)) }) writeFileSync(file, sessionJsonl(events), 'utf8') } @@ -1038,7 +1039,7 @@ describe('installLlmReplay (through the real LlmRuntime)', () => { writeLog(TEXT_CHUNKS) const childFile = join(dir, 'session.1.jsonl') writeFileSync(childFile, sessionJsonl( - TEXT_CHUNKS.map((chunk, i) => chunkEvent(i + 1, 1, 1, chunk)), + TEXT_CHUNKS.map((chunk, i) => chunkEvent(SessionSeq(i + 1), 1, 1, chunk)), { id: 'child', createdAt: 10 }, ), 'utf8') const ctx = new Context() @@ -1051,22 +1052,27 @@ describe('installLlmReplay (through the real LlmRuntime)', () => { }) describe('parseSessionHeader', () => { - it('reads id, createdAt, and seedLength off the header line', () => { + it('reads id, createdAt, and the inherited event count off the v0 header line', () => { expect(parseSessionHeader(sessionJsonl([], { id: 'abc', createdAt: 42 }))) - .toEqual({ id: 'abc', createdAt: 42, seedLength: 0 }) + .toEqual({ id: 'abc', createdAt: 42, inheritedEventCount: 0 }) }) - it('reads a non-zero seedLength (a fork child header)', () => { + it('reads a non-zero v0 seedLength as the inherited event count', () => { expect(parseSessionHeader('{"type":"session","version":0,"id":"child","createdAt":7,"seedLength":4}\n')) - .toEqual({ id: 'child', createdAt: 7, seedLength: 4 }) + .toEqual({ id: 'child', createdAt: 7, inheritedEventCount: 4 }) }) - it('falls back to id="" / createdAt=0 / seedLength=0 when the header lacks them', () => { - expect(parseSessionHeader('{"type":"session","version":0}\n')).toEqual({ id: '', createdAt: 0, seedLength: 0 }) + it('falls back to id="" / createdAt=0 / inheritedEventCount=0 when the header lacks them', () => { + expect(parseSessionHeader('{"type":"session","version":0}\n')).toEqual({ id: '', createdAt: 0, inheritedEventCount: 0 }) }) it('falls back on an empty buffer (no header line)', () => { - expect(parseSessionHeader('')).toEqual({ id: '', createdAt: 0, seedLength: 0 }) + expect(parseSessionHeader('')).toEqual({ id: '', createdAt: 0, inheritedEventCount: 0 }) + }) + + it.each([-1, 0.5, Number.MAX_SAFE_INTEGER + 1])('rejects invalid v0 seedLength %s', (seedLength) => { + expect(() => parseSessionHeader(JSON.stringify({ type: 'session', version: 0, seedLength }))) + .toThrow(/SessionLogOffset/) }) }) @@ -1105,10 +1111,10 @@ describe('loadSessionScripts', () => { // The child fixture: 2 seeded parent events (a chunk + its finish) then the // child's own turn. seedLength = 2 marks where the inherited prefix ends. const childEvents: SessionEvent[] = [ - chunkEvent(0, 1, 1, parentChunk), - chunkEvent(1, 1, 1, { type: 'finish', reason: { kind: 'stop' } }), - chunkEvent(2, 2, 1, childChunks[0]!), - chunkEvent(3, 2, 1, childChunks[1]!), + chunkEvent(SessionSeq(0), 1, 1, parentChunk), + chunkEvent(SessionSeq(1), 1, 1, { type: 'finish', reason: { kind: 'stop' } }), + chunkEvent(SessionSeq(2), 2, 1, childChunks[0]!), + chunkEvent(SessionSeq(3), 2, 1, childChunks[1]!), ] const childPath = join(dir, 'session.1.jsonl') writeFileSync(childPath, sessionJsonl(childEvents, { id: 'child', createdAt: 200, seedLength: 2 }), 'utf8') @@ -1335,7 +1341,7 @@ describe('apply (the plugin entry)', () => { }) it('installs replay and its catalog from explicit config', async () => { - writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8') + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(SessionSeq(i + 1), 1, 1, c))), 'utf8') const ctx = new Context() await ctx.plugin(LlmRuntime) apply(ctx, { @@ -1352,7 +1358,7 @@ describe('apply (the plugin entry)', () => { }) it('declares flat image request pricing only for models that configure it', async () => { - writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8') + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(SessionSeq(i + 1), 1, 1, c))), 'utf8') const ctx = new Context() await ctx.plugin(LlmRuntime) installLlmReplay(ctx, { @@ -1426,7 +1432,7 @@ describe('apply (the plugin entry)', () => { }) it('uses only the file when no override path is configured or in the env', async () => { - writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8') + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(SessionSeq(i + 1), 1, 1, c))), 'utf8') process.env.DSH_SNAPSHOT_FILE = file delete process.env.DSH_SNAPSHOT_OVERRIDE const ctx = new Context() @@ -1455,9 +1461,9 @@ describe('apply (the plugin entry)', () => { { type: 'text-delta', index: 0, text: 'kid' }, { type: 'finish', reason: { kind: 'stop' } }, ] - writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8') + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(SessionSeq(i + 1), 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8') const childFile = join(dir, 'session.1.jsonl') - writeFileSync(childFile, sessionJsonl(childSecond.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'c', createdAt: 2 }), 'utf8') + writeFileSync(childFile, sessionJsonl(childSecond.map((c, i) => chunkEvent(SessionSeq(i + 1), 1, 1, c)), { id: 'c', createdAt: 2 }), 'utf8') const ctx = new Context() await ctx.plugin(LlmRuntime) apply(ctx, { file, childFiles: [childFile] }) @@ -1473,9 +1479,9 @@ describe('apply (the plugin entry)', () => { { type: 'text-delta', index: 0, text: 'env-kid' }, { type: 'finish', reason: { kind: 'stop' } }, ] - writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8') + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(SessionSeq(i + 1), 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8') const childFile = join(dir, 'session.1.jsonl') - writeFileSync(childFile, sessionJsonl(childChunks.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'c', createdAt: 2 }), 'utf8') + writeFileSync(childFile, sessionJsonl(childChunks.map((c, i) => chunkEvent(SessionSeq(i + 1), 1, 1, c)), { id: 'c', createdAt: 2 }), 'utf8') process.env.DSH_SNAPSHOT_FILE = file process.env.DSH_SNAPSHOT_CHILD_FILES = childFile // single entry, no delimiter needed const ctx = new Context() @@ -1488,7 +1494,7 @@ describe('apply (the plugin entry)', () => { }) it('ignores an empty $DSH_SNAPSHOT_CHILD_FILES (single-session)', async () => { - writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8') + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(SessionSeq(i + 1), 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8') process.env.DSH_SNAPSHOT_FILE = file process.env.DSH_SNAPSHOT_CHILD_FILES = '' const ctx = new Context() diff --git a/packages/test-support/session-snapshot/src/normalize.ts b/packages/test-support/session-snapshot/src/normalize.ts index 8073e9430b..6e8903bf2a 100644 --- a/packages/test-support/session-snapshot/src/normalize.ts +++ b/packages/test-support/session-snapshot/src/normalize.ts @@ -10,6 +10,8 @@ import { decodeSeqRanges, decodeStorageRecord, packChunkRuns, + SessionLogOffset, + SessionSeq, type SessionEvent, } from '@deepseek-ai/dsh-session' import { redactSessionSnapshotIds } from './identity.ts' @@ -385,16 +387,16 @@ function repackSessionSnapshot(rawLog: string): string { const lines = rawLog.split('\n').filter(line => line.trim().length > 0) const header = lines.shift() as string - let nextSeq = 0 + let nextSeq = SessionLogOffset(0) const events = lines.flatMap((line) => { const record = JSON.parse(line) as Record if (isPackedFixtureRow(record)) { const decoded = decodeStorageRecord({ ...record, seq0: nextSeq, time0: 0 }) - nextSeq += decoded.length + nextSeq = SessionLogOffset(nextSeq + decoded.length) return decoded } - const event = { ...record, seq: nextSeq, time: 0 } as SessionEvent - nextSeq += 1 + const event = { ...record, seq: SessionSeq(nextSeq), time: 0 } as SessionEvent + nextSeq = SessionLogOffset(nextSeq + 1) return [event] }) const body = packChunkRuns(events).map((stored) => { diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts index c8f52e7c80..4b41b6a301 100644 --- a/packages/todo/tool-todo/tests/invariant.spec.ts +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import ToolRuntime from '@deepseek-ai/dsh-tools' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as TodoInvariant from '@deepseek-ai/dsh-tool-todo/invariant' @@ -100,13 +100,13 @@ describe('todo snapshot invariants', () => { it('validates seeded sessions announced after companion installation', async () => { const ctx = await setup() const valid = ctx.sessions.create(SessionId('todo-seeded-valid'), { seed: [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'todo/write', seq: 1, time: 2, data: { todos: [] } }, + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'todo/write', seq: SessionSeq(1), time: 2, data: { todos: [] } }, ] }) expect(() => valid.append('todo/write', { todos: [] })).not.toThrow() expect(() => ctx.sessions.create(SessionId('todo-seeded-invalid'), { seed: [ - { type: 'todo/write', seq: 0, time: 1, data: { todos: [] } }, + { type: 'todo/write', seq: SessionSeq(0), time: 1, data: { todos: [] } }, ] })).toThrow(/outside any open turn/) }) diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index c54f710c33..a3d60f5b63 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -5,7 +5,7 @@ import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { Session, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { TodoItem } from '@deepseek-ai/dsh-tool-todo' import { type Agent } from '@deepseek-ai/dsh-agent' @@ -297,7 +297,7 @@ describe('todo/write event', () => { expect(replayed.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos) .toEqual([{ content: 'only', status: 'completed' }]) - expect(replayed.snapshotEvents(0, original.seq)).toEqual(original.snapshotEvents()) + expect(replayed.snapshotEvents(SessionLogOffset(0), original.seq)).toEqual(original.snapshotEvents()) expect(replayed.firstLiveSeq).toBe(original.seq) }) }) diff --git a/packages/typert/generator/tests/schema-emitter.spec.ts b/packages/typert/generator/tests/schema-emitter.spec.ts index 16ac97b7ca..fcc6faa175 100644 --- a/packages/typert/generator/tests/schema-emitter.spec.ts +++ b/packages/typert/generator/tests/schema-emitter.spec.ts @@ -500,6 +500,40 @@ describe('SchemaEmitter supported projection matrix', () => { expect(schema.safeParse(1).success).toBe(false) }) + it('erases a unique-symbol numeric brand to its wire number', async () => { + const nominal = declaration('NominalNumber', 'alias', { + typeParameters: [{ id: 'nominal-number:brand', name: 'Brand', const: false }], + type: 'nominal-number:intersection', + }) + const symbolMember = { + ...property('[TOKEN]', 'nominal-number:brand-reference', { readonly: true }), + computed: 'symbol', + } as const + const schema = await loadSchema(emit([ + { + id: 'root', + kind: 'reference', + name: 'NominalNumber', + target: { kind: 'declaration', symbol: 'NominalNumber' }, + arguments: ['brand'], + }, + { id: 'brand', kind: 'literal', value: 'Fixture', text: "'Fixture'" }, + { id: 'nominal-number:intersection', kind: 'intersection', types: ['number', 'nominal-number:marker'] }, + keyword('number', 'number'), + { id: 'nominal-number:marker', kind: 'object', members: [symbolMember] }, + { + id: 'nominal-number:brand-reference', + kind: 'reference', + name: 'Brand', + target: { kind: 'type-parameter', parameter: 'nominal-number:brand' }, + arguments: [], + }, + ], undefined, [nominal])) + + expect(schema.safeParse(7).success).toBe(true) + expect(schema.safeParse('7').success).toBe(false) + }) + it('classifies every TypeNode kind and executes every supported kind', () => { const expected = Object.entries(ZOD_NODE_SUPPORT) .filter(([, support]) => support === 'supported') diff --git a/packages/util/brand/README.i18n.yaml b/packages/util/brand/README.i18n.yaml index 3b16704f15..77c442b474 100644 --- a/packages/util/brand/README.i18n.yaml +++ b/packages/util/brand/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/util/brand/README.md -README.md: f539cd66bd390ef0d92ff07cc54a26f39f2e3780 -README.zh.md: 4f9b113ceede7e963e43eb47dba6a96950abfcb4 +README.md: f9bcea11d223645a9bb1d2194a129f353220c684 +README.zh.md: be9d8f828e05655b41b054a11221e382c90ef58b diff --git a/packages/util/brand/README.md b/packages/util/brand/README.md index f539cd66bd..f9bcea11d2 100644 --- a/packages/util/brand/README.md +++ b/packages/util/brand/README.md @@ -1,5 +1,5 @@ --- -description: "Nominal string types and stateless constructors for packages that own identifiers crossing package boundaries." +description: "Nominal string and number types with stateless constructors for packages that own confusable domain values." kind: "package-library" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-brand` makes structurally identical strings non-interchangeable at the type level: a `SessionId` cannot be passed where a `ToolCallId` is expected even though both are plain strings at runtime. `brandString()` applies a nominal brand to one domain-owned string without shared runtime state and lets capability packages own their concrete id types without importing an unrelated capability. +`dsh-brand` makes structurally identical strings or numbers non-interchangeable at the type level: a `SessionId` cannot be passed where a `ToolCallId` is expected, and an event sequence cannot be passed where a log offset is required. `brandString()` and `brandNumber()` apply nominal brands without shared runtime state, so owning packages can define domain types without importing an unrelated capability. ## Table of Contents @@ -23,7 +23,7 @@ English | [中文](README.zh.md) ## Use this package -Brand the ids a package owns when they cross a package boundary and could plausibly be confused with another package's ids; not every string needs a brand. A branded id is a contract for TypeScript callers: it only ever enters the functions that expect it, and an id from another package is rejected at compile time. +Brand a domain value when it crosses a package boundary and could plausibly be confused with another value represented by the same primitive; not every string or number needs a brand. A branded value is a contract for TypeScript callers: it enters only functions that expect its domain, and a different brand is rejected at compile time. ### Branding a string @@ -39,9 +39,23 @@ const sessionId = brandString('session-1') `brandString()` changes only the static type and performs no runtime validation. Validate domain grammar before calling it when the owning type has one. Once branded, the id compares, logs, serializes to JSON, and crosses the wire as an ordinary string. +### Branding a number + +Declare a numeric brand in its owning package and apply it only after that package admits the number: + +```ts +import { brandNumber, type BrandedNumber } from '@deepseek-ai/dsh-brand' + +export type SessionSeq = BrandedNumber<'SessionSeq'> + +const seq = brandNumber(7) +``` + +`brandNumber()` returns the original number and performs no validation. The owning package validates requirements such as non-negative safe-integer range before branding. Comparison, arithmetic, logging, JSON serialization, and wire transport retain ordinary number behavior; arithmetic produces an unbranded number that the owner must admit again before it re-enters the domain. + ### When to brand -Brand ids that cross package boundaries and could plausibly be confused — `ToolCallId` in `dsh-llm`, the shared agent/session `SessionId` in `dsh-session`, `JobId` in `dsh-jobs`, `LspProviderId` in `dsh-lsp`. Strings that never leave their owning package do not need this abstraction. +Brand values that cross package boundaries and could plausibly be confused — `ToolCallId` in `dsh-llm`, the shared agent/session `SessionId` in `dsh-session`, `JobId` in `dsh-jobs`, and `SessionSeq` versus `SessionLogOffset` in `dsh-session`. Values that stay local or cannot be confused do not need this abstraction. ----- @@ -51,18 +65,18 @@ Brand ids that cross package boundaries and could plausibly be confused — `Too
Implementation internals — click to expand -The primitive is one intersection type: `string & { readonly [BRAND]: B }`, where `BRAND` is a module-private `unique symbol`. +The package defines two intersection types, `string & { readonly [BRAND]: B }` and `number & { readonly [BRAND]: B }`, where `BRAND` is a module-private `unique symbol`. ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Branded string type and its stateless constructor | +| [`src/index.ts`](src/index.ts) | Branded string and number types with stateless constructors | | — | No runtime invariant companion is published; this pure utility owns no event stream or mutable runtime data; its value algebra is enforced by unit tests. | ### How values stay portable -The private symbol never exists at runtime: TypeScript erases it, so branded values have no tag or prototype. `brandString()` returns its input unchanged. Separate installed copies therefore produce interchangeable values without sharing a registry or constructor identity. +The private symbol never exists at runtime: TypeScript erases it, so branded values have no tag or prototype. `brandString()` and `brandNumber()` return their inputs unchanged. Separate installed copies therefore produce interchangeable values without sharing a registry or constructor identity. ### Why it stays dependency-free @@ -75,7 +89,7 @@ Keeping these helpers in their own package means `dsh-jobs` can brand `JobId` wi ## Further Exploration -Read these pages when you need the ids this primitive brands or the type conventions around it. +Read these pages when you need the values these primitives brand or the type conventions around them. - [Core subsystem](../../../docs/subsystems/core.md) — where the shared `SessionId` brand and the type rules are documented. - [LSP subsystem](../../../docs/subsystems/lsp.md) — `LspProviderId`, a branded provider id built on this primitive. diff --git a/packages/util/brand/README.zh.md b/packages/util/brand/README.zh.md index 4f9b113cee..be9d8f828e 100644 --- a/packages/util/brand/README.zh.md +++ b/packages/util/brand/README.zh.md @@ -1,5 +1,5 @@ --- -description: "供拥有跨包标识符的包使用的名义字符串类型与无状态构造函数。" +description: "供拥有易混淆领域值的包使用的名义字符串与数字类型及无状态构造函数。" kind: "package-library" --- @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-brand` 让结构相同的字符串在类型层面不可互换:即使 `SessionId` 与 `ToolCallId` 在运行时都是普通字符串,前者也无法传给期望后者的位置。`brandString()` 为领域拥有的字符串应用名义品牌且不持有共享运行时状态,让能力包可以拥有自己的具体 id 类型,而无需导入不相关的能力。 +`dsh-brand` 让结构相同的字符串或数字在类型层面不可互换:`SessionId` 无法传给期望 `ToolCallId` 的位置,事件序号也无法传给需要日志偏移量的位置。`brandString()` 与 `brandNumber()` 在不持有共享运行时状态的情况下应用名义品牌,让所属包可以定义领域类型,而无需导入不相关的能力。 ## 目录 @@ -23,7 +23,7 @@ kind: "package-library" ## 使用本包 -当包拥有的 id 跨越包边界、并可能与其他包的 id 混淆时,为其添加品牌;并非每个字符串都需要品牌。品牌化 id 是给 TypeScript 调用方的约定:它只会进入期望它的函数,来自其他包的 id 会在编译期被拒绝。 +当领域值跨越包边界,并可能与使用同一原语表示的另一个值混淆时,为其添加品牌;并非每个字符串或数字都需要品牌。品牌化值是给 TypeScript 调用方的约定:它只会进入期望该领域的函数,不同品牌会在编译期被拒绝。 ### 为字符串添加品牌 @@ -39,9 +39,23 @@ const sessionId = brandString('session-1') `brandString()` 只改变静态类型,不执行运行时校验。所属类型若有领域文法,应在调用前完成校验。添加品牌后,该 id 与普通字符串一样比较、记录日志、序列化为 JSON 和跨 wire 传输。 +### 为数字添加品牌 + +在所属包中声明数字品牌,并且仅在该包准入数字之后应用品牌: + +```ts +import { brandNumber, type BrandedNumber } from '@deepseek-ai/dsh-brand' + +export type SessionSeq = BrandedNumber<'SessionSeq'> + +const seq = brandNumber(7) +``` + +`brandNumber()` 原样返回数字,不执行校验。所属包会在添加品牌前校验非负安全整数范围等要求。比较、算术、日志、JSON 序列化与 wire 传输保留普通数字行为;算术会产生未品牌化数字,所属包必须重新准入该数字,才能让它再次进入领域。 + ### 何时添加品牌 -为跨包边界且可能被混淆的 id 添加品牌——`dsh-llm` 中的 `ToolCallId`、`dsh-session` 中共享的 agent/会话 `SessionId`、`dsh-jobs` 中的 `JobId`、`dsh-lsp` 中的 `LspProviderId`。从不离开所属包的字符串不需要这种抽象。 +为跨包边界且可能被混淆的值添加品牌——`dsh-llm` 中的 `ToolCallId`、`dsh-session` 中共享的 agent/会话 `SessionId`、`dsh-jobs` 中的 `JobId`,以及 `dsh-session` 中的 `SessionSeq` 与 `SessionLogOffset`。保持局部或无法混淆的值不需要这种抽象。 ----- @@ -51,18 +65,18 @@ const sessionId = brandString('session-1')
实现细节——点击展开 -该原语是一个交叉类型:`string & { readonly [BRAND]: B }`,其中 `BRAND` 是模块私有的 `unique symbol`。 +该包定义两个交叉类型:`string & { readonly [BRAND]: B }` 与 `number & { readonly [BRAND]: B }`,其中 `BRAND` 是模块私有的 `unique symbol`。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 品牌化字符串类型及其无状态构造函数 | +| [`src/index.ts`](src/index.ts) | 品牌化字符串与数字类型及其无状态构造函数 | | — | 不发布运行时不变量伴生入口;擦除由编译器保证。 | ### 值为何可移植 -私有 symbol 在运行时不存在:TypeScript 会将其擦除,因此品牌化值没有标签或 prototype。`brandString()` 原样返回输入。因此,彼此独立安装的副本无需共享注册表或 constructor identity,也会生成可互换的值。 +私有 symbol 在运行时不存在:TypeScript 会将其擦除,因此品牌化值没有标签或 prototype。`brandString()` 与 `brandNumber()` 都原样返回输入。因此,彼此独立安装的副本无需共享注册表或 constructor identity,也会生成可互换的值。 ### 为何保持无依赖 @@ -75,7 +89,7 @@ const sessionId = brandString('session-1') ## 进一步探索 -当你需要本原语所品牌化的 id 或围绕它的类型约定时,阅读以下页面。 +当你需要这些原语所品牌化的值或围绕它们的类型约定时,阅读以下页面。 - [核心子系统](../../../docs/subsystems/core.zh.md)——共享 `SessionId` 品牌与类型规则的记录位置。 - [LSP 子系统](../../../docs/subsystems/lsp.zh.md)——构建在本原语之上的品牌化提供方 id `LspProviderId`。 diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 5a77c4810e..b6d049357b 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-brand", - "description": "Stateless branded-string primitives for the DeepSeek Harness", + "description": "Stateless branded primitive types for the DeepSeek Harness", "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" diff --git a/packages/util/brand/src/index.ts b/packages/util/brand/src/index.ts index 68f3d857d8..7f89f3eddc 100644 --- a/packages/util/brand/src/index.ts +++ b/packages/util/brand/src/index.ts @@ -1,12 +1,12 @@ /** - * Duplicate-install-safe nominal string helpers. + * Duplicate-install-safe nominal primitive helpers. * - * A brand makes structurally-identical strings non-interchangeable at the type - * level: a `SessionId` cannot be passed where a `ToolCallId` is expected, even - * though both are plain strings at runtime. Comparison, logging, and - * serialization all behave as ordinary strings. + * A brand makes structurally identical strings or numbers non-interchangeable + * at the type level: a `SessionId` cannot be passed where a `ToolCallId` is + * expected, and an event sequence cannot be passed as a log offset. Comparison, + * logging, and serialization retain the underlying primitive behavior. * - * This package owns no concrete id and keeps no runtime identity or mutable + * This package owns no concrete domain value and keeps no runtime identity or mutable * state, so independently installed copies produce interchangeable values. * * @module @deepseek-ai/dsh-brand @@ -17,6 +17,9 @@ declare const BRAND: unique symbol /** A string carrying a compile-time-only brand `B`. */ export type Branded = string & { readonly [BRAND]: B } +/** A number carrying a compile-time-only brand `B`. */ +export type BrandedNumber = number & { readonly [BRAND]: B } + /** * Apply a compile-time string brand without changing the value. * @param value - string admitted by the domain that owns the target brand. @@ -25,3 +28,12 @@ export type Branded = string & { readonly [BRAND]: B } export function brandString>(value: string | T): T { return value as T } + +/** + * Apply a compile-time number brand without changing the value. + * @param value - number admitted by the domain that owns the target brand. + * @returns the same number with the requested compile-time brand. + */ +export function brandNumber>(value: number | T): T { + return value as T +} diff --git a/packages/util/brand/tests/brand.spec.ts b/packages/util/brand/tests/brand.spec.ts new file mode 100644 index 0000000000..96f2665cc8 --- /dev/null +++ b/packages/util/brand/tests/brand.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { brandNumber, type BrandedNumber } from '../src/index.ts' + +type EventOrdinal = BrandedNumber<'EventOrdinal'> + +describe('numeric brands', () => { + it('brands a number without changing its runtime value', () => { + const value = brandNumber(7) + + expect(value).toBe(7) + expectTypeOf(value).toEqualTypeOf() + }) +}) diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts index 67f29d29fd..6d14949aba 100644 --- a/packages/workflow/tool-ralph/tests/integration.spec.ts +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -101,7 +101,8 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { for (const child of children) { expect(child.session.header.cwd).toBe('/tmp/ralph-shared-workspace') expect(child.session.header.parentSession).toBe(parent.session.header.id) - expect(child.session.header.seedLength).toBeUndefined() + expect(child.session.header.isSeeded).toBe(false) + expect(child.session.inheritedEventCount).toBe(0) expect(ctx.agents.get(child.id)).toBeUndefined() } diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 22900c7649..49348cea2a 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -23,6 +23,7 @@ const header = (id: string, cwd?: string, createdAt = 0): SessionHeader => ({ version: 0, id: SessionId(id), createdAt, + isSeeded: false, ...(cwd === undefined ? {} : { cwd }), }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da090f24c2..4521b1bbf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7062,6 +7062,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-util-values': + specifier: workspace:^ + version: link:../../util/values '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a6fa86fe40..e0f068e0d7 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -317,6 +317,10 @@ export const LINK_MAP: Readonly> = { SessionForkRequest: 'session.md', SessionForkValue: 'session.md', SessionId: 'core.md', + SessionLogOffset: 'session.md', + SessionSeq: 'session.md', + SessionSeqCursor: 'session.md', + OptionalSessionSeq: 'session.md', SessionListRequest: 'session.md', SessionListValue: 'session.md', ModelCatalog: 'session.md', @@ -408,6 +412,7 @@ export const LINK_MAP: Readonly> = { PrepareSessionOptions: 'persistence.md', SessionHeader: 'persistence.md', SessionInspection: 'persistence.md', + SessionEventSuffix: 'persistence.md', BorrowedSessionSource: 'persistence.md', SessionLocation: 'persistence.md', SessionPreparation: 'persistence.md', diff --git a/scripts/session-fixture-layout.spec.ts b/scripts/session-fixture-layout.spec.ts index ddff386615..ca1fdfbf4f 100644 --- a/scripts/session-fixture-layout.spec.ts +++ b/scripts/session-fixture-layout.spec.ts @@ -1,6 +1,6 @@ import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' -import { type SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import { canonicalSessionFixture, @@ -14,7 +14,7 @@ const root = resolve(import.meta.dirname, '..') function chunkRun(): SessionEvent[] { return Array.from({ length: 4 }, (_, index) => ({ type: 'assistant/chunk', - seq: index, + seq: SessionSeq(index), time: 10 + index, data: { turn: 1, diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index df432dd3b5..7def55bbee 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -469,6 +469,26 @@ "symbol": "RequestContext", "source": "packages/core/session/src/types.ts" }, + { + "doc": "docs/subsystems/session.md", + "symbol": "SessionSeq", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/subsystems/session.md", + "symbol": "SessionLogOffset", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/subsystems/session.md", + "symbol": "SessionSeqCursor", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/subsystems/session.md", + "symbol": "OptionalSessionSeq", + "source": "packages/core/session/src/types.ts" + }, { "doc": "docs/subsystems/todo.md", "symbol": "TodoItem", @@ -556,11 +576,21 @@ "source": "packages/core/session/src/preparation.ts", "projection": "public-api" }, + { + "doc": "docs/subsystems/persistence.md", + "symbol": "SessionStorageMetadata", + "source": "packages/session/session-persistence/src/index.ts" + }, { "doc": "docs/subsystems/persistence.md", "symbol": "SessionInspection", "source": "packages/session/session-persistence/src/index.ts" }, + { + "doc": "docs/subsystems/persistence.md", + "symbol": "SessionEventSuffix", + "source": "packages/session/session-persistence/src/index.ts" + }, { "doc": "docs/subsystems/persistence.md", "symbol": "SessionLocation",