From b0422f2a50b68fcd9ac3b7fdc8b8fea852201d79 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:08:10 +0800 Subject: [PATCH] fix review findings: bump session format version + restore late turn-end warn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of the trace-event fold found two merge-blockers. Blocker #1 — format version. Folding usage onto assistant/message and removing the standalone usage/error events changed the persisted SessionEventMap shape, which per the AGENTS.md "bump the version and reject — don't migrate" policy requires a backend to reject any non-current log. Centralize the version in an exported SESSION_FORMAT_VERSION constant (dsh-session), read by both write sites (Session constructor default, SessionStore.prepare header) and the coordinator's load-time assertVersion check. The constant is pinned at 0: while unreleased the on-disk format is unstable/pre-release, so breaking shape churn is absorbed at v0 (no monotonic bump until the first tagged release) and any non-0 log is rejected on load — no migration. Update every test/fixture/doc that stamps a currently-written header to the constant, bump the ACP snapshot fixture + golden headers to v0, and keep the version-rejection test meaningful by switching its bad value to a clearly non-current 99. AGENTS.md documents both the monotonic (SQLite SCHEMA_VERSION) and pinned-0 (session log) pre-release stances. Blocker #2 — restore the late turn-end warn. failTurn now sets the error reason only while the turn is still open; once turn/end is appended (a throwing agent/turn-end listener after closeTurn) the reason can no longer reach the durable log, so the late throw is logged via ctx.logger.warn instead of vanishing into a futile post-close assignment. A regression test asserts the warn fires. Also guard the normal-step assistant/message append with the same content-or-usage condition as the max-tokens branch (a content-less, usage-less step records no trace-only row), with a covering test. --- AGENTS.md | 2 +- docs/core-data-structures/persistence.md | 6 +++- ...6-20-collapse-trace-only-session-events.md | 7 +++-- .../tests/snapshot-normalize.spec.ts | 2 +- .../snapshots/cancel/session.golden.jsonl | 2 +- .../tests/snapshots/cancel/session.jsonl | 2 +- .../error-finish/session.golden.jsonl | 2 +- .../snapshots/error-finish/session.jsonl | 2 +- .../tests/snapshots/handshake/session.jsonl | 2 +- .../snapshots/multi-turn/session.golden.jsonl | 2 +- .../tests/snapshots/multi-turn/session.jsonl | 2 +- .../snapshots/reject-extra-dirs/session.jsonl | 2 +- .../snapshots/text-turn/session.golden.jsonl | 2 +- .../tests/snapshots/text-turn/session.jsonl | 2 +- .../tool-call-turn/session.golden.jsonl | 2 +- .../snapshots/tool-call-turn/session.jsonl | 2 +- .../workspace-edit/session.golden.jsonl | 2 +- .../snapshots/workspace-edit/session.jsonl | 2 +- packages/bash/tool-bash/tests/tools.spec.ts | 6 ++-- packages/core/agent-loop/src/loop.ts | 28 ++++++++++++++----- packages/core/agent-loop/tests/loop.spec.ts | 19 +++++++++++++ .../agent-loop/tests/review-fixes.spec.ts | 6 +++- packages/core/session/src/index.ts | 6 ++-- packages/core/session/src/types.ts | 23 ++++++++++++++- packages/core/session/tests/session.spec.ts | 12 ++++---- .../session-persistence-jsonl/README.md | 2 +- .../tests/jsonl.spec.ts | 16 +++++------ .../session-persistence/src/coordinator.ts | 6 ++-- .../session-persistence/tests/contract.ts | 6 ++-- .../tests/coordinator-contract.ts | 6 ++-- .../llm-replay/tests/llm-replay.spec.ts | 4 +-- packages/ui/acp/tests/load.spec.ts | 6 ++-- 32 files changed, 127 insertions(+), 64 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2fdd25b552..419b7e41a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ This is the monorepo for the DeepSeek Harness group. It currently hosts the code **This applies only while the harness is unreleased — remove this section at the first tagged/published release.** There are no external consumers yet, so optimize for the *correct foundation*, not for a small diff. When the right structure means moving a file across package boundaries, renaming a public symbol, or repackaging a plugin, do it — and update every reference in the same change. Do **not** add backward-compat shims, deprecation aliases, re-export stubs, or "keep it where it is to avoid churn" hedges; those are debts you take on to protect callers you do not have. Churn now is cheap; a wrong foundation set in stone is not. (Once released, this inverts — backward compatibility becomes a real constraint and this section comes out.) -This extends to **on-disk formats, schemas, and stored data**: while unreleased there is no persisted user data to preserve, so a format/schema/contract change needs **no migration path**. Bump the version and reject (don't migrate) anything not at the current version — e.g. the SQLite backend's `SCHEMA_VERSION` bump that drops columns simply rejects any non-current `user_version` on open, with no v1→v2 migration. A migration written now is a shim for data that does not exist. +This extends to **on-disk formats, schemas, and stored data**: while unreleased there is no persisted user data to preserve, so a format/schema/contract change needs **no migration path** — a backend REJECTS anything not at the current version rather than upgrading it. How the *version number itself* behaves pre-release is a per-format choice between two equally-valid stances, and the repo uses both deliberately. **Monotonic bump-and-reject**: each breaking change increments the version — e.g. the SQLite backend's `SCHEMA_VERSION` bump that drops columns rejects any non-current `user_version` on open, with no migration; use it when a stored artifact has a small enumerable set of revisions worth telling apart. **A pinned `0` "unstable / pre-release" version**: the format stays at `0` and absorbs ALL pre-release shape churn without bumping, while a backend still rejects any non-`0` log — the session event log uses this (`SESSION_FORMAT_VERSION = 0` in `dsh-session`), because its shape changes often while unreleased and bumping on every tweak would dress up an unstable format as a sequence of stable boundaries that mean nothing yet; pinning `0` and documenting it "no compatibility implied" makes the instability *explicit* instead of pretending each revision is a real version. Either way there is no migration code, and either way a real monotonic policy begins at the first tagged release. A migration written now is a shim for data that does not exist. ## Tests document behavior, not golden truth diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index f1ee857998..45c1d8dd6b 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -20,7 +20,11 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t ```ts type-equiv interface SessionHeader { - /** On-disk format version; a persistence backend rejects unknown versions. */ + /** + * On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the + * session is created. A persistence backend rejects any other version on load + * (no migration — see the constant). + */ version: number /** The session's id (mirrors the {@link Session}'s id). */ id: SessionId diff --git a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md index 3a2c11bdc6..f0bc1f107f 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md @@ -26,7 +26,7 @@ If analytics become real, add a projection helper or a dedicated telemetry store - The loop records durable failures through `turn/end { kind: 'error', step, message, code? }` or an equivalent no-information-loss shape and reports live diagnostics through `agent/error`. - ACP snapshots and persistence tests stop asserting trace-only lines. - Documentation explains exactly where token usage and operational errors are observed. -- The session format version and recorded fixtures are refreshed; non-current stored logs are rejected per the pre-release format policy. +- Recorded fixtures are refreshed for the new event shape; the session format version stays pinned at `0` (unstable/pre-release) and backends reject any non-`0` stored log per the pre-release format policy. ## What we give up @@ -34,9 +34,10 @@ A consumer can no longer filter the canonical log for standalone `usage` or step ## Implementation note -Shipped as proposed, with two scope refinements (per AGENTS.md "RFCs are proposals, not golden truth"): +Shipped as proposed, with one scope refinement (per AGENTS.md "RFCs are proposals, not golden truth"): -- **No format-version bump.** The acceptance criterion "the session format version and recorded fixtures are refreshed" over-reached: the harness is pre-release with no persisted user data, so per the pre-release format policy there is nothing to migrate or reject. The session `version` stays `1`; only event shapes and recorded fixtures change. `turn/end.reason.error.step` is therefore optional-on-read for any hypothetical pre-existing log but guaranteed for newly-written ones — no migration shim. - **Empty-content `assistant/message` hosts usage with no data loss.** The proof the proposal demanded (no persisted usage chunk becomes unrepresented) lands on the max-tokens path: a step cut off with usage but empty content (e.g. only a dropped tool call) previously emitted a standalone `usage`. It now records an empty-content `assistant/message { content: [], usage }`. To keep that from injecting a spurious content-less assistant turn into the provider transcript, `deriveMessages()` skips empty-content `assistant/message` events. A regression test asserts usage stays represented AND derived history is uncorrupted. +**Format version.** The persisted `SessionEventMap` shape changed (usage folded onto `assistant/message`, standalone `usage`/`error` removed, `step` on `turn/end.reason.error`), so per the AGENTS.md "bump the version and reject — don't migrate" policy a backend must reject any non-current log. The version literal is centralized in an exported `SESSION_FORMAT_VERSION` constant (read by both write sites and the coordinator's load-time check). While the harness is unreleased the on-disk format is pre-release/unstable, so the constant stays **`0`**: a breaking format change is absorbed at v0 (no monotonic bump until the first tagged release, when a specific format boundary becomes worth distinguishing) and old logs at any other version are rejected on load — there is no v0→vN migration (no persisted user data exists). `turn/end.reason.error.step` is required for newly-written logs. + Usage is now observed on `assistant/message.usage`; an operational error's step on `turn/end.reason` for `kind: 'error'`. `agent/error` + logging are unchanged for live diagnostics. diff --git a/examples/acp-agent/tests/snapshot-normalize.spec.ts b/examples/acp-agent/tests/snapshot-normalize.spec.ts index bfdeab5dc8..b220344bb9 100644 --- a/examples/acp-agent/tests/snapshot-normalize.spec.ts +++ b/examples/acp-agent/tests/snapshot-normalize.spec.ts @@ -60,7 +60,7 @@ describe('normalizeStdout', () => { }) describe('normalizeSessionLog', () => { - const header = (over: object) => JSON.stringify({ type: 'session', version: 1, id: 's', createdAt: 123, ...over }) + const header = (over: object) => JSON.stringify({ type: 'session', version: 0, id: 's', createdAt: 123, ...over }) const event = (over: object) => JSON.stringify({ type: 'turn/start', seq: 1, time: 999, data: { turn: 1 }, ...over }) it('zeroes the header createdAt', () => { diff --git a/examples/acp-agent/tests/snapshots/cancel/session.golden.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.golden.jsonl index ecb5155beb..fd2d0c3f23 100644 --- a/examples/acp-agent/tests/snapshots/cancel/session.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/session.golden.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Start a long task; this turn will be cancelled mid-stream."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/cancel/session.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.jsonl index ab44090be6..a6f73319bc 100644 --- a/examples/acp-agent/tests/snapshots/cancel/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/session.jsonl @@ -1 +1 @@ -{"type":"session","version":1,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} +{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} diff --git a/examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl b/examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl index 9f6ee27674..42dd973de2 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"This prompt triggers a recorded provider error."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl index ab44090be6..a6f73319bc 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl @@ -1 +1 @@ -{"type":"session","version":1,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} +{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} diff --git a/examples/acp-agent/tests/snapshots/handshake/session.jsonl b/examples/acp-agent/tests/snapshots/handshake/session.jsonl index ab44090be6..a6f73319bc 100644 --- a/examples/acp-agent/tests/snapshots/handshake/session.jsonl +++ b/examples/acp-agent/tests/snapshots/handshake/session.jsonl @@ -1 +1 @@ -{"type":"session","version":1,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} +{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl index 92f0465e66..64102deef7 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl index 5d56942463..914eecdcb6 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"803f0752-a3db-4394-9c93-3b6fcd410664","createdAt":1781834688308,"cwd":"/tmp/acp-snap-cwd-QVaaKH"} +{"type":"session","version":0,"id":"803f0752-a3db-4394-9c93-3b6fcd410664","createdAt":1781834688308,"cwd":"/tmp/acp-snap-cwd-QVaaKH"} {"type":"turn/start","seq":0,"time":1781834688311,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1781834688312,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":1781834688312,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/reject-extra-dirs/session.jsonl b/examples/acp-agent/tests/snapshots/reject-extra-dirs/session.jsonl index ab44090be6..a6f73319bc 100644 --- a/examples/acp-agent/tests/snapshots/reject-extra-dirs/session.jsonl +++ b/examples/acp-agent/tests/snapshots/reject-extra-dirs/session.jsonl @@ -1 +1 @@ -{"type":"session","version":1,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} +{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl index 9b8447bf17..8aafeb2b82 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index ed34f9a727..7509bf8ef9 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"b8c052fd-b33f-475a-8a0c-bc3b75527602","createdAt":1781834679270,"cwd":"/tmp/acp-snap-cwd-TJst85"} +{"type":"session","version":0,"id":"b8c052fd-b33f-475a-8a0c-bc3b75527602","createdAt":1781834679270,"cwd":"/tmp/acp-snap-cwd-TJst85"} {"type":"turn/start","seq":0,"time":1781834679273,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1781834679273,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":1781834679273,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl index e9e72c2494..34ccf11d37 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo SNAPSHOT_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl index 3566adab87..3aae0d7ccb 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"0e5b2fc1-d220-4a81-b48b-939c14dea057","createdAt":1781834681068,"cwd":"/tmp/acp-snap-cwd-5F2H38"} +{"type":"session","version":0,"id":"0e5b2fc1-d220-4a81-b48b-939c14dea057","createdAt":1781834681068,"cwd":"/tmp/acp-snap-cwd-5F2H38"} {"type":"turn/start","seq":0,"time":1781834681072,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1781834681073,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo SNAPSHOT_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":1781834681073,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl index 4415e42f8f..a4d92d9319 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"A file named greeting.txt in the current directory contains one word. Use the bash tool to append a second line containing the word WORLD to it (so it has two lines), then read the file back with `cat greeting.txt` to confirm, and reply with the single word DONE. Use a single bash call per action."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl index b7a54cfaeb..235267ad36 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"06bd4899-ec95-43e3-82ca-42c719d8b19b","createdAt":1781834683850,"cwd":"/tmp/acp-snap-cwd-Jsq2M2"} +{"type":"session","version":0,"id":"06bd4899-ec95-43e3-82ca-42c719d8b19b","createdAt":1781834683850,"cwd":"/tmp/acp-snap-cwd-Jsq2M2"} {"type":"turn/start","seq":0,"time":1781834683853,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1781834683854,"data":{"content":[{"type":"text","text":"A file named greeting.txt in the current directory contains one word. Use the bash tool to append a second line containing the word WORLD to it (so it has two lines), then read the file back with `cat greeting.txt` to confirm, and reply with the single word DONE. Use a single bash call per action."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":1781834683854,"data":{"turn":1,"step":1}} diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 33c392ff7e..68cf71eda6 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -43,7 +43,7 @@ function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: un // `session.header.id`, NOT the registry key. Using distinct values here makes // the test fail if a regression matched on the wrong field (a same-value fake // would pass either way — the "hits the line but not the scenario" trap). - const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 1, id: sessionId, createdAt: 0 } } } as unknown as Agent + const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent const dispose = ctx.agents.register(agent) const list = fakeAgentDisposers.get(ctx) ?? [] list.push(dispose) @@ -466,7 +466,7 @@ describe('background task ownership (cross-session isolation)', () => { // the same token). The impl reads `session.header.id`, so the fakes MUST carry // it. const fakeAgent = (sessionId: string) => - ({ inject: () => undefined, session: { header: { version: 1, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent + ({ inject: () => undefined, session: { header: { version: 0, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent it('rejects bash_output/bash_kill for a task owned by a DIFFERENT session token', async () => { const ctx = await setup() @@ -582,7 +582,7 @@ describe('session-cwd routing (per-session workdir)', () => { } // An agent whose session header carries a cwd (what session/new records). const agentInCwd = (cwd: string) => - ({ inject: () => undefined, session: { header: { version: 1, id: 'c', createdAt: 0, cwd } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent + ({ inject: () => undefined, session: { header: { version: 0, id: 'c', createdAt: 0, cwd } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent it('defaults bash to the agent\'s session cwd (not the server launch dir)', async () => { const ctx = await setup() diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index f0eb6fb88a..c39924b34e 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -322,15 +322,22 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, const failTurn = (err: CodedError): void => { if (errorReported) return errorReported = true - // Set `reason` here so the durable failure is captured before closeTurn - // appends turn/end. The step number rides along so the operational error's - // location survives in the durable log. - reason = { kind: 'error', step, ...errorData(err) } + // Set the error reason ONLY while the turn is still open — closeTurn appends + // turn/end with it. If the turn has already ended (the only way here: a + // throwing agent/turn-end listener after closeTurn(true) already appended + // turn/end), the reason can no longer affect the durable log, so log the late + // throw directly instead — otherwise the listener exception would vanish. + if (!turnEnded) { + reason = { kind: 'error', step, ...errorData(err) } + } else { + ctx.logger.warn(`agent "${agent.id}": agent/turn-end listener threw after turn ${turn} closed: ${err.message}`) + } try { ctx.emit('agent/error', agent, turn, step, err) } catch { - // contained: the error is already captured on `reason`; a throwing - // agent/error listener must not prevent the turn from closing. + // contained: the error is already captured (on `reason`, or via the logger + // above); a throwing agent/error listener must not prevent the turn from + // closing. } } @@ -609,7 +616,14 @@ async function runStep( let message: Message = assembler.message() message = await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message)) - session.append('assistant/message', { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) }) + // Same content-or-usage guard as the max-tokens branch: a step that finishes + // with neither assembled content nor usage (e.g. a bare `stop` finish that + // streamed nothing) records no assistant/message — an empty-content message + // exists only to host usage, and deriveMessages() skips it either way, so + // appending one with no usage would be a pure trace-only row. + if (message.content.length > 0 || assembler.usage) { + session.append('assistant/message', { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) }) + } // --- Tool execution (sequential; parallel execution is a TODO) --- // ToolRegistry.execute converts tool failures (including aborts) into diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 8cb60e99b7..d018eff7a2 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -487,6 +487,25 @@ describe('agent loop', () => { expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }]) }) + it('appends no assistant/message for a normal stop finish with empty content and no usage', async () => { + // A clean `stop` finish that streamed nothing assembled (no blocks) and + // carried no usage chunk has nothing to record: the content-or-usage guard + // on the normal step path suppresses a pure trace-only empty assistant/message. + const adapter = new MockAdapter([[{ type: 'finish', reason: { kind: 'stop' } }]]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const reasons: TurnEndReason[] = [] + ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + expect(reasons).toEqual([{ kind: 'completed' }]) + expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false) + expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }]) + }) + it('keeps safe max-tokens assistant content while dropping truncated tool calls', async () => { const callId = CallId('c1') const adapter = new MockAdapter([[ diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 3695904322..ed0900c4a1 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' @@ -806,6 +806,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end') } }) const errors: Error[] = [] ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) send(agent, 'go') await waitForIdle(ctx, agent) @@ -815,6 +816,9 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(c.errors).toBe(0) // NO session error event (it would be post-turn/end) expect(agent.session.events.at(-1)?.type).toBe('turn/end') // last event is the boundary expect(errors.map(e => e.message)).toEqual(['boom turn-end']) // surfaced via agent/error + // The late throw is also logged directly: failTurn's turn-already-ended + // branch warns so a throwing turn-end listener after turn/end never vanishes. + expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/turn-end listener threw after turn 1 closed')) // The whole log is loadable (nothing dropped): a fresh replay sees the turn. const replay = new Session(SessionId('replay'), [...agent.session.events]) expect(replay.deriveMessages().map(m => m.role)).toEqual(['user', 'assistant']) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 55eeb523a5..5f423f9b93 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -9,7 +9,7 @@ import { Context, Service } from 'cordis' import { isAbsolute } from 'node:path' import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' -import { SessionId } from './types.ts' +import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader } from './types.ts' import { isJsonValue } from './json.ts' @@ -112,7 +112,7 @@ export class Session { // structuredClone can never hit a non-cloneable value here. this.log = seed.map(event => structuredClone(event)) } - this.header = header ?? { version: 1, id, createdAt: Date.now() } + this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() } } get events(): readonly SessionEvent[] { @@ -284,7 +284,7 @@ export class SessionStore extends Service { throw new Error(`session cwd must be an absolute path, got "${cwd}"`) } const header: SessionHeader = { - version: 1, + version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: options?.meta?.createdAt ?? Date.now(), ...cwd !== undefined ? { cwd } : {}, diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index ab9159377d..2302b5b94d 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -9,6 +9,23 @@ export function SessionId(id: string): SessionId { return id as SessionId } +/** + * The on-disk session format version, stamped into every newly-written + * {@link SessionHeader} and enforced by every persistence backend on load. The + * single source of truth for the version — write sites and the load-time check + * all read it. + * + * It is **`0`** deliberately: while the harness is unreleased the on-disk format + * is **unstable / pre-release, with no compatibility implied**. Breaking changes + * to the persisted {@link SessionEventMap} shape (folding fields onto an event, + * removing a variant, …) happen freely and do NOT bump this — v0 absorbs all + * pre-release churn, and a backend simply REJECTS any log not at v0 (there is no + * migration; no persisted user data exists to preserve). A real, monotonically + * bumped version policy begins at the first tagged release, when a specific + * format boundary becomes worth distinguishing. + */ +export const SESSION_FORMAT_VERSION = 0 + /** * Immutable session metadata — written once at creation and never rewritten. * @@ -19,7 +36,11 @@ export function SessionId(id: string): SessionId { * metadata) writes such a header. */ export interface SessionHeader { - /** On-disk format version; a persistence backend rejects unknown versions. */ + /** + * On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the + * session is created. A persistence backend rejects any other version on load + * (no migration — see the constant). + */ version: number /** The session's id (mirrors the {@link Session}'s id). */ id: SessionId diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 075d106948..f095bdfb40 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' describe('Session', () => { it('derives message history from the event log', () => { @@ -255,11 +255,11 @@ describe('SessionStore', () => { expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined() }) - it('synthesizes a minimal v1 header for a bare-created session', async () => { + it('synthesizes a minimal current-version header for a bare-created session', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('plain')) - expect(session.header).toMatchObject({ version: 1, id: 'plain' }) + expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'plain' }) expect(typeof session.header.createdAt).toBe('number') expect(session.header.cwd).toBeUndefined() expect(session.header.parentSession).toBeUndefined() @@ -272,7 +272,7 @@ describe('SessionStore', () => { meta: { cwd: '/work/project', parentSession: SessionId('parent') }, }) expect(session.header).toMatchObject({ - version: 1, + version: SESSION_FORMAT_VERSION, id: 'child', cwd: '/work/project', parentSession: 'parent', @@ -288,9 +288,9 @@ describe('SessionStore', () => { expect(ctx.sessions.get(SessionId('rel'))).toBeUndefined() }) - it('a bare Session() constructed without the store still exposes a v1 header', () => { + it('a bare Session() constructed without the store still exposes a current-version header', () => { const session = new Session(SessionId('bare')) - expect(session.header).toMatchObject({ version: 1, id: 'bare' }) + expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare' }) expect(typeof session.header.createdAt).toBe('number') }) diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 54514755a3..b8df12e547 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -25,7 +25,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. - **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). - **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. -- **Format version.** Only v1 is supported; `load` rejects an unknown version. While the harness is unreleased a format change bumps the version and rejects non-current logs — there is no migration (no persisted user data to preserve). +- **Format version.** Only the current `SESSION_FORMAT_VERSION` (v0) is supported; `load` rejects any other version. While the harness is unreleased the on-disk format is pre-release/unstable: a breaking format change is absorbed at v0 (no bump until the first tagged release) and non-current logs are rejected — there is no migration (no persisted user data to preserve). ## Write path 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 8fa02665eb..1df70f9c9a 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -249,7 +249,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { it('path-traversal session ids are neutralized (no escape from root)', async () => { const evil = SessionId('../../etc/pwn') - const m = { version: 1, id: evil, createdAt: 1 } + const m = { version: 0, id: evil, createdAt: 1 } await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(evil, oneTurnLog()) // The file lives UNDER root, not at ../../etc. @@ -310,7 +310,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => { const log = [ - JSON.stringify({ type: 'session', version: 1, id: 'g', createdAt: 1 }), + JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1 }), JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1 ].join('\n') + '\n' @@ -323,7 +323,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => { const log = [ - JSON.stringify({ type: 'session', version: 1, id: 'g2', createdAt: 1 }), + JSON.stringify({ type: 'session', version: 0, id: 'g2', createdAt: 1 }), JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1 JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }), @@ -335,7 +335,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('rejects a corrupt line BEFORE a later committed turn/end (committed data damaged)', () => { const log = [ - JSON.stringify({ type: 'session', version: 1, id: 'c', createdAt: 1 }), + JSON.stringify({ type: 'session', version: 0, id: 'c', createdAt: 1 }), '{not json', // corrupt, sits in the committed region (a turn/end follows) JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }), ].join('\n') + '\n' @@ -343,7 +343,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { }) it('a header-only log (no event lines at all) preserves nothing — committedBytes is the header', () => { - const log = JSON.stringify({ type: 'session', version: 1, id: 'h0', createdAt: 1 }) + '\n' + const log = JSON.stringify({ type: 'session', version: 0, id: 'h0', createdAt: 1 }) + '\n' const scanned = scanLog(Buffer.from(log)) expect(scanned.events).toEqual([]) // committedBytes falls back to the header line's end (no preserved events). @@ -352,7 +352,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('a corrupt line after the last turn/end bounds the preserved tail', () => { const log = [ - JSON.stringify({ type: 'session', version: 1, id: 'c2', createdAt: 1 }), + JSON.stringify({ type: 'session', version: 0, id: 'c2', createdAt: 1 }), JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), '{not json', // corrupt crash fragment, no turn/end committed ].join('\n') + '\n' @@ -363,7 +363,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => { const log = [ - JSON.stringify({ type: 'session', version: 1, id: 't', createdAt: 1 }), + JSON.stringify({ type: 'session', version: 0, id: 't', createdAt: 1 }), JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }), JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail @@ -442,7 +442,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { // field is tolerated by the header type guard) and confirm list() reads it. const bucket = join(root, '_no-cwd') await mkdir(bucket, { recursive: true }) - const bigHeader = JSON.stringify({ type: 'session', version: 1, id: 'big', createdAt: 1, pad: 'x'.repeat(9000) }) + const bigHeader = JSON.stringify({ type: 'session', version: 0, id: 'big', createdAt: 1, pad: 'x'.repeat(9000) }) await writeFile(join(bucket, 'big.jsonl'), bigHeader + '\n') const ids = (await ctx.sessionPersistence.list()).map(x => x.id) expect(ids).toContain('big') diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index d0f8717ff1..7c7b044ee1 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -25,7 +25,7 @@ */ import { Context } from 'cordis' -import { interruptedTurnClosers } from '@deepseek-ai/dsh-session' +import { interruptedTurnClosers, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { assertSerializable, seedCoversPrefix } from './index.ts' @@ -319,8 +319,8 @@ export class PersistenceCoordinator { } private assertVersion(meta: SessionHeader): void { - if (meta.version !== 1) { - throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v1 is supported)`) + if (meta.version !== SESSION_FORMAT_VERSION) { + throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v${SESSION_FORMAT_VERSION} is supported)`) } } diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index aa7c76c84c..a0f0e7bfa0 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -9,7 +9,7 @@ */ import { describe, expect, it } from 'vitest' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { CallId } from '@deepseek-ai/dsh-llm' import type { SessionPersistence } from '../src/index.ts' @@ -23,7 +23,7 @@ export interface ContractBackend { /** Build a minimal {@link SessionHeader} for a session id. */ export function meta(id: string, cwd?: string): SessionHeader { return { - version: 1, + version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt: 1000, ...cwd !== undefined ? { cwd } : {}, @@ -57,7 +57,7 @@ export function runPersistenceContract(name: string, make: () => Promise Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { - const m = { version: 2, id: SessionId('v2'), createdAt: 1, cwd: WORK } + const m = { version: 99, id: SessionId('v99'), createdAt: 1, cwd: WORK } await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/version/) @@ -685,7 +685,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { - const m = { version: 1, id: SessionId('forked-child'), createdAt: 1, cwd: WORK, parentSession: SessionId('the-parent') } + const m = { version: SESSION_FORMAT_VERSION, id: SessionId('forked-child'), createdAt: 1, cwd: WORK, parentSession: SessionId('the-parent') } await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) const loaded = await ctx.sessionPersistence.load(m.id) diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 556fb5abff..925881273a 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -33,7 +33,7 @@ const TEXT_CHUNKS: StreamChunk[] = [ /** Build a minimal session-JSONL string: a header line + the given events. */ function sessionJsonl(events: SessionEvent[]): string { - const header = JSON.stringify({ type: 'session', version: 1, id: 's1', createdAt: 0 }) + const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 }) return [header, ...events.map(e => JSON.stringify(e))].join('\n') + '\n' } @@ -67,7 +67,7 @@ describe('parseSessionLog', () => { }) it('ignores blank lines', () => { - const header = JSON.stringify({ type: 'session', version: 1, id: 's1', createdAt: 0 }) + const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 }) const ev = chunkEvent(1, 1, 1, TEXT_CHUNKS[0] as StreamChunk) expect(parseSessionLog(`${header}\n\n${JSON.stringify(ev)}\n\n`)).toEqual([ev]) }) diff --git a/packages/ui/acp/tests/load.spec.ts b/packages/ui/acp/tests/load.spec.ts index d254ee8885..a87fa49a9e 100644 --- a/packages/ui/acp/tests/load.spec.ts +++ b/packages/ui/acp/tests/load.spec.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts' @@ -167,7 +167,7 @@ describe('acp bridge — session/load replay', () => { loader = await makeBridgeHarness({ storageDir, script: [] }) const otherCwd = '/some/other/workspace' await loader.ctx.sessionPersistence.create({ - version: 1, id: SessionId('elsewhere'), createdAt: 1, cwd: otherCwd, + version: SESSION_FORMAT_VERSION, id: SessionId('elsewhere'), createdAt: 1, cwd: otherCwd, }) await loader.ctx.sessionPersistence.append(SessionId('elsewhere'), [ { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, @@ -204,7 +204,7 @@ describe('acp bridge — session/load replay', () => { // to the server's launch dir (the request cwd does not override the header). loader = await makeBridgeHarness({ storageDir, script: [] }) await loader.ctx.sessionPersistence.create({ - version: 1, id: SessionId('legacy'), createdAt: 1, // no cwd + version: SESSION_FORMAT_VERSION, id: SessionId('legacy'), createdAt: 1, // no cwd }) await loader.ctx.sessionPersistence.append(SessionId('legacy'), [ { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },