@jskit-ai/assistant-core 0.2.7 → 0.2.9

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,547 @@
1
+ import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
+ import {
3
+ OPENCODE_INTERRUPT_TIMEOUT_MS, inspectOpenCodeMessageAdmission, openCodeMessageRows, dispatchOpenCodeTurn, runOpenCodeConversationTurn
4
+ } from "./openCodeTurn.js";
5
+ import { ensureOpenCodeSession, sameOpenCodeSelection } from "./openCodeNativeSession.js";
6
+ import { openCodeTurnSnapshot } from "./openCodeTurnMonitor.js";
7
+ import { normalizeNullableText } from "./normalize.js";
8
+ import { ignorePromptRejection } from "./nativeCommandCallbacks.js";
9
+
10
+ /**
11
+ * A session's main native conversation: its readiness, the messages sent to
12
+ * it, its renewal turn, its interrupt, and the reconciliation of saved runs
13
+ * after a restart.
14
+ * @param {{
15
+ * processes: Map<string, object>,
16
+ * monitors: Map<string, Promise<object>>,
17
+ * turns: Map<string, object>,
18
+ * processOwner: import("./openCodeProcessOwner.js").OpenCodeProcessOwner,
19
+ * turnMonitor: import("./openCodeTurnMonitor.js").OpenCodeTurnMonitor
20
+ * }} options
21
+ */
22
+ export function createOpenCodeSessionTurns({ processes, monitors, turns, processOwner, turnMonitor }) {
23
+ async function runPreparedRenewalTurn(preparation) {
24
+ const acquired = await processOwner.acquirePrepared(await preparation.process());
25
+ const releaseDirectory = await processOwner.admitDirectory(acquired.workdir);
26
+ try {
27
+ const target = await ensureOpenCodeSession(acquired, preparation.session());
28
+ const turn = preparation.turn(target);
29
+ const result = await runOpenCodeConversationTurn(target.server.client, target.upstreamSessionId, turn.input, {
30
+ ...turn.options,
31
+ ...(turn.release ? { close: () => processOwner.releaseProcessTarget(target, {}, turn.release) } : {})
32
+ });
33
+ return turn.completeResult(result);
34
+ } finally {
35
+ releaseDirectory();
36
+ }
37
+ }
38
+
39
+ async function sendPreparedMessage(prepared, options) {
40
+ if (Object.hasOwn(prepared, "value")) return prepared.value;
41
+ return prepared.project(await sendMessage(prepared.key, prepared.input, options, prepared.application));
42
+ }
43
+
44
+ async function inspectPreparedMessage(prepared) {
45
+ const target = await processOwner.acquirePrepared(prepared.process);
46
+ const observation = await inspectOpenCodeMessageAdmission(target.server.client, prepared.threadId, prepared.inputMessageId);
47
+ return prepared.project(observation);
48
+ }
49
+
50
+ async function interruptPreparedTurn(prepared) {
51
+ const target = processes.get(prepared.key) || null;
52
+ return interruptTurn(prepared.key, target, {
53
+ threadId: target ? "" : prepared.threadId, writeRun: prepared.writeRun
54
+ }).catch(prepared.failure);
55
+ }
56
+
57
+ async function sendMessage(key, input, options, application) {
58
+ if (input.duplicate) {
59
+ const currentTurn = openCodeTurnSnapshot(turns.get(key));
60
+ return {
61
+ delivered: true,
62
+ duplicate: true,
63
+ ok: true,
64
+ thread: { id: currentTurn?.threadId || input.threadId },
65
+ turn: currentTurn
66
+ };
67
+ }
68
+ const currentTurn = turns.get(key);
69
+ if (currentTurn?.observationError) {
70
+ await monitors.get(key);
71
+ }
72
+ if (currentTurn?.active && currentTurn.observationError) {
73
+ // An explicit Send may retry stopping uncertain work, but cannot overlap it.
74
+ try {
75
+ await processOwner.stopUnobservedSession(processes.get(key), currentTurn.threadId);
76
+ } catch (error) {
77
+ return { failure: { error, recovery: true } };
78
+ }
79
+ currentTurn.active = false;
80
+ currentTurn.state = "interrupted";
81
+ await application.writeRun(currentTurn, "interrupted", currentTurn.observationError);
82
+ }
83
+ let currentMonitor = monitors.get(key);
84
+ let currentThreadId = input.threadId;
85
+ const ownershipMatchesTurn = Boolean(
86
+ currentMonitor &&
87
+ options.turnOwnership &&
88
+ normalizeNullableText(options.turnOwnership.threadId) === currentThreadId &&
89
+ normalizeNullableText(options.turnOwnership.turnId) === normalizeNullableText(currentTurn?.id)
90
+ );
91
+ if (ownershipMatchesTurn && options.turnOwnership.reusable !== true) {
92
+ return { ownerConflict: true, threadId: currentThreadId,
93
+ turn: openCodeTurnSnapshot(currentTurn, currentThreadId) };
94
+ }
95
+ const eventStartedAt = Date.now();
96
+ const startedAt = new Date(eventStartedAt).toISOString();
97
+ const providerMessageId = input.id;
98
+ const startingTurn = currentMonitor
99
+ ? null
100
+ : {
101
+ active: true,
102
+ error: "",
103
+ eventStartedAt,
104
+ id: providerMessageId,
105
+ inputMessageId: providerMessageId,
106
+ startedAt,
107
+ state: "starting",
108
+ threadId: currentThreadId,
109
+ updatedAt: startedAt
110
+ };
111
+ if (startingTurn) {
112
+ turns.set(key, startingTurn);
113
+ await application.writeRun(startingTurn, "starting");
114
+ }
115
+ let admitted = null;
116
+ let target = null;
117
+ let admission = null;
118
+ let dispatchTurn = null;
119
+ let dispatchMonitor = null;
120
+ let previousInputMessageId = null;
121
+ let previousAdmission;
122
+ let recoveryReceipt = null;
123
+ let recoveryObservationError = null;
124
+ try {
125
+ const preparation = await application.prepare({ overwrite: !currentMonitor, threadId: currentThreadId });
126
+ const preparedTarget = preparation.process ? await processOwner.acquirePrepared(preparation.process) : preparation.target;
127
+ if (!monitors.get(key)) await processOwner.currentDirectory(preparedTarget.workdir);
128
+ target = await ensureOpenCodeSession(preparedTarget, preparation.session);
129
+ currentThreadId = target.upstreamSessionId;
130
+ if (startingTurn) {
131
+ startingTurn.threadId = currentThreadId;
132
+ }
133
+ const prompt = await application.prompt();
134
+ // Preparation can outlive the preceding turn's final projection. Do not
135
+ // attach a new input to a monitor that has already claimed retirement.
136
+ currentMonitor = monitors.get(key);
137
+ if (currentMonitor && !turns.get(key)?.active) {
138
+ await currentMonitor;
139
+ currentMonitor = monitors.get(key);
140
+ }
141
+ const recoveryTurn = currentMonitor ? turns.get(key) : null;
142
+ if (recoveryTurn?.recoveryAdmission) {
143
+ try {
144
+ recoveryReceipt = await recoveryTurn.recoveryAdmission.promise;
145
+ currentMonitor = monitors.get(key);
146
+ if (currentMonitor && !turns.get(key)?.active) {
147
+ await currentMonitor;
148
+ currentMonitor = monitors.get(key);
149
+ }
150
+ // Stop must not turn a waiting, undispatched input into a fresh turn.
151
+ recoveryTurn.abortController.signal.throwIfAborted();
152
+ target.abortController.signal.throwIfAborted();
153
+ } catch (error) {
154
+ // This is R's failure, not a rejected or attempted native B input.
155
+ return { failure: { error, attempted: false, threadId: currentThreadId,
156
+ turn: openCodeTurnSnapshot(turns.get(key), currentThreadId) } };
157
+ }
158
+ }
159
+ // A new native turn starts only on its directory's current credentials;
160
+ // the wait, if any, is for that directory's own running turns.
161
+ while (!currentMonitor && !processOwner.directoryCurrent(target.workdir)) {
162
+ await processOwner.waitForDirectory(target.workdir);
163
+ currentMonitor = monitors.get(key);
164
+ if (currentMonitor && !turns.get(key)?.active) {
165
+ await currentMonitor;
166
+ currentMonitor = monitors.get(key);
167
+ }
168
+ }
169
+ admission = Promise.withResolvers();
170
+ // The observer may be between polls when dispatch or persistence fails.
171
+ // Keep the raw promise rejectable without an unhandled rejection.
172
+ void admission.promise.catch(() => {});
173
+ let eventReady = null;
174
+ if (currentMonitor) {
175
+ dispatchTurn = turns.get(key);
176
+ previousInputMessageId = dispatchTurn.inputMessageId;
177
+ previousAdmission = dispatchTurn.admission;
178
+ dispatchTurn.admission = admission;
179
+ dispatchTurn.inputMessageId = providerMessageId;
180
+ dispatchTurn.updatedAt = new Date().toISOString();
181
+ dispatchMonitor = currentMonitor;
182
+ } else {
183
+ eventReady = Promise.withResolvers();
184
+ dispatchMonitor = turnMonitor.beginMessageMonitor(key, target, { id: providerMessageId, eventStartedAt, startedAt }, {
185
+ ...options, admission, eventReady
186
+ }, application.monitor);
187
+ dispatchTurn = turns.get(key);
188
+ }
189
+ admitted = await dispatchOpenCodeTurn(dispatchTurn, {
190
+ get agent() { return prompt.agent; },
191
+ delivery: currentMonitor ? "steer" : "queue",
192
+ id: providerMessageId,
193
+ get model() { return prompt.model; },
194
+ get prompt() { return prompt.prompt; },
195
+ get attachments() { return prompt.attachments; }
196
+ }, {
197
+ get client() { return target.server.client; },
198
+ get conversationId() { return target.upstreamSessionId; },
199
+ events: eventReady ? { waitUntilReady: () => eventReady.promise } : null,
200
+ get signal() {
201
+ return AbortSignal.any([
202
+ target.abortController.signal,
203
+ turns.get(key).abortController.signal
204
+ ]);
205
+ },
206
+ beforeDispatch: () => prompt.beforeDispatch(target.upstreamSessionId),
207
+ promptTimeoutMs: options.promptTimeoutMs,
208
+ authorizeAttachments: prompt.authorizeAttachments
209
+ });
210
+ } catch (error) {
211
+ const rejected = error?.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 408;
212
+ const retainPreviousInput = previousInputMessageId !== null &&
213
+ (dispatchTurn?.promptAttempted !== true || rejected);
214
+ if (retainPreviousInput) {
215
+ if (dispatchTurn.admission === admission && dispatchTurn.inputMessageId === providerMessageId) {
216
+ dispatchTurn.inputMessageId = previousInputMessageId;
217
+ dispatchTurn.admission = previousAdmission;
218
+ }
219
+ admission.resolve();
220
+ } else {
221
+ // Attempted unknown admission must retain the new input and use the
222
+ // same observer's original Stop/error recovery, never roll back to A.
223
+ admission?.reject(error);
224
+ dispatchTurn?.abortController?.abort(error);
225
+ }
226
+ if (rejected) {
227
+ const { onPromptRejected = ignorePromptRejection } = input;
228
+ await onPromptRejected();
229
+ }
230
+ if (admission && !retainPreviousInput) await dispatchMonitor;
231
+ if (startingTurn && !admission && !monitors.has(key)) {
232
+ startingTurn.active = false;
233
+ startingTurn.error = normalizeNullableText(error?.message) || "OpenCode prompt delivery failed.";
234
+ startingTurn.state = "failed";
235
+ startingTurn.updatedAt = new Date().toISOString();
236
+ await application.writeRun(
237
+ startingTurn,
238
+ "failed",
239
+ startingTurn.error
240
+ ).catch(() => null);
241
+ }
242
+ return { failure: { error, attempted: dispatchTurn?.promptAttempted === true, threadId: currentThreadId,
243
+ turn: openCodeTurnSnapshot(turns.get(key) || startingTurn, currentThreadId) } };
244
+ }
245
+ if (dispatchTurn.admission === admission && dispatchTurn.inputMessageId === providerMessageId) {
246
+ dispatchTurn.inputMessageId = normalizeNullableText(admitted.id);
247
+ dispatchTurn.updatedAt = new Date().toISOString();
248
+ }
249
+ let conversationTurn;
250
+ try {
251
+ conversationTurn = await application.commit(admitted);
252
+ } catch (error) {
253
+ admission.reject(error);
254
+ dispatchTurn.abortController.abort(error);
255
+ // Preserve the persistence error while joining the original recovery.
256
+ await dispatchMonitor.catch(() => null);
257
+ throw error;
258
+ }
259
+ if (recoveryReceipt) {
260
+ const receiptAbort = new AbortController();
261
+ // The original monitor can fail and retire without aborting its turn.
262
+ // Its settlement also ends this read; it never creates a successor turn.
263
+ void dispatchMonitor.then(() => receiptAbort.abort(new Error(
264
+ dispatchTurn.observationError || dispatchTurn.error || "OpenCode observation ended before input custody was confirmed."
265
+ )), error => receiptAbort.abort(error));
266
+ try {
267
+ const observation = await inspectOpenCodeMessageAdmission(target.server.client, currentThreadId,
268
+ normalizeNullableText(admitted.id), { wait: true, signal: AbortSignal.any([
269
+ target.abortController.signal, dispatchTurn.abortController.signal, receiptAbort.signal
270
+ ]) });
271
+ const message = openCodeMessageRows(observation.messages).find(message =>
272
+ message.type === "user" && normalizeNullableText(message.id) === normalizeNullableText(admitted.id));
273
+ const created = message?.time?.created;
274
+ const recoveryCreated = recoveryReceipt.time?.created;
275
+ // Match the native latest-user order, including its ID tie-breaker.
276
+ if (!Number.isFinite(created) || !Number.isFinite(recoveryCreated) ||
277
+ !(created > recoveryCreated || (created === recoveryCreated && message.id > recoveryReceipt.id))) {
278
+ throw new AppError(503,
279
+ "OpenCode could not confirm that the new input follows its final-answer recovery. The admitted message was not resent.",
280
+ { code: "assistant_opencode_recovery_order_unconfirmed", details: {} });
281
+ }
282
+ } catch (error) {
283
+ recoveryObservationError = error;
284
+ } finally {
285
+ receiptAbort.abort();
286
+ }
287
+ }
288
+ if (recoveryObservationError) {
289
+ // Native B was dispatched: keep its authored receipt even when observing
290
+ // its place in native history failed, and use the original cleanup owner.
291
+ admission.reject(recoveryObservationError);
292
+ dispatchTurn.abortController.abort(recoveryObservationError);
293
+ await dispatchMonitor.catch(() => null);
294
+ } else {
295
+ admission.resolve();
296
+ }
297
+ const turn = openCodeTurnSnapshot(turns.get(key), target.upstreamSessionId);
298
+ return {
299
+ conversationTurn,
300
+ delivered: true,
301
+ deliveryMode: currentMonitor ? "steer" : "new_turn",
302
+ ok: true,
303
+ thread: { id: target.upstreamSessionId },
304
+ turn,
305
+ workdir: input.workdir
306
+ };
307
+ }
308
+
309
+ async function reconcileSessions(sessions = [], options = {}, preparation) {
310
+ const results = [];
311
+ for (const session of sessions) {
312
+ const sessionId = normalizeNullableText(session?.sessionId || session?.id);
313
+ try {
314
+ const prepared = await preparation.session(sessionId, session, options);
315
+ const resumed = await reconcilePreparedSession(prepared, prepared.activeRun, options);
316
+ results.push({ ok: true, resumed, sessionId });
317
+ } catch (error) {
318
+ results.push({ error: normalizeNullableText(error?.message), ok: false, sessionId });
319
+ }
320
+ }
321
+ return {
322
+ failed: results.filter((result) => result.ok === false),
323
+ ok: results.every((result) => result.ok),
324
+ results,
325
+ sessionCount: results.length
326
+ };
327
+ }
328
+
329
+ async function reconcilePreparedSession(prepared, activeRun, options) {
330
+ const target = await processOwner.acquirePrepared(await prepared.process());
331
+ const current = await ensureOpenCodeSession(target, prepared.session());
332
+ return reconcileSessionTurn(prepared.key, current, activeRun, options, prepared.observation).catch(prepared.failure);
333
+ }
334
+
335
+ async function reconcileSessionTurn(key, target, activeRun, options, application) {
336
+ if (activeRun?.observationError) {
337
+ await processOwner.stopUnobservedSession(target, activeRun.threadId);
338
+ await application.writeRun({ ...activeRun, id: activeRun.turnId }, "interrupted", activeRun.observationError);
339
+ } else if (activeRun) {
340
+ turnMonitor.beginMessageMonitor(key, target, {
341
+ restored: true,
342
+ eventStartedAt: Date.parse(normalizeNullableText(activeRun.startedAt)) || Date.now(),
343
+ id: normalizeNullableText(activeRun.turnId) || application.fallbackTurnId,
344
+ startedAt: normalizeNullableText(activeRun.startedAt)
345
+ }, options, application.monitor);
346
+ }
347
+ return Boolean(activeRun && !activeRun.observationError);
348
+ }
349
+
350
+ async function ensurePreparedSessionReadiness(prepared) {
351
+ if (Object.hasOwn(prepared, "value")) return prepared.value;
352
+ let target = processes.get(prepared.key);
353
+ const connection = await prepared.connection();
354
+ const { ready, unhealthyProcess, missingUpstream } = await inspectSessionReadiness(target, {
355
+ selection: prepared.selection, workdir: prepared.workdir, connection,
356
+ changed: prepared.changed, refresh: prepared.refresh
357
+ });
358
+ if (!ready) {
359
+ const { value: recovered } = await prepared.prepare(recovery => recoverSessionReadiness(target, {
360
+ unhealthyProcess, missingUpstream,
361
+ get key() { return recovery.key; }
362
+ }, async () => {
363
+ const configuration = recovery.configuration();
364
+ async function ensureProcess() {
365
+ return processOwner.acquirePrepared(await configuration.process());
366
+ }
367
+ const current = await ensureProcess();
368
+ return ensureOpenCodeSession(current, configuration.session);
369
+ }));
370
+ if (recovered?.ok === false) return recovered;
371
+ target = recovered;
372
+ }
373
+ const savedRun = prepared.observation.run;
374
+ if (needsObservationRecovery(prepared.key, savedRun)) {
375
+ await prepared.observation.write(async (run, commit) => {
376
+ if (!needsObservationRecovery(prepared.key, run)) return;
377
+ const threadId = normalizeNullableText(run.threadId) || target.upstreamSessionId;
378
+ await recoverObservation(target, threadId, () => commit(writeRun => (
379
+ recordStoppedObservation(prepared.key, run, threadId, writeRun)
380
+ )));
381
+ });
382
+ }
383
+ return {
384
+ ok: true,
385
+ thread: { id: target.upstreamSessionId },
386
+ turn: openCodeTurnSnapshot(turns.get(prepared.key), target.upstreamSessionId),
387
+ workdir: prepared.workdir
388
+ };
389
+ }
390
+
391
+ async function inspectSessionReadiness(target, { selection, workdir, connection, refresh, changed }) {
392
+ let ready = false;
393
+ let unhealthyProcess = null;
394
+ let missingUpstream = false;
395
+ if (
396
+ target?.upstream &&
397
+ sameOpenCodeSelection(target.upstreamSelection, selection) &&
398
+ target.workdir === workdir &&
399
+ normalizeNullableText(target.selection?.catalogRevision) === normalizeNullableText(selection.catalogRevision) &&
400
+ target.connectionFingerprint === connection.fingerprint &&
401
+ target.canonicalUrl === connection.canonicalUrl &&
402
+ target.endpointCode === connection.endpointCode
403
+ ) {
404
+ const observedProcess = processOwner.current;
405
+ const observedServer = target.server;
406
+ let healthy = false;
407
+ const probe = AbortSignal.timeout(1_000);
408
+ try {
409
+ await target.server.client.health({ signal: probe });
410
+ healthy = true;
411
+ ready = Boolean(await target.server.client.readSession(target.upstreamSessionId));
412
+ } catch (error) {
413
+ if ((!healthy && probe.aborted) || (healthy && error?.statusCode !== 404)) {
414
+ throw error;
415
+ }
416
+ if (!healthy) {
417
+ unhealthyProcess = observedProcess;
418
+ }
419
+ }
420
+ const current = await refresh();
421
+ if (
422
+ processes.get(current.key) !== target ||
423
+ target.abortController.signal.aborted ||
424
+ target.server !== observedServer ||
425
+ target.workdir !== current.workdir ||
426
+ !sameOpenCodeSelection(target.upstreamSelection, current.selection) ||
427
+ normalizeNullableText(target.selection?.catalogRevision) !== normalizeNullableText(current.selection.catalogRevision)
428
+ ) {
429
+ throw changed();
430
+ }
431
+ missingUpstream = healthy && !ready;
432
+ }
433
+ return { ready, unhealthyProcess, missingUpstream };
434
+ }
435
+
436
+ async function recoverSessionReadiness(target, recovery, prepare) {
437
+ if (recovery.unhealthyProcess && processOwner.current === recovery.unhealthyProcess) {
438
+ await processOwner.stop("opencode-health-check-failed");
439
+ }
440
+ if (recovery.missingUpstream && processes.get(recovery.key) === target) {
441
+ target.upstream = null;
442
+ }
443
+ return prepare();
444
+ }
445
+
446
+ function needsObservationRecovery(key, run) {
447
+ return Boolean(run?.active && run.observationError && !monitors.has(key));
448
+ }
449
+
450
+ async function recordStoppedObservation(key, run, threadId, writeRun) {
451
+ const stopped = {
452
+ ...turns.get(key),
453
+ ...run,
454
+ id: normalizeNullableText(run.turnId),
455
+ threadId,
456
+ active: false,
457
+ state: "interrupted",
458
+ updatedAt: new Date().toISOString()
459
+ };
460
+ await writeRun(stopped, "interrupted", run.observationError);
461
+ turns.set(key, stopped);
462
+ }
463
+
464
+ async function recoverObservation(target, threadId, commit) {
465
+ if (threadId !== target.upstreamSessionId) return;
466
+ const status = await target.server.client.sessionStatus(threadId, {
467
+ signal: AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS)
468
+ });
469
+ if (status?.type !== "idle") return;
470
+ await commit();
471
+ }
472
+
473
+ async function interruptTurn(key, target, { threadId = "", writeRun } = {}) {
474
+ const turn = turns.get(key);
475
+ // Helpers can acquire the shared process before a main conversation exists.
476
+ // Their cleanup is owned by closeSession, not a native main-turn interrupt.
477
+ if (!target || (!normalizeNullableText(target.upstreamSessionId) && !turn?.active)) {
478
+ return {
479
+ interrupted: false,
480
+ ok: true,
481
+ thread: { id: threadId },
482
+ turn: openCodeTurnSnapshot(turn)
483
+ };
484
+ }
485
+ if (turn?.observationError) {
486
+ await monitors.get(key);
487
+ if (turn.active) {
488
+ await processOwner.stopUnobservedSession(target, turn.threadId);
489
+ turn.active = false;
490
+ turn.state = "interrupted";
491
+ await writeRun(turn, turn.state, turn.observationError);
492
+ }
493
+ return { ok: true, interrupted: true, thread: { id: turn.threadId }, turn: openCodeTurnSnapshot(turn) };
494
+ }
495
+ if (turn) {
496
+ turn.interruptRequested = true;
497
+ }
498
+ const interrupted = Boolean(turn?.active);
499
+ try {
500
+ const confirmed = await target.server.client.interrupt(target.upstreamSessionId, {
501
+ signal: AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS)
502
+ });
503
+ if (confirmed !== true) {
504
+ throw new AppError(502, "OpenCode did not confirm Stop. Try Stop again.", {
505
+ code: "assistant_opencode_interrupt_unconfirmed", details: {}
506
+ });
507
+ }
508
+ } catch (error) {
509
+ if (turn) turn.interruptRequested = false;
510
+ if (error?.name === "TimeoutError") {
511
+ throw new AppError(504, "OpenCode did not confirm Stop within 5 seconds. Try Stop again.", {
512
+ code: "assistant_opencode_interrupt_timeout", details: {}
513
+ });
514
+ }
515
+ throw error;
516
+ }
517
+ if (turn?.active && turn.abortController) {
518
+ turn.interruptAcknowledged = true;
519
+ turn.abortController.abort();
520
+ turn.admission?.resolve();
521
+ await monitors.get(key);
522
+ }
523
+ return {
524
+ interrupted,
525
+ ok: true,
526
+ thread: { id: target.upstreamSessionId },
527
+ turn: openCodeTurnSnapshot(turn, target.upstreamSessionId)
528
+ };
529
+ }
530
+
531
+ return Object.freeze({
532
+ runPreparedRenewalTurn,
533
+ sendPreparedMessage,
534
+ inspectPreparedMessage,
535
+ interruptPreparedTurn,
536
+ sendMessage,
537
+ reconcileSessions,
538
+ reconcilePreparedSession,
539
+ ensurePreparedSessionReadiness,
540
+ inspectSessionReadiness,
541
+ recoverSessionReadiness,
542
+ needsObservationRecovery,
543
+ recordStoppedObservation,
544
+ recoverObservation,
545
+ interruptTurn
546
+ });
547
+ }