@jskit-ai/assistant-core 0.1.199 → 0.1.201

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 (164) hide show
  1. package/examples/conversation/README.md +8 -1
  2. package/examples/conversation/package.json +5 -5
  3. package/fixtures/responsive-assistant/App.vue +4 -1
  4. package/package.json +99 -36
  5. package/src/client/conversation/AssistantConversationElement.vue +27 -4
  6. package/src/client/conversation/AssistantConversationStatus.vue +24 -0
  7. package/src/client/conversation/AssistantGoalControl.vue +8 -5
  8. package/src/client/conversation/AssistantTranscript.vue +10 -3
  9. package/src/client/conversation/index.js +2 -0
  10. package/src/client/conversation/messageDelivery.js +115 -32
  11. package/src/client/conversation/retainedConversation.js +32 -0
  12. package/src/client/conversation/useAssistantQuestions.js +76 -0
  13. package/src/client/lib/assistantApi.js +72 -2
  14. package/src/server/conversation/agentRun.js +222 -0
  15. package/src/server/conversation/attachments.js +93 -0
  16. package/src/server/conversation/claudeClient.js +221 -0
  17. package/src/server/conversation/claudeHistory.js +256 -0
  18. package/src/server/conversation/claudeProcess.js +327 -0
  19. package/src/server/conversation/claudeTools.js +42 -0
  20. package/src/server/conversation/claudeTurn.js +1082 -0
  21. package/src/server/conversation/codexConfiguration.js +1183 -0
  22. package/src/server/conversation/codexConversations.js +1082 -0
  23. package/src/server/conversation/codexDetachedTurn.js +332 -0
  24. package/src/server/conversation/codexEvents.js +194 -1
  25. package/src/server/conversation/codexGoalCommands.js +124 -0
  26. package/src/server/conversation/codexHelperExecution.js +306 -0
  27. package/src/server/conversation/codexHelperThreadLedger.js +804 -0
  28. package/src/server/conversation/codexHelperThreadRecovery.js +395 -0
  29. package/src/server/conversation/codexHelperThreads.js +638 -0
  30. package/src/server/conversation/codexHistoryAdapter.js +369 -0
  31. package/src/server/conversation/codexMessageCommands.js +663 -0
  32. package/src/server/conversation/codexMessageDelivery.js +211 -0
  33. package/src/server/conversation/codexNativeHistoryExport.js +95 -0
  34. package/src/server/conversation/codexNotificationObserver.js +482 -0
  35. package/src/server/conversation/codexNotificationQueue.js +103 -0
  36. package/src/server/conversation/codexObservationControl.js +174 -0
  37. package/src/server/conversation/codexOutput.js +931 -0
  38. package/src/server/conversation/codexProcess.js +1559 -0
  39. package/src/server/conversation/codexProcessLeader.js +14 -0
  40. package/src/server/conversation/codexProvider.js +2710 -0
  41. package/src/server/conversation/codexProviderOwner.js +1058 -0
  42. package/src/server/conversation/codexProviderSelection.js +218 -0
  43. package/src/server/conversation/codexReasoning.js +314 -0
  44. package/src/server/conversation/codexRenewalCommands.js +290 -0
  45. package/src/server/conversation/codexResultSettlement.js +409 -0
  46. package/src/server/conversation/codexRunJournal.js +658 -0
  47. package/src/server/conversation/codexRuntimeLifecycle.js +288 -0
  48. package/src/server/conversation/codexSessionCleanup.js +96 -0
  49. package/src/server/conversation/codexSessionReconciliation.js +284 -0
  50. package/src/server/conversation/codexThreadReadiness.js +397 -0
  51. package/src/server/conversation/codexThreadStatus.js +354 -0
  52. package/src/server/conversation/codexTurn.js +701 -255
  53. package/src/server/conversation/codexTurnControl.js +317 -0
  54. package/src/server/conversation/codexTurnRecovery.js +990 -0
  55. package/src/server/conversation/codexTurnState.js +549 -0
  56. package/src/server/conversation/commandHook.js +18 -0
  57. package/src/server/conversation/commandWrapper.js +37 -0
  58. package/src/server/conversation/configuration.js +43 -0
  59. package/src/server/conversation/continuity.js +289 -0
  60. package/src/server/conversation/fileStorage.js +55 -0
  61. package/src/server/conversation/hookBridge.js +19 -0
  62. package/src/server/conversation/index.js +13 -0
  63. package/src/server/conversation/localExecution.js +665 -0
  64. package/src/server/conversation/memoryStorage.js +7 -72
  65. package/src/server/conversation/nativeHistoryExport.js +90 -0
  66. package/src/server/conversation/nativeStorage.js +23 -0
  67. package/src/server/conversation/openCodeClient.js +209 -19
  68. package/src/server/conversation/openCodePlugin.js +84 -0
  69. package/src/server/conversation/openCodeProcess.js +476 -0
  70. package/src/server/conversation/openCodeRuntime.js +1578 -0
  71. package/src/server/conversation/openCodeTools.js +60 -0
  72. package/src/server/conversation/openCodeTurn.js +557 -0
  73. package/src/server/conversation/provider.js +30 -0
  74. package/src/server/conversation/providers/api.js +136 -0
  75. package/src/server/conversation/providers/claude.js +63 -0
  76. package/src/server/conversation/providers/claudeDriver.js +425 -0
  77. package/src/server/conversation/providers/codex.js +248 -0
  78. package/src/server/conversation/providers/codexDriver.js +1078 -0
  79. package/src/server/conversation/providers/opencode.js +49 -0
  80. package/src/server/conversation/providers/opencodeDriver.js +650 -0
  81. package/src/server/conversation/providers/scoped.js +312 -0
  82. package/src/server/conversation/runtime.js +2220 -0
  83. package/src/server/conversation/runtimeStateUpgrade.js +125 -0
  84. package/src/server/conversation/storage.js +184 -0
  85. package/src/server/conversation/streams.js +7 -2
  86. package/src/server/conversation/structuredOutput.js +240 -0
  87. package/src/server/conversation/systemPrompt.js +54 -0
  88. package/src/server/conversation/tools.js +84 -0
  89. package/src/server/conversation/transcript.js +5 -4
  90. package/src/server/hosts/claudeHistory.js +5 -0
  91. package/src/server/hosts/claudeProcess.js +11 -0
  92. package/src/server/hosts/claudeTurn.js +4 -0
  93. package/src/server/hosts/codexConfiguration.js +20 -0
  94. package/src/server/hosts/codexEvents.js +13 -0
  95. package/src/server/hosts/codexProcess.js +12 -0
  96. package/src/server/hosts/codexProvider.js +23 -0
  97. package/src/server/hosts/codexTurn.js +20 -0
  98. package/src/server/hosts/nativeHistory.js +3 -0
  99. package/src/server/hosts/openCodeClient.js +3 -0
  100. package/src/server/hosts/openCodeProcess.js +23 -0
  101. package/src/server/hosts/openCodeTurn.js +7 -0
  102. package/src/server/index.js +2 -0
  103. package/src/server/lib/aiConnectionClient.js +13 -6
  104. package/src/server/lib/assistantCompletion.js +190 -0
  105. package/src/server/lib/assistantToolLoop.js +314 -0
  106. package/src/server/lib/serviceToolCatalog.js +25 -10
  107. package/src/shared/assistantResponseText.js +29 -0
  108. package/src/shared/conversation/attachments.js +19 -0
  109. package/src/shared/conversation/index.js +3 -0
  110. package/src/shared/conversation/pagination.js +78 -0
  111. package/src/shared/conversation/patches.js +150 -0
  112. package/src/shared/conversation/questions.js +48 -0
  113. package/src/shared/conversation/streaming.js +7 -4
  114. package/src/shared/index.js +2 -0
  115. package/src/shared/nativeProviders.js +45 -0
  116. package/src/testing/conversationStorageContract.js +31 -1
  117. package/src/testing/nativeCodex.js +11 -0
  118. package/src/testing/nativeOpenCode.js +3 -0
  119. package/test/aiConnectionClient.test.js +2 -0
  120. package/test/apiConversationDriver.test.js +73 -0
  121. package/test/assistantApiSurfaceHeader.test.js +85 -0
  122. package/test/assistantBoundedToolLoop.test.js +285 -0
  123. package/test/assistantScroll.browser.test.js +16 -0
  124. package/test/claudeClient.test.js +189 -0
  125. package/test/claudeConversationSession.test.js +1173 -0
  126. package/test/claudeInstructionResume.test.js +82 -0
  127. package/test/claudeProcess.test.js +204 -0
  128. package/test/claudeTurn.test.js +253 -0
  129. package/test/codexConversationSession.test.js +2122 -0
  130. package/test/codexGoal.test.js +37 -0
  131. package/test/codexHelperDeletion.test.js +163 -0
  132. package/test/codexHelperThreadLedger.test.js +494 -0
  133. package/test/codexHistoryAdapter.test.js +541 -0
  134. package/test/codexInteractiveArguments.test.js +30 -0
  135. package/test/codexIsolation.test.js +756 -0
  136. package/test/codexModelCatalog.test.js +362 -0
  137. package/test/codexNativeHistoryExport.test.js +81 -0
  138. package/test/codexThreadSelection.test.js +136 -0
  139. package/test/codexTurn.test.js +2158 -0
  140. package/test/conversation.test.js +110 -0
  141. package/test/conversationAgentRun.test.js +326 -0
  142. package/test/conversationApiFacilities.test.js +90 -0
  143. package/test/conversationAttachments.test.js +238 -0
  144. package/test/conversationCommandWrapper.test.js +48 -0
  145. package/test/conversationConfiguration.test.js +174 -0
  146. package/test/conversationNativeStorage.test.js +25 -0
  147. package/test/conversationReplacement.test.js +172 -0
  148. package/test/conversationRuntime.test.js +241 -0
  149. package/test/conversationRuntimeStateUpgrade.test.js +144 -0
  150. package/test/conversationSession.test.js +1080 -0
  151. package/test/conversationStreaming.test.js +68 -0
  152. package/test/conversationSystemPrompt.test.js +84 -0
  153. package/test/conversationTools.test.js +416 -0
  154. package/test/fileConversationStorage.test.js +73 -0
  155. package/test/fixtures/codexHelperThreadLedger.js +39 -0
  156. package/test/localConversationExecution.test.js +90 -0
  157. package/test/messageDelivery.test.js +234 -1
  158. package/test/nativeConversationRetirement.test.js +215 -0
  159. package/test/openCodeClient.test.js +515 -0
  160. package/test/openCodeProcess.test.js +177 -0
  161. package/test/openCodeRuntime.test.js +322 -0
  162. package/test/openCodeTools.test.js +287 -0
  163. package/test/opencodeConversationSession.test.js +789 -0
  164. package/test/serviceToolCatalog.test.js +24 -0
@@ -0,0 +1,60 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+
4
+ /** Private transport from the native plugin to the application's shared executor. */
5
+ export async function createOpenCodeToolBridge({ execute, maxArgumentBytes, schemas }) {
6
+ const token = randomBytes(32).toString("hex");
7
+ const pending = new Map();
8
+ let closed = false;
9
+ let stopped;
10
+ const server = createServer({ requestTimeout: 30_000, headersTimeout: 10_000 }, (request, response) => {
11
+ if (closed || request.method !== "POST" || request.url !== "/call" ||
12
+ request.headers.authorization !== `Bearer ${token}` || request.headers.origin ||
13
+ request.headers["content-type"] !== "application/json") {
14
+ response.writeHead(403).end();
15
+ return;
16
+ }
17
+ if (pending.size >= 32) { response.writeHead(429).end(); return; }
18
+ const controller = new AbortController();
19
+ response.on("close", () => {
20
+ if (!response.writableFinished) controller.abort(new Error("OpenCode tool connection closed."));
21
+ });
22
+ const work = (async () => {
23
+ try {
24
+ const chunks = [];
25
+ let size = 0;
26
+ for await (const chunk of request) {
27
+ size += chunk.length;
28
+ if (size > maxArgumentBytes + 4096) throw new Error("OpenCode application tool arguments exceeded their size limit.");
29
+ chunks.push(chunk);
30
+ }
31
+ const input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
32
+ controller.signal.throwIfAborted();
33
+ const result = await execute(input, controller.signal);
34
+ if (!response.destroyed) response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(result));
35
+ } catch (error) {
36
+ if (!response.destroyed) response.writeHead(400, { "content-type": "application/json" })
37
+ .end(JSON.stringify({ error: error instanceof SyntaxError ? "Invalid application tool request." : error.message }));
38
+ }
39
+ })().finally(() => pending.delete(controller));
40
+ pending.set(controller, work);
41
+ });
42
+ await new Promise((resolve, reject) => {
43
+ server.once("error", reject);
44
+ server.listen(0, "127.0.0.1", resolve);
45
+ });
46
+ return {
47
+ configuration: { url: `http://127.0.0.1:${server.address().port}/call`, token, schemas },
48
+ whenIdle: () => Promise.allSettled([...pending.values()]),
49
+ async close() {
50
+ if (!closed) {
51
+ closed = true;
52
+ for (const controller of pending.keys()) controller.abort(new Error("OpenCode application tools closed."));
53
+ stopped = new Promise(resolve => server.close(resolve));
54
+ server.closeAllConnections();
55
+ }
56
+ await stopped;
57
+ await Promise.allSettled([...pending.values()]);
58
+ }
59
+ };
60
+ }
@@ -0,0 +1,557 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { openCodeAssistantMessageText as assistantMessageText } from "./openCodeClient.js";
3
+
4
+ const text = value => String(value ?? "").trim();
5
+ export const OPENCODE_INTERRUPT_TIMEOUT_MS = 5_000;
6
+ const OPENCODE_EVENT_READY_TIMEOUT_MS = 120_000;
7
+
8
+ export function openCodeDetachedPrompt(input = {}) {
9
+ const prompt = text(input.prompt || input.message);
10
+ if (!input.outputSchema) {
11
+ return prompt;
12
+ }
13
+ return [
14
+ prompt,
15
+ "",
16
+ "Return only one JSON value matching this JSON Schema. Do not wrap it in Markdown code fences:",
17
+ JSON.stringify(input.outputSchema)
18
+ ].join("\n");
19
+ }
20
+
21
+ export function openCodeStructuredOutput(value = "") {
22
+ const original = String(value ?? "");
23
+ const match = /^```(?:json)?[\t ]*\r?\n([\s\S]*?)\r?\n```$/iu.exec(original.trim());
24
+ return match ? match[1].trim() : original;
25
+ }
26
+
27
+
28
+ function record(value = null) {
29
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
30
+ }
31
+
32
+ function openCodeEventSummary(event = {}) {
33
+ const payload = record(event.data);
34
+ const properties = record(payload.properties);
35
+ const data = Object.keys(properties).length ? properties : record(payload.data);
36
+ const info = record(data.info);
37
+ const part = record(data.part);
38
+ const model = record(info.model || data.model);
39
+ return {
40
+ agent: text(info.agent || data.agent),
41
+ at: Number(data.timestamp || part.time?.start || part.time?.created) || Date.now(),
42
+ eventId: text(event.id || payload.id || part.id),
43
+ messageId: text(part.messageID || data.messageID || info.id),
44
+ modelId: text(model.id || model.modelID || info.modelID),
45
+ modelProviderId: text(model.providerID || info.providerID),
46
+ partId: text(part.id || data.partID),
47
+ ...(part.type === "text" && typeof part.text === "string" ? { textSnapshot: part.text } : {}),
48
+ ...(payload.type === "message.part.delta" && data.field === "text" && typeof data.delta === "string"
49
+ ? { textDelta: data.delta } : {}),
50
+ partType: text(part.type) || (
51
+ text(payload.type) === "session.next.reasoning.ended"
52
+ ? "reasoning"
53
+ : text(payload.type) === "session.next.text.ended"
54
+ ? "text"
55
+ : ""
56
+ ),
57
+ text: ["reasoning", "text"].includes(text(part.type))
58
+ ? text(part.text || data.delta).slice(0, 32_000)
59
+ : ["session.next.text.ended", "session.next.reasoning.ended"].includes(text(payload.type))
60
+ ? text(data.text).slice(0, 32_000)
61
+ : "",
62
+ tool: text(part.tool || data.tool),
63
+ type: text(payload.type || event.event)
64
+ };
65
+ }
66
+
67
+ async function consumeOpenCodeEvents(client, conversationId, {
68
+ eventStartedAt = null, onEvent = null, onError = null, onReady = null,
69
+ readError = openCodeMessageError, signal
70
+ } = {}) {
71
+ for await (const event of client.events(conversationId, { onReady, signal })) {
72
+ signal?.throwIfAborted();
73
+ const summary = openCodeEventSummary(event);
74
+ const current = eventStartedAt === null || Number(summary.at) >= Number(eventStartedAt);
75
+ if (
76
+ current && summary.type === "session.error" &&
77
+ text(event.data?.properties?.sessionID) === conversationId &&
78
+ typeof onError === "function"
79
+ ) {
80
+ const failure = record(event.data?.properties?.error);
81
+ onError(Object.assign(new Error(readError({ error: failure }) || "OpenCode turn failed."), {
82
+ name: text(failure.name) || "Error"
83
+ }));
84
+ }
85
+ if (summary.type && current && typeof onEvent === "function") {
86
+ await onEvent(summary, event);
87
+ }
88
+ }
89
+ const error = new Error("OpenCode's event connection ended before observation was closed.");
90
+ error.code = "assistant_opencode_observation_lost";
91
+ error.details = {};
92
+ error.statusCode = 503;
93
+ throw error;
94
+ }
95
+
96
+ export function observeOpenCodeEvents(client, conversationId, {
97
+ abortController, signal = abortController.signal, ...options
98
+ } = {}) {
99
+ const eventAbort = new AbortController();
100
+ const eventReady = Promise.withResolvers();
101
+ eventReady.promise.catch(() => {});
102
+ let eventFailure = null;
103
+ const events = consumeOpenCodeEvents(client, conversationId, {
104
+ ...options,
105
+ onError: error => { eventFailure = error; },
106
+ onReady: eventReady.resolve,
107
+ signal: AbortSignal.any([signal, eventAbort.signal])
108
+ }).catch(error => {
109
+ eventReady.reject(error);
110
+ if (!eventAbort.signal.aborted && !signal.aborted) {
111
+ eventFailure = error;
112
+ abortController.abort(error);
113
+ }
114
+ });
115
+ return {
116
+ completion: events,
117
+ readFailure: () => eventFailure,
118
+ waitUntilReady({ timeoutMs = OPENCODE_EVENT_READY_TIMEOUT_MS, timeoutError } = {}) {
119
+ const timeout = setTimeout(() => eventReady.reject(timeoutError || Object.assign(
120
+ new Error("OpenCode's event connection did not become ready."),
121
+ { code: "assistant_opencode_events_timeout", details: {}, statusCode: 504 }
122
+ )), timeoutMs);
123
+ void eventReady.promise.then(() => clearTimeout(timeout), () => clearTimeout(timeout));
124
+ return eventReady.promise;
125
+ },
126
+ async close() {
127
+ eventAbort.abort();
128
+ await events;
129
+ }
130
+ };
131
+ }
132
+
133
+ export function openCodeMessageError(message = {}) {
134
+ return text(message?.error?.message || message?.error?.data?.message || message?.error?.name || message?.error);
135
+ }
136
+
137
+ export async function inspectOpenCodeMessageAdmission(client, conversationId, inputMessageId, {
138
+ signal = AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS)
139
+ } = {}) {
140
+ try {
141
+ // Do not create a missing native session or submit another prompt. Absence
142
+ // from bounded history is uncertainty, not proof of non-admission.
143
+ const messages = await client.messages(conversationId, { limit: 100, order: "desc" }, { signal });
144
+ const accepted = openCodeMessageRows(messages).some((message) =>
145
+ message.type === "user" && text(message.id) === inputMessageId);
146
+ return { accepted, messages };
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ export async function steerOpenCodeTurn(client, conversationId, turn, input, { signal } = {}) {
153
+ const previousMessageId = turn.inputMessageId;
154
+ // The existing observer follows the latest admitted prompt in this conversation.
155
+ turn.inputMessageId = input.id;
156
+ try {
157
+ return await client.prompt(conversationId, {
158
+ ...input,
159
+ delivery: "steer",
160
+ resume: true
161
+ }, { signal });
162
+ } catch (error) {
163
+ turn.inputMessageId = previousMessageId;
164
+ throw error;
165
+ }
166
+ }
167
+
168
+ export async function dispatchOpenCodeTurn(client, conversationId, turn, input, options = {}) {
169
+ const { events, beforeDispatch, readiness, promptTimeoutMs } = options;
170
+ turn.promptAttempted = false;
171
+ if (events) await events.waitUntilReady(readiness);
172
+ const signal = options.signal;
173
+ signal.throwIfAborted();
174
+ await beforeDispatch?.();
175
+ if (options.authorizeAttachments) {
176
+ signal.throwIfAborted();
177
+ if (input.attachments?.length) {
178
+ await (options.client ?? client).allowConversationAttachments(options.conversationId ?? conversationId, input.attachments);
179
+ }
180
+ signal.throwIfAborted();
181
+ }
182
+ turn.promptAttempted = true;
183
+ turn.mayBeRunning = true;
184
+ return (options.client ?? client).prompt(options.conversationId ?? conversationId, {
185
+ ...input,
186
+ delivery: input.delivery === "steer" ? "steer" : "queue",
187
+ resume: true
188
+ }, { signal: promptTimeoutMs ? AbortSignal.any([signal, AbortSignal.timeout(promptTimeoutMs)]) : signal });
189
+ }
190
+
191
+ // Native automatic compaction and its continuation belong to the admitted turn.
192
+ export function openCodeMessageRows(value = null) {
193
+ const rows = Array.isArray(value) ? value : Array.isArray(value?.data) ? value.data : [];
194
+ return rows
195
+ .map((message, index) => ({ index, message }))
196
+ .sort((left, right) => (
197
+ (Number(left.message?.time?.created) || 0) - (Number(right.message?.time?.created) || 0) ||
198
+ left.index - right.index
199
+ ))
200
+ .map(({ message }) => message);
201
+ }
202
+
203
+ export function openCodePersistentConversationMessages(rows, conversationMessageId) {
204
+ return rows.filter((row) => row.type === "assistant").flatMap((row) => {
205
+ const complete = Boolean(row.time?.completed || row.finish || row.error);
206
+ const reasoning = (row.content || []).filter((part) => part.type === "reasoning" && text(part.text))
207
+ .map((part, index) => ({ id: conversationMessageId(row.id, part.id || index, "reasoning"), role: "thinking", text: part.text, complete }));
208
+ const answer = assistantMessageText(row);
209
+ return [...reasoning, ...(answer ? [{ id: conversationMessageId(row.id, "assistant"), role: "assistant", text: answer, complete }] : [])];
210
+ });
211
+ }
212
+
213
+ export async function projectOpenCodeConversationMessages(messages, expectedId, {
214
+ maximumOutput, outputSchema, previous, onMessage
215
+ }) {
216
+ for (const message of openCodeRowsForInput(messages, expectedId)) {
217
+ if (message.type !== "assistant" || message.summary || message.error) continue;
218
+ const text = assistantMessageText(message);
219
+ const thinking = (message.content || []).filter(part => part.type === "reasoning").map(part => part.text || "").join("\n\n");
220
+ if (text.length > maximumOutput || thinking.length > 4 * 1024 * 1024) throw new Error("OpenCode output exceeded the configured limit.");
221
+ const complete = Boolean(message.time?.completed || message.finish);
222
+ const progress = message.finish === "tool-calls" || (message.content || []).some(part => part.type === "tool");
223
+ const answer = outputSchema && complete && !progress ? openCodeStructuredOutput(text) : text;
224
+ for (const [role, value] of [["thinking", thinking], ["assistant", answer]]) {
225
+ const key = `${message.id}:${role}`;
226
+ if (value && (previous.get(key)?.text !== value || previous.get(key)?.complete !== complete)) {
227
+ const item = { id: key, outputId: key, role: role === "assistant" && progress ? "commentary" : role, text: value, complete };
228
+ await onMessage(item);
229
+ previous.set(key, item);
230
+ }
231
+ }
232
+ }
233
+ const result = openCodeLastAssistantResult(openCodeRowsForInput(messages, expectedId));
234
+ return { failure: result.error, providerApiFailure: text(result.message?.error?.name) === "APIError" };
235
+ }
236
+
237
+ export function openCodeRowsForInput(value = null, inputMessageId = "") {
238
+ const rows = openCodeMessageRows(value);
239
+ const index = rows.findIndex((message) => text(message?.id) === text(inputMessageId));
240
+ if (index < 0) {
241
+ return [];
242
+ }
243
+ const turnRows = [];
244
+ let compactionFollowup = false;
245
+ for (const message of rows.slice(index + 1)) {
246
+ if (message?.type === "user") {
247
+ if (message.content?.some((part) => part.type === "compaction" && part.auto === true)) {
248
+ compactionFollowup = true;
249
+ } else {
250
+ // Native automatic compaction emits one continuation or replays the
251
+ // original prompt. Both remain part of this already admitted request.
252
+ if (!compactionFollowup) break;
253
+ compactionFollowup = false;
254
+ }
255
+ }
256
+ turnRows.push(message);
257
+ }
258
+ return turnRows;
259
+ }
260
+
261
+ function openCodeMessageResultForInput(value = null, inputMessageId = "", { readError = openCodeMessageError } = {}) {
262
+ const rows = openCodeRowsForInput(value, inputMessageId);
263
+ if (!rows.length && !openCodeMessageRows(value).some((message) => (
264
+ text(message?.id) === text(inputMessageId)
265
+ ))) {
266
+ return null;
267
+ }
268
+ if (rows.at(-1)?.type !== "assistant") {
269
+ return { admitted: true, complete: false, error: "", text: "", turnId: "" };
270
+ }
271
+ const result = openCodeLastAssistantResult(rows, { readError });
272
+ return {
273
+ admitted: true,
274
+ complete: Boolean(result.error || (!result.message?.summary &&
275
+ (result.message?.time?.completed || result.message?.finish))),
276
+ error: result.error,
277
+ text: result.text,
278
+ turnId: text(result.message?.id)
279
+ };
280
+ }
281
+
282
+ export function openCodeLastAssistantResult(value = null, { readError = openCodeMessageError } = {}) {
283
+ const message = [...openCodeMessageRows(value)]
284
+ .reverse()
285
+ .find((candidate) => candidate?.type === "assistant");
286
+ return {
287
+ error: readError(message),
288
+ message,
289
+ text: message?.summary ? "" : assistantMessageText(message)
290
+ };
291
+ }
292
+
293
+ function latestOpenCodeMessageResult(value, readError) {
294
+ const result = openCodeLastAssistantResult(value, { readError });
295
+ if (!result.message) {
296
+ return null;
297
+ }
298
+ return {
299
+ admitted: true,
300
+ complete: Boolean(result.error || (!result.message.summary &&
301
+ (result.message.time?.completed || result.message.finish))),
302
+ error: result.error,
303
+ text: result.text,
304
+ turnId: text(result.message.id)
305
+ };
306
+ }
307
+
308
+ export async function waitForOpenCodeMessages(client, conversationId = "", inputMessageId = "", {
309
+ onMessages = null,
310
+ readFailure = () => null,
311
+ readError = openCodeMessageError,
312
+ signal
313
+ } = {}) {
314
+ const resolveInputMessageId = typeof inputMessageId === "function"
315
+ ? inputMessageId
316
+ : () => text(inputMessageId);
317
+ let completedInputMessageId = null;
318
+ while (true) {
319
+ signal?.throwIfAborted();
320
+ const expectedInputMessageId = text(resolveInputMessageId());
321
+ const messages = await client.messages(conversationId, {
322
+ limit: 100,
323
+ order: "desc"
324
+ }, { signal });
325
+ signal?.throwIfAborted();
326
+ if (expectedInputMessageId !== text(resolveInputMessageId())) continue;
327
+ if (typeof onMessages === "function") {
328
+ await onMessages(messages, expectedInputMessageId);
329
+ }
330
+ if (expectedInputMessageId !== text(resolveInputMessageId())) continue;
331
+ const result = expectedInputMessageId
332
+ ? openCodeMessageResultForInput(messages, expectedInputMessageId, { readError })
333
+ : latestOpenCodeMessageResult(messages, readError);
334
+ const failure = readFailure();
335
+ if (failure && (await client.sessionStatus(conversationId, { signal })).type === "idle") {
336
+ throw failure;
337
+ }
338
+ // An assistant message can finish with tool calls while the native turn
339
+ // continues. Only an idle session acknowledges the final answer.
340
+ if (result?.complete && (await client.sessionStatus(conversationId, { signal })).type === "idle" &&
341
+ expectedInputMessageId === text(resolveInputMessageId())) {
342
+ if (completedInputMessageId === expectedInputMessageId) {
343
+ return { messages, result };
344
+ }
345
+ completedInputMessageId = expectedInputMessageId;
346
+ } else {
347
+ completedInputMessageId = null;
348
+ }
349
+ await delay(250, undefined, { signal });
350
+ }
351
+ }
352
+
353
+ export async function waitForOpenCodeFinalResponse(client, conversationId, turn, {
354
+ agent, model, recoveryMessageId, beforeRecovery = null, ...options
355
+ } = {}) {
356
+ const waitForCompletion = () => waitForOpenCodeMessages(
357
+ client, conversationId, () => turn.inputMessageId, options
358
+ );
359
+ let completion = await waitForCompletion();
360
+ // Work around https://github.com/anomalyco/opencode/issues/37073. Some
361
+ // reasoning models finish successfully without emitting a text part.
362
+ if (
363
+ !turn.interruptRequested &&
364
+ !completion.result?.error &&
365
+ !text(completion.result?.text)
366
+ ) {
367
+ await beforeRecovery?.(completion);
368
+ const admitted = await client.prompt(conversationId, {
369
+ agent,
370
+ delivery: "queue",
371
+ id: recoveryMessageId,
372
+ model,
373
+ prompt: {
374
+ text: "Your previous response ended without a user-facing final answer. Do not call tools or repeat your reasoning. Return the concise final answer to the user's latest request now."
375
+ },
376
+ resume: true
377
+ }, { signal: options.signal });
378
+ turn.inputMessageId = text(admitted?.id) || recoveryMessageId;
379
+ turn.updatedAt = new Date().toISOString();
380
+ completion = await waitForCompletion();
381
+ }
382
+ return completion;
383
+ }
384
+
385
+ // Production detached-turn lifetime. Applications project the completed native
386
+ // result before observation is closed, then publish/checkpoint their own state.
387
+ export async function observeOpenCodeTurnCompletion(runtime, target, conversationId, turn, {
388
+ events, signal, timeoutMs, finalResponse, projectResult = value => value, afterClose, ...waitOptions
389
+ } = {}) {
390
+ const turnAbort = turn.abortController;
391
+ let stopped = true;
392
+ let outcome = "completed";
393
+ try {
394
+ const options = { ...waitOptions, signal: AbortSignal.any([
395
+ signal, ...(timeoutMs ? [AbortSignal.timeout(timeoutMs)] : [])
396
+ ]) };
397
+ const completion = finalResponse
398
+ ? await waitForOpenCodeFinalResponse(target.server.client, conversationId, turn, { ...finalResponse, ...options })
399
+ : await waitForOpenCodeMessages(target.server.client, conversationId, () => turn.inputMessageId, options);
400
+ return await projectResult(completion);
401
+ } catch (error) {
402
+ const failure = signal.aborted ? signal.reason : error;
403
+ outcome = turn.interrupted ? "interrupted" : "failed";
404
+ if (turn.interrupted) return { interrupted: true };
405
+ if (!target.abortController.signal.aborted) {
406
+ try {
407
+ await runtime.stopUnobservedSession(target, conversationId);
408
+ } catch (stopError) {
409
+ stopped = false;
410
+ throw Object.assign(new Error(`${failure.message} ${stopError.message}`, { cause: failure }), { cleanupFailed: true });
411
+ }
412
+ }
413
+ throw failure;
414
+ } finally {
415
+ await events.close();
416
+ if (turn.abortController === turnAbort) {
417
+ turn.active = !stopped;
418
+ turn.mayBeRunning = !stopped;
419
+ }
420
+ await afterClose?.({ stopped, outcome });
421
+ }
422
+ }
423
+
424
+ // Production native receipt/wait sequence used for an idempotent bounded turn.
425
+ // The host supplies its approved input and keeps its renewal presentation.
426
+ export async function runOpenCodeConversationTurn(client, conversationId, input = {}, {
427
+ expectedThreadId = "", forbiddenThreadId = "", requireFreshHistory = false,
428
+ completeResult, close
429
+ } = {}) {
430
+ const { inputMessageId, readError,
431
+ createError = (reason, message, details) => Object.assign(new Error(message ||
432
+ (reason === "unreadable" ? "The exact OpenCode turn did not produce a readable result." : "OpenCode did not finish the turn.")),
433
+ { code: `assistant_opencode_turn_${reason}`, details,
434
+ statusCode: ["thread_mismatch", "fresh_thread_required", "unrelated_history"].includes(reason) ? 409 : 502 })
435
+ } = input;
436
+ if (
437
+ (expectedThreadId && expectedThreadId !== conversationId) ||
438
+ (requireFreshHistory && forbiddenThreadId && forbiddenThreadId === conversationId)
439
+ ) {
440
+ throw createError(
441
+ requireFreshHistory ? "fresh_thread_required" : "thread_mismatch",
442
+ requireFreshHistory
443
+ ? "The renewed OpenCode conversation does not own the expected fresh native history."
444
+ : "The OpenCode predecessor history changed before handover generation.",
445
+ { actualThreadId: conversationId, expectedThreadId, ...(requireFreshHistory ? { forbiddenThreadId } : {}) }
446
+ );
447
+ }
448
+ let existingMessages;
449
+ if (requireFreshHistory) {
450
+ existingMessages = openCodeMessageRows(await client.messages(
451
+ conversationId,
452
+ { limit: 100, order: "desc" }
453
+ ));
454
+ const unrelatedUserMessage = existingMessages.find((message) => (
455
+ message?.type === "user" && text(message.id) !== inputMessageId
456
+ ));
457
+ if (unrelatedUserMessage) {
458
+ throw createError(
459
+ "unrelated_history",
460
+ "The successor OpenCode history contains unrelated conversation and cannot be used for renewal.",
461
+ { threadId: conversationId }
462
+ );
463
+ }
464
+ }
465
+ // Hosts may build the approved prompt lazily, after the original identity and
466
+ // freshness checks. The bounded turn keeps its separate receipt-history read.
467
+ const { prompt, agent, model, timeoutMs = 180_000 } = input;
468
+ let messages = await client.messages(conversationId, {
469
+ limit: 100,
470
+ order: "desc"
471
+ });
472
+ let result = openCodeMessageResultForInput(messages, inputMessageId, { readError });
473
+ let admitted = null;
474
+ if (!result) {
475
+ admitted = await client.prompt(conversationId, {
476
+ agent,
477
+ delivery: "queue",
478
+ id: inputMessageId,
479
+ model,
480
+ prompt: { text: String(prompt || "") },
481
+ resume: true
482
+ });
483
+ }
484
+ if (!result?.complete) {
485
+ try {
486
+ const completion = await waitForOpenCodeMessages(
487
+ client,
488
+ conversationId,
489
+ inputMessageId,
490
+ {
491
+ readError, signal: AbortSignal.timeout(timeoutMs)
492
+ }
493
+ );
494
+ messages = completion.messages;
495
+ result = completion.result;
496
+ } catch (error) {
497
+ let inputAccepted = Boolean(result);
498
+ if (!inputAccepted && admitted) {
499
+ try {
500
+ inputAccepted = Boolean(openCodeMessageResultForInput(
501
+ await client.messages(conversationId, {
502
+ limit: 100,
503
+ order: "desc"
504
+ }),
505
+ inputMessageId, { readError }
506
+ ));
507
+ } catch {
508
+ // The exact provider history remains the admission proof. If it
509
+ // cannot be read, renewal must leave the predecessor available.
510
+ }
511
+ }
512
+ throw createError(
513
+ "failed",
514
+ text(error?.message),
515
+ {
516
+ inputAccepted,
517
+ threadId: conversationId
518
+ }
519
+ );
520
+ }
521
+ }
522
+ if (!result?.complete || (!result.text && !result.error)) {
523
+ throw createError(
524
+ "unreadable",
525
+ "",
526
+ {
527
+ inputAccepted: true,
528
+ threadId: conversationId
529
+ }
530
+ );
531
+ }
532
+ if (result.error) {
533
+ throw createError(
534
+ "failed",
535
+ result.error,
536
+ {
537
+ inputAccepted: true,
538
+ threadId: conversationId,
539
+ turnId: result.turnId
540
+ }
541
+ );
542
+ }
543
+ const completion = {
544
+ admitted,
545
+ reconciled: !admitted,
546
+ text: result.text,
547
+ threadId: conversationId,
548
+ turnId: result.turnId || text(admitted?.id)
549
+ };
550
+ if (completeResult) await completeResult(completion);
551
+ const proof = close ? await close() : undefined;
552
+ return {
553
+ ...completion,
554
+ ...(requireFreshHistory ? { freshThread: existingMessages.length === 0 } : {}),
555
+ ...(close ? { processExitProof: proof } : {})
556
+ };
557
+ }
@@ -0,0 +1,30 @@
1
+ import { createApiConversationDriver } from "./providers/api.js";
2
+ import { createClaudeConversationDriver } from "./providers/claudeDriver.js";
3
+ import { createCodexConversationDriver } from "./providers/codexDriver.js";
4
+ import { createOpenCodeConversationDriver } from "./providers/opencodeDriver.js";
5
+ import { validateCommandWrapper } from "./commandWrapper.js";
6
+
7
+ // Internal construction/opening boundary shared by the runtime coordinators.
8
+ // The caller supplies its existing conversation store and native host facets.
9
+ export function createConversationProviderFactory({ connections, apiClientFactory, fetch, limits, actions, toolCatalog } = {}) {
10
+ function createDriver(engine, host) {
11
+ if (host?.nativeTools !== undefined && typeof host.nativeTools !== "boolean") throw new TypeError("host.nativeTools must be an explicit server-side boolean grant.");
12
+ if (host?.commandWrapper !== undefined) {
13
+ validateCommandWrapper(host.commandWrapper);
14
+ if (host.nativeTools !== true || engine === "api") throw new TypeError("A command wrapper requires native coding tools.");
15
+ }
16
+ const driver = engine === "api" ? createApiConversationDriver({ connections, apiClientFactory, fetch, limits })
17
+ : engine === "claude" ? createClaudeConversationDriver({ connections, host, limits })
18
+ : engine === "codex" ? createCodexConversationDriver({ connections, fetch, host, limits })
19
+ : engine === "opencode" ? createOpenCodeConversationDriver({ connections, host, limits }) : null;
20
+ if (!driver) throw new TypeError(`The common conversation runtime does not yet support ${engine}.`);
21
+ if ((actions || toolCatalog) && !driver.capabilities.tools) throw new TypeError(`Application tools are not yet supported by the ${engine} conversation adapter.`);
22
+ return driver;
23
+ }
24
+ return Object.freeze({
25
+ createDriver,
26
+ open(driver, { binding, conversation, onFailure, writeBinding }) {
27
+ return driver.open({ binding, conversation, onFailure, writeBinding });
28
+ }
29
+ });
30
+ }