@deepseek-ai/dsh-api-session-controller 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.
Files changed (38) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +34 -9
  3. package/README.zh.md +34 -9
  4. package/lib/client.js +774 -641
  5. package/lib/index.js +318 -143
  6. package/lib/typert.host.js +384 -186
  7. package/lib/typert.remote-client.d.ts +10 -5
  8. package/lib/typert.remote-client.js +225 -125
  9. package/lib/types/archived-session-gate.d.ts +30 -0
  10. package/lib/types/archived-session-gate.js +52 -0
  11. package/lib/types/client/contract/session.d.ts +11 -4
  12. package/lib/types/client/contract/sessions.d.ts +11 -16
  13. package/lib/types/client/contract/snapshot.d.ts +1 -1
  14. package/lib/types/client/index.d.ts +1 -1
  15. package/lib/types/client/sessions/lineage.d.ts +4 -1
  16. package/lib/types/client/sessions/lineage.js +2 -1
  17. package/lib/types/client/sessions/manager.d.ts +66 -59
  18. package/lib/types/client/sessions/manager.js +249 -277
  19. package/lib/types/client/sessions/projection-store.d.ts +38 -13
  20. package/lib/types/client/sessions/projection-store.js +54 -13
  21. package/lib/types/client/sessions/remotes.d.ts +1 -2
  22. package/lib/types/client/sessions/service.d.ts +23 -35
  23. package/lib/types/client/sessions/service.js +35 -47
  24. package/lib/types/client/sessions/session.d.ts +23 -19
  25. package/lib/types/client/sessions/session.js +158 -59
  26. package/lib/types/client/transport.js +5 -2
  27. package/lib/types/commands.d.ts +4 -2
  28. package/lib/types/commands.js +72 -36
  29. package/lib/types/control.d.ts +2 -4
  30. package/lib/types/control.js +2 -44
  31. package/lib/types/history.js +25 -10
  32. package/lib/types/index.d.ts +32 -5
  33. package/lib/types/index.js +117 -13
  34. package/lib/types/list.js +24 -15
  35. package/lib/types/skill-catalog.js +60 -50
  36. package/lib/types/types.d.ts +45 -25
  37. package/lib/types/types.js +0 -1
  38. package/package.json +71 -76
@@ -15,10 +15,15 @@ function projectionsBaseline(value) {
15
15
  asOfSeq: value.asOfSeq === -1 ? -1 : SessionSeq(value.asOfSeq),
16
16
  };
17
17
  }
18
- /** Messages requested per history page. */
18
+ /** Minimum message count for ordinary history windows. */
19
19
  export const PAGE_MESSAGES = 50;
20
- /** Messages requested per page while a turn jump loops backwards (fewer, larger round trips). */
20
+ const HISTORY_PAGE_OPTIONS = { maxMessages: 500, turnWindow: { minMessages: PAGE_MESSAGES, minTurns: 2 } };
21
+ /** Minimum messages per page while a turn jump loops backwards. */
21
22
  export const JUMP_PAGE_MESSAGES = 200;
23
+ const JUMP_PAGE_OPTIONS = {
24
+ ...HISTORY_PAGE_OPTIONS,
25
+ turnWindow: { ...HISTORY_PAGE_OPTIONS.turnWindow, minMessages: JUMP_PAGE_MESSAGES },
26
+ };
22
27
  /**
23
28
  * Owns a session's event window, lifecycle state, and observable
24
29
  * snapshot. React bindings remain outside this data layer. Features see only
@@ -43,6 +48,7 @@ export class Session {
43
48
  jumpTargetSeq = null;
44
49
  /** The running jump loop's completion, shared by retargeting callers. */
45
50
  jumpPromise = null;
51
+ pendingHistory = null;
46
52
  stopObservingInbox;
47
53
  assistantStream = new ClientAssistantStream();
48
54
  running = false;
@@ -56,7 +62,7 @@ export class Session {
56
62
  promptAttempted = false;
57
63
  /** A first accepted prompt stays in the engaging phase until its turn is observable. */
58
64
  firstPromptPendingTurn = false;
59
- /** Empty-log mirror (see ConversationSnapshot.blank); unknown bare sessions begin conservatively blank. */
65
+ /** New Session display state; unknown bare sessions begin conservatively blank. */
60
66
  blankBit = true;
61
67
  removed = false;
62
68
  promptError = null;
@@ -72,8 +78,9 @@ export class Session {
72
78
  * Per-session projection value store (push model; see the session-projection
73
79
  * subsystem page, docs/subsystems/session-projection.md): finished whole
74
80
  * values computed on the Host, seeded by the tail page's
75
- * projections block and updated by Session Controller control frames under the
76
- * one higher-seq-wins rule. Keys are read via `projections.faceOf(key)`
81
+ * projections block and updated by Session Controller control frames;
82
+ * Host-sequenced writes merge under higher-seq-wins and cached list blocks
83
+ * yield to them (projection-store.ts). Keys are read via `projections.faceOf(key)`
77
84
  * (the useProjection resolution face); the conversation snapshot never
78
85
  * carries projection values, and no client-side domain folding exists.
79
86
  * Manager-owned when constructed through SessionManager (frames route and
@@ -140,16 +147,15 @@ export class Session {
140
147
  */
141
148
  beginSubmission(input) {
142
149
  const requestId = randomUUID();
150
+ const placement = this.running ? input.mode === 'steer' ? 'steering' : 'queued' : 'transcript';
143
151
  this.pendingSubmissions = [...this.pendingSubmissions, {
144
152
  requestId,
145
- placement: this.running
146
- ? input.mode === 'steer' ? 'steering' : 'queued'
147
- : 'transcript',
153
+ placement,
148
154
  time: Date.now(),
149
155
  text: input.text,
150
156
  attachments: input.attachments,
151
157
  }];
152
- this.submissionSettlements.set(requestId, { onRetire: input.onRetire, retiring: false });
158
+ this.submissionSettlements.set(requestId, { placement, onRetire: input.onRetire, retiring: false });
153
159
  // The blank → engaging edge flips here, ahead of prompt(): the composer
154
160
  // docks and the echo renders on the click's own frame.
155
161
  this.promptAttempted = true;
@@ -213,19 +219,12 @@ export class Session {
213
219
  this.notifier.markDirty();
214
220
  return result;
215
221
  }
216
- // Blank flips on ACCEPTANCE, not attempt: an accepted prompt starts the
217
- // conversation's first turn on the host (the host criterion — a logged
218
- // turn/start — is fact, not optimism; standalone command and projection
219
- // events never flip it), while a rejected first prompt must keep the
220
- // session blank — the client-side blank mirror only ever lowers, so
221
- // flipping early on a failure would surface the session forever and
222
- // strip its connectWorkspace reuse eligibility against the host's
223
- // authority.
222
+ // Rejection must leave a first prompt blank and eligible for workspace reuse.
224
223
  if (this.blankBit) {
225
224
  this.blankBit = false;
226
- this.options.onEngaged?.(this);
227
225
  this.notifier.markDirty();
228
226
  }
227
+ this.options.onEngaged?.(this);
229
228
  return result;
230
229
  }
231
230
  /**
@@ -310,7 +309,7 @@ export class Session {
310
309
  this.openPromise = promise;
311
310
  return promise;
312
311
  }
313
- /** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend. */
312
+ /** Prepend one Turn-aligned page: at least 50 messages and two Turn starts, capped at 500 messages. */
314
313
  async loadOlder() {
315
314
  if (this.openState !== 'open' || !this.hasMore || this.loadingOlder)
316
315
  return;
@@ -320,7 +319,10 @@ export class Session {
320
319
  this.loadingOlder = true;
321
320
  this.notifier.markDirty();
322
321
  try {
323
- await events.prepend({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES });
322
+ await events.prepend({
323
+ beforeSeq: this.baseSeq,
324
+ ...HISTORY_PAGE_OPTIONS,
325
+ });
324
326
  }
325
327
  catch (error) {
326
328
  if (!isRemoteFailure(error)) {
@@ -347,6 +349,15 @@ export class Session {
347
349
  // loop starts here.
348
350
  if (this.loadingOlder)
349
351
  return Promise.resolve();
352
+ const events = this.events;
353
+ if (events === undefined)
354
+ return Promise.resolve();
355
+ const pending = {
356
+ beforeSeq: this.baseSeq,
357
+ hasMore: this.hasMore,
358
+ pages: [],
359
+ };
360
+ this.pendingHistory = pending;
350
361
  this.jumpTargetSeq = seq;
351
362
  this.loadingOlder = true;
352
363
  this.notifier.markDirty();
@@ -356,17 +367,14 @@ export class Session {
356
367
  const generation = this.openGeneration;
357
368
  this.jumpPromise = (async () => {
358
369
  try {
359
- while (this.hasMore && this.jumpTargetSeq !== null && this.baseSeq > this.jumpTargetSeq) {
370
+ while (pending.hasMore && this.jumpTargetSeq !== null && pending.beforeSeq > this.jumpTargetSeq) {
360
371
  if (generation !== this.openGeneration)
361
372
  return;
362
- const events = this.events;
363
- if (events === undefined)
364
- return;
365
- const before = this.baseSeq;
366
- await events.prepend({ beforeSeq: this.baseSeq, maxMessages: JUMP_PAGE_MESSAGES });
373
+ const before = pending.beforeSeq;
374
+ await events.prepend({ beforeSeq: before, ...JUMP_PAGE_OPTIONS });
367
375
  // No-progress guard: an empty or dropped page that still claims more
368
376
  // history must end the loop, not spin it.
369
- if (this.baseSeq >= before)
377
+ if (pending.beforeSeq >= before)
370
378
  return;
371
379
  }
372
380
  }
@@ -378,7 +386,11 @@ export class Session {
378
386
  finally {
379
387
  this.jumpTargetSeq = null;
380
388
  this.jumpPromise = null;
389
+ this.pendingHistory = null;
381
390
  this.loadingOlder = false;
391
+ if (generation === this.openGeneration && pending.pages.length > 0) {
392
+ this.prependWindow(pending.pages.reverse().flat(), pending.hasMore);
393
+ }
382
394
  this.notifier.markDirty();
383
395
  }
384
396
  })();
@@ -424,8 +436,7 @@ export class Session {
424
436
  * @param running - the new running state.
425
437
  */
426
438
  handleRunning(running) {
427
- // Turn-start conversion: a blank session never runs, so the first
428
- // running:true proves another side's first message landed.
439
+ // Running converts display state without establishing durable turn history.
429
440
  if (running && this.blankBit) {
430
441
  this.blankBit = false;
431
442
  this.notifier.markDirty();
@@ -465,13 +476,15 @@ export class Session {
465
476
  this.notifier.markDirty();
466
477
  }
467
478
  /**
468
- * Blank-bit relay from the authoritative summary source (`session.list` and
469
- * `api-session/added`). Monotone: once any signal (local first send,
470
- * running flip, an earlier summary) cleared it, a stale true never
471
- * re-blanks.
472
- * @param blank - the summary's derived empty-log bit.
479
+ * Apply the Manager's effective display blank, further reconciled with the
480
+ * current `sessionListMetadata` projection. Local send attempts and current
481
+ * running state prevent re-blanking; an earlier false summary alone does not.
482
+ * The Manager retains acceptance and earlier running observations across
483
+ * Session-object replacement.
484
+ * @param blank - New Session display state after Manager reconciliation.
473
485
  */
474
486
  handleBlank(blank) {
487
+ blank = blank && this.projections.values().sessionListMetadata?.blank !== false;
475
488
  if (blank === this.blankBit)
476
489
  return;
477
490
  if (blank && (this.promptAttempted || this.running))
@@ -499,10 +512,12 @@ export class Session {
499
512
  async dispose() {
500
513
  this.stopObservingInbox();
501
514
  // Unsettled echoes retire as failed so their owners can restore or
502
- // release browser resources; echoes already scheduled as observed keep
503
- // that settlement.
504
- for (const requestId of [...this.submissionSettlements.keys()]) {
505
- this.retireFailedSubmission(requestId);
515
+ // release browser resources; admitted echoes keep their observed outcome.
516
+ for (const [requestId, settlement] of [...this.submissionSettlements]) {
517
+ if (settlement.admitted !== undefined)
518
+ this.scheduleObservedRetirement(requestId, settlement.admitted);
519
+ else
520
+ this.retireFailedSubmission(requestId);
506
521
  }
507
522
  this.openGeneration++;
508
523
  const events = this.events;
@@ -527,7 +542,7 @@ export class Session {
527
542
  });
528
543
  this.events = events;
529
544
  try {
530
- await events.open({ maxMessages: PAGE_MESSAGES });
545
+ await events.open(HISTORY_PAGE_OPTIONS);
531
546
  if (generation !== this.openGeneration || this.events !== events)
532
547
  return;
533
548
  this.openState = 'open';
@@ -570,13 +585,33 @@ export class Session {
570
585
  const visible = this.assistantStream.replace(entries, assistantStream);
571
586
  this.baseSeq = SessionLogOffset(entries[0]?.event.seq ?? 0);
572
587
  this.hasMore = hasMore;
588
+ if (this.pendingHistory !== null) {
589
+ this.pendingHistory.beforeSeq = this.baseSeq;
590
+ this.pendingHistory.hasMore = hasMore;
591
+ this.pendingHistory.pages.length = 0;
592
+ }
573
593
  if (visible.some(entry => entry.event.type === 'turn/start'))
574
594
  this.firstPromptPendingTurn = false;
575
595
  if (projections !== undefined)
576
596
  this.projections.seed(projections);
577
597
  this.eventSource.replace(visible, hasMore);
598
+ // A new follow baseline replaces confirmed optimistic inputs with Host-owned rows.
599
+ // Receipt-backed inputs are accepted, not failed, even if their history is outside this window.
600
+ if (projections !== undefined) {
601
+ for (const [requestId, { receipt }] of this.submissionSettlements) {
602
+ if (receipt !== undefined && receipt.seq <= projections.asOfSeq) {
603
+ this.scheduleObservedRetirement(requestId, receipt.attachments);
604
+ }
605
+ }
606
+ }
578
607
  for (const entry of visible)
579
608
  this.observeSubmissionEvent(entry.event);
609
+ if (projections !== undefined) {
610
+ const inbox = projections.values.inbox;
611
+ for (const target of ['next-turn', 'next-step']) {
612
+ this.observeSubmissionInsertions(target, inbox?.[target] ?? [], 0, projections.asOfSeq);
613
+ }
614
+ }
580
615
  this.notifier.markDirty();
581
616
  }
582
617
  publishAssistantEntry(result) {
@@ -609,6 +644,13 @@ export class Session {
609
644
  }
610
645
  /** Prepend one stream-validated history page. */
611
646
  prependWindow(entries, hasMore) {
647
+ if (this.pendingHistory !== null) {
648
+ const pending = this.pendingHistory;
649
+ pending.beforeSeq = entries[0] === undefined ? pending.beforeSeq : SessionLogOffset(entries[0].event.seq);
650
+ pending.hasMore = hasMore;
651
+ pending.pages.push(entries);
652
+ return;
653
+ }
612
654
  this.baseSeq = entries[0] === undefined ? this.baseSeq : SessionLogOffset(entries[0].event.seq);
613
655
  this.hasMore = hasMore;
614
656
  this.eventSource.prepend(entries, hasMore);
@@ -631,37 +673,91 @@ export class Session {
631
673
  if (this.submissionSettlements.size === 0)
632
674
  return;
633
675
  if (event.type === 'agent/inbox/spliced') {
634
- const splice = event.data;
635
- if (Array.isArray(splice?.inserted)) {
636
- for (const message of splice.inserted)
637
- this.observeSubmissionEvent({ type: 'user/message', data: message });
676
+ const { target, start, removedCount = 0, inserted, outcome } = event.data;
677
+ for (const [requestId, settlement] of this.submissionSettlements) {
678
+ const receipt = settlement.receipt;
679
+ if (receipt?.target !== target || receipt.index === null || receipt.seq >= event.seq)
680
+ continue;
681
+ const removed = receipt.index >= start && receipt.index < start + removedCount;
682
+ if (removed && outcome === 'canceled')
683
+ this.retireFailedSubmission(requestId);
684
+ else
685
+ settlement.receipt = {
686
+ ...receipt,
687
+ seq: event.seq,
688
+ index: removed ? null : receipt.index < start ? receipt.index : receipt.index + inserted.length - removedCount,
689
+ };
690
+ }
691
+ this.observeSubmissionInsertions(target, inserted, start, event.seq);
692
+ for (const message of inserted)
693
+ this.observeSubmissionMessage(message, false);
694
+ return;
695
+ }
696
+ if (event.type === 'request/context' || event.type === 'turn/end') {
697
+ for (const [requestId, settlement] of this.submissionSettlements) {
698
+ if (settlement.admitted === undefined && settlement.receipt?.index === null
699
+ && settlement.receipt.seq < event.seq)
700
+ this.retireFailedSubmission(requestId);
638
701
  }
639
702
  return;
640
703
  }
641
- if (event.type !== 'user/message')
704
+ if (event.type === 'user/message')
705
+ this.observeSubmissionMessage(event.data, true);
706
+ }
707
+ observeSubmissionInsertions(target, messages, start, seq) {
708
+ for (const [index, message] of messages.entries()) {
709
+ const source = message.source;
710
+ if (source.kind !== 'user' || !('rpcId' in source))
711
+ continue;
712
+ const settlement = this.submissionSettlements.get(source.rpcId);
713
+ if (settlement === undefined || settlement.placement === 'queued'
714
+ || settlement.retiring || (settlement.receipt?.seq ?? -1) > seq)
715
+ continue;
716
+ settlement.receipt = { target, seq, index: start + index, attachments: attachmentRefsIn(message.content) };
717
+ }
718
+ }
719
+ observeSubmissionMessage(message, admitted) {
720
+ const source = message.source;
721
+ if (source.kind !== 'user' || !('rpcId' in source))
722
+ return;
723
+ const settlement = this.submissionSettlements.get(source.rpcId);
724
+ if (settlement === undefined || settlement.retiring)
725
+ return;
726
+ if (!admitted) {
727
+ if (settlement.placement === 'queued')
728
+ this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content));
729
+ return;
730
+ }
731
+ settlement.admitted = attachmentRefsIn(message.content);
732
+ this.retireAdmittedSubmission(source.rpcId);
733
+ }
734
+ /** Retire admitted Chat identities only after stale Inbox rows can no longer reappear. */
735
+ retireAdmittedSubmission(requestId) {
736
+ const settlement = this.submissionSettlements.get(requestId);
737
+ if (settlement?.admitted === undefined)
642
738
  return;
643
- // Structural read: window entries may be compact history records, so the
644
- // fields are narrowed rather than trusted (same posture as Conversation
645
- // assembly matchers).
646
- const data = event.data;
647
- const source = data?.source;
648
- if (source?.kind !== 'user' || typeof source.rpcId !== 'string')
739
+ const receipt = settlement.receipt;
740
+ if (receipt?.index === null
741
+ && (this.projections.seqOf('inbox') ?? -1) < receipt.seq)
649
742
  return;
650
- this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(data?.content));
743
+ this.scheduleObservedRetirement(requestId, settlement.admitted);
651
744
  }
652
- /** Retire local echoes when their accepted messages appear in the durable Inbox projection. */
745
+ /** Inbox acceptance retires queued echoes; its watermark completes admitted Chat handoffs. */
653
746
  observeSubmissionInbox() {
654
747
  if (this.submissionSettlements.size === 0)
655
748
  return;
656
749
  const inbox = this.projections.get('inbox');
657
750
  if (inbox === undefined)
658
751
  return;
659
- for (const message of [...inbox['next-turn'], ...inbox['next-step']]) {
660
- const source = message.source;
661
- if (source.kind === 'user' && 'rpcId' in source) {
662
- this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content));
663
- }
752
+ const seq = this.projections.seqOf('inbox');
753
+ for (const target of ['next-turn', 'next-step']) {
754
+ if (seq !== undefined)
755
+ this.observeSubmissionInsertions(target, inbox[target], 0, seq);
756
+ for (const message of inbox[target])
757
+ this.observeSubmissionMessage(message, false);
664
758
  }
759
+ for (const requestId of this.submissionSettlements.keys())
760
+ this.retireAdmittedSubmission(requestId);
665
761
  }
666
762
  /**
667
763
  * Latch one observed settlement and remove the echo an animation frame
@@ -679,7 +775,7 @@ export class Session {
679
775
  /** Remove one unsettled echo immediately (prompt rejection, abort, or disposal). */
680
776
  retireFailedSubmission(requestId) {
681
777
  const settlement = this.submissionSettlements.get(requestId);
682
- if (settlement === undefined || settlement.retiring)
778
+ if (settlement === undefined || settlement.retiring || settlement.admitted !== undefined)
683
779
  return;
684
780
  settlement.retiring = true;
685
781
  this.finishSubmission(requestId, { reason: 'failed' });
@@ -710,6 +806,7 @@ export class Session {
710
806
  this.notifier.markDirty();
711
807
  }
712
808
  buildSnapshot() {
809
+ const identity = this.projections.values().subagent;
713
810
  return {
714
811
  sessionId: this.sessionId,
715
812
  pendingSubmissions: this.pendingSubmissions,
@@ -717,7 +814,9 @@ export class Session {
717
814
  subagent: this.address === undefined
718
815
  ? null
719
816
  : {
720
- address: this.address,
817
+ address: this.address.mode === 'unknown' && identity != null
818
+ ? { ...this.address, mode: identity.mode }
819
+ : this.address,
721
820
  ...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }),
722
821
  },
723
822
  removed: this.removed,
@@ -76,7 +76,7 @@ export class SessionEventStream extends RemoteJournalStream {
76
76
  for await (const frame of this.remote.session.follow({
77
77
  address: this.address,
78
78
  assistantStream: true,
79
- ...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }),
79
+ ...this.repairRequest(request),
80
80
  }, signal)) {
81
81
  if (frame.type === 'snapshot') {
82
82
  for (const record of frame.records)
@@ -121,7 +121,10 @@ export class SessionEventStream extends RemoteJournalStream {
121
121
  }
122
122
  /** @inheritdoc */
123
123
  repairRequest(request) {
124
- return request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages };
124
+ return {
125
+ ...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }),
126
+ ...(request.turnWindow === undefined ? {} : { turnWindow: request.turnWindow }),
127
+ };
125
128
  }
126
129
  }
127
130
  //# sourceMappingURL=transport.js.map
@@ -32,8 +32,10 @@ export declare class SessionCommandController {
32
32
  */
33
33
  rename(request: SessionRenameRequest): Promise<SessionRenameValue>;
34
34
  /**
35
- * Create a new ordinary Session from one completed-turn prefix.
36
- * @param request - source Session and optional event anchor.
35
+ * Create a new ordinary Session from an exact event prefix. An explicit
36
+ * `atSeq` is the inclusive cut; an omitted value selects the latest
37
+ * completed-turn prefix. An open cut receives synthetic fork closers.
38
+ * @param request - source Session and optional exact event boundary.
37
39
  * @returns the new Session identity.
38
40
  */
39
41
  fork(request: SessionForkRequest): Promise<SessionForkValue>;
@@ -55,6 +55,7 @@ import { randomUUID } from 'node:crypto';
55
55
  import { brandString } from '@deepseek-ai/dsh-brand';
56
56
  import { AttachmentError } from '@deepseek-ai/dsh-attachment';
57
57
  import { ReasoningEffortId, assistantStreamChunks, createUserMessage, freezeMessage, } from '@deepseek-ai/dsh-llm';
58
+ import { buildForkSeed } from '@deepseek-ai/dsh-session/fork';
58
59
  import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session';
59
60
  import { SessionQueryError } from '@deepseek-ai/dsh-session-query';
60
61
  import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title';
@@ -65,6 +66,23 @@ import { ApiSessionCwdConflict, ApiSessionNotFound, ApiSessionPresetConflict, Ap
65
66
  function hasPromptContent(content) {
66
67
  return content.some(part => part.type !== 'text' || part.text.trim().length > 0);
67
68
  }
69
+ /**
70
+ * Resolve the omitted-`atSeq` default to the latest completed-turn prefix,
71
+ * including standalone events before the next turn begins.
72
+ */
73
+ function latestCompletedPrefixBoundary(events) {
74
+ const lastTurnEnd = events.findLast(event => event.type === 'turn/end');
75
+ if (lastTurnEnd === undefined)
76
+ return undefined;
77
+ let boundary = lastTurnEnd.seq;
78
+ for (const next of events.slice(boundary + 1)) {
79
+ if (next.type === 'turn/start' || (next.type === 'user/message' && next.surfaceOp === 'append')
80
+ || next.type === 'agent/inbox/spliced')
81
+ break;
82
+ boundary = next.seq;
83
+ }
84
+ return boundary;
85
+ }
68
86
  /** Implements Session business commands delegated by the Session Controller Remote service. */
69
87
  export class SessionCommandController {
70
88
  ctx;
@@ -180,8 +198,10 @@ export class SessionCommandController {
180
198
  }
181
199
  }
182
200
  /**
183
- * Create a new ordinary Session from one completed-turn prefix.
184
- * @param request - source Session and optional event anchor.
201
+ * Create a new ordinary Session from an exact event prefix. An explicit
202
+ * `atSeq` is the inclusive cut; an omitted value selects the latest
203
+ * completed-turn prefix. An open cut receives synthetic fork closers.
204
+ * @param request - source Session and optional exact event boundary.
185
205
  * @returns the new Session identity.
186
206
  */
187
207
  async fork(request) {
@@ -208,20 +228,13 @@ export class SessionCommandController {
208
228
  throw new RemoteError('gateway/internal', `fork source unavailable for session "${request.sessionId}": ${String(error)}`, {});
209
229
  }
210
230
  const source = __addDisposableResource(env_1, observed, false);
211
- const lastSeq = source.events.at(-1)?.seq ?? -1;
212
- const anchoredBoundary = atSeq === undefined
213
- ? undefined
214
- : source.events.find(event => event.type === 'turn/end' && event.seq >= atSeq);
215
- const boundary = anchoredBoundary
216
- ?? (atSeq === undefined || atSeq > lastSeq
217
- ? source.events.findLast(event => event.type === 'turn/end')
218
- : undefined);
219
- if (boundary === undefined) {
220
- throw new RemoteError('session/fork-unavailable', atSeq !== undefined && atSeq <= lastSeq
221
- ? `session "${request.sessionId}" has not completed the turn containing event ${String(atSeq)}`
222
- : `session "${request.sessionId}" has no completed turn to fork from`, { sessionId: request.sessionId });
231
+ const boundary = atSeq ?? latestCompletedPrefixBoundary(source.events);
232
+ if (boundary === undefined || source.events[boundary]?.seq !== boundary) {
233
+ throw new RemoteError('session/fork-unavailable', request.atSeq === undefined
234
+ ? `session "${request.sessionId}" has no completed turn to fork from`
235
+ : `event ${String(request.atSeq)} does not exist in session "${request.sessionId}" (last seq: ${String(source.events.at(-1)?.seq ?? 'none')})`, { sessionId: request.sessionId });
223
236
  }
224
- const cut = SessionLogOffset(boundary.seq + 1);
237
+ const seed = buildForkSeed(source.events, boundary);
225
238
  let workspace;
226
239
  try {
227
240
  workspace = await this.forkWorkspace(source.header);
@@ -235,8 +248,8 @@ export class SessionCommandController {
235
248
  const { provider, model } = this.ctx.agentDefaultModel.currentSelection();
236
249
  await this.ctx.agents.create({
237
250
  sessionId: childId,
238
- seed: source.events.slice(0, cut),
239
- inheritedEventCount: cut,
251
+ seed,
252
+ inheritedEventCount: SessionLogOffset(boundary + 1),
240
253
  meta: {
241
254
  ...(source.header.cwd === undefined ? {} : { cwd: source.header.cwd }),
242
255
  parentSession: source.header.id,
@@ -383,6 +396,7 @@ export class SessionCommandController {
383
396
  */
384
397
  async updateQueue(request) {
385
398
  if (request.action.kind === 'edit') {
399
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- Remote callers can submit untyped JSON.
386
400
  if (request.action.content.some(block => block.type !== 'text')) {
387
401
  throw new RemoteError('session/attachment-invalid', 'queue edits accept text content only', { reason: 'QUEUE_EDIT_NON_TEXT' });
388
402
  }
@@ -471,6 +485,9 @@ export class SessionCommandController {
471
485
  rejectCreation(sessionId, error) {
472
486
  if (remoteErrorOf(error) !== undefined)
473
487
  throw error;
488
+ if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') {
489
+ throw new RemoteError('session/writer-held', error.message, { sessionId });
490
+ }
474
491
  if (error instanceof ApiSessionPresetConflict) {
475
492
  throw new RemoteError('agent-preset/conflict', error.message, {
476
493
  sessionId: error.sessionId,
@@ -554,33 +571,52 @@ function imageBlockIn(content, match) {
554
571
  if (match(ref))
555
572
  return ref;
556
573
  }
557
- if (block.type === 'tool-result') {
558
- const nested = imageBlockIn(block.content, match);
559
- if (nested !== undefined)
560
- return nested;
561
- }
562
574
  }
563
575
  return undefined;
564
576
  }
577
+ /** Read only first-party declared content fields; unknown event payloads stay opaque. */
565
578
  function imageInEvent(event, match) {
566
579
  const data = event.data;
567
- const direct = imageBlockIn(data.content, match);
568
- if (direct !== undefined)
569
- return direct;
570
- const message = imageBlockIn(data.message?.content, match);
571
- if (message !== undefined)
572
- return message;
573
- for (const inserted of data.inserted ?? []) {
574
- const found = imageBlockIn(inserted.content, match);
575
- if (found !== undefined)
576
- return found;
577
- }
578
- if (event.type === 'assistant/message' || event.type === 'assistant/attempt') {
579
- for (const chunk of assistantStreamChunks(event.data.stream, 'block-end')) {
580
- const found = imageBlockIn([chunk.block], match);
580
+ // First-party event payloads can be present without their producer plugin mounted.
581
+ const type = event.type;
582
+ switch (type) {
583
+ case 'user/message':
584
+ case 'tool/ptc-dispatch':
585
+ return imageBlockIn(data.content, match);
586
+ case 'system/message':
587
+ case 'developer/message':
588
+ case 'tool/result':
589
+ case 'team/message/queued':
590
+ return imageBlockIn(data.message?.content, match);
591
+ case 'agent/inbox/spliced': {
592
+ const messages = data.inserted;
593
+ if (!Array.isArray(messages))
594
+ return undefined;
595
+ for (const message of messages) {
596
+ if (typeof message !== 'object' || message === null || Array.isArray(message))
597
+ continue;
598
+ const found = imageBlockIn(message.content, match);
599
+ if (found !== undefined)
600
+ return found;
601
+ }
602
+ return undefined;
603
+ }
604
+ case 'compaction/summary':
605
+ return imageBlockIn(data.summary, match) ?? imageBlockIn(data.rawOutput, match);
606
+ case 'assistant/message': {
607
+ const found = imageBlockIn(data.message?.content, match);
581
608
  if (found !== undefined)
582
609
  return found;
610
+ break;
583
611
  }
612
+ case 'assistant/attempt': break;
613
+ default: return undefined;
614
+ }
615
+ const assistant = event;
616
+ for (const chunk of assistantStreamChunks(assistant.data.stream, 'block-end')) {
617
+ const found = imageBlockIn([chunk.block], match);
618
+ if (found !== undefined)
619
+ return found;
584
620
  }
585
621
  return undefined;
586
622
  }
@@ -1,11 +1,11 @@
1
- /** Live Session jobs and projection state with reconnect baselines. */
1
+ /** Live Session projection state with reconnect baselines. */
2
2
  import type { Context } from '@deepseek-ai/cordis';
3
3
  import type { SessionControlFrame } from './types.ts';
4
4
  /** Owns the Host-wide Session control stream. */
5
5
  export declare class SessionControlController {
6
6
  private readonly ctx;
7
7
  private readonly streams;
8
- /** @param ctx - Host context carrying live Agent, projection, and jobs services. */
8
+ /** @param ctx - Host context carrying live Agent and projection services. */
9
9
  constructor(ctx: Context);
10
10
  /**
11
11
  * Open one generation of Host-wide live control state.
@@ -15,8 +15,6 @@ export declare class SessionControlController {
15
15
  control(signal: AbortSignal): AsyncIterable<SessionControlFrame>;
16
16
  private baseline;
17
17
  private projectionBaseline;
18
- private onJobsChanged;
19
- private jobsFor;
20
18
  private broadcast;
21
19
  }
22
20
  //# sourceMappingURL=control.d.ts.map