narraeon 0.8.0 → 0.8.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.
@@ -73,6 +73,14 @@ const requiredFields = {
73
73
  message: "string",
74
74
  continuation: "object",
75
75
  },
76
+ "setting-improvement.message.edit": {
77
+ packageId: "string",
78
+ sessionId: "string",
79
+ requestId: "string",
80
+ messageId: "string",
81
+ message: "string",
82
+ filePolicy: "string",
83
+ },
76
84
  "setting-improvement.cancel": { sessionId: "string" },
77
85
  "setting-improvement.resume": {
78
86
  packageId: "string",
@@ -152,6 +160,14 @@ const requiredFields = {
152
160
  message: "string",
153
161
  continuation: "object",
154
162
  },
163
+ "world.revision.message.edit": {
164
+ worldId: "string",
165
+ sessionId: "string",
166
+ requestId: "string",
167
+ messageId: "string",
168
+ message: "string",
169
+ filePolicy: "string",
170
+ },
155
171
  "world.revision.cancel": { sessionId: "string" },
156
172
  "world.revision.resume": {
157
173
  worldId: "string",
@@ -251,6 +267,10 @@ function validateRequestFields(request) {
251
267
  if (!valid || (kind === "string" && value === "" && !allowsEmptyString))
252
268
  throw new V1ProtocolError("invalid_request", `${String(request.type)}.${field} is invalid`);
253
269
  }
270
+ if (request.type === "setting-improvement.message.edit" &&
271
+ request.filePolicy !== "keep" &&
272
+ request.filePolicy !== "revert")
273
+ throw new V1ProtocolError("invalid_request", "setting-improvement.message.edit.filePolicy is invalid");
254
274
  if (request.type === "world.story.export" &&
255
275
  request.format !== "markdown" &&
256
276
  request.format !== "text")
@@ -510,6 +530,7 @@ const requestTypes = new Set([
510
530
  "setting-improvement.session.delete",
511
531
  "setting-improvement.rollback",
512
532
  "setting-improvement.message",
533
+ "setting-improvement.message.edit",
513
534
  "setting-improvement.cancel",
514
535
  "setting-improvement.resume",
515
536
  "play.read",
@@ -551,6 +572,7 @@ const requestTypes = new Set([
551
572
  "world.revision.session.read",
552
573
  "world.revision.session.delete",
553
574
  "world.revision.message",
575
+ "world.revision.message.edit",
554
576
  "world.revision.cancel",
555
577
  "world.revision.resume",
556
578
  "world.revision.files.replace",
@@ -25,7 +25,7 @@ import { projectArtifactForFrontend, projectDebugArtifactForFrontend, } from "./
25
25
  import { projectPlayerViewPanels, } from "./extension/PlayerViewPanelProjector.js";
26
26
  import { FileNativePromptCompiler } from "./prompt/FileNativePromptCompiler.js";
27
27
  import { FileNativeSettingImprovementStore } from "./setting/FileNativeSettingImprovementStore.js";
28
- import { SettingImprovementRollbackError, SettingImprovementSession, } from "./setting/SettingImprovementSession.js";
28
+ import { SettingImprovementRollbackError, SettingImprovementSession, SettingMessageEditError, } from "./setting/SettingImprovementSession.js";
29
29
  import { FileNativeWorldStore } from "./world/FileNativeWorldStore.js";
30
30
  import { WorldDocumentStore } from "./world/WorldDocumentStore.js";
31
31
  import { FileNativeAiFailureLog } from "./model/AiFailureLog.js";
@@ -33,7 +33,7 @@ import { FileNativeRuntimeErrorLog } from "./RuntimeErrorLog.js";
33
33
  import { ProtagonistTemplateStore } from "./config/ProtagonistTemplateStore.js";
34
34
  import { AppPreferencesStore } from "./config/AppPreferencesStore.js";
35
35
  import { FileNativeWorldRevisionStore } from "./world-revision/FileNativeWorldRevisionStore.js";
36
- import { WorldRevisionSession } from "./world-revision/WorldRevisionSession.js";
36
+ import { WorldRevisionSession, WorldRevisionMessageEditError, } from "./world-revision/WorldRevisionSession.js";
37
37
  import { WorldRevisionWorkspace, WorldRevisionWorkspaceError, } from "./world-revision/WorldRevisionWorkspace.js";
38
38
  import { ConversationChanges } from "./ConversationChanges.js";
39
39
  // 先取得当前端点再按它读取的请求,在端点被回退丢弃时最多重做这么多次(#94)。
@@ -424,6 +424,22 @@ export class V1Runtime {
424
424
  }
425
425
  case "setting-improvement.message":
426
426
  return this.#settingImprovements.send(request);
427
+ case "setting-improvement.message.edit":
428
+ try {
429
+ return await this.#settingImprovements.editMessage(request);
430
+ }
431
+ catch (error) {
432
+ if (error instanceof SettingMessageEditError)
433
+ throw new V1ProtocolError("invalid_request", error.message, {
434
+ cause: error,
435
+ details: {
436
+ kind: "setting_message_edit",
437
+ reason: error.reason,
438
+ paths: error.paths,
439
+ },
440
+ });
441
+ throw error;
442
+ }
427
443
  case "setting-improvement.cancel":
428
444
  return this.#settingImprovements.cancel(request.sessionId);
429
445
  case "setting-improvement.resume":
@@ -708,6 +724,22 @@ export class V1Runtime {
708
724
  return this.#worldRevisionCall(() => this.#worldRevisions.deleteSession(request.worldId, request.sessionId));
709
725
  case "world.revision.message":
710
726
  return this.#worldRevisionCall(() => this.#worldRevisions.send(request));
727
+ case "world.revision.message.edit":
728
+ try {
729
+ return await this.#worldRevisionCall(() => this.#worldRevisions.editMessage(request));
730
+ }
731
+ catch (error) {
732
+ if (error instanceof WorldRevisionMessageEditError)
733
+ throw new V1ProtocolError("invalid_request", error.message, {
734
+ cause: error,
735
+ details: {
736
+ kind: "world_revision_message_edit",
737
+ reason: error.reason,
738
+ paths: error.paths,
739
+ },
740
+ });
741
+ throw error;
742
+ }
711
743
  case "world.revision.cancel":
712
744
  return this.#worldRevisionCall(() => this.#worldRevisions.cancel(request.sessionId));
713
745
  case "world.revision.resume":
@@ -0,0 +1,79 @@
1
+ import { validAuthoringNativeContinuation } from "../model/FileNativeModelAdapters.js";
2
+ /** Checks idempotency before a target claims its own publication lease. */
3
+ export function completedAuthoringEdit(session, edit) {
4
+ const prior = session.editReceipts?.find(({ requestId }) => requestId === edit.requestId);
5
+ if (prior !== undefined &&
6
+ (prior.messageId !== edit.messageId ||
7
+ prior.message !== edit.message ||
8
+ prior.filePolicy !== edit.filePolicy))
9
+ throw new Error("This edit request ID was already used for different content");
10
+ if (!session.completedRequestIds.includes(edit.requestId))
11
+ return false;
12
+ if (prior === undefined)
13
+ throw new Error("This request ID was already used for another authoring operation");
14
+ return true;
15
+ }
16
+ /** Locates and validates the native prefix and prepares the replacement transcript. */
17
+ export function planAuthoringEdit(session, edit) {
18
+ const messageIndex = session.messages.findIndex((entry) => entry.role === "user" && entry.id === edit.messageId);
19
+ if (messageIndex < 0)
20
+ throw new Error("The selected authoring message no longer exists");
21
+ const userIndex = session.messages
22
+ .slice(0, messageIndex)
23
+ .filter((entry) => entry.role === "user").length;
24
+ const itemIndex = session.modelItems
25
+ .map((item, index) => (item.kind === "user" ? index : -1))
26
+ .filter((index) => index >= 0)[userIndex];
27
+ if (itemIndex === undefined ||
28
+ session.modelItems[itemIndex]?.kind !== "user" ||
29
+ session.modelItems[itemIndex].text !== session.messages[messageIndex].text)
30
+ throw new Error("The selected authoring message cannot be matched to the native transcript");
31
+ const prefix = session.modelItems.slice(0, itemIndex);
32
+ if (!validAuthoringNativeContinuation(prefix, session.modelBinding.provider))
33
+ throw new Error("The native continuation before this message is damaged; start a fresh context");
34
+ const next = structuredClone(session);
35
+ next.editReceipts = [...(next.editReceipts ?? []), edit];
36
+ delete next.retryDispatch;
37
+ next.modelItems = prefix;
38
+ next.messages = next.messages.slice(0, messageIndex);
39
+ next.requests = (next.requests ?? []).filter((entry) => entry.modelItemStart < itemIndex);
40
+ return {
41
+ itemIndex,
42
+ tail: session.modelItems.slice(itemIndex),
43
+ next: { ...next, requests: next.requests },
44
+ };
45
+ }
46
+ /** Returns a candidate tree and conflicts; the target validates and publishes it. */
47
+ export function revertAuthoringChanges(files, items) {
48
+ const result = new Map(files.map((file) => [file.path, structuredClone(file)]));
49
+ const conflicts = new Set();
50
+ const changes = items.flatMap((item) => item.kind === "tool" && !item.isError ? item.changes : []);
51
+ for (const change of changes.reverse()) {
52
+ if (conflicts.has(change.path))
53
+ continue;
54
+ const malformed = (change.kind === "create" &&
55
+ (change.before !== null || change.after === null)) ||
56
+ (change.kind === "delete" &&
57
+ (change.before === null || change.after !== null)) ||
58
+ (change.kind === "modify" &&
59
+ (change.before === null ||
60
+ change.after === null ||
61
+ change.before === change.after));
62
+ const current = result.get(change.path);
63
+ if (malformed ||
64
+ (change.after === null
65
+ ? current !== undefined
66
+ : current?.contents !== change.after || current.encoding !== undefined)) {
67
+ conflicts.add(change.path);
68
+ continue;
69
+ }
70
+ if (change.before === null)
71
+ result.delete(change.path);
72
+ else
73
+ result.set(change.path, { path: change.path, contents: change.before });
74
+ }
75
+ return {
76
+ files: [...result.values()].sort((a, b) => a.path.localeCompare(b.path)),
77
+ conflicts: [...conflicts].sort(),
78
+ };
79
+ }
@@ -0,0 +1,59 @@
1
+ import { validAuthoringNativeContinuation } from "../model/FileNativeModelAdapters.js";
2
+ import { dispatchRequiresFreshContext } from "../model/DispatchFailure.js";
3
+ export function prepareAuthoringDispatch(session, request) {
4
+ const retry = session.retryDispatch;
5
+ if (retry?.state === "retryable") {
6
+ session.retryDispatch = { ...retry, state: "dispatching" };
7
+ return { ...request, requestId: retry.requestId, exchange: retry.exchange };
8
+ }
9
+ session.retryDispatch = {
10
+ requestId: request.requestId,
11
+ exchange: request.exchange,
12
+ modelItemCount: session.modelItems.length,
13
+ state: "dispatching",
14
+ };
15
+ return request;
16
+ }
17
+ export function checkpointNextAuthoringDispatch(session, state) {
18
+ const request = session.requests?.at(-1);
19
+ if (session.modelBinding.provider !== "google_interactions" &&
20
+ request !== undefined)
21
+ session.retryDispatch = {
22
+ requestId: request.requestId,
23
+ exchange: session.exchange + 1,
24
+ modelItemCount: session.modelItems.length,
25
+ state,
26
+ };
27
+ }
28
+ export function failAuthoringDispatch(session, error, phase) {
29
+ const checkpoint = session.retryDispatch;
30
+ if (checkpoint === undefined)
31
+ return;
32
+ // Acceptance clears the previous checkpoint. During settlement only a
33
+ // completed tool receipt can create the next one, before its final save.
34
+ const settledNextRequest = phase === "settling" &&
35
+ session.pendingSettlement === null &&
36
+ checkpoint.state === "dispatching" &&
37
+ checkpoint.modelItemCount === session.modelItems.length;
38
+ checkpoint.state =
39
+ session.modelBinding.provider !== "google_interactions" &&
40
+ (phase === "preparing" || phase === "dispatched" || settledNextRequest) &&
41
+ !dispatchRequiresFreshContext(error, false)
42
+ ? "retryable"
43
+ : "blocked";
44
+ }
45
+ export function recoverAuthoringDispatch(session) {
46
+ if (session.retryDispatch?.state === "dispatching")
47
+ session.retryDispatch.state =
48
+ session.modelBinding.provider === "google_interactions"
49
+ ? "blocked"
50
+ : "retryable";
51
+ }
52
+ export function canRetryAuthoringDispatch(session) {
53
+ return (session.runStatus === "interrupted" &&
54
+ session.modelBinding.provider !== "google_interactions" &&
55
+ session.googleOutcomeUnknown !== true &&
56
+ session.pendingSettlement === null &&
57
+ session.retryDispatch?.state === "retryable" &&
58
+ validAuthoringNativeContinuation(session.modelItems, session.modelBinding.provider));
59
+ }
@@ -1,3 +1,4 @@
1
+ import { prepareAuthoringDispatch, failAuthoringDispatch, } from "./AuthoringRetry.js";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { aggregateModelUsage, } from "../../protocol/modelUsage.js";
3
4
  import { ModelHostCancelledError } from "../model/ModelHost.js";
@@ -76,38 +77,51 @@ export class ModelToolConversation {
76
77
  }
77
78
  async #execute(input, live) {
78
79
  const { session } = input;
80
+ let phase = "settling";
79
81
  try {
80
82
  for (let round = 0; round < maximumExchangesPerMessage; round += 1) {
81
- const request = authoringExchange(session, live.requestId, input.worldRevisionBoundaries);
82
- session.exchange += 1;
83
+ let request = authoringExchange(session, input.exchangeRequestId ?? live.requestId, input.worldRevisionBoundaries);
84
+ if (input.retrySupported)
85
+ request = prepareAuthoringDispatch(session, request);
86
+ session.exchange = Math.max(session.exchange, request.exchange ?? session.exchange + 1);
83
87
  if (session.modelBinding.provider === "google_interactions")
84
88
  session.googleOutcomeUnknown = true;
85
89
  live.streaming = emptyStreaming();
86
90
  session.updatedAt = Date.now();
91
+ phase = "preparing";
87
92
  await input.save(session);
88
93
  input.changed?.(true);
94
+ phase = "dispatched";
89
95
  const response = await input.host.exchange(request, {
90
96
  signal: live.controller.signal,
91
97
  onGoogleHop: (hop) => {
92
- const snapshot = session.requests?.findLast((entry) => entry.requestId === live.requestId);
98
+ const requestId = input.exchangeRequestId ?? live.requestId;
99
+ const snapshot = session.requests?.findLast((entry) => entry.requestId === requestId);
93
100
  if (snapshot?.wireRequest === undefined)
94
101
  return;
95
102
  snapshot.wireRequest = withGoogleHop(snapshot.wireRequest, hop);
96
- return input.recordGoogleHop?.(live.requestId, hop);
103
+ return input.recordGoogleHop?.(requestId, hop);
97
104
  },
98
105
  onDelta: (delta) => {
99
106
  recordDelta(live, delta);
100
107
  input.changed?.(false);
101
108
  },
102
109
  });
110
+ if (live.controller.signal.aborted)
111
+ throw new ModelHostCancelledError("The authoring request was cancelled");
112
+ phase = "validating";
103
113
  assertCompleteResponse(response);
104
114
  const toolCalls = response.toolCalls ?? [];
105
115
  assertUniqueToolCallIds(toolCalls);
106
116
  const visibleText = response.text ?? "";
107
- if (toolCalls.length === 0 && visibleText.trim().length === 0)
117
+ session.usage = aggregateModelUsage(session.usage, response.usage);
118
+ if (toolCalls.length === 0 && visibleText.trim().length === 0) {
119
+ phase = "dispatched";
108
120
  throw new Error(input.failure?.emptyResponse ??
109
121
  "The model returned an empty tool-free authoring response");
110
- session.usage = aggregateModelUsage(session.usage, response.usage);
122
+ }
123
+ delete session.retryDispatch;
124
+ phase = "settling";
111
125
  session.modelItems.push({
112
126
  kind: "assistant",
113
127
  text: visibleText,
@@ -140,6 +154,7 @@ export class ModelToolConversation {
140
154
  }
141
155
  // The exchange budget only bounds a runaway loop. Every tool response is
142
156
  // already settled, so the conversation pauses and can resume unchanged.
157
+ delete session.retryDispatch;
143
158
  session.runStatus = "paused";
144
159
  session.activeRequestId = null;
145
160
  session.completedRequestIds = rememberRequest(session.completedRequestIds, live.requestId);
@@ -149,6 +164,7 @@ export class ModelToolConversation {
149
164
  return input.view(session);
150
165
  }
151
166
  catch (error) {
167
+ failAuthoringDispatch(session, error, phase);
152
168
  session.runStatus = "interrupted";
153
169
  session.activeRequestId = null;
154
170
  session.completedRequestIds = rememberRequest(session.completedRequestIds, live.requestId);
@@ -1,5 +1,6 @@
1
1
  import { isAbsolute, win32 } from "node:path";
2
2
  import { inflateRawSync } from "node:zlib";
3
+ import { FileNativeSettingImprovementStore } from "../setting/FileNativeSettingImprovementStore.js";
3
4
  import { portableContentTreePathKey } from "../../protocol/contentTree.js";
4
5
  import { fictionGameplay, gameplayFile, parseGameplay, } from "../../protocol/gameplay.js";
5
6
  import { defaultAppLocale, } from "../../protocol/appPreferences.js";
@@ -36,6 +37,10 @@ export class ContentWorkspace {
36
37
  limits: this.#limits,
37
38
  ...options.currentTree,
38
39
  });
40
+ const recovery = new FileNativeSettingImprovementStore(dataRoot, {
41
+ content: this,
42
+ });
43
+ this.#currentTreeLibrary.setBeforeLease((id, lease) => recovery.recoverPendingEdits(id, lease));
39
44
  }
40
45
  createCurrentTreeContentPackage(gameplay = fictionGameplay) {
41
46
  const locale = this.#locale();
@@ -64,8 +69,8 @@ export class ContentWorkspace {
64
69
  readCurrentTreeContentPackage(localId) {
65
70
  return this.#currentTreeLibrary.readPackage(localId);
66
71
  }
67
- beginCurrentTreeContentPackageOperation(localId) {
68
- return this.#currentTreeLibrary.acquireOperationLease(localId);
72
+ beginCurrentTreeContentPackageOperation(localId, options = {}) {
73
+ return this.#currentTreeLibrary.acquireOperationLease(localId, options);
69
74
  }
70
75
  inspectCurrentTreeContentPackage(files) {
71
76
  return inspectContentPackageCurrentTree(files);
@@ -61,6 +61,10 @@ export class CurrentTreeContentLibrary {
61
61
  #beforeCurrentTreeReplace;
62
62
  #mutationTail = Promise.resolve();
63
63
  #legacyLocksRemoval;
64
+ #beforeLease;
65
+ setBeforeLease(hook) {
66
+ this.#beforeLease = hook;
67
+ }
64
68
  constructor(dataRoot, options = {}) {
65
69
  this.#legacyLocksRoot = join(dataRoot, "content", ".package-locks");
66
70
  this.#packagesRoot = join(dataRoot, "content", "packages");
@@ -162,9 +166,6 @@ export class CurrentTreeContentLibrary {
162
166
  }
163
167
  }
164
168
  async readPackage(localId) {
165
- const snapshot = await this.#readPackageSnapshot(localId);
166
- if (snapshot.metadata.schemaVersion === 2)
167
- return snapshot.package;
168
169
  const lease = await this.acquireOperationLease(localId, { wait: true });
169
170
  try {
170
171
  await ensureStoredPackageTitle(this.#packageRoot(localId), localId, lease.package.title);
@@ -221,7 +222,7 @@ export class CurrentTreeContentLibrary {
221
222
  localId,
222
223
  title: normalizedTitle,
223
224
  });
224
- return packageSummary(await this.readPackage(localId));
225
+ return packageSummary({ ...lease.package, title: normalizedTitle });
225
226
  }
226
227
  finally {
227
228
  lease.release();
@@ -283,19 +284,24 @@ export class CurrentTreeContentLibrary {
283
284
  releaseInProcessPackageLease(packageRoot);
284
285
  };
285
286
  try {
286
- const package_ = (await this.#readPackageSnapshot(localId)).package;
287
- return {
288
- package: structuredClone(package_),
287
+ let package_ = (await this.#readPackageSnapshot(localId)).package;
288
+ const lease = {
289
+ get package() {
290
+ return structuredClone(package_);
291
+ },
289
292
  replace: async (files, options = {}) => {
290
293
  if (released)
291
294
  throw new CurrentTreeContentPackageBusyError();
292
295
  options.signal?.throwIfAborted();
293
296
  const normalized = normalizeCurrentContentTreeFiles(files, this.#limits);
294
297
  assertGameplayModeUnchanged(package_.files, normalized);
295
- return this.#replaceNormalizedCurrentTree(localId, package_.title, normalized, options.signal);
298
+ package_ = await this.#replaceNormalizedCurrentTree(localId, package_.title, normalized, options.signal);
299
+ return structuredClone(package_);
296
300
  },
297
301
  release,
298
302
  };
303
+ await this.#beforeLease?.(localId, lease);
304
+ return lease;
299
305
  }
300
306
  catch (error) {
301
307
  release();
@@ -0,0 +1,8 @@
1
+ import { ModelHostBindingMismatchError, ModelHostContinuationError, ModelHostOutcomeUnknownError, modelHostFailureRequiresFreshContext, } from "./ModelHost.js";
2
+ /** Protocol facts only; each lifecycle decides which failures permit replay. */
3
+ export function dispatchRequiresFreshContext(error, googleDispatched) {
4
+ return (error instanceof ModelHostBindingMismatchError ||
5
+ error instanceof ModelHostContinuationError ||
6
+ (googleDispatched && error instanceof ModelHostOutcomeUnknownError) ||
7
+ modelHostFailureRequiresFreshContext(error));
8
+ }
@@ -1652,6 +1652,23 @@ function providerText(content) {
1652
1652
  }
1653
1653
  return JSON.stringify(content) ?? "";
1654
1654
  }
1655
+ /** Uses the same native payload checks as the production request encoders. */
1656
+ export function validAuthoringNativeContinuation(items, provider) {
1657
+ return items.every((item) => {
1658
+ if (item.kind !== "assistant")
1659
+ return true;
1660
+ const state = item.providerState;
1661
+ if (state?.protocol !== provider)
1662
+ return false;
1663
+ if (state.protocol === "chat_completions")
1664
+ return validChatAssistantMessage(state.assistantMessage);
1665
+ if (state.protocol === "openai_responses")
1666
+ return validResponsesOutput(state.output);
1667
+ if (state.protocol === "anthropic_messages")
1668
+ return validAnthropicContent(state.content);
1669
+ return validGoogleInteractionsContinuation(state.steps);
1670
+ });
1671
+ }
1655
1672
  function validChatAssistantMessage(value) {
1656
1673
  if (!isRecord(value) || value.role !== "assistant")
1657
1674
  return false;
@@ -749,6 +749,55 @@ export class FileNativePlayTimelineStore {
749
749
  indexValue.chainId !== chainId ||
750
750
  stateValue.chainId !== chainId)
751
751
  throw new Error("Play context durable identity is inconsistent");
752
+ // Older releases disallowed retry after a player cancellation. The tail
753
+ // cancellation and interrupted assistant prove this was a dispatch ending,
754
+ // not a settlement failure. Validate the frozen request before exposing it.
755
+ if (!indexValue.importedFromArchive &&
756
+ stateValue.status === "interrupted" &&
757
+ !stateValue.canRetry &&
758
+ stateValue.eventCount >= 2) {
759
+ try {
760
+ const [tail, previous] = await Promise.all([
761
+ readJson(join(root, "events", numbered(stateValue.eventCount))),
762
+ readJson(join(root, "events", numbered(stateValue.eventCount - 1))),
763
+ ]);
764
+ assertPlayEvent(tail);
765
+ assertPlayEvent(previous);
766
+ if (tail.kind === "cancellation" &&
767
+ previous.kind === "assistant" &&
768
+ previous.status === "interrupted") {
769
+ const [base, continuation] = await Promise.all([
770
+ readJson(join(root, "base.json")),
771
+ readJson(join(root, "continuations", `${stateValue.continuationDigest}.json`)),
772
+ ]);
773
+ assertContextBase(base);
774
+ assertContextContinuation(continuation);
775
+ if (base.worldId === worldId &&
776
+ base.chainId === chainId &&
777
+ continuation.chainId === chainId &&
778
+ continuation.lastRequestDigest !== null &&
779
+ createHash("sha256")
780
+ .update(JSON.stringify(continuation))
781
+ .digest("hex") === stateValue.continuationDigest) {
782
+ const request = await readJson(join(root, "requests", `${continuation.lastRequestDigest}.json`));
783
+ const provider = request.modelConfig?.provider ?? base.modelBinding?.provider;
784
+ if (provider !== undefined &&
785
+ provider !== "google_interactions" &&
786
+ Array.isArray(request.appended) &&
787
+ // The legacy record carries no codec-validation receipt. Only
788
+ // requests without native assistant fragments can be proven safe.
789
+ request.appended.every((item) => item.kind !== "assistant") &&
790
+ createHash("sha256")
791
+ .update(JSON.stringify(request))
792
+ .digest("hex") === continuation.lastRequestDigest)
793
+ stateValue.canRetry = true;
794
+ }
795
+ }
796
+ }
797
+ catch {
798
+ // Missing or damaged legacy evidence never grants retry or breaks metadata reads.
799
+ }
800
+ }
752
801
  // Old versions persisted a configuration preflight failure as a terminal
753
802
  // context failure. Recover only the disposition proven by its event prefix.
754
803
  // Archive contexts and genuine provider failures retain their restrictions.
@@ -1,4 +1,5 @@
1
- import { ModelHostBindingMismatchError, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, modelHostFailureRequiresFreshContext, } from "../model/ModelHost.js";
1
+ import { dispatchRequiresFreshContext } from "../model/DispatchFailure.js";
2
+ import { ModelHostCancelledError, ModelHostFailureError, ModelHostOutcomeUnknownError, } from "../model/ModelHost.js";
2
3
  import { classifyPlayFailure } from "./PlayFailure.js";
3
4
  const messages = {
4
5
  playerCommitted: "The player message is committed; the frozen model request is ready to continue.",
@@ -97,19 +98,21 @@ export class PlayAdvanceLifecycle {
97
98
  }
98
99
  /**
99
100
  * The ending of an exchange that raised an error before its response was
100
- * settled. Cancellation after dispatch is named explicitly here and is never
101
- * a retry, even though `ModelHostCancelledError` is also an outcome-unknown
102
- * error (ADR-0030).
101
+ * settled. Google dispatched outcomes remain unknown and cannot be replayed.
103
102
  */
104
- export function dispatchFailureEnding(error, streamingEventId) {
103
+ export function dispatchFailureEnding(error, streamingEventId, googleDispatched = false) {
105
104
  const message = errorMessage(error, messages.dispatchInterrupted);
106
105
  if (error instanceof ModelHostCancelledError)
107
- return { kind: "cancelled_after_dispatch", message, streamingEventId };
108
- const requiresFreshContext = error instanceof ModelHostBindingMismatchError ||
109
- error instanceof ModelHostContinuationError ||
110
- error instanceof ModelHostOutcomeUnknownError ||
111
- modelHostFailureRequiresFreshContext(error);
112
- const canRetry = !requiresFreshContext && error instanceof ModelHostFailureError;
106
+ return {
107
+ kind: "cancelled_after_dispatch",
108
+ message,
109
+ canRetry: !googleDispatched,
110
+ streamingEventId,
111
+ };
112
+ const requiresFreshContext = dispatchRequiresFreshContext(error, googleDispatched);
113
+ const canRetry = !requiresFreshContext &&
114
+ (error instanceof ModelHostFailureError ||
115
+ error instanceof ModelHostOutcomeUnknownError);
113
116
  return {
114
117
  kind: "failed",
115
118
  message,
@@ -141,12 +144,13 @@ export function noContinuationEnding() {
141
144
  };
142
145
  }
143
146
  /** A settled response with neither text nor a complete tool call. */
144
- export function emptyResponseEnding() {
147
+ export function emptyResponseEnding(canRetry = false, streamingEventId) {
145
148
  return {
146
149
  kind: "failed",
147
150
  message: messages.noOutput,
148
151
  failureKind: "other",
149
- canRetry: false,
152
+ canRetry,
153
+ streamingEventId,
150
154
  };
151
155
  }
152
156
  /**
@@ -199,7 +203,7 @@ function applyEnding(session, ending) {
199
203
  case "cancelled_after_dispatch":
200
204
  interruptStreamingEvent(session, ending.streamingEventId);
201
205
  session.status = "interrupted";
202
- session.canRetry = false;
206
+ session.canRetry = ending.canRetry;
203
207
  session.lastFailure = ending.message;
204
208
  delete session.lastFailureKind;
205
209
  session.events.push({
@@ -1281,14 +1281,14 @@ export class PlayCallChain {
1281
1281
  calls.some((call) => call.id === event.callId)))
1282
1282
  throw new ModelHostOutcomeUnknownError("Google Interactions repeated a tool-call ID during the same send; its outcome cannot be safely settled. Use a fresh context.");
1283
1283
  }
1284
- const text = durable.text ?? existing.text;
1284
+ const text = durable.text ?? "";
1285
1285
  const hasText = text.trim().length > 0;
1286
1286
  const responseKind = calls.length > 0 ? "tool_step" : hasText ? "narrative" : "empty";
1287
1287
  const narrative = responseKind === "narrative";
1288
1288
  const assistantEvent = {
1289
1289
  id: advance.eventId,
1290
1290
  kind: "assistant",
1291
- text,
1291
+ text: responseKind === "empty" ? existing.text : text,
1292
1292
  status: "completed",
1293
1293
  responseKind,
1294
1294
  exchange: advance.exchange,
@@ -1306,6 +1306,15 @@ export class PlayCallChain {
1306
1306
  : { stopReason: durable.stopReason }),
1307
1307
  continuation: durable.providerState === undefined ? "unavailable" : "available",
1308
1308
  };
1309
+ // No response has been settled or added to the transcript. Non-Google
1310
+ // providers may explicitly replay the frozen request without duplicating work.
1311
+ if (responseKind === "empty" &&
1312
+ (session.lastRequest?.modelConfig?.provider ??
1313
+ session.modelBinding?.provider) !== "google_interactions") {
1314
+ session.events = session.events.map((event) => event.id === advance.eventId ? assistantEvent : event);
1315
+ await this.#advances.end(session, emptyResponseEnding(true, advance.eventId), advance);
1316
+ return { kind: "terminal", status: "interrupted", narrative: false };
1317
+ }
1309
1318
  const workingTools = new Map(session.completedToolMap);
1310
1319
  const prepared = [];
1311
1320
  const trailingEvents = [];
@@ -1615,7 +1624,7 @@ export class PlayCallChain {
1615
1624
  },
1616
1625
  });
1617
1626
  if (signal.aborted)
1618
- throw new ModelHostCancelledError("The player cancelled the dispatched model request; its response was not committed and cannot be replayed.");
1627
+ throw new ModelHostCancelledError("The player cancelled the dispatched model request; its response was not committed.");
1619
1628
  this.#setInvocationAbortable(session.chainId, false);
1620
1629
  if ((response.toolCalls?.length ?? 0) > 0)
1621
1630
  this.#observeInvocation(session.chainId, "tool", {
@@ -1625,7 +1634,10 @@ export class PlayCallChain {
1625
1634
  await this.#failureLog?.recordExchangeIfActive(response.diagnostics);
1626
1635
  }
1627
1636
  catch (error) {
1628
- await this.#advances.end(session, dispatchFailureEnding(error, event.id), advance, () => this.#notifySnapshot(observer, session));
1637
+ await this.#advances.end(session, dispatchFailureEnding(signal.aborted && !(error instanceof ModelHostCancelledError)
1638
+ ? new ModelHostCancelledError("The player cancelled the dispatched model request; its response was not committed.")
1639
+ : error, event.id, (request.modelConfig?.provider ??
1640
+ session.modelBinding?.provider) === "google_interactions"), advance, () => this.#notifySnapshot(observer, session));
1629
1641
  return projectView(session);
1630
1642
  }
1631
1643
  try {