@deepseek-ai/dsh-session 0.1.6-alpha.1 → 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",
@@ -133,7 +134,8 @@ const KNOWN_SESSION_EVENT_TYPES = new Set([
133
134
  "turn/end",
134
135
  "turn/start",
135
136
  "user/message",
136
- "web/deepseek-search-llm-request"
137
+ "web/deepseek-search-llm-request",
138
+ "workspace/changes"
137
139
  ]);
138
140
  /** Event types whose model-visible effects require an explicit pure interpreter. */
139
141
  const MESSAGE_PROJECTION_EVENT_TYPES = new Set(["image/offload"]);
@@ -151,6 +153,7 @@ const MESSAGE_PROJECTION_EVENT_TYPES = new Set(["image/offload"]);
151
153
  /** Runtime counterpart of the message-producing event union. */
152
154
  const SURFACE_EVENT_TYPES = new Set([
153
155
  "system/message",
156
+ "developer/message",
154
157
  "user/message",
155
158
  "assistant/message",
156
159
  "tool/result"
@@ -158,7 +161,7 @@ const SURFACE_EVENT_TYPES = new Set([
158
161
  /**
159
162
  * Whether an event type can join the model-visible surface.
160
163
  * @param type - event type to test.
161
- * @returns true for one of the four message-producing event types.
164
+ * @returns true for one of the message-producing event types.
162
165
  */
163
166
  function isSurfaceEligibleType(type) {
164
167
  return SURFACE_EVENT_TYPES.has(type);
@@ -199,7 +202,7 @@ function isReplacementSurfaceEvent(event) {
199
202
  /**
200
203
  * Project a single event into the LLM message it derives to, or null when it
201
204
  * produces none — a non-surface event (attempt, boundary, log-only record) or an
202
- * empty-content assistant/message (which exists only to host usage). A caller
205
+ * empty-content system, developer, or assistant message. A caller
203
206
  * reconstructing model input supplies the same prefix's `projectedMessages`
204
207
  * from {@link foldSurface}; without that map this function reads original
205
208
  * event content. Session instance methods apply the live projection. Messages
@@ -214,6 +217,7 @@ function deriveEventMessage(event, projectedMessages) {
214
217
  switch (event.type) {
215
218
  case "user/message": return event.data;
216
219
  case "system/message":
220
+ case "developer/message":
217
221
  case "assistant/message":
218
222
  if (event.data.message.content.length === 0) return null;
219
223
  return event.data.message;
@@ -226,14 +230,33 @@ function isRecord(value) {
226
230
  return typeof value === "object" && value !== null && !Array.isArray(value);
227
231
  }
228
232
  /**
229
- * Reject noncanonical request-header fields and contradictory tool failure metadata.
233
+ * Reject noncanonical request-header fields, developer roles/content, and contradictory tool failure metadata.
230
234
  * This does not validate complete event payloads or embedded provider streams.
231
235
  * @param event - event whose locally related payload fields are inspected.
232
236
  * @param subject - event location to include in validation errors.
233
- * @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.
234
238
  */
235
239
  function validateSessionEventData(event, subject) {
236
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
+ }
237
260
  if (event.type === "request/header") {
238
261
  if (!isRecord(data)) throw new Error(`${subject} data must be an object`);
239
262
  const header = data["header"];
@@ -246,9 +269,7 @@ function validateSessionEventData(event, subject) {
246
269
  if (!isRecord(data)) throw new Error(`${subject} data must be an object`);
247
270
  if (data["error"] === void 0) return;
248
271
  const message = data["message"];
249
- const content = isRecord(message) ? message["content"] : void 0;
250
- const block = Array.isArray(content) ? content[0] : void 0;
251
- 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`);
252
273
  }
253
274
  }
254
275
  /** Create an empty surface fold state. */
@@ -306,6 +327,23 @@ function assertSourceEventReferences(event, shadowedSeqs) {
306
327
  const missing = shadowedSeqs.filter((seq) => !sources.has(seq));
307
328
  if (missing.length > 0) throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(", ")}`);
308
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
+ }
309
347
  /**
310
348
  * Validate one event's surface metadata without checking membership in a log or surface.
311
349
  * @param event - event whose marker and source sequence values are inspected.
@@ -358,21 +396,13 @@ function assertToolResultRewrite(event, shadowedSeqs, events, baseSeq) {
358
396
  if (original?.type !== "tool/result") throw new Error("tool/result surface replacement must target a current tool/result");
359
397
  const originalRest = { ...original.data };
360
398
  const replacementRest = { ...event.data };
361
- const originalResult = original.data.message.content[0];
362
- const replacementResult = event.data.message.content[0];
363
399
  originalRest["message"] = {
364
400
  ...original.data.message,
365
- content: [{
366
- ...originalResult,
367
- content: null
368
- }]
401
+ content: null
369
402
  };
370
403
  replacementRest["message"] = {
371
404
  ...event.data.message,
372
- content: [{
373
- ...replacementResult,
374
- content: null
375
- }]
405
+ content: null
376
406
  };
377
407
  if (!isDeepEqualJson(originalRest, replacementRest)) throw new Error("tool/result surface replacement may change only content");
378
408
  }
@@ -392,6 +422,7 @@ function assertSystemHeadRewrite(event, state, startIdx, shadowedSeqs, events, b
392
422
  function planSurfaceEvent(state, event, expectedSeq, events, baseSeq, projections) {
393
423
  if (event.seq !== expectedSeq) throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`);
394
424
  const surfaceOp = validateSurfaceMetadata(event);
425
+ assertDeveloperHeader(event, events, baseSeq);
395
426
  const projection = projections.find((item) => item.type === event.type);
396
427
  if (projection !== void 0) return {
397
428
  kind: "project",
@@ -606,64 +637,49 @@ function foldRequestHeader(events, from) {
606
637
  return state;
607
638
  }
608
639
  //#endregion
609
- //#region lib/types/preparation.js
610
- /**
611
- * Ownership of one unpublished Session before registry publication.
612
- * @module @deepseek-ai/dsh-session/preparation
613
- */
614
- /**
615
- * One exact unpublished Session and the provider state that keeps it usable.
616
- * Disposal is synchronous and idempotent. Providers decide whether release
617
- * returns the Session to a cache or discards it; publication may consume that
618
- * state before disposal, making the callback a no-op.
619
- */
620
- var SessionPreparation = class SessionPreparation {
621
- options;
622
- released = false;
623
- /** The exact Session to use for setup and publication. */
624
- session;
625
- constructor(session, options) {
626
- this.options = options;
627
- this.session = session;
628
- }
629
- /**
630
- * Wrap an unpublished Session in one preparation lifetime.
631
- * @param session - exact unpublished Session.
632
- * @param options - optional provider release behavior.
633
- * @returns a preparation disposed after publication or rollback.
634
- */
635
- static create(session, options) {
636
- return new SessionPreparation(session, options ?? {});
637
- }
638
- /** Release provider state once when this preparation leaves its caller. */
639
- [Symbol.dispose]() {
640
- if (this.released) return;
641
- this.released = true;
642
- this.options.release?.();
643
- }
644
- };
645
- //#endregion
646
640
  //#region lib/types/repair.js
647
641
  /**
648
- * Crash-recovery repair for an interrupted session log. It preserves a fully
649
- * written final turn and supplies the missing tool, step, and turn boundaries
650
- * 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.
651
648
  * @module @deepseek-ai/dsh-session/repair
652
649
  */
653
650
  /** Recovery code for an assistant tool request that never reached a recorded call start. */
654
651
  const TOOL_NOT_STARTED = "TOOL_NOT_STARTED";
655
652
  /** Recovery code for a recorded tool call whose completed outcome was not durably recorded. */
656
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
+ };
657
665
  /**
658
666
  * Return deterministic synthetic events that close an open tail turn. Unmatched
659
- * calls receive error results first, followed by an open `step/end` and an
660
- * interrupted `turn/end`; sequences continue the log and timestamps reuse the
661
- * 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.
662
671
  *
663
- * @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.
664
680
  * @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
665
681
  */
666
- function interruptedTurnClosers(events) {
682
+ function openTurnClosers(events, cause) {
667
683
  let openTurn = null;
668
684
  let openStep = null;
669
685
  const pendingCalls = /* @__PURE__ */ new Map();
@@ -704,23 +720,21 @@ function interruptedTurnClosers(events) {
704
720
  let seq = last.seq + 1;
705
721
  const time = last.time;
706
722
  const closers = [];
723
+ const text = CLOSER_TEXT[cause.kind];
707
724
  for (const [callId, { step, callSeq }] of pendingCalls) {
708
725
  const started = callSeq !== void 0;
709
726
  const message = deepFreeze({
710
- id: brandString(`interrupted-tool-result-${callId}-${seq}`),
711
- role: "user",
727
+ id: brandString(`${cause.kind}-tool-result-${callId}-${seq}`),
728
+ role: "tool",
729
+ toolCallId: callId,
730
+ isError: true,
712
731
  source: {
713
732
  kind: "tool",
714
733
  callId
715
734
  },
716
735
  content: [{
717
- type: "tool-result",
718
- toolCallId: callId,
719
- isError: true,
720
- content: [{
721
- type: "text",
722
- 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."
723
- }]
736
+ type: "text",
737
+ text: started ? text.started : text.notStarted
724
738
  }]
725
739
  });
726
740
  closers.push({
@@ -758,11 +772,87 @@ function interruptedTurnClosers(events) {
758
772
  time,
759
773
  data: {
760
774
  turn: openTurn,
761
- reason: { kind: "interrupted" }
775
+ reason: { kind: cause.kind }
762
776
  }
763
777
  });
764
778
  return closers;
765
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
+ };
766
856
  //#endregion
767
857
  //#region lib/types/seq-ranges.js
768
858
  /** Lossless range encoding for JSONL `sourceEventSeqs` arrays. */
@@ -833,7 +923,7 @@ function validateSessionHeader(id, input) {
833
923
  if (input === null || typeof input !== "object" || Array.isArray(input)) throw new Error("session header is not a plain JSON record");
834
924
  const record = input;
835
925
  if (Object.hasOwn(record, "seedLength")) throw new Error("session header has invalid field \"seedLength\"");
836
- 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)}`);
837
927
  if (record.id !== id) throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`);
838
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");
839
929
  if (record.cwd !== void 0) {
@@ -858,7 +948,7 @@ function validateRestoredSessionHeader(id, input) {
858
948
  /** Detach, validate, and freeze the creation metadata published by a session. */
859
949
  function snapshotSessionHeader(id, source) {
860
950
  const snapshot = snapshotJsonValue(source === void 0 ? {
861
- version: 3,
951
+ version: 4,
862
952
  id,
863
953
  createdAt: Date.now(),
864
954
  isSeeded: false
@@ -883,6 +973,7 @@ function adoptSessionEvent(event) {
883
973
  case "user/message":
884
974
  deepFreeze(event.data);
885
975
  break;
976
+ case "developer/message":
886
977
  case "system/message":
887
978
  case "assistant/message":
888
979
  case "tool/result":
@@ -921,6 +1012,7 @@ function assertSessionEventEnvelope(value, index) {
921
1012
  validateSessionEventData(event, `seed ${type} at index ${index}`);
922
1013
  switch (type) {
923
1014
  case "request/header":
1015
+ case "developer/message":
924
1016
  case "system/message":
925
1017
  case "user/message":
926
1018
  case "assistant/attempt":
@@ -969,15 +1061,16 @@ function assertAdapterDefaults(value, config, index) {
969
1061
  const defaults = value;
970
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`);
971
1063
  }
972
- /** The four surface event types whose payload carries an identified message. */
1064
+ /** The surface event types whose payload carries an identified message. */
973
1065
  function isMessageEventType(type) {
974
- 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";
975
1067
  }
976
1068
  const MESSAGE_ROLE_BY_TYPE = {
977
1069
  "system/message": "system",
1070
+ "developer/message": "developer",
978
1071
  "user/message": "user",
979
1072
  "assistant/message": "assistant",
980
- "tool/result": "user"
1073
+ "tool/result": "tool"
981
1074
  };
982
1075
  /** Validate only the event-specific invariants needed to safely replay a message. */
983
1076
  function assertMessageEventShape(event, subject) {
@@ -995,7 +1088,7 @@ function assertMessageEventShape(event, subject) {
995
1088
  if (!Array.isArray(messageRecord["content"])) throw new Error(`${subject} message has invalid content`);
996
1089
  const sourceRecord = source;
997
1090
  if (type === "system/message") {
998
- 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`);
999
1092
  return;
1000
1093
  }
1001
1094
  if (type === "assistant/message") {
@@ -1004,10 +1097,7 @@ function assertMessageEventShape(event, subject) {
1004
1097
  }
1005
1098
  if (type !== "tool/result") return;
1006
1099
  if (sourceRecord["kind"] !== "tool" || typeof sourceRecord["callId"] !== "string" || sourceRecord["callId"] === "") throw new Error(`${subject} message must have tool source`);
1007
- const content = messageRecord["content"];
1008
- const block = content[0];
1009
- 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`);
1010
- 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`);
1011
1101
  }
1012
1102
  /** Whether an unknown value carries the current provider/model pair. */
1013
1103
  function hasProviderModel(value) {
@@ -1064,30 +1154,26 @@ var Session = class Session {
1064
1154
  return this.header.id;
1065
1155
  }
1066
1156
  /**
1067
- * The first seq appended IN THIS PROCESS: the length of the constructor
1068
- * seed (0 without one). Events with smaller seq values entered through
1069
- * construction — replay, fork, or resume — and were never published on the
1070
- * `session/event` firehose (constructor seeds do not emit). This offset marks
1071
- * the constructor-input boundary for lifecycle ownership and persistence
1072
- * adoption; consumers that need complete canonical history still start at
1073
- * seq 0. Distinct from {@link inheritedEventCount}, the DURABLE
1074
- * fork-lineage cut: a resumed session's constructor seed is its full stored
1075
- * log, while the inherited count keeps the original fork value — this field is the
1076
- * 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.
1077
1161
  *
1078
- * Not persisted itself: a seeded session projects it into the log as the
1079
- * `session/end-seed` event, which is what a consumer reading STORED history
1080
- * reads. Locate the LAST such event, not necessarily one at this seq — a
1081
- * seed already ending in one is not re-marked, so reopening an untouched
1082
- * session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
1083
- * this field in-process: it is exact before the marker reaches storage.
1084
- *
1085
- * When this lifecycle appends the marker, it occupies this seq before the
1086
- * store attaches and therefore does not publish either. Otherwise this seq
1087
- * 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.
1088
1168
  */
1089
1169
  firstLiveSeq;
1090
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
+ /**
1091
1177
  * Create a detached session by validating and snapshotting borrowed seed
1092
1178
  * events and storage metadata.
1093
1179
  * @param id - session identity.
@@ -1141,10 +1227,14 @@ var Session = class Session {
1141
1227
  const inheritedEventCount = SessionLogOffset(suppliedInheritedEventCount ?? 0);
1142
1228
  if (!this.header.isSeeded && inheritedEventCount !== 0) throw new Error("unseeded session inherited event count must be 0");
1143
1229
  if (inheritedEventCount > this.log.length) throw new Error("session inherited event count exceeds its event log");
1144
- 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");
1145
1234
  this.inheritedEventCount = inheritedEventCount;
1146
- if (seed !== void 0 && mode === "snapshot" && this.header.isSeeded) this.append("session/end-seed", { inherited: true });
1147
- 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", {});
1148
1238
  }
1149
1239
  /** Cached immutable full snapshot of the private append-only log. */
1150
1240
  eventsSnapshot;
@@ -1478,7 +1568,7 @@ var SessionStore = class extends Service {
1478
1568
  const seed = options?.seed;
1479
1569
  const meta = options?.meta;
1480
1570
  const header = {
1481
- version: 3,
1571
+ version: 4,
1482
1572
  id: sessionId,
1483
1573
  createdAt: meta?.createdAt ?? Date.now(),
1484
1574
  ...meta?.cwd === void 0 ? {} : { cwd: meta.cwd },
@@ -1650,10 +1740,12 @@ var SessionStore = class extends Service {
1650
1740
  return [...this.store.values()].map((entry) => entry.session);
1651
1741
  }
1652
1742
  /**
1653
- * 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.
1654
1744
  * `boundary` is an inclusive source event seq; omitted means the source's
1655
- * current last event. The selected slice may end with a between-turn event
1656
- * 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.
1657
1749
  *
1658
1750
  * @param source - Live source session object or id.
1659
1751
  * @param boundary - Inclusive source event seq to fork through; omitted means
@@ -1666,10 +1758,12 @@ var SessionStore = class extends Service {
1666
1758
  fork(source, boundary, childSessionId) {
1667
1759
  if (childSessionId !== void 0 && this.get(childSessionId) !== void 0) throw new SessionForkError(`session "${childSessionId}" already exists`, "SESSION_ALREADY_EXISTS");
1668
1760
  const liveSource = this._resolveForkSource(source);
1669
- 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);
1670
1764
  return this.create(childSessionId, {
1671
1765
  seed,
1672
- inheritedEventCount: SessionLogOffset(seed.length),
1766
+ inheritedEventCount: SessionLogOffset(resolved === void 0 ? 0 : resolved + 1),
1673
1767
  meta: {
1674
1768
  ...liveSource.header.cwd !== void 0 ? { cwd: liveSource.header.cwd } : {},
1675
1769
  parentSession: liveSource.id,
@@ -1677,25 +1771,22 @@ var SessionStore = class extends Service {
1677
1771
  }
1678
1772
  });
1679
1773
  }
1680
- _forkSeed(session, requestedBoundary) {
1681
- const lastEvent = session.snapshotEvents().at(-1);
1774
+ _forkBoundary(sessionId, events, requestedBoundary) {
1775
+ const lastEvent = events.at(-1);
1682
1776
  let boundary;
1683
1777
  if (requestedBoundary !== void 0) boundary = requestedBoundary;
1684
1778
  else {
1685
- if (lastEvent === void 0) return [];
1779
+ if (lastEvent === void 0) return void 0;
1686
1780
  boundary = lastEvent.seq;
1687
1781
  }
1688
- 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");
1689
- 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) {
1690
1784
  const lastSeq = lastEvent?.seq;
1691
- 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");
1692
1786
  }
1693
- const boundaryEvent = session.eventAt(boundary);
1694
- 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");
1695
- const events = session.snapshotEvents(SessionLogOffset(0), SessionLogOffset(boundary + 1));
1696
- const lastTurnBoundary = events.findLast((event) => event.type === "turn/start" || event.type === "turn/end");
1697
- if (lastTurnBoundary?.type === "turn/start") throw new SessionForkError(`fork boundary ${boundary} in session "${session.id}" ends inside open turn ${lastTurnBoundary.data.turn}`, "OPEN_TURN");
1698
- 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;
1699
1790
  }
1700
1791
  _resolveForkSource(source) {
1701
1792
  if (typeof source === "string") {
@@ -1710,4 +1801,4 @@ var SessionStore = class extends Service {
1710
1801
  }
1711
1802
  };
1712
1803
  //#endregion
1713
- 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