From da94bfd37c07f952b55a1bb91d10b6a342bd3ffb Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 30 Jun 2026 12:53:46 +0800 Subject: [PATCH 01/14] feat: add session fork service --- docs/architecture.md | 6 +- docs/cordis-catalog/events-and-services.md | 11 + docs/core-data-structures/core.md | 1 + docs/core-data-structures/session-fork.md | 19 ++ docs/module-graph.md | 2 + docs/rfc/README.md | 1 + .../2026-06-30-session-fork-service.md | 53 +++++ packages/README.md | 3 + packages/session-fork/README.md | 9 + packages/session-fork/session-fork/README.md | 29 +++ .../session-fork/session-fork/package.json | 34 +++ .../session-fork/session-fork/src/index.ts | 128 +++++++++++ .../session-fork/tests/session-fork.spec.ts | 209 ++++++++++++++++++ .../session-fork/session-fork/tsconfig.json | 21 ++ pnpm-lock.yaml | 15 ++ tsconfig.base.json | 1 + tsconfig.build.json | 1 + tsconfig.json | 1 + 18 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 docs/core-data-structures/session-fork.md create mode 100644 docs/rfc/implemented/feature/2026-06-30-session-fork-service.md create mode 100644 packages/session-fork/README.md create mode 100644 packages/session-fork/session-fork/README.md create mode 100644 packages/session-fork/session-fork/package.json create mode 100644 packages/session-fork/session-fork/src/index.ts create mode 100644 packages/session-fork/session-fork/tests/session-fork.spec.ts create mode 100644 packages/session-fork/session-fork/tsconfig.json diff --git a/docs/architecture.md b/docs/architecture.md index 315715c828..c3b2130fb3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,6 +54,7 @@ Dependency rule: **extension** plugins depend on interface packages, never on `d | `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `ReactLoopAgent`s and drives their loops | | `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks | | `ctx.compact` | `CompactService` (abstract) | dsh-compact | compaction seam: decide when history is too large, summarize an older range into a single surface node | +| `ctx.sessionFork` | `SessionForkService` | dsh-session-fork | live-session fork seam: validate turn-boundary forks, snapshot seed events, create forked child sessions | All registrations (`registerAdapter`, `section`, `tools`, `register`, …) go through `ctx.effect()` and return disposers, so plugin hot-reload (vendored HMR) and fiber disposal clean up automatically. @@ -88,7 +89,7 @@ A `Session` is an append-only log of typed `SessionEvent`s — the single source - `tool/result` → user message carrying a `tool-result` block - `context/message`, `steering/message` → user-role messages wrapped in a tagged envelope (``) at their chronological position — the "system-reminder" pattern; models distinguish them from real user prompts by the envelope. **TODO(review)**: the real adapters now exist (the original precondition); the envelope still wants a deliberate review against live model behavior (`TODO(review)` in dsh-session). -Replay/fork = `ctx.sessions.create(id, { seed: seedEvents })`. Trace/telemetry = listen to `session/event`. +Replay/fork = `ctx.sessions.create(id, { seed: seedEvents })`; user-facing live-session fork policy lives in the optional `ctx.sessionFork` service, which rejects non-boundary forks instead of changing the core store. Trace/telemetry = listen to `session/event`. **Durability seam**: `session/event` is a synchronous notification; persistence plugins buffer (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end. The durable backend is a real **capability seam**: the abstract `SessionPersistence` service (`dsh-session-persistence`, `ctx.sessionPersistence`) defines create/append/load/list over the existing `SessionEvent` (no parallel persisted type), and `dsh-session-persistence-jsonl` is the first implementation — an append-only JSONL log per session with crash-safe atomic writes, crash recovery that PRESERVES an interrupted turn (closing it with a synthetic `turn/end {interrupted}` rather than truncating — a turn can be huge), and a read/replay path. Session metadata (format version, cwd, lineage) travels separately as `SessionHeader`, attached to a `Session` via `session.header`. Resuming a persisted session into a live agent is `ctx.agents.resume({ resumeSessionId })`. A second backend, `dsh-session-persistence-sqlite` (`node:sqlite`, one row per `SessionEvent` — the row shape `(session_id, seq, type, time, data)` maps 1:1 onto it), passes the same `runPersistenceContract` suite, proving the seam is genuinely backend-agnostic. @@ -194,6 +195,7 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl | Dynamic workflow | orchestrator plugin on `agent/turn-end` / `agent/step-end` driving `send`/`steer` (+ sub-agents later) | | Queued + steering messages | core `Agent.send()` / `Agent.steer()` | | Context compaction (auto + manual) | the `ctx.compact` seam ([dsh-compact](../packages/compact/compact)): a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure at turn boundaries, manual = a `/compact` tool. See the [compaction capability-seam RFC](rfc/proposed/feature/2026-06-18-compaction-capability-seam.md) | +| Session fork | the `ctx.sessionFork` seam ([dsh-session-fork](../packages/session-fork/session-fork)): validate the source is at a turn boundary, snapshot its seed, and create a child session with `parentSession`/`seedLength` metadata. | | System prompt configurability | `ctx.systemPrompt.section()` with ordering | | AGENTS.md (root) | a section provider reading the file | | AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener | @@ -223,4 +225,4 @@ Tracked here deliberately — each is designed-for but not implemented: - **Sub-agent spawn/fork semantics** (seam: `AgentLoop.create()`); inter-agent channels beyond `send`/`steer`/events. - **Compaction implementation** (auto thresholds, summarization prompts) on the `agent/request` seam, with its session-event types added by declaration merging. - **Parallel tool execution** (concurrency-safety hints on ToolDefinition). -- **Session branching/tree** (pi-style entry tree) if needed beyond seed-based forking. +- **Session branching/tree** (pi-style entry tree) if needed beyond the current seed-based `ctx.sessionFork` service. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 42c5f7440c..7b701a47c3 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -393,6 +393,17 @@ Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../co Source: [`packages/llm/llm/src/index.ts:69`](../../packages/llm/llm/src/index.ts) +### `ctx.sessionFork` — `SessionForkService` + +`ctx.sessionFork`: validates live session fork boundaries and creates seeded child sessions using the existing `ctx.sessions.create({ seed })` primitive. + +```ts cordis-catalog +snapshot(source: SessionForkSource): SessionForkSeed +fork(options: ForkSessionOptions): Session +``` + +Source: [`packages/session-fork/session-fork/src/index.ts:63`](../../packages/session-fork/session-fork/src/index.ts) + ### `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) Abstract durable session-persistence service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.sessionPersistence` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 757e6fb400..2d1e60c916 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -22,6 +22,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s | | [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface | | [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split | +| [session-fork.md](session-fork.md) | the session fork seam: live-session boundary validation, seed snapshot metadata, and child-session creation | > Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts. diff --git a/docs/core-data-structures/session-fork.md b/docs/core-data-structures/session-fork.md new file mode 100644 index 0000000000..3bb02a9162 --- /dev/null +++ b/docs/core-data-structures/session-fork.md @@ -0,0 +1,19 @@ +# Session Fork + +The session fork service is an optional capability over the core session store. It does not add new log events or persisted record shapes; it packages the existing seed primitive into a safe service with a turn-boundary policy. + +Package: [`@deepseek-ai/dsh-session-fork`](../../packages/session-fork/session-fork) (`ctx.sessionFork`). The decision and rationale are recorded in [the session fork service RFC](../rfc/implemented/feature/2026-06-30-session-fork-service.md). + +## Service Shape + +`SessionForkService.snapshot(source)` accepts a live `Session` object or live `SessionId`, validates the source log is empty or ends at `turn/end`, then returns the resolved source, a deep-cloned `SessionEvent[]` seed, and child metadata: `parentSession`, `seedLength`, and optional inherited `cwd`. + +`SessionForkService.fork({ source, sessionId? })` is a convenience wrapper around `ctx.sessions.create(sessionId, { seed, meta })`. Consumers that create agents can use `snapshot()` directly and pass the returned seed/meta through the agent factory instead of creating a detached session first. + +## Boundary Policy + +The boundary rule is structural: every `turn/end` reason is forkable, and every non-empty log whose last event is not `turn/end` is rejected. This is intentionally stricter than the subagent fork backend, which clips to the parent's last completed-turn prefix because it is usually invoked from inside the parent's active tool turn. + +## Persistence + +No persistence method is added. A forked child is just a normal live session with seed events already present at creation time, so existing persistence backends persist the inherited prefix and header metadata through `session/created` and `session/flush`. diff --git a/docs/module-graph.md b/docs/module-graph.md index c2736abca1..ae971376b7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -22,6 +22,7 @@ graph TD compact --> session llm-replay --> llm llm-replay --> session + session-fork --> session session-persistence --> session invariants --> agent invariants --> llm @@ -108,6 +109,7 @@ graph TD | `agent` | `brand`, `llm`, `session` | | `compact` | `llm`, `session` | | `llm-replay` | `llm`, `session` | +| `session-fork` | `session` | | `session-persistence` | `session` | | `invariants` | `agent`, `llm`, `session` | | `session-persistence-jsonl` | `session`, `session-persistence` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 52676cdebf..e1e6075aa8 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -86,6 +86,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 | | [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 | | [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 | +| [Session fork service](implemented/feature/2026-06-30-session-fork-service.md) | 2026-06-30 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-06-30-session-fork-service.md b/docs/rfc/implemented/feature/2026-06-30-session-fork-service.md new file mode 100644 index 0000000000..6b59f0a9f6 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-session-fork-service.md @@ -0,0 +1,53 @@ +# RFC: Session fork service + +Status: implemented (proposed 2026-06-30, accepted 2026-06-30) + +## Context + +The event-sourced session log already has the primitive a fork needs: create a new session with a seed event prefix, then derive model history from that seeded log exactly as replay does. That primitive is intentionally low-level. It lives on `dsh-session` as `ctx.sessions.create(id, { seed })`, while durable metadata such as `parentSession` and `seedLength` is stored on the out-of-log `SessionHeader` introduced by [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md). The same mechanics already support in-process subagent fork children and replay routing for forked child logs. + +What is missing is a reusable product service for ordinary session forking. Putting that directly on `dsh-session` would make a derived workflow part of the core log API, even though the core session package should stay focused on append-only storage, derived history, and lifecycle events. The harness architecture prefers optional capability plugins over widening the core spine; [event-sourced sessions](../../implemented/architecture/2026-06-11-event-sourced-sessions.md) provide the log semantics, and [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) provide the extension pattern. + +The main semantic hazard is the fork boundary. A session event log is only a valid seed when it is contiguous and balanced. Forking inside an active turn would copy an open `turn/start`, possibly an open `step/start`, and possibly dangling tool calls. That violates the turn-enclosure and provider-transcript invariants, and it creates a misleading child history that appears to have participated in an unfinished parent turn. The existing [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) deliberately solves a different problem: a tool-triggered subagent fork usually happens while the parent turn is open, so `dsh-subagent-fork` clips the seed to the parent's last completed-turn prefix. A general session fork should not silently clip; it should reject attempts made away from a boundary. + +## Decision + +The shipped design adds an optional product package, `@deepseek-ai/dsh-session-fork`, under `packages/session-fork/session-fork`. It registers `ctx.sessionFork` and depends only on `cordis` plus the `dsh-session` vocabulary/service. No new session event types, persistence methods, ACP methods, agent-loop hooks, or subagent behavior are added in the first cut. + +The service exposes two operations: + +```ts ignore-check +type SessionForkSource = Session | SessionId + +interface SessionForkSeed { + source: Session + seed: SessionEvent[] + meta: { + parentSession: SessionId + seedLength: number + cwd?: string + } +} + +interface ForkSessionOptions { + source: SessionForkSource + sessionId?: SessionId +} + +class SessionForkService extends Service { + snapshot(source: SessionForkSource): SessionForkSeed + fork(options: ForkSessionOptions): Session +} +``` + +`snapshot()` is the reusable half. It resolves only live sessions from `ctx.sessions`; v1 does not load unloaded persisted sessions by id. It validates the source is at a turn boundary, deep-clones the source events, and returns the seed plus metadata a caller can pass to a later session or agent creation path. This shape keeps the fork computation reusable for future ACP or agent-facing consumers without coupling this service to `ctx.agents`. + +`fork()` is the convenience half. It calls `snapshot()`, then creates a live child session via `ctx.sessions.create(sessionId, { seed, meta })`. The child inherits the source session's `cwd`, stamps `parentSession` to the source id, and sets `seedLength` to the seeded prefix length. When `sessionId` is omitted, `SessionStore` generates one using its existing id policy. + +The boundary rule is structural: an empty source log is forkable, and any source whose last event is `turn/end` is forkable regardless of the turn-end reason (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `interrupted`, or a future merge-extensible reason). Any non-empty source whose last event is not `turn/end` is inside a turn or otherwise not at the boundary and is rejected with a typed `SessionForkError` code. This is intentionally stricter than `dsh-subagent-fork`, whose completed-prefix clipping remains unchanged because it serves tool-time delegation rather than user/session branching. + +## Consequences + +The feature is a small capability seam rather than a change to `dsh-session`: the core log keeps its low-level seed primitive, while `dsh-session-fork` owns policy, error taxonomy, and convenience creation. 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 v1 scope deliberately excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. Those can consume `snapshot()` later. If a future ACP method is added, it should advertise the capability only after it has transcript/snapshot coverage; this RFC adds no editor-facing updates, so no ACP snapshot is required now. Fork-child replay remains covered by the existing [seed-boundary testing RFC](../../implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md), while this service gets focused unit tests plus one persistence integration test. diff --git a/packages/README.md b/packages/README.md index 3997fd5190..4d1f8b6f32 100644 --- a/packages/README.md +++ b/packages/README.md @@ -14,6 +14,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`compact/`](compact/README.md) | Compaction capability family: the abstract seam (backend + tool deferred) | Product — stable surface | | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | | [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface | +| [`session-fork/`](session-fork/README.md) | Session fork capability family: live-session fork snapshots and child session creation | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface | | [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations | @@ -31,6 +32,7 @@ dsh-session ← dsh-llm, dsh-brand dsh-system-prompt ← dsh-llm dsh-agent ← dsh-llm, dsh-session, dsh-brand dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; backend + tool deferred) +dsh-session-fork ← dsh-session (live-session fork snapshots + child session creation) dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent dsh-bash-local ← dsh-bash (BashExecutor impl) dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) @@ -70,6 +72,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | | `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | | `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` | +| `session-fork/` | `session-fork` | Session fork service over live session seeds | `ctx.sessionFork` | | `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) | | `session-persistence/` | `session-persistence` | Persistence seam + write coordinator | `ctx.sessionPersistence` | diff --git a/packages/session-fork/README.md b/packages/session-fork/README.md new file mode 100644 index 0000000000..ea4e97e3bb --- /dev/null +++ b/packages/session-fork/README.md @@ -0,0 +1,9 @@ +# session-fork/ — session fork capability family + +The session fork capability: a small optional service that validates a live session is at a turn boundary, snapshots its event log as a seed, and creates forked child sessions through the existing `dsh-session` seed primitive. All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `session-fork/` | Session fork service: reusable seed snapshot + forked live-session creation | `ctx.sessionFork` | + +The interface and implementation live together at `session-fork/session-fork/` because v1 has no swappable backend: all durable behavior is delegated to the existing session store and persistence backends. The decision is recorded in [the session fork service RFC](../../docs/rfc/implemented/feature/2026-06-30-session-fork-service.md). diff --git a/packages/session-fork/session-fork/README.md b/packages/session-fork/session-fork/README.md new file mode 100644 index 0000000000..7f89d299c4 --- /dev/null +++ b/packages/session-fork/session-fork/README.md @@ -0,0 +1,29 @@ +# @deepseek-ai/dsh-session-fork + +Session fork service (`ctx.sessionFork`) for creating seeded child sessions from a live source session at a turn boundary. + +## Service: `SessionForkService` + +`SessionForkService` is an optional plugin over `dsh-session`; it does not add session events or persistence methods. It owns fork policy, while `ctx.sessions.create(id, { seed, meta })` remains the low-level replay/fork primitive. + +| Method | Purpose | +|---|---| +| `snapshot(source)` | Resolve a live `Session | SessionId`, reject non-boundary logs, and return a deep-cloned seed plus `parentSession` / `seedLength` metadata. | +| `fork({ source, sessionId? })` | Create a live child session from `snapshot(source)`, using the caller-supplied child id or the session store's generated id. | + +## Boundary Rule + +A source is forkable only when its log is empty or its last event is `turn/end`. The service accepts any turn-end reason, including `aborted`, `error`, `disposed`, `max-tokens`, and crash-repaired `interrupted`; the boundary is structural, not a statement that the prior turn was successful. + +Forking inside a turn is rejected with `SessionForkError` code `OPEN_TURN`. The service intentionally does not clip to an older completed prefix; that behavior is specific to `dsh-subagent-fork`, where tool-time delegation normally happens while the parent turn is open. + +## Errors + +| Code | Meaning | +|---|---| +| `SESSION_NOT_FOUND` | A source id is not live in `ctx.sessions`, or a passed `Session` object is not the live store object for its id. | +| `OPEN_TURN` | The source log is non-empty and does not end at `turn/end`. | + +## Persistence + +Forked sessions use existing session metadata: `parentSession` points to the source session id, `seedLength` is the number of inherited events, and `cwd` is inherited when present. Persistence backends observe the forked child through their existing `session/created` and `session/flush` write path, so no backend-specific fork API is needed. diff --git a/packages/session-fork/session-fork/package.json b/packages/session-fork/session-fork/package.json new file mode 100644 index 0000000000..73dd30b349 --- /dev/null +++ b/packages/session-fork/session-fork/package.json @@ -0,0 +1,34 @@ +{ + "name": "@deepseek-ai/dsh-session-fork", + "description": "Session fork service for creating seeded child sessions at turn boundaries", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/session-fork/session-fork/src/index.ts b/packages/session-fork/session-fork/src/index.ts new file mode 100644 index 0000000000..0754e86ce1 --- /dev/null +++ b/packages/session-fork/session-fork/src/index.ts @@ -0,0 +1,128 @@ +/** + * Session forking as an optional service. The core session store exposes the + * low-level seed primitive; this plugin owns the policy for when a live session + * may be forked and the metadata stamped on the child. + * + * @module @deepseek-ai/dsh-session-fork + */ + +import { Context, Service } from 'cordis' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +declare module 'cordis' { + interface Context { + sessionFork: SessionForkService + } +} + +/** A fork source: either the live session object or its live store id. */ +export type SessionForkSource = Session | SessionId + +/** Metadata and seed events that can create a forked child session or agent. */ +export interface SessionForkSeed { + /** The resolved live source session. */ + source: Session + /** Deep-cloned seed events copied from the source session at a turn boundary. */ + seed: SessionEvent[] + /** Session creation metadata for the forked child. */ + meta: { + /** The source session id. */ + parentSession: SessionId + /** How many leading child events were inherited rather than produced. */ + seedLength: number + /** The source session workspace, inherited by the child when present. */ + cwd?: string + } +} + +/** Inputs for the convenience session-creation path. */ +export interface ForkSessionOptions { + /** Live source session object or id. */ + source: SessionForkSource + /** Optional child session id; omitted delegates to SessionStore's id policy. */ + sessionId?: SessionId +} + +export type SessionForkErrorCode = + | 'SESSION_NOT_FOUND' + | 'OPEN_TURN' + +/** Typed error for service-level fork rejections. */ +export class SessionForkError extends Error { + constructor(message: string, public readonly code: SessionForkErrorCode) { + super(message) + this.name = 'SessionForkError' + } +} + +/** + * `ctx.sessionFork`: validates live session fork boundaries and creates seeded + * child sessions using the existing `ctx.sessions.create({ seed })` primitive. + */ +export class SessionForkService extends Service { + static inject = ['sessions'] + + constructor(ctx: Context) { + super(ctx, 'sessionFork') + } + + /** + * Resolve and validate a live source session, then return a reusable deep- + * cloned fork seed. A non-empty source must end exactly at `turn/end`; this + * service rejects open turns rather than clipping to an older boundary. + */ + snapshot(source: SessionForkSource): SessionForkSeed { + const session = this.resolve(source) + this.assertTurnBoundary(session) + const seed = session.events.map(event => structuredClone(event)) + return { + source: session, + seed, + meta: { + ...session.header.cwd !== undefined ? { cwd: session.header.cwd } : {}, + parentSession: session.id, + seedLength: seed.length, + }, + } + } + + /** + * Convenience path: create a live child session from a fork snapshot. Callers + * that create agents can use {@link snapshot} and pass its seed/meta through + * `ctx.agents.create` instead. + */ + fork(options: ForkSessionOptions): Session { + const snapshot = this.snapshot(options.source) + return this.ctx.sessions.create(options.sessionId, { + seed: snapshot.seed, + meta: snapshot.meta, + }) + } + + private resolve(source: SessionForkSource): Session { + if (typeof source === 'string') { + const session = this.ctx.sessions.get(source) + if (session === undefined) throw new SessionForkError(`session "${source}" not found`, 'SESSION_NOT_FOUND') + return session + } + + const live = this.ctx.sessions.get(source.id) + if (live !== source) { + throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND') + } + return source + } + + private assertTurnBoundary(session: Session): void { + const last = session.events.at(-1) + if (last !== undefined && last.type !== 'turn/end') { + throw new SessionForkError( + `cannot fork session "${session.id}" inside an open turn (last event: ${last.type})`, + 'OPEN_TURN', + ) + } + } +} + +export default SessionForkService diff --git a/packages/session-fork/session-fork/tests/session-fork.spec.ts b/packages/session-fork/session-fork/tests/session-fork.spec.ts new file mode 100644 index 0000000000..c5c931c732 --- /dev/null +++ b/packages/session-fork/session-fork/tests/session-fork.spec.ts @@ -0,0 +1,209 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { CallId } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionForkService, { SessionForkError } from '../src/index.ts' + +const tempDirs: string[] = [] + +afterEach(async () => { + for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true }) +}) + +async function tempRoot(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-session-fork-')) + tempDirs.push(dir) + return dir +} + +async function setup(): Promise<{ ctx: Context; fork: SessionForkService }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionForkService) + return { ctx, fork: ctx.sessionFork } +} + +function appendClosedTurn(session: Session, reason: TurnEndReason = { kind: 'completed' }): void { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: 'hello' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 1, reason }) +} + +function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/message'> { + const event = events.find((e): e is SessionEvent<'user/message'> => e.type === 'user/message') + if (event === undefined) throw new Error('missing user/message') + return event +} + +describe('SessionForkService', () => { + it('registers as ctx.sessionFork and unregisters on fiber disposal', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionForkService) + expect(ctx.sessionFork).toBeInstanceOf(SessionForkService) + + await fiber.dispose() + + expect(ctx.sessionFork).toBeUndefined() + }) + + it('snapshots an empty live session as an empty seed with lineage metadata', async () => { + const { ctx, fork } = await setup() + const source = ctx.sessions.create(SessionId('empty-parent'), { meta: { cwd: '/workspace' } }) + + const snapshot = fork.snapshot(source) + + expect(snapshot.source).toBe(source) + expect(snapshot.seed).toEqual([]) + expect(snapshot.meta).toEqual({ + cwd: '/workspace', + parentSession: SessionId('empty-parent'), + seedLength: 0, + }) + }) + + it('snapshots a completed boundary by live session id and deep-clones seed events', async () => { + const { ctx, fork } = await setup() + const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } }) + appendClosedTurn(source) + + const snapshot = fork.snapshot(SessionId('parent')) + + expect(snapshot.source).toBe(source) + expect(snapshot.seed).toEqual(source.events) + expect(snapshot.seed).not.toBe(source.events) + expect(snapshot.seed[1]).not.toBe(source.events[1]) + firstUserMessage(snapshot.seed).data.content[0] = { type: 'text', text: 'mutated' } + expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(snapshot.meta).toEqual({ + cwd: '/workspace', + parentSession: SessionId('parent'), + seedLength: source.events.length, + }) + }) + + it('accepts every turn/end reason as a fork boundary', async () => { + const { ctx, fork } = await setup() + const reasons: TurnEndReason[] = [ + { kind: 'completed' }, + { kind: 'aborted', reason: 'cancelled by user' }, + { kind: 'error', step: 1, message: 'model failed', code: 'MODEL' }, + { kind: 'disposed' }, + { kind: 'max-tokens' }, + { kind: 'interrupted' }, + ] + + for (const reason of reasons) { + const source = ctx.sessions.create(SessionId(`parent-${reason.kind}`)) + appendClosedTurn(source, reason) + + const snapshot = fork.snapshot(source) + + expect(snapshot.seed.at(-1)?.type).toBe('turn/end') + expect(snapshot.meta.seedLength).toBe(source.events.length) + } + }) + + it('rejects an unknown live session id', async () => { + const { fork } = await setup() + + expect(() => fork.snapshot(SessionId('missing'))) + .toThrow(new SessionForkError('session "missing" not found', 'SESSION_NOT_FOUND')) + }) + + it('rejects a detached Session object that is not live in ctx.sessions', async () => { + const { fork } = await setup() + const detached = new Session(SessionId('detached')) + + expect(() => fork.snapshot(detached)) + .toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND')) + }) + + it('rejects non-empty logs whose last event is not turn/end', async () => { + const { ctx, fork } = await setup() + const cases: [string, (session: Session) => void][] = [ + ['turn/start', (session) => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + }], + ['step/start', (session) => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + }], + ['user/message', (session) => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { content: [{ type: 'text', text: 'open' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + }], + ['assistant/message', (session) => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'partial' }] }, { surfaceOp: 'append' }) + }], + ['tool/call', (session) => { + const callId = CallId('call-open') + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }], + }, { surfaceOp: 'append' }) + session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' }) + }], + ] + + for (const [lastType, build] of cases) { + const source = ctx.sessions.create(SessionId(`open-${lastType}`)) + build(source) + + expect(() => fork.snapshot(source)) + .toThrow(new SessionForkError(`cannot fork session "open-${lastType}" inside an open turn (last event: ${lastType})`, 'OPEN_TURN')) + } + }) + + it('creates a forked child session with the seed and lineage metadata', async () => { + const { ctx, fork } = await setup() + const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } }) + appendClosedTurn(source) + + const child = fork.fork({ source, sessionId: SessionId('child') }) + + expect(child.id).toBe(SessionId('child')) + expect(child.events).toEqual(source.events) + expect(child.header.parentSession).toBe(source.id) + expect(child.header.seedLength).toBe(source.events.length) + expect(child.header.cwd).toBe('/workspace') + firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' } + expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }]) + }) + + it('persists a forked child seed through the existing session write path', async () => { + const root = await tempRoot() + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionForkService) + await ctx.plugin(SessionPersistenceJsonl, { root }) + const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } }) + appendClosedTurn(source) + + const child = ctx.sessionFork.fork({ source, sessionId: SessionId('persist-child') }) + await ctx.parallel('session/flush', child) + const loaded = await ctx.sessionPersistence.load(child.id) + + expect(loaded.events).toEqual(source.events) + expect(loaded.meta).toMatchObject({ + id: SessionId('persist-child'), + cwd: '/workspace', + parentSession: SessionId('persist-parent'), + seedLength: source.events.length, + }) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/session-fork/session-fork/tsconfig.json b/packages/session-fork/session-fork/tsconfig.json new file mode 100644 index 0000000000..e817086a6a --- /dev/null +++ b/packages/session-fork/session-fork/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc57472bef..873c02f0d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -289,6 +289,21 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/session-fork/session-fork: + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/session-persistence/session-persistence: devDependencies: '@deepseek-ai/dsh-session': diff --git a/tsconfig.base.json b/tsconfig.base.json index 3f2d828cb4..787d983dbf 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -45,6 +45,7 @@ "./packages/bash/*/src", "./packages/compact/*/src", "./packages/subagent/*/src", + "./packages/session-fork/*/src", "./packages/todo/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index 22ba7a0687..f1ece94409 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -23,6 +23,7 @@ { "path": "./packages/core/agent-core" }, { "path": "./packages/bash/bash" }, { "path": "./packages/compact/compact" }, + { "path": "./packages/session-fork/session-fork" }, { "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-pi-ai" }, { "path": "./packages/bash/bash-local" }, diff --git a/tsconfig.json b/tsconfig.json index f0a4389df5..d27afdf866 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,6 +38,7 @@ { "path": "./packages/bash/bash-local" }, { "path": "./packages/bash/tool-bash" }, { "path": "./packages/compact/compact" }, + { "path": "./packages/session-fork/session-fork" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, { "path": "./packages/ui/acp-agent" }, From 088860b80b3d19614d956da270289eecf4012127 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 30 Jun 2026 13:04:40 +0800 Subject: [PATCH 02/14] fix: classify session fork errors --- docs/cordis-catalog/events-and-services.md | 2 +- .../2026-06-30-session-fork-service.md | 2 +- packages/session-fork/session-fork/README.md | 4 +++- .../session-fork/session-fork/src/index.ts | 8 +++++++- .../session-fork/tests/session-fork.spec.ts | 19 +++++++++++++++++++ 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 7b701a47c3..aa6e25441d 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -402,7 +402,7 @@ snapshot(source: SessionForkSource): SessionForkSeed fork(options: ForkSessionOptions): Session ``` -Source: [`packages/session-fork/session-fork/src/index.ts:63`](../../packages/session-fork/session-fork/src/index.ts) +Source: [`packages/session-fork/session-fork/src/index.ts:65`](../../packages/session-fork/session-fork/src/index.ts) ### `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) diff --git a/docs/rfc/implemented/feature/2026-06-30-session-fork-service.md b/docs/rfc/implemented/feature/2026-06-30-session-fork-service.md index 6b59f0a9f6..716edc4e51 100644 --- a/docs/rfc/implemented/feature/2026-06-30-session-fork-service.md +++ b/docs/rfc/implemented/feature/2026-06-30-session-fork-service.md @@ -44,7 +44,7 @@ class SessionForkService extends Service { `fork()` is the convenience half. It calls `snapshot()`, then creates a live child session via `ctx.sessions.create(sessionId, { seed, meta })`. The child inherits the source session's `cwd`, stamps `parentSession` to the source id, and sets `seedLength` to the seeded prefix length. When `sessionId` is omitted, `SessionStore` generates one using its existing id policy. -The boundary rule is structural: an empty source log is forkable, and any source whose last event is `turn/end` is forkable regardless of the turn-end reason (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `interrupted`, or a future merge-extensible reason). Any non-empty source whose last event is not `turn/end` is inside a turn or otherwise not at the boundary and is rejected with a typed `SessionForkError` code. This is intentionally stricter than `dsh-subagent-fork`, whose completed-prefix clipping remains unchanged because it serves tool-time delegation rather than user/session branching. +The boundary rule is structural: an empty source log is forkable, and any source whose last event is `turn/end` is forkable regardless of the turn-end reason (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `interrupted`, or a future merge-extensible reason). Any non-empty source whose last event is not `turn/end` is inside a turn or otherwise not at the boundary and is rejected with a typed `SessionForkError` code. The service also classifies non-live source ids (`SESSION_NOT_FOUND`), stale `Session` object references whose id is live on a different instance (`SESSION_NOT_LIVE`), and duplicate requested child ids (`SESSION_ALREADY_EXISTS`) instead of leaking raw store errors. This is intentionally stricter than `dsh-subagent-fork`, whose completed-prefix clipping remains unchanged because it serves tool-time delegation rather than user/session branching. ## Consequences diff --git a/packages/session-fork/session-fork/README.md b/packages/session-fork/session-fork/README.md index 7f89d299c4..0f8e4f237c 100644 --- a/packages/session-fork/session-fork/README.md +++ b/packages/session-fork/session-fork/README.md @@ -21,7 +21,9 @@ Forking inside a turn is rejected with `SessionForkError` code `OPEN_TURN`. The | Code | Meaning | |---|---| -| `SESSION_NOT_FOUND` | A source id is not live in `ctx.sessions`, or a passed `Session` object is not the live store object for its id. | +| `SESSION_NOT_FOUND` | A source id is not live in `ctx.sessions`, or a passed `Session` object's id is not live in the store. | +| `SESSION_NOT_LIVE` | A passed `Session` object has a live id in the store, but it is not that live store instance. | +| `SESSION_ALREADY_EXISTS` | The requested child `sessionId` is already live in `ctx.sessions`. | | `OPEN_TURN` | The source log is non-empty and does not end at `turn/end`. | ## Persistence diff --git a/packages/session-fork/session-fork/src/index.ts b/packages/session-fork/session-fork/src/index.ts index 0754e86ce1..b7781601ea 100644 --- a/packages/session-fork/session-fork/src/index.ts +++ b/packages/session-fork/session-fork/src/index.ts @@ -46,6 +46,8 @@ export interface ForkSessionOptions { export type SessionForkErrorCode = | 'SESSION_NOT_FOUND' + | 'SESSION_NOT_LIVE' + | 'SESSION_ALREADY_EXISTS' | 'OPEN_TURN' /** Typed error for service-level fork rejections. */ @@ -94,6 +96,9 @@ export class SessionForkService extends Service { */ fork(options: ForkSessionOptions): Session { const snapshot = this.snapshot(options.source) + if (options.sessionId !== undefined && this.ctx.sessions.get(options.sessionId) !== undefined) { + throw new SessionForkError(`session "${options.sessionId}" already exists`, 'SESSION_ALREADY_EXISTS') + } return this.ctx.sessions.create(options.sessionId, { seed: snapshot.seed, meta: snapshot.meta, @@ -108,9 +113,10 @@ export class SessionForkService extends Service { } const live = this.ctx.sessions.get(source.id) - if (live !== source) { + if (live === undefined) { throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND') } + if (live !== source) throw new SessionForkError(`session "${source.id}" is not the live store instance`, 'SESSION_NOT_LIVE') return source } diff --git a/packages/session-fork/session-fork/tests/session-fork.spec.ts b/packages/session-fork/session-fork/tests/session-fork.spec.ts index c5c931c732..44dcd3c962 100644 --- a/packages/session-fork/session-fork/tests/session-fork.spec.ts +++ b/packages/session-fork/session-fork/tests/session-fork.spec.ts @@ -127,6 +127,15 @@ describe('SessionForkService', () => { .toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND')) }) + it('rejects a stale Session object whose id is live on a different instance', async () => { + const { ctx, fork } = await setup() + ctx.sessions.create(SessionId('same-id')) + const stale = new Session(SessionId('same-id')) + + expect(() => fork.snapshot(stale)) + .toThrow(new SessionForkError('session "same-id" is not the live store instance', 'SESSION_NOT_LIVE')) + }) + it('rejects non-empty logs whose last event is not turn/end', async () => { const { ctx, fork } = await setup() const cases: [string, (session: Session) => void][] = [ @@ -184,6 +193,16 @@ describe('SessionForkService', () => { expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }]) }) + it('rejects a child session id that is already live with a typed fork error', async () => { + const { ctx, fork } = await setup() + const source = ctx.sessions.create(SessionId('parent')) + appendClosedTurn(source) + ctx.sessions.create(SessionId('child')) + + expect(() => fork.fork({ source, sessionId: SessionId('child') })) + .toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS')) + }) + it('persists a forked child seed through the existing session write path', async () => { const root = await tempRoot() const ctx = new Context() From 680a41f43c592bd2ff8cad60bc867e5c718c78d6 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 30 Jun 2026 13:14:06 +0800 Subject: [PATCH 03/14] fix: stabilize session fork duplicate-id errors --- packages/session-fork/session-fork/README.md | 2 +- packages/session-fork/session-fork/src/index.ts | 2 +- .../session-fork/tests/session-fork.spec.ts | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/session-fork/session-fork/README.md b/packages/session-fork/session-fork/README.md index 0f8e4f237c..f473f8c272 100644 --- a/packages/session-fork/session-fork/README.md +++ b/packages/session-fork/session-fork/README.md @@ -8,7 +8,7 @@ Session fork service (`ctx.sessionFork`) for creating seeded child sessions from | Method | Purpose | |---|---| -| `snapshot(source)` | Resolve a live `Session | SessionId`, reject non-boundary logs, and return a deep-cloned seed plus `parentSession` / `seedLength` metadata. | +| `snapshot(source)` | Resolve a live `Session \| SessionId`, reject non-boundary logs, and return a deep-cloned seed plus `parentSession` / `seedLength` metadata. | | `fork({ source, sessionId? })` | Create a live child session from `snapshot(source)`, using the caller-supplied child id or the session store's generated id. | ## Boundary Rule diff --git a/packages/session-fork/session-fork/src/index.ts b/packages/session-fork/session-fork/src/index.ts index b7781601ea..2606fb959c 100644 --- a/packages/session-fork/session-fork/src/index.ts +++ b/packages/session-fork/session-fork/src/index.ts @@ -95,10 +95,10 @@ export class SessionForkService extends Service { * `ctx.agents.create` instead. */ fork(options: ForkSessionOptions): Session { - const snapshot = this.snapshot(options.source) if (options.sessionId !== undefined && this.ctx.sessions.get(options.sessionId) !== undefined) { throw new SessionForkError(`session "${options.sessionId}" already exists`, 'SESSION_ALREADY_EXISTS') } + const snapshot = this.snapshot(options.source) return this.ctx.sessions.create(options.sessionId, { seed: snapshot.seed, meta: snapshot.meta, diff --git a/packages/session-fork/session-fork/tests/session-fork.spec.ts b/packages/session-fork/session-fork/tests/session-fork.spec.ts index 44dcd3c962..b39dc0c44b 100644 --- a/packages/session-fork/session-fork/tests/session-fork.spec.ts +++ b/packages/session-fork/session-fork/tests/session-fork.spec.ts @@ -203,6 +203,16 @@ describe('SessionForkService', () => { .toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS')) }) + it('rejects a duplicate child session id before validating the source boundary', async () => { + const { ctx, fork } = await setup() + const source = ctx.sessions.create(SessionId('open-parent')) + source.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + ctx.sessions.create(SessionId('child')) + + expect(() => fork.fork({ source, sessionId: SessionId('child') })) + .toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS')) + }) + it('persists a forked child seed through the existing session write path', async () => { const root = await tempRoot() const ctx = new Context() From a35362bdd6dda66f32382e295d0d4790483dfbb0 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 2 Jul 2026 16:46:37 +0800 Subject: [PATCH 04/14] style: prefix session fork private helpers --- packages/session-fork/session-fork/src/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/session-fork/session-fork/src/index.ts b/packages/session-fork/session-fork/src/index.ts index 2606fb959c..7d27a2d0ee 100644 --- a/packages/session-fork/session-fork/src/index.ts +++ b/packages/session-fork/session-fork/src/index.ts @@ -75,8 +75,8 @@ export class SessionForkService extends Service { * service rejects open turns rather than clipping to an older boundary. */ snapshot(source: SessionForkSource): SessionForkSeed { - const session = this.resolve(source) - this.assertTurnBoundary(session) + const session = this._resolve(source) + this._assertTurnBoundary(session) const seed = session.events.map(event => structuredClone(event)) return { source: session, @@ -105,7 +105,7 @@ export class SessionForkService extends Service { }) } - private resolve(source: SessionForkSource): Session { + private _resolve(source: SessionForkSource): Session { if (typeof source === 'string') { const session = this.ctx.sessions.get(source) if (session === undefined) throw new SessionForkError(`session "${source}" not found`, 'SESSION_NOT_FOUND') @@ -120,7 +120,7 @@ export class SessionForkService extends Service { return source } - private assertTurnBoundary(session: Session): void { + private _assertTurnBoundary(session: Session): void { const last = session.events.at(-1) if (last !== undefined && last.type !== 'turn/end') { throw new SessionForkError( From 0bf874912896d84081915e06ea7d35311e4719bb Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 6 Jul 2026 12:35:34 +0800 Subject: [PATCH 05/14] fix: fold session fork into session store --- docs/architecture.md | 7 +- docs/cordis-catalog/services.md | 15 +- docs/core-data-structures/core.md | 1 - docs/core-data-structures/session-fork.md | 19 --- docs/core-data-structures/session.md | 9 ++ docs/module-graph.md | 5 - docs/rfc/INDEX.md | 2 +- .../2026-06-30-session-fork-service.md | 53 ------- .../2026-06-30-session-store-fork-helpers.md | 59 ++++++++ packages/README.md | 1 - packages/core/session/README.md | 6 +- packages/core/session/src/index.ts | 109 ++++++++++++++ .../session/tests/fork.spec.ts} | 102 ++++--------- packages/session-fork/README.md | 9 -- packages/session-fork/session-fork/README.md | 31 ---- .../session-fork/session-fork/package.json | 34 ----- .../session-fork/session-fork/src/index.ts | 140 ------------------ .../session-fork/session-fork/tsconfig.json | 21 --- .../tests/jsonl.spec.ts | 26 ++++ pnpm-lock.yaml | 30 +--- tsconfig.base.json | 1 - tsconfig.build.json | 1 - tsconfig.json | 1 - 23 files changed, 241 insertions(+), 441 deletions(-) delete mode 100644 docs/core-data-structures/session-fork.md delete mode 100644 docs/rfc/implemented/feature/2026-06-30-session-fork-service.md create mode 100644 docs/rfc/implemented/feature/2026-06-30-session-store-fork-helpers.md rename packages/{session-fork/session-fork/tests/session-fork.spec.ts => core/session/tests/fork.spec.ts} (69%) delete mode 100644 packages/session-fork/README.md delete mode 100644 packages/session-fork/session-fork/README.md delete mode 100644 packages/session-fork/session-fork/package.json delete mode 100644 packages/session-fork/session-fork/src/index.ts delete mode 100644 packages/session-fork/session-fork/tsconfig.json diff --git a/docs/architecture.md b/docs/architecture.md index da6617b777..6ef0073246 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,7 +30,6 @@ The default distribution is a composition, not a hierarchy. `packages/core/` is | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | | `ctx.compact` | [`compact/`](../packages/compact/README.md) | session-surface compaction | | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | -| `ctx.sessionFork` | [`session-fork/`](../packages/session-fork/README.md) | live-session fork boundary validation and seed snapshots | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs | ## Event Surface @@ -109,7 +108,7 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the The session log is the source of truth. `deriveMessages()` projects session events into the `Message[]` sent to the model; raw `assistant/chunk` events stay in the log for replay and UI fidelity. Replay, fork, resume, transcript rendering, telemetry, and persistence all derive from the same event stream. -The low-level fork primitive is `ctx.sessions.create(id, { seed, meta })`. The optional `ctx.sessionFork` service owns live-session fork policy: it validates that a source session is empty or at a turn boundary, snapshots the seed, and creates child metadata without changing the core log. +The low-level fork primitive is `ctx.sessions.create(id, { seed, meta })`. The session store also exposes `ctx.sessions.snapshot(source)` and `ctx.sessions.fork({ source, sessionId? })` for ordinary live-session forks: they validate that a source session is empty or at a turn boundary, snapshot the seed, and create child metadata without changing the core log. Durability is a plugin concern. Persistence backends buffer synchronous `session/event` notifications and the loop awaits a turn-end checkpoint before moving on. The `SessionPersistence` seam stores `SessionEvent` directly, with metadata in `SessionHeader`; JSONL and SQLite share one contract suite. @@ -125,7 +124,7 @@ Streaming is a raw chunk protocol (`block-start` through `finish`) with `BlockAs A swappable capability usually splits into **interface / implementation / consumer**: the interface owns the `ctx` key and vocabulary; an implementation registers a backend; a consumer exposes model-facing behavior through `ctx.tools` or prompt assembly. The bash trio is the reference shape, and the [capability seam graph](capability-seams.md) shows the current package families. -Some seams bend the template deliberately. LLM keeps interface and consumer vocabulary together because adapters are the implementations. Filesystem adds policy as event gates around provider primitives. Web is one service with search and fetch provider registries, so provider swaps do not rename model tools. Session-fork keeps its interface and implementation together because it delegates durable work to the existing session store. Subagents use a named provider registry because multiple delegation backends can coexist; `spawn` starts fresh, `fork` seeds from the parent's completed-turn prefix, and ACP can drive an out-of-process child ([subagent.md](core-data-structures/subagent.md)). +Some seams bend the template deliberately. LLM keeps interface and consumer vocabulary together because adapters are the implementations. Filesystem adds policy as event gates around provider primitives. Web is one service with search and fetch provider registries, so provider swaps do not rename model tools. Subagents use a named provider registry because multiple delegation backends can coexist; `spawn` starts fresh, `fork` seeds from the parent's completed-turn prefix, and ACP can drive an out-of-process child ([subagent.md](core-data-structures/subagent.md)). ### Bundles And Apps @@ -144,6 +143,6 @@ New behavior should attach to a documented seam; changing the shipped loop requi | Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | -| Fork a live session | use `ctx.sessionFork` to validate the boundary and create a seeded child session | +| Fork a live session | use `ctx.sessions.snapshot()` for reusable seed metadata or `ctx.sessions.fork()` to create a seeded child session | The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md). diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ac89ccc970..eaeca95539 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -126,17 +126,6 @@ Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../co Source: [`packages/llm/llm/src/index.ts:78`](../../packages/llm/llm/src/index.ts) -## `ctx.sessionFork` — `SessionForkService` - -`ctx.sessionFork`: validates live session fork boundaries and creates seeded child sessions using the existing `ctx.sessions.create({ seed })` primitive. - -```ts cordis-catalog -snapshot(source: SessionForkSource): SessionForkSeed -fork(options: ForkSessionOptions): Session -``` - -Source: [`packages/session-fork/session-fork/src/index.ts:65`](../../packages/session-fork/session-fork/src/index.ts) - ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) Abstract durable session-persistence service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.sessionPersistence` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). @@ -172,9 +161,11 @@ enter(session: Session): () => void announce(session: Session): void get(id: SessionId): Session | undefined list(): Session[] +snapshot(source: SessionForkSource): SessionForkSeed +fork(options: ForkSessionOptions): Session ``` -Source: [`packages/core/session/src/index.ts:327`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:369`](../../packages/core/session/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 696143d70f..61f980ff73 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -23,7 +23,6 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` | | [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface | | [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split | -| [session-fork.md](session-fork.md) | the session fork seam: live-session boundary validation, seed snapshot metadata, and child-session creation | | [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider/capability status, `WebError` | > Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts. diff --git a/docs/core-data-structures/session-fork.md b/docs/core-data-structures/session-fork.md deleted file mode 100644 index 3bb02a9162..0000000000 --- a/docs/core-data-structures/session-fork.md +++ /dev/null @@ -1,19 +0,0 @@ -# Session Fork - -The session fork service is an optional capability over the core session store. It does not add new log events or persisted record shapes; it packages the existing seed primitive into a safe service with a turn-boundary policy. - -Package: [`@deepseek-ai/dsh-session-fork`](../../packages/session-fork/session-fork) (`ctx.sessionFork`). The decision and rationale are recorded in [the session fork service RFC](../rfc/implemented/feature/2026-06-30-session-fork-service.md). - -## Service Shape - -`SessionForkService.snapshot(source)` accepts a live `Session` object or live `SessionId`, validates the source log is empty or ends at `turn/end`, then returns the resolved source, a deep-cloned `SessionEvent[]` seed, and child metadata: `parentSession`, `seedLength`, and optional inherited `cwd`. - -`SessionForkService.fork({ source, sessionId? })` is a convenience wrapper around `ctx.sessions.create(sessionId, { seed, meta })`. Consumers that create agents can use `snapshot()` directly and pass the returned seed/meta through the agent factory instead of creating a detached session first. - -## Boundary Policy - -The boundary rule is structural: every `turn/end` reason is forkable, and every non-empty log whose last event is not `turn/end` is rejected. This is intentionally stricter than the subagent fork backend, which clips to the parent's last completed-turn prefix because it is usually invoked from inside the parent's active tool turn. - -## Persistence - -No persistence method is added. A forked child is just a normal live session with seed events already present at creation time, so existing persistence backends persist the inherited prefix and header metadata through `session/created` and `session/flush`. diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 92a9d3421f..60c117a6d5 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -159,6 +159,15 @@ export interface SurfaceNode { Everything else (`turn/*`, `step/*`) is structural and does not project into a message. Token usage is observed on `assistant/message.usage` (the step that produced it); an operational error's step number is on `turn/end.reason` for `kind: 'error'`. +## Live-session fork helpers + +`ctx.sessions.create(id, { seed, meta })` is the low-level replay/fork primitive. For ordinary live-session forks, `SessionStore` adds two policy helpers: + +- `snapshot(source)` accepts a live `Session` object or live `SessionId`, validates the source log is empty or ends at `turn/end`, then returns a deep-cloned `SessionEvent[]` seed plus child metadata (`parentSession`, `seedLength`, and inherited `cwd`). +- `fork({ source, sessionId? })` calls `snapshot(source)` and immediately creates the live child via `ctx.sessions.create(sessionId, { seed, meta })`. + +The split is intentional: `snapshot()` is the reusable seed/metadata computation for callers that create an agent or defer session creation; `fork()` is the convenience path when a caller only needs a child `Session`. Both reject open-turn sources instead of clipping to an older prefix. `dsh-subagent-fork` keeps its completed-prefix clipping because tool-time delegation usually starts while the parent turn is open; ordinary session branching should not silently drop the parent turn tail. + ## What started a turn: `TurnTriggerMap` ```ts type-equiv diff --git a/docs/module-graph.md b/docs/module-graph.md index 561d087d1a..ed1cc592d4 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -78,9 +78,6 @@ flowchart TD pkg_app_boot["app-boot"] pkg_stdio_agent["stdio-agent"] end - subgraph group_session_fork["packages/session-fork"] - pkg_session_fork["session-fork"] - end pkg_llm --> pkg_brand pkg_bash --> pkg_brand pkg_llm_deepseek --> pkg_llm @@ -109,7 +106,6 @@ flowchart TD pkg_session_persistence --> pkg_session pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session - pkg_session_fork --> pkg_session pkg_tools --> pkg_agent pkg_tools --> pkg_llm pkg_tools --> pkg_system_prompt @@ -230,7 +226,6 @@ flowchart TD | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`session-fork`](../packages/session-fork/session-fork) | `session-fork` | [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index ea2f3d7f85..1e337bf6c1 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -58,7 +58,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges](implemented/feature/2026-06-30-hook-bridges.md) | 2026-06-30 | | [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 | | [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 | -| [Session fork service](implemented/feature/2026-06-30-session-fork-service.md) | 2026-06-30 | +| [SessionStore fork helpers](implemented/feature/2026-06-30-session-store-fork-helpers.md) | 2026-06-30 | | [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-06-30-session-fork-service.md b/docs/rfc/implemented/feature/2026-06-30-session-fork-service.md deleted file mode 100644 index 716edc4e51..0000000000 --- a/docs/rfc/implemented/feature/2026-06-30-session-fork-service.md +++ /dev/null @@ -1,53 +0,0 @@ -# RFC: Session fork service - -Status: implemented (proposed 2026-06-30, accepted 2026-06-30) - -## Context - -The event-sourced session log already has the primitive a fork needs: create a new session with a seed event prefix, then derive model history from that seeded log exactly as replay does. That primitive is intentionally low-level. It lives on `dsh-session` as `ctx.sessions.create(id, { seed })`, while durable metadata such as `parentSession` and `seedLength` is stored on the out-of-log `SessionHeader` introduced by [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md). The same mechanics already support in-process subagent fork children and replay routing for forked child logs. - -What is missing is a reusable product service for ordinary session forking. Putting that directly on `dsh-session` would make a derived workflow part of the core log API, even though the core session package should stay focused on append-only storage, derived history, and lifecycle events. The harness architecture prefers optional capability plugins over widening the core spine; [event-sourced sessions](../../implemented/architecture/2026-06-11-event-sourced-sessions.md) provide the log semantics, and [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) provide the extension pattern. - -The main semantic hazard is the fork boundary. A session event log is only a valid seed when it is contiguous and balanced. Forking inside an active turn would copy an open `turn/start`, possibly an open `step/start`, and possibly dangling tool calls. That violates the turn-enclosure and provider-transcript invariants, and it creates a misleading child history that appears to have participated in an unfinished parent turn. The existing [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) deliberately solves a different problem: a tool-triggered subagent fork usually happens while the parent turn is open, so `dsh-subagent-fork` clips the seed to the parent's last completed-turn prefix. A general session fork should not silently clip; it should reject attempts made away from a boundary. - -## Decision - -The shipped design adds an optional product package, `@deepseek-ai/dsh-session-fork`, under `packages/session-fork/session-fork`. It registers `ctx.sessionFork` and depends only on `cordis` plus the `dsh-session` vocabulary/service. No new session event types, persistence methods, ACP methods, agent-loop hooks, or subagent behavior are added in the first cut. - -The service exposes two operations: - -```ts ignore-check -type SessionForkSource = Session | SessionId - -interface SessionForkSeed { - source: Session - seed: SessionEvent[] - meta: { - parentSession: SessionId - seedLength: number - cwd?: string - } -} - -interface ForkSessionOptions { - source: SessionForkSource - sessionId?: SessionId -} - -class SessionForkService extends Service { - snapshot(source: SessionForkSource): SessionForkSeed - fork(options: ForkSessionOptions): Session -} -``` - -`snapshot()` is the reusable half. It resolves only live sessions from `ctx.sessions`; v1 does not load unloaded persisted sessions by id. It validates the source is at a turn boundary, deep-clones the source events, and returns the seed plus metadata a caller can pass to a later session or agent creation path. This shape keeps the fork computation reusable for future ACP or agent-facing consumers without coupling this service to `ctx.agents`. - -`fork()` is the convenience half. It calls `snapshot()`, then creates a live child session via `ctx.sessions.create(sessionId, { seed, meta })`. The child inherits the source session's `cwd`, stamps `parentSession` to the source id, and sets `seedLength` to the seeded prefix length. When `sessionId` is omitted, `SessionStore` generates one using its existing id policy. - -The boundary rule is structural: an empty source log is forkable, and any source whose last event is `turn/end` is forkable regardless of the turn-end reason (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `interrupted`, or a future merge-extensible reason). Any non-empty source whose last event is not `turn/end` is inside a turn or otherwise not at the boundary and is rejected with a typed `SessionForkError` code. The service also classifies non-live source ids (`SESSION_NOT_FOUND`), stale `Session` object references whose id is live on a different instance (`SESSION_NOT_LIVE`), and duplicate requested child ids (`SESSION_ALREADY_EXISTS`) instead of leaking raw store errors. This is intentionally stricter than `dsh-subagent-fork`, whose completed-prefix clipping remains unchanged because it serves tool-time delegation rather than user/session branching. - -## Consequences - -The feature is a small capability seam rather than a change to `dsh-session`: the core log keeps its low-level seed primitive, while `dsh-session-fork` owns policy, error taxonomy, and convenience creation. 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 v1 scope deliberately excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. Those can consume `snapshot()` later. If a future ACP method is added, it should advertise the capability only after it has transcript/snapshot coverage; this RFC adds no editor-facing updates, so no ACP snapshot is required now. Fork-child replay remains covered by the existing [seed-boundary testing RFC](../../implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md), while this service gets focused unit tests plus one persistence integration test. diff --git a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-helpers.md b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-helpers.md new file mode 100644 index 0000000000..a22d3c5e77 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-helpers.md @@ -0,0 +1,59 @@ +# RFC: SessionStore fork helpers + +Status: implemented + +## Problem + +The event-sourced session log already has the primitive a fork needs: create a new session with a seed event prefix, then derive model history from that seeded log exactly as replay does. That primitive is intentionally low-level: `ctx.sessions.create(id, { seed, meta })` accepts any valid seed, but ordinary live-session branching needs policy around where the seed may be taken, which metadata is stamped on the child, and how errors are classified. + +The semantic hazard is the fork boundary. A session event log is only a valid user-visible fork seed when it is contiguous and balanced. Forking inside an active turn would copy an open `turn/start`, possibly an open `step/start`, and possibly dangling tool calls. That violates the turn-enclosure and provider-transcript invariants, and it creates a misleading child history that appears to have participated in an unfinished parent turn. The existing [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) deliberately solves a different problem: a tool-triggered subagent fork usually happens while the parent turn is open, so `dsh-subagent-fork` clips the seed to the parent's last completed-turn prefix. A general session fork should not silently clip; it should reject attempts made away from a boundary. + +## Decision + +`dsh-session` owns ordinary live-session fork helpers directly on `ctx.sessions`. There is no separate `dsh-session-fork` package or `ctx.sessionFork` service: the helpers have no independent backend, event vocabulary, lifecycle, or persistence behavior, and all durable work delegates to the existing session store and persistence backends. + +The store exposes two operations: + +```ts ignore-check +type SessionForkSource = Session | SessionId + +interface SessionForkSeed { + source: Session + seed: SessionEvent[] + meta: { + parentSession: SessionId + seedLength: number + cwd?: string + } +} + +interface ForkSessionOptions { + source: SessionForkSource + sessionId?: SessionId +} + +class SessionStore extends Service { + snapshot(source: SessionForkSource): SessionForkSeed + fork(options: ForkSessionOptions): Session +} +``` + +`snapshot()` is the reusable half. It resolves only live sessions from `ctx.sessions`; v1 does not load unloaded persisted sessions by id. It validates the source is at a turn boundary, deep-clones the source events, and returns the seed plus metadata a caller can pass to a later session or agent creation path. This keeps the fork computation reusable for future ACP or agent-facing consumers without coupling `dsh-session` to `ctx.agents`. + +`fork()` is the convenience half. It calls `snapshot()`, then creates a live child session via `ctx.sessions.create(sessionId, { seed, meta })`. The child inherits the source session's `cwd`, stamps `parentSession` to the source id, and sets `seedLength` to the seeded prefix length. When `sessionId` is omitted, `SessionStore` generates one using its existing id policy. + +The boundary rule is structural: an empty source log is forkable, and any source whose last event is `turn/end` is forkable regardless of the turn-end reason (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `interrupted`, or a future merge-extensible reason). Any non-empty source whose last event is not `turn/end` is inside a turn or otherwise not at the boundary and is rejected with a typed `SessionForkError` code. The helpers also classify non-live source ids (`SESSION_NOT_FOUND`), stale `Session` object references whose id is live on a different instance (`SESSION_NOT_LIVE`), and duplicate requested child ids (`SESSION_ALREADY_EXISTS`) instead of leaking lower-level store errors. + +## Alternatives considered + +**Separate `ctx.sessionFork` service.** This was the first implementation, but review showed it overfit the capability-seam pattern. The code had no swappable backend, no extra event surface, no independent ownership lifecycle, and no durable behavior beyond `ctx.sessions.create({ seed, meta })`. Keeping a separate package would make callers discover and install a second service just to perform policy around a session-store primitive. + +**Only expose `fork()`.** A one-function API is simpler for immediate child-session creation, but it forces callers that need a seed for another creation path to create a detached child session just to get the seed. `snapshot()` keeps the seed/metadata computation reusable without importing `ctx.agents` into `dsh-session`; `fork()` remains the simple one-call convenience. + +**Silently clip open turns to the last completed boundary.** That is correct for `dsh-subagent-fork`, where delegation often starts while the parent turn is open and the child should inherit only the completed prefix. It is wrong for ordinary user/session branching because it hides that the requested fork point was not actually a valid boundary and silently drops the parent turn tail. + +## Consequences + +The public surface stays small and discoverable: live session branching is part of `ctx.sessions`, next to `create({ seed })`, rather than a standalone service. 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 v1 scope still excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. Those can consume `snapshot()` later. If a future ACP method is added, it should advertise the capability only after it has transcript/snapshot coverage; this RFC adds no editor-facing updates, so no ACP snapshot is required now. Fork-child replay remains covered by the existing [seed-boundary testing RFC](../../implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md), while these helpers get focused `dsh-session` unit tests plus JSONL persistence coverage. diff --git a/packages/README.md b/packages/README.md index ad27017a0e..f07e0d5a36 100644 --- a/packages/README.md +++ b/packages/README.md @@ -16,7 +16,6 @@ Packages are grouped by modular role at `packages///`. The group dir | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | | [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | | [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface | -| [`session-fork/`](session-fork/README.md) | Session fork capability family: live-session fork snapshots and child session creation | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) + the app packages | Product — stable surface | diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 72ca52bfef..6908c88dbd 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -9,6 +9,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API - `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber. +- `ctx.sessions.snapshot(source: Session | SessionId): SessionForkSeed` — Resolve a live session object or id, reject non-boundary logs, and return a deep-cloned seed plus `parentSession` / `seedLength` metadata. Use this when the caller will pass the seed/meta into another creation path instead of creating a detached session immediately. +- `ctx.sessions.fork({ source, sessionId? }): Session` — Convenience wrapper around `snapshot(source)` + `create(sessionId, { seed, meta })`; creates a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` @@ -67,9 +69,9 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Extension points - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log. -- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME invariants `append` enforces — including that every surface-eligible event (`SurfaceEventType`) carries a `surfaceOp` marker — so a marker-less message event is rejected at construction rather than silently vanishing from `deriveMessages()` (the surface is the sole derivation path) on resume. +- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME invariants `append` enforces — including that every surface-eligible event (`SurfaceEventType`) carries a `surfaceOp` marker — so a marker-less message event is rejected at construction rather than silently vanishing from `deriveMessages()` (the surface is the sole derivation path) on resume. Ordinary live-session forks use `ctx.sessions.snapshot()` to validate an empty or `turn/end` boundary and build reusable seed metadata, or `ctx.sessions.fork()` to create the child session immediately. - Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint. ### What is NOT here (TODO) -- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond seed-based forking. +- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond turn-boundary `snapshot()` / `fork()`. diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index fa9bbb5ba4..987329fb23 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -318,6 +318,48 @@ export class Session { } } +/** A fork source: either the live session object or its live store id. */ +export type SessionForkSource = Session | SessionId + +/** Metadata and seed events that can create a forked child session or agent. */ +export interface SessionForkSeed { + /** The resolved live source session. */ + source: Session + /** Deep-cloned seed events copied from the source session at a turn boundary. */ + seed: SessionEvent[] + /** Session creation metadata for the forked child. */ + meta: { + /** The source session id. */ + parentSession: SessionId + /** How many leading child events were inherited rather than produced. */ + seedLength: number + /** The source session workspace, inherited by the child when present. */ + cwd?: string + } +} + +/** Inputs for the convenience session-creation path. */ +export interface ForkSessionOptions { + /** Live source session object or id. */ + source: SessionForkSource + /** Optional child session id; omitted delegates to SessionStore's id policy. */ + sessionId?: SessionId +} + +export type SessionForkErrorCode = + | 'SESSION_NOT_FOUND' + | 'SESSION_NOT_LIVE' + | 'SESSION_ALREADY_EXISTS' + | 'OPEN_TURN' + +/** Typed error for session fork rejections. */ +export class SessionForkError extends Error { + constructor(message: string, public readonly code: SessionForkErrorCode) { + super(message) + this.name = 'SessionForkError' + } +} + /** * In-memory session store (`ctx.sessions`). * @@ -452,6 +494,73 @@ export class SessionStore extends Service { list(): Session[] { return [...this.store.values()] } + + /** + * Resolve and validate a live source session, then return a reusable deep- + * cloned fork seed. A non-empty source must end exactly at `turn/end`; this + * rejects open turns rather than clipping to an older boundary. + * + * @param source Live session object or live store id to snapshot. + * @returns Deep-cloned seed events plus child session metadata. + */ + snapshot(source: SessionForkSource): SessionForkSeed { + const session = this._resolveForkSource(source) + this._assertForkBoundary(session) + const seed = session.events.map(event => structuredClone(event)) + return { + source: session, + seed, + meta: { + ...session.header.cwd !== undefined ? { cwd: session.header.cwd } : {}, + parentSession: session.id, + seedLength: seed.length, + }, + } + } + + /** + * Convenience path: create a live child session from a fork snapshot. Callers + * that create agents can use {@link snapshot} and pass its seed/meta through + * `ctx.agents.create` instead. + * + * @param options Source and optional child session id for the fork. + * @returns The created live child session. + */ + fork(options: ForkSessionOptions): Session { + if (options.sessionId !== undefined && this.get(options.sessionId) !== undefined) { + throw new SessionForkError(`session "${options.sessionId}" already exists`, 'SESSION_ALREADY_EXISTS') + } + const snapshot = this.snapshot(options.source) + return this.create(options.sessionId, { + seed: snapshot.seed, + meta: snapshot.meta, + }) + } + + private _resolveForkSource(source: SessionForkSource): Session { + if (typeof source === 'string') { + const session = this.get(source) + if (session === undefined) throw new SessionForkError(`session "${source}" not found`, 'SESSION_NOT_FOUND') + return session + } + + const live = this.get(source.id) + if (live === undefined) { + throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND') + } + if (live !== source) throw new SessionForkError(`session "${source.id}" is not the live store instance`, 'SESSION_NOT_LIVE') + return source + } + + private _assertForkBoundary(session: Session): void { + const last = session.events.at(-1) + if (last !== undefined && last.type !== 'turn/end') { + throw new SessionForkError( + `cannot fork session "${session.id}" inside an open turn (last event: ${last.type})`, + 'OPEN_TURN', + ) + } + } } export default SessionStore diff --git a/packages/session-fork/session-fork/tests/session-fork.spec.ts b/packages/core/session/tests/fork.spec.ts similarity index 69% rename from packages/session-fork/session-fork/tests/session-fork.spec.ts rename to packages/core/session/tests/fork.spec.ts index b39dc0c44b..efe10a904b 100644 --- a/packages/session-fork/session-fork/tests/session-fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -1,31 +1,13 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' -import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import SessionForkService, { SessionForkError } from '../src/index.ts' -const tempDirs: string[] = [] - -afterEach(async () => { - for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true }) -}) - -async function tempRoot(): Promise { - const dir = await mkdtemp(join(tmpdir(), 'dsh-session-fork-')) - tempDirs.push(dir) - return dir -} - -async function setup(): Promise<{ ctx: Context; fork: SessionForkService }> { +async function setup(): Promise<{ ctx: Context; sessions: SessionStore }> { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(SessionForkService) - return { ctx, fork: ctx.sessionFork } + return { ctx, sessions: ctx.sessions } } function appendClosedTurn(session: Session, reason: TurnEndReason = { kind: 'completed' }): void { @@ -43,23 +25,12 @@ function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/m return event } -describe('SessionForkService', () => { - it('registers as ctx.sessionFork and unregisters on fiber disposal', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SessionForkService) - expect(ctx.sessionFork).toBeInstanceOf(SessionForkService) - - await fiber.dispose() - - expect(ctx.sessionFork).toBeUndefined() - }) - +describe('SessionStore fork helpers', () => { it('snapshots an empty live session as an empty seed with lineage metadata', async () => { - const { ctx, fork } = await setup() + const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('empty-parent'), { meta: { cwd: '/workspace' } }) - const snapshot = fork.snapshot(source) + const snapshot = sessions.snapshot(source) expect(snapshot.source).toBe(source) expect(snapshot.seed).toEqual([]) @@ -71,11 +42,11 @@ describe('SessionForkService', () => { }) it('snapshots a completed boundary by live session id and deep-clones seed events', async () => { - const { ctx, fork } = await setup() + const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } }) appendClosedTurn(source) - const snapshot = fork.snapshot(SessionId('parent')) + const snapshot = sessions.snapshot(SessionId('parent')) expect(snapshot.source).toBe(source) expect(snapshot.seed).toEqual(source.events) @@ -91,7 +62,7 @@ describe('SessionForkService', () => { }) it('accepts every turn/end reason as a fork boundary', async () => { - const { ctx, fork } = await setup() + const { ctx, sessions } = await setup() const reasons: TurnEndReason[] = [ { kind: 'completed' }, { kind: 'aborted', reason: 'cancelled by user' }, @@ -105,7 +76,7 @@ describe('SessionForkService', () => { const source = ctx.sessions.create(SessionId(`parent-${reason.kind}`)) appendClosedTurn(source, reason) - const snapshot = fork.snapshot(source) + const snapshot = sessions.snapshot(source) expect(snapshot.seed.at(-1)?.type).toBe('turn/end') expect(snapshot.meta.seedLength).toBe(source.events.length) @@ -113,31 +84,31 @@ describe('SessionForkService', () => { }) it('rejects an unknown live session id', async () => { - const { fork } = await setup() + const { sessions } = await setup() - expect(() => fork.snapshot(SessionId('missing'))) + expect(() => sessions.snapshot(SessionId('missing'))) .toThrow(new SessionForkError('session "missing" not found', 'SESSION_NOT_FOUND')) }) it('rejects a detached Session object that is not live in ctx.sessions', async () => { - const { fork } = await setup() + const { sessions } = await setup() const detached = new Session(SessionId('detached')) - expect(() => fork.snapshot(detached)) + expect(() => sessions.snapshot(detached)) .toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND')) }) it('rejects a stale Session object whose id is live on a different instance', async () => { - const { ctx, fork } = await setup() + const { ctx, sessions } = await setup() ctx.sessions.create(SessionId('same-id')) const stale = new Session(SessionId('same-id')) - expect(() => fork.snapshot(stale)) + expect(() => sessions.snapshot(stale)) .toThrow(new SessionForkError('session "same-id" is not the live store instance', 'SESSION_NOT_LIVE')) }) it('rejects non-empty logs whose last event is not turn/end', async () => { - const { ctx, fork } = await setup() + const { ctx, sessions } = await setup() const cases: [string, (session: Session) => void][] = [ ['turn/start', (session) => { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -172,17 +143,17 @@ describe('SessionForkService', () => { const source = ctx.sessions.create(SessionId(`open-${lastType}`)) build(source) - expect(() => fork.snapshot(source)) + expect(() => sessions.snapshot(source)) .toThrow(new SessionForkError(`cannot fork session "open-${lastType}" inside an open turn (last event: ${lastType})`, 'OPEN_TURN')) } }) it('creates a forked child session with the seed and lineage metadata', async () => { - const { ctx, fork } = await setup() + const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } }) appendClosedTurn(source) - const child = fork.fork({ source, sessionId: SessionId('child') }) + const child = sessions.fork({ source, sessionId: SessionId('child') }) expect(child.id).toBe(SessionId('child')) expect(child.events).toEqual(source.events) @@ -194,45 +165,22 @@ describe('SessionForkService', () => { }) it('rejects a child session id that is already live with a typed fork error', async () => { - const { ctx, fork } = await setup() + const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('parent')) appendClosedTurn(source) ctx.sessions.create(SessionId('child')) - expect(() => fork.fork({ source, sessionId: SessionId('child') })) + expect(() => sessions.fork({ source, sessionId: SessionId('child') })) .toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS')) }) it('rejects a duplicate child session id before validating the source boundary', async () => { - const { ctx, fork } = await setup() + const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('open-parent')) source.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) ctx.sessions.create(SessionId('child')) - expect(() => fork.fork({ source, sessionId: SessionId('child') })) + expect(() => sessions.fork({ source, sessionId: SessionId('child') })) .toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS')) }) - - it('persists a forked child seed through the existing session write path', async () => { - const root = await tempRoot() - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionForkService) - await ctx.plugin(SessionPersistenceJsonl, { root }) - const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } }) - appendClosedTurn(source) - - const child = ctx.sessionFork.fork({ source, sessionId: SessionId('persist-child') }) - await ctx.parallel('session/flush', child) - const loaded = await ctx.sessionPersistence.load(child.id) - - expect(loaded.events).toEqual(source.events) - expect(loaded.meta).toMatchObject({ - id: SessionId('persist-child'), - cwd: '/workspace', - parentSession: SessionId('persist-parent'), - seedLength: source.events.length, - }) - await ctx.fiber.dispose() - }) }) diff --git a/packages/session-fork/README.md b/packages/session-fork/README.md deleted file mode 100644 index ea4e97e3bb..0000000000 --- a/packages/session-fork/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# session-fork/ — session fork capability family - -The session fork capability: a small optional service that validates a live session is at a turn boundary, snapshots its event log as a seed, and creates forked child sessions through the existing `dsh-session` seed primitive. All **product** packages. - -| Package | Role | ctx key | -|---|---|---| -| `session-fork/` | Session fork service: reusable seed snapshot + forked live-session creation | `ctx.sessionFork` | - -The interface and implementation live together at `session-fork/session-fork/` because v1 has no swappable backend: all durable behavior is delegated to the existing session store and persistence backends. The decision is recorded in [the session fork service RFC](../../docs/rfc/implemented/feature/2026-06-30-session-fork-service.md). diff --git a/packages/session-fork/session-fork/README.md b/packages/session-fork/session-fork/README.md deleted file mode 100644 index f473f8c272..0000000000 --- a/packages/session-fork/session-fork/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# @deepseek-ai/dsh-session-fork - -Session fork service (`ctx.sessionFork`) for creating seeded child sessions from a live source session at a turn boundary. - -## Service: `SessionForkService` - -`SessionForkService` is an optional plugin over `dsh-session`; it does not add session events or persistence methods. It owns fork policy, while `ctx.sessions.create(id, { seed, meta })` remains the low-level replay/fork primitive. - -| Method | Purpose | -|---|---| -| `snapshot(source)` | Resolve a live `Session \| SessionId`, reject non-boundary logs, and return a deep-cloned seed plus `parentSession` / `seedLength` metadata. | -| `fork({ source, sessionId? })` | Create a live child session from `snapshot(source)`, using the caller-supplied child id or the session store's generated id. | - -## Boundary Rule - -A source is forkable only when its log is empty or its last event is `turn/end`. The service accepts any turn-end reason, including `aborted`, `error`, `disposed`, `max-tokens`, and crash-repaired `interrupted`; the boundary is structural, not a statement that the prior turn was successful. - -Forking inside a turn is rejected with `SessionForkError` code `OPEN_TURN`. The service intentionally does not clip to an older completed prefix; that behavior is specific to `dsh-subagent-fork`, where tool-time delegation normally happens while the parent turn is open. - -## Errors - -| Code | Meaning | -|---|---| -| `SESSION_NOT_FOUND` | A source id is not live in `ctx.sessions`, or a passed `Session` object's id is not live in the store. | -| `SESSION_NOT_LIVE` | A passed `Session` object has a live id in the store, but it is not that live store instance. | -| `SESSION_ALREADY_EXISTS` | The requested child `sessionId` is already live in `ctx.sessions`. | -| `OPEN_TURN` | The source log is non-empty and does not end at `turn/end`. | - -## Persistence - -Forked sessions use existing session metadata: `parentSession` points to the source session id, `seedLength` is the number of inherited events, and `cwd` is inherited when present. Persistence backends observe the forked child through their existing `session/created` and `session/flush` write path, so no backend-specific fork API is needed. diff --git a/packages/session-fork/session-fork/package.json b/packages/session-fork/session-fork/package.json deleted file mode 100644 index 73dd30b349..0000000000 --- a/packages/session-fork/session-fork/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-session-fork", - "description": "Session fork service for creating seeded child sessions at turn boundaries", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.6" - }, - "devDependencies": { - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "cordis": "^4.0.0-rc.6" - } -} diff --git a/packages/session-fork/session-fork/src/index.ts b/packages/session-fork/session-fork/src/index.ts deleted file mode 100644 index e1e0a31545..0000000000 --- a/packages/session-fork/session-fork/src/index.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Session forking as an optional service. The core session store exposes the - * low-level seed primitive; this plugin owns the policy for when a live session - * may be forked and the metadata stamped on the child. - * - * @module @deepseek-ai/dsh-session-fork - */ - -import { Context, Service } from 'cordis' -import { SessionId } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' - -declare module 'cordis' { - interface Context { - sessionFork: SessionForkService - } -} - -/** A fork source: either the live session object or its live store id. */ -export type SessionForkSource = Session | SessionId - -/** Metadata and seed events that can create a forked child session or agent. */ -export interface SessionForkSeed { - /** The resolved live source session. */ - source: Session - /** Deep-cloned seed events copied from the source session at a turn boundary. */ - seed: SessionEvent[] - /** Session creation metadata for the forked child. */ - meta: { - /** The source session id. */ - parentSession: SessionId - /** How many leading child events were inherited rather than produced. */ - seedLength: number - /** The source session workspace, inherited by the child when present. */ - cwd?: string - } -} - -/** Inputs for the convenience session-creation path. */ -export interface ForkSessionOptions { - /** Live source session object or id. */ - source: SessionForkSource - /** Optional child session id; omitted delegates to SessionStore's id policy. */ - sessionId?: SessionId -} - -export type SessionForkErrorCode = - | 'SESSION_NOT_FOUND' - | 'SESSION_NOT_LIVE' - | 'SESSION_ALREADY_EXISTS' - | 'OPEN_TURN' - -/** Typed error for service-level fork rejections. */ -export class SessionForkError extends Error { - constructor(message: string, public readonly code: SessionForkErrorCode) { - super(message) - this.name = 'SessionForkError' - } -} - -/** - * `ctx.sessionFork`: validates live session fork boundaries and creates seeded - * child sessions using the existing `ctx.sessions.create({ seed })` primitive. - */ -export class SessionForkService extends Service { - static inject = ['sessions'] - - constructor(ctx: Context) { - super(ctx, 'sessionFork') - } - - /** - * Resolve and validate a live source session, then return a reusable deep- - * cloned fork seed. A non-empty source must end exactly at `turn/end`; this - * service rejects open turns rather than clipping to an older boundary. - * - * @param source Live session object or live store id to snapshot. - * @returns Deep-cloned seed events plus child session metadata. - */ - snapshot(source: SessionForkSource): SessionForkSeed { - const session = this._resolve(source) - this._assertTurnBoundary(session) - const seed = session.events.map(event => structuredClone(event)) - return { - source: session, - seed, - meta: { - ...session.header.cwd !== undefined ? { cwd: session.header.cwd } : {}, - parentSession: session.id, - seedLength: seed.length, - }, - } - } - - /** - * Convenience path: create a live child session from a fork snapshot. Callers - * that create agents can use {@link snapshot} and pass its seed/meta through - * `ctx.agents.create` instead. - * - * @param options Source and optional child session id for the fork. - * @returns The created live child session. - */ - fork(options: ForkSessionOptions): Session { - if (options.sessionId !== undefined && this.ctx.sessions.get(options.sessionId) !== undefined) { - throw new SessionForkError(`session "${options.sessionId}" already exists`, 'SESSION_ALREADY_EXISTS') - } - const snapshot = this.snapshot(options.source) - return this.ctx.sessions.create(options.sessionId, { - seed: snapshot.seed, - meta: snapshot.meta, - }) - } - - private _resolve(source: SessionForkSource): Session { - if (typeof source === 'string') { - const session = this.ctx.sessions.get(source) - if (session === undefined) throw new SessionForkError(`session "${source}" not found`, 'SESSION_NOT_FOUND') - return session - } - - const live = this.ctx.sessions.get(source.id) - if (live === undefined) { - throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND') - } - if (live !== source) throw new SessionForkError(`session "${source.id}" is not the live store instance`, 'SESSION_NOT_LIVE') - return source - } - - private _assertTurnBoundary(session: Session): void { - const last = session.events.at(-1) - if (last !== undefined && last.type !== 'turn/end') { - throw new SessionForkError( - `cannot fork session "${session.id}" inside an open turn (last event: ${last.type})`, - 'OPEN_TURN', - ) - } - } -} - -export default SessionForkService diff --git a/packages/session-fork/session-fork/tsconfig.json b/packages/session-fork/session-fork/tsconfig.json deleted file mode 100644 index e817086a6a..0000000000 --- a/packages/session-fork/session-fork/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../core/session" - } - ] -} diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index c6b7903357..3d4dc8aa84 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -23,6 +23,15 @@ afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) +function appendClosedTurn(session: Session): void { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: 'hello' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) +} + // Run the shared backend contract against the real JSONL backend. runPersistenceContract('jsonl', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-')) @@ -131,6 +140,23 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect(loaded.events).toEqual(log) // chunks preserved, contiguous seqs }) + it('persists a forked child seed through the existing session write path', async () => { + const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } }) + appendClosedTurn(source) + + const child = ctx.sessions.fork({ source, sessionId: SessionId('persist-child') }) + await ctx.parallel('session/flush', child) + const loaded = await ctx.sessionPersistence.load(child.id) + + expect(loaded.events).toEqual(source.events) + expect(loaded.meta).toMatchObject({ + id: SessionId('persist-child'), + cwd: '/workspace', + parentSession: SessionId('persist-parent'), + seedLength: source.events.length, + }) + }) + it('crash recovery: load preserves the interrupted turn and closes it with a synthetic turn/end {interrupted}', async () => { const m = meta('crash', '/proj') await ctx.sessionPersistence.create(m) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9b853b92a..97bcc8b288 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -511,21 +511,6 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/session-fork/session-fork: - devDependencies: - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../../session-persistence/session-persistence-jsonl - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/session-persistence/session-persistence: devDependencies: '@deepseek-ai/dsh-session': @@ -2375,9 +2360,6 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} - '@types/trusted-types@2.0.7': - resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -5108,9 +5090,6 @@ snapshots: '@types/tough-cookie@4.0.5': {} - '@types/trusted-types@2.0.7': - optional: true - '@types/unist@3.0.3': {} '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': @@ -5204,10 +5183,7 @@ snapshots: '@typescript-eslint/types': 8.61.0 eslint-visitor-keys: 5.0.1 - '@upsetjs/venn.js@2.0.0': - optionalDependencies: - d3-selection: 3.0.0 - d3-transition: 3.0.1(d3-selection@3.0.0) + '@upsetjs/venn.js@2.0.0': {} '@vitest/coverage-v8@4.1.8(vitest@4.1.8)': dependencies: @@ -5597,9 +5573,7 @@ snapshots: diff@9.0.0: {} - dompurify@3.4.11: - optionalDependencies: - '@types/trusted-types': 2.0.7 + dompurify@3.4.11: {} dts-resolver@3.0.0(oxc-resolver@11.20.0): optionalDependencies: diff --git a/tsconfig.base.json b/tsconfig.base.json index efc82efce2..40e4dbe728 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -46,7 +46,6 @@ "./packages/fs/*/src", "./packages/compact/*/src", "./packages/subagent/*/src", - "./packages/session-fork/*/src", "./packages/web/*/src", "./packages/todo/*/src", "./packages/hooks/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index c55d401f13..b6ba7901f2 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -23,7 +23,6 @@ { "path": "./packages/core/agent-core" }, { "path": "./packages/bash/bash" }, { "path": "./packages/compact/compact" }, - { "path": "./packages/session-fork/session-fork" }, { "path": "./packages/compact/compact-basic" }, { "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-pi-ai" }, diff --git a/tsconfig.json b/tsconfig.json index 100994fb9b..9cd7aa8a6d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -42,7 +42,6 @@ { "path": "./packages/fs/fs-policy" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/compact/compact" }, - { "path": "./packages/session-fork/session-fork" }, { "path": "./packages/compact/compact-basic" }, { "path": "./packages/web/web" }, { "path": "./packages/web/web-search-exa" }, From 37f3aedc0b6fc46a2df5fc5f037d3c61edf171fc Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 6 Jul 2026 12:37:54 +0800 Subject: [PATCH 06/14] docs: keep architecture summary within budget --- docs/architecture.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 6ef0073246..4a25c6232d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,7 +108,7 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the The session log is the source of truth. `deriveMessages()` projects session events into the `Message[]` sent to the model; raw `assistant/chunk` events stay in the log for replay and UI fidelity. Replay, fork, resume, transcript rendering, telemetry, and persistence all derive from the same event stream. -The low-level fork primitive is `ctx.sessions.create(id, { seed, meta })`. The session store also exposes `ctx.sessions.snapshot(source)` and `ctx.sessions.fork({ source, sessionId? })` for ordinary live-session forks: they validate that a source session is empty or at a turn boundary, snapshot the seed, and create child metadata without changing the core log. +For live forks, `ctx.sessions.snapshot(source)` validates an empty or turn-ended source and returns seed metadata; `ctx.sessions.fork()` creates the child session from it. Durability is a plugin concern. Persistence backends buffer synchronous `session/event` notifications and the loop awaits a turn-end checkpoint before moving on. The `SessionPersistence` seam stores `SessionEvent` directly, with metadata in `SessionHeader`; JSONL and SQLite share one contract suite. @@ -143,6 +143,6 @@ New behavior should attach to a documented seam; changing the shipped loop requi | Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | -| Fork a live session | use `ctx.sessions.snapshot()` for reusable seed metadata or `ctx.sessions.fork()` to create a seeded child session | +| Fork a live session | use `ctx.sessions.snapshot()` or `ctx.sessions.fork()` | The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md). From 37aac7f31376889900a3d115cd142ebac7fc041c Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 6 Jul 2026 13:57:59 +0800 Subject: [PATCH 07/14] fix: collapse session fork to one api --- docs/architecture.md | 4 +- docs/cordis-catalog/services.md | 3 +- docs/core-data-structures/session.md | 9 +- docs/rfc/INDEX.md | 2 +- .../2026-06-30-session-store-fork-api.md | 47 +++++ .../2026-06-30-session-store-fork-helpers.md | 59 ------ packages/core/session/README.md | 7 +- packages/core/session/src/index.ts | 156 ++++++++++------ packages/core/session/tests/fork.spec.ts | 172 +++++++++++++----- .../tests/jsonl.spec.ts | 2 +- 10 files changed, 284 insertions(+), 177 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md delete mode 100644 docs/rfc/implemented/feature/2026-06-30-session-store-fork-helpers.md diff --git a/docs/architecture.md b/docs/architecture.md index 4a25c6232d..f786423683 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,7 +108,7 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the The session log is the source of truth. `deriveMessages()` projects session events into the `Message[]` sent to the model; raw `assistant/chunk` events stay in the log for replay and UI fidelity. Replay, fork, resume, transcript rendering, telemetry, and persistence all derive from the same event stream. -For live forks, `ctx.sessions.snapshot(source)` validates an empty or turn-ended source and returns seed metadata; `ctx.sessions.fork()` creates the child session from it. +For live forks, `ctx.sessions.fork({ source, boundary?, childSessionId? })` creates a child from a turn-enclosed source prefix. Durability is a plugin concern. Persistence backends buffer synchronous `session/event` notifications and the loop awaits a turn-end checkpoint before moving on. The `SessionPersistence` seam stores `SessionEvent` directly, with metadata in `SessionHeader`; JSONL and SQLite share one contract suite. @@ -143,6 +143,6 @@ New behavior should attach to a documented seam; changing the shipped loop requi | Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | -| Fork a live session | use `ctx.sessions.snapshot()` or `ctx.sessions.fork()` | +| Fork a live session | use `ctx.sessions.fork({ source, boundary?, childSessionId? })` | The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md). diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index eaeca95539..cb65f69da9 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -161,11 +161,10 @@ enter(session: Session): () => void announce(session: Session): void get(id: SessionId): Session | undefined list(): Session[] -snapshot(source: SessionForkSource): SessionForkSeed fork(options: ForkSessionOptions): Session ``` -Source: [`packages/core/session/src/index.ts:369`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:358`](../../packages/core/session/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 60c117a6d5..dc755b9c3f 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -159,14 +159,13 @@ export interface SurfaceNode { Everything else (`turn/*`, `step/*`) is structural and does not project into a message. Token usage is observed on `assistant/message.usage` (the step that produced it); an operational error's step number is on `turn/end.reason` for `kind: 'error'`. -## Live-session fork helpers +## Live-session fork API -`ctx.sessions.create(id, { seed, meta })` is the low-level replay/fork primitive. For ordinary live-session forks, `SessionStore` adds two policy helpers: +`ctx.sessions.create(id, { seed, meta })` is the low-level replay/fork primitive. For ordinary live-session forks, `SessionStore` exposes one policy API: -- `snapshot(source)` accepts a live `Session` object or live `SessionId`, validates the source log is empty or ends at `turn/end`, then returns a deep-cloned `SessionEvent[]` seed plus child metadata (`parentSession`, `seedLength`, and inherited `cwd`). -- `fork({ source, sessionId? })` calls `snapshot(source)` and immediately creates the live child via `ctx.sessions.create(sessionId, { seed, meta })`. +- `fork({ source, boundary?, childSessionId? })` accepts a live `Session` object or live `SessionId`, selects source events through the inclusive `boundary` seq (default: current last event), validates that selected prefix is turn-enclosed and empty or ends at `turn/end`, then creates a live child session with deep-cloned seed events plus child metadata (`parentSession`, `seedLength`, and inherited `cwd`). -The split is intentional: `snapshot()` is the reusable seed/metadata computation for callers that create an agent or defer session creation; `fork()` is the convenience path when a caller only needs a child `Session`. Both reject open-turn sources instead of clipping to an older prefix. `dsh-subagent-fork` keeps its completed-prefix clipping because tool-time delegation usually starts while the parent turn is open; ordinary session branching should not silently drop the parent turn tail. +An explicit `boundary` lets callers fork from a previous completed turn even if the source has newer events or an open current turn. The API rejects open or malformed selected prefixes instead of clipping silently. `dsh-subagent-fork` 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. ## What started a turn: `TurnTriggerMap` diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 1e337bf6c1..6501cbb82b 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -58,7 +58,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges](implemented/feature/2026-06-30-hook-bridges.md) | 2026-06-30 | | [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 | | [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 | -| [SessionStore fork helpers](implemented/feature/2026-06-30-session-store-fork-helpers.md) | 2026-06-30 | +| [SessionStore fork API](implemented/feature/2026-06-30-session-store-fork-api.md) | 2026-06-30 | | [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md new file mode 100644 index 0000000000..cbc445d727 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md @@ -0,0 +1,47 @@ +# RFC: SessionStore fork API + +Status: implemented + +## Problem + +The event-sourced session log already has the primitive a fork needs: create a new session with a seed event prefix, then derive model history from that seeded log exactly as replay does. That primitive is intentionally low-level: `ctx.sessions.create(id, { seed, meta })` accepts any valid seed, but ordinary live-session branching needs policy around which prefix can be copied, which metadata is stamped on the child, and how errors are classified. + +The semantic hazard is the fork boundary. A valid user-visible fork seed must be contiguous and turn-enclosed. Forking inside an active turn would copy an open `turn/start`, possibly an open `step/start`, and possibly dangling tool calls. That violates turn-enclosure and provider-transcript invariants, and it creates a misleading child history that appears to have participated in an unfinished parent turn. The existing [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) deliberately solves a different problem: tool-triggered subagent forks usually happen while the parent turn is open, so `dsh-subagent-fork` clips the seed to the parent's last completed-turn prefix. A general session fork should not silently clip; it should either fork the requested boundary or reject it. + +## Decision + +`dsh-session` owns ordinary live-session forking directly on `ctx.sessions`. There is no separate `dsh-session-fork` package or `ctx.sessionFork` service: the API has no independent backend, event vocabulary, lifecycle, or persistence behavior, and all durable work delegates to the existing session store and persistence backends. + +The store exposes one operation: + +```ts ignore-check +type SessionForkSource = Session | SessionId + +interface ForkSessionOptions { + source: SessionForkSource + boundary?: number + childSessionId?: SessionId +} + +class SessionStore extends Service { + fork(options: ForkSessionOptions): 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. The selected prefix 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. + +The boundary rule is structural: an empty selected prefix is forkable, and any non-empty selected prefix must be turn-enclosed and end at `turn/end`, regardless of the turn-end reason (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `interrupted`, or a future merge-extensible reason). Any selected prefix whose boundary is not an existing event seq, ends inside a turn, contains events outside a turn, contains nested turns, or has an orphan `turn/end` is rejected with a typed `SessionForkError` code. The API also classifies non-live source ids (`SESSION_NOT_FOUND`), stale `Session` object references whose id is live on a different instance (`SESSION_NOT_LIVE`), duplicate requested child ids (`SESSION_ALREADY_EXISTS`), and invalid boundary values (`INVALID_BOUNDARY`). + +## Alternatives considered + +**Separate `ctx.sessionFork` service.** This was the first implementation, but review showed it overfit the capability-seam pattern. The code had no swappable backend, no extra event surface, no independent ownership lifecycle, and no durable behavior beyond `ctx.sessions.create({ seed, meta })`. Keeping a separate package would make callers discover and install a second service just to perform policy around a session-store primitive. + +**Two functions: `snapshot()` plus `fork()`.** This preserved a reusable seed/metadata computation, but the only supported consumer created a session immediately. It also made the surface feel more abstract than the concrete operation users need. A single `fork()` with an explicit `boundary` keeps the API direct while still supporting previous-point forks. + +**Silently clip open turns to the last completed boundary.** That is correct for `dsh-subagent-fork`, where delegation often starts while the parent turn is open and the child should inherit only the completed prefix. It is wrong for ordinary user/session branching because it hides that the requested fork point was not actually a valid boundary and silently drops the parent turn tail. + +## Consequences + +The public surface 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 v1 scope still 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 transcript/snapshot coverage; this RFC adds no editor-facing updates, so no ACP snapshot is required now. Fork-child replay remains covered by the existing [seed-boundary testing RFC](../../implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md), while this API gets focused `dsh-session` unit tests plus JSONL persistence coverage. diff --git a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-helpers.md b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-helpers.md deleted file mode 100644 index a22d3c5e77..0000000000 --- a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-helpers.md +++ /dev/null @@ -1,59 +0,0 @@ -# RFC: SessionStore fork helpers - -Status: implemented - -## Problem - -The event-sourced session log already has the primitive a fork needs: create a new session with a seed event prefix, then derive model history from that seeded log exactly as replay does. That primitive is intentionally low-level: `ctx.sessions.create(id, { seed, meta })` accepts any valid seed, but ordinary live-session branching needs policy around where the seed may be taken, which metadata is stamped on the child, and how errors are classified. - -The semantic hazard is the fork boundary. A session event log is only a valid user-visible fork seed when it is contiguous and balanced. Forking inside an active turn would copy an open `turn/start`, possibly an open `step/start`, and possibly dangling tool calls. That violates the turn-enclosure and provider-transcript invariants, and it creates a misleading child history that appears to have participated in an unfinished parent turn. The existing [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) deliberately solves a different problem: a tool-triggered subagent fork usually happens while the parent turn is open, so `dsh-subagent-fork` clips the seed to the parent's last completed-turn prefix. A general session fork should not silently clip; it should reject attempts made away from a boundary. - -## Decision - -`dsh-session` owns ordinary live-session fork helpers directly on `ctx.sessions`. There is no separate `dsh-session-fork` package or `ctx.sessionFork` service: the helpers have no independent backend, event vocabulary, lifecycle, or persistence behavior, and all durable work delegates to the existing session store and persistence backends. - -The store exposes two operations: - -```ts ignore-check -type SessionForkSource = Session | SessionId - -interface SessionForkSeed { - source: Session - seed: SessionEvent[] - meta: { - parentSession: SessionId - seedLength: number - cwd?: string - } -} - -interface ForkSessionOptions { - source: SessionForkSource - sessionId?: SessionId -} - -class SessionStore extends Service { - snapshot(source: SessionForkSource): SessionForkSeed - fork(options: ForkSessionOptions): Session -} -``` - -`snapshot()` is the reusable half. It resolves only live sessions from `ctx.sessions`; v1 does not load unloaded persisted sessions by id. It validates the source is at a turn boundary, deep-clones the source events, and returns the seed plus metadata a caller can pass to a later session or agent creation path. This keeps the fork computation reusable for future ACP or agent-facing consumers without coupling `dsh-session` to `ctx.agents`. - -`fork()` is the convenience half. It calls `snapshot()`, then creates a live child session via `ctx.sessions.create(sessionId, { seed, meta })`. The child inherits the source session's `cwd`, stamps `parentSession` to the source id, and sets `seedLength` to the seeded prefix length. When `sessionId` is omitted, `SessionStore` generates one using its existing id policy. - -The boundary rule is structural: an empty source log is forkable, and any source whose last event is `turn/end` is forkable regardless of the turn-end reason (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `interrupted`, or a future merge-extensible reason). Any non-empty source whose last event is not `turn/end` is inside a turn or otherwise not at the boundary and is rejected with a typed `SessionForkError` code. The helpers also classify non-live source ids (`SESSION_NOT_FOUND`), stale `Session` object references whose id is live on a different instance (`SESSION_NOT_LIVE`), and duplicate requested child ids (`SESSION_ALREADY_EXISTS`) instead of leaking lower-level store errors. - -## Alternatives considered - -**Separate `ctx.sessionFork` service.** This was the first implementation, but review showed it overfit the capability-seam pattern. The code had no swappable backend, no extra event surface, no independent ownership lifecycle, and no durable behavior beyond `ctx.sessions.create({ seed, meta })`. Keeping a separate package would make callers discover and install a second service just to perform policy around a session-store primitive. - -**Only expose `fork()`.** A one-function API is simpler for immediate child-session creation, but it forces callers that need a seed for another creation path to create a detached child session just to get the seed. `snapshot()` keeps the seed/metadata computation reusable without importing `ctx.agents` into `dsh-session`; `fork()` remains the simple one-call convenience. - -**Silently clip open turns to the last completed boundary.** That is correct for `dsh-subagent-fork`, where delegation often starts while the parent turn is open and the child should inherit only the completed prefix. It is wrong for ordinary user/session branching because it hides that the requested fork point was not actually a valid boundary and silently drops the parent turn tail. - -## Consequences - -The public surface stays small and discoverable: live session branching is part of `ctx.sessions`, next to `create({ seed })`, rather than a standalone service. 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 v1 scope still excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. Those can consume `snapshot()` later. If a future ACP method is added, it should advertise the capability only after it has transcript/snapshot coverage; this RFC adds no editor-facing updates, so no ACP snapshot is required now. Fork-child replay remains covered by the existing [seed-boundary testing RFC](../../implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md), while these helpers get focused `dsh-session` unit tests plus JSONL persistence coverage. diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 6908c88dbd..7115b162cf 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -9,8 +9,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API - `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber. -- `ctx.sessions.snapshot(source: Session | SessionId): SessionForkSeed` — Resolve a live session object or id, reject non-boundary logs, and return a deep-cloned seed plus `parentSession` / `seedLength` metadata. Use this when the caller will pass the seed/meta into another creation path instead of creating a detached session immediately. -- `ctx.sessions.fork({ source, sessionId? }): Session` — Convenience wrapper around `snapshot(source)` + `create(sessionId, { seed, meta })`; creates a live child session with lineage metadata. +- `ctx.sessions.fork({ source, boundary?, childSessionId? }): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that selected prefix to be turn-enclosed, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` @@ -69,9 +68,9 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Extension points - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log. -- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME invariants `append` enforces — including that every surface-eligible event (`SurfaceEventType`) carries a `surfaceOp` marker — so a marker-less message event is rejected at construction rather than silently vanishing from `deriveMessages()` (the surface is the sole derivation path) on resume. Ordinary live-session forks use `ctx.sessions.snapshot()` to validate an empty or `turn/end` boundary and build reusable seed metadata, or `ctx.sessions.fork()` to create the child session immediately. +- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME invariants `append` enforces — including that every surface-eligible event (`SurfaceEventType`) carries a `surfaceOp` marker — so a marker-less message event is rejected at construction rather than silently vanishing from `deriveMessages()` (the surface is the sole derivation path) on resume. Ordinary live-session forks use `ctx.sessions.fork({ source, boundary?, childSessionId? })`, where `boundary` is the inclusive source event seq to fork through. - Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint. ### What is NOT here (TODO) -- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond turn-boundary `snapshot()` / `fork()`. +- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`. diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 987329fb23..ee4c6fa8f1 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -321,35 +321,24 @@ export class Session { /** A fork source: either the live session object or its live store id. */ export type SessionForkSource = Session | SessionId -/** Metadata and seed events that can create a forked child session or agent. */ -export interface SessionForkSeed { - /** The resolved live source session. */ - source: Session - /** Deep-cloned seed events copied from the source session at a turn boundary. */ - seed: SessionEvent[] - /** Session creation metadata for the forked child. */ - meta: { - /** The source session id. */ - parentSession: SessionId - /** How many leading child events were inherited rather than produced. */ - seedLength: number - /** The source session workspace, inherited by the child when present. */ - cwd?: string - } -} - -/** Inputs for the convenience session-creation path. */ +/** Inputs for live session forking. */ export interface ForkSessionOptions { /** Live source session object or id. */ source: SessionForkSource + /** + * Inclusive source event seq to fork through. Omitted means the source's + * current last event; omitted on an empty source forks an empty child. + */ + boundary?: number /** Optional child session id; omitted delegates to SessionStore's id policy. */ - sessionId?: SessionId + childSessionId?: SessionId } export type SessionForkErrorCode = | 'SESSION_NOT_FOUND' | 'SESSION_NOT_LIVE' | 'SESSION_ALREADY_EXISTS' + | 'INVALID_BOUNDARY' | 'OPEN_TURN' /** Typed error for session fork rejections. */ @@ -496,47 +485,67 @@ export class SessionStore extends Service { } /** - * Resolve and validate a live source session, then return a reusable deep- - * cloned fork seed. A non-empty source must end exactly at `turn/end`; this - * rejects open turns rather than clipping to an older boundary. + * Create a live child session from a turn-enclosed prefix of a live source. + * `boundary` is an inclusive source event seq; omitted means the source's + * current last event. A non-empty selected slice must be turn-enclosed and end + * at `turn/end`; this rejects open turns rather than clipping silently. * - * @param source Live session object or live store id to snapshot. - * @returns Deep-cloned seed events plus child session metadata. - */ - snapshot(source: SessionForkSource): SessionForkSeed { - const session = this._resolveForkSource(source) - this._assertForkBoundary(session) - const seed = session.events.map(event => structuredClone(event)) - return { - source: session, - seed, - meta: { - ...session.header.cwd !== undefined ? { cwd: session.header.cwd } : {}, - parentSession: session.id, - seedLength: seed.length, - }, - } - } - - /** - * Convenience path: create a live child session from a fork snapshot. Callers - * that create agents can use {@link snapshot} and pass its seed/meta through - * `ctx.agents.create` instead. - * - * @param options Source and optional child session id for the fork. + * @param options Source, optional boundary, and optional child id for the fork. * @returns The created live child session. */ fork(options: ForkSessionOptions): Session { - if (options.sessionId !== undefined && this.get(options.sessionId) !== undefined) { - throw new SessionForkError(`session "${options.sessionId}" already exists`, 'SESSION_ALREADY_EXISTS') + if (options.childSessionId !== undefined && this.get(options.childSessionId) !== undefined) { + throw new SessionForkError(`session "${options.childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS') } - const snapshot = this.snapshot(options.source) - return this.create(options.sessionId, { - seed: snapshot.seed, - meta: snapshot.meta, + const source = this._resolveForkSource(options.source) + const seed = this._forkSeed(source, options.boundary) + return this.create(options.childSessionId, { + seed, + meta: { + ...source.header.cwd !== undefined ? { cwd: source.header.cwd } : {}, + parentSession: source.id, + seedLength: seed.length, + }, }) } + private _forkSeed(session: Session, requestedBoundary: number | undefined): SessionEvent[] { + const events = session.events + const lastEvent = events.at(-1) + let boundary: number + if (requestedBoundary !== undefined) { + boundary = requestedBoundary + } else { + if (lastEvent === undefined) return [] + boundary = lastEvent.seq + } + if (!Number.isSafeInteger(boundary) || boundary < 0) { + throw new SessionForkError( + `fork boundary for session "${session.id}" must be a non-negative safe integer, got ${String(boundary)}`, + 'INVALID_BOUNDARY', + ) + } + if (boundary >= events.length) { + const lastSeq = events.at(-1)?.seq + throw new SessionForkError( + `fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? 'none'})`, + 'INVALID_BOUNDARY', + ) + } + + const boundaryEvent = events[boundary] + if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) { + throw new SessionForkError( + `fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`, + 'INVALID_BOUNDARY', + ) + } + + const seed = events.slice(0, boundary + 1) + this._assertForkBoundary(session, seed, boundary) + return seed.map(event => structuredClone(event)) + } + private _resolveForkSource(source: SessionForkSource): Session { if (typeof source === 'string') { const session = this.get(source) @@ -552,11 +561,46 @@ export class SessionStore extends Service { return source } - private _assertForkBoundary(session: Session): void { - const last = session.events.at(-1) - if (last !== undefined && last.type !== 'turn/end') { + private _assertForkBoundary(session: Session, seed: readonly SessionEvent[], boundary: number): void { + let openTurn: SessionEvent<'turn/start'> | undefined + for (const event of seed) { + switch (event.type) { + case 'turn/start': { + if (openTurn !== undefined) { + throw new SessionForkError( + `cannot fork session "${session.id}" at boundary ${boundary}: turn ${event.data.turn} starts before turn ${openTurn.data.turn} ended`, + 'OPEN_TURN', + ) + } + openTurn = event + break + } + case 'turn/end': { + if (openTurn === undefined) { + throw new SessionForkError( + `cannot fork session "${session.id}" at boundary ${boundary}: turn/end at seq ${event.seq} has no matching turn/start`, + 'OPEN_TURN', + ) + } + openTurn = undefined + break + } + default: { + if (openTurn === undefined) { + throw new SessionForkError( + `cannot fork session "${session.id}" at boundary ${boundary}: event ${event.seq} (${event.type}) is outside a turn`, + 'OPEN_TURN', + ) + } + break + } + } + } + + const last = seed.at(-1) + if (openTurn !== undefined || last?.type !== 'turn/end') { throw new SessionForkError( - `cannot fork session "${session.id}" inside an open turn (last event: ${last.type})`, + `cannot fork session "${session.id}" at boundary ${boundary}: slice ends inside an open turn (last event: ${last?.type ?? 'none'})`, 'OPEN_TURN', ) } diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index efe10a904b..17cc7a590b 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -10,13 +10,26 @@ async function setup(): Promise<{ ctx: Context; sessions: SessionStore }> { return { ctx, sessions: ctx.sessions } } -function appendClosedTurn(session: Session, reason: TurnEndReason = { kind: 'completed' }): void { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) +function appendClosedTurn( + session: Session, + turn: number, + text = `hello ${turn}`, + reason: TurnEndReason = { kind: 'completed' }, +): void { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { - content: [{ type: 'text', text: 'hello' }], + content: [{ type: 'text', text }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason }) +} + +function appendOpenTurn(session: Session, turn: number): void { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: `open ${turn}` }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason }) } function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/message'> { @@ -25,43 +38,68 @@ function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/m return event } -describe('SessionStore fork helpers', () => { - it('snapshots an empty live session as an empty seed with lineage metadata', async () => { +function lastSeq(session: Session): number { + const event = session.events.at(-1) + if (event === undefined) throw new Error('missing last event') + return event.seq +} + +describe('SessionStore.fork', () => { + it('forks an empty live session as an empty child with lineage metadata', async () => { const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('empty-parent'), { meta: { cwd: '/workspace' } }) - const snapshot = sessions.snapshot(source) + const child = sessions.fork({ source, childSessionId: SessionId('empty-child') }) - expect(snapshot.source).toBe(source) - expect(snapshot.seed).toEqual([]) - expect(snapshot.meta).toEqual({ + expect(child.events).toEqual([]) + expect(child.header).toMatchObject({ + id: SessionId('empty-child'), cwd: '/workspace', parentSession: SessionId('empty-parent'), seedLength: 0, }) }) - it('snapshots a completed boundary by live session id and deep-clones seed events', async () => { + it('forks the latest completed boundary by default and deep-clones seed events', async () => { const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } }) - appendClosedTurn(source) + appendClosedTurn(source, 1, 'hello') - const snapshot = sessions.snapshot(SessionId('parent')) + const child = sessions.fork({ source: SessionId('parent'), childSessionId: SessionId('child') }) - expect(snapshot.source).toBe(source) - expect(snapshot.seed).toEqual(source.events) - expect(snapshot.seed).not.toBe(source.events) - expect(snapshot.seed[1]).not.toBe(source.events[1]) - firstUserMessage(snapshot.seed).data.content[0] = { type: 'text', text: 'mutated' } + expect(child.events).toEqual(source.events) + expect(child.events).not.toBe(source.events) + expect(child.events[1]).not.toBe(source.events[1]) + firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' } expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }]) - expect(snapshot.meta).toEqual({ + expect(child.header).toMatchObject({ + id: SessionId('child'), cwd: '/workspace', parentSession: SessionId('parent'), seedLength: source.events.length, }) }) - it('accepts every turn/end reason as a fork boundary', async () => { + it('forks from an earlier turn boundary even when the source currently has an open tail', async () => { + const { ctx, sessions } = await setup() + const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } }) + appendClosedTurn(source, 1, 'first') + const firstBoundary = lastSeq(source) + appendClosedTurn(source, 2, 'second') + appendOpenTurn(source, 3) + + const child = sessions.fork({ + source, + boundary: firstBoundary, + childSessionId: SessionId('child-from-first'), + }) + + expect(child.events).toEqual(source.events.slice(0, firstBoundary + 1)) + expect(child.header.seedLength).toBe(firstBoundary + 1) + expect(child.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'first' }] }]) + }) + + it('accepts every turn/end reason as an explicit fork boundary', async () => { const { ctx, sessions } = await setup() const reasons: TurnEndReason[] = [ { kind: 'completed' }, @@ -74,19 +112,42 @@ describe('SessionStore fork helpers', () => { for (const reason of reasons) { const source = ctx.sessions.create(SessionId(`parent-${reason.kind}`)) - appendClosedTurn(source, reason) + appendClosedTurn(source, 1, reason.kind, reason) - const snapshot = sessions.snapshot(source) + const child = sessions.fork({ + source, + boundary: lastSeq(source), + childSessionId: SessionId(`child-${reason.kind}`), + }) - expect(snapshot.seed.at(-1)?.type).toBe('turn/end') - expect(snapshot.meta.seedLength).toBe(source.events.length) + expect(child.events.at(-1)?.type).toBe('turn/end') + expect(child.header.seedLength).toBe(source.events.length) } }) + it('rejects invalid boundaries before creating a child', async () => { + const { ctx, sessions } = await setup() + const empty = ctx.sessions.create(SessionId('empty')) + expect(() => sessions.fork({ source: empty, boundary: 0, childSessionId: 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, boundary: -1, childSessionId: SessionId('negative') })) + .toThrow(/non-negative safe integer/) + expect(() => sessions.fork({ source, boundary: 0.5, childSessionId: SessionId('fraction') })) + .toThrow(/non-negative safe integer/) + expect(() => sessions.fork({ source, boundary: Number.MAX_SAFE_INTEGER + 1, childSessionId: SessionId('unsafe') })) + .toThrow(/non-negative safe integer/) + expect(() => sessions.fork({ source, boundary: source.seq, childSessionId: SessionId('past-end') })) + .toThrow(new SessionForkError(`fork boundary ${source.seq} does not exist in session "parent" (last seq: ${source.seq - 1})`, 'INVALID_BOUNDARY')) + }) + it('rejects an unknown live session id', async () => { const { sessions } = await setup() - expect(() => sessions.snapshot(SessionId('missing'))) + expect(() => sessions.fork({ source: SessionId('missing') })) .toThrow(new SessionForkError('session "missing" not found', 'SESSION_NOT_FOUND')) }) @@ -94,7 +155,7 @@ describe('SessionStore fork helpers', () => { const { sessions } = await setup() const detached = new Session(SessionId('detached')) - expect(() => sessions.snapshot(detached)) + expect(() => sessions.fork({ source: detached })) .toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND')) }) @@ -103,28 +164,32 @@ describe('SessionStore fork helpers', () => { ctx.sessions.create(SessionId('same-id')) const stale = new Session(SessionId('same-id')) - expect(() => sessions.snapshot(stale)) + expect(() => sessions.fork({ source: stale })) .toThrow(new SessionForkError('session "same-id" is not the live store instance', 'SESSION_NOT_LIVE')) }) - it('rejects non-empty logs whose last event is not turn/end', async () => { + it('rejects selected slices whose boundary is inside an open turn', async () => { const { ctx, sessions } = await setup() - const cases: [string, (session: Session) => void][] = [ + const cases: [string, (session: Session) => number][] = [ ['turn/start', (session) => { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + return lastSeq(session) }], ['step/start', (session) => { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) + return lastSeq(session) }], ['user/message', (session) => { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'open' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + return lastSeq(session) }], ['assistant/message', (session) => { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'partial' }] }, { surfaceOp: 'append' }) + return lastSeq(session) }], ['tool/call', (session) => { const callId = CallId('call-open') @@ -136,51 +201,64 @@ describe('SessionStore fork helpers', () => { content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }], }, { surfaceOp: 'append' }) session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' }) + return lastSeq(session) }], ] for (const [lastType, build] of cases) { const source = ctx.sessions.create(SessionId(`open-${lastType}`)) - build(source) + const boundary = build(source) - expect(() => sessions.snapshot(source)) - .toThrow(new SessionForkError(`cannot fork session "open-${lastType}" inside an open turn (last event: ${lastType})`, 'OPEN_TURN')) + expect(() => sessions.fork({ source, boundary })) + .toThrow(new SessionForkError(`cannot fork session "open-${lastType}" at boundary ${boundary}: slice ends inside an open turn (last event: ${lastType})`, 'OPEN_TURN')) } }) - it('creates a forked child session with the seed and lineage metadata', async () => { + it('rejects malformed turn enclosure in the selected slice', async () => { const { ctx, sessions } = await setup() - const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } }) - appendClosedTurn(source) + const outside = ctx.sessions.create(SessionId('outside'), { + seed: [ + { type: 'step/start', seq: 0, time: 1, data: { turn: 1, step: 1 } }, + ], + }) + expect(() => sessions.fork({ source: outside, boundary: 0 })) + .toThrow(new SessionForkError('cannot fork session "outside" at boundary 0: event 0 (step/start) is outside a turn', 'OPEN_TURN')) - const child = sessions.fork({ source, sessionId: SessionId('child') }) + const nested = ctx.sessions.create(SessionId('nested'), { + seed: [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/start', seq: 1, time: 2, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + ], + }) + expect(() => sessions.fork({ source: nested, boundary: 1 })) + .toThrow(new SessionForkError('cannot fork session "nested" at boundary 1: turn 2 starts before turn 1 ended', 'OPEN_TURN')) - expect(child.id).toBe(SessionId('child')) - expect(child.events).toEqual(source.events) - expect(child.header.parentSession).toBe(source.id) - expect(child.header.seedLength).toBe(source.events.length) - expect(child.header.cwd).toBe('/workspace') - firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' } - expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }]) + const orphanEnd = ctx.sessions.create(SessionId('orphan-end'), { + seed: [ + { type: 'turn/end', seq: 0, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, + ], + }) + expect(() => sessions.fork({ source: orphanEnd, boundary: 0 })) + .toThrow(new SessionForkError('cannot fork session "orphan-end" at boundary 0: turn/end at seq 0 has no matching turn/start', 'OPEN_TURN')) }) it('rejects a child session id that is already live with a typed fork error', async () => { const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('parent')) - appendClosedTurn(source) + appendClosedTurn(source, 1) ctx.sessions.create(SessionId('child')) - expect(() => sessions.fork({ source, sessionId: SessionId('child') })) + expect(() => sessions.fork({ source, childSessionId: SessionId('child') })) .toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS')) }) - it('rejects a duplicate child session id before validating the source boundary', async () => { + it('rejects a duplicate child session id before validating the boundary', async () => { const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('open-parent')) source.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) ctx.sessions.create(SessionId('child')) - expect(() => sessions.fork({ source, sessionId: SessionId('child') })) + expect(() => sessions.fork({ source, childSessionId: SessionId('child') })) .toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS')) }) }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 3d4dc8aa84..b40ad0d0b6 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -144,7 +144,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } }) appendClosedTurn(source) - const child = ctx.sessions.fork({ source, sessionId: SessionId('persist-child') }) + const child = ctx.sessions.fork({ source, childSessionId: SessionId('persist-child') }) await ctx.parallel('session/flush', child) const loaded = await ctx.sessionPersistence.load(child.id) From 71f71816b9868783e0dc542f7efd8785488cb8c4 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 6 Jul 2026 14:17:31 +0800 Subject: [PATCH 08/14] fix: keep fork boundary check simple --- docs/core-data-structures/session.md | 4 +- .../2026-06-30-session-store-fork-api.md | 4 +- packages/core/session/README.md | 4 +- packages/core/session/src/index.ts | 57 +++---------------- packages/core/session/tests/fork.spec.ts | 30 +--------- packages/core/session/tests/session.spec.ts | 4 ++ 6 files changed, 19 insertions(+), 84 deletions(-) diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index dc755b9c3f..69ac26bad0 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -163,9 +163,9 @@ Everything else (`turn/*`, `step/*`) is structural and does not project into a m `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), validates that selected prefix is turn-enclosed and empty or ends at `turn/end`, 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 `boundary` seq (default: current last event), requires the boundary event to be `turn/end`, then creates a live child session with deep-cloned seed events plus child metadata (`parentSession`, `seedLength`, and inherited `cwd`). -An explicit `boundary` lets callers fork from a previous completed turn even if the source has newer events or an open current turn. The API rejects open or malformed selected prefixes instead of clipping silently. `dsh-subagent-fork` 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. +An explicit `boundary` lets callers fork from a previous completed turn even if the source has newer events or an open current turn. The API rejects non-`turn/end` boundaries instead of clipping silently. Broader turn-enclosure sanity stays in the existing `dsh-invariants` plugin and persistence repair path rather than being duplicated in `fork()`. `dsh-subagent-fork` 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. ## What started a turn: `TurnTriggerMap` diff --git a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md index cbc445d727..a1eac1baec 100644 --- a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md +++ b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md @@ -28,9 +28,9 @@ class SessionStore extends Service { } ``` -`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. The selected prefix 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 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 only checks that the requested boundary exists and is a `turn/end`. The selected prefix is then 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. -The boundary rule is structural: an empty selected prefix is forkable, and any non-empty selected prefix must be turn-enclosed and end at `turn/end`, regardless of the turn-end reason (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `interrupted`, or a future merge-extensible reason). Any selected prefix whose boundary is not an existing event seq, ends inside a turn, contains events outside a turn, contains nested turns, or has an orphan `turn/end` is rejected with a typed `SessionForkError` code. The API also classifies non-live source ids (`SESSION_NOT_FOUND`), stale `Session` object references whose id is live on a different instance (`SESSION_NOT_LIVE`), duplicate requested child ids (`SESSION_ALREADY_EXISTS`), and invalid boundary values (`INVALID_BOUNDARY`). +The boundary rule is structural: an empty selected prefix is forkable, and any non-empty selected prefix must end at `turn/end`, regardless of the turn-end reason (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `interrupted`, or a future merge-extensible reason). A boundary that is not an existing event seq, is not a safe integer, or does not point at `turn/end` is rejected with a typed `SessionForkError` code. Broader session-log sanity remains in the existing invariant/repair layers: `dsh-invariants` checks turn enclosure and richer event ordering in dev, while persistence repair handles the valid crash-tail case of a final interrupted turn. The API also classifies non-live source ids (`SESSION_NOT_FOUND`), stale `Session` object references whose id is live on a different instance (`SESSION_NOT_LIVE`), duplicate requested child ids (`SESSION_ALREADY_EXISTS`), and invalid boundary values (`INVALID_BOUNDARY`). ## Alternatives considered diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 7115b162cf..030af3700c 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -9,7 +9,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API - `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber. -- `ctx.sessions.fork({ source, boundary?, childSessionId? }): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that selected prefix to be turn-enclosed, and create a live child session with lineage metadata. +- `ctx.sessions.fork({ source, boundary?, childSessionId? }): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` @@ -68,7 +68,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Extension points - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log. -- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME invariants `append` enforces — including that every surface-eligible event (`SurfaceEventType`) carries a `surfaceOp` marker — so a marker-less message event is rejected at construction rather than silently vanishing from `deriveMessages()` (the surface is the sole derivation path) on resume. Ordinary live-session forks use `ctx.sessions.fork({ source, boundary?, childSessionId? })`, where `boundary` is the inclusive source event seq to fork through. +- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME always-on invariants `append` enforces — contiguous seqs, JSON-serializable data, and required `surfaceOp` markers on surface-eligible events — so marker-less message events are rejected at construction rather than silently vanishing from `deriveMessages()`. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork({ source, boundary?, childSessionId? })`, where `boundary` is the inclusive source event seq to fork through. - Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint. ### What is NOT here (TODO) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index ee4c6fa8f1..e7445da1c1 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -487,8 +487,7 @@ export class SessionStore extends Service { /** * Create a live child session from a turn-enclosed prefix of a live source. * `boundary` is an inclusive source event seq; omitted means the source's - * current last event. A non-empty selected slice must be turn-enclosed and end - * at `turn/end`; this rejects open turns rather than clipping silently. + * current last event. A non-empty selected slice must end at `turn/end`. * * @param options Source, optional boundary, and optional child id for the fork. * @returns The created live child session. @@ -540,10 +539,14 @@ export class SessionStore extends Service { 'INVALID_BOUNDARY', ) } + if (boundaryEvent.type !== 'turn/end') { + throw new SessionForkError( + `fork boundary ${boundary} in session "${session.id}" must be turn/end, got ${boundaryEvent.type}`, + 'OPEN_TURN', + ) + } - const seed = events.slice(0, boundary + 1) - this._assertForkBoundary(session, seed, boundary) - return seed.map(event => structuredClone(event)) + return events.slice(0, boundary + 1).map(event => structuredClone(event)) } private _resolveForkSource(source: SessionForkSource): Session { @@ -561,50 +564,6 @@ export class SessionStore extends Service { return source } - private _assertForkBoundary(session: Session, seed: readonly SessionEvent[], boundary: number): void { - let openTurn: SessionEvent<'turn/start'> | undefined - for (const event of seed) { - switch (event.type) { - case 'turn/start': { - if (openTurn !== undefined) { - throw new SessionForkError( - `cannot fork session "${session.id}" at boundary ${boundary}: turn ${event.data.turn} starts before turn ${openTurn.data.turn} ended`, - 'OPEN_TURN', - ) - } - openTurn = event - break - } - case 'turn/end': { - if (openTurn === undefined) { - throw new SessionForkError( - `cannot fork session "${session.id}" at boundary ${boundary}: turn/end at seq ${event.seq} has no matching turn/start`, - 'OPEN_TURN', - ) - } - openTurn = undefined - break - } - default: { - if (openTurn === undefined) { - throw new SessionForkError( - `cannot fork session "${session.id}" at boundary ${boundary}: event ${event.seq} (${event.type}) is outside a turn`, - 'OPEN_TURN', - ) - } - break - } - } - } - - const last = seed.at(-1) - if (openTurn !== undefined || last?.type !== 'turn/end') { - throw new SessionForkError( - `cannot fork session "${session.id}" at boundary ${boundary}: slice ends inside an open turn (last event: ${last?.type ?? 'none'})`, - 'OPEN_TURN', - ) - } - } } export default SessionStore diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 17cc7a590b..189d5ae690 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -210,38 +210,10 @@ describe('SessionStore.fork', () => { const boundary = build(source) expect(() => sessions.fork({ source, boundary })) - .toThrow(new SessionForkError(`cannot fork session "open-${lastType}" at boundary ${boundary}: slice ends inside an open turn (last event: ${lastType})`, 'OPEN_TURN')) + .toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" must be turn/end, got ${lastType}`, 'OPEN_TURN')) } }) - it('rejects malformed turn enclosure in the selected slice', async () => { - const { ctx, sessions } = await setup() - const outside = ctx.sessions.create(SessionId('outside'), { - seed: [ - { type: 'step/start', seq: 0, time: 1, data: { turn: 1, step: 1 } }, - ], - }) - expect(() => sessions.fork({ source: outside, boundary: 0 })) - .toThrow(new SessionForkError('cannot fork session "outside" at boundary 0: event 0 (step/start) is outside a turn', 'OPEN_TURN')) - - const nested = ctx.sessions.create(SessionId('nested'), { - seed: [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'turn/start', seq: 1, time: 2, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, - ], - }) - expect(() => sessions.fork({ source: nested, boundary: 1 })) - .toThrow(new SessionForkError('cannot fork session "nested" at boundary 1: turn 2 starts before turn 1 ended', 'OPEN_TURN')) - - const orphanEnd = ctx.sessions.create(SessionId('orphan-end'), { - seed: [ - { type: 'turn/end', seq: 0, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, - ], - }) - expect(() => sessions.fork({ source: orphanEnd, boundary: 0 })) - .toThrow(new SessionForkError('cannot fork session "orphan-end" at boundary 0: turn/end at seq 0 has no matching turn/start', 'OPEN_TURN')) - }) - it('rejects a child session id that is already live with a typed fork error', async () => { const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('parent')) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 27f8b5d420..695ef2441f 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -61,8 +61,10 @@ describe('Session', () => { it('replays identically from a seeded event log', () => { const original = new Session(SessionId('s3')) + original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) original.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' }) + original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const replayed = new Session(SessionId('s3-replay'), [...original.events]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) @@ -417,7 +419,9 @@ describe('todo/write event', () => { it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => { const original = new Session(SessionId('t4')) + original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] }) + original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // Seeding a non-surface event with no surfaceOp must not throw. const replayed = new Session(SessionId('t4-replay'), [...original.events]) expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos) From 6a7bb4045bd0286faf6f91604a5460a72f2ebb11 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 6 Jul 2026 14:35:22 +0800 Subject: [PATCH 09/14] test: normalize platform sed snapshot output --- .../tests/snapshot-normalize.spec.ts | 23 +++++++++++++++++++ .../acp-agent/tests/snapshot-normalize.ts | 5 ++++ 2 files changed, 28 insertions(+) diff --git a/examples/acp-agent/tests/snapshot-normalize.spec.ts b/examples/acp-agent/tests/snapshot-normalize.spec.ts index bfe29af8a5..629df2446d 100644 --- a/examples/acp-agent/tests/snapshot-normalize.spec.ts +++ b/examples/acp-agent/tests/snapshot-normalize.spec.ts @@ -42,6 +42,20 @@ describe('normalizeStdout', () => { expect(normalizeStdout(raw, ctx)).toContain('{{sessionId}}') }) + it('normalizes macOS sed -i stderr for the fs-policy-reject fixture', () => { + const raw = JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { + update: { + sessionUpdate: 'tool_call_update', + content: [{ type: 'content', content: { type: 'text', text: '```console\n[stderr]\nsed: 1: "settings.txt\n": unterminated substitute pattern\n[exit code: 1]\n```' } }], + }, + }, + }) + expect(normalizeStdout(raw, ctx)).toContain('```console\\n(no output)\\n```') + }) + it('leaves notification frames without an id untouched in id-space', () => { const raw = JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: {} }) const out = normalizeStdout(raw, ctx) @@ -91,6 +105,15 @@ describe('normalizeSessionLog', () => { expect(out).toContain('{{sessionId}}') }) + it('normalizes macOS sed -i stderr in persisted tool results', () => { + const ev = JSON.stringify({ + type: 'tool/result', seq: 2, time: 5, + data: { content: [{ type: 'text', text: '[stderr]\nsed: 1: "settings.txt\n": unterminated substitute pattern\n[exit code: 1]' }] }, + }) + const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx) + expect(out).toContain('"text":"(no output)"') + }) + it('zeroes a hook/result durationMs (run-to-run noise) but keeps its decision', () => { const ev = JSON.stringify({ type: 'hook/result', seq: 2, time: 5, diff --git a/examples/acp-agent/tests/snapshot-normalize.ts b/examples/acp-agent/tests/snapshot-normalize.ts index 8150057fa4..f4109342d5 100644 --- a/examples/acp-agent/tests/snapshot-normalize.ts +++ b/examples/acp-agent/tests/snapshot-normalize.ts @@ -37,6 +37,11 @@ function scrubString(value: string, ctx: NormalizeContext): string { out = out.split(ctx.cwd).join(CWD) for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID) out = out.replace(UUID_RE, SESSION_ID) + // The fs-policy-reject fixture replays a recorded GNU-sed `sed -i` command. + // macOS/BSD sed treats the same argv as an error. The snapshot's behavior is + // the policy flow, not platform sed syntax, so normalize this exact stderr to + // the Linux no-output result the fixture records. + out = out.split('[stderr]\nsed: 1: "settings.txt\n": unterminated substitute pattern\n[exit code: 1]').join('(no output)') return out } From 50ffa0c9a90e71504b3b13e1bd1999f5de280fd5 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 6 Jul 2026 16:00:52 +0800 Subject: [PATCH 10/14] test: use portable command in fs policy snapshot --- AGENTS.md | 2 +- .../tests/snapshot-normalize.spec.ts | 23 ------ .../acp-agent/tests/snapshot-normalize.ts | 5 -- .../snapshots/fs-policy-reject/session.jsonl | 80 +++++++++---------- .../fs-policy-reject/stdout.golden.jsonl | 4 +- 5 files changed, 43 insertions(+), 71 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8433ac6e79..e59ff9991c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_UR - **Symmetry is usually more correct**: parallel values get parallel form; asymmetry smells of a missed extraction. - **Tests document behavior, not golden truth**: a green test pins what the code DOES, not what it SHOULD do. Before preserving a behavior solely for its test, ask whether it is load-bearing; an artifact changes together with its test, with the why in the PR ([worked example](docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md)). - **RFCs are proposals, not golden truth**: validate its premise against current code before implementing; friction is evidence of over-reach — amend on the way to `implemented/` ([worked example](docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)). -- **Testing policy** — tiers, with-key generosity, real-over-mock, world-verification, real-load-path and published-bin guards: [docs/testing.md](docs/testing.md). A transcript/UX-affecting change needs a snapshot test, or a PR note why none applies. +- **Testing policy** — [docs/testing.md](docs/testing.md). Transcript/UX changes need snapshots or a PR note. Snapshot fixtures must replay on macOS/Linux; avoid GNU/BSD-only commands (e.g. `sed -i`); fix fixtures, not normalizers. - **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([render-intent RFC](docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md), [cookbook](docs/cookbook/adding-a-tool.md)). - **A new capability seam, lifecycle shape, or transcript surface names its coverage at every tier (unit, e2e, snapshot) at plan time** and verifies the harness can express it — a gap is scheduled work, not a mid-build surprise. - **Merge PRs with merge commits** (`gh pr merge --merge`), never squash/rebase. **Never rewrite a pushed branch**; update a child by merging its parent down. **A review fix lands on the PR that introduced the issue, as a separate commit**, then merges down ([stacked-review guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). diff --git a/examples/acp-agent/tests/snapshot-normalize.spec.ts b/examples/acp-agent/tests/snapshot-normalize.spec.ts index 629df2446d..bfe29af8a5 100644 --- a/examples/acp-agent/tests/snapshot-normalize.spec.ts +++ b/examples/acp-agent/tests/snapshot-normalize.spec.ts @@ -42,20 +42,6 @@ describe('normalizeStdout', () => { expect(normalizeStdout(raw, ctx)).toContain('{{sessionId}}') }) - it('normalizes macOS sed -i stderr for the fs-policy-reject fixture', () => { - const raw = JSON.stringify({ - jsonrpc: '2.0', - method: 'session/update', - params: { - update: { - sessionUpdate: 'tool_call_update', - content: [{ type: 'content', content: { type: 'text', text: '```console\n[stderr]\nsed: 1: "settings.txt\n": unterminated substitute pattern\n[exit code: 1]\n```' } }], - }, - }, - }) - expect(normalizeStdout(raw, ctx)).toContain('```console\\n(no output)\\n```') - }) - it('leaves notification frames without an id untouched in id-space', () => { const raw = JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: {} }) const out = normalizeStdout(raw, ctx) @@ -105,15 +91,6 @@ describe('normalizeSessionLog', () => { expect(out).toContain('{{sessionId}}') }) - it('normalizes macOS sed -i stderr in persisted tool results', () => { - const ev = JSON.stringify({ - type: 'tool/result', seq: 2, time: 5, - data: { content: [{ type: 'text', text: '[stderr]\nsed: 1: "settings.txt\n": unterminated substitute pattern\n[exit code: 1]' }] }, - }) - const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx) - expect(out).toContain('"text":"(no output)"') - }) - it('zeroes a hook/result durationMs (run-to-run noise) but keeps its decision', () => { const ev = JSON.stringify({ type: 'hook/result', seq: 2, time: 5, diff --git a/examples/acp-agent/tests/snapshot-normalize.ts b/examples/acp-agent/tests/snapshot-normalize.ts index f4109342d5..8150057fa4 100644 --- a/examples/acp-agent/tests/snapshot-normalize.ts +++ b/examples/acp-agent/tests/snapshot-normalize.ts @@ -37,11 +37,6 @@ function scrubString(value: string, ctx: NormalizeContext): string { out = out.split(ctx.cwd).join(CWD) for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID) out = out.replace(UUID_RE, SESSION_ID) - // The fs-policy-reject fixture replays a recorded GNU-sed `sed -i` command. - // macOS/BSD sed treats the same argv as an error. The snapshot's behavior is - // the policy flow, not platform sed syntax, so normalize this exact stderr to - // the Linux no-output result the fixture records. - out = out.split('[stderr]\nsed: 1: "settings.txt\n": unterminated substitute pattern\n[exit code: 1]').join('(no output)') return out } diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 3e329ba6c6..edb41dd46d 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -180,7 +180,7 @@ {"type":"assistant/chunk","seq":178,"time":1783279386389,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} {"type":"assistant/chunk","seq":179,"time":1783279386416,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} {"type":"assistant/chunk","seq":180,"time":1783279386416,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":181,"time":1783279386416,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sed"}}} +{"type":"assistant/chunk","seq":181,"time":1783279386416,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" awk"}}} {"type":"assistant/chunk","seq":182,"time":1783279386416,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} {"type":"assistant/chunk","seq":183,"time":1783279386444,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}} {"type":"assistant/chunk","seq":184,"time":1783279386444,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} @@ -197,47 +197,47 @@ {"type":"assistant/chunk","seq":195,"time":1783279386528,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} {"type":"assistant/chunk","seq":196,"time":1783279386614,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":197,"time":1783279386615,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":198,"time":1783279386615,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":199,"time":1783279386615,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":200,"time":1783279386643,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":201,"time":1783279386643,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":202,"time":1783279386643,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":203,"time":1783279386643,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":204,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"sed"}}} -{"type":"assistant/chunk","seq":205,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" -"}}} -{"type":"assistant/chunk","seq":206,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"i"}}} -{"type":"assistant/chunk","seq":207,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" '"}}} -{"type":"assistant/chunk","seq":208,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"s"}}} -{"type":"assistant/chunk","seq":209,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"/"}}} -{"type":"assistant/chunk","seq":210,"time":1783279386699,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"blue"}}} -{"type":"assistant/chunk","seq":211,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"/g"}}} -{"type":"assistant/chunk","seq":212,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"reen"}}} -{"type":"assistant/chunk","seq":213,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"/g"}}} -{"type":"assistant/chunk","seq":214,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"'"}}} -{"type":"assistant/chunk","seq":215,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" settings"}}} -{"type":"assistant/chunk","seq":216,"time":1783279386726,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":217,"time":1783279386727,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":218,"time":1783279386754,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":219,"time":1783279386754,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":220,"time":1783279386754,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":221,"time":1783279386754,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":222,"time":1783279386781,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":223,"time":1783279386781,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":224,"time":1783279386781,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"Replace"}}} -{"type":"assistant/chunk","seq":225,"time":1783279386781,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" blue"}}} -{"type":"assistant/chunk","seq":226,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":227,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" green"}}} -{"type":"assistant/chunk","seq":228,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" in"}}} -{"type":"assistant/chunk","seq":229,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" settings"}}} -{"type":"assistant/chunk","seq":230,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":231,"time":1783279386837,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":232,"time":1783279386838,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":233,"time":1783279386896,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The edit tool requires reading the file first due to the default fs-policy. However, the user explicitly instructed me NOT to use the read tool. I need to follow the user's instructions. The edit tool returned an error saying it requires reading the file first. I cannot comply with the user's request to not read the file because the tool enforces it. Let me try a different approach - maybe I can use subagent to do this, or use bash to do the replacement. Let me use bash with sed to replace \"blue\" with \"green\" without reading the file."}}}} -{"type":"assistant/chunk","seq":234,"time":1783279386896,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","arguments":"{\"command\": \"sed -i 's/blue/green/g' settings.txt\", \"description\": \"Replace blue with green in settings.txt\"}"}}}} +{"type":"assistant/chunk","seq":198,"time":1783279386615,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"{\"com"}}} +{"type":"assistant/chunk","seq":199,"time":1783279386615,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"mand\""}}} +{"type":"assistant/chunk","seq":200,"time":1783279386643,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":":\"awk"}}} +{"type":"assistant/chunk","seq":201,"time":1783279386643,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" '{gs"}}} +{"type":"assistant/chunk","seq":202,"time":1783279386643,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"ub(/b"}}} +{"type":"assistant/chunk","seq":203,"time":1783279386643,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"lue/,"}}} +{"type":"assistant/chunk","seq":204,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\\\"gre"}}} +{"type":"assistant/chunk","seq":205,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"en\\\")"}}} +{"type":"assistant/chunk","seq":206,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"}1' s"}}} +{"type":"assistant/chunk","seq":207,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"ettin"}}} +{"type":"assistant/chunk","seq":208,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"gs.tx"}}} +{"type":"assistant/chunk","seq":209,"time":1783279386671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"t > s"}}} +{"type":"assistant/chunk","seq":210,"time":1783279386699,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"ettin"}}} +{"type":"assistant/chunk","seq":211,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"gs.tm"}}} +{"type":"assistant/chunk","seq":212,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"p && "}}} +{"type":"assistant/chunk","seq":213,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"mv se"}}} +{"type":"assistant/chunk","seq":214,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"tting"}}} +{"type":"assistant/chunk","seq":215,"time":1783279386700,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"s.tmp"}}} +{"type":"assistant/chunk","seq":216,"time":1783279386726,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" sett"}}} +{"type":"assistant/chunk","seq":217,"time":1783279386727,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"ings."}}} +{"type":"assistant/chunk","seq":218,"time":1783279386754,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"txt\","}}} +{"type":"assistant/chunk","seq":219,"time":1783279386754,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\"des"}}} +{"type":"assistant/chunk","seq":220,"time":1783279386754,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"crip"}}} +{"type":"assistant/chunk","seq":221,"time":1783279386754,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"tion"}}} +{"type":"assistant/chunk","seq":222,"time":1783279386781,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"\":\"R"}}} +{"type":"assistant/chunk","seq":223,"time":1783279386781,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"epla"}}} +{"type":"assistant/chunk","seq":224,"time":1783279386781,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"ce b"}}} +{"type":"assistant/chunk","seq":225,"time":1783279386781,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"lue "}}} +{"type":"assistant/chunk","seq":226,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"with"}}} +{"type":"assistant/chunk","seq":227,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":" gre"}}} +{"type":"assistant/chunk","seq":228,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"en i"}}} +{"type":"assistant/chunk","seq":229,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"n se"}}} +{"type":"assistant/chunk","seq":230,"time":1783279386809,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"ttin"}}} +{"type":"assistant/chunk","seq":231,"time":1783279386837,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"gs.t"}}} +{"type":"assistant/chunk","seq":232,"time":1783279386838,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","argumentsDelta":"xt\"}"}}} +{"type":"assistant/chunk","seq":233,"time":1783279386896,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The edit tool requires reading the file first due to the default fs-policy. However, the user explicitly instructed me NOT to use the read tool. I need to follow the user's instructions. The edit tool returned an error saying it requires reading the file first. I cannot comply with the user's request to not read the file because the tool enforces it. Let me try a different approach - maybe I can use subagent to do this, or use bash to do the replacement. Let me use bash with awk to replace \"blue\" with \"green\" without reading the file."}}}} +{"type":"assistant/chunk","seq":234,"time":1783279386896,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","arguments":"{\"command\":\"awk '{gsub(/blue/,\\\"green\\\")}1' settings.txt > settings.tmp && mv settings.tmp settings.txt\",\"description\":\"Replace blue with green in settings.txt\"}"}}}} {"type":"assistant/chunk","seq":235,"time":1783279386896,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":227,"outputTokens":195,"cacheReadTokens":2048,"reasoningTokens":118}}}} {"type":"assistant/chunk","seq":236,"time":1783279386896,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":237,"time":1783279386897,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The edit tool requires reading the file first due to the default fs-policy. However, the user explicitly instructed me NOT to use the read tool. I need to follow the user's instructions. The edit tool returned an error saying it requires reading the file first. I cannot comply with the user's request to not read the file because the tool enforces it. Let me try a different approach - maybe I can use subagent to do this, or use bash to do the replacement. Let me use bash with sed to replace \"blue\" with \"green\" without reading the file."},{"type":"tool-call","id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","arguments":"{\"command\": \"sed -i 's/blue/green/g' settings.txt\", \"description\": \"Replace blue with green in settings.txt\"}"}],"usage":{"inputTokens":227,"outputTokens":195,"cacheReadTokens":2048,"reasoningTokens":118}},"sourceEventSeqs":[77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236],"surfaceOp":"append"} -{"type":"tool/call","seq":238,"time":1783279386897,"data":{"turn":1,"step":2,"callId":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","arguments":"{\"command\": \"sed -i 's/blue/green/g' settings.txt\", \"description\": \"Replace blue with green in settings.txt\"}"}} +{"type":"assistant/message","seq":237,"time":1783279386897,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The edit tool requires reading the file first due to the default fs-policy. However, the user explicitly instructed me NOT to use the read tool. I need to follow the user's instructions. The edit tool returned an error saying it requires reading the file first. I cannot comply with the user's request to not read the file because the tool enforces it. Let me try a different approach - maybe I can use subagent to do this, or use bash to do the replacement. Let me use bash with awk to replace \"blue\" with \"green\" without reading the file."},{"type":"tool-call","id":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","arguments":"{\"command\":\"awk '{gsub(/blue/,\\\"green\\\")}1' settings.txt > settings.tmp && mv settings.tmp settings.txt\",\"description\":\"Replace blue with green in settings.txt\"}"}],"usage":{"inputTokens":227,"outputTokens":195,"cacheReadTokens":2048,"reasoningTokens":118}},"sourceEventSeqs":[77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236],"surfaceOp":"append"} +{"type":"tool/call","seq":238,"time":1783279386897,"data":{"turn":1,"step":2,"callId":"call_00_SvvpTh6bWybYXoO77NHg8535","name":"bash","arguments":"{\"command\":\"awk '{gsub(/blue/,\\\"green\\\")}1' settings.txt > settings.tmp && mv settings.tmp settings.txt\",\"description\":\"Replace blue with green in settings.txt\"}"}} {"type":"tool/result","seq":239,"time":1783279386915,"data":{"turn":1,"step":2,"callId":"call_00_SvvpTh6bWybYXoO77NHg8535","content":[{"type":"text","text":"(no output)"}],"isError":false},"sourceEventSeqs":[238],"surfaceOp":"append"} {"type":"step/end","seq":240,"time":1783279386916,"data":{"turn":1,"step":2}} {"type":"step/start","seq":241,"time":1783279386916,"data":{"turn":1,"step":3}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl index 638dac6373..e21f8cda14 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl @@ -137,7 +137,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" awk"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" replace"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} @@ -152,7 +152,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_SvvpTh6bWybYXoO77NHg8535","title":"sed -i 's/blue/green/g' settings.txt","kind":"execute","status":"in_progress","rawInput":"sed -i 's/blue/green/g' settings.txt","content":[{"type":"content","content":{"type":"text","text":"Replace blue with green in settings.txt"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_SvvpTh6bWybYXoO77NHg8535","title":"awk '{gsub(/blue/,\"green\")}1' settings.txt > settings.tmp && mv settings.tmp settings.txt","kind":"execute","status":"in_progress","rawInput":"awk '{gsub(/blue/,\"green\")}1' settings.txt > settings.tmp && mv settings.tmp settings.txt","content":[{"type":"content","content":{"type":"text","text":"Replace blue with green in settings.txt"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_SvvpTh6bWybYXoO77NHg8535","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\n(no output)\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} From 9c5da8d81ff57a68b4886ca2f6f9db0c5713ac1d Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 6 Jul 2026 16:16:47 +0800 Subject: [PATCH 11/14] test: cover corrupt fork boundary guard --- packages/core/session/tests/fork.spec.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 189d5ae690..46c9ef71ba 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -144,6 +144,18 @@ describe('SessionStore.fork', () => { .toThrow(new SessionForkError(`fork boundary ${source.seq} does not exist in session "parent" (last seq: ${source.seq - 1})`, 'INVALID_BOUNDARY')) }) + it('rejects a corrupted live source whose array index no longer matches event seq', async () => { + const { ctx, sessions } = await setup() + 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 } + + expect(() => sessions.fork({ source, boundary: 2, childSessionId: 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() + }) + it('rejects an unknown live session id', async () => { const { sessions } = await setup() From c7fba41ba969f1f0e905fe2bb28768e8c88595ac Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 7 Jul 2026 09:04:49 +0800 Subject: [PATCH 12/14] Inline session fork parameters --- docs/architecture.md | 2 +- docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/session.md | 2 +- .../2026-06-30-session-store-fork-api.md | 8 +--- packages/core/session/README.md | 4 +- packages/core/session/src/index.ts | 36 +++++++---------- packages/core/session/tests/fork.spec.ts | 40 ++++++++----------- .../tests/jsonl.spec.ts | 2 +- 8 files changed, 38 insertions(+), 60 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index afe262e52c..371b5df579 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -143,6 +143,6 @@ New behavior should attach to a documented seam; changing the shipped loop requi | Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | -| Fork a live session | use `ctx.sessions.fork({ source, boundary?, childSessionId? })` | +| Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` | The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md). diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d4aca35c5f..16434225d6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -161,10 +161,10 @@ enter(session: Session): () => void announce(session: Session): void get(id: SessionId): Session | undefined list(): Session[] -fork(options: ForkSessionOptions): Session +fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:402`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:389`](../../packages/core/session/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index c2ee9f0a12..b7cdbaf2c2 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -204,7 +204,7 @@ Everything else (`turn/*`, `step/*`) is structural and does not project into a m `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 boundary event to be `turn/end`, 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 `boundary` seq (default: current last event), requires the boundary event to be `turn/end`, then creates a live child session with deep-cloned seed events plus child metadata (`parentSession`, `seedLength`, and inherited `cwd`). An explicit `boundary` lets callers fork from a previous completed turn even if the source has newer events or an open current turn. The API rejects non-`turn/end` boundaries instead of clipping silently. Broader turn-enclosure sanity stays in the existing `dsh-invariants` plugin and persistence repair path rather than being duplicated in `fork()`. `dsh-subagent-fork` 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. diff --git a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md index a1eac1baec..bf366afc02 100644 --- a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md +++ b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md @@ -17,14 +17,8 @@ The store exposes one operation: ```ts ignore-check type SessionForkSource = Session | SessionId -interface ForkSessionOptions { - source: SessionForkSource - boundary?: number - childSessionId?: SessionId -} - class SessionStore extends Service { - fork(options: ForkSessionOptions): Session + fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session } ``` diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 49813a7950..0c0d60e26a 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -9,7 +9,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API - `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber. -- `ctx.sessions.fork({ source, boundary?, childSessionId? }): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata. +- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` @@ -73,7 +73,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Extension points - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log. -- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME always-on invariants `append` enforces — contiguous seqs, JSON-serializable data, and required `surfaceOp` markers on surface-eligible events — so marker-less message events are rejected at construction rather than silently vanishing from `deriveMessages()`. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork({ source, boundary?, childSessionId? })`, where `boundary` is the inclusive source event seq to fork through. +- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME always-on invariants `append` enforces — contiguous seqs, JSON-serializable data, and required `surfaceOp` markers on surface-eligible events — so marker-less message events are rejected at construction rather than silently vanishing from `deriveMessages()`. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork(source, boundary?, childSessionId?)`, where `boundary` is the inclusive source event seq to fork through. - Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint. ### What is NOT here (TODO) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index ffc7eaaacf..75708f5017 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -365,19 +365,6 @@ export class Session { /** A fork source: either the live session object or its live store id. */ export type SessionForkSource = Session | SessionId -/** Inputs for live session forking. */ -export interface ForkSessionOptions { - /** Live source session object or id. */ - source: SessionForkSource - /** - * Inclusive source event seq to fork through. Omitted means the source's - * current last event; omitted on an empty source forks an empty child. - */ - boundary?: number - /** Optional child session id; omitted delegates to SessionStore's id policy. */ - childSessionId?: SessionId -} - export type SessionForkErrorCode = | 'SESSION_NOT_FOUND' | 'SESSION_NOT_LIVE' @@ -533,20 +520,25 @@ export class SessionStore extends Service { * `boundary` is an inclusive source event seq; omitted means the source's * current last event. A non-empty selected slice must end at `turn/end`. * - * @param options Source, optional boundary, and optional child id for the fork. + * @param source - Live source session object or id. + * @param boundary - 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. + * @param childSessionId - Optional child session id; omitted delegates to + * `SessionStore`'s id policy. * @returns The created live child session. */ - fork(options: ForkSessionOptions): Session { - if (options.childSessionId !== undefined && this.get(options.childSessionId) !== undefined) { - throw new SessionForkError(`session "${options.childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS') + fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session { + if (childSessionId !== undefined && this.get(childSessionId) !== undefined) { + throw new SessionForkError(`session "${childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS') } - const source = this._resolveForkSource(options.source) - const seed = this._forkSeed(source, options.boundary) - return this.create(options.childSessionId, { + const liveSource = this._resolveForkSource(source) + const seed = this._forkSeed(liveSource, boundary) + return this.create(childSessionId, { seed, meta: { - ...source.header.cwd !== undefined ? { cwd: source.header.cwd } : {}, - parentSession: source.id, + ...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {}, + parentSession: liveSource.id, seedLength: seed.length, }, }) diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 46c9ef71ba..25328294cf 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -49,7 +49,7 @@ describe('SessionStore.fork', () => { const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('empty-parent'), { meta: { cwd: '/workspace' } }) - const child = sessions.fork({ source, childSessionId: SessionId('empty-child') }) + const child = sessions.fork(source, undefined, SessionId('empty-child')) expect(child.events).toEqual([]) expect(child.header).toMatchObject({ @@ -65,7 +65,7 @@ describe('SessionStore.fork', () => { const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } }) appendClosedTurn(source, 1, 'hello') - const child = sessions.fork({ source: SessionId('parent'), childSessionId: SessionId('child') }) + const child = sessions.fork(SessionId('parent'), undefined, SessionId('child')) expect(child.events).toEqual(source.events) expect(child.events).not.toBe(source.events) @@ -88,11 +88,7 @@ describe('SessionStore.fork', () => { appendClosedTurn(source, 2, 'second') appendOpenTurn(source, 3) - const child = sessions.fork({ - source, - boundary: firstBoundary, - childSessionId: SessionId('child-from-first'), - }) + const child = sessions.fork(source, firstBoundary, SessionId('child-from-first')) expect(child.events).toEqual(source.events.slice(0, firstBoundary + 1)) expect(child.header.seedLength).toBe(firstBoundary + 1) @@ -114,11 +110,7 @@ describe('SessionStore.fork', () => { const source = ctx.sessions.create(SessionId(`parent-${reason.kind}`)) appendClosedTurn(source, 1, reason.kind, reason) - const child = sessions.fork({ - source, - boundary: lastSeq(source), - childSessionId: SessionId(`child-${reason.kind}`), - }) + const child = sessions.fork(source, lastSeq(source), SessionId(`child-${reason.kind}`)) expect(child.events.at(-1)?.type).toBe('turn/end') expect(child.header.seedLength).toBe(source.events.length) @@ -128,19 +120,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({ source: empty, boundary: 0, childSessionId: SessionId('empty-child') })) + expect(() => sessions.fork(empty, 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, boundary: -1, childSessionId: SessionId('negative') })) + expect(() => sessions.fork(source, -1, SessionId('negative'))) .toThrow(/non-negative safe integer/) - expect(() => sessions.fork({ source, boundary: 0.5, childSessionId: SessionId('fraction') })) + expect(() => sessions.fork(source, 0.5, SessionId('fraction'))) .toThrow(/non-negative safe integer/) - expect(() => sessions.fork({ source, boundary: Number.MAX_SAFE_INTEGER + 1, childSessionId: SessionId('unsafe') })) + expect(() => sessions.fork(source, Number.MAX_SAFE_INTEGER + 1, SessionId('unsafe'))) .toThrow(/non-negative safe integer/) - expect(() => sessions.fork({ source, boundary: source.seq, childSessionId: SessionId('past-end') })) + expect(() => sessions.fork(source, 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')) }) @@ -151,7 +143,7 @@ describe('SessionStore.fork', () => { const mutableLog = (source as unknown as { log: SessionEvent[] }).log mutableLog[2] = { ...mutableLog[2]!, seq: 99 } - expect(() => sessions.fork({ source, boundary: 2, childSessionId: SessionId('corrupt-child') })) + expect(() => sessions.fork(source, 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() }) @@ -159,7 +151,7 @@ describe('SessionStore.fork', () => { it('rejects an unknown live session id', async () => { const { sessions } = await setup() - expect(() => sessions.fork({ source: SessionId('missing') })) + expect(() => sessions.fork(SessionId('missing'))) .toThrow(new SessionForkError('session "missing" not found', 'SESSION_NOT_FOUND')) }) @@ -167,7 +159,7 @@ describe('SessionStore.fork', () => { const { sessions } = await setup() const detached = new Session(SessionId('detached')) - expect(() => sessions.fork({ source: detached })) + expect(() => sessions.fork(detached)) .toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND')) }) @@ -176,7 +168,7 @@ describe('SessionStore.fork', () => { ctx.sessions.create(SessionId('same-id')) const stale = new Session(SessionId('same-id')) - expect(() => sessions.fork({ source: stale })) + expect(() => sessions.fork(stale)) .toThrow(new SessionForkError('session "same-id" is not the live store instance', 'SESSION_NOT_LIVE')) }) @@ -221,7 +213,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, boundary)) .toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" must be turn/end, got ${lastType}`, 'OPEN_TURN')) } }) @@ -232,7 +224,7 @@ describe('SessionStore.fork', () => { appendClosedTurn(source, 1) ctx.sessions.create(SessionId('child')) - expect(() => sessions.fork({ source, childSessionId: SessionId('child') })) + expect(() => sessions.fork(source, undefined, SessionId('child'))) .toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS')) }) @@ -242,7 +234,7 @@ describe('SessionStore.fork', () => { source.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) ctx.sessions.create(SessionId('child')) - expect(() => sessions.fork({ source, childSessionId: SessionId('child') })) + expect(() => sessions.fork(source, undefined, SessionId('child'))) .toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS')) }) }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index b40ad0d0b6..6eed63a2f4 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -144,7 +144,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } }) appendClosedTurn(source) - const child = ctx.sessions.fork({ source, childSessionId: SessionId('persist-child') }) + const child = ctx.sessions.fork(source, undefined, SessionId('persist-child')) await ctx.parallel('session/flush', child) const loaded = await ctx.sessionPersistence.load(child.id) From 3e448605448c73b5f3ea08d375b6f162a4c3192e Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 7 Jul 2026 09:19:59 +0800 Subject: [PATCH 13/14] docs: refresh config catalog source links --- docs/config-catalog.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 66766144a8..8125779d79 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -218,7 +218,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude/src/index.ts:55`](../packages/hooks/hooks-claude/src/index.ts) +Source: [`packages/hooks/hooks-claude/src/index.ts:56`](../packages/hooks/hooks-claude/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` @@ -243,7 +243,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:43`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-invariants` From dad928e83b836b6f387b23f08b53d5dd95280769 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 7 Jul 2026 09:22:56 +0800 Subject: [PATCH 14/14] docs: refresh config catalog source link --- docs/config-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8125779d79..c24edf007a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -329,7 +329,7 @@ export interface Config { } ``` -Source: [`packages/support/llm-replay/src/index.ts:411`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:415`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-jsonl`