narraeon 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { equalModelHostBinding, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, } from "../model/ModelHost.js";
2
+ import { equalModelHostBinding, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, } from "../model/ModelHost.js";
3
3
  import { FileNativePromptCompiler, } from "../prompt/FileNativePromptCompiler.js";
4
4
  import { runPlayFollowupRequests, } from "./PlayFollowupRequests.js";
5
5
  import { FileNativePlayDocuments, fingerprintControl, } from "./PlayDocumentTools.js";
@@ -35,6 +35,7 @@ export class PlayCallChain {
35
35
  #active = new Map();
36
36
  #worldChains = new Map();
37
37
  #loadedCursors = new Map();
38
+ #activeInvocations = new Map();
38
39
  constructor(worlds, compiler = new FileNativePromptCompiler(), artifacts, failureLog) {
39
40
  this.#worlds = worlds;
40
41
  this.#compiler = compiler;
@@ -42,6 +43,9 @@ export class PlayCallChain {
42
43
  this.#failureLog = failureLog;
43
44
  }
44
45
  async start(input) {
46
+ return this.#runInvocation(input, (signal) => this.#start(input, signal));
47
+ }
48
+ async #start(input, signal) {
45
49
  validateIdentity(input.chainId, "call-chain ID");
46
50
  validateIdentity(input.exchangeId, "exchange ID");
47
51
  validatePlayerText(input.playerText);
@@ -134,9 +138,12 @@ export class PlayCallChain {
134
138
  this.#active.set(session.chainId, session);
135
139
  this.#worldChains.set(session.worldId, session.chainId);
136
140
  await this.#persist(session);
137
- return this.#submitPlayer(session, input.exchangeId, input.playerText, "fresh", input.modelHost, input.observer);
141
+ return this.#submitPlayer(session, input.exchangeId, input.playerText, "fresh", input.modelHost, signal, input.observer);
138
142
  }
139
143
  async append(input) {
144
+ return this.#runInvocation(input, (signal) => this.#append(input, signal));
145
+ }
146
+ async #append(input, signal) {
140
147
  validateIdentity(input.exchangeId, "exchange ID");
141
148
  const session = await this.#requireChain(input.worldId, input.chainId);
142
149
  await this.#assertCurrentHead(session);
@@ -147,10 +154,10 @@ export class PlayCallChain {
147
154
  if (session.status === "interrupted") {
148
155
  if (!session.canRetry || session.lastRequest === null)
149
156
  throw new PlayCallChainError("Call-chain processing failed, and there is no model context to continue.");
150
- return this.#dispatch(session, input.modelHost, structuredClone(session.lastRequest), session.lastRequestAttempt + 1, input.observer);
157
+ return this.#dispatch(session, input.modelHost, structuredClone(session.lastRequest), session.lastRequestAttempt + 1, signal, input.observer);
151
158
  }
152
159
  session.exchange += 1;
153
- return this.#dispatch(session, input.modelHost, createRequest(session, input.modelHost, session.exchange), 1, input.observer);
160
+ return this.#dispatch(session, input.modelHost, createRequest(session, input.modelHost, session.exchange), 1, signal, input.observer);
154
161
  }
155
162
  if (duplicatePlayerExchange(session, input.exchangeId, input.playerText, "append"))
156
163
  return projectView(session);
@@ -158,14 +165,100 @@ export class PlayCallChain {
158
165
  throw new PlayCallChainError(session.canRetry
159
166
  ? "The call chain has an incomplete model response; clear the input and append a continuation, or use a fresh context."
160
167
  : "The call chain was interrupted; use a fresh context.");
161
- return this.#submitPlayer(session, input.exchangeId, input.playerText, "append", input.modelHost, input.observer);
168
+ return this.#submitPlayer(session, input.exchangeId, input.playerText, "append", input.modelHost, signal, input.observer);
169
+ }
170
+ cancel(input) {
171
+ validateIdentity(input.worldId, "world ID");
172
+ validateIdentity(input.chainId, "call-chain ID");
173
+ validateIdentity(input.exchangeId, "exchange ID");
174
+ const active = this.#activeInvocations.get(input.chainId);
175
+ if (active?.worldId !== input.worldId ||
176
+ active?.exchangeId !== input.exchangeId ||
177
+ active?.abortable !== true)
178
+ return { outcome: "not_running" };
179
+ if (!active.controller.signal.aborted) {
180
+ active.phase = "cancelling";
181
+ active.lastActivityAt = Date.now();
182
+ active.controller.abort();
183
+ }
184
+ return { outcome: "cancellation_requested" };
185
+ }
186
+ async #runInvocation(input, run) {
187
+ validateIdentity(input.worldId, "world ID");
188
+ validateIdentity(input.chainId, "call-chain ID");
189
+ validateIdentity(input.exchangeId, "exchange ID");
190
+ if (this.#activeInvocations.has(input.chainId) ||
191
+ [...this.#activeInvocations.values()].some(({ worldId }) => worldId === input.worldId))
192
+ throw new PlayCallChainError("A model request is already running for this world or call chain.");
193
+ const startedAt = Date.now();
194
+ const invocation = {
195
+ worldId: input.worldId,
196
+ chainId: input.chainId,
197
+ exchangeId: input.exchangeId,
198
+ phase: "preparing",
199
+ startedAt,
200
+ lastActivityAt: startedAt,
201
+ reasoningChars: 0,
202
+ textChars: 0,
203
+ toolChars: 0,
204
+ toolCalls: 0,
205
+ dispatches: 0,
206
+ controller: new AbortController(),
207
+ abortable: true,
208
+ };
209
+ this.#activeInvocations.set(input.chainId, invocation);
210
+ try {
211
+ return await run(invocation.controller.signal);
212
+ }
213
+ finally {
214
+ if (this.#activeInvocations.get(input.chainId) === invocation)
215
+ this.#activeInvocations.delete(input.chainId);
216
+ }
217
+ }
218
+ #observeInvocation(chainId, phase, increments = {}) {
219
+ const active = this.#activeInvocations.get(chainId);
220
+ if (active === undefined || active.phase === "cancelling")
221
+ return;
222
+ active.phase = phase;
223
+ active.lastActivityAt = Date.now();
224
+ active.reasoningChars += increments.reasoningChars ?? 0;
225
+ active.textChars += increments.textChars ?? 0;
226
+ active.toolChars += increments.toolChars ?? 0;
227
+ active.toolCalls += increments.toolCalls ?? 0;
228
+ active.dispatches += increments.dispatches ?? 0;
229
+ }
230
+ #setInvocationAbortable(chainId, abortable) {
231
+ const active = this.#activeInvocations.get(chainId);
232
+ if (active !== undefined)
233
+ active.abortable = abortable;
234
+ }
235
+ #projectActiveView(session) {
236
+ const view = projectView(session);
237
+ const active = this.#activeInvocations.get(session.chainId);
238
+ if (active?.worldId !== session.worldId)
239
+ return view;
240
+ const { controller: _controller, worldId: _worldId, abortable: _abortable, ...progress } = active;
241
+ void _controller;
242
+ void _worldId;
243
+ void _abortable;
244
+ return { ...view, activeInvocation: structuredClone(progress) };
245
+ }
246
+ #notifySnapshot(observer, session) {
247
+ try {
248
+ observer?.onSnapshot?.(this.#projectActiveView(session));
249
+ }
250
+ catch {
251
+ // A browser stream may disconnect while the durable model request keeps
252
+ // running. Presentation failure never cancels or changes play Authority.
253
+ }
162
254
  }
163
255
  async inspectWorld(worldId) {
164
256
  const activeId = this.#worldChains.get(worldId);
165
257
  const active = activeId === undefined ? undefined : this.#active.get(activeId);
166
258
  if (active !== undefined) {
167
- await this.#reconcileSessionAdvance(active);
168
- return projectView(active);
259
+ if (this.#activeInvocations.get(active.chainId)?.worldId !== active.worldId)
260
+ await this.#reconcileSessionAdvance(active);
261
+ return this.#projectActiveView(active);
169
262
  }
170
263
  const persisted = await this.#readPersisted(worldId);
171
264
  if (persisted === null)
@@ -177,7 +270,28 @@ export class PlayCallChain {
177
270
  await this.#reconcileSessionAdvance(session, advance);
178
271
  if (session.status === "running")
179
272
  await this.#interruptAbandonedDispatch(session);
180
- return projectView(session);
273
+ return this.#projectActiveView(session);
274
+ }
275
+ async deriveWorld(input) {
276
+ const result = await this.#worlds.deriveWorld({
277
+ ...input,
278
+ stageTarget: async ({ targetWorldRoot, targetWorldId, binding }) => {
279
+ await this.stageForkToDerivedWorld({
280
+ sourceWorldId: input.sourceWorldId,
281
+ sourceHead: input.sourceHead,
282
+ targetWorldId,
283
+ targetWorldRoot,
284
+ targetBinding: binding,
285
+ });
286
+ },
287
+ });
288
+ if ((await this.inspectWorld(result.world.worldId)) === null)
289
+ await this.forkToDerivedWorld({
290
+ sourceWorldId: input.sourceWorldId,
291
+ sourceHead: input.sourceHead,
292
+ targetWorldId: result.world.worldId,
293
+ });
294
+ return result;
181
295
  }
182
296
  async forkToDerivedWorld(input) {
183
297
  const existing = await this.#readPersisted(input.targetWorldId);
@@ -193,12 +307,34 @@ export class PlayCallChain {
193
307
  const sourceContext = sourceContexts[selectedContextIndex];
194
308
  const sourceEvents = eventsThroughHead(sourceContext.events, input.sourceHead);
195
309
  return this.#forkSelectionToDerivedWorld({
310
+ sourceWorldId: input.sourceWorldId,
311
+ sourceContexts,
312
+ selectedContextIndex,
313
+ sourceEvents,
314
+ sourceHead: input.sourceHead,
315
+ targetWorldId: input.targetWorldId,
316
+ branchIdentity: input.sourceHead,
317
+ });
318
+ }
319
+ async stageForkToDerivedWorld(input) {
320
+ const source = await this.#readPersisted(input.sourceWorldId);
321
+ if (source === null)
322
+ return null;
323
+ const sourceContexts = await this.#worlds.playTimeline.readAllContexts(input.sourceWorldId);
324
+ const selectedContextIndex = sourceContexts.findIndex(({ events }) => eventsThroughHead(events, input.sourceHead) !== null);
325
+ if (selectedContextIndex < 0)
326
+ return null;
327
+ const sourceEvents = eventsThroughHead(sourceContexts[selectedContextIndex].events, input.sourceHead);
328
+ return this.#forkSelectionToDerivedWorld({
329
+ sourceWorldId: input.sourceWorldId,
196
330
  sourceContexts,
197
331
  selectedContextIndex,
198
332
  sourceEvents,
199
333
  sourceHead: input.sourceHead,
200
334
  targetWorldId: input.targetWorldId,
201
335
  branchIdentity: input.sourceHead,
336
+ targetWorldRoot: input.targetWorldRoot,
337
+ targetBinding: input.targetBinding,
202
338
  });
203
339
  }
204
340
  /**
@@ -232,8 +368,12 @@ export class PlayCallChain {
232
368
  throw new PlayCallChainError("The same replacement exchange ID is already bound to different player text.");
233
369
  const authority = await this.#worlds.readAuthorityHistory(input.worldId);
234
370
  const commit = authority.commits.find(({ head }) => head === appliedEvent.committedHead);
235
- if (commit?.operationId !== input.operationId ||
236
- commit.mode !== "timeline_revision")
371
+ const operation = await this.#worlds.getOperationOutcome(input.operationId);
372
+ if (commit?.mode !== "timeline_revision" ||
373
+ (operation.outcome !== "committed" &&
374
+ operation.outcome !== "committed_materialization_pending") ||
375
+ operation.worldId !== input.worldId ||
376
+ operation.head !== appliedEvent.committedHead)
237
377
  throw new PlayCallChainError("The replacement exchange ID is already used by another commit.");
238
378
  return {
239
379
  outcome: "revised",
@@ -374,20 +514,36 @@ export class PlayCallChain {
374
514
  };
375
515
  }
376
516
  async #forkSelectionToDerivedWorld(input) {
377
- const existing = await this.#readPersisted(input.targetWorldId);
378
- if (existing !== null)
379
- return projectView(existing);
517
+ if (input.targetWorldRoot === undefined) {
518
+ const existing = await this.#readPersisted(input.targetWorldId);
519
+ if (existing !== null)
520
+ return projectView(existing);
521
+ }
380
522
  const sourceContext = input.sourceContexts[input.selectedContextIndex];
381
- if ((await this.#worlds.currentHead(input.targetWorldId)) !== input.sourceHead)
523
+ if (input.targetWorldRoot === undefined &&
524
+ (await this.#worlds.currentHead(input.targetWorldId)) !== input.sourceHead)
382
525
  throw new PlayCallChainError("The forked world's Authority does not match the selected history endpoint, so the call chain cannot be written.");
383
526
  const [baseline, selected] = await Promise.all([
384
- this.#worlds.recoverEndpoint(input.targetWorldId, sourceContext.baselineHead),
385
- this.#worlds.recoverEndpoint(input.targetWorldId, input.sourceHead),
527
+ this.#worlds.recoverEndpoint(input.targetWorldRoot === undefined
528
+ ? input.targetWorldId
529
+ : input.sourceWorldId, sourceContext.baselineHead),
530
+ this.#worlds.recoverEndpoint(input.targetWorldRoot === undefined
531
+ ? input.targetWorldId
532
+ : input.sourceWorldId, input.sourceHead),
386
533
  ]);
387
- const transcript = transcriptThroughEvents(sourceContext.transcript, input.sourceEvents);
388
- const completedKeys = completedToolKeys(input.sourceEvents);
534
+ const selectsCompleteContext = input.sourceEvents.length === sourceContext.events.length;
535
+ // A complete context already defines the selected trace closure. Preserve
536
+ // its model transcript verbatim instead of reconstructing it from the
537
+ // independently persisted page-event projection.
538
+ const transcript = selectsCompleteContext
539
+ ? structuredClone(sourceContext.transcript)
540
+ : transcriptThroughEvents(sourceContext.transcript, input.sourceEvents);
541
+ const completedKeys = selectsCompleteContext
542
+ ? null
543
+ : completedToolKeys(input.sourceEvents);
389
544
  const events = structuredClone(input.sourceEvents);
390
- const derivedBinding = await this.#worlds.bindPlayCallChain(input.targetWorldId);
545
+ const derivedBinding = input.targetBinding ??
546
+ (await this.#worlds.bindPlayCallChain(input.targetWorldId));
391
547
  const derivedDocuments = restorePlayDocuments(derivedBinding.files, sourceContext, input.sourceEvents);
392
548
  const now = Date.now();
393
549
  const timelineGeneration = this.#worlds.playTimeline.newGeneration();
@@ -396,7 +552,7 @@ export class PlayCallChain {
396
552
  .slice(0, input.selectedContextIndex)
397
553
  .entries()) {
398
554
  const chainId = derivedChainId(context.chainId, `prefix:${input.branchIdentity}:${index}`, input.targetWorldId);
399
- await this.#worlds.playTimeline.persist({
555
+ const previousContext = {
400
556
  ...independentContextCopy(context),
401
557
  schemaVersion: 3,
402
558
  kind: "play_call_chain",
@@ -405,7 +561,16 @@ export class PlayCallChain {
405
561
  previousContexts: [],
406
562
  previousChainId,
407
563
  timelineGeneration,
408
- });
564
+ };
565
+ if (input.targetWorldRoot === undefined)
566
+ await this.#worlds.playTimeline.persist(previousContext);
567
+ else
568
+ await this.#worlds.playTimeline.cloneContextPrefixToStaging({
569
+ sourceWorldId: input.sourceWorldId,
570
+ targetWorldRoot: input.targetWorldRoot,
571
+ source: context,
572
+ target: previousContext,
573
+ });
409
574
  previousChainId = chainId;
410
575
  }
411
576
  const derived = {
@@ -438,7 +603,7 @@ export class PlayCallChain {
438
603
  transcript,
439
604
  events,
440
605
  completedTools: sourceContext.completedTools
441
- .filter(({ key }) => completedKeys.has(key))
606
+ .filter(({ key }) => completedKeys === null || completedKeys.has(key))
442
607
  .map((item) => structuredClone(item)),
443
608
  documentAuthorizationCheckpoints: authorizationCheckpointsThroughEvents(sourceContext, events, derivedDocuments.authorizationCheckpoint()),
444
609
  changedDocuments: changedDocumentsAtHead(sourceContext.changedDocuments, baseline.state, selected.state),
@@ -452,7 +617,15 @@ export class PlayCallChain {
452
617
  lastFailure: null,
453
618
  updatedAt: now,
454
619
  };
455
- await this.#worlds.playTimeline.persist(structuredClone(derived));
620
+ if (input.targetWorldRoot === undefined)
621
+ await this.#worlds.playTimeline.persist(structuredClone(derived));
622
+ else
623
+ await this.#worlds.playTimeline.cloneContextPrefixToStaging({
624
+ sourceWorldId: input.sourceWorldId,
625
+ targetWorldRoot: input.targetWorldRoot,
626
+ source: sourceContext,
627
+ target: structuredClone(derived),
628
+ });
456
629
  return projectView(derived);
457
630
  }
458
631
  forgetWorld(worldId) {
@@ -464,7 +637,7 @@ export class PlayCallChain {
464
637
  this.#active.delete(activeId);
465
638
  this.#worldChains.delete(worldId);
466
639
  }
467
- async #submitPlayer(session, exchangeId, playerText, context, modelHost, observer) {
640
+ async #submitPlayer(session, exchangeId, playerText, context, modelHost, signal, observer) {
468
641
  if (duplicatePlayerExchange(session, exchangeId, playerText, context))
469
642
  return projectView(session);
470
643
  if (session.status === "running")
@@ -505,7 +678,7 @@ export class PlayCallChain {
505
678
  if (accepted.outcome === "committed" ||
506
679
  accepted.outcome === "committed_materialization_pending") {
507
680
  await this.#settlePlayerAdvance(session, advance);
508
- return this.#dispatch(session, modelHost, structuredClone(nextRequest), 1, observer);
681
+ return this.#dispatch(session, modelHost, structuredClone(nextRequest), 1, signal, observer);
509
682
  }
510
683
  const message = error instanceof Error
511
684
  ? error.message
@@ -522,7 +695,7 @@ export class PlayCallChain {
522
695
  await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
523
696
  throw new PlayCallChainError(message);
524
697
  }
525
- return this.#dispatch(session, modelHost, structuredClone(nextRequest), 1, observer);
698
+ return this.#dispatch(session, modelHost, structuredClone(nextRequest), 1, signal, observer);
526
699
  }
527
700
  async #settlePlayerAdvance(session, advance) {
528
701
  const outcome = await this.#worlds.commitPlayStep({
@@ -786,10 +959,14 @@ export class PlayCallChain {
786
959
  throw new PlayCallChainError("Authority accepted a response whose durable settlement is missing.");
787
960
  return this.#settleResponseAdvance(session, advance, current.settlementPrepared.settlement);
788
961
  }
789
- async #dispatch(session, modelHost, firstRequest, firstAttempt, observer) {
962
+ async #dispatch(session, modelHost, firstRequest, firstAttempt, signal, observer) {
790
963
  let request = structuredClone(firstRequest);
791
964
  let attempt = firstAttempt;
792
965
  for (;;) {
966
+ this.#setInvocationAbortable(session.chainId, true);
967
+ if (signal.aborted)
968
+ return this.#cancelBeforeProviderDispatch(session, request, attempt, observer);
969
+ this.#observeInvocation(session.chainId, "waiting");
793
970
  session.status = "running";
794
971
  session.canRetry = false;
795
972
  session.lastFailure = null;
@@ -824,11 +1001,22 @@ export class PlayCallChain {
824
1001
  };
825
1002
  await this.#worlds.playAdvances.begin(advance);
826
1003
  crashAtPlayAdvanceEdge("after_response_advance_began");
827
- notifySnapshot(observer, session);
1004
+ this.#notifySnapshot(observer, session);
1005
+ if (signal.aborted)
1006
+ return this.#cancelBeforeProviderDispatch(session, request, attempt, observer, advance);
828
1007
  let response;
829
1008
  try {
1009
+ this.#observeInvocation(session.chainId, "waiting", { dispatches: 1 });
830
1010
  response = await modelHost.exchange(request, {
1011
+ signal,
831
1012
  onDelta: (delta) => {
1013
+ this.#observeInvocation(session.chainId, delta.kind, {
1014
+ ...(delta.kind === "reasoning"
1015
+ ? { reasoningChars: delta.text.length }
1016
+ : delta.kind === "text"
1017
+ ? { textChars: delta.text.length }
1018
+ : { toolChars: delta.text.length }),
1019
+ });
832
1020
  if (delta.kind === "text")
833
1021
  event.text += delta.text;
834
1022
  else if (delta.kind === "reasoning")
@@ -844,6 +1032,13 @@ export class PlayCallChain {
844
1032
  });
845
1033
  },
846
1034
  });
1035
+ if (signal.aborted)
1036
+ throw new ModelHostCancelledError("The player cancelled the dispatched model request; its response was not committed and cannot be replayed.");
1037
+ this.#setInvocationAbortable(session.chainId, false);
1038
+ if ((response.toolCalls?.length ?? 0) > 0)
1039
+ this.#observeInvocation(session.chainId, "tool", {
1040
+ toolCalls: response.toolCalls?.length ?? 0,
1041
+ });
847
1042
  if (response.diagnostics !== undefined)
848
1043
  await this.#failureLog?.recordExchangeIfActive(response.diagnostics);
849
1044
  }
@@ -854,7 +1049,9 @@ export class PlayCallChain {
854
1049
  : "The model request was interrupted.";
855
1050
  session.events.push({
856
1051
  id: session.nextEventId++,
857
- kind: "failure",
1052
+ kind: error instanceof ModelHostCancelledError
1053
+ ? "cancellation"
1054
+ : "failure",
858
1055
  message,
859
1056
  });
860
1057
  session.status = "interrupted";
@@ -865,7 +1062,7 @@ export class PlayCallChain {
865
1062
  session.lastFailure = message;
866
1063
  await this.#persist(session);
867
1064
  await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
868
- notifySnapshot(observer, session);
1065
+ this.#notifySnapshot(observer, session);
869
1066
  return projectView(session);
870
1067
  }
871
1068
  const durableResponse = durableModelResponse(response);
@@ -909,11 +1106,11 @@ export class PlayCallChain {
909
1106
  },
910
1107
  ],
911
1108
  });
912
- notifySnapshot(observer, session);
1109
+ this.#notifySnapshot(observer, session);
913
1110
  return projectView(session);
914
1111
  }
915
1112
  if (settled.status !== "ready") {
916
- notifySnapshot(observer, session);
1113
+ this.#notifySnapshot(observer, session);
917
1114
  return projectView(session);
918
1115
  }
919
1116
  // The exchange has settled: no tool call is pending and the narrative
@@ -926,13 +1123,31 @@ export class PlayCallChain {
926
1123
  message: "The play call chain recovered during a later model exchange and completed.",
927
1124
  details: { parentHead: session.parentHead },
928
1125
  });
929
- await this.#runFollowups(session, modelHost, observer);
1126
+ if ((session.followups?.length ?? 0) > 0) {
1127
+ // Follow-up artifacts are part of this player-visible invocation.
1128
+ // Keep `running` ephemeral: the settled main response remains the
1129
+ // durable recovery point if this Runtime process stops here.
1130
+ session.status = "running";
1131
+ this.#observeInvocation(session.chainId, "followup");
1132
+ this.#setInvocationAbortable(session.chainId, true);
1133
+ this.#notifySnapshot(observer, session);
1134
+ }
1135
+ await this.#runFollowups(session, modelHost, signal, observer);
1136
+ this.#setInvocationAbortable(session.chainId, false);
1137
+ if (signal.aborted) {
1138
+ session.events.push({
1139
+ id: session.nextEventId++,
1140
+ kind: "cancellation",
1141
+ message: "The player cancelled the remaining follow-up generation after the narrative was committed.",
1142
+ });
1143
+ touch(session);
1144
+ }
930
1145
  session.status = "ready";
931
1146
  await this.#persist(session);
932
- notifySnapshot(observer, session);
1147
+ this.#notifySnapshot(observer, session);
933
1148
  return projectView(session);
934
1149
  }
935
- notifySnapshot(observer, session);
1150
+ this.#notifySnapshot(observer, session);
936
1151
  request = settled.nextRequest;
937
1152
  attempt = 1;
938
1153
  }
@@ -942,10 +1157,10 @@ export class PlayCallChain {
942
1157
  if (recovered.kind === "continue") {
943
1158
  request = recovered.nextRequest;
944
1159
  attempt = 1;
945
- notifySnapshot(observer, session);
1160
+ this.#notifySnapshot(observer, session);
946
1161
  continue;
947
1162
  }
948
- notifySnapshot(observer, session);
1163
+ this.#notifySnapshot(observer, session);
949
1164
  return projectView(session);
950
1165
  }
951
1166
  const message = error instanceof Error
@@ -970,11 +1185,34 @@ export class PlayCallChain {
970
1185
  session.canRetry = false;
971
1186
  session.lastFailure = message;
972
1187
  await this.#persist(session);
973
- notifySnapshot(observer, session);
1188
+ this.#notifySnapshot(observer, session);
974
1189
  return projectView(session);
975
1190
  }
976
1191
  }
977
1192
  }
1193
+ async #cancelBeforeProviderDispatch(session, request, attempt, observer, advance) {
1194
+ if (advance !== undefined) {
1195
+ const streaming = session.events.find((event) => event.kind === "assistant" && event.id === advance.eventId);
1196
+ if (streaming !== undefined)
1197
+ streaming.status = "interrupted";
1198
+ }
1199
+ const message = "The player cancelled before the next Provider dispatch; the frozen request can be resumed without replaying a completed model result.";
1200
+ session.events.push({
1201
+ id: session.nextEventId++,
1202
+ kind: "cancellation",
1203
+ message,
1204
+ });
1205
+ session.status = "interrupted";
1206
+ session.canRetry = true;
1207
+ session.lastRequest = structuredClone(request);
1208
+ session.lastRequestAttempt = Math.max(0, attempt - 1);
1209
+ session.lastFailure = message;
1210
+ await this.#persist(session);
1211
+ if (advance !== undefined)
1212
+ await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
1213
+ this.#notifySnapshot(observer, session);
1214
+ return projectView(session);
1215
+ }
978
1216
  /**
979
1217
  * Dispatch the frozen follow-ups once against the settled exchange.
980
1218
  *
@@ -983,11 +1221,24 @@ export class PlayCallChain {
983
1221
  * player nothing more than a missing panel. Failures are recorded in the
984
1222
  * trace and the chain still returns to `ready`.
985
1223
  */
986
- async #runFollowups(session, modelHost, observer) {
1224
+ async #runFollowups(session, modelHost, signal, observer) {
987
1225
  const followups = session.followups ?? [];
988
1226
  if (this.#artifacts === undefined || followups.length === 0)
989
1227
  return;
1228
+ const observeFollowupDelta = (delta) => {
1229
+ this.#observeInvocation(session.chainId, "followup", {
1230
+ ...(delta.kind === "reasoning"
1231
+ ? { reasoningChars: delta.text.length }
1232
+ : delta.kind === "text"
1233
+ ? { textChars: delta.text.length }
1234
+ : { toolChars: delta.text.length }),
1235
+ });
1236
+ this.#notifySnapshot(observer, session);
1237
+ };
990
1238
  const record = (outcome) => {
1239
+ this.#observeInvocation(session.chainId, "followup", {
1240
+ toolCalls: outcome.toolCalls.length,
1241
+ });
991
1242
  session.events.push({
992
1243
  id: session.nextEventId++,
993
1244
  kind: "followup",
@@ -1002,7 +1253,7 @@ export class PlayCallChain {
1002
1253
  ...(outcome.failure === undefined ? {} : { failure: outcome.failure }),
1003
1254
  });
1004
1255
  touch(session);
1005
- notifySnapshot(observer, session);
1256
+ this.#notifySnapshot(observer, session);
1006
1257
  };
1007
1258
  try {
1008
1259
  await runPlayFollowupRequests({
@@ -1027,10 +1278,22 @@ export class PlayCallChain {
1027
1278
  },
1028
1279
  head: session.parentHead,
1029
1280
  maxOutputTokens: modelHost.binding().maxOutputTokens,
1281
+ signal,
1030
1282
  ...(this.#failureLog === undefined
1031
1283
  ? {}
1032
1284
  : { failureLog: this.#failureLog }),
1033
- observer: { onOutcome: record },
1285
+ observer: {
1286
+ onProviderDispatch: () => {
1287
+ this.#setInvocationAbortable(session.chainId, true);
1288
+ this.#observeInvocation(session.chainId, "followup", {
1289
+ dispatches: 1,
1290
+ });
1291
+ this.#notifySnapshot(observer, session);
1292
+ },
1293
+ onProviderDelta: observeFollowupDelta,
1294
+ onProviderSettled: () => this.#setInvocationAbortable(session.chainId, false),
1295
+ onOutcome: record,
1296
+ },
1034
1297
  });
1035
1298
  }
1036
1299
  catch (error) {
@@ -1230,7 +1493,7 @@ function finalizePreparedReceipts(settlement) {
1230
1493
  function appendCommittedHistory(session, head, messages) {
1231
1494
  const sequence = Number(head.slice("commit:".length));
1232
1495
  for (const [index, message] of messages.entries()) {
1233
- const path = `${session.worldId}.message.${sequence}.${index + 1}.${message.role}`;
1496
+ const path = `message.${sequence}.${index + 1}.${message.role}`;
1234
1497
  const existing = session.history.find((item) => item.path === path);
1235
1498
  if (existing !== undefined) {
1236
1499
  if (existing.contents !== message.exactText)
@@ -1393,7 +1656,9 @@ function projectContext(context) {
1393
1656
  };
1394
1657
  }
1395
1658
  function projectActivityEvent(event) {
1396
- if (event.kind === "player" || event.kind === "failure")
1659
+ if (event.kind === "player" ||
1660
+ event.kind === "failure" ||
1661
+ event.kind === "cancellation")
1397
1662
  return structuredClone(event);
1398
1663
  if (event.kind === "assistant") {
1399
1664
  const { reasoning, toolFragment, usage, ...summary } = event;
@@ -1535,21 +1800,12 @@ function validatePlayerText(value) {
1535
1800
  function touch(session) {
1536
1801
  session.updatedAt = Date.now();
1537
1802
  }
1538
- function notifySnapshot(observer, session) {
1539
- try {
1540
- observer?.onSnapshot?.(projectView(session));
1541
- }
1542
- catch {
1543
- // A browser stream may disconnect while the durable model request keeps
1544
- // running. Presentation failure never cancels or changes play Authority.
1545
- }
1546
- }
1547
1803
  function notifyAssistantDelta(observer, delta) {
1548
1804
  try {
1549
1805
  observer?.onAssistantDelta?.(structuredClone(delta));
1550
1806
  }
1551
1807
  catch {
1552
- // See notifySnapshot: the Provider exchange outlives its browser stream.
1808
+ // Like snapshot projection, the Provider exchange outlives its browser stream.
1553
1809
  }
1554
1810
  }
1555
1811
  function crashAtPlayAdvanceEdge(edge) {