@kb-labs/agent-core 2.118.2 → 2.119.0-canary.2077501f1

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/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as _kb_labs_agent_contracts from '@kb-labs/agent-contracts';
2
- import { AgentMode, AgentSessionInfo, AgentSession, AgentEvent, Turn, FileChangeSummary, AgentEventCallback, Tracer, LLMTier, TaskPlan, SessionProgress, TaskSpec, AgentConfig, TaskResult, ModeConfig, SpecSection, StopConditionResult, LoopResult, AgentMemory, FeatureFlags, SpawnAgentRequest, SpawnAgentResult, AsyncTask, ToolPack, ToolFilter, ToolDefinition, ResolvedTool, ToolResult, IterationSnapshot, RunEvaluation } from '@kb-labs/agent-contracts';
2
+ import { AgentMode, AgentSessionInfo, AgentSession, AgentEvent, TurnDelta, Turn, FileChangeSummary, AgentEventCallback, Tracer, LLMTier, TaskPlan, SessionProgress, TaskSpec, AgentConfig, TaskResult, ModeConfig, SpecSection, StopConditionResult, LoopResult, AgentMemory, FeatureFlags, SpawnAgentRequest, SpawnAgentResult, AsyncTask, ToolPack, ToolFilter, ToolDefinition, ResolvedTool, ToolResult, IterationSnapshot, RunEvaluation } from '@kb-labs/agent-contracts';
3
3
  import { ToolRegistry, SessionMemoryBridge } from '@kb-labs/agent-tools';
4
4
  import { RuntimeProfile, ResultMapper, StopCondition, RunContext, LLMCallResult, ExecutionLoop as ExecutionLoop$1, LoopContext as LoopContext$1, LoopResult as LoopResult$1, AgentMiddleware, ControlAction, LLMCtx, LLMCallPatch, ToolExecCtx, ToolOutput, IAgentRunner, AgentSDK, ToolGuard, OutputProcessor, InputNormalizer, ToolCallInput, ContextMeta } from '@kb-labs/agent-sdk';
5
5
  export { AgentMiddleware, ControlAction, LLMCallPatch, LLMCallResult, LLMCtx, RunContext, ToolExecCtx, ToolOutput } from '@kb-labs/agent-sdk';
@@ -40,21 +40,27 @@ interface SessionKpiBaseline {
40
40
  declare class SessionManager {
41
41
  private workingDir;
42
42
  private readonly artifactStore;
43
- /** In-memory cache of per-run sequence counters (initialized lazily from NDJSON) */
44
- private runSeqCounters;
45
- /** In-memory cache of turn sequence counters per session (for turns.json). */
46
- private turnSeqCounters;
43
+ /**
44
+ * In-memory cache of the per-session monotonic event sequence counter
45
+ * (initialized lazily from NDJSON line count). Backed by shared
46
+ * process-wide state — see SharedSessionManagerState's doc comment for
47
+ * why this must NOT be a plain instance field.
48
+ */
49
+ private get sessionSeqCounters();
50
+ /** In-memory cache of turn sequence counters per session (for turns.json). Shared — see sessionSeqCounters. */
51
+ private get turnSeqCounters();
47
52
  /** Turn assembler for creating turn snapshots from events */
48
53
  private assembler;
49
- /** Write queue per session to prevent concurrent write race conditions */
50
- private writeQueues;
54
+ /** Write queue per session to prevent concurrent write race conditions. Shared — see sessionSeqCounters. */
55
+ private get writeQueues();
51
56
  /**
52
57
  * Serialized event-processing queue per session.
53
58
  * Ensures addEvent calls for the same session are processed in arrival order
54
59
  * even when the caller fires them concurrently with void (fire-and-forget).
55
60
  * This prevents tool:end being processed by TurnAssembler before tool:start.
61
+ * Shared — see sessionSeqCounters.
56
62
  */
57
- private eventQueues;
63
+ private get eventQueues();
58
64
  /** Write queue per session for artifact projection updates */
59
65
  private artifactWriteQueues;
60
66
  /** Write queue per session for KPI baseline updates */
@@ -108,22 +114,41 @@ declare class SessionManager {
108
114
  total: number;
109
115
  }>;
110
116
  /**
111
- * Get next per-run sessionSeq for a specific run within a session.
112
- * Each run has independent sequence counters starting from 1.
113
- * Lazily initialized: reads NDJSON on first call to find max existing sessionSeq for this runId,
114
- * then uses in-memory counter for subsequent calls.
115
- */
117
+ * Get the next seq for a session — monotonic across the WHOLE session
118
+ * (every run, every turn), not scoped to a single run. This is the one
119
+ * cursor the WS protocol and REST resume/gap-recovery paths rely on;
120
+ * it must be a single, ever-increasing number per session for "seq > N"
121
+ * comparisons to mean anything.
122
+ *
123
+ * Lazily initialized from the NDJSON LINE COUNT, not the max embedded
124
+ * `sessionSeq` value. Old sessions were written under the previous
125
+ * per-run scheme, where two different runs both start their own
126
+ * `sessionSeq` at 1 — so the max embedded value can be far smaller than
127
+ * the true number of events and would hand out colliding seq numbers
128
+ * for old sessions. Line count is always >= any embedded per-run value
129
+ * and is exactly equal to it for sessions already written under this
130
+ * per-session scheme, so it's a safe bootstrap either way.
131
+ */
132
+ private ensureSessionSeqBootstrapped;
116
133
  private getNextSessionSeq;
134
+ /**
135
+ * Current session-wide cursor value WITHOUT advancing it. Used for the
136
+ * cold-start snapshot and gap-recovery projection, both of which need to
137
+ * hand the client a `seq` to resume from without consuming one.
138
+ */
139
+ getCurrentSessionSeq(sessionId: string): Promise<number>;
117
140
  /**
118
141
  * Add event to session (append-only, race-condition safe).
119
- * Assigns per-run sessionSeq for ordering within each run.
120
- * Also processes event to update turn snapshots.
142
+ * Assigns the session-wide monotonic sessionSeq.
143
+ * Also processes the event to update turn snapshots, returning whatever
144
+ * TurnDelta[] that produced (empty if the event didn't touch a turn) so
145
+ * callers can broadcast them once persistence is confirmed.
121
146
  *
122
147
  * Events for the same session are serialized via eventQueues so that
123
148
  * concurrent fire-and-forget callers (void addEvent(...)) don't cause
124
149
  * tool:end to be processed before tool:start in TurnAssembler.
125
150
  */
126
- addEvent(sessionId: string, event: AgentEvent): Promise<void>;
151
+ addEvent(sessionId: string, event: AgentEvent): Promise<TurnDelta[]>;
127
152
  private _addEventInternal;
128
153
  /**
129
154
  * Get all events for a session
@@ -222,17 +247,47 @@ declare class SessionManager {
222
247
  * Create user turn with task/question
223
248
  * Called when user submits a task to agent
224
249
  */
225
- createUserTurn(sessionId: string, task: string, runId: string): Promise<Turn>;
250
+ createUserTurn(sessionId: string, task: string, runId: string, clientId?: string): Promise<Turn>;
226
251
  /**
227
252
  * Reserve next turn sequence number atomically per session.
228
253
  * Uses the same serialized write queue as turns.json updates.
229
254
  */
230
255
  private reserveNextTurnSequence;
231
256
  /**
232
- * Process event and update turn snapshot
233
- * Returns updated turn if changed, null otherwise
234
- */
235
- processEventAndUpdateTurn(sessionId: string, event: AgentEvent): Promise<Turn | null>;
257
+ * Process event, update the turn projection, and compute the deltas this
258
+ * mutation produced (relative to the turn's state just before this
259
+ * event). Returns null when the event didn't touch any turn, or touched
260
+ * one without producing any externally-observable change.
261
+ *
262
+ * The "before" snapshot is captured from the assembler's live (in-place
263
+ * mutated) turn reference BEFORE processEventAsync runs — the assembler
264
+ * mutates the same object, so this is the only point at which a
265
+ * pre-mutation snapshot is available.
266
+ *
267
+ * Deliberately resolves the CURRENT turn via peekTurn rather than trusting
268
+ * processEventAsync's own return value: the assembler treats "just
269
+ * created, no steps yet" (a bare agent:start with nothing else to report)
270
+ * as "not updated" and returns null for it — but the turn WAS created as
271
+ * a side effect (added to activeTurns), which is itself diff-worthy: it's
272
+ * the only point `turn:created` can ever fire from. Without this, the
273
+ * turn is silently created in memory but never persisted or broadcast,
274
+ * and every later event sees `before !== null` (peekTurn already finds
275
+ * it), so `turn:created` never fires at all.
276
+ */
277
+ processEventAndUpdateTurn(sessionId: string, event: AgentEvent): Promise<{
278
+ turn: Turn;
279
+ deltas: TurnDelta[];
280
+ } | null>;
281
+ /**
282
+ * Current projection of a session: its full turn list plus the seq cursor
283
+ * it reflects. Used for the cold-start snapshot AND as the gap-recovery
284
+ * resume payload (see ADR note in the sync design doc) — both send the
285
+ * same shape, just at different points in the connection lifecycle.
286
+ */
287
+ getProjection(sessionId: string): Promise<{
288
+ turns: Turn[];
289
+ seq: number;
290
+ }>;
236
291
  /**
237
292
  * Store extracted trace artifacts for a completed run.
238
293
  */
package/dist/index.js CHANGED
@@ -101,6 +101,24 @@ var init_turn_assembler = __esm({
101
101
  }
102
102
  return updated || completed ? turn : null;
103
103
  }
104
+ /**
105
+ * Public wrapper around getTurnId, for callers that need to snapshot the
106
+ * turn's pre-mutation state (e.g. to diff against post-mutation state)
107
+ * before calling processEventAsync.
108
+ */
109
+ getTurnIdForEvent(event) {
110
+ return this.getTurnId(event);
111
+ }
112
+ /**
113
+ * Peek at the current in-progress turn for a turn id, WITHOUT mutating
114
+ * anything. Returns a live reference (not a clone) — the assembler
115
+ * mutates turn/step objects in place, so callers that need a stable
116
+ * "before" snapshot must clone this themselves before calling
117
+ * processEventAsync.
118
+ */
119
+ peekTurn(turnId) {
120
+ return this.activeTurns.get(turnId);
121
+ }
104
122
  /**
105
123
  * Derive turn ID from event.
106
124
  * For root agents: use agentId
@@ -486,33 +504,124 @@ var init_turn_assembler = __esm({
486
504
  }
487
505
  });
488
506
 
507
+ // src/planning/turn-diff.ts
508
+ function stepsEqual(a, b) {
509
+ return JSON.stringify(a) === JSON.stringify(b);
510
+ }
511
+ function diffTurn(before, after, seq) {
512
+ const deltas = [];
513
+ if (!before) {
514
+ deltas.push({ kind: "turn:created", seq, turn: { ...after, steps: [] } });
515
+ for (const step of after.steps) {
516
+ deltas.push({ kind: "turn:step:appended", seq, turnId: after.id, step });
517
+ }
518
+ if (after.status !== "streaming") {
519
+ deltas.push({
520
+ kind: "turn:status",
521
+ seq,
522
+ turnId: after.id,
523
+ status: after.status,
524
+ completedAt: after.completedAt,
525
+ error: after.error
526
+ });
527
+ }
528
+ return deltas;
529
+ }
530
+ for (let i = 0; i < after.steps.length; i++) {
531
+ const afterStep = after.steps[i];
532
+ if (!afterStep) {
533
+ continue;
534
+ }
535
+ const beforeStep = before.steps[i];
536
+ if (!beforeStep) {
537
+ deltas.push({ kind: "turn:step:appended", seq, turnId: after.id, step: afterStep });
538
+ } else if (!stepsEqual(beforeStep, afterStep)) {
539
+ deltas.push({ kind: "turn:step:updated", seq, turnId: after.id, step: afterStep });
540
+ }
541
+ }
542
+ if (before.status !== after.status || before.completedAt !== after.completedAt) {
543
+ deltas.push({
544
+ kind: "turn:status",
545
+ seq,
546
+ turnId: after.id,
547
+ status: after.status,
548
+ completedAt: after.completedAt,
549
+ error: after.error
550
+ });
551
+ }
552
+ const beforeMeta = before.metadata;
553
+ const afterMeta = after.metadata;
554
+ const metaPatch = {};
555
+ for (const key of Object.keys(afterMeta)) {
556
+ if (!stepsEqual(beforeMeta[key], afterMeta[key])) {
557
+ metaPatch[key] = afterMeta[key];
558
+ }
559
+ }
560
+ if (Object.keys(metaPatch).length > 0) {
561
+ deltas.push({ kind: "turn:metadata", seq, turnId: after.id, patch: metaPatch });
562
+ }
563
+ return deltas;
564
+ }
565
+ var init_turn_diff = __esm({
566
+ "src/planning/turn-diff.ts"() {
567
+ }
568
+ });
569
+
489
570
  // src/planning/session-manager.ts
490
571
  var session_manager_exports = {};
491
572
  __export(session_manager_exports, {
492
573
  SessionManager: () => SessionManager
493
574
  });
494
- var SessionManager;
575
+ function getSharedState() {
576
+ const g = globalThis;
577
+ if (!g[SHARED_STATE_KEY]) {
578
+ g[SHARED_STATE_KEY] = {
579
+ sessionSeqCounters: /* @__PURE__ */ new Map(),
580
+ turnSeqCounters: /* @__PURE__ */ new Map(),
581
+ writeQueues: /* @__PURE__ */ new Map(),
582
+ eventQueues: /* @__PURE__ */ new Map()
583
+ };
584
+ }
585
+ return g[SHARED_STATE_KEY];
586
+ }
587
+ var SHARED_STATE_KEY, SessionManager;
495
588
  var init_session_manager = __esm({
496
589
  "src/planning/session-manager.ts"() {
497
590
  init_turn_assembler();
591
+ init_turn_diff();
592
+ SHARED_STATE_KEY = "__kb_agent_session_manager_shared_state__";
498
593
  SessionManager = class {
499
594
  workingDir;
500
595
  artifactStore;
501
- /** In-memory cache of per-run sequence counters (initialized lazily from NDJSON) */
502
- runSeqCounters = /* @__PURE__ */ new Map();
503
- /** In-memory cache of turn sequence counters per session (for turns.json). */
504
- turnSeqCounters = /* @__PURE__ */ new Map();
596
+ /**
597
+ * In-memory cache of the per-session monotonic event sequence counter
598
+ * (initialized lazily from NDJSON line count). Backed by shared
599
+ * process-wide state — see SharedSessionManagerState's doc comment for
600
+ * why this must NOT be a plain instance field.
601
+ */
602
+ get sessionSeqCounters() {
603
+ return getSharedState().sessionSeqCounters;
604
+ }
605
+ /** In-memory cache of turn sequence counters per session (for turns.json). Shared — see sessionSeqCounters. */
606
+ get turnSeqCounters() {
607
+ return getSharedState().turnSeqCounters;
608
+ }
505
609
  /** Turn assembler for creating turn snapshots from events */
506
610
  assembler = new TurnAssembler();
507
- /** Write queue per session to prevent concurrent write race conditions */
508
- writeQueues = /* @__PURE__ */ new Map();
611
+ /** Write queue per session to prevent concurrent write race conditions. Shared — see sessionSeqCounters. */
612
+ get writeQueues() {
613
+ return getSharedState().writeQueues;
614
+ }
509
615
  /**
510
616
  * Serialized event-processing queue per session.
511
617
  * Ensures addEvent calls for the same session are processed in arrival order
512
618
  * even when the caller fires them concurrently with void (fire-and-forget).
513
619
  * This prevents tool:end being processed by TurnAssembler before tool:start.
620
+ * Shared — see sessionSeqCounters.
514
621
  */
515
- eventQueues = /* @__PURE__ */ new Map();
622
+ get eventQueues() {
623
+ return getSharedState().eventQueues;
624
+ }
516
625
  /** Write queue per session for artifact projection updates */
517
626
  artifactWriteQueues = /* @__PURE__ */ new Map();
518
627
  /** Write queue per session for KPI baseline updates */
@@ -657,39 +766,54 @@ var init_session_manager = __esm({
657
766
  // Event Storage (NDJSON format - append-safe)
658
767
  // ═══════════════════════════════════════════════════════════════════════
659
768
  /**
660
- * Get next per-run sessionSeq for a specific run within a session.
661
- * Each run has independent sequence counters starting from 1.
662
- * Lazily initialized: reads NDJSON on first call to find max existing sessionSeq for this runId,
663
- * then uses in-memory counter for subsequent calls.
769
+ * Get the next seq for a session — monotonic across the WHOLE session
770
+ * (every run, every turn), not scoped to a single run. This is the one
771
+ * cursor the WS protocol and REST resume/gap-recovery paths rely on;
772
+ * it must be a single, ever-increasing number per session for "seq > N"
773
+ * comparisons to mean anything.
774
+ *
775
+ * Lazily initialized from the NDJSON LINE COUNT, not the max embedded
776
+ * `sessionSeq` value. Old sessions were written under the previous
777
+ * per-run scheme, where two different runs both start their own
778
+ * `sessionSeq` at 1 — so the max embedded value can be far smaller than
779
+ * the true number of events and would hand out colliding seq numbers
780
+ * for old sessions. Line count is always >= any embedded per-run value
781
+ * and is exactly equal to it for sessions already written under this
782
+ * per-session scheme, so it's a safe bootstrap either way.
664
783
  */
665
- async getNextSessionSeq(sessionId, runId) {
666
- const key = `${sessionId}:${runId}`;
667
- if (!this.runSeqCounters.has(key)) {
668
- let maxSeq = 0;
669
- try {
670
- const content = await promises.readFile(this.getEventsPath(sessionId), "utf-8");
671
- const lines = content.split("\n").filter((l) => l.trim());
672
- for (const line of lines) {
673
- try {
674
- const evt = JSON.parse(line);
675
- if (evt.runId === runId && evt.sessionSeq != null && evt.sessionSeq > maxSeq) {
676
- maxSeq = evt.sessionSeq;
677
- }
678
- } catch {
679
- }
680
- }
681
- } catch {
682
- }
683
- this.runSeqCounters.set(key, maxSeq);
784
+ async ensureSessionSeqBootstrapped(sessionId) {
785
+ if (this.sessionSeqCounters.has(sessionId)) {
786
+ return;
787
+ }
788
+ let lineCount = 0;
789
+ try {
790
+ const content = await promises.readFile(this.getEventsPath(sessionId), "utf-8");
791
+ lineCount = content.split("\n").filter((l) => l.trim()).length;
792
+ } catch {
684
793
  }
685
- const next = this.runSeqCounters.get(key) + 1;
686
- this.runSeqCounters.set(key, next);
794
+ this.sessionSeqCounters.set(sessionId, lineCount);
795
+ }
796
+ async getNextSessionSeq(sessionId) {
797
+ await this.ensureSessionSeqBootstrapped(sessionId);
798
+ const next = this.sessionSeqCounters.get(sessionId) + 1;
799
+ this.sessionSeqCounters.set(sessionId, next);
687
800
  return next;
688
801
  }
802
+ /**
803
+ * Current session-wide cursor value WITHOUT advancing it. Used for the
804
+ * cold-start snapshot and gap-recovery projection, both of which need to
805
+ * hand the client a `seq` to resume from without consuming one.
806
+ */
807
+ async getCurrentSessionSeq(sessionId) {
808
+ await this.ensureSessionSeqBootstrapped(sessionId);
809
+ return this.sessionSeqCounters.get(sessionId);
810
+ }
689
811
  /**
690
812
  * Add event to session (append-only, race-condition safe).
691
- * Assigns per-run sessionSeq for ordering within each run.
692
- * Also processes event to update turn snapshots.
813
+ * Assigns the session-wide monotonic sessionSeq.
814
+ * Also processes the event to update turn snapshots, returning whatever
815
+ * TurnDelta[] that produced (empty if the event didn't touch a turn) so
816
+ * callers can broadcast them once persistence is confirmed.
693
817
  *
694
818
  * Events for the same session are serialized via eventQueues so that
695
819
  * concurrent fire-and-forget callers (void addEvent(...)) don't cause
@@ -708,12 +832,12 @@ var init_session_manager = __esm({
708
832
  const sessionDir = this.getSessionDir(sessionId);
709
833
  await promises.mkdir(sessionDir, { recursive: true });
710
834
  await this.artifactStore.ensureSessionArtifacts(sessionId);
711
- const runId = event.runId || "unknown";
712
- const sessionSeq = await this.getNextSessionSeq(sessionId, runId);
835
+ const sessionSeq = await this.getNextSessionSeq(sessionId);
713
836
  const line = JSON.stringify({ ...event, sessionSeq }) + "\n";
714
837
  await promises.appendFile(this.getEventsPath(sessionId), line, "utf-8");
715
838
  await promises.appendFile(this.artifactStore.getArtifactPath(sessionId, "trace.ndjson"), line, "utf-8");
716
- await this.processEventAndUpdateTurn(sessionId, { ...event, sessionSeq });
839
+ const result = await this.processEventAndUpdateTurn(sessionId, { ...event, sessionSeq });
840
+ return result?.deltas ?? [];
717
841
  }
718
842
  /**
719
843
  * Get all events for a session
@@ -1104,7 +1228,7 @@ ${tail}`;
1104
1228
  * Create user turn with task/question
1105
1229
  * Called when user submits a task to agent
1106
1230
  */
1107
- async createUserTurn(sessionId, task, runId) {
1231
+ async createUserTurn(sessionId, task, runId, clientId) {
1108
1232
  const sequence = await this.reserveNextTurnSequence(sessionId);
1109
1233
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1110
1234
  const userTurn = {
@@ -1125,7 +1249,8 @@ ${tail}`;
1125
1249
  }
1126
1250
  ],
1127
1251
  metadata: {
1128
- agentId: "user"
1252
+ agentId: "user",
1253
+ ...clientId ? { clientId } : {}
1129
1254
  }
1130
1255
  };
1131
1256
  await this.storeTurnSnapshot(sessionId, userTurn);
@@ -1153,21 +1278,63 @@ ${tail}`;
1153
1278
  return reserved;
1154
1279
  }
1155
1280
  /**
1156
- * Process event and update turn snapshot
1157
- * Returns updated turn if changed, null otherwise
1281
+ * Process event, update the turn projection, and compute the deltas this
1282
+ * mutation produced (relative to the turn's state just before this
1283
+ * event). Returns null when the event didn't touch any turn, or touched
1284
+ * one without producing any externally-observable change.
1285
+ *
1286
+ * The "before" snapshot is captured from the assembler's live (in-place
1287
+ * mutated) turn reference BEFORE processEventAsync runs — the assembler
1288
+ * mutates the same object, so this is the only point at which a
1289
+ * pre-mutation snapshot is available.
1290
+ *
1291
+ * Deliberately resolves the CURRENT turn via peekTurn rather than trusting
1292
+ * processEventAsync's own return value: the assembler treats "just
1293
+ * created, no steps yet" (a bare agent:start with nothing else to report)
1294
+ * as "not updated" and returns null for it — but the turn WAS created as
1295
+ * a side effect (added to activeTurns), which is itself diff-worthy: it's
1296
+ * the only point `turn:created` can ever fire from. Without this, the
1297
+ * turn is silently created in memory but never persisted or broadcast,
1298
+ * and every later event sees `before !== null` (peekTurn already finds
1299
+ * it), so `turn:created` never fires at all.
1158
1300
  */
1159
1301
  async processEventAndUpdateTurn(sessionId, event) {
1160
- const turn = await this.assembler.processEventAsync(event, async (sid) => {
1302
+ const turnId = this.assembler.getTurnIdForEvent(event);
1303
+ if (!turnId) {
1304
+ return null;
1305
+ }
1306
+ const liveBefore = this.assembler.peekTurn(turnId);
1307
+ const before = liveBefore ? structuredClone(liveBefore) : null;
1308
+ const returnedTurn = await this.assembler.processEventAsync(event, async (sid) => {
1161
1309
  return this.reserveNextTurnSequence(sid);
1162
1310
  });
1163
- if (turn) {
1164
- if (event.runId && turn.type === "assistant" && !turn.metadata.runId) {
1165
- turn.metadata.runId = event.runId;
1166
- }
1167
- await this.storeTurnSnapshot(sessionId, turn);
1168
- return turn;
1311
+ const turn = returnedTurn ?? this.assembler.peekTurn(turnId);
1312
+ if (!turn) {
1313
+ return null;
1169
1314
  }
1170
- return null;
1315
+ if (event.runId && turn.type === "assistant" && !turn.metadata.runId) {
1316
+ turn.metadata.runId = event.runId;
1317
+ }
1318
+ const seq = event.sessionSeq ?? await this.getCurrentSessionSeq(sessionId);
1319
+ const deltas = diffTurn(before, turn, seq);
1320
+ if (deltas.length === 0) {
1321
+ return null;
1322
+ }
1323
+ await this.storeTurnSnapshot(sessionId, turn);
1324
+ return { turn, deltas };
1325
+ }
1326
+ /**
1327
+ * Current projection of a session: its full turn list plus the seq cursor
1328
+ * it reflects. Used for the cold-start snapshot AND as the gap-recovery
1329
+ * resume payload (see ADR note in the sync design doc) — both send the
1330
+ * same shape, just at different points in the connection lifecycle.
1331
+ */
1332
+ async getProjection(sessionId) {
1333
+ const [turns, seq] = await Promise.all([
1334
+ this.getTurns(sessionId),
1335
+ this.getCurrentSessionSeq(sessionId)
1336
+ ]);
1337
+ return { turns, seq };
1171
1338
  }
1172
1339
  /**
1173
1340
  * Store extracted trace artifacts for a completed run.