narraeon 0.7.4 → 0.7.6

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 (30) hide show
  1. package/dist/node/protocol/v1.js +4 -0
  2. package/dist/node/runtime/V1Runtime.js +13 -1
  3. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +64 -163
  4. package/dist/node/runtime/content/FileNativeContentTree.js +16 -13
  5. package/dist/node/runtime/model/ToolArgumentCoercion.js +138 -0
  6. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +34 -18
  7. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +6 -2
  8. package/dist/node/runtime/play/OrderedPlayPrompts.js +3 -6
  9. package/dist/node/runtime/play/PlayAdvanceLifecycle.js +211 -0
  10. package/dist/node/runtime/play/PlayCallChain.js +276 -398
  11. package/dist/node/runtime/play/PlayDocumentTools.js +19 -0
  12. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +3 -3
  13. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +15 -0
  14. package/dist/node/runtime/prompt/ToolNameAlias.js +14 -0
  15. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +370 -169
  16. package/dist/node/runtime/world/FileNativeWorldStore.js +151 -109
  17. package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -1
  18. package/dist/node/runtime/world/WorldDocumentStore.js +1 -1
  19. package/dist/node/runtime/world/WorldLedgerAuthority.js +37 -3
  20. package/dist/node/runtime/world/WorldTurnLedger.js +17 -4
  21. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -4
  22. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +19 -241
  23. package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +8 -27
  24. package/dist/node/server/createServer.js +1 -4
  25. package/dist/node/shared/ordered-play-prompts.js +5 -0
  26. package/dist/node/shared/turn-guide.js +27 -2
  27. package/dist/web/assets/index-Cj2JYCia.js +1408 -0
  28. package/dist/web/index.html +1 -1
  29. package/package.json +1 -1
  30. package/dist/web/assets/index-9N9kO8kY.js +0 -1408
@@ -1,19 +1,22 @@
1
1
  import { projectLegacyModelBindingFailure } from "./LegacyModelBindingFailure.js";
2
- import { classifyPlayFailure } from "./PlayFailure.js";
2
+ import { dispatchFailureEnding, emptyResponseEnding, lastEndingCancelled, noContinuationEnding, PlayAdvanceLifecycle, settlementFailureEnding, } from "./PlayAdvanceLifecycle.js";
3
3
  import { isResolutionTool, resolutionMarkdown, resolutionToolNames, resolveTabletop, seededRandom, } from "./TabletopResolution.js";
4
4
  import { captureNarrativeOrigin, snapshotBackground, narrativeBackground, } from "../history/HistoryBackground.js";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import { hasLegacyHistoryTools } from "../prompt/FileNativeToolRegistry.js";
7
+ import { renderToolAliasNote, resolveToolName, } from "../prompt/ToolNameAlias.js";
7
8
  import { PlayCallChainError } from "./PlayCallChainError.js";
8
9
  import { preparePlayerRevisionTrace, prepareForkTrace, restorePlayDocuments, independentContextCopy, } from "./PlayTraceRetention.js";
9
10
  import { cloneSharingPromptRuns, currentPlayPrompt, recordedPlayPrompt, playPromptRunsThroughEvents, promptRunStrings, sharePromptRun, } from "./PlayPromptRun.js";
10
11
  import { comparePromptPrefixes } from "../prompt/WorldPromptDiagnostics.js";
11
12
  import { createHash, randomBytes } from "node:crypto";
13
+ import { isTurnGuideLine } from "../../shared/ordered-play-prompts.js";
14
+ import { turnGuideWithTurnNote } from "../../shared/turn-guide.js";
12
15
  import { completedPlayerRounds, continuationNotice, playerInputAppend, toolStepNotice, turnGuideNotice, } from "./PlayContinuity.js";
13
16
  import { renderFreshContextCoverage } from "../prompt/WorldMaterialCoverage.js";
14
17
  import { renderWriteSizeAdvice, readDeclaredWorldClock, } from "../prompt/WorldMaintenanceReport.js";
15
18
  import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
16
- import { equalModelHostBinding, ModelHostBindingMismatchError, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, modelHostFailureRequiresFreshContext, } from "../model/ModelHost.js";
19
+ import { equalModelHostBinding, ModelHostCancelledError, } from "../model/ModelHost.js";
17
20
  import { FileNativePromptCompiler, } from "../prompt/FileNativePromptCompiler.js";
18
21
  import { parsePlayPresetFiles, } from "./FileNativePlayPresetStore.js";
19
22
  import { runPlayFollowupRequests, } from "./PlayFollowupRequests.js";
@@ -82,6 +85,7 @@ export class PlayCallChain {
82
85
  #invocationSettled = new Map();
83
86
  /** Worlds whose release is queued in their WorldQueue slot. */
84
87
  #releasing = new Set();
88
+ #advances;
85
89
  constructor(worlds, compiler = new FileNativePromptCompiler(), artifacts, failureLog, changed = () => {
86
90
  /* No observers attached. */
87
91
  }) {
@@ -90,6 +94,10 @@ export class PlayCallChain {
90
94
  this.#compiler = compiler;
91
95
  this.#artifacts = artifacts;
92
96
  this.#failureLog = failureLog;
97
+ this.#advances = new PlayAdvanceLifecycle({
98
+ persist: (session) => this.#persist(session),
99
+ clearMarker: (advance) => this.#worlds.playAdvances.markSettled(advance),
100
+ });
93
101
  }
94
102
  async start(input) {
95
103
  return this.#runInvocation(input, (signal) => this.#start(input, signal));
@@ -98,6 +106,7 @@ export class PlayCallChain {
98
106
  validateIdentity(input.chainId, "call-chain ID");
99
107
  validateIdentity(input.exchangeId, "exchange ID");
100
108
  validatePlayerText(input.playerText);
109
+ validTurnNote(input.turnNote);
101
110
  assertModelHostBinding(input.modelBinding, input.modelHost);
102
111
  // Only a resubmission to the current context needs that context loaded.
103
112
  await this.#worlds.ensureCurrentStorage(input.worldId);
@@ -115,32 +124,12 @@ export class PlayCallChain {
115
124
  if (active?.status === "running")
116
125
  throw new PlayCallChainError("A model request is still running for this world.");
117
126
  const binding = await this.#worlds.bindPlayCallChain(input.worldId);
118
- const documents = new FileNativePlayDocuments(binding.files);
119
- const documentMaintenance = await this.#bindDocumentMaintenance(documents, input.worldId, binding.parentHead);
120
- const compilation = this.#compiler.compilePlayCallChain({
121
- extensionControls: await this.#worlds.extensionControls(input.worldId, input.playPreset, this.#compiler.locale),
122
- endpoint: {
123
- id: `${input.worldId}:${binding.parentHead}`,
124
- commit: binding.parentHead,
125
- },
126
- hostBinding: structuredClone(input.hostBinding),
127
- world: {
128
- controlFingerprint: fingerprintControl(binding.files),
129
- documentSnapshot: documents.snapshot,
130
- additionalMaterials: structuredClone(binding.additionalMaterials),
131
- history: structuredClone(binding.history),
132
- historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
133
- narrativeCheckpoint: binding.narrativeCheckpoint,
134
- documentMaintenance,
135
- documentMaintenanceUnavailableReason: documents.maintenanceWarning,
136
- },
137
- playerInputPlacement: "append",
138
- playerInput: input.playerText,
139
- modelBinding: structuredClone(input.modelBinding),
140
- }, input.playPreset);
141
- const tools = structuredClone(compilation.toolUniverse);
142
- const bootstrap = structuredClone(compilation.bootstrap);
143
- documents.bindBootstrap(bootstrap);
127
+ const fresh = await this.#compileFreshContext(input.worldId, binding, {
128
+ hostBinding: input.hostBinding,
129
+ playPreset: input.playPreset,
130
+ modelBinding: input.modelBinding,
131
+ playerText: input.playerText,
132
+ });
144
133
  const now = Date.now();
145
134
  const session = {
146
135
  schemaVersion: 3,
@@ -159,14 +148,15 @@ export class PlayCallChain {
159
148
  name: input.playPreset.name,
160
149
  revision: input.playPreset.revision,
161
150
  },
162
- followups: structuredClone(compilation.followups ?? []),
151
+ followups: fresh.followups,
163
152
  playPresetScriptsEnabled: input.playPreset.scriptsEnabled,
164
153
  presetFiles: structuredClone(input.playPreset.files),
165
154
  modelBinding: structuredClone(input.modelBinding),
166
155
  status: "ready",
167
156
  canRetry: false,
168
- bootstrap,
169
- tools: structuredClone(tools),
157
+ bootstrap: fresh.bootstrap,
158
+ tools: fresh.tools,
159
+ ...turnNoteField(validTurnNote(input.turnNote)),
170
160
  transcript: [],
171
161
  events: [],
172
162
  completedTools: [],
@@ -178,7 +168,7 @@ export class PlayCallChain {
178
168
  lastRequestAttempt: 0,
179
169
  lastFailure: null,
180
170
  updatedAt: now,
181
- documents,
171
+ documents: fresh.documents,
182
172
  history: historyEntries(binding.history, binding.historyBackgrounds),
183
173
  narrativeCheckpoint: binding.narrativeCheckpoint,
184
174
  completedToolMap: new Map(),
@@ -195,6 +185,45 @@ export class PlayCallChain {
195
185
  await this.#worlds.completeArchiveFreshContext(session.worldId);
196
186
  return this.#submitPlayer(session, input.exchangeId, input.playerText, "fresh", input.modelHost, signal, input.observer);
197
187
  }
188
+ /**
189
+ * Compiles a fresh context's input and binds its documents to the bootstrap.
190
+ * Starting a context and a player revision that begins one both come through
191
+ * here; they differ only in the world binding they read. A continuing context
192
+ * compiles against its frozen world context instead (`#refreshPrompt`).
193
+ */
194
+ async #compileFreshContext(worldId, binding, input) {
195
+ const documents = new FileNativePlayDocuments(binding.files);
196
+ const documentMaintenance = await this.#bindDocumentMaintenance(documents, worldId, binding.parentHead);
197
+ const compilation = this.#compiler.compilePlayCallChain({
198
+ extensionControls: await this.#worlds.extensionControls(worldId, input.playPreset, this.#compiler.locale),
199
+ endpoint: {
200
+ id: `${worldId}:${binding.parentHead}`,
201
+ commit: binding.parentHead,
202
+ },
203
+ hostBinding: structuredClone(input.hostBinding),
204
+ world: {
205
+ controlFingerprint: fingerprintControl(binding.files),
206
+ documentSnapshot: documents.snapshot,
207
+ additionalMaterials: structuredClone(binding.additionalMaterials),
208
+ history: structuredClone(binding.history),
209
+ historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
210
+ narrativeCheckpoint: binding.narrativeCheckpoint,
211
+ documentMaintenance,
212
+ documentMaintenanceUnavailableReason: documents.maintenanceWarning,
213
+ },
214
+ playerInputPlacement: "append",
215
+ playerInput: input.playerText,
216
+ modelBinding: structuredClone(input.modelBinding),
217
+ }, input.playPreset);
218
+ const bootstrap = structuredClone(compilation.bootstrap);
219
+ documents.bindBootstrap(bootstrap);
220
+ return {
221
+ documents,
222
+ bootstrap,
223
+ tools: structuredClone(compilation.toolUniverse),
224
+ followups: structuredClone(compilation.followups ?? []),
225
+ };
226
+ }
198
227
  async append(input) {
199
228
  return this.#runInvocation(input, (signal) => this.#append(input, signal));
200
229
  }
@@ -219,6 +248,9 @@ export class PlayCallChain {
219
248
  return this.#dispatch(session, input.modelHost, structuredClone(session.lastRequest), session.lastRequestAttempt + 1, signal, input.observer);
220
249
  }
221
250
  await this.#refreshPrompt(session, input);
251
+ // Each dispatch carries exactly the request the client sent with it; a
252
+ // continuation without one must not inherit the previous turn's line.
253
+ setTurnNote(session, validTurnNote(input.turnNote));
222
254
  session.exchange += 1;
223
255
  const last = session.transcript.at(-1);
224
256
  if (last?.kind === "assistant" &&
@@ -227,7 +259,7 @@ export class PlayCallChain {
227
259
  session.narrativeOrigin = await this.#captureOrigin(session);
228
260
  session.transcript.push(continuationNotice(this.#compiler.locale));
229
261
  }
230
- return this.#dispatch(session, input.modelHost, createRequest(session, input.modelHost, session.exchange), 1, signal, input.observer);
262
+ return this.#dispatch(session, input.modelHost, createRequest(session, input.modelHost, session.exchange, this.#compiler.locale), 1, signal, input.observer);
231
263
  }
232
264
  if (duplicatePlayerExchange(session, input.exchangeId, input.playerText, "append"))
233
265
  return projectView(session);
@@ -236,6 +268,7 @@ export class PlayCallChain {
236
268
  ? "The call chain has an incomplete model response; clear the input and append a continuation, or use a fresh context."
237
269
  : "The call chain was interrupted; use a fresh context.");
238
270
  await this.#refreshPrompt(session, input);
271
+ setTurnNote(session, validTurnNote(input.turnNote));
239
272
  return this.#submitPlayer(session, input.exchangeId, input.playerText, "append", input.modelHost, signal, input.observer);
240
273
  }
241
274
  async #refreshPrompt(session, input) {
@@ -722,6 +755,7 @@ export class PlayCallChain {
722
755
  eventId: input.eventId,
723
756
  replacementExchangeId: input.replacementExchangeId,
724
757
  replacementText: input.replacementText,
758
+ ...turnNoteField(validTurnNote(input.turnNote)),
725
759
  continuation: input.continuation,
726
760
  restoresHead: playerRevisionRestoresHead(sourceContext, eventIndex),
727
761
  discardChainIds: contexts
@@ -830,6 +864,7 @@ export class PlayCallChain {
830
864
  previousContexts: [],
831
865
  previousChainId,
832
866
  timelineGeneration,
867
+ ...turnNoteField(intent.turnNote),
833
868
  parentHead: head,
834
869
  updatedAt: Date.now(),
835
870
  };
@@ -896,35 +931,17 @@ export class PlayCallChain {
896
931
  }
897
932
  async #prepareFreshPlayerRevision(worldId, freshContext, restoresHead, replacementText) {
898
933
  const binding = await this.#worlds.bindPlayCallChainAt(worldId, restoresHead);
899
- const documents = new FileNativePlayDocuments(binding.files);
900
- const documentMaintenance = await this.#bindDocumentMaintenance(documents, worldId, binding.parentHead);
901
- const compilation = this.#compiler.compilePlayCallChain({
902
- extensionControls: await this.#worlds.extensionControls(worldId, freshContext.playPreset, this.#compiler.locale),
903
- endpoint: {
904
- id: `${worldId}:${binding.parentHead}`,
905
- commit: binding.parentHead,
906
- },
907
- hostBinding: structuredClone(freshContext.hostBinding),
908
- world: {
909
- controlFingerprint: fingerprintControl(binding.files),
910
- documentSnapshot: documents.snapshot,
911
- additionalMaterials: structuredClone(binding.additionalMaterials),
912
- history: structuredClone(binding.history),
913
- historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
914
- narrativeCheckpoint: binding.narrativeCheckpoint,
915
- documentMaintenance,
916
- documentMaintenanceUnavailableReason: documents.maintenanceWarning,
917
- },
918
- playerInputPlacement: "append",
919
- playerInput: replacementText,
920
- modelBinding: structuredClone(freshContext.modelBinding),
921
- }, freshContext.playPreset);
922
- documents.bindBootstrap(compilation.bootstrap);
934
+ const fresh = await this.#compileFreshContext(worldId, binding, {
935
+ hostBinding: freshContext.hostBinding,
936
+ playPreset: freshContext.playPreset,
937
+ modelBinding: freshContext.modelBinding,
938
+ playerText: replacementText,
939
+ });
923
940
  return {
924
941
  binding,
925
- bootstrap: structuredClone(compilation.bootstrap),
926
- tools: structuredClone(compilation.toolUniverse),
927
- followups: structuredClone(compilation.followups ?? []),
942
+ bootstrap: fresh.bootstrap,
943
+ tools: fresh.tools,
944
+ followups: fresh.followups,
928
945
  };
929
946
  }
930
947
  async #buildFreshPlayerRevision(input) {
@@ -968,6 +985,7 @@ export class PlayCallChain {
968
985
  revision: input.freshContext.playPreset.revision,
969
986
  },
970
987
  followups: structuredClone(fresh.followups),
988
+ ...turnNoteField(input.intent.turnNote),
971
989
  playPresetScriptsEnabled: input.freshContext.playPreset.scriptsEnabled,
972
990
  presetFiles: structuredClone(input.freshContext.playPreset.files),
973
991
  modelBinding: structuredClone(input.freshContext.modelBinding),
@@ -1135,10 +1153,7 @@ export class PlayCallChain {
1135
1153
  throw new PlayCallChainError("The call chain is waiting for the model to return.");
1136
1154
  session.narrativeOrigin = await this.#captureOrigin(session);
1137
1155
  const exchange = session.exchange + 1;
1138
- const nextRequest = createRequest(session, modelHost, exchange, [
1139
- ...session.transcript,
1140
- ...playerInputAppend(playerText),
1141
- ]);
1156
+ const nextRequest = createRequest(session, modelHost, exchange, this.#compiler.locale, [...session.transcript, ...playerInputAppend(playerText)]);
1142
1157
  const operationKey = `player:${exchangeId}`;
1143
1158
  const playerOperationId = operationId(session.chainId, operationKey);
1144
1159
  const advance = {
@@ -1166,23 +1181,9 @@ export class PlayCallChain {
1166
1181
  await this.#settlePlayerAdvance(session, advance);
1167
1182
  }
1168
1183
  catch (error) {
1169
- const message = error instanceof Error
1170
- ? error.message
1171
- : "Failed to write the original player text.";
1172
- const failureKind = classifyPlayFailure(error, false);
1173
- session.events.push({
1174
- id: session.nextEventId++,
1175
- kind: "failure",
1176
- message,
1177
- failureKind,
1178
- });
1179
- session.status = "interrupted";
1180
- session.canRetry = false;
1181
- session.lastFailure = message;
1182
- session.lastFailureKind = failureKind;
1183
- await this.#persist(session);
1184
- await this.#worlds.playAdvances.markSettled(advance);
1185
- throw new PlayCallChainError(message);
1184
+ const ending = settlementFailureEnding(error, "Failed to write the original player text.");
1185
+ await this.#advances.end(session, ending, advance);
1186
+ throw new PlayCallChainError(ending.message);
1186
1187
  }
1187
1188
  return this.#dispatch(session, modelHost, structuredClone(nextRequest), 1, signal, observer);
1188
1189
  }
@@ -1223,8 +1224,6 @@ export class PlayCallChain {
1223
1224
  ...session.events.filter(({ id }) => id < advance.eventId),
1224
1225
  playerEvent,
1225
1226
  ];
1226
- if (session.transcript.length < advance.transcriptStart)
1227
- throw new PlayCallChainError("The recovered call-chain transcript is shorter than its player prefix.");
1228
1227
  session.transcript = [
1229
1228
  ...session.transcript.slice(0, advance.transcriptStart),
1230
1229
  ...structuredClone(transcriptAppendOf(advance.nextRequest).slice(advance.transcriptStart)),
@@ -1237,24 +1236,28 @@ export class PlayCallChain {
1237
1236
  else
1238
1237
  session.narrativeOrigin = structuredClone(advance.narrativeOrigin);
1239
1238
  session.exchange = advance.exchange;
1240
- session.lastRequest = structuredClone(advance.nextRequest);
1241
- session.lastRequestAttempt = 0;
1242
- session.status = "interrupted";
1243
- session.canRetry = true;
1244
- session.lastFailure =
1245
- "The player message is committed; the frozen model request is ready to continue.";
1246
- session.lastFailureKind = "retryable";
1247
- await this.#persist(session);
1248
- await this.#worlds.playAdvances.markSettled(advance);
1239
+ await this.#advances.end(session, {
1240
+ kind: "resumable",
1241
+ reason: "player_committed",
1242
+ request: advance.nextRequest,
1243
+ }, advance);
1249
1244
  }
1250
- async #prepareResponseSettlement(session, advance, response) {
1245
+ /**
1246
+ * Settles one complete model response in this process. The tool batch, the
1247
+ * narrative and state commit, and the transcript and event tail advance
1248
+ * together. Nothing is kept for a replay: a process that stops before this
1249
+ * returns is recovered as an interrupted exchange (ADR-0044).
1250
+ */
1251
+ async #settleResponse(session, advance, response) {
1252
+ const durable = durableModelResponse(response);
1251
1253
  const existing = session.events.find(({ id }) => id === advance.eventId);
1252
1254
  if (existing?.kind !== "assistant")
1253
1255
  throw new PlayCallChainError("The durable Provider result no longer matches its assistant event.");
1254
- const calls = response.toolCalls ?? [];
1255
- const text = response.text ?? existing.text;
1256
+ const calls = durable.toolCalls ?? [];
1257
+ const text = durable.text ?? existing.text;
1256
1258
  const hasText = text.trim().length > 0;
1257
1259
  const responseKind = calls.length > 0 ? "tool_step" : hasText ? "narrative" : "empty";
1260
+ const narrative = responseKind === "narrative";
1258
1261
  const assistantEvent = {
1259
1262
  id: advance.eventId,
1260
1263
  kind: "assistant",
@@ -1263,18 +1266,18 @@ export class PlayCallChain {
1263
1266
  responseKind,
1264
1267
  exchange: advance.exchange,
1265
1268
  attempt: advance.attempt,
1266
- ...(response.reasoningContent === undefined
1269
+ ...(durable.reasoningContent === undefined
1267
1270
  ? existing.reasoning === undefined
1268
1271
  ? {}
1269
1272
  : { reasoning: existing.reasoning }
1270
- : { reasoning: response.reasoningContent }),
1271
- ...(response.usage === undefined
1273
+ : { reasoning: durable.reasoningContent }),
1274
+ ...(durable.usage === undefined
1272
1275
  ? {}
1273
- : { usage: structuredClone(response.usage) }),
1274
- ...(response.stopReason === undefined
1276
+ : { usage: structuredClone(durable.usage) }),
1277
+ ...(durable.stopReason === undefined
1275
1278
  ? {}
1276
- : { stopReason: response.stopReason }),
1277
- continuation: response.providerState === undefined ? "unavailable" : "available",
1279
+ : { stopReason: durable.stopReason }),
1280
+ continuation: durable.providerState === undefined ? "unavailable" : "available",
1278
1281
  };
1279
1282
  const workingTools = new Map(session.completedToolMap);
1280
1283
  const prepared = [];
@@ -1282,7 +1285,11 @@ export class PlayCallChain {
1282
1285
  let nextEventId = advance.eventId + 1;
1283
1286
  // Fresh in-memory seed per settlement. It is never persisted: a crashed
1284
1287
  // exchange is retried by the player and re-rolls (ADR-0044).
1285
- const resolutionSeed = calls.some(({ name }) => isResolutionTool(name))
1288
+ const providedToolNames = currentPlayPrompt(session).tools.map(({ name }) => name);
1289
+ const resolutionSeed = calls.some(({ name }) => {
1290
+ const resolved = resolveToolName(name, providedToolNames);
1291
+ return resolved.kind !== "rejected" && isResolutionTool(resolved.name);
1292
+ })
1286
1293
  ? randomBytes(32).toString("hex")
1287
1294
  : undefined;
1288
1295
  for (const call of calls) {
@@ -1293,6 +1300,9 @@ export class PlayCallChain {
1293
1300
  kind: "tool_call",
1294
1301
  callId: call.id,
1295
1302
  name: call.name,
1303
+ ...(item.event.executedName === undefined
1304
+ ? {}
1305
+ : { executedName: item.event.executedName }),
1296
1306
  arguments: structuredClone(call.arguments),
1297
1307
  replayed: item.replayed,
1298
1308
  });
@@ -1300,8 +1310,8 @@ export class PlayCallChain {
1300
1310
  trailingEvents.push(structuredClone(item.event));
1301
1311
  }
1302
1312
  const stateChanges = session.documents.stateChanges();
1303
- // Resolve once after the entire tool batch. Store the prediction with the
1304
- // prepared fact so crash recovery publishes exactly the same receipt.
1313
+ // Resolve once after the entire tool batch, so every write result reports
1314
+ // the prediction that the commit below makes true.
1305
1315
  const writes = [...workingTools.values()].filter(({ result }) => result.candidateWrite !== undefined);
1306
1316
  let worldClock;
1307
1317
  if (writes.length > 0) {
@@ -1346,16 +1356,15 @@ export class PlayCallChain {
1346
1356
  result.candidateWrite.freshContextCoverage = `${this.#compiler.locale === "zh-CN" ? "暂时无法判定下次注入;写入结果不受影响。原因" : "Fresh-context coverage unavailable; the write result is unaffected. Reason"}: ${reason}`;
1347
1357
  }
1348
1358
  }
1349
- const visibleText = responseKind === "narrative";
1350
1359
  const previousNarrative = session.events.findLast((event) => event.kind === "assistant" &&
1351
1360
  event.committedHead !== undefined &&
1352
1361
  event.responseKind === "narrative");
1353
1362
  const previousPlayer = session.events.findLast((event) => event.kind === "player");
1354
- const checkpointRequested = visibleText &&
1363
+ const checkpointRequested = narrative &&
1355
1364
  session.events.some((event) => event.id >
1356
1365
  Math.max(previousNarrative?.id ?? 0, previousPlayer?.id ?? 0) &&
1357
1366
  event.kind === "tool_result" &&
1358
- event.name === "world_checkpoint" &&
1367
+ (event.executedName ?? event.name) === "world_checkpoint" &&
1359
1368
  event.ok);
1360
1369
  const narrativeCheckpoint = checkpointRequested
1361
1370
  ? {
@@ -1369,177 +1378,135 @@ export class PlayCallChain {
1369
1378
  const assistantItem = {
1370
1379
  kind: "assistant",
1371
1380
  text,
1372
- ...(response.reasoningContent === undefined
1381
+ ...(durable.reasoningContent === undefined
1373
1382
  ? {}
1374
- : { reasoningContent: response.reasoningContent }),
1375
- ...(response.providerState === undefined
1383
+ : { reasoningContent: durable.reasoningContent }),
1384
+ ...(durable.providerState === undefined
1376
1385
  ? {}
1377
- : { providerState: response.providerState }),
1386
+ : { providerState: durable.providerState }),
1378
1387
  toolCalls: structuredClone(calls),
1379
1388
  };
1380
- const background = visibleText
1389
+ const background = narrative
1381
1390
  ? narrativeBackground(advance.narrativeOrigin, session.documents.snapshot)
1382
1391
  : undefined;
1383
- return {
1384
- ...(background === undefined ? {} : { background }),
1385
- assistantEvent,
1386
- playContext: session.continuityContextId ?? session.chainId,
1387
- ...(worldClock === undefined || stateChanges.length === 0
1388
- ? {}
1389
- : { worldClock }),
1390
- trailingEvents,
1391
- transcriptStart: session.transcript.length,
1392
- transcriptAppend: [
1393
- ...(calls.length > 0 ||
1394
- visibleText ||
1395
- response.providerState !== undefined
1396
- ? [assistantItem]
1397
- : []),
1398
- ...prepared.map(({ transcript }) => structuredClone(transcript)),
1399
- ...(calls.length > 0 && hasText
1400
- ? [toolStepNotice(this.#compiler.locale)]
1401
- : []),
1402
- ],
1403
- completedTools: [...workingTools.values()].map((item) => structuredClone(item)),
1404
- stateChanges: structuredClone(stateChanges),
1405
- visibleText,
1406
- ...(narrativeCheckpoint === undefined ? {} : { narrativeCheckpoint }),
1407
- };
1408
- }
1409
- async #settleResponseAdvance(session, advance, prepared) {
1410
- const settlement = structuredClone(prepared);
1411
- const hasToolCalls = settlement.trailingEvents.some(({ kind }) => kind === "tool_call");
1412
- // A legacy prepared fact has no responseKind. Its visibleText decision is
1413
- // already part of an immutable recovery operation and must be replayed as
1414
- // recorded; every newly prepared fact is checked against the current rule.
1415
- if (settlement.assistantEvent.responseKind !== undefined) {
1416
- const expectedResponseKind = hasToolCalls
1417
- ? "tool_step"
1418
- : settlement.assistantEvent.text.trim().length > 0
1419
- ? "narrative"
1420
- : "empty";
1421
- if (settlement.assistantEvent.responseKind !== expectedResponseKind ||
1422
- settlement.visibleText !== (expectedResponseKind === "narrative"))
1423
- throw new PlayCallChainError("The prepared response settlement has an inconsistent narrative classification.");
1424
- }
1392
+ const transcriptStart = session.transcript.length;
1393
+ const transcriptAppend = [
1394
+ ...(calls.length > 0 || narrative || durable.providerState !== undefined
1395
+ ? [assistantItem]
1396
+ : []),
1397
+ ...prepared.map(({ transcript }) => structuredClone(transcript)),
1398
+ ...(calls.length > 0 && hasText
1399
+ ? [toolStepNotice(this.#compiler.locale)]
1400
+ : []),
1401
+ ];
1402
+ const completedTools = [...workingTools.values()].map((item) => structuredClone(item));
1403
+ const failedTools = trailingEvents.filter((candidate) => candidate.kind === "tool_result" && !candidate.ok);
1404
+ if (failedTools.length > 0 && response.diagnostics !== undefined)
1405
+ await this.#failureLog?.recordFailure({
1406
+ exchange: response.diagnostics,
1407
+ failures: [
1408
+ {
1409
+ kind: "tool_execution",
1410
+ message: "A Runtime tool call from the model was rejected.",
1411
+ details: {
1412
+ calls: failedTools.map((result) => ({
1413
+ id: result.callId,
1414
+ name: result.name,
1415
+ ok: result.ok,
1416
+ markdown: result.markdown,
1417
+ replayed: result.replayed,
1418
+ })),
1419
+ },
1420
+ },
1421
+ ],
1422
+ });
1425
1423
  let committedHead;
1426
- if (settlement.visibleText || settlement.stateChanges.length > 0) {
1424
+ if (narrative || stateChanges.length > 0) {
1427
1425
  const outcome = await this.#worlds.commitPlayStep({
1428
- ...(settlement.playContext === undefined
1426
+ playContext: session.continuityContextId ?? session.chainId,
1427
+ ...(worldClock === undefined || stateChanges.length === 0
1429
1428
  ? {}
1430
- : { playContext: settlement.playContext }),
1431
- ...(settlement.worldClock === undefined
1432
- ? {}
1433
- : { worldClock: settlement.worldClock }),
1429
+ : { worldClock }),
1434
1430
  operationId: advance.operationId,
1435
1431
  worldId: session.worldId,
1436
1432
  parentHead: advance.parentHead,
1437
- historyAppend: settlement.visibleText
1433
+ historyAppend: narrative
1438
1434
  ? [
1439
1435
  {
1440
1436
  role: "narrator",
1441
- exactText: settlement.assistantEvent.text,
1442
- ...(settlement.background === undefined
1443
- ? {}
1444
- : { background: settlement.background }),
1437
+ exactText: text,
1438
+ ...(background === undefined ? {} : { background }),
1445
1439
  },
1446
1440
  ]
1447
1441
  : [],
1448
1442
  nextMaterials: structuredClone(session.nextMaterials),
1449
- stateChanges: structuredClone(settlement.stateChanges),
1450
- ...(settlement.narrativeCheckpoint === undefined
1451
- ? {}
1452
- : { narrativeCheckpoint: settlement.narrativeCheckpoint }),
1443
+ stateChanges: structuredClone(stateChanges),
1444
+ ...(narrativeCheckpoint === undefined ? {} : { narrativeCheckpoint }),
1453
1445
  });
1454
1446
  committedHead = outcome.head;
1455
- settlement.assistantEvent.committedHead = outcome.head;
1456
- if (settlement.narrativeCheckpoint !== undefined) {
1457
- settlement.assistantEvent.checkpoint = true;
1447
+ assistantEvent.committedHead = outcome.head;
1448
+ if (narrativeCheckpoint !== undefined) {
1449
+ assistantEvent.checkpoint = true;
1458
1450
  session.narrativeCheckpoint = {
1459
- ...settlement.narrativeCheckpoint,
1451
+ ...narrativeCheckpoint,
1460
1452
  head: outcome.head,
1461
1453
  historyMessageId: `message.${outcome.head.slice("commit:".length)}.1.narrator`,
1462
1454
  };
1463
1455
  }
1464
1456
  appendCommittedHistory(session, outcome.head, outcome.historyAppend);
1465
1457
  }
1466
- finalizePreparedReceipts(settlement);
1458
+ finalizeWriteReceipts({
1459
+ stateChanges,
1460
+ completedTools,
1461
+ trailingEvents,
1462
+ transcriptAppend,
1463
+ });
1467
1464
  session.events = [
1468
1465
  ...session.events.filter(({ id }) => id < advance.eventId),
1469
- structuredClone(settlement.assistantEvent),
1470
- ...structuredClone(settlement.trailingEvents),
1466
+ structuredClone(assistantEvent),
1467
+ ...structuredClone(trailingEvents),
1471
1468
  ];
1472
1469
  session.nextEventId =
1473
- Math.max(advance.eventId, ...settlement.trailingEvents.map(({ id }) => id)) + 1;
1474
- if (session.transcript.length < settlement.transcriptStart)
1475
- throw new PlayCallChainError("The recovered call-chain transcript is shorter than its settlement prefix.");
1470
+ Math.max(advance.eventId, ...trailingEvents.map(({ id }) => id)) + 1;
1476
1471
  session.transcript = [
1477
- ...session.transcript.slice(0, settlement.transcriptStart),
1478
- ...structuredClone(settlement.transcriptAppend),
1472
+ ...session.transcript.slice(0, transcriptStart),
1473
+ ...structuredClone(transcriptAppend),
1479
1474
  ];
1480
- session.completedTools = structuredClone(settlement.completedTools);
1481
- session.completedToolMap = new Map(settlement.completedTools.map((item) => [
1482
- item.key,
1483
- structuredClone(item),
1484
- ]));
1475
+ session.completedTools = structuredClone(completedTools);
1476
+ session.completedToolMap = new Map(completedTools.map((item) => [item.key, structuredClone(item)]));
1485
1477
  if (committedHead !== undefined)
1486
1478
  session.parentHead = committedHead;
1487
- if (settlement.stateChanges.length > 0) {
1479
+ if (stateChanges.length > 0) {
1488
1480
  session.documents.acceptCommittedState();
1489
- mergeChangedDocuments(session, settlement.stateChanges);
1490
- }
1491
- if (settlement.stateChanges.length > 0)
1481
+ mergeChangedDocuments(session, stateChanges);
1492
1482
  await this.#bindDocumentMaintenance(session.documents, session.worldId, session.parentHead);
1483
+ }
1484
+ const hasToolCalls = calls.length > 0;
1493
1485
  session.exchange = Math.max(session.exchange, advance.exchange);
1494
- session.canRetry = false;
1495
- session.lastFailure = null;
1496
- delete session.lastFailureKind;
1497
- let result;
1498
- if (settlement.assistantEvent.continuation !== "available") {
1499
- const message = "The complete model response has no provider-native continuation payload; use a fresh context.";
1500
- session.events.push({
1501
- id: session.nextEventId++,
1502
- kind: "failure",
1503
- message,
1504
- failureKind: "context_unavailable",
1505
- });
1506
- session.status = "interrupted";
1507
- session.lastFailure = message;
1508
- session.lastFailureKind = "context_unavailable";
1509
- result = { kind: "terminal", status: "interrupted" };
1486
+ // The chain has been `running` since its dispatch began, so its failure
1487
+ // fields are already clear; the ending below replaces them.
1488
+ if (assistantEvent.continuation !== "available") {
1489
+ await this.#advances.end(session, noContinuationEnding(), advance);
1490
+ return { kind: "terminal", status: "interrupted", narrative };
1510
1491
  }
1511
- else if (hasToolCalls) {
1492
+ if (hasToolCalls) {
1512
1493
  session.exchange += 1;
1513
- const nextRequest = createRequestWithMaxOutputTokens(session, session.lastRequest?.maxOutputTokens ?? 1, session.exchange);
1494
+ const nextRequest = createRequestWithMaxOutputTokens(session, session.lastRequest?.maxOutputTokens ?? 1, session.exchange, this.#compiler.locale);
1514
1495
  if (session.lastRequest?.modelConfig !== undefined)
1515
1496
  nextRequest.modelConfig = structuredClone(session.lastRequest.modelConfig);
1516
- session.lastRequest = structuredClone(nextRequest);
1517
- session.lastRequestAttempt = 0;
1518
- session.status = "interrupted";
1519
- session.canRetry = true;
1520
- session.lastFailure =
1521
- "The completed tool response is settled; its frozen continuation request is ready.";
1522
- session.lastFailureKind = "retryable";
1523
- result = { kind: "continue", nextRequest };
1497
+ await this.#advances.end(session, {
1498
+ kind: "resumable",
1499
+ reason: "tool_step_settled",
1500
+ request: nextRequest,
1501
+ }, advance);
1502
+ return { kind: "continue", nextRequest };
1524
1503
  }
1525
- else {
1526
- session.status = settlement.visibleText ? "ready" : "interrupted";
1527
- if (!settlement.visibleText) {
1528
- const message = "The model returned neither text nor a complete tool call.";
1529
- session.events.push({
1530
- id: session.nextEventId++,
1531
- kind: "failure",
1532
- message,
1533
- failureKind: "other",
1534
- });
1535
- session.lastFailure = message;
1536
- session.lastFailureKind = "other";
1537
- }
1538
- result = { kind: "terminal", status: session.status };
1504
+ if (!narrative) {
1505
+ await this.#advances.end(session, emptyResponseEnding(), advance);
1506
+ return { kind: "terminal", status: "interrupted", narrative };
1539
1507
  }
1540
- await this.#persist(session);
1541
- await this.#worlds.playAdvances.markSettled(advance);
1542
- return result;
1508
+ await this.#advances.end(session, { kind: "ready" }, advance);
1509
+ return { kind: "terminal", status: "ready", narrative };
1543
1510
  }
1544
1511
  async #dispatch(session, modelHost, firstRequest, firstAttempt, signal, observer) {
1545
1512
  let request = modelHost.freezeRequest?.(firstRequest) ?? structuredClone(firstRequest);
@@ -1549,12 +1516,7 @@ export class PlayCallChain {
1549
1516
  if (signal.aborted)
1550
1517
  return this.#cancelBeforeProviderDispatch(session, request, attempt, observer);
1551
1518
  this.#observeInvocation(session.chainId, "waiting");
1552
- session.status = "running";
1553
- session.canRetry = false;
1554
- session.lastFailure = null;
1555
- delete session.lastFailureKind;
1556
- session.lastRequest = structuredClone(request);
1557
- session.lastRequestAttempt = attempt;
1519
+ this.#advances.beginDispatch(session, request, attempt);
1558
1520
  const responseExchange = request.exchange ?? session.exchange;
1559
1521
  const event = {
1560
1522
  id: session.nextEventId++,
@@ -1639,66 +1601,13 @@ export class PlayCallChain {
1639
1601
  await this.#failureLog?.recordExchangeIfActive(response.diagnostics);
1640
1602
  }
1641
1603
  catch (error) {
1642
- event.status = "interrupted";
1643
- const message = error instanceof Error
1644
- ? error.message
1645
- : "The model request was interrupted.";
1646
- session.status = "interrupted";
1647
- const requiresFreshContext = error instanceof ModelHostBindingMismatchError ||
1648
- error instanceof ModelHostContinuationError ||
1649
- error instanceof ModelHostOutcomeUnknownError ||
1650
- modelHostFailureRequiresFreshContext(error);
1651
- session.canRetry =
1652
- !requiresFreshContext && error instanceof ModelHostFailureError;
1653
- session.lastFailure = message;
1654
- if (error instanceof ModelHostCancelledError) {
1655
- delete session.lastFailureKind;
1656
- session.events.push({
1657
- id: session.nextEventId++,
1658
- kind: "cancellation",
1659
- message,
1660
- });
1661
- }
1662
- else {
1663
- session.lastFailureKind = classifyPlayFailure(error, session.canRetry);
1664
- session.events.push({
1665
- id: session.nextEventId++,
1666
- kind: "failure",
1667
- message,
1668
- failureKind: session.lastFailureKind,
1669
- });
1670
- }
1671
- await this.#persist(session);
1672
- await this.#worlds.playAdvances.markSettled(advance);
1673
- this.#notifySnapshot(observer, session);
1604
+ await this.#advances.end(session, dispatchFailureEnding(error, event.id), advance, () => this.#notifySnapshot(observer, session));
1674
1605
  return projectView(session);
1675
1606
  }
1676
- const durableResponse = durableModelResponse(response);
1677
1607
  try {
1678
- const settlement = await this.#prepareResponseSettlement(session, advance, durableResponse);
1679
- const failedTools = settlement.trailingEvents.filter((candidate) => candidate.kind === "tool_result" && !candidate.ok);
1680
- if (failedTools.length > 0 && response.diagnostics !== undefined)
1681
- await this.#failureLog?.recordFailure({
1682
- exchange: response.diagnostics,
1683
- failures: [
1684
- {
1685
- kind: "tool_execution",
1686
- message: "A Runtime tool call from the model was rejected.",
1687
- details: {
1688
- calls: failedTools.map((result) => ({
1689
- id: result.callId,
1690
- name: result.name,
1691
- ok: result.ok,
1692
- markdown: result.markdown,
1693
- replayed: result.replayed,
1694
- })),
1695
- },
1696
- },
1697
- ],
1698
- });
1699
- const settled = await this.#settleResponseAdvance(session, advance, settlement);
1608
+ const settled = await this.#settleResponse(session, advance, response);
1700
1609
  if (settled.kind === "terminal") {
1701
- if (!settlement.visibleText) {
1610
+ if (!settled.narrative) {
1702
1611
  const message = "The model returned neither text nor a complete tool call.";
1703
1612
  if (response.diagnostics !== undefined)
1704
1613
  await this.#failureLog?.recordFailure({
@@ -1738,18 +1647,7 @@ export class PlayCallChain {
1738
1647
  }
1739
1648
  await this.#runFollowups(session, modelHost, signal, observer);
1740
1649
  this.#setInvocationAbortable(session.chainId, false);
1741
- if (signal.aborted) {
1742
- session.events.push({
1743
- id: session.nextEventId++,
1744
- kind: "cancellation",
1745
- message: "The player cancelled the remaining follow-up generation after the narrative was committed.",
1746
- });
1747
- touch(session);
1748
- this.changed(session.worldId);
1749
- }
1750
- session.status = "ready";
1751
- await this.#persist(session);
1752
- this.#notifySnapshot(observer, session);
1650
+ await this.#advances.end(session, { kind: "ready", followupsCancelled: signal.aborted }, null, () => this.#notifySnapshot(observer, session));
1753
1651
  return projectView(session);
1754
1652
  }
1755
1653
  this.#notifySnapshot(observer, session);
@@ -1757,58 +1655,29 @@ export class PlayCallChain {
1757
1655
  attempt = 1;
1758
1656
  }
1759
1657
  catch (error) {
1760
- const message = error instanceof Error
1761
- ? error.message
1762
- : "Call-chain processing failed.";
1658
+ const ending = settlementFailureEnding(error, "Call-chain processing failed.");
1763
1659
  if (response.diagnostics !== undefined)
1764
1660
  await this.#failureLog?.recordFailure({
1765
1661
  exchange: response.diagnostics,
1766
1662
  failures: [
1767
1663
  {
1768
1664
  kind: "runtime_post_processing",
1769
- message,
1665
+ message: ending.message,
1770
1666
  },
1771
1667
  ],
1772
1668
  });
1773
- const failureKind = classifyPlayFailure(error, false);
1774
- session.events.push({
1775
- id: session.nextEventId++,
1776
- kind: "failure",
1777
- message,
1778
- failureKind,
1779
- });
1780
- session.status = "interrupted";
1781
- session.canRetry = false;
1782
- session.lastFailure = message;
1783
- session.lastFailureKind = failureKind;
1784
- await this.#persist(session);
1785
- this.#notifySnapshot(observer, session);
1669
+ await this.#advances.end(session, ending, advance, () => this.#notifySnapshot(observer, session));
1786
1670
  return projectView(session);
1787
1671
  }
1788
1672
  }
1789
1673
  }
1790
1674
  async #cancelBeforeProviderDispatch(session, request, attempt, observer, advance) {
1791
- if (advance !== undefined) {
1792
- const streaming = session.events.find((event) => event.kind === "assistant" && event.id === advance.eventId);
1793
- if (streaming !== undefined)
1794
- streaming.status = "interrupted";
1795
- }
1796
- const message = "The player cancelled before the next Provider dispatch; the frozen request can be resumed without replaying a completed model result.";
1797
- session.events.push({
1798
- id: session.nextEventId++,
1799
- kind: "cancellation",
1800
- message,
1801
- });
1802
- session.status = "interrupted";
1803
- session.canRetry = true;
1804
- session.lastRequest = structuredClone(request);
1805
- session.lastRequestAttempt = Math.max(0, attempt - 1);
1806
- session.lastFailure = message;
1807
- delete session.lastFailureKind;
1808
- await this.#persist(session);
1809
- if (advance !== undefined)
1810
- await this.#worlds.playAdvances.markSettled(advance);
1811
- this.#notifySnapshot(observer, session);
1675
+ await this.#advances.end(session, {
1676
+ kind: "cancelled_before_dispatch",
1677
+ request,
1678
+ attempt,
1679
+ ...(advance === undefined ? {} : { streamingEventId: advance.eventId }),
1680
+ }, advance ?? null, () => this.#notifySnapshot(observer, session));
1812
1681
  return projectView(session);
1813
1682
  }
1814
1683
  /**
@@ -1982,42 +1851,10 @@ export class PlayCallChain {
1982
1851
  : loaded;
1983
1852
  if (advance === null && session.status !== "running")
1984
1853
  return;
1985
- await this.#interruptAbandonedDispatch(session, advance);
1986
- if (advance !== null)
1987
- await this.#worlds.playAdvances.markSettled(advance);
1988
- }
1989
- async #interruptAbandonedDispatch(session, advance) {
1854
+ // Authority already moved past the persisted parent head: the world cannot
1855
+ // be replayed onto that head, so the recovery requires a fresh context.
1990
1856
  const headMoved = (await this.#worlds.currentHead(session.worldId)) !== session.parentHead;
1991
- const playerPhase = advance?.advanceKind === "player";
1992
- const streaming = playerPhase
1993
- ? undefined
1994
- : session.events.findLast((event) => event.kind === "assistant" &&
1995
- event.status === "streaming" &&
1996
- (advance === null || event.id === advance.eventId));
1997
- if (streaming !== undefined)
1998
- streaming.status = "interrupted";
1999
- // Authority already moved past the persisted parent head: the world
2000
- // cannot be replayed onto that head, so a fresh context is required.
2001
- const canRetry = !headMoved && !playerPhase && session.lastRequest !== null;
2002
- const message = headMoved
2003
- ? "The service stopped after the world was updated; use a fresh context."
2004
- : playerPhase
2005
- ? "The service stopped while saving the player message; use a fresh context."
2006
- : canRetry
2007
- ? "The service stopped while the model request was in progress; retry to call the model again."
2008
- : "The service stopped before preparing a model request; use a fresh context.";
2009
- const failureKind = canRetry ? "retryable" : "other";
2010
- session.events.push({
2011
- id: session.nextEventId++,
2012
- kind: "failure",
2013
- message,
2014
- failureKind,
2015
- });
2016
- session.status = "interrupted";
2017
- session.canRetry = canRetry;
2018
- session.lastFailure = message;
2019
- session.lastFailureKind = failureKind;
2020
- await this.#persist(session);
1857
+ await this.#advances.recoverAbandoned(session, advance, headMoved);
2021
1858
  }
2022
1859
  async #assertCurrentHead(session) {
2023
1860
  if ((await this.#worlds.currentHead(session.worldId)) !== session.parentHead)
@@ -2121,14 +1958,16 @@ export class PlayCallChain {
2121
1958
  function followupOperationId(session) {
2122
1959
  return `${session.chainId}:followup:${session.parentHead}`;
2123
1960
  }
2124
- function createRequest(session, modelHost, exchange, appended = session.transcript) {
2125
- const request = createRequestWithMaxOutputTokens(session, modelHost.binding().maxOutputTokens, exchange, appended);
1961
+ function createRequest(session, modelHost, exchange, locale, appended = session.transcript) {
1962
+ const request = createRequestWithMaxOutputTokens(session, modelHost.binding().maxOutputTokens, exchange, locale, appended);
2126
1963
  return modelHost.freezeRequest?.(request) ?? request;
2127
1964
  }
2128
- function createRequestWithMaxOutputTokens(session, maxOutputTokens, exchange, appended = session.transcript) {
1965
+ function createRequestWithMaxOutputTokens(session, maxOutputTokens, exchange, locale, appended = session.transcript) {
2129
1966
  // The turn guide is a request-level suffix: it follows whatever this
2130
- // dispatch appends and is never part of the transcript it is built on.
2131
- const turnGuide = currentPlayPrompt(session).bootstrap.turnGuide;
1967
+ // dispatch appends and is never part of the transcript it is built on. The
1968
+ // player's one-turn request joins the suffix here, not in the bootstrap, so
1969
+ // the bootstrap snapshot stays shared by every dispatch of the turn.
1970
+ const turnGuide = turnGuideWithTurnNote(locale, currentPlayPrompt(session).bootstrap.turnGuide, session.turnNote);
2132
1971
  return {
2133
1972
  bootstrap: structuredClone(currentPlayPrompt(session).bootstrap),
2134
1973
  tools: structuredClone(currentPlayPrompt(session).tools),
@@ -2162,7 +2001,11 @@ function durableModelResponse(response) {
2162
2001
  void _diagnostics;
2163
2002
  return structuredClone(durable);
2164
2003
  }
2165
- function finalizePreparedReceipts(settlement) {
2004
+ /**
2005
+ * Copies each committed write's receipt into the tool results that report it,
2006
+ * then drops the candidate write. Receipts state what the commit made true.
2007
+ */
2008
+ function finalizeWriteReceipts(settlement) {
2166
2009
  const committedWriteRefs = new Set(settlement.stateChanges.map(({ stableShortRef }) => stableShortRef));
2167
2010
  const markdownByCall = new Map();
2168
2011
  for (const completed of settlement.completedTools) {
@@ -2218,13 +2061,17 @@ function appendCommittedHistory(session, head, messages) {
2218
2061
  function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2219
2062
  const key = `${exchange}:${call.id}`;
2220
2063
  const signature = JSON.stringify(call.arguments);
2064
+ const provided = currentPlayPrompt(session).tools.map(({ name }) => name);
2065
+ const resolved = resolveToolName(call.name, provided);
2066
+ // The executed name, not the model's spelling, decides replay identity.
2067
+ const executedName = resolved.kind === "rejected" ? call.name : resolved.name;
2221
2068
  const completed = working.get(key);
2222
2069
  let result;
2223
2070
  let replayed = false;
2224
2071
  if (completed !== undefined) {
2225
2072
  replayed = true;
2226
2073
  result =
2227
- completed.name === call.name && completed.signature === signature
2074
+ completed.name === executedName && completed.signature === signature
2228
2075
  ? structuredClone(completed.result)
2229
2076
  : {
2230
2077
  ok: false,
@@ -2232,19 +2079,19 @@ function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2232
2079
  markdown: "# Runtime tool rejected\n\nThe same tool-call ID is already bound to another tool name or argument set.",
2233
2080
  };
2234
2081
  }
2235
- else if (!callChainToolNames.has(call.name) ||
2236
- !currentPlayPrompt(session).tools.some(({ name }) => name === call.name)) {
2082
+ else if (resolved.kind === "rejected" ||
2083
+ !callChainToolNames.has(executedName)) {
2237
2084
  result = {
2238
2085
  ok: false,
2239
2086
  failureKind: "protocol",
2240
- markdown: `# Runtime tool rejected\n\nThe current call chain does not provide ${call.name}.`,
2087
+ markdown: `# Runtime tool rejected\n\nThe current call chain does not provide ${call.name}. Available tools: ${provided.join(", ")}.`,
2241
2088
  };
2242
2089
  }
2243
2090
  else {
2244
2091
  try {
2245
- result = isResolutionTool(call.name)
2246
- ? resolutionToolResult(call.name, call.arguments, resolutionSeed, key, locale)
2247
- : call.name === "world_checkpoint"
2092
+ result = isResolutionTool(executedName)
2093
+ ? resolutionToolResult(executedName, call.arguments, resolutionSeed, key, locale)
2094
+ : executedName === "world_checkpoint"
2248
2095
  ? typeof call.arguments === "object" &&
2249
2096
  call.arguments !== null &&
2250
2097
  !Array.isArray(call.arguments) &&
@@ -2260,7 +2107,7 @@ function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2260
2107
  failureKind: "protocol",
2261
2108
  markdown: "world_checkpoint requires an empty argument object.",
2262
2109
  }
2263
- : session.documents.execute(call, session.history, {
2110
+ : session.documents.execute({ ...call, name: executedName }, session.history, {
2264
2111
  historyScope: session.worldId,
2265
2112
  locale,
2266
2113
  legacyHistoryTools: hasLegacyHistoryTools(currentPlayPrompt(session).tools),
@@ -2273,9 +2120,14 @@ function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2273
2120
  markdown: `# Runtime tool failed\n\n${error instanceof Error ? error.message : "Tool execution failed."}`,
2274
2121
  };
2275
2122
  }
2123
+ if (resolved.kind === "alias")
2124
+ result = {
2125
+ ...result,
2126
+ markdown: `${result.markdown}\n\n${renderToolAliasNote(call.name, executedName, locale)}`,
2127
+ };
2276
2128
  working.set(key, {
2277
2129
  key,
2278
- name: call.name,
2130
+ name: executedName,
2279
2131
  signature,
2280
2132
  result: structuredClone(result),
2281
2133
  });
@@ -2285,6 +2137,7 @@ function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2285
2137
  kind: "tool_result",
2286
2138
  callId: call.id,
2287
2139
  name: call.name,
2140
+ ...(executedName === call.name ? {} : { executedName }),
2288
2141
  ok: result.ok,
2289
2142
  markdown: result.markdown,
2290
2143
  ...(result.resolution === undefined
@@ -2402,6 +2255,7 @@ function projectView(session) {
2402
2255
  ...projectContext(session),
2403
2256
  worldId: session.worldId,
2404
2257
  previousContexts: (session.previousContexts ?? []).map((context) => projectContext(context)),
2258
+ ...(lastEndingCancelled(session) ? { lastCancelled: true } : {}),
2405
2259
  };
2406
2260
  }
2407
2261
  function projectContextReads(session) {
@@ -2414,7 +2268,8 @@ function projectContextReads(session) {
2414
2268
  exchange = event.exchange;
2415
2269
  continue;
2416
2270
  }
2417
- if (event.kind === "tool_call" && event.name === "context_read") {
2271
+ if (event.kind === "tool_call" &&
2272
+ (event.executedName ?? event.name) === "context_read") {
2418
2273
  const ref = isRecord(event.arguments) && typeof event.arguments.ref === "string"
2419
2274
  ? event.arguments.ref
2420
2275
  : "(invalid ref)";
@@ -2424,7 +2279,8 @@ function projectContextReads(session) {
2424
2279
  });
2425
2280
  continue;
2426
2281
  }
2427
- if (event.kind !== "tool_result" || event.name !== "context_read")
2282
+ if (event.kind !== "tool_result" ||
2283
+ (event.executedName ?? event.name) !== "context_read")
2428
2284
  continue;
2429
2285
  const call = calls.get(event.callId);
2430
2286
  if (call === undefined)
@@ -2528,6 +2384,28 @@ function validatePlayerText(value) {
2528
2384
  if (value.trim().length === 0)
2529
2385
  throw new PlayCallChainError("Player text must not be empty.");
2530
2386
  }
2387
+ /**
2388
+ * The player's one-turn request: one line, bounded like a turn-guide line.
2389
+ * Blank input means no request. Length beyond the soft limit is only warned
2390
+ * about by the client and Prompt Preview, never rejected here.
2391
+ */
2392
+ function validTurnNote(value) {
2393
+ if (value === undefined)
2394
+ return undefined;
2395
+ if (!isTurnGuideLine(value))
2396
+ throw new PlayCallChainError("The turn request must be a single line of at most 1000 characters.");
2397
+ const trimmed = value.trim();
2398
+ return trimmed.length === 0 ? undefined : trimmed;
2399
+ }
2400
+ function setTurnNote(session, turnNote) {
2401
+ if (turnNote === undefined)
2402
+ delete session.turnNote;
2403
+ else
2404
+ session.turnNote = turnNote;
2405
+ }
2406
+ function turnNoteField(turnNote) {
2407
+ return turnNote === undefined ? {} : { turnNote };
2408
+ }
2531
2409
  function touch(session) {
2532
2410
  session.updatedAt = Date.now();
2533
2411
  }