@jskit-ai/assistant-core 0.2.7 → 0.2.8

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 (214) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/package.json +18 -7
  3. package/src/client/browser.js +21 -0
  4. package/src/client/conversation/AssistantAttachmentQueue.vue +25 -108
  5. package/src/client/conversation/AssistantComposerActions.vue +1 -1
  6. package/src/client/conversation/AssistantConversationElement.vue +14 -52
  7. package/src/client/conversation/AssistantGoalControl.vue +13 -10
  8. package/src/client/conversation/AssistantPromptInput.vue +6 -91
  9. package/src/client/conversation/AssistantTranscript.css +505 -0
  10. package/src/client/conversation/AssistantTranscript.vue +24 -1197
  11. package/src/client/conversation/attachmentFiles.js +158 -0
  12. package/src/client/conversation/attachmentQueueItems.js +121 -0
  13. package/src/client/conversation/attachmentUploadRows.js +265 -0
  14. package/src/client/conversation/conversationDisplay.js +54 -0
  15. package/src/client/conversation/messageDelivery.js +13 -9
  16. package/src/client/conversation/retainedConversation.js +14 -3
  17. package/src/client/conversation/submitText.js +13 -2
  18. package/src/client/conversation/transcriptEntries.js +225 -0
  19. package/src/client/conversation/useAssistantAttachments.js +86 -388
  20. package/src/client/conversation/useAssistantQuestions.js +16 -3
  21. package/src/client/conversation/useAssistantSuggestions.js +16 -2
  22. package/src/client/conversation/useScrollToBottom.js +17 -19
  23. package/src/client/conversation/useTextareaAutoGrow.js +115 -0
  24. package/src/client/conversation/useTranscriptScrollFollow.js +525 -0
  25. package/src/client/lib/assistantApi.js +192 -161
  26. package/src/client/types.js +110 -0
  27. package/src/server/conversation/attachments.js +21 -6
  28. package/src/server/conversation/boundedToolLoop.js +1 -0
  29. package/src/server/conversation/claudeClient.js +71 -20
  30. package/src/server/conversation/claudeCommandRun.js +191 -0
  31. package/src/server/conversation/claudeHistory.js +7 -0
  32. package/src/server/conversation/claudeNativeTurn.js +708 -0
  33. package/src/server/conversation/claudeProcess.js +186 -112
  34. package/src/server/conversation/claudeProcessStart.js +214 -0
  35. package/src/server/conversation/claudePromptDelivery.js +183 -0
  36. package/src/server/conversation/claudeSessionCleanup.js +248 -0
  37. package/src/server/conversation/claudeTools.js +15 -3
  38. package/src/server/conversation/claudeTurn.js +212 -1388
  39. package/src/server/conversation/codexAccountReader.js +168 -0
  40. package/src/server/conversation/codexAuthPreflight.js +5 -4
  41. package/src/server/conversation/codexClient.js +18 -6
  42. package/src/server/conversation/codexConfiguration.js +8 -844
  43. package/src/server/conversation/codexConversationStops.js +377 -0
  44. package/src/server/conversation/codexConversationStore.js +119 -85
  45. package/src/server/conversation/codexConversationTurns.js +378 -0
  46. package/src/server/conversation/codexConversations.js +134 -866
  47. package/src/server/conversation/codexDetachedConversation.js +203 -0
  48. package/src/server/conversation/codexDetachedTurn.js +145 -104
  49. package/src/server/conversation/codexEvents.js +5 -200
  50. package/src/server/conversation/codexGoalCommands.js +14 -4
  51. package/src/server/conversation/codexHelperExecution.js +57 -57
  52. package/src/server/conversation/codexHelperThreadLedger.js +30 -288
  53. package/src/server/conversation/codexHelperThreadRecord.js +304 -0
  54. package/src/server/conversation/codexHelperThreadRecovery.js +54 -76
  55. package/src/server/conversation/codexHelperThreads.js +94 -93
  56. package/src/server/conversation/codexHistoryAdapter.js +13 -8
  57. package/src/server/conversation/codexIsolation.js +672 -0
  58. package/src/server/conversation/codexLiveItems.js +379 -0
  59. package/src/server/conversation/codexMessageCommands.js +68 -286
  60. package/src/server/conversation/codexMessageDelivery.js +46 -37
  61. package/src/server/conversation/codexModelCatalog.js +86 -7
  62. package/src/server/conversation/codexNotificationObserver.js +81 -88
  63. package/src/server/conversation/codexNotificationQueue.js +21 -10
  64. package/src/server/conversation/codexObservationControl.js +105 -89
  65. package/src/server/conversation/codexOutput.js +84 -419
  66. package/src/server/conversation/codexProcess.js +29 -1053
  67. package/src/server/conversation/codexProcessLeader.js +1 -0
  68. package/src/server/conversation/codexPromptDispatch.js +241 -0
  69. package/src/server/conversation/codexProvider.js +147 -1807
  70. package/src/server/conversation/codexProviderAuth.js +348 -0
  71. package/src/server/conversation/codexProviderInvalidation.js +371 -0
  72. package/src/server/conversation/codexProviderInventories.js +437 -0
  73. package/src/server/conversation/codexProviderOwner.js +149 -957
  74. package/src/server/conversation/codexProviderRelease.js +465 -0
  75. package/src/server/conversation/codexProviderSelection.js +26 -15
  76. package/src/server/conversation/codexProviderThreads.js +698 -0
  77. package/src/server/conversation/codexProviderTurns.js +453 -0
  78. package/src/server/conversation/codexReasoning.js +32 -23
  79. package/src/server/conversation/codexRenewalCommands.js +18 -6
  80. package/src/server/conversation/codexRenewalThreads.js +7 -22
  81. package/src/server/conversation/codexResultSettlement.js +70 -88
  82. package/src/server/conversation/codexRunJournal.js +91 -97
  83. package/src/server/conversation/codexRuntime.js +426 -0
  84. package/src/server/conversation/codexRuntimeInventory.js +113 -0
  85. package/src/server/conversation/codexRuntimeLifecycle.js +39 -13
  86. package/src/server/conversation/codexRuntimeStart.js +431 -0
  87. package/src/server/conversation/codexRuntimeStop.js +324 -0
  88. package/src/server/conversation/codexSessionCleanup.js +25 -10
  89. package/src/server/conversation/codexSessionReconciliation.js +21 -7
  90. package/src/server/conversation/codexThreadReadiness.js +49 -52
  91. package/src/server/conversation/codexThreadSnapshot.js +210 -0
  92. package/src/server/conversation/codexThreadStatus.js +48 -62
  93. package/src/server/conversation/codexTurn.js +171 -55
  94. package/src/server/conversation/codexTurnActivity.js +210 -0
  95. package/src/server/conversation/codexTurnControl.js +44 -48
  96. package/src/server/conversation/codexTurnEndings.js +610 -0
  97. package/src/server/conversation/codexTurnRecovery.js +97 -848
  98. package/src/server/conversation/codexTurnState.js +138 -111
  99. package/src/server/conversation/completedResponses.js +227 -0
  100. package/src/server/conversation/configuration.js +2 -2
  101. package/src/server/conversation/continuity.js +43 -6
  102. package/src/server/conversation/conversationDelivery.js +394 -0
  103. package/src/server/conversation/conversationEntries.js +365 -0
  104. package/src/server/conversation/conversationEvents.js +192 -0
  105. package/src/server/conversation/conversationGoals.js +187 -0
  106. package/src/server/conversation/conversationHandles.js +237 -0
  107. package/src/server/conversation/conversationReads.js +206 -0
  108. package/src/server/conversation/conversationRefusals.js +58 -0
  109. package/src/server/conversation/conversationReplacement.js +254 -0
  110. package/src/server/conversation/conversationSending.js +273 -0
  111. package/src/server/conversation/conversationStopping.js +121 -0
  112. package/src/server/conversation/conversationTurnDelivery.js +413 -0
  113. package/src/server/conversation/conversationTurns.js +511 -0
  114. package/src/server/conversation/deliveryTiming.js +25 -3
  115. package/src/server/conversation/fileStorage.js +11 -6
  116. package/src/server/conversation/hookBridge.js +14 -3
  117. package/src/server/conversation/index.js +1 -1
  118. package/src/server/conversation/localExecution.js +82 -625
  119. package/src/server/conversation/nativeCommandCallbacks.js +10 -0
  120. package/src/server/conversation/nativeConversationLifecycle.js +379 -0
  121. package/src/server/conversation/nativeHistoryExport.js +27 -18
  122. package/src/server/conversation/normalize.js +9 -0
  123. package/src/server/conversation/openCodeClient.js +295 -366
  124. package/src/server/conversation/openCodeNativeHistory.js +141 -0
  125. package/src/server/conversation/openCodeNativeSession.js +79 -0
  126. package/src/server/conversation/openCodeProcess.js +109 -53
  127. package/src/server/conversation/openCodeProcessOwner.js +565 -0
  128. package/src/server/conversation/openCodeRuntime.js +111 -1814
  129. package/src/server/conversation/openCodeSessionTurns.js +547 -0
  130. package/src/server/conversation/openCodeTemporaryConversations.js +394 -0
  131. package/src/server/conversation/openCodeTools.js +28 -14
  132. package/src/server/conversation/openCodeTurn.js +80 -74
  133. package/src/server/conversation/openCodeTurnMonitor.js +388 -0
  134. package/src/server/conversation/processIdentity.js +582 -0
  135. package/src/server/conversation/provider.js +21 -6
  136. package/src/server/conversation/providers/api.js +268 -107
  137. package/src/server/conversation/providers/claude.js +29 -6
  138. package/src/server/conversation/providers/claudeDriver.js +325 -250
  139. package/src/server/conversation/providers/codex.js +69 -33
  140. package/src/server/conversation/providers/codexDriver.js +503 -1081
  141. package/src/server/conversation/providers/codexDriverConnection.js +331 -0
  142. package/src/server/conversation/providers/codexDriverTurns.js +464 -0
  143. package/src/server/conversation/providers/opencode.js +30 -4
  144. package/src/server/conversation/providers/opencodeDriver.js +321 -659
  145. package/src/server/conversation/providers/opencodeStandalone.js +475 -0
  146. package/src/server/conversation/providers/scoped.js +64 -19
  147. package/src/server/conversation/runtime.js +171 -2863
  148. package/src/server/conversation/storage.js +91 -50
  149. package/src/server/conversation/streams.js +15 -2
  150. package/src/server/conversation/structuredOutput.js +7 -11
  151. package/src/server/conversation/systemPrompt.js +13 -1
  152. package/src/server/conversation/tools.js +47 -15
  153. package/src/server/conversation/transcript.js +49 -20
  154. package/src/server/hosts/claudeTurn.js +2 -4
  155. package/src/server/hosts/codexConfiguration.js +6 -8
  156. package/src/server/hosts/codexEvents.js +4 -2
  157. package/src/server/hosts/codexProcess.js +1 -1
  158. package/src/server/hosts/codexProvider.js +11 -9
  159. package/src/server/hosts/codexTurn.js +3 -3
  160. package/src/server/hosts/openCodeClient.js +4 -2
  161. package/src/server/index.js +0 -2
  162. package/src/server/lib/aiConnectionClient.js +35 -25
  163. package/src/server/lib/ndjson.js +1 -0
  164. package/src/server/lib/providers/anthropicClient.js +15 -6
  165. package/src/server/lib/providers/common.js +10 -6
  166. package/src/server/lib/providers/openAiCompatibleClient.js +40 -23
  167. package/src/server/lib/serviceToolCatalog.js +29 -283
  168. package/src/server/lib/serviceToolEntries.js +270 -0
  169. package/src/server/types.js +78 -0
  170. package/src/shared/assistantResource.js +0 -23
  171. package/src/shared/conversation/answerChoiceSugar.js +2 -14
  172. package/src/shared/conversation/messageInputSugar.js +18 -0
  173. package/src/shared/conversation/numberedQuestionSugar.js +6 -16
  174. package/src/shared/conversation/pagination.js +1 -1
  175. package/src/shared/conversation/questions.js +1 -0
  176. package/src/shared/conversation/streaming.js +1 -0
  177. package/src/shared/index.js +0 -1
  178. package/src/testing/conversationStorageContract.js +20 -0
  179. package/test/aiConnectionClient.test.js +42 -1
  180. package/test/apiConversationDriver.test.js +217 -38
  181. package/test/assistantBoundedToolLoop.test.js +1 -1
  182. package/test/claudeConversationSession.test.js +15 -11
  183. package/test/claudeInstructionResume.test.js +1 -1
  184. package/test/claudeProcess.test.js +9 -9
  185. package/test/claudeTurn.test.js +21 -8
  186. package/test/codexAccountChange.test.js +5 -3
  187. package/test/codexConversationSession.test.js +22 -21
  188. package/test/codexEvents.test.js +3 -1
  189. package/test/codexHelperDeletion.test.js +3 -7
  190. package/test/codexIsolation.test.js +6 -6
  191. package/test/codexModelCatalog.test.js +6 -7
  192. package/test/codexProcess.test.js +3 -3
  193. package/test/codexThreadSelection.test.js +4 -1
  194. package/test/codexTurn.test.js +29 -20
  195. package/test/conversationApiFacilities.test.js +11 -9
  196. package/test/conversationAttachments.test.js +3 -3
  197. package/test/conversationConfiguration.test.js +4 -4
  198. package/test/conversationNativeStorage.test.js +11 -10
  199. package/test/conversationReplacement.test.js +13 -13
  200. package/test/conversationRuntime.test.js +9 -5
  201. package/test/conversationSession.test.js +78 -22
  202. package/test/conversationTools.test.js +47 -26
  203. package/test/fileConversationStorage.test.js +10 -3
  204. package/test/fixtures/codexHelperThreadLedger.js +3 -3
  205. package/test/frameworkActions.test.js +33 -18
  206. package/test/localConversationExecution.test.js +4 -4
  207. package/test/localExecution.test.js +1 -1
  208. package/test/openCodeConnectionChange.test.js +3 -2
  209. package/test/openCodeRuntime.test.js +8 -7
  210. package/test/openCodeTools.test.js +1 -1
  211. package/test/opencodeConversationSession.test.js +4 -3
  212. package/test/serviceToolCatalog.test.js +3 -8
  213. package/src/server/lib/assistantCompletion.js +0 -190
  214. package/src/server/lib/assistantToolLoop.js +0 -259
@@ -0,0 +1,58 @@
1
+ // The refusals the conversation runtime gives its callers, and the error texts its saved state keeps.
2
+ import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
3
+
4
+ /**
5
+ * A refusal for the caller. code is its own value, or one a client branches on
6
+ * shared with other sentences; messageCode names this sentence so a client can
7
+ * show it in the user's language.
8
+ */
9
+ function failure(message, code, statusCode = 409, { messageCode, messageParams } = {}) {
10
+ return new AppError(statusCode, message, { code, ...(messageCode ? { messageCode, messageParams } : {}) });
11
+ }
12
+
13
+ /** A native delivery result keeps its own sentence and messageCode when it has one. */
14
+ function deliveryFailure(delivered) {
15
+ const code = delivered?.code || "conversation_delivery_uncertain";
16
+ return delivered?.error
17
+ ? failure(delivered.error, code, 409, delivered)
18
+ : failure("Native delivery could not be confirmed.", code, 409, { messageCode: "assistant.conversation.deliveryUnconfirmed" });
19
+ }
20
+
21
+ // Error texts that conversation state keeps, including saved records written by
22
+ // earlier releases and the API engine's turn failures. A snapshot or event names
23
+ // them so a client can show them in the user's language; any other error text is
24
+ // shown as sent.
25
+ const STATE_ERROR_MESSAGES = Object.freeze([
26
+ { message: "This conversation exceeds the configured API history limit. Start another conversation or increase the limit.",
27
+ messageCode: "assistant.conversation.apiHistoryLimit" },
28
+ { message: "The model reached its output token limit before completing the answer. Increase maxOutputTokens in the conversation limits.",
29
+ messageCode: "assistant.conversation.outputTokenLimit" },
30
+ { message: "The model stream ended without confirming its application tool calls.", messageCode: "assistant.conversation.toolCallsUnconfirmed" },
31
+ { message: "The model stream ended without confirming a completed answer.", messageCode: "assistant.conversation.answerUnconfirmed" },
32
+ { message: "Application tool progress text exceeds the configured API limit.", messageCode: "assistant.conversation.toolProgressLimit" },
33
+ { message: "The assistant exceeded the configured final reply limit.", messageCode: "assistant.conversation.finalReplyLimit" },
34
+ { message: "Conversation updates exceed the configured history budget. Renew the context with a continuity briefing before continuing.",
35
+ messageCode: "assistant.conversation.historyBudgetExceeded" },
36
+ { message: "The continuity briefing exceeds the configured history budget.", messageCode: "assistant.conversation.briefingBudgetExceeded" },
37
+ { message: "Assistant provider is not configured.", messageCode: "assistant.provider.notConfigured" },
38
+ { message: "The previous submission was interrupted. Check delivery before submitting it again.",
39
+ messageCode: "assistant.conversation.submissionInterrupted" },
40
+ { message: "The application restarted before this answer completed. The request was not replayed.",
41
+ messageCode: "assistant.conversation.restartInterrupted" },
42
+ { message: "Copied into a stopped development environment. This request was not replayed.",
43
+ messageCode: "assistant.conversation.copiedInterrupted" },
44
+ { message: "The application operation result could not be saved. Restore storage access and retry saving.",
45
+ messageCode: "assistant.conversation.operationResultUnsaved" },
46
+ { message: "Conversation progress could not be saved. Restore access to its storage before continuing.",
47
+ messageCode: "assistant.conversation.progressUnsaved" },
48
+ { message: "The conversation request exceeded its time limit.", messageCode: "assistant.conversation.requestTimedOut" },
49
+ { message: "Work stopped.", messageCode: "assistant.conversation.workStopped" },
50
+ { message: "Stopped by user.", messageCode: "assistant.conversation.stoppedByUser" }
51
+ ]);
52
+
53
+ function stateErrorFields(error) {
54
+ const known = STATE_ERROR_MESSAGES.find(entry => entry.message === error);
55
+ return known ? { errorMessageCode: known.messageCode, errorMessageParams: {} } : {};
56
+ }
57
+
58
+ export { deliveryFailure, failure, stateErrorFields };
@@ -0,0 +1,254 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { randomUUID } from "node:crypto";
3
+ import { normalizeConversationConfiguration } from "./configuration.js";
4
+ import { hasUnfinishedConversationRewind, createConversationRuntimeReplacement,
5
+ releaseConversationRuntimeBinding, finishConversationRuntimeReplacement,
6
+ conversationNativeIdentity } from "./runtimeStateUpgrade.js";
7
+ import { createConversationChangeover, conversationContinuity, conversationHistoryVersions } from "./continuity.js";
8
+ import { failure } from "./conversationRefusals.js";
9
+ import { pendingSuccessor, retainedSegments } from "./conversationReads.js";
10
+
11
+ /** @typedef {import("./conversationEntries.js").ConversationEntry} ConversationEntry */
12
+
13
+ /**
14
+ * Replacing an open conversation's native conversation: a renewal, an engine or model change,
15
+ * or a selection, with its continuity briefing and the predecessor it retains.
16
+ * @param {object} dependencies
17
+ * @param {import("../types.js").ReentrantConversationStorage | null} dependencies.storage
18
+ * @param {import("./provider.js").ConversationProviderFactory} dependencies.providers
19
+ * @param {import("./conversationEvents.js").ConversationEvents} dependencies.events
20
+ * @param {import("./conversationEntries.js").ConversationEntries} dependencies.entries
21
+ * @param {import("./conversationReads.js").ConversationReads} dependencies.reads
22
+ * @param {import("./conversationDelivery.js").ConversationDelivery} dependencies.delivery
23
+ * @param {boolean} dependencies.completedEnvelope
24
+ * @param {string | undefined} dependencies.defaultIntegrationId
25
+ * @param {number} dependencies.maximumContinuity
26
+ */
27
+ function createConversationReplacement({ storage, providers, events, entries, reads, delivery, completedEnvelope, defaultIntegrationId,
28
+ maximumContinuity }) {
29
+ /**
30
+ * @param {ConversationEntry} entry
31
+ * @param {object} context
32
+ * @param {object} input
33
+ * @param {boolean} selection Whether this selects an engine or model instead of replacing the native conversation.
34
+ * @param {string} representation
35
+ */
36
+ async function replace(entry, context, input, selection, representation) {
37
+ const operation = selection ? "select" : "replace";
38
+ if (completedEnvelope && input?.engine === "api") throw failure("Completed envelopes require a native engine.", "conversation_completed_envelopes_need_native_engine", 400, { messageCode: "assistant.conversation.completedEnvelopesNeedNativeEngine" });
39
+ await events.access(context, entry.id, operation, entry);
40
+ if (entry.conversation?.commands) {
41
+ if (!selection) throw failure("This conversation retains its original replacement policy.", "conversation_replacement_policy_retained", 400, { messageCode: "assistant.conversation.replacementPolicyRetained" });
42
+ return entry.conversation.commands.select(input, context);
43
+ }
44
+ if (entry.conversation?.native?.scoped) throw failure("This scoped conversation retains its original selected profile.", "conversation_selected_profile_retained", 409, { messageCode: "assistant.conversation.selectedProfileRetained" });
45
+ const keys = selection ? ["operationId", "expectedSegmentId", "engine", "configuration", "retireNative"]
46
+ : ["operationId", "expectedSegmentId", "reason", "engine", "configuration", "briefing"];
47
+ if (!input || !/^[\w-]{1,128}$/u.test(input.operationId || "") || typeof input.expectedSegmentId !== "string" ||
48
+ !input.expectedSegmentId || (selection ? typeof input.engine !== "string" : !["engine-change", "model-change", "renewal"].includes(input.reason)) ||
49
+ input.briefing !== undefined && typeof input.briefing !== "string" ||
50
+ input.retireNative !== undefined && typeof input.retireNative !== "boolean" ||
51
+ Object.keys(input).some(key => !keys.includes(key))) {
52
+ throw failure("Replacement needs an operationId, exact predecessor segment, and an engine-change, model-change or renewal reason.", "conversation_replacement_request_invalid", 400, { messageCode: "assistant.conversation.replacementRequestInvalid" });
53
+ }
54
+ if (entry.active) throw failure("Stop the current turn before replacing its native conversation.", "conversation_busy", 409, { messageCode: "assistant.conversation.stopBeforeReplace" });
55
+ if (entry.storageFailure) throw entry.storageFailure;
56
+ if (entry.conversation) {
57
+ const state = await entries.readState(entry);
58
+ const saved = await entry.conversation.state.read();
59
+ const retry = saved?.replacement?.operationId === input.operationId;
60
+ let nativeResult;
61
+ if (state.segmentId !== input.expectedSegmentId && !retry) throw failure("The native predecessor has changed. Read the conversation before replacing it.", "conversation_predecessor_changed", 409, { messageCode: "assistant.conversation.predecessorChanged" });
62
+ if (selection) {
63
+ if (!entry.conversation.selection?.write) throw failure("This host requires its authorized assistant selection operation.", "conversation_host_selection_required", 409, { messageCode: "assistant.conversation.hostSelectionRequired" });
64
+ await entry.conversation.selection.write(input, context);
65
+ } else {
66
+ if (!input.briefing?.trim()) throw failure("Native replacement requires a continuity briefing.", "conversation_replacement_briefing_required", 400, { messageCode: "assistant.conversation.replacementBriefingRequired" });
67
+ const replacement = createConversationChangeover({ state: entry.conversation.state,
68
+ identity: entry.conversation.identity, presentation: entry.conversation.presentation,
69
+ agent: { async closeSession() { await entry.provider.dispose({ changeover: true, forgetConversationBinding: true }); return { ok: true }; } } });
70
+ nativeResult = await replacement.replace({ engineId: entry.engine, messages: await entry.conversation.transcript.history(),
71
+ operationId: input.operationId, expectedId: input.expectedSegmentId.startsWith(`${entry.engine}:`)
72
+ ? input.expectedSegmentId.slice(entry.engine.length + 1) : "", handover: input.briefing || "" });
73
+ if (retry && saved.replacement.status === "accepted") {
74
+ return representation === "native" ? nativeResult : { operationId: input.operationId, segmentId: entry.segmentId, duplicate: true };
75
+ }
76
+ }
77
+ await entries.rebindHostConversation(entry, context);
78
+ entry.goal = null;
79
+ const receipt = { operationId: input.operationId, segmentId: entry.segmentId, ...(retry ? { duplicate: true } : {}) };
80
+ await events.emit(entry, { type: "replaced", reason: input.reason || "model-change", engine: entry.engine, ...receipt });
81
+ return representation === "native" ? nativeResult : receipt;
82
+ }
83
+ const request = { ...structuredClone(input), operation };
84
+ // Receipt identity must survive JSON storage without picking up a later
85
+ // runtime's connection default when the same operation is retried.
86
+ if (request.engine === undefined) delete request.engine;
87
+ if (request.briefing === undefined) delete request.briefing;
88
+ if (request.configuration === undefined) delete request.configuration;
89
+ else if (request.configuration) request.configuration = normalizeConversationConfiguration(request.configuration);
90
+ let state = await entries.readState(entry);
91
+ if (hasUnfinishedConversationRewind(state)) {
92
+ throw failure("This conversation has an unfinished Undo operation from an earlier runtime. Inspect its saved native and local history offline before replacing it; no history was changed.", "conversation_rewind_inspection_before_replace", 409, { messageCode: "assistant.conversation.rewindInspectionBeforeReplace" });
93
+ }
94
+ const completed = state.predecessors.find(item => item.replacement?.operationId === input.operationId);
95
+ if (completed) {
96
+ if (!isDeepStrictEqual(completed.replacement, request)) throw failure("This replacement operationId already belongs to another request.", "conversation_replacement_operation_conflict", 409, { messageCode: "assistant.conversation.replacementOperationConflict" });
97
+ return { operationId: input.operationId, segmentId: completed.successorId, duplicate: true };
98
+ }
99
+ let pending = state.replacement;
100
+ if (pending && !isDeepStrictEqual(pending.request, request)) throw failure("Retry the unfinished native replacement before starting another one.", "conversation_replacement_unfinished", 409, { messageCode: "assistant.conversation.replacementUnfinished" });
101
+ if (state.segmentId !== input.expectedSegmentId) throw failure("The native predecessor has changed. Read the conversation before replacing it.", "conversation_predecessor_changed", 409, { messageCode: "assistant.conversation.predecessorChanged" });
102
+ if (!selection && retainedSegments(state).some(segment => segment.request)) throw failure("Resolve pending delivery before replacing native history. Another engine may be selected while its receipt stays pending.", "conversation_delivery_uncertain", 409, { messageCode: "assistant.conversation.pendingDeliveryBeforeReplace" });
103
+ if (!selection && !pending && pendingSuccessor(state)) throw failure("Deliver the previous replacement's briefing before replacing native history again.", "conversation_briefing_before_replacement", 409, { messageCode: "assistant.conversation.briefingBeforeReplacement" });
104
+ if (entry.executionFailure && !pending) throw entry.executionFailure;
105
+ const engine = pending?.engine || input.engine || state.engine;
106
+ const configuration = pending?.configuration || (input.configuration
107
+ ? normalizeConversationConfiguration(input.configuration, { engine, defaultIntegrationId }) : state.configuration);
108
+ const driver = providers.createDriver(engine, entry.host);
109
+ driver.validateConfiguration(configuration);
110
+ const predecessorId = conversationNativeIdentity(state);
111
+ const nativeReplacement = !selection && state.engine !== "api" && predecessorId &&
112
+ (state.engine !== "claude" || state.binding.sent);
113
+ const retainNativeBinding = input.retireNative !== true && engine === state.engine &&
114
+ (engine !== "opencode" || configuration.integrationId === state.configuration.integrationId);
115
+ if (selection && engine !== "api" && retainNativeBinding && !pending) {
116
+ // The production native owners apply compatible settings to the running
117
+ // conversation themselves. Selecting a model is not process retirement.
118
+ await storage.write(entry.id, async transaction => {
119
+ const metadata = await transaction.readMetadata();
120
+ const current = metadata.runtime;
121
+ current.predecessors.push({ segmentId: current.segmentId, engine: current.engine,
122
+ configuration: current.configuration, binding: current.binding, seen: current.seen,
123
+ request: current.request, successorId: current.segmentId, replacement: request });
124
+ current.configuration = structuredClone(configuration);
125
+ await transaction.writeMetadata(metadata);
126
+ });
127
+ const receipt = { operationId: input.operationId, segmentId: state.segmentId };
128
+ await events.emit(entry, { type: "replaced", reason: "model-change", engine, ...receipt });
129
+ return receipt;
130
+ }
131
+ if (!pending) {
132
+ const history = (await reads.readTranscript(entry)).filter(turn => !turn.metadata?.runtime?.supersededBy);
133
+ let destination = null;
134
+ if (selection) {
135
+ if (retainNativeBinding) destination = state;
136
+ else if (input.retireNative !== true && engine !== state.engine && driver.createBinding) {
137
+ destination = retainedSegments(state).findLast(segment => segment.engine === engine &&
138
+ (engine !== "opencode" || segment.configuration.integrationId === configuration.integrationId));
139
+ }
140
+ }
141
+ if (engine !== "api" && (input.briefing || "").length > maximumContinuity) throw new Error("The continuity briefing exceeds the configured history budget.");
142
+ const continuity = destination ? { text: destination.continuity, attachments: destination.continuityAttachments || [] }
143
+ : engine === "api" ? conversationContinuity({ history, briefing: input.briefing, maximumCharacters: maximumContinuity })
144
+ : { text: undefined, attachments: [] };
145
+ pending = createConversationRuntimeReplacement({ request, engine, configuration: structuredClone(configuration),
146
+ reason: selection ? state.engine === engine ? "model-change" : "engine-change" : input.reason,
147
+ segmentId: destination?.segmentId || randomUUID(), continuity,
148
+ seen: destination ? destination.seen : engine === "api" ? conversationHistoryVersions(history) : {}, destination });
149
+ }
150
+
151
+ function saveReplacement() {
152
+ return storage.write(entry.id, async transaction => {
153
+ const metadata = await transaction.readMetadata();
154
+ if (metadata.runtime.segmentId !== input.expectedSegmentId || metadata.runtime.replacement &&
155
+ !isDeepStrictEqual(metadata.runtime.replacement.request, request)) {
156
+ throw failure("The saved replacement changed before it could be written.", "conversation_replacement_changed_before_write", 409, { messageCode: "assistant.conversation.replacementChangedBeforeWrite" });
157
+ }
158
+ metadata.runtime.replacement = pending;
159
+ await transaction.writeMetadata(metadata);
160
+ });
161
+ }
162
+
163
+ async function closeSession() {
164
+ try { await entry.provider.dispose(); }
165
+ catch (error) { entry.executionFailure = error; entry.error = error.message; throw error; }
166
+ return { ok: true };
167
+ }
168
+
169
+ let provider;
170
+ const bindingOwner = {};
171
+ async function replacementTransaction(callback) {
172
+ // These common storage/host facilities allocate an inert successor handle.
173
+ // Native process/thread creation stays in ordinary Send, after this commit.
174
+ await events.access(context, entry.id, operation, entry);
175
+ if (pending.segmentId === state.segmentId) {
176
+ pending.binding = (await entries.readState(entry)).binding || null;
177
+ }
178
+ if (!Object.hasOwn(pending, "binding")) {
179
+ pending.binding = driver.createBinding ? await driver.createBinding() : null;
180
+ await saveReplacement();
181
+ }
182
+ provider = await entries.openProvider(entry, driver, pending.binding, pending.segmentId, bindingOwner);
183
+ try {
184
+ await events.access(context, entry.id, operation, entry);
185
+ await storage.write(entry.id, async transaction => {
186
+ const metadata = await transaction.readMetadata();
187
+ state = metadata.runtime;
188
+ if (state.segmentId !== input.expectedSegmentId || !isDeepStrictEqual(state.replacement?.request, request)) {
189
+ throw failure("The saved replacement changed before its binding was released.", "conversation_replacement_changed_before_release", 409, { messageCode: "assistant.conversation.replacementChangedBeforeRelease" });
190
+ }
191
+ await callback({
192
+ async releaseBinding(replacement) {
193
+ if (replacement && conversationNativeIdentity(state) !== replacement.previous.conversationId) {
194
+ throw failure("The native predecessor identity changed before its binding was released.", "conversation_predecessor_changed_before_release", 409, { messageCode: "assistant.conversation.predecessorChangedBeforeRelease" });
195
+ }
196
+ releaseConversationRuntimeBinding(state, pending, request, engine);
197
+ },
198
+ async write(value) {
199
+ finishConversationRuntimeReplacement(state, { engine, selection, retireNative: input.retireNative, pending, value });
200
+ await transaction.writeMetadata(metadata);
201
+ }
202
+ });
203
+ });
204
+ } catch (error) { await provider.dispose(); throw error; }
205
+ }
206
+
207
+ if (nativeReplacement) {
208
+ const replacement = createConversationChangeover({
209
+ state: {
210
+ read: delivery.changeoverState(entry).read,
211
+ async write(value) {
212
+ pending.preparedAt = value.replacement.preparedAt;
213
+ await saveReplacement();
214
+ },
215
+ transaction: replacementTransaction
216
+ },
217
+ identity: {
218
+ async inspect(expectedId) {
219
+ const { runtime } = await storage.read(entry.id, transaction => transaction.readMetadata());
220
+ if (runtime.segmentId !== input.expectedSegmentId || conversationNativeIdentity(runtime) !== expectedId) {
221
+ throw failure("The native predecessor identity changed or is incomplete.", "conversation_predecessor_incomplete", 409, { messageCode: "assistant.conversation.predecessorIncomplete" });
222
+ }
223
+ return { bindingNames: ["binding"], previous: { engineId: runtime.engine, conversationId: expectedId } };
224
+ }
225
+ },
226
+ agent: { closeSession }
227
+ });
228
+ await replacement.replace({ engineId: engine, messages: [], operationId: input.operationId,
229
+ expectedId: predecessorId, handover: input.briefing || "" });
230
+ } else {
231
+ // API conversations, retained-engine selection and unstarted native handles
232
+ // have no native predecessor replacement. Keep their common facilities.
233
+ if (!state.replacement) await saveReplacement();
234
+ await closeSession();
235
+ await replacementTransaction(async transaction => {
236
+ await transaction.releaseBinding();
237
+ await transaction.write();
238
+ });
239
+ }
240
+ entries.adoptBinding(entry, { engine, driver, provider, bindingOwner, segmentId: pending.segmentId });
241
+ Object.assign(entry, { goal: null, executionFailure: null, error: pending.submission?.error || "" });
242
+ const receipt = { operationId: input.operationId, segmentId: pending.segmentId };
243
+ await events.emit(entry, { type: "replaced", reason: pending.reason, engine, ...receipt });
244
+ return receipt;
245
+ }
246
+
247
+ return Object.freeze({
248
+ replace
249
+ });
250
+ }
251
+
252
+ /** @typedef {ReturnType<typeof createConversationReplacement>} ConversationReplacement */
253
+
254
+ export { createConversationReplacement };
@@ -0,0 +1,273 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { createConversationDeliveryTiming, recordNoDeliveryTiming } from "./deliveryTiming.js";
3
+ import { codexAppServerTurnStateFromAgentRun } from "./codexTurn.js";
4
+ import { conversationAttachmentIds } from "./attachments.js";
5
+ import { failure } from "./conversationRefusals.js";
6
+ import { retainedSegments, submittedMessage } from "./conversationReads.js";
7
+ import { serial } from "./conversationEvents.js";
8
+
9
+ /** @typedef {import("./conversationEntries.js").ConversationEntry} ConversationEntry */
10
+
11
+ /**
12
+ * One message an open conversation sends: its validated input, who sent it and how, and its
13
+ * admission as the turn delivers it. admitSend creates it.
14
+ * @typedef {object} ConversationRequest
15
+ * @property {{ messageId: string, text: string, origin: string, attachments: object[], data?: object, goal?: object }} input
16
+ * @property {any} [originalInput] A native caller's own input, kept for the native representation.
17
+ * @property {string} operation The operation it is authorized as: send, steer, wake or goal.
18
+ * @property {object} context
19
+ * @property {string} at
20
+ * @property {(stage: string) => void} deliveryTiming
21
+ * @property {boolean} [completedEnvelope]
22
+ * @property {string[]} [excludedMessageIds]
23
+ * @property {boolean} [nativeToolAttempt]
24
+ * @property {boolean} steering Whether it steers a running native turn.
25
+ * @property {PromiseWithResolvers<any>} admission Resolves with its receipt once the turn admits it.
26
+ * @property {Map<string, object>} progress Its completed commentary and thinking, by message id.
27
+ * @property {ReturnType<typeof import("./tools.js").createConversationTools>} [tools]
28
+ * @property {string} [turnId]
29
+ * @property {string} [nativeTurnId]
30
+ * @property {string} [nativeThreadId]
31
+ * @property {string} [goalMessageId]
32
+ * @property {string} [continuedBy] The message whose admission sealed it.
33
+ * @property {object | null} [answer]
34
+ * @property {boolean} [accepted]
35
+ * @property {boolean} [admissionSettled]
36
+ * @property {boolean} [admissionPublished]
37
+ * @property {boolean} [sealed] Its output is final; late native snapshots no longer change it.
38
+ * @property {boolean} [finished]
39
+ * @property {Promise<void>} [deliveryCommitted]
40
+ * @property {object} [recovered] The saved state of a turn a duplicate delivery recovered.
41
+ */
42
+
43
+ /** Whether a saved request and a new message carry the same content. */
44
+ const sameRequest = (left, right) => left.text === right.text && (left.origin || "user") === right.origin &&
45
+ isDeepStrictEqual(left.goal, right.goal) && isDeepStrictEqual(left.data, right.data) &&
46
+ isDeepStrictEqual((left.attachments || []).map(file => file.attachmentId), right.attachments.map(file => file.attachmentId));
47
+
48
+ /**
49
+ * Sending a message: its validation, its admission into the conversation queue, and the
50
+ * turn it starts or the running turn it steers.
51
+ * @param {object} dependencies
52
+ * @param {import("../types.js").ReentrantConversationStorage | null} dependencies.storage
53
+ * @param {import("./conversationEvents.js").ConversationEvents} dependencies.events
54
+ * @param {import("./conversationEntries.js").ConversationEntries} dependencies.entries
55
+ * @param {import("./conversationReads.js").ConversationReads} dependencies.reads
56
+ * @param {import("./conversationTurns.js").ConversationTurns} dependencies.turns
57
+ * @param {import("../types.js").ConversationDeliveryTimingObserver | undefined} dependencies.onDeliveryTiming
58
+ * @param {readonly string[]} dependencies.deliveryHostStages
59
+ * @param {boolean} dependencies.completedEnvelope
60
+ * @param {number} dependencies.maximumInput
61
+ */
62
+ function createConversationSending({ storage, events, entries, reads, turns, onDeliveryTiming, deliveryHostStages, completedEnvelope, maximumInput }) {
63
+ /**
64
+ * @param {ConversationEntry} entry
65
+ * @param {object} context
66
+ * @param {object} input
67
+ * @param {string} [origin]
68
+ * @param {object} [goal]
69
+ * @param {string} [representation]
70
+ * @param {object} [wakeOptions]
71
+ */
72
+ async function send(entry, context, input, origin = "user", goal, representation, wakeOptions) {
73
+ const deliveryTiming = createConversationDeliveryTiming({ engine: entry.engine,
74
+ conversationId: entry.id, messageId: input?.messageId, report: onDeliveryTiming, hostStages: deliveryHostStages });
75
+ deliveryTiming("received");
76
+ const grant = new Set();
77
+ try {
78
+ await events.access(context, entry.id, goal ? "goal" : input?.steer === true ? "steer" : origin === "application" ? "wake" : "send", entry, grant);
79
+ if (entry.conversation?.commands) {
80
+ if (origin !== "user" || goal) throw failure("This conversation supports its original application Send operation.", "conversation_original_send_only", 400, { messageCode: "assistant.conversation.originalSendOnly" });
81
+ return entry.conversation.commands.send(input, context);
82
+ }
83
+ if (entry.conversation?.native?.scoped) return entry.provider.sendNative({ input, context });
84
+ const originalInput = representation === "native" ? input : undefined;
85
+ const message = originalInput ? { ...input, text: input.displayMessage ?? input.message ?? input.text ?? "" } : input;
86
+ const admission = await serial(entry, () => admitSend(entry, context, message, origin, goal, originalInput, wakeOptions, deliveryTiming, grant));
87
+ const result = await (admission.receipt || admission.promise);
88
+ if (!entry.conversation) return result;
89
+ if (representation === "native") return result.nativeResult;
90
+ if (result.error) throw result.error;
91
+ return result.receipt;
92
+ } catch (error) {
93
+ deliveryTiming("failed");
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Called while holding the conversation queue, including Claude's stop-then-Send goal commands.
100
+ * Every command keeps the ordinary receipt path.
101
+ * @param {ConversationEntry} entry
102
+ * @param {object} context
103
+ * @param {object} input
104
+ * @param {string} [origin]
105
+ * @param {object} [goal]
106
+ * @param {object} [originalInput]
107
+ * @param {object} [wakeOptions]
108
+ * @param {(stage: string) => void} [deliveryTiming]
109
+ * @param {Set<string>} [grant]
110
+ */
111
+ async function admitSend(entry, context, input, origin = "user", goal, originalInput, wakeOptions, deliveryTiming = recordNoDeliveryTiming, grant) {
112
+ deliveryTiming("preparation-started");
113
+ const operation = goal ? "goal" : input?.steer === true ? "steer" : origin === "application" ? "wake" : "send";
114
+ await events.access(context, entry.id, operation, entry, grant);
115
+ if (entry.cancelledMessages.has(input?.messageId)) {
116
+ throw Object.assign(failure("This message was cancelled. Send it again as a new message.", "conversation_message_cancelled", 409,
117
+ { messageCode: "assistant.conversation.messageCancelled" }), { delivery: "not-sent" });
118
+ }
119
+ await entry.conversation?.admission(context, operation);
120
+ deliveryTiming("authorized");
121
+ if (goal) context?.signal?.throwIfAborted();
122
+ let suppliedExclusions;
123
+ if (wakeOptions !== undefined) {
124
+ if (!completedEnvelope || entry.conversation || origin !== "application" || goal ||
125
+ !wakeOptions || typeof wakeOptions !== "object" || Array.isArray(wakeOptions) ||
126
+ Object.keys(wakeOptions).some(name => name !== "excludedMessageIds") ||
127
+ !Array.isArray(wakeOptions.excludedMessageIds) ||
128
+ wakeOptions.excludedMessageIds.some(id => typeof id !== "string" || !id.trim() || id.length > 128) ||
129
+ new Set(wakeOptions.excludedMessageIds).size !== wakeOptions.excludedMessageIds.length) {
130
+ throw failure("Native history exclusions require a server-owned completed application wake and exact message IDs.", "conversation_invalid_message", 400, { messageCode: "assistant.conversation.historyExclusionsInvalid" });
131
+ }
132
+ suppliedExclusions = [...wakeOptions.excludedMessageIds];
133
+ }
134
+ const attachmentIds = conversationAttachmentIds(input?.attachmentIds);
135
+ if (!input || typeof input.messageId !== "string" || !input.messageId.trim() || input.messageId.length > 128 ||
136
+ entry.engine !== "api" && !/^[\w-]{1,128}$/u.test(input.messageId) ||
137
+ typeof input.text !== "string" || !input.text.trim() && !attachmentIds.length || input.text.length > maximumInput) {
138
+ throw failure("Bounded text or attachment IDs and a stable messageId are required.", "conversation_invalid_message", 400, { messageCode: "assistant.conversation.messageContentRequired" });
139
+ }
140
+ if (!originalInput && input.attachments?.length) throw failure("Supply attachmentIds; only the application's resolver can supply file content.", "conversation_attachment_ids_only", 400, { messageCode: "assistant.conversation.attachmentIdsOnly" });
141
+ if (attachmentIds.length && !reads.capabilities(entry).attachments) throw failure("File attachments are not configured for this conversation.", "conversation_attachments_not_configured", 400, { messageCode: "assistant.conversation.attachmentsNotConfigured" });
142
+ if (input.steer !== undefined && typeof input.steer !== "boolean") throw failure("steer must be a boolean.", "conversation_invalid_message", 400, { messageCode: "assistant.conversation.steerNotBoolean" });
143
+ if (input.steer && !entry.driver.capabilities.steering) throw failure("This connection does not support steering.", "conversation_steering_unsupported", 400, { messageCode: "assistant.conversation.steeringUnsupported" });
144
+ let data;
145
+ if (input.data !== undefined) {
146
+ try {
147
+ if (!input.data || typeof input.data !== "object" || Array.isArray(input.data)) throw new TypeError();
148
+ const encoded = JSON.stringify(input.data);
149
+ if (input.text.length + encoded.length > maximumInput) throw new TypeError();
150
+ data = JSON.parse(encoded);
151
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new TypeError();
152
+ } catch {
153
+ throw failure("Application data must be a JSON object within the conversation input limit.", "conversation_invalid_message", 400, { messageCode: "assistant.conversation.applicationDataInvalid" });
154
+ }
155
+ }
156
+ const message = { messageId: input.messageId, text: input.text.trim(), origin,
157
+ attachments: attachmentIds.map(attachmentId => ({ attachmentId })), ...(data !== undefined ? { data } : {}), ...(goal ? { goal } : {}) };
158
+ if (entry.storageFailure) throw entry.storageFailure;
159
+ if (entry.executionFailure) throw entry.executionFailure;
160
+ if (goal && goal.expectedSegmentId !== entry.segmentId) throw failure("The conversation changed before this goal command was sent.", "conversation_goal_changed", 409, { messageCode: "assistant.conversation.goalConversationChanged" });
161
+ const pending = entry.active?.requests.get(message.messageId);
162
+ if (pending && entry.engine !== "api" && !pending.admissionSettled) {
163
+ if (suppliedExclusions && !isDeepStrictEqual(pending.excludedMessageIds || [], suppliedExclusions)) throw failure("This messageId already belongs to different native history exclusions.", "conversation_message_id_exclusions_conflict", 409, { messageCode: "assistant.conversation.messageIdExclusionsConflict" });
164
+ if (!entry.conversation && !sameRequest(pending.input, message)) throw failure("This messageId already belongs to different content.", "conversation_message_id_content_conflict", 409, { messageCode: "assistant.conversation.messageIdContentConflict" });
165
+ return { promise: pending.admission.promise };
166
+ }
167
+ const existing = await reads.findSubmittedTurn(entry, message.messageId);
168
+ deliveryTiming("history-read");
169
+ const nativeState = entry.engine !== "api" && (await entries.readState(entry));
170
+ deliveryTiming("state-read");
171
+ const nativePending = nativeState?.request;
172
+ if (existing) {
173
+ const previous = submittedMessage(existing);
174
+ if (!entry.conversation && !sameRequest(previous, message)) {
175
+ throw failure("This messageId already belongs to different content.", "conversation_message_id_content_conflict", 409, { messageCode: "assistant.conversation.messageIdContentConflict" });
176
+ }
177
+ if (!entry.conversation && !nativePending) return { receipt: { status: "accepted", messageId: message.messageId, turnId: existing.turnId, origin, duplicate: true } };
178
+ }
179
+ if (pending && !entry.conversation) {
180
+ if (!sameRequest(pending.input, message)) throw failure("This messageId already belongs to different content.", "conversation_message_id_content_conflict", 409, { messageCode: "assistant.conversation.messageIdContentConflict" });
181
+ if (suppliedExclusions && !isDeepStrictEqual(pending.excludedMessageIds || [], suppliedExclusions)) throw failure("This messageId already belongs to different native history exclusions.", "conversation_message_id_exclusions_conflict", 409, { messageCode: "assistant.conversation.messageIdExclusionsConflict" });
182
+ return { promise: pending.admission.promise };
183
+ }
184
+ const working = entry.active;
185
+ if (entry.conversation && ["codex", "opencode", "claude"].includes(entry.engine) && ["send", "goal"].includes(operation) && working?.finishing) {
186
+ // No native dispatch has occurred for this request. Finish the previous
187
+ // waiter before reserving the same authored message or goal request.
188
+ await working.done;
189
+ return admitSend(entry, context, input, origin, goal, originalInput, wakeOptions, deliveryTiming, grant);
190
+ }
191
+ const nativeSteering = !working && (nativeState.nativeTurn?.active || entry.driver.canonicalTranscript && nativeState.binding?.codexAppServerRun &&
192
+ codexAppServerTurnStateFromAgentRun(nativeState.binding.codexAppServerRun).active);
193
+ if (completedEnvelope && entry.engine === "claude" &&
194
+ (working?.request.completedEnvelope || nativePending?.completedEnvelope === true || origin === "application" && !goal) &&
195
+ (working || input.steer || nativeSteering)) {
196
+ throw failure("Completed Claude responses cannot share a native steering turn. Wait for the response or stop it.", "conversation_not_steerable", 409, { messageCode: "assistant.conversation.completedClaudeNotSteerable" });
197
+ }
198
+ if (working && !input.steer && !goal && !entry.conversation) throw failure("Wait for this answer or use steer to send another instruction.", "conversation_busy", 409, { messageCode: "assistant.conversation.waitForAnswer" });
199
+ if (input.steer && (!working && !nativeSteering || working?.finishing) && !(nativePending?.attempted || nativePending?.inspectionOnly)) {
200
+ const previousState = nativeState?.nativeTurn?.runState || nativeState?.nativeTurn?.state ||
201
+ (await reads.lastTranscriptTurn(entry))?.metadata?.runtime?.status;
202
+ const completed = working ? !working.controller.signal.aborted && !working.timedOut : ["complete", "completed"].includes(previousState);
203
+ throw Object.assign(failure("There is no active native turn to steer. Send a new message.",
204
+ completed ? "conversation_steer_turn_finished" : "conversation_not_steerable", 409, { messageCode: "assistant.conversation.noActiveTurnToSteer" }), { delivery: "not-sent" });
205
+ }
206
+ if (goal && working?.finishing) throw failure("Wait for the current turn to finish saving before starting the goal.", "conversation_busy", 409, { messageCode: "assistant.conversation.waitForTurnSaving" });
207
+ if (working && (!working.request.admissionSettled || working.steering)) throw failure("Wait for the pending instruction to be admitted before steering again.", "conversation_busy", 409, { messageCode: "assistant.conversation.waitForInstructionAdmission" });
208
+ const barrier = working ? Promise.withResolvers() : null;
209
+ if (barrier) working.steering = barrier.promise;
210
+ const release = () => {
211
+ barrier?.resolve();
212
+ if (working && working.steering === barrier?.promise) working.steering = null;
213
+ };
214
+ let configuration;
215
+ let markedResponse = false;
216
+ let nativeToolAttempt;
217
+ let excludedMessageIds;
218
+ let requestAt = new Date().toISOString();
219
+ // The bound state was read above unless this is an API conversation.
220
+ try { configuration = entry.conversation ? (nativeState || await entries.readState(entry)).configuration : await storage.write(entry.id, async transaction => {
221
+ const metadata = await transaction.readMetadata();
222
+ if (metadata.runtime.replacement) throw failure("Finish the pending native replacement before sending another message.", "conversation_replacement_pending_before_send", 409, { messageCode: "assistant.conversation.replacementPendingBeforeSend" });
223
+ if (retainedSegments(metadata.runtime).some(segment => segment.segmentId !== entry.segmentId && segment.request?.messageId === message.messageId)) {
224
+ throw failure("This message has uncertain delivery in a retained conversation. Select that engine and check its receipt before submitting it again.", "conversation_delivery_uncertain", 409, { messageCode: "assistant.conversation.retainedDeliveryUncertain" });
225
+ }
226
+ if (metadata.runtime.request) {
227
+ if (suppliedExclusions && metadata.runtime.request.messageId !== message.messageId) {
228
+ throw failure("Check delivery of the previous instruction before preparing another completed response.", "conversation_delivery_uncertain", 409, { messageCode: "assistant.conversation.previousInstructionUnchecked" });
229
+ }
230
+ if (entry.engine === "api") throw failure("The previous submission has uncertain delivery. It will not be resent automatically.", "conversation_delivery_uncertain", 409, { messageCode: "assistant.conversation.previousSubmissionUncertain" });
231
+ if (metadata.runtime.request.messageId === message.messageId) {
232
+ if (!sameRequest(metadata.runtime.request, message)) throw failure("This messageId already belongs to different content.", "conversation_message_id_content_conflict", 409, { messageCode: "assistant.conversation.messageIdContentConflict" });
233
+ if (suppliedExclusions && !isDeepStrictEqual(metadata.runtime.request.excludedMessageIds || [], suppliedExclusions)) {
234
+ throw failure("This messageId already belongs to different native history exclusions.", "conversation_message_id_exclusions_conflict", 409, { messageCode: "assistant.conversation.messageIdExclusionsConflict" });
235
+ }
236
+ requestAt = metadata.runtime.request.at;
237
+ }
238
+ } else metadata.runtime.request = { ...message, at: requestAt,
239
+ ...(completedEnvelope && origin === "application" && !goal ? { completedEnvelope: true, excludedMessageIds: suppliedExclusions || [],
240
+ ...(entry.engine === "claude" ? { nativeToolAttempt: false } : {}) } : {}),
241
+ ...(entry.engine !== "api" ? { attempted: false } : {}), ...(input.steer ? { steering: true } : {}) };
242
+ markedResponse = origin === "application" && !goal && metadata.runtime.request?.messageId === message.messageId &&
243
+ metadata.runtime.request.completedEnvelope === true;
244
+ if (markedResponse) excludedMessageIds = structuredClone(metadata.runtime.request.excludedMessageIds || []);
245
+ if (entry.engine === "claude" && markedResponse && typeof metadata.runtime.request.nativeToolAttempt === "boolean") nativeToolAttempt = metadata.runtime.request.nativeToolAttempt;
246
+ await transaction.writeMetadata(metadata);
247
+ return metadata.runtime.configuration;
248
+ }); } catch (error) { release(); throw error; }
249
+ deliveryTiming("request-saved");
250
+ /** @type {ConversationRequest} */
251
+ const request = { input: message, originalInput, operation, context, at: requestAt, deliveryTiming,
252
+ ...(markedResponse ? { completedEnvelope: true, excludedMessageIds } : {}),
253
+ ...(typeof nativeToolAttempt === "boolean" ? { nativeToolAttempt } : {}), steering: Boolean(working || input.steer && nativeSteering),
254
+ admission: Promise.withResolvers(), progress: new Map() };
255
+ request.admission.promise.catch(() => {});
256
+ if (working) {
257
+ working.requests.set(message.messageId, request);
258
+ working.submit(request).catch(error => request.admission.reject(error)).finally(release);
259
+ return { promise: request.admission.promise };
260
+ }
261
+ turns.start(entry, request, configuration);
262
+ return { promise: request.admission.promise };
263
+ }
264
+
265
+ return Object.freeze({
266
+ send,
267
+ admitSend
268
+ });
269
+ }
270
+
271
+ /** @typedef {ReturnType<typeof createConversationSending>} ConversationSending */
272
+
273
+ export { createConversationSending };