@deepseek-ai/dsh-session 0.1.6-alpha.2 → 0.1.7-alpha.1

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.
package/lib/index.js CHANGED
@@ -53,7 +53,7 @@ function SessionLogOffset(value) {
53
53
  * immutable prior-generation, and current fast-path rules are recorded in
54
54
  * `.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md`.
55
55
  */
56
- const SESSION_FORMAT_VERSION = 3;
56
+ const SESSION_FORMAT_VERSION = 4;
57
57
  //#endregion
58
58
  //#region lib/types/known-event-types.js
59
59
  /**
@@ -91,6 +91,7 @@ const KNOWN_SESSION_EVENT_TYPES = new Set([
91
91
  "compaction/start",
92
92
  "compaction/summary",
93
93
  "deliverables/presented",
94
+ "developer/message",
94
95
  "feedback/message-delete",
95
96
  "feedback/message-put",
96
97
  "feedback/record",
@@ -152,6 +153,7 @@ const MESSAGE_PROJECTION_EVENT_TYPES = new Set(["image/offload"]);
152
153
  /** Runtime counterpart of the message-producing event union. */
153
154
  const SURFACE_EVENT_TYPES = new Set([
154
155
  "system/message",
156
+ "developer/message",
155
157
  "user/message",
156
158
  "assistant/message",
157
159
  "tool/result"
@@ -159,7 +161,7 @@ const SURFACE_EVENT_TYPES = new Set([
159
161
  /**
160
162
  * Whether an event type can join the model-visible surface.
161
163
  * @param type - event type to test.
162
- * @returns true for one of the four message-producing event types.
164
+ * @returns true for one of the message-producing event types.
163
165
  */
164
166
  function isSurfaceEligibleType(type) {
165
167
  return SURFACE_EVENT_TYPES.has(type);
@@ -200,7 +202,7 @@ function isReplacementSurfaceEvent(event) {
200
202
  /**
201
203
  * Project a single event into the LLM message it derives to, or null when it
202
204
  * produces none — a non-surface event (attempt, boundary, log-only record) or an
203
- * empty-content assistant/message (which exists only to host usage). A caller
205
+ * empty-content system, developer, or assistant message. A caller
204
206
  * reconstructing model input supplies the same prefix's `projectedMessages`
205
207
  * from {@link foldSurface}; without that map this function reads original
206
208
  * event content. Session instance methods apply the live projection. Messages
@@ -215,6 +217,7 @@ function deriveEventMessage(event, projectedMessages) {
215
217
  switch (event.type) {
216
218
  case "user/message": return event.data;
217
219
  case "system/message":
220
+ case "developer/message":
218
221
  case "assistant/message":
219
222
  if (event.data.message.content.length === 0) return null;
220
223
  return event.data.message;
@@ -227,14 +230,33 @@ function isRecord(value) {
227
230
  return typeof value === "object" && value !== null && !Array.isArray(value);
228
231
  }
229
232
  /**
230
- * Reject noncanonical request-header fields and contradictory tool failure metadata.
233
+ * Reject noncanonical request-header fields, developer roles/content, and contradictory tool failure metadata.
231
234
  * This does not validate complete event payloads or embedded provider streams.
232
235
  * @param event - event whose locally related payload fields are inspected.
233
236
  * @param subject - event location to include in validation errors.
234
- * @throws when request data/header is not an object, optional header fields are empty, or tool failure metadata contradicts its message.
237
+ * @throws when request-header fields, developer roles/content, or tool failure metadata are invalid.
235
238
  */
236
239
  function validateSessionEventData(event, subject) {
237
240
  const data = event.data;
241
+ if (SURFACE_EVENT_TYPES.has(event.type) && isRecord(data)) {
242
+ const message = event.type === "user/message" ? data : data["message"];
243
+ if (isRecord(message)) {
244
+ if (event.type === "developer/message" !== (message["role"] === "developer")) throw new Error(`${subject} developer/message and developer role must occur together`);
245
+ if (message["role"] !== "developer" && Array.isArray(message["content"]) && message["content"].some((block) => isRecord(block) && (block["type"] === "tool-addition" || block["type"] === "tool-removal"))) throw new Error(`${subject} tool-change blocks require developer role`);
246
+ if (event.type === "developer/message" && Array.isArray(message["content"])) {
247
+ let hasAdditions = false;
248
+ for (const block of message["content"]) {
249
+ if (!isRecord(block) || block["type"] !== "tool-addition" && block["type"] !== "tool-removal") continue;
250
+ if (typeof block["toolName"] !== "string" || block["toolName"].length === 0) throw new Error(`${subject} ${block["type"]} requires a nonempty toolName`);
251
+ if (block["type"] === "tool-addition") {
252
+ hasAdditions = true;
253
+ if (Object.hasOwn(block, "tool")) throw new Error(`${subject} tool-addition must omit inline tool definitions`);
254
+ }
255
+ }
256
+ if (hasAdditions ? !isEventSeq(data["headerSeq"]) : Object.hasOwn(data, "headerSeq")) throw new Error(`${subject} requires headerSeq exactly when tool additions are present`);
257
+ }
258
+ }
259
+ }
238
260
  if (event.type === "request/header") {
239
261
  if (!isRecord(data)) throw new Error(`${subject} data must be an object`);
240
262
  const header = data["header"];
@@ -247,9 +269,7 @@ function validateSessionEventData(event, subject) {
247
269
  if (!isRecord(data)) throw new Error(`${subject} data must be an object`);
248
270
  if (data["error"] === void 0) return;
249
271
  const message = data["message"];
250
- const content = isRecord(message) ? message["content"] : void 0;
251
- const block = Array.isArray(content) ? content[0] : void 0;
252
- if (!isRecord(block) || block["isError"] !== true) throw new Error(`${subject} error requires message content[0].isError === true`);
272
+ if (!isRecord(message) || message["isError"] !== true) throw new Error(`${subject} error requires message.isError === true`);
253
273
  }
254
274
  }
255
275
  /** Create an empty surface fold state. */
@@ -307,6 +327,23 @@ function assertSourceEventReferences(event, shadowedSeqs) {
307
327
  const missing = shadowedSeqs.filter((seq) => !sources.has(seq));
308
328
  if (missing.length > 0) throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(", ")}`);
309
329
  }
330
+ /** Resolve tool additions against their immutable historical request header. */
331
+ function assertDeveloperHeader(event, events, baseSeq) {
332
+ if (event.type !== "developer/message") return;
333
+ validateSessionEventData(event, `developer/message at seq ${event.seq}`);
334
+ if (event.data.headerSeq === void 0) return;
335
+ const headerSeq = event.data.headerSeq;
336
+ const headerEvent = events[headerSeq - baseSeq];
337
+ if (headerSeq >= event.seq || headerEvent?.type !== "request/header") throw new Error("developer/message headerSeq must reference an earlier request/header");
338
+ for (const block of event.data.message.content) {
339
+ if (block.type !== "tool-addition") continue;
340
+ const definitions = headerEvent.data.header.tools?.filter((tool) => tool.name === block.toolName) ?? [];
341
+ if (definitions.length !== 1) throw new Error(`developer/message tool-addition "${block.toolName}" must name exactly one tool in headerSeq ${headerSeq}`);
342
+ const definition = definitions[0];
343
+ if (typeof definition.description !== "string" || !isRecord(definition.parameters)) throw new Error(`developer/message tool-addition "${block.toolName}" requires a complete tool definition in headerSeq ${headerSeq}`);
344
+ if (Object.hasOwn(definition, "deferLoading") && definition.deferLoading !== true) throw new Error("developer/message referenced tool deferLoading must be true when present");
345
+ }
346
+ }
310
347
  /**
311
348
  * Validate one event's surface metadata without checking membership in a log or surface.
312
349
  * @param event - event whose marker and source sequence values are inspected.
@@ -359,21 +396,13 @@ function assertToolResultRewrite(event, shadowedSeqs, events, baseSeq) {
359
396
  if (original?.type !== "tool/result") throw new Error("tool/result surface replacement must target a current tool/result");
360
397
  const originalRest = { ...original.data };
361
398
  const replacementRest = { ...event.data };
362
- const originalResult = original.data.message.content[0];
363
- const replacementResult = event.data.message.content[0];
364
399
  originalRest["message"] = {
365
400
  ...original.data.message,
366
- content: [{
367
- ...originalResult,
368
- content: null
369
- }]
401
+ content: null
370
402
  };
371
403
  replacementRest["message"] = {
372
404
  ...event.data.message,
373
- content: [{
374
- ...replacementResult,
375
- content: null
376
- }]
405
+ content: null
377
406
  };
378
407
  if (!isDeepEqualJson(originalRest, replacementRest)) throw new Error("tool/result surface replacement may change only content");
379
408
  }
@@ -393,6 +422,7 @@ function assertSystemHeadRewrite(event, state, startIdx, shadowedSeqs, events, b
393
422
  function planSurfaceEvent(state, event, expectedSeq, events, baseSeq, projections) {
394
423
  if (event.seq !== expectedSeq) throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`);
395
424
  const surfaceOp = validateSurfaceMetadata(event);
425
+ assertDeveloperHeader(event, events, baseSeq);
396
426
  const projection = projections.find((item) => item.type === event.type);
397
427
  if (projection !== void 0) return {
398
428
  kind: "project",
@@ -607,64 +637,49 @@ function foldRequestHeader(events, from) {
607
637
  return state;
608
638
  }
609
639
  //#endregion
610
- //#region lib/types/preparation.js
611
- /**
612
- * Ownership of one unpublished Session before registry publication.
613
- * @module @deepseek-ai/dsh-session/preparation
614
- */
615
- /**
616
- * One exact unpublished Session and the provider state that keeps it usable.
617
- * Disposal is synchronous and idempotent. Providers decide whether release
618
- * returns the Session to a cache or discards it; publication may consume that
619
- * state before disposal, making the callback a no-op.
620
- */
621
- var SessionPreparation = class SessionPreparation {
622
- options;
623
- released = false;
624
- /** The exact Session to use for setup and publication. */
625
- session;
626
- constructor(session, options) {
627
- this.options = options;
628
- this.session = session;
629
- }
630
- /**
631
- * Wrap an unpublished Session in one preparation lifetime.
632
- * @param session - exact unpublished Session.
633
- * @param options - optional provider release behavior.
634
- * @returns a preparation disposed after publication or rollback.
635
- */
636
- static create(session, options) {
637
- return new SessionPreparation(session, options ?? {});
638
- }
639
- /** Release provider state once when this preparation leaves its caller. */
640
- [Symbol.dispose]() {
641
- if (this.released) return;
642
- this.released = true;
643
- this.options.release?.();
644
- }
645
- };
646
- //#endregion
647
640
  //#region lib/types/repair.js
648
641
  /**
649
- * Crash-recovery repair for an interrupted session log. It preserves a fully
650
- * written final turn and supplies the missing tool, step, and turn boundaries
651
- * needed to resume with a provider-valid transcript.
642
+ * Synthetic closer events that balance a session log whose tail turn is open.
643
+ * Two producers share the mechanism: crash recovery closes an interrupted
644
+ * persisted log on reload, and fork-seed construction closes a prefix cut
645
+ * inside the source's open turn. Both preserve every fully written event and
646
+ * close the unfinished step and turn. Calls in already closed steps remain
647
+ * unchanged, including any missing results.
652
648
  * @module @deepseek-ai/dsh-session/repair
653
649
  */
654
650
  /** Recovery code for an assistant tool request that never reached a recorded call start. */
655
651
  const TOOL_NOT_STARTED = "TOOL_NOT_STARTED";
656
652
  /** Recovery code for a recorded tool call whose completed outcome was not durably recorded. */
657
653
  const TOOL_OUTCOME_UNKNOWN = "TOOL_OUTCOME_UNKNOWN";
654
+ /** Model-visible wording of the synthetic error tool results, keyed by cause. */
655
+ const CLOSER_TEXT = {
656
+ interrupted: {
657
+ started: "The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.",
658
+ notStarted: "The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed."
659
+ },
660
+ forked: {
661
+ started: "The history inherited by this branch records this tool call starting but does not include its result. The parent session may have completed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.",
662
+ notStarted: "The history inherited by this branch has no record of this tool call starting. The parent session may have executed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."
663
+ }
664
+ };
658
665
  /**
659
666
  * Return deterministic synthetic events that close an open tail turn. Unmatched
660
- * calls receive error results first, followed by an open `step/end` and an
661
- * interrupted `turn/end`; sequences continue the log and timestamps reuse the
662
- * last real event. A balanced or empty log returns no events.
667
+ * calls in its open step receive error results, followed by `step/end` and a
668
+ * `turn/end` carrying the cause's reason. Calls in closed steps remain unchanged.
669
+ * Sequences continue the log and timestamps reuse the last real event. A balanced or empty log returns no
670
+ * events.
663
671
  *
664
- * @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
672
+ * Package-internal: each cause has exactly one owner, so external callers go
673
+ * through {@link interruptedTurnClosers} (persistence crash recovery) or
674
+ * `buildForkSeed` in `./fork.ts` (fork seeds) instead of selecting a cause.
675
+ *
676
+ * @param events - the log to scan: a valid committed prefix, possibly ending
677
+ * inside an open turn (a crash tail or a mid-turn fork cut).
678
+ * @param cause - why the turn is being closed; selects the `turn/end` reason
679
+ * and the model-visible wording of synthetic error tool results.
665
680
  * @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
666
681
  */
667
- function interruptedTurnClosers(events) {
682
+ function openTurnClosers(events, cause) {
668
683
  let openTurn = null;
669
684
  let openStep = null;
670
685
  const pendingCalls = /* @__PURE__ */ new Map();
@@ -705,23 +720,21 @@ function interruptedTurnClosers(events) {
705
720
  let seq = last.seq + 1;
706
721
  const time = last.time;
707
722
  const closers = [];
723
+ const text = CLOSER_TEXT[cause.kind];
708
724
  for (const [callId, { step, callSeq }] of pendingCalls) {
709
725
  const started = callSeq !== void 0;
710
726
  const message = deepFreeze({
711
- id: brandString(`interrupted-tool-result-${callId}-${seq}`),
712
- role: "user",
727
+ id: brandString(`${cause.kind}-tool-result-${callId}-${seq}`),
728
+ role: "tool",
729
+ toolCallId: callId,
730
+ isError: true,
713
731
  source: {
714
732
  kind: "tool",
715
733
  callId
716
734
  },
717
735
  content: [{
718
- type: "tool-result",
719
- toolCallId: callId,
720
- isError: true,
721
- content: [{
722
- type: "text",
723
- text: started ? "The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly." : "The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed."
724
- }]
736
+ type: "text",
737
+ text: started ? text.started : text.notStarted
725
738
  }]
726
739
  });
727
740
  closers.push({
@@ -759,11 +772,87 @@ function interruptedTurnClosers(events) {
759
772
  time,
760
773
  data: {
761
774
  turn: openTurn,
762
- reason: { kind: "interrupted" }
775
+ reason: { kind: cause.kind }
763
776
  }
764
777
  });
765
778
  return closers;
766
779
  }
780
+ /**
781
+ * Crash-recovery entry point: synthetic closers that balance a persisted log
782
+ * whose tail turn was interrupted. Used by crash-recovery callers; fork
783
+ * seeds receive their `forked`-cause closers through `buildForkSeed` in
784
+ * `./fork.ts`, and cause selection stays internal to those two owners.
785
+ *
786
+ * @param events - the persisted log to scan, possibly ending inside an open turn.
787
+ * @returns the synthetic `interrupted` closer events to append after `events`; empty when the log is already balanced.
788
+ */
789
+ function interruptedTurnClosers(events) {
790
+ return openTurnClosers(events, { kind: "interrupted" });
791
+ }
792
+ //#endregion
793
+ //#region lib/types/fork.js
794
+ /**
795
+ * Fork seed construction over an exact source-event prefix.
796
+ * @module @deepseek-ai/dsh-session/fork
797
+ */
798
+ /**
799
+ * Copy an inclusive event prefix, mark its inherited cut, and close its open tail with forked results
800
+ * and step/turn endings. Closed steps and turns are preserved unchanged.
801
+ * The caller validates that the boundary is an existing contiguous event seq;
802
+ * Session construction snapshots the borrowed events before publication.
803
+ *
804
+ * @param events - source log with contiguous seqs from zero.
805
+ * @param boundary - inclusive source event seq the child inherits through.
806
+ * @returns a new array retaining the source event objects, followed by synthetic
807
+ * closers outside the inherited prefix counted by `inheritedEventCount`.
808
+ */
809
+ function buildForkSeed(events, boundary) {
810
+ const prefix = events.slice(0, boundary + 1);
811
+ prefix.push({
812
+ type: "session/end-seed",
813
+ seq: SessionSeq(boundary + 1),
814
+ time: events[boundary].time,
815
+ data: { inherited: true }
816
+ });
817
+ return prefix.concat(openTurnClosers(prefix, { kind: "forked" }));
818
+ }
819
+ //#endregion
820
+ //#region lib/types/preparation.js
821
+ /**
822
+ * Ownership of one unpublished Session before registry publication.
823
+ * @module @deepseek-ai/dsh-session/preparation
824
+ */
825
+ /**
826
+ * One exact unpublished Session and the provider state that keeps it usable.
827
+ * Disposal is synchronous and idempotent. Providers decide whether release
828
+ * returns the Session to a cache or discards it; publication may consume that
829
+ * state before disposal, making the callback a no-op.
830
+ */
831
+ var SessionPreparation = class SessionPreparation {
832
+ options;
833
+ released = false;
834
+ /** The exact Session to use for setup and publication. */
835
+ session;
836
+ constructor(session, options) {
837
+ this.options = options;
838
+ this.session = session;
839
+ }
840
+ /**
841
+ * Wrap an unpublished Session in one preparation lifetime.
842
+ * @param session - exact unpublished Session.
843
+ * @param options - optional provider release behavior.
844
+ * @returns a preparation disposed after publication or rollback.
845
+ */
846
+ static create(session, options) {
847
+ return new SessionPreparation(session, options ?? {});
848
+ }
849
+ /** Release provider state once when this preparation leaves its caller. */
850
+ [Symbol.dispose]() {
851
+ if (this.released) return;
852
+ this.released = true;
853
+ this.options.release?.();
854
+ }
855
+ };
767
856
  //#endregion
768
857
  //#region lib/types/seq-ranges.js
769
858
  /** Lossless range encoding for JSONL `sourceEventSeqs` arrays. */
@@ -834,7 +923,7 @@ function validateSessionHeader(id, input) {
834
923
  if (input === null || typeof input !== "object" || Array.isArray(input)) throw new Error("session header is not a plain JSON record");
835
924
  const record = input;
836
925
  if (Object.hasOwn(record, "seedLength")) throw new Error("session header has invalid field \"seedLength\"");
837
- if (record.version !== 3) throw new Error(`session header version must be 3, got ${String(record.version)}`);
926
+ if (record.version !== 4) throw new Error(`session header version must be 4, got ${String(record.version)}`);
838
927
  if (record.id !== id) throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`);
839
928
  if (typeof record.createdAt !== "number" || !Number.isSafeInteger(record.createdAt) || record.createdAt < 0) throw new Error("session header createdAt must be a non-negative safe integer");
840
929
  if (record.cwd !== void 0) {
@@ -859,7 +948,7 @@ function validateRestoredSessionHeader(id, input) {
859
948
  /** Detach, validate, and freeze the creation metadata published by a session. */
860
949
  function snapshotSessionHeader(id, source) {
861
950
  const snapshot = snapshotJsonValue(source === void 0 ? {
862
- version: 3,
951
+ version: 4,
863
952
  id,
864
953
  createdAt: Date.now(),
865
954
  isSeeded: false
@@ -884,6 +973,7 @@ function adoptSessionEvent(event) {
884
973
  case "user/message":
885
974
  deepFreeze(event.data);
886
975
  break;
976
+ case "developer/message":
887
977
  case "system/message":
888
978
  case "assistant/message":
889
979
  case "tool/result":
@@ -922,6 +1012,7 @@ function assertSessionEventEnvelope(value, index) {
922
1012
  validateSessionEventData(event, `seed ${type} at index ${index}`);
923
1013
  switch (type) {
924
1014
  case "request/header":
1015
+ case "developer/message":
925
1016
  case "system/message":
926
1017
  case "user/message":
927
1018
  case "assistant/attempt":
@@ -970,15 +1061,16 @@ function assertAdapterDefaults(value, config, index) {
970
1061
  const defaults = value;
971
1062
  if (Object.keys(defaults).some((key) => !allowedAdapterKeys.has(key)) || Object.values(defaults).some((marker) => marker !== true) || defaults["reasoningEffort"] === true && config["reasoningEffort"] === void 0 || defaults["maxTokens"] === true && config["maxTokens"] === void 0) throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`);
972
1063
  }
973
- /** The four surface event types whose payload carries an identified message. */
1064
+ /** The surface event types whose payload carries an identified message. */
974
1065
  function isMessageEventType(type) {
975
- return type === "system/message" || type === "user/message" || type === "assistant/message" || type === "tool/result";
1066
+ return type === "developer/message" || type === "system/message" || type === "user/message" || type === "assistant/message" || type === "tool/result";
976
1067
  }
977
1068
  const MESSAGE_ROLE_BY_TYPE = {
978
1069
  "system/message": "system",
1070
+ "developer/message": "developer",
979
1071
  "user/message": "user",
980
1072
  "assistant/message": "assistant",
981
- "tool/result": "user"
1073
+ "tool/result": "tool"
982
1074
  };
983
1075
  /** Validate only the event-specific invariants needed to safely replay a message. */
984
1076
  function assertMessageEventShape(event, subject) {
@@ -996,7 +1088,7 @@ function assertMessageEventShape(event, subject) {
996
1088
  if (!Array.isArray(messageRecord["content"])) throw new Error(`${subject} message has invalid content`);
997
1089
  const sourceRecord = source;
998
1090
  if (type === "system/message") {
999
- if (sourceRecord["kind"] !== "plugin" || typeof sourceRecord["plugin"] !== "string" || sourceRecord["plugin"] === "") throw new Error(`${subject} message must have plugin source`);
1091
+ if (sourceRecord["kind"] !== "system-prompt") throw new Error(`${subject} message must have system-prompt source`);
1000
1092
  return;
1001
1093
  }
1002
1094
  if (type === "assistant/message") {
@@ -1005,10 +1097,7 @@ function assertMessageEventShape(event, subject) {
1005
1097
  }
1006
1098
  if (type !== "tool/result") return;
1007
1099
  if (sourceRecord["kind"] !== "tool" || typeof sourceRecord["callId"] !== "string" || sourceRecord["callId"] === "") throw new Error(`${subject} message must have tool source`);
1008
- const content = messageRecord["content"];
1009
- const block = content[0];
1010
- if (content.length !== 1 || typeof block !== "object" || block === null || block["type"] !== "tool-result" || !Array.isArray(block["content"])) throw new Error(`${subject} message must contain one tool-result block`);
1011
- if (block["toolCallId"] !== sourceRecord["callId"]) throw new Error(`${subject} message has mismatched tool call ids`);
1100
+ if (messageRecord["toolCallId"] !== sourceRecord["callId"]) throw new Error(`${subject} message has mismatched tool call ids`);
1012
1101
  }
1013
1102
  /** Whether an unknown value carries the current provider/model pair. */
1014
1103
  function hasProviderModel(value) {
@@ -1065,30 +1154,26 @@ var Session = class Session {
1065
1154
  return this.header.id;
1066
1155
  }
1067
1156
  /**
1068
- * The first seq appended IN THIS PROCESS: the length of the constructor
1069
- * seed (0 without one). Events with smaller seq values entered through
1070
- * construction replay, fork, or resume and were never published on the
1071
- * `session/event` firehose (constructor seeds do not emit). This offset marks
1072
- * the constructor-input boundary for lifecycle ownership and persistence
1073
- * adoption; consumers that need complete canonical history still start at
1074
- * seq 0. Distinct from {@link inheritedEventCount}, the DURABLE
1075
- * fork-lineage cut: a resumed session's constructor seed is its full stored
1076
- * log, while the inherited count keeps the original fork value — this field is the
1077
- * in-process construction fact.
1157
+ * The constructor seed length (0 without one), before any marker appended
1158
+ * during construction. Seed events never publish on `session/event`. A
1159
+ * marker appended before the store attaches occupies this seq without
1160
+ * publishing either; otherwise this seq is available for the next append.
1078
1161
  *
1079
- * Not persisted itself: a seeded session projects it into the log as the
1080
- * `session/end-seed` event, which is what a consumer reading STORED history
1081
- * reads. Locate the LAST such event, not necessarily one at this seq — a
1082
- * seed already ending in one is not re-marked, so reopening an untouched
1083
- * session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
1084
- * this field in-process: it is exact before the marker reaches storage.
1085
- *
1086
- * When this lifecycle appends the marker, it occupies this seq before the
1087
- * store attaches and therefore does not publish either. Otherwise this seq
1088
- * holds an ordinary published write.
1162
+ * This in-process offset is not persisted. A fork seed can already contain
1163
+ * the child's inherited marker and synthetic closers, so its child-owned
1164
+ * history starts at {@link inheritedEventCount}, before this offset. A
1165
+ * resumed Session's seed contains its full stored log, while its inherited
1166
+ * count keeps the durable fork cut. Consumers needing complete canonical
1167
+ * history start at seq 0.
1089
1168
  */
1090
1169
  firstLiveSeq;
1091
1170
  /**
1171
+ * First event produced for this object lifecycle. A new fork includes its
1172
+ * child-owned seed marker and closers; a restored Session starts after its
1173
+ * complete stored prefix. This in-process capture offset is not persisted.
1174
+ */
1175
+ firstLifecycleSeq;
1176
+ /**
1092
1177
  * Create a detached session by validating and snapshotting borrowed seed
1093
1178
  * events and storage metadata.
1094
1179
  * @param id - session identity.
@@ -1142,10 +1227,14 @@ var Session = class Session {
1142
1227
  const inheritedEventCount = SessionLogOffset(suppliedInheritedEventCount ?? 0);
1143
1228
  if (!this.header.isSeeded && inheritedEventCount !== 0) throw new Error("unseeded session inherited event count must be 0");
1144
1229
  if (inheritedEventCount > this.log.length) throw new Error("session inherited event count exceeds its event log");
1145
- if (mode === "snapshot" && this.header.isSeeded && inheritedEventCount !== this.log.length) throw new Error("seeded session constructor seed must equal its inherited prefix");
1230
+ const seedMarker = this.log[inheritedEventCount];
1231
+ const markedSeed = seedMarker?.type === "session/end-seed" && seedMarker.data.inherited === true;
1232
+ if (mode === "snapshot" && this.header.isSeeded && inheritedEventCount !== this.log.length && !markedSeed) throw new Error("seeded session constructor seed must equal its inherited prefix or mark its inherited cut");
1233
+ if (markedSeed && this.log.slice(inheritedEventCount + 1).some((event) => event.type === "session/end-seed" && event.data.inherited === true)) throw new Error("session inherited event count must identify the final inherited marker");
1146
1234
  this.inheritedEventCount = inheritedEventCount;
1147
- if (seed !== void 0 && mode === "snapshot" && this.header.isSeeded) this.append("session/end-seed", { inherited: true });
1148
- else if (seed !== void 0 && this.log.at(-1)?.type !== "session/end-seed") this.append("session/end-seed", {});
1235
+ this.firstLifecycleSeq = mode === "snapshot" && this.header.isSeeded ? inheritedEventCount : this.firstLiveSeq;
1236
+ if (seed !== void 0 && mode === "snapshot" && this.header.isSeeded && !markedSeed) this.append("session/end-seed", { inherited: true });
1237
+ else if (seed !== void 0 && !(mode === "snapshot" && this.header.isSeeded) && this.log.at(-1)?.type !== "session/end-seed") this.append("session/end-seed", {});
1149
1238
  }
1150
1239
  /** Cached immutable full snapshot of the private append-only log. */
1151
1240
  eventsSnapshot;
@@ -1479,7 +1568,7 @@ var SessionStore = class extends Service {
1479
1568
  const seed = options?.seed;
1480
1569
  const meta = options?.meta;
1481
1570
  const header = {
1482
- version: 3,
1571
+ version: 4,
1483
1572
  id: sessionId,
1484
1573
  createdAt: meta?.createdAt ?? Date.now(),
1485
1574
  ...meta?.cwd === void 0 ? {} : { cwd: meta.cwd },
@@ -1651,10 +1740,12 @@ var SessionStore = class extends Service {
1651
1740
  return [...this.store.values()].map((entry) => entry.session);
1652
1741
  }
1653
1742
  /**
1654
- * Create a live child session from a stable prefix of a live source.
1743
+ * Create a live child session from an exact prefix of a live source.
1655
1744
  * `boundary` is an inclusive source event seq; omitted means the source's
1656
- * current last event. The selected slice may end with a between-turn event
1657
- * but must not end inside an open turn.
1745
+ * current last event. An open tail receives synthetic tool results and
1746
+ * step/turn closers with the forked cause. Closed steps and turns remain
1747
+ * unchanged, including any failed tool calls already missing results.
1748
+ * `inheritedEventCount` counts only copied source events, excluding these closers.
1658
1749
  *
1659
1750
  * @param source - Live source session object or id.
1660
1751
  * @param boundary - Inclusive source event seq to fork through; omitted means
@@ -1667,10 +1758,12 @@ var SessionStore = class extends Service {
1667
1758
  fork(source, boundary, childSessionId) {
1668
1759
  if (childSessionId !== void 0 && this.get(childSessionId) !== void 0) throw new SessionForkError(`session "${childSessionId}" already exists`, "SESSION_ALREADY_EXISTS");
1669
1760
  const liveSource = this._resolveForkSource(source);
1670
- const seed = this._forkSeed(liveSource, boundary);
1761
+ const events = liveSource.snapshotEvents();
1762
+ const resolved = this._forkBoundary(liveSource.id, events, boundary);
1763
+ const seed = resolved === void 0 ? [] : buildForkSeed(events, resolved);
1671
1764
  return this.create(childSessionId, {
1672
1765
  seed,
1673
- inheritedEventCount: SessionLogOffset(seed.length),
1766
+ inheritedEventCount: SessionLogOffset(resolved === void 0 ? 0 : resolved + 1),
1674
1767
  meta: {
1675
1768
  ...liveSource.header.cwd !== void 0 ? { cwd: liveSource.header.cwd } : {},
1676
1769
  parentSession: liveSource.id,
@@ -1678,25 +1771,22 @@ var SessionStore = class extends Service {
1678
1771
  }
1679
1772
  });
1680
1773
  }
1681
- _forkSeed(session, requestedBoundary) {
1682
- const lastEvent = session.snapshotEvents().at(-1);
1774
+ _forkBoundary(sessionId, events, requestedBoundary) {
1775
+ const lastEvent = events.at(-1);
1683
1776
  let boundary;
1684
1777
  if (requestedBoundary !== void 0) boundary = requestedBoundary;
1685
1778
  else {
1686
- if (lastEvent === void 0) return [];
1779
+ if (lastEvent === void 0) return void 0;
1687
1780
  boundary = lastEvent.seq;
1688
1781
  }
1689
- if (!Number.isSafeInteger(boundary) || boundary < 0) throw new SessionForkError(`fork boundary for session "${session.id}" must be a non-negative safe integer, got ${String(boundary)}`, "INVALID_BOUNDARY");
1690
- if (boundary >= session.seq) {
1782
+ if (!Number.isSafeInteger(boundary) || boundary < 0) throw new SessionForkError(`fork boundary for session "${sessionId}" must be a non-negative safe integer, got ${String(boundary)}`, "INVALID_BOUNDARY");
1783
+ if (boundary >= events.length) {
1691
1784
  const lastSeq = lastEvent?.seq;
1692
- throw new SessionForkError(`fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? "none"})`, "INVALID_BOUNDARY");
1785
+ throw new SessionForkError(`fork boundary ${boundary} does not exist in session "${sessionId}" (last seq: ${lastSeq ?? "none"})`, "INVALID_BOUNDARY");
1693
1786
  }
1694
- const boundaryEvent = session.eventAt(boundary);
1695
- if (boundaryEvent === void 0 || boundaryEvent.seq !== boundary) throw new SessionForkError(`fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`, "INVALID_BOUNDARY");
1696
- const events = session.snapshotEvents(SessionLogOffset(0), SessionLogOffset(boundary + 1));
1697
- const lastTurnBoundary = events.findLast((event) => event.type === "turn/start" || event.type === "turn/end");
1698
- if (lastTurnBoundary?.type === "turn/start") throw new SessionForkError(`fork boundary ${boundary} in session "${session.id}" ends inside open turn ${lastTurnBoundary.data.turn}`, "OPEN_TURN");
1699
- return events;
1787
+ const boundaryEvent = events[boundary];
1788
+ if (boundaryEvent === void 0 || boundaryEvent.seq !== boundary) throw new SessionForkError(`fork boundary ${boundary} does not match a contiguous event seq in session "${sessionId}"`, "INVALID_BOUNDARY");
1789
+ return boundary;
1700
1790
  }
1701
1791
  _resolveForkSource(source) {
1702
1792
  if (typeof source === "string") {
@@ -1711,4 +1801,4 @@ var SessionStore = class extends Service {
1711
1801
  }
1712
1802
  };
1713
1803
  //#endregion
1714
- export { KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, Session, SessionForkError, SessionId, SessionLogOffset, SessionPreparation, SessionSeq, SessionStore, SessionStore as default, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN, adoptSessionEvent, canonicalHeader, decodeSeqRanges, deriveEventMessage, encodeSeqRanges, foldRequestHeader, foldSurface, headerEquals, interruptedTurnClosers, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEligibleType, isSurfaceEvent, snapshotSessionEvent };
1804
+ export { KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, Session, SessionForkError, SessionId, SessionLogOffset, SessionPreparation, SessionSeq, SessionStore, SessionStore as default, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN, adoptSessionEvent, buildForkSeed, canonicalHeader, decodeSeqRanges, deriveEventMessage, encodeSeqRanges, foldRequestHeader, foldSurface, headerEquals, interruptedTurnClosers, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEligibleType, isSurfaceEvent, snapshotSessionEvent };
package/lib/invariant.js CHANGED
@@ -44,6 +44,9 @@ function validateEvent(trace, event, fail) {
44
44
  if (event.data.step !== trace.nextStep) fail(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`);
45
45
  openStep = event.data.step;
46
46
  break;
47
+ case "developer/message":
48
+ requireOpenStep(trace, "developer/message", event.data.turn, event.data.step, fail);
49
+ break;
47
50
  case "step/end":
48
51
  requireOpenStep(trace, "step/end", event.data.turn, event.data.step, fail);
49
52
  pendingCalls = { kind: "clear" };
@@ -70,7 +73,7 @@ function validateEvent(trace, event, fail) {
70
73
  }
71
74
  requireOpenStep(trace, "tool/result", event.data.turn, event.data.step, fail);
72
75
  const callId = event.data.message.source.callId;
73
- const syntheticNotStarted = event.data.message.content[0].isError === true && event.data.error?.code === "TOOL_NOT_STARTED";
76
+ const syntheticNotStarted = event.data.message.isError === true && event.data.error?.code === "TOOL_NOT_STARTED";
74
77
  if (!trace.pendingCalls.has(callId) && !syntheticNotStarted) fail(`tool/result for ${callId} with no prior tool/call in this step`);
75
78
  pendingCalls = {
76
79
  kind: "delete",
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Fork seed construction over an exact source-event prefix.
3
+ * @module @deepseek-ai/dsh-session/fork
4
+ */
5
+ import type { SessionEvent, SessionSeq as SessionSeqType } from './types.ts';
6
+ /**
7
+ * Copy an inclusive event prefix, mark its inherited cut, and close its open tail with forked results
8
+ * and step/turn endings. Closed steps and turns are preserved unchanged.
9
+ * The caller validates that the boundary is an existing contiguous event seq;
10
+ * Session construction snapshots the borrowed events before publication.
11
+ *
12
+ * @param events - source log with contiguous seqs from zero.
13
+ * @param boundary - inclusive source event seq the child inherits through.
14
+ * @returns a new array retaining the source event objects, followed by synthetic
15
+ * closers outside the inherited prefix counted by `inheritedEventCount`.
16
+ */
17
+ export declare function buildForkSeed(events: readonly SessionEvent[], boundary: SessionSeqType): SessionEvent[];
18
+ //# sourceMappingURL=fork.d.ts.map