@deepseek-ai/dsh-session 0.1.6-alpha.2 → 0.1.7-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.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Fork seed construction over an exact source-event prefix.
3
+ * @module @deepseek-ai/dsh-session/fork
4
+ */
5
+ import { openTurnClosers } from "./repair.js";
6
+ import { SessionSeq } from "./types.js";
7
+ /**
8
+ * Copy an inclusive event prefix, mark its inherited cut, and close its open tail with forked results
9
+ * and step/turn endings. Closed steps and turns are preserved unchanged.
10
+ * The caller validates that the boundary is an existing contiguous event seq;
11
+ * Session construction snapshots the borrowed events before publication.
12
+ *
13
+ * @param events - source log with contiguous seqs from zero.
14
+ * @param boundary - inclusive source event seq the child inherits through.
15
+ * @returns a new array retaining the source event objects, followed by synthetic
16
+ * closers outside the inherited prefix counted by `inheritedEventCount`.
17
+ */
18
+ export function buildForkSeed(events, boundary) {
19
+ const prefix = events.slice(0, boundary + 1);
20
+ prefix.push({
21
+ type: 'session/end-seed', seq: SessionSeq(boundary + 1),
22
+ // oxlint-disable-next-line typescript/no-non-null-assertion -- the caller validated this exact source event.
23
+ time: events[boundary].time,
24
+ data: { inherited: true },
25
+ });
26
+ return prefix.concat(openTurnClosers(prefix, { kind: 'forked' }));
27
+ }
28
+ //# sourceMappingURL=fork.js.map
@@ -12,10 +12,11 @@ import { SessionLogOffset, SessionSeq } from './types.ts';
12
12
  import type { TypertLookup } from '@deepseek-ai/dsh-typert-protocol';
13
13
  import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SessionSeedEventState, SurfaceIntent, SurfaceEventType } from './types.ts';
14
14
  import type { SessionSurface, SessionMessageProjection } from './surface.ts';
15
+ export { buildForkSeed } from './fork.ts';
15
16
  export * from './types.ts';
16
17
  export { SessionPreparation } from './preparation.ts';
17
18
  export type { SessionPreparationOptions } from './preparation.ts';
18
- export type { AssistantMessage, SystemMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm';
19
+ export type { AssistantMessage, DeveloperMessage, SystemMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm';
19
20
  export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts';
20
21
  export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult, SessionMessageProjection, SessionMessageProjectionContext } from './surface.ts';
21
22
  export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts';
@@ -120,29 +121,25 @@ export declare class Session {
120
121
  /** The session identity, derived from its durable header's single copy. */
121
122
  get id(): SessionId;
122
123
  /**
123
- * The first seq appended IN THIS PROCESS: the length of the constructor
124
- * seed (0 without one). Events with smaller seq values entered through
125
- * construction replay, fork, or resume and were never published on the
126
- * `session/event` firehose (constructor seeds do not emit). This offset marks
127
- * the constructor-input boundary for lifecycle ownership and persistence
128
- * adoption; consumers that need complete canonical history still start at
129
- * seq 0. Distinct from {@link inheritedEventCount}, the DURABLE
130
- * fork-lineage cut: a resumed session's constructor seed is its full stored
131
- * log, while the inherited count keeps the original fork value — this field is the
132
- * in-process construction fact.
124
+ * The constructor seed length (0 without one), before any marker appended
125
+ * during construction. Seed events never publish on `session/event`. A
126
+ * marker appended before the store attaches occupies this seq without
127
+ * publishing either; otherwise this seq is available for the next append.
133
128
  *
134
- * Not persisted itself: a seeded session projects it into the log as the
135
- * `session/end-seed` event, which is what a consumer reading STORED history
136
- * reads. Locate the LAST such event, not necessarily one at this seq — a
137
- * seed already ending in one is not re-marked, so reopening an untouched
138
- * session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
139
- * this field in-process: it is exact before the marker reaches storage.
140
- *
141
- * When this lifecycle appends the marker, it occupies this seq before the
142
- * store attaches and therefore does not publish either. Otherwise this seq
143
- * holds an ordinary published write.
129
+ * This in-process offset is not persisted. A fork seed can already contain
130
+ * the child's inherited marker and synthetic closers, so its child-owned
131
+ * history starts at {@link inheritedEventCount}, before this offset. A
132
+ * resumed Session's seed contains its full stored log, while its inherited
133
+ * count keeps the durable fork cut. Consumers needing complete canonical
134
+ * history start at seq 0.
144
135
  */
145
136
  readonly firstLiveSeq: SessionLogOffset;
137
+ /**
138
+ * First event produced for this object lifecycle. A new fork includes its
139
+ * child-owned seed marker and closers; a restored Session starts after its
140
+ * complete stored prefix. This in-process capture offset is not persisted.
141
+ */
142
+ readonly firstLifecycleSeq: SessionLogOffset;
146
143
  /**
147
144
  * Create a detached session by validating and snapshotting borrowed seed
148
145
  * events and storage metadata.
@@ -307,11 +304,10 @@ export type SessionForkSource = Session | SessionId;
307
304
  * Rejection codes for session forking: the fork source id is unknown to the
308
305
  * live store (`SESSION_NOT_FOUND`) or names a session object that is not the
309
306
  * store's live instance (`SESSION_NOT_LIVE`); the requested child id is
310
- * already taken (`SESSION_ALREADY_EXISTS`); the boundary is not a contiguous
311
- * existing seq (`INVALID_BOUNDARY`); or the selected prefix ends inside an
312
- * open turn (`OPEN_TURN`).
307
+ * already taken (`SESSION_ALREADY_EXISTS`); or the boundary is not a contiguous
308
+ * existing seq (`INVALID_BOUNDARY`).
313
309
  */
314
- export type SessionForkErrorCode = 'SESSION_NOT_FOUND' | 'SESSION_NOT_LIVE' | 'SESSION_ALREADY_EXISTS' | 'INVALID_BOUNDARY' | 'OPEN_TURN';
310
+ export type SessionForkErrorCode = 'SESSION_NOT_FOUND' | 'SESSION_NOT_LIVE' | 'SESSION_ALREADY_EXISTS' | 'INVALID_BOUNDARY';
315
311
  /** Typed error for session fork rejections. */
316
312
  export declare class SessionForkError extends Error {
317
313
  readonly code: SessionForkErrorCode;
@@ -444,10 +440,12 @@ export declare class SessionStore extends Service {
444
440
  */
445
441
  list(): Session[];
446
442
  /**
447
- * Create a live child session from a stable prefix of a live source.
443
+ * Create a live child session from an exact prefix of a live source.
448
444
  * `boundary` is an inclusive source event seq; omitted means the source's
449
- * current last event. The selected slice may end with a between-turn event
450
- * but must not end inside an open turn.
445
+ * current last event. An open tail receives synthetic tool results and
446
+ * step/turn closers with the forked cause. Closed steps and turns remain
447
+ * unchanged, including any failed tool calls already missing results.
448
+ * `inheritedEventCount` counts only copied source events, excluding these closers.
451
449
  *
452
450
  * @param source - Live source session object or id.
453
451
  * @param boundary - Inclusive source event seq to fork through; omitted means
@@ -458,7 +456,7 @@ export declare class SessionStore extends Service {
458
456
  * @returns The created live child session.
459
457
  */
460
458
  fork(source: SessionForkSource, boundary?: SessionSeq, childSessionId?: SessionId): Session;
461
- private _forkSeed;
459
+ private _forkBoundary;
462
460
  private _resolveForkSource;
463
461
  }
464
462
  export { decodeSeqRanges, encodeSeqRanges } from './seq-ranges.ts';
@@ -13,6 +13,8 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope';
13
13
  import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from "./types.js";
14
14
  import { SurfaceManager, validateSessionEventData, validateSurfaceMetadata } from "./surface.js";
15
15
  import { foldRequestHeader } from "./request-header.js";
16
+ import { buildForkSeed } from "./fork.js";
17
+ export { buildForkSeed } from "./fork.js";
16
18
  export * from "./types.js";
17
19
  export { SessionPreparation } from "./preparation.js";
18
20
  export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from "./repair.js";
@@ -101,6 +103,7 @@ export function adoptSessionEvent(event) {
101
103
  case 'user/message':
102
104
  deepFreeze(event.data);
103
105
  break;
106
+ case 'developer/message':
104
107
  case 'system/message':
105
108
  case 'assistant/message':
106
109
  case 'tool/result':
@@ -153,6 +156,7 @@ function assertSessionEventEnvelope(value, index) {
153
156
  validateSessionEventData(event, `seed ${type} at index ${index}`);
154
157
  switch (type) {
155
158
  case 'request/header':
159
+ case 'developer/message':
156
160
  case 'system/message':
157
161
  case 'user/message':
158
162
  case 'assistant/attempt':
@@ -226,16 +230,17 @@ function assertAdapterDefaults(value, config, index) {
226
230
  throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`);
227
231
  }
228
232
  }
229
- /** The four surface event types whose payload carries an identified message. */
233
+ /** The surface event types whose payload carries an identified message. */
230
234
  function isMessageEventType(type) {
231
- return type === 'system/message' || type === 'user/message'
235
+ return type === 'developer/message' || type === 'system/message' || type === 'user/message'
232
236
  || type === 'assistant/message' || type === 'tool/result';
233
237
  }
234
238
  const MESSAGE_ROLE_BY_TYPE = {
235
239
  'system/message': 'system',
240
+ 'developer/message': 'developer',
236
241
  'user/message': 'user',
237
242
  'assistant/message': 'assistant',
238
- 'tool/result': 'user',
243
+ 'tool/result': 'tool',
239
244
  };
240
245
  /** Validate only the event-specific invariants needed to safely replay a message. */
241
246
  function assertMessageEventShape(event, subject) {
@@ -268,9 +273,8 @@ function assertMessageEventShape(event, subject) {
268
273
  }
269
274
  const sourceRecord = source;
270
275
  if (type === 'system/message') {
271
- if (sourceRecord['kind'] !== 'plugin' || typeof sourceRecord['plugin'] !== 'string'
272
- || sourceRecord['plugin'] === '') {
273
- throw new Error(`${subject} message must have plugin source`);
276
+ if (sourceRecord['kind'] !== 'system-prompt') {
277
+ throw new Error(`${subject} message must have system-prompt source`);
274
278
  }
275
279
  return;
276
280
  }
@@ -287,14 +291,7 @@ function assertMessageEventShape(event, subject) {
287
291
  || sourceRecord['callId'] === '') {
288
292
  throw new Error(`${subject} message must have tool source`);
289
293
  }
290
- const content = messageRecord['content'];
291
- const block = content[0];
292
- if (content.length !== 1 || typeof block !== 'object' || block === null
293
- || block['type'] !== 'tool-result'
294
- || !Array.isArray(block['content'])) {
295
- throw new Error(`${subject} message must contain one tool-result block`);
296
- }
297
- if (block['toolCallId'] !== sourceRecord['callId']) {
294
+ if (messageRecord['toolCallId'] !== sourceRecord['callId']) {
298
295
  throw new Error(`${subject} message has mismatched tool call ids`);
299
296
  }
300
297
  }
@@ -358,29 +355,25 @@ export class Session {
358
355
  return this.header.id;
359
356
  }
360
357
  /**
361
- * The first seq appended IN THIS PROCESS: the length of the constructor
362
- * seed (0 without one). Events with smaller seq values entered through
363
- * construction replay, fork, or resume and were never published on the
364
- * `session/event` firehose (constructor seeds do not emit). This offset marks
365
- * the constructor-input boundary for lifecycle ownership and persistence
366
- * adoption; consumers that need complete canonical history still start at
367
- * seq 0. Distinct from {@link inheritedEventCount}, the DURABLE
368
- * fork-lineage cut: a resumed session's constructor seed is its full stored
369
- * log, while the inherited count keeps the original fork value — this field is the
370
- * in-process construction fact.
371
- *
372
- * Not persisted itself: a seeded session projects it into the log as the
373
- * `session/end-seed` event, which is what a consumer reading STORED history
374
- * reads. Locate the LAST such event, not necessarily one at this seq — a
375
- * seed already ending in one is not re-marked, so reopening an untouched
376
- * session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
377
- * this field in-process: it is exact before the marker reaches storage.
358
+ * The constructor seed length (0 without one), before any marker appended
359
+ * during construction. Seed events never publish on `session/event`. A
360
+ * marker appended before the store attaches occupies this seq without
361
+ * publishing either; otherwise this seq is available for the next append.
378
362
  *
379
- * When this lifecycle appends the marker, it occupies this seq before the
380
- * store attaches and therefore does not publish either. Otherwise this seq
381
- * holds an ordinary published write.
363
+ * This in-process offset is not persisted. A fork seed can already contain
364
+ * the child's inherited marker and synthetic closers, so its child-owned
365
+ * history starts at {@link inheritedEventCount}, before this offset. A
366
+ * resumed Session's seed contains its full stored log, while its inherited
367
+ * count keeps the durable fork cut. Consumers needing complete canonical
368
+ * history start at seq 0.
382
369
  */
383
370
  firstLiveSeq;
371
+ /**
372
+ * First event produced for this object lifecycle. A new fork includes its
373
+ * child-owned seed marker and closers; a restored Session starts after its
374
+ * complete stored prefix. This in-process capture offset is not persisted.
375
+ */
376
+ firstLifecycleSeq;
384
377
  /**
385
378
  * Create a detached session by validating and snapshotting borrowed seed
386
379
  * events and storage metadata.
@@ -462,17 +455,23 @@ export class Session {
462
455
  if (inheritedEventCount > this.log.length) {
463
456
  throw new Error('session inherited event count exceeds its event log');
464
457
  }
465
- if (mode === 'snapshot' && this.header.isSeeded && inheritedEventCount !== this.log.length) {
466
- throw new Error('seeded session constructor seed must equal its inherited prefix');
458
+ const seedMarker = this.log[inheritedEventCount];
459
+ const markedSeed = seedMarker?.type === 'session/end-seed' && seedMarker.data.inherited === true;
460
+ if (mode === 'snapshot' && this.header.isSeeded && inheritedEventCount !== this.log.length && !markedSeed) {
461
+ throw new Error('seeded session constructor seed must equal its inherited prefix or mark its inherited cut');
462
+ }
463
+ if (markedSeed && this.log.slice(inheritedEventCount + 1).some(event => event.type === 'session/end-seed' && event.data.inherited === true)) {
464
+ throw new Error('session inherited event count must identify the final inherited marker');
467
465
  }
468
466
  this.inheritedEventCount = inheritedEventCount;
467
+ this.firstLifecycleSeq = mode === 'snapshot' && this.header.isSeeded ? inheritedEventCount : this.firstLiveSeq;
469
468
  // A fresh seeded child always owns one tagged marker at its inherited cut,
470
469
  // even when the copied prefix already ends in an ancestor marker. Restore
471
470
  // retains that durable marker and appends only the ordinary resume marker.
472
- if (seed !== undefined && mode === 'snapshot' && this.header.isSeeded) {
471
+ if (seed !== undefined && mode === 'snapshot' && this.header.isSeeded && !markedSeed) {
473
472
  this.append('session/end-seed', { inherited: true });
474
473
  }
475
- else if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
474
+ else if (seed !== undefined && !(mode === 'snapshot' && this.header.isSeeded) && this.log.at(-1)?.type !== 'session/end-seed') {
476
475
  this.append('session/end-seed', {});
477
476
  }
478
477
  }
@@ -1035,10 +1034,12 @@ export class SessionStore extends Service {
1035
1034
  return [...this.store.values()].map(entry => entry.session);
1036
1035
  }
1037
1036
  /**
1038
- * Create a live child session from a stable prefix of a live source.
1037
+ * Create a live child session from an exact prefix of a live source.
1039
1038
  * `boundary` is an inclusive source event seq; omitted means the source's
1040
- * current last event. The selected slice may end with a between-turn event
1041
- * but must not end inside an open turn.
1039
+ * current last event. An open tail receives synthetic tool results and
1040
+ * step/turn closers with the forked cause. Closed steps and turns remain
1041
+ * unchanged, including any failed tool calls already missing results.
1042
+ * `inheritedEventCount` counts only copied source events, excluding these closers.
1042
1043
  *
1043
1044
  * @param source - Live source session object or id.
1044
1045
  * @param boundary - Inclusive source event seq to fork through; omitted means
@@ -1053,10 +1054,13 @@ export class SessionStore extends Service {
1053
1054
  throw new SessionForkError(`session "${childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS');
1054
1055
  }
1055
1056
  const liveSource = this._resolveForkSource(source);
1056
- const seed = this._forkSeed(liveSource, boundary);
1057
+ // oxlint-disable-next-line typescript/no-deprecated -- Existing fork snapshot read; migration deferred.
1058
+ const events = liveSource.snapshotEvents();
1059
+ const resolved = this._forkBoundary(liveSource.id, events, boundary);
1060
+ const seed = resolved === undefined ? [] : buildForkSeed(events, resolved);
1057
1061
  return this.create(childSessionId, {
1058
1062
  seed,
1059
- inheritedEventCount: SessionLogOffset(seed.length),
1063
+ inheritedEventCount: SessionLogOffset(resolved === undefined ? 0 : resolved + 1),
1060
1064
  meta: {
1061
1065
  ...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {},
1062
1066
  parentSession: liveSource.id,
@@ -1064,38 +1068,29 @@ export class SessionStore extends Service {
1064
1068
  },
1065
1069
  });
1066
1070
  }
1067
- _forkSeed(session, requestedBoundary) {
1068
- // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
1069
- const lastEvent = session.snapshotEvents().at(-1);
1071
+ _forkBoundary(sessionId, events, requestedBoundary) {
1072
+ const lastEvent = events.at(-1);
1070
1073
  let boundary;
1071
1074
  if (requestedBoundary !== undefined) {
1072
1075
  boundary = requestedBoundary;
1073
1076
  }
1074
1077
  else {
1075
1078
  if (lastEvent === undefined)
1076
- return [];
1079
+ return undefined;
1077
1080
  boundary = lastEvent.seq;
1078
1081
  }
1079
1082
  if (!Number.isSafeInteger(boundary) || boundary < 0) {
1080
- throw new SessionForkError(`fork boundary for session "${session.id}" must be a non-negative safe integer, got ${String(boundary)}`, 'INVALID_BOUNDARY');
1083
+ throw new SessionForkError(`fork boundary for session "${sessionId}" must be a non-negative safe integer, got ${String(boundary)}`, 'INVALID_BOUNDARY');
1081
1084
  }
1082
- if (boundary >= session.seq) {
1085
+ if (boundary >= events.length) {
1083
1086
  const lastSeq = lastEvent?.seq;
1084
- throw new SessionForkError(`fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? 'none'})`, 'INVALID_BOUNDARY');
1087
+ throw new SessionForkError(`fork boundary ${boundary} does not exist in session "${sessionId}" (last seq: ${lastSeq ?? 'none'})`, 'INVALID_BOUNDARY');
1085
1088
  }
1086
- // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
1087
- const boundaryEvent = session.eventAt(boundary);
1089
+ const boundaryEvent = events[boundary];
1088
1090
  if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
1089
- throw new SessionForkError(`fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`, 'INVALID_BOUNDARY');
1090
- }
1091
- // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
1092
- const events = session.snapshotEvents(SessionLogOffset(0), SessionLogOffset(boundary + 1));
1093
- const lastTurnBoundary = events
1094
- .findLast(event => event.type === 'turn/start' || event.type === 'turn/end');
1095
- if (lastTurnBoundary?.type === 'turn/start') {
1096
- throw new SessionForkError(`fork boundary ${boundary} in session "${session.id}" ends inside open turn ${lastTurnBoundary.data.turn}`, 'OPEN_TURN');
1091
+ throw new SessionForkError(`fork boundary ${boundary} does not match a contiguous event seq in session "${sessionId}"`, 'INVALID_BOUNDARY');
1097
1092
  }
1098
- return events;
1093
+ return boundary;
1099
1094
  }
1100
1095
  _resolveForkSource(source) {
1101
1096
  if (typeof source === 'string') {
@@ -65,6 +65,10 @@ function validateEvent(trace, event, fail) {
65
65
  openStep = event.data.step;
66
66
  break;
67
67
  }
68
+ case 'developer/message': {
69
+ requireOpenStep(trace, 'developer/message', event.data.turn, event.data.step, fail);
70
+ break;
71
+ }
68
72
  case 'step/end': {
69
73
  requireOpenStep(trace, 'step/end', event.data.turn, event.data.step, fail);
70
74
  pendingCalls = { kind: 'clear' };
@@ -96,7 +100,7 @@ function validateEvent(trace, event, fail) {
96
100
  }
97
101
  requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step, fail);
98
102
  const callId = event.data.message.source.callId;
99
- const syntheticNotStarted = event.data.message.content[0].isError === true && event.data.error?.code === TOOL_NOT_STARTED;
103
+ const syntheticNotStarted = event.data.message.isError === true && event.data.error?.code === TOOL_NOT_STARTED;
100
104
  if (!trace.pendingCalls.has(callId) && !syntheticNotStarted) {
101
105
  fail(`tool/result for ${callId} with no prior tool/call in this step`);
102
106
  }
@@ -33,6 +33,7 @@ export const KNOWN_SESSION_EVENT_TYPES = new Set([
33
33
  'compaction/start',
34
34
  'compaction/summary',
35
35
  'deliverables/presented',
36
+ 'developer/message',
36
37
  'feedback/message-delete',
37
38
  'feedback/message-put',
38
39
  'feedback/record',
@@ -1,7 +1,10 @@
1
1
  /**
2
- * Crash-recovery repair for an interrupted session log. It preserves a fully
3
- * written final turn and supplies the missing tool, step, and turn boundaries
4
- * needed to resume with a provider-valid transcript.
2
+ * Synthetic closer events that balance a session log whose tail turn is open.
3
+ * Two producers share the mechanism: crash recovery closes an interrupted
4
+ * persisted log on reload, and fork-seed construction closes a prefix cut
5
+ * inside the source's open turn. Both preserve every fully written event and
6
+ * close the unfinished step and turn. Calls in already closed steps remain
7
+ * unchanged, including any missing results.
5
8
  * @module @deepseek-ai/dsh-session/repair
6
9
  */
7
10
  import type { SessionEvent } from './types.ts';
@@ -9,14 +12,46 @@ import type { SessionEvent } from './types.ts';
9
12
  export declare const TOOL_NOT_STARTED = "TOOL_NOT_STARTED";
10
13
  /** Recovery code for a recorded tool call whose completed outcome was not durably recorded. */
11
14
  export declare const TOOL_OUTCOME_UNKNOWN = "TOOL_OUTCOME_UNKNOWN";
15
+ /**
16
+ * Why an open tail turn is closed with synthetic events: `interrupted` is
17
+ * crash recovery over a persisted log; `forked` is a fork seed cut inside the
18
+ * source's open turn. The cause selects the synthetic `turn/end` reason, the
19
+ * model-visible wording of synthetic error tool results, and the
20
+ * deterministic synthetic message-id prefix. The error codes
21
+ * ({@link TOOL_NOT_STARTED} / {@link TOOL_OUTCOME_UNKNOWN}) are shared: both
22
+ * causes state the same fact about the call's recorded lifecycle.
23
+ */
24
+ export type OpenTurnCloseCause = {
25
+ readonly kind: 'interrupted';
26
+ } | {
27
+ readonly kind: 'forked';
28
+ };
12
29
  /**
13
30
  * Return deterministic synthetic events that close an open tail turn. Unmatched
14
- * calls receive error results first, followed by an open `step/end` and an
15
- * interrupted `turn/end`; sequences continue the log and timestamps reuse the
16
- * last real event. A balanced or empty log returns no events.
31
+ * calls in its open step receive error results, followed by `step/end` and a
32
+ * `turn/end` carrying the cause's reason. Calls in closed steps remain unchanged.
33
+ * Sequences continue the log and timestamps reuse the last real event. A balanced or empty log returns no
34
+ * events.
35
+ *
36
+ * Package-internal: each cause has exactly one owner, so external callers go
37
+ * through {@link interruptedTurnClosers} (persistence crash recovery) or
38
+ * `buildForkSeed` in `./fork.ts` (fork seeds) instead of selecting a cause.
17
39
  *
18
- * @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
40
+ * @param events - the log to scan: a valid committed prefix, possibly ending
41
+ * inside an open turn (a crash tail or a mid-turn fork cut).
42
+ * @param cause - why the turn is being closed; selects the `turn/end` reason
43
+ * and the model-visible wording of synthetic error tool results.
19
44
  * @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
20
45
  */
46
+ export declare function openTurnClosers(events: readonly SessionEvent[], cause: OpenTurnCloseCause): SessionEvent[];
47
+ /**
48
+ * Crash-recovery entry point: synthetic closers that balance a persisted log
49
+ * whose tail turn was interrupted. Used by crash-recovery callers; fork
50
+ * seeds receive their `forked`-cause closers through `buildForkSeed` in
51
+ * `./fork.ts`, and cause selection stays internal to those two owners.
52
+ *
53
+ * @param events - the persisted log to scan, possibly ending inside an open turn.
54
+ * @returns the synthetic `interrupted` closer events to append after `events`; empty when the log is already balanced.
55
+ */
21
56
  export declare function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[];
22
57
  //# sourceMappingURL=repair.d.ts.map
@@ -1,7 +1,10 @@
1
1
  /**
2
- * Crash-recovery repair for an interrupted session log. It preserves a fully
3
- * written final turn and supplies the missing tool, step, and turn boundaries
4
- * needed to resume with a provider-valid transcript.
2
+ * Synthetic closer events that balance a session log whose tail turn is open.
3
+ * Two producers share the mechanism: crash recovery closes an interrupted
4
+ * persisted log on reload, and fork-seed construction closes a prefix cut
5
+ * inside the source's open turn. Both preserve every fully written event and
6
+ * close the unfinished step and turn. Calls in already closed steps remain
7
+ * unchanged, including any missing results.
5
8
  * @module @deepseek-ai/dsh-session/repair
6
9
  */
7
10
  import { brandString } from '@deepseek-ai/dsh-brand';
@@ -11,16 +14,35 @@ import { SessionSeq } from "./types.js";
11
14
  export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED';
12
15
  /** Recovery code for a recorded tool call whose completed outcome was not durably recorded. */
13
16
  export const TOOL_OUTCOME_UNKNOWN = 'TOOL_OUTCOME_UNKNOWN';
17
+ /** Model-visible wording of the synthetic error tool results, keyed by cause. */
18
+ const CLOSER_TEXT = {
19
+ interrupted: {
20
+ 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.',
21
+ notStarted: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
22
+ },
23
+ forked: {
24
+ 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.',
25
+ 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.',
26
+ },
27
+ };
14
28
  /**
15
29
  * Return deterministic synthetic events that close an open tail turn. Unmatched
16
- * calls receive error results first, followed by an open `step/end` and an
17
- * interrupted `turn/end`; sequences continue the log and timestamps reuse the
18
- * last real event. A balanced or empty log returns no events.
30
+ * calls in its open step receive error results, followed by `step/end` and a
31
+ * `turn/end` carrying the cause's reason. Calls in closed steps remain unchanged.
32
+ * Sequences continue the log and timestamps reuse the last real event. A balanced or empty log returns no
33
+ * events.
19
34
  *
20
- * @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
35
+ * Package-internal: each cause has exactly one owner, so external callers go
36
+ * through {@link interruptedTurnClosers} (persistence crash recovery) or
37
+ * `buildForkSeed` in `./fork.ts` (fork seeds) instead of selecting a cause.
38
+ *
39
+ * @param events - the log to scan: a valid committed prefix, possibly ending
40
+ * inside an open turn (a crash tail or a mid-turn fork cut).
41
+ * @param cause - why the turn is being closed; selects the `turn/end` reason
42
+ * and the model-visible wording of synthetic error tool results.
21
43
  * @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
22
44
  */
23
- export function interruptedTurnClosers(events) {
45
+ export function openTurnClosers(events, cause) {
24
46
  let openTurn = null;
25
47
  let openStep = null;
26
48
  // Reset at each turn boundary so earlier calls cannot leak into tail repair.
@@ -70,7 +92,7 @@ export function interruptedTurnClosers(events) {
70
92
  break;
71
93
  }
72
94
  }
73
- // Balanced log (no crash mid-turn): nothing to close. An open turn implies
95
+ // Balanced log (no open tail turn): nothing to close. An open turn implies
74
96
  // `events` is non-empty (its turn/start was logged), so `last` exists.
75
97
  const last = events.at(-1);
76
98
  if (openTurn === null || last === undefined)
@@ -81,24 +103,19 @@ export function interruptedTurnClosers(events) {
81
103
  let seq = last.seq + 1;
82
104
  const time = last.time;
83
105
  const closers = [];
84
- // Close calls before their step: providers reject dangling assistant calls,
85
- // and Map insertion order preserves their transcript order.
106
+ // Close calls before their step; Map insertion order preserves transcript order.
107
+ const text = CLOSER_TEXT[cause.kind];
86
108
  for (const [callId, { step, callSeq }] of pendingCalls) {
87
109
  const started = callSeq !== undefined;
88
110
  const message = deepFreeze({
89
- id: brandString(`interrupted-tool-result-${callId}-${seq}`),
90
- role: 'user',
111
+ id: brandString(`${cause.kind}-tool-result-${callId}-${seq}`),
112
+ role: 'tool',
113
+ toolCallId: callId,
114
+ isError: true,
91
115
  source: { kind: 'tool', callId },
92
116
  content: [{
93
- type: 'tool-result',
94
- toolCallId: callId,
95
- isError: true,
96
- content: [{
97
- type: 'text',
98
- text: started
99
- ? '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.'
100
- : 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
101
- }],
117
+ type: 'text',
118
+ text: started ? text.started : text.notStarted,
102
119
  }],
103
120
  });
104
121
  closers.push({
@@ -122,7 +139,19 @@ export function interruptedTurnClosers(events) {
122
139
  if (openStep !== null) {
123
140
  closers.push({ type: 'step/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, step: openStep } });
124
141
  }
125
- closers.push({ type: 'turn/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, reason: { kind: 'interrupted' } } });
142
+ closers.push({ type: 'turn/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, reason: { kind: cause.kind } } });
126
143
  return closers;
127
144
  }
145
+ /**
146
+ * Crash-recovery entry point: synthetic closers that balance a persisted log
147
+ * whose tail turn was interrupted. Used by crash-recovery callers; fork
148
+ * seeds receive their `forked`-cause closers through `buildForkSeed` in
149
+ * `./fork.ts`, and cause selection stays internal to those two owners.
150
+ *
151
+ * @param events - the persisted log to scan, possibly ending inside an open turn.
152
+ * @returns the synthetic `interrupted` closer events to append after `events`; empty when the log is already balanced.
153
+ */
154
+ export function interruptedTurnClosers(events) {
155
+ return openTurnClosers(events, { kind: 'interrupted' });
156
+ }
128
157
  //# sourceMappingURL=repair.js.map
@@ -38,7 +38,7 @@ export interface SessionMessageProjection<T extends SessionEventType = SessionEv
38
38
  /**
39
39
  * Whether an event type can join the model-visible surface.
40
40
  * @param type - event type to test.
41
- * @returns true for one of the four message-producing event types.
41
+ * @returns true for one of the message-producing event types.
42
42
  */
43
43
  export declare function isSurfaceEligibleType(type: string): boolean;
44
44
  /**
@@ -76,7 +76,7 @@ export declare function isReplacementSurfaceEvent(event: SessionEvent): event is
76
76
  /**
77
77
  * Project a single event into the LLM message it derives to, or null when it
78
78
  * produces none — a non-surface event (attempt, boundary, log-only record) or an
79
- * empty-content assistant/message (which exists only to host usage). A caller
79
+ * empty-content system, developer, or assistant message. A caller
80
80
  * reconstructing model input supplies the same prefix's `projectedMessages`
81
81
  * from {@link foldSurface}; without that map this function reads original
82
82
  * event content. Session instance methods apply the live projection. Messages
@@ -87,11 +87,11 @@ export declare function isReplacementSurfaceEvent(event: SessionEvent): event is
87
87
  */
88
88
  export declare function deriveEventMessage(event: SessionEvent, projectedMessages?: ReadonlyMap<SessionSeq, Message>): Message | null;
89
89
  /**
90
- * Reject noncanonical request-header fields and contradictory tool failure metadata.
90
+ * Reject noncanonical request-header fields, developer roles/content, and contradictory tool failure metadata.
91
91
  * This does not validate complete event payloads or embedded provider streams.
92
92
  * @param event - event whose locally related payload fields are inspected.
93
93
  * @param subject - event location to include in validation errors.
94
- * @throws when request data/header is not an object, optional header fields are empty, or tool failure metadata contradicts its message.
94
+ * @throws when request-header fields, developer roles/content, or tool failure metadata are invalid.
95
95
  */
96
96
  export declare function validateSessionEventData(event: Pick<SessionEvent, 'type' | 'data'>, subject: string): void;
97
97
  /** One replacement operation observed while folding a session surface. */