Procházet zdrojové kódy

fix review findings: bump session format version + restore late turn-end warn

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.
Tianyi Cui před 2 měsíci
rodič
revize
b0422f2a50
32 změnil soubory, kde provedl 127 přidání a 64 odebrání
  1. 1 1
      AGENTS.md
  2. 5 1
      docs/core-data-structures/persistence.md
  3. 4 3
      docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md
  4. 1 1
      examples/acp-agent/tests/snapshot-normalize.spec.ts
  5. 1 1
      examples/acp-agent/tests/snapshots/cancel/session.golden.jsonl
  6. 1 1
      examples/acp-agent/tests/snapshots/cancel/session.jsonl
  7. 1 1
      examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl
  8. 1 1
      examples/acp-agent/tests/snapshots/error-finish/session.jsonl
  9. 1 1
      examples/acp-agent/tests/snapshots/handshake/session.jsonl
  10. 1 1
      examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl
  11. 1 1
      examples/acp-agent/tests/snapshots/multi-turn/session.jsonl
  12. 1 1
      examples/acp-agent/tests/snapshots/reject-extra-dirs/session.jsonl
  13. 1 1
      examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl
  14. 1 1
      examples/acp-agent/tests/snapshots/text-turn/session.jsonl
  15. 1 1
      examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl
  16. 1 1
      examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl
  17. 1 1
      examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl
  18. 1 1
      examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl
  19. 3 3
      packages/bash/tool-bash/tests/tools.spec.ts
  20. 21 7
      packages/core/agent-loop/src/loop.ts
  21. 19 0
      packages/core/agent-loop/tests/loop.spec.ts
  22. 5 1
      packages/core/agent-loop/tests/review-fixes.spec.ts
  23. 3 3
      packages/core/session/src/index.ts
  24. 22 1
      packages/core/session/src/types.ts
  25. 6 6
      packages/core/session/tests/session.spec.ts
  26. 1 1
      packages/session-persistence/session-persistence-jsonl/README.md
  27. 8 8
      packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
  28. 3 3
      packages/session-persistence/session-persistence/src/coordinator.ts
  29. 3 3
      packages/session-persistence/session-persistence/tests/contract.ts
  30. 3 3
      packages/session-persistence/session-persistence/tests/coordinator-contract.ts
  31. 2 2
      packages/support/llm-replay/tests/llm-replay.spec.ts
  32. 3 3
      packages/ui/acp/tests/load.spec.ts

+ 1 - 1
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
 

+ 5 - 1
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

+ 4 - 3
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.

+ 1 - 1
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', () => {

+ 1 - 1
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}}

+ 1 - 1
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}

+ 1 - 1
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}}

+ 1 - 1
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}

+ 1 - 1
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}

+ 1 - 1
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}}

+ 1 - 1
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}}

+ 1 - 1
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}

+ 1 - 1
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}}

+ 1 - 1
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}}

+ 1 - 1
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}}

+ 1 - 1
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}}

+ 1 - 1
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}}

+ 1 - 1
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}}

+ 3 - 3
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()

+ 21 - 7
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

+ 19 - 0
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([[

+ 5 - 1
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'])

+ 3 - 3
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 } : {},

+ 22 - 1
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

+ 6 - 6
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')
   })
 

+ 1 - 1
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
 

+ 8 - 8
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')

+ 3 - 3
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<TornMarker = unknown> {
   }
 
   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)`)
     }
   }
 

+ 3 - 3
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<Contrac
         await persistence.append(m.id, log)
 
         const loaded = await persistence.load(m.id)
-        expect(loaded.meta).toMatchObject({ version: 1, id: m.id, cwd: '/work' })
+        expect(loaded.meta).toMatchObject({ version: SESSION_FORMAT_VERSION, id: m.id, cwd: '/work' })
         expect(loaded.events).toEqual(log)
       } finally {
         await dispose()

+ 3 - 3
packages/session-persistence/session-persistence/tests/coordinator-contract.ts

@@ -28,7 +28,7 @@
 
 import { describe, expect, it } from 'vitest'
 import { Context, type Fiber } from 'cordis'
-import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
 import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
 import type { SessionPersistence } from '../src/index.ts'
 import { meta, oneTurnLog } from './contract.ts'
@@ -671,7 +671,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => 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)

+ 2 - 2
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])
   })

+ 3 - 3
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' } } } },