@deepseek-ai/dsh-session 0.1.2-alpha.5 → 0.1.3-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,15 +10,13 @@ import type { Scoped } from '@deepseek-ai/dsh-scope';
10
10
  import type { Message } from '@deepseek-ai/dsh-llm';
11
11
  import { SessionLogOffset, SessionSeq } from './types.ts';
12
12
  import type { TypertLookup } from '@deepseek-ai/dsh-typert-protocol';
13
- import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SurfaceIntent, SurfaceEventType } from './types.ts';
13
+ import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SessionSeedEventState, SurfaceIntent, SurfaceEventType } from './types.ts';
14
14
  import type { SessionSurface } from './surface.ts';
15
15
  export * from './types.ts';
16
16
  export { SessionPreparation } from './preparation.ts';
17
17
  export type { SessionPreparationOptions } from './preparation.ts';
18
18
  export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm';
19
19
  export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts';
20
- export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts';
21
- export type { ChunkRow, StorageRecord } from './chunk-rows.ts';
22
20
  export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts';
23
21
  export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts';
24
22
  export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts';
@@ -124,9 +122,10 @@ export declare class Session {
124
122
  * The first seq appended IN THIS PROCESS: the length of the constructor
125
123
  * seed (0 without one). Events with smaller seq values entered through
126
124
  * construction — replay, fork, or resume — and were never published on the
127
- * `session/event` firehose (constructor seeds do not emit), so consumers
128
- * that replay the log as a publication substitute (telemetry adoption)
129
- * start here. Distinct from {@link inheritedEventCount}, the DURABLE
125
+ * `session/event` firehose (constructor seeds do not emit). This offset marks
126
+ * the constructor-input boundary for lifecycle ownership and persistence
127
+ * adoption; consumers that need complete canonical history still start at
128
+ * seq 0. Distinct from {@link inheritedEventCount}, the DURABLE
130
129
  * fork-lineage cut: a resumed session's constructor seed is its full stored
131
130
  * log, while the inherited count keeps the original fork value — this field is the
132
131
  * in-process construction fact.
@@ -154,16 +153,19 @@ export declare class Session {
154
153
  */
155
154
  static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset): Session;
156
155
  /**
157
- * Restore a detached session by taking ownership of fresh persistence values.
158
- * The storage format, event envelopes, sequence continuity, surface transitions,
159
- * and header fields are validated before the restored objects are frozen.
156
+ * Restore a detached session by adopting an independently owned or deeply frozen seed.
157
+ * Runtime-required event fields, event envelopes, sequence continuity, surface
158
+ * transitions, and header fields are validated without copying or freezing events.
159
+ * Embedded Assistant streams remain opaque until a stream consumer or storage
160
+ * verifier reads them.
160
161
  * @param id - restored session identity.
161
- * @param seed - fresh detached events whose ownership is transferred.
162
- * @param header - fresh detached metadata whose ownership is transferred.
162
+ * @param seed - independently owned or deeply frozen events.
163
+ * @param header - independently owned storage metadata.
163
164
  * @param inheritedEventCount - exact fork-inherited prefix length decoded from storage.
165
+ * @param eventState - aliasing state carried from the operation that produced the seed.
164
166
  * @returns a restored detached session.
165
167
  */
166
- static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader, inheritedEventCount: SessionLogOffset): Session;
168
+ static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader, inheritedEventCount: SessionLogOffset, eventState: SessionSeedEventState): Session;
167
169
  private constructor();
168
170
  /** Cached immutable full snapshot of the private append-only log. */
169
171
  private eventsSnapshot;
@@ -212,7 +214,8 @@ export declare class Session {
212
214
  * declare how it joins the surface, the sole source of derived model
213
215
  * history) and
214
216
  * rejected by the compiler for non-surface types like `turn/start` or
215
- * `assistant/chunk`.
217
+ * `assistant/attempt`. Assistant messages embed their exact provider
218
+ * stream and cannot cite top-level source events.
216
219
  * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
217
220
  * `data` that entered the log, so reading `event.data` back sees the logged
218
221
  * value, never the caller's still-mutable input.
@@ -230,7 +233,7 @@ export declare class Session {
230
233
  * append reentered while this acceptance/publication boundary is open also
231
234
  * rejects before the log changes.
232
235
  */
233
- append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []): SessionEvent<T>;
236
+ append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent<T>] : []): SessionEvent<T>;
234
237
  /** Cached fold of the request-header events — see {@link requestHeader}. */
235
238
  private headerFold;
236
239
  /** Log position (events consumed) the header fold has reached. */
@@ -305,8 +308,9 @@ export declare class SessionForkError extends Error {
305
308
  /**
306
309
  * In-memory session store (`ctx.sessions`).
307
310
  *
308
- * Persistence is intentionally not implemented here — persistence plugins
309
- * subscribe to `session/event` and flush on `session/flush` / dispose.
311
+ * Persistence is intentionally not implemented here — the agent lifecycle
312
+ * attaches a session-log writer to each published session's write handle;
313
+ * a session published outside that lifecycle persists nothing.
310
314
  */
311
315
  export declare class SessionStore extends Service {
312
316
  private store;
@@ -345,10 +349,9 @@ export declare class SessionStore extends Service {
345
349
  *
346
350
  * @param id - the session id; omitted, the store mints `session-<n>`.
347
351
  * @param options - seed events and/or creation metadata for the header. With
348
- * `seedSource: 'persistence'`, metadata and events must be fresh detached
349
- * graphs whose ownership transfers to this call: they are validated and
350
- * frozen in place through {@link Session.fromRestore}, so the caller must
351
- * retain no mutable aliases.
352
+ * `eventState`, every seed event is either independently owned or any
353
+ * shared value is deeply frozen; {@link Session.fromRestore} validates and
354
+ * adopts those values without copying or freezing them.
352
355
  * @returns the constructed session, NOT yet in the store.
353
356
  * @throws if a session with `id` already exists, metadata is not a plain
354
357
  * lossless-JSON record with valid scalar fields, or `meta.cwd` is a
@@ -8,7 +8,7 @@
8
8
  import { Service } from '@deepseek-ai/cordis';
9
9
  import { isAbsolute } from 'node:path';
10
10
  import { brandString } from '@deepseek-ai/dsh-brand';
11
- import { deepFreeze, snapshotJsonValue } from '@deepseek-ai/dsh-util-values';
11
+ import { assertNever, deepFreeze, snapshotJsonValue } from '@deepseek-ai/dsh-util-values';
12
12
  import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope';
13
13
  import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from "./types.js";
14
14
  import { deriveEventMessage, SurfaceManager } from "./surface.js";
@@ -16,7 +16,6 @@ import { foldRequestHeader } from "./request-header.js";
16
16
  export * from "./types.js";
17
17
  export { SessionPreparation } from "./preparation.js";
18
18
  export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from "./repair.js";
19
- export { decodeStorageRecord, packChunkRuns } from "./chunk-rows.js";
20
19
  export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from "./surface.js";
21
20
  export { canonicalHeader, foldRequestHeader, headerEquals } from "./request-header.js";
22
21
  export { KNOWN_SESSION_EVENT_TYPES } from "./known-event-types.js";
@@ -117,28 +116,9 @@ export function adoptSessionEvent(event) {
117
116
  export function snapshotSessionEvent(event) {
118
117
  return adoptSessionEvent(structuredClone(event));
119
118
  }
120
- /** Deep-freeze one acyclic JSON tree without consuming the JavaScript call stack. */
121
- function freezeRestoredObject(value) {
122
- const pending = [value];
123
- while (pending.length > 0) {
124
- // The non-empty check proves an object remains to visit.
125
- // oxlint-disable-next-line typescript/no-non-null-assertion
126
- const current = pending.pop();
127
- Object.freeze(current);
128
- for (const key in current) {
129
- const child = current[key];
130
- if (child !== null && typeof child === 'object')
131
- pending.push(child);
132
- }
133
- }
134
- return value;
135
- }
136
119
  /** Validate the fixed event envelope after one-pass JSON materialization. */
137
120
  function assertSessionEventEnvelope(value, index) {
138
121
  const event = value;
139
- if (event['type'] === 'request/header-delta') {
140
- throw new Error(`seed event at index ${index} uses unsupported legacy request/header-delta format`);
141
- }
142
122
  for (const key in event) {
143
123
  switch (key) {
144
124
  case 'type':
@@ -166,6 +146,7 @@ function assertSessionEventEnvelope(value, index) {
166
146
  switch (type) {
167
147
  case 'request/header':
168
148
  case 'user/message':
149
+ case 'assistant/attempt':
169
150
  case 'assistant/message':
170
151
  case 'tool/result':
171
152
  assertCurrentLlmShape(event, index);
@@ -193,12 +174,36 @@ function assertCurrentLlmShape(event, index) {
193
174
  throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`);
194
175
  }
195
176
  assertAdapterDefaults(headerRecord?.['adapterDefaults'], configRecord, index);
177
+ const reason = record?.['reason'];
178
+ if (reason !== 'initial' && reason !== 'resume' && reason !== 'change' && reason !== 'series') {
179
+ throw new Error(`seed request/header at index ${index} has an invalid reason`);
180
+ }
181
+ if (record?.['startsSeries'] !== undefined && record['startsSeries'] !== true) {
182
+ throw new Error(`seed request/header at index ${index} has an invalid startsSeries marker`);
183
+ }
196
184
  }
197
185
  const type = event['type'];
186
+ if (type === 'assistant/attempt') {
187
+ assertAssistantSettlementShape(record, type, index);
188
+ return;
189
+ }
198
190
  if (type !== 'user/message' && type !== 'assistant/message'
199
191
  && type !== 'tool/result')
200
192
  return;
201
193
  assertMessageEventShape(event, `seed ${type} at index ${index}`);
194
+ if (type === 'assistant/message') {
195
+ assertAssistantSettlementShape(record, type, index);
196
+ }
197
+ }
198
+ /** Validate fields used directly by restored Session lifecycle logic without replaying the embedded stream. */
199
+ function assertAssistantSettlementShape(data, type, index) {
200
+ const turn = data?.['turn'];
201
+ const step = data?.['step'];
202
+ if (typeof turn !== 'number' || !Number.isSafeInteger(turn) || turn < 0 || Object.is(turn, -0)
203
+ || typeof step !== 'number' || !Number.isSafeInteger(step) || step < 0 || Object.is(step, -0)
204
+ || !Array.isArray(data?.['stream'])) {
205
+ throw new Error(`seed ${type} at index ${index} has invalid settlement fields`);
206
+ }
202
207
  }
203
208
  const allowedAdapterKeys = new Set(['reasoningEffort', 'maxTokens']);
204
209
  /** Validate adapter-default markers imported from a durable request header. */
@@ -279,17 +284,6 @@ function hasProviderModel(value) {
279
284
  return typeof pair['provider'] === 'string' && pair['provider'].length > 0
280
285
  && typeof pair['model'] === 'string' && pair['model'].length > 0;
281
286
  }
282
- /** Reject request-header vocabulary removed with the legacy delta codec. */
283
- function assertSupportedRequestHeader(type, data, location) {
284
- if (type === 'request/header-delta') {
285
- throw new Error(`${location} uses unsupported legacy request/header-delta format`);
286
- }
287
- if (type === 'request/header'
288
- && data !== null && typeof data === 'object' && !Array.isArray(data)
289
- && data['reason'] === 'fallback') {
290
- throw new Error(`${location} uses unsupported legacy request/header reason "fallback"`);
291
- }
292
- }
293
287
  /** Resolve one listener snapshot, including Cordis's internal dispatch checks. */
294
288
  function collectSessionCallbacks(ctx, args) {
295
289
  return [...ctx.events.dispatch('emit', args)];
@@ -345,9 +339,10 @@ export class Session {
345
339
  * The first seq appended IN THIS PROCESS: the length of the constructor
346
340
  * seed (0 without one). Events with smaller seq values entered through
347
341
  * construction — replay, fork, or resume — and were never published on the
348
- * `session/event` firehose (constructor seeds do not emit), so consumers
349
- * that replay the log as a publication substitute (telemetry adoption)
350
- * start here. Distinct from {@link inheritedEventCount}, the DURABLE
342
+ * `session/event` firehose (constructor seeds do not emit). This offset marks
343
+ * the constructor-input boundary for lifecycle ownership and persistence
344
+ * adoption; consumers that need complete canonical history still start at
345
+ * seq 0. Distinct from {@link inheritedEventCount}, the DURABLE
351
346
  * fork-lineage cut: a resumed session's constructor seed is its full stored
352
347
  * log, while the inherited count keeps the original fork value — this field is the
353
348
  * in-process construction fact.
@@ -377,22 +372,23 @@ export class Session {
377
372
  return new Session(id, seed, header, 'snapshot', inheritedEventCount);
378
373
  }
379
374
  /**
380
- * Restore a detached session by taking ownership of fresh persistence values.
381
- * The storage format, event envelopes, sequence continuity, surface transitions,
382
- * and header fields are validated before the restored objects are frozen.
375
+ * Restore a detached session by adopting an independently owned or deeply frozen seed.
376
+ * Runtime-required event fields, event envelopes, sequence continuity, surface
377
+ * transitions, and header fields are validated without copying or freezing events.
378
+ * Embedded Assistant streams remain opaque until a stream consumer or storage
379
+ * verifier reads them.
383
380
  * @param id - restored session identity.
384
- * @param seed - fresh detached events whose ownership is transferred.
385
- * @param header - fresh detached metadata whose ownership is transferred.
381
+ * @param seed - independently owned or deeply frozen events.
382
+ * @param header - independently owned storage metadata.
386
383
  * @param inheritedEventCount - exact fork-inherited prefix length decoded from storage.
384
+ * @param eventState - aliasing state carried from the operation that produced the seed.
387
385
  * @returns a restored detached session.
388
386
  */
389
- static fromRestore(id, seed, header, inheritedEventCount) {
390
- return new Session(id, seed, header, 'restore', inheritedEventCount);
387
+ static fromRestore(id, seed, header, inheritedEventCount, eventState) {
388
+ return new Session(id, seed, header, eventState, inheritedEventCount);
391
389
  }
392
390
  constructor(id, seed, header, mode = 'snapshot', suppliedInheritedEventCount) {
393
- const restoredHeader = mode === 'restore'
394
- ? validateRestoredSessionHeader(id, header)
395
- : undefined;
391
+ const restoredHeader = mode === 'snapshot' ? undefined : validateRestoredSessionHeader(id, header);
396
392
  if (seed !== undefined) {
397
393
  // Validate the seed to the SAME invariants `append` enforces, so a
398
394
  // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
@@ -404,12 +400,11 @@ export class Session {
404
400
  for (const [index, source] of seed.entries()) {
405
401
  // The seed is a persistence/replay boundary: validate and detach the
406
402
  // complete event in one lossless-JSON pass.
407
- const snapshot = mode === 'restore' ? source : snapshotJsonValue(source);
403
+ const snapshot = mode === 'snapshot' ? snapshotJsonValue(source) : source;
408
404
  if (snapshot === undefined) {
409
405
  throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`);
410
406
  }
411
407
  assertSessionEventEnvelope(snapshot, index);
412
- assertSupportedRequestHeader(snapshot.type, snapshot.data, `seed event at index ${index}`);
413
408
  if (snapshot.seq !== index) {
414
409
  throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`);
415
410
  }
@@ -422,7 +417,7 @@ export class Session {
422
417
  catch (error) {
423
418
  throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`);
424
419
  }
425
- this.log.push(mode === 'restore' ? freezeRestoredObject(snapshot) : deepFreeze(snapshot));
420
+ this.log.push(mode === 'snapshot' ? deepFreeze(snapshot) : snapshot);
426
421
  }
427
422
  }
428
423
  this.firstLiveSeq = SessionLogOffset(this.log.length);
@@ -440,12 +435,17 @@ export class Session {
440
435
  if (inheritedEventCount > this.log.length) {
441
436
  throw new Error('session inherited event count exceeds its event log');
442
437
  }
438
+ if (mode === 'snapshot' && this.header.isSeeded && inheritedEventCount !== this.log.length) {
439
+ throw new Error('seeded session constructor seed must equal its inherited prefix');
440
+ }
443
441
  this.inheritedEventCount = inheritedEventCount;
444
- // Appended here so the marker is already in `events` when a backend
445
- // captures the creation seed: no load-time write. Re-marking is skipped
446
- // because a cold session is resumed on first touch, so repeatedly opening
447
- // one must not grow its log per open.
448
- if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
442
+ // A fresh seeded child always owns one tagged marker at its inherited cut,
443
+ // even when the copied prefix already ends in an ancestor marker. Restore
444
+ // retains that durable marker and appends only the ordinary resume marker.
445
+ if (seed !== undefined && mode === 'snapshot' && this.header.isSeeded) {
446
+ this.append('session/end-seed', { inherited: true });
447
+ }
448
+ else if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
449
449
  this.append('session/end-seed', {});
450
450
  }
451
451
  }
@@ -510,7 +510,8 @@ export class Session {
510
510
  * declare how it joins the surface, the sole source of derived model
511
511
  * history) and
512
512
  * rejected by the compiler for non-surface types like `turn/start` or
513
- * `assistant/chunk`.
513
+ * `assistant/attempt`. Assistant messages embed their exact provider
514
+ * stream and cannot cite top-level source events.
514
515
  * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
515
516
  * `data` that entered the log, so reading `event.data` back sees the logged
516
517
  * value, never the caller's still-mutable input.
@@ -538,7 +539,6 @@ export class Session {
538
539
  if (dataSnapshot === undefined) {
539
540
  throw new Error(`session event "${type}" carries non-JSON-serializable data`);
540
541
  }
541
- assertSupportedRequestHeader(type, dataSnapshot, `session event "${type}"`);
542
542
  const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata);
543
543
  if (surfaceMetadataSnapshot === undefined) {
544
544
  throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`);
@@ -688,8 +688,9 @@ export class SessionForkError extends Error {
688
688
  /**
689
689
  * In-memory session store (`ctx.sessions`).
690
690
  *
691
- * Persistence is intentionally not implemented here — persistence plugins
692
- * subscribe to `session/event` and flush on `session/flush` / dispose.
691
+ * Persistence is intentionally not implemented here — the agent lifecycle
692
+ * attaches a session-log writer to each published session's write handle;
693
+ * a session published outside that lifecycle persists nothing.
693
694
  */
694
695
  export class SessionStore extends Service {
695
696
  store = new Map();
@@ -750,10 +751,9 @@ export class SessionStore extends Service {
750
751
  *
751
752
  * @param id - the session id; omitted, the store mints `session-<n>`.
752
753
  * @param options - seed events and/or creation metadata for the header. With
753
- * `seedSource: 'persistence'`, metadata and events must be fresh detached
754
- * graphs whose ownership transfers to this call: they are validated and
755
- * frozen in place through {@link Session.fromRestore}, so the caller must
756
- * retain no mutable aliases.
754
+ * `eventState`, every seed event is either independently owned or any
755
+ * shared value is deeply frozen; {@link Session.fromRestore} validates and
756
+ * adopts those values without copying or freezing them.
757
757
  * @returns the constructed session, NOT yet in the store.
758
758
  * @throws if a session with `id` already exists, metadata is not a plain
759
759
  * lossless-JSON record with valid scalar fields, or `meta.cwd` is a
@@ -771,8 +771,18 @@ export class SessionStore extends Service {
771
771
  }
772
772
  if (this.store.has(sessionId))
773
773
  throw new Error(`session "${sessionId}" already exists`);
774
- if (options?.seedSource === 'persistence') {
775
- return Session.fromRestore(sessionId, options.seed, options.meta, options.inheritedEventCount);
774
+ if (options !== undefined) {
775
+ const { eventState } = options;
776
+ switch (eventState) {
777
+ case 'detached':
778
+ case 'shared-frozen':
779
+ return Session.fromRestore(sessionId, options.seed, options.meta, options.inheritedEventCount, eventState);
780
+ case undefined:
781
+ break;
782
+ /* v8 ignore next -- closed-union exhaustiveness guard */
783
+ default:
784
+ assertNever(eventState, 'SessionStore.prepare event state');
785
+ }
776
786
  }
777
787
  const seed = options?.seed;
778
788
  const meta = options?.meta;
@@ -72,8 +72,8 @@ function validateEvent(trace, event, fail) {
72
72
  nextStep += 1;
73
73
  break;
74
74
  }
75
- case 'assistant/chunk': {
76
- requireOpenStep(trace, 'assistant/chunk', event.data.turn, event.data.step, fail);
75
+ case 'assistant/attempt': {
76
+ requireOpenStep(trace, 'assistant/attempt', event.data.turn, event.data.step, fail);
77
77
  break;
78
78
  }
79
79
  case 'assistant/message': {
@@ -24,7 +24,7 @@ export const KNOWN_SESSION_EVENT_TYPES = new Set([
24
24
  'approval/asked',
25
25
  'approval/decided',
26
26
  'approval/policy',
27
- 'assistant/chunk',
27
+ 'assistant/attempt',
28
28
  'assistant/message',
29
29
  'command/done',
30
30
  'command/run',
@@ -32,6 +32,8 @@ export const KNOWN_SESSION_EVENT_TYPES = new Set([
32
32
  'compaction/prune',
33
33
  'compaction/start',
34
34
  'compaction/summary',
35
+ 'feedback/message-delete',
36
+ 'feedback/message-put',
35
37
  'feedback/record',
36
38
  'goal/change',
37
39
  'hook/invoked',
@@ -50,7 +50,7 @@ export declare function isReplacementSurfaceEvent(event: SessionEvent): event is
50
50
  };
51
51
  /**
52
52
  * Project a single event into the LLM message it derives to, or null when it
53
- * produces none — a non-surface event (chunk, boundary, log-only record) or an
53
+ * produces none — a non-surface event (attempt, boundary, log-only record) or an
54
54
  * empty-content assistant/message (which exists only to host usage). This is
55
55
  * THE per-node projection rule: `Session.deriveMessages` folds it over the
56
56
  * live surface, external reconstructors and pure projections fold the same
@@ -58,7 +58,7 @@ export function isReplacementSurfaceEvent(event) {
58
58
  }
59
59
  /**
60
60
  * Project a single event into the LLM message it derives to, or null when it
61
- * produces none — a non-surface event (chunk, boundary, log-only record) or an
61
+ * produces none — a non-surface event (attempt, boundary, log-only record) or an
62
62
  * empty-content assistant/message (which exists only to host usage). This is
63
63
  * THE per-node projection rule: `Session.deriveMessages` folds it over the
64
64
  * live surface, external reconstructors and pure projections fold the same
@@ -71,7 +71,7 @@ export function isReplacementSurfaceEvent(event) {
71
71
  */
72
72
  export function deriveEventMessage(event) {
73
73
  // Intentionally non-exhaustive: only message-producing events derive
74
- // history; turn/step boundaries, chunks, usage, and errors are trace/replay
74
+ // history; turn/step boundaries, failed attempts, and errors are trace/replay
75
75
  // data.
76
76
  switch (event.type) {
77
77
  // Ordinary prompts and injected context project in user role: the event's
@@ -97,7 +97,7 @@ export function deriveEventMessage(event) {
97
97
  return event.data.message;
98
98
  }
99
99
  default:
100
- // A non-surface event (boundary, chunk, log-only record) projects to
100
+ // A non-surface event (boundary, attempt, log-only record) projects to
101
101
  // no message. Merge-extensible union: no assertNever here.
102
102
  return null;
103
103
  }
@@ -153,13 +153,16 @@ function surfaceOpOf(event) {
153
153
  /** Validate cited source-event seqs against prior log entries and the replacement range. */
154
154
  function assertProvenance(event, shadowedSeqs) {
155
155
  const raw = event.sourceEventSeqs;
156
+ if (event.type === 'assistant/message' && raw !== undefined) {
157
+ throw new Error('assistant/message embeds its source stream and cannot carry sourceEventSeqs');
158
+ }
156
159
  const sources = new Set();
157
160
  if (raw !== undefined) {
158
161
  if (!Array.isArray(raw)) {
159
162
  throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`);
160
163
  }
161
- if (raw.length === 0 && event.type !== 'assistant/message') {
162
- throw new Error('sourceEventSeqs must not be empty except on assistant/message');
164
+ if (raw.length === 0) {
165
+ throw new Error('sourceEventSeqs must not be empty');
163
166
  }
164
167
  let nonEarlierSource;
165
168
  for (const source of raw) {