@jskit-ai/assistant-core 0.1.212 → 0.1.214

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 (60) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +7 -1
  3. package/package.json +5 -5
  4. package/src/client/conversation/AssistantConversationElement.vue +5 -1
  5. package/src/client/conversation/AssistantTranscript.vue +10 -1
  6. package/src/server/conversation/agentRun.js +38 -11
  7. package/src/server/conversation/claudeHistory.js +40 -5
  8. package/src/server/conversation/claudeProcess.js +28 -4
  9. package/src/server/conversation/claudeTools.js +11 -2
  10. package/src/server/conversation/claudeTurn.js +213 -23
  11. package/src/server/conversation/codexConfiguration.js +1 -1
  12. package/src/server/conversation/codexHistoryAdapter.js +11 -4
  13. package/src/server/conversation/codexNotificationQueue.js +11 -2
  14. package/src/server/conversation/codexObservationControl.js +8 -8
  15. package/src/server/conversation/codexOutput.js +20 -15
  16. package/src/server/conversation/codexProvider.js +40 -3
  17. package/src/server/conversation/codexProviderOwner.js +110 -1
  18. package/src/server/conversation/codexTurn.js +3 -0
  19. package/src/server/conversation/continuity.js +24 -6
  20. package/src/server/conversation/index.js +1 -1
  21. package/src/server/conversation/nativeHistoryExport.js +97 -0
  22. package/src/server/conversation/openCodeClient.js +47 -1
  23. package/src/server/conversation/openCodeRuntime.js +183 -28
  24. package/src/server/conversation/openCodeTurn.js +65 -24
  25. package/src/server/conversation/providers/api.js +7 -0
  26. package/src/server/conversation/providers/claudeDriver.js +52 -7
  27. package/src/server/conversation/providers/codex.js +19 -2
  28. package/src/server/conversation/providers/codexDriver.js +92 -14
  29. package/src/server/conversation/providers/opencodeDriver.js +31 -9
  30. package/src/server/conversation/runtime.js +424 -65
  31. package/src/server/conversation/runtimeStateUpgrade.js +77 -2
  32. package/src/server/conversation/streams.js +25 -5
  33. package/src/server/conversation/tools.js +2 -2
  34. package/src/server/conversation/transcript.js +23 -8
  35. package/src/server/hosts/claudeHistory.js +1 -0
  36. package/src/server/hosts/claudeProcess.js +1 -0
  37. package/src/server/hosts/codexProvider.js +3 -0
  38. package/src/server/hosts/nativeHistory.js +1 -0
  39. package/src/server/hosts/openCodeClient.js +1 -0
  40. package/src/server/hosts/openCodeTurn.js +1 -0
  41. package/src/server/index.js +1 -1
  42. package/src/server/lib/assistantToolLoop.js +99 -2
  43. package/test/aiConnectionClient.test.js +57 -0
  44. package/test/assistantBoundedToolLoop.test.js +332 -0
  45. package/test/assistantScroll.browser.test.js +151 -0
  46. package/test/claudeConversationSession.test.js +1417 -3
  47. package/test/claudeProcess.test.js +175 -0
  48. package/test/claudeTurn.test.js +466 -0
  49. package/test/codexConversationSession.test.js +1257 -12
  50. package/test/codexHistoryAdapter.test.js +74 -1
  51. package/test/codexTurn.test.js +282 -1
  52. package/test/conversation.test.js +83 -0
  53. package/test/conversationAgentRun.test.js +181 -0
  54. package/test/conversationRuntime.test.js +95 -0
  55. package/test/conversationRuntimeStateUpgrade.test.js +89 -0
  56. package/test/conversationStreaming.test.js +85 -0
  57. package/test/conversationTools.test.js +165 -1
  58. package/test/openCodeClient.test.js +186 -0
  59. package/test/openCodeRuntime.test.js +372 -0
  60. package/test/opencodeConversationSession.test.js +637 -5
@@ -181,6 +181,13 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
181
181
  let sharedProcessStop = null;
182
182
  let failedStartup = null;
183
183
  let closed = false;
184
+ let connectionChanging = false;
185
+
186
+ function requireConnectionAvailable() {
187
+ if (connectionChanging) throw Object.assign(new Error("OpenCode is changing a connection. Retry after the connection change finishes."), {
188
+ code: "assistant_opencode_connection_changing", statusCode: 409
189
+ });
190
+ }
184
191
  const bindingScopes = new Map();
185
192
  let registryPath = "";
186
193
  let bindingWrite = Promise.resolve();
@@ -230,7 +237,9 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
230
237
  }
231
238
 
232
239
  async function ensure(selected, start) {
240
+ requireConnectionAvailable();
233
241
  await sharedProcessStop;
242
+ requireConnectionAvailable();
234
243
  if (failedStartup) await stop("opencode-startup-cleanup");
235
244
  if (sharedProcess) {
236
245
  const currentConnection = sharedProcess.connections.get(selected?.modelProviderId);
@@ -243,6 +252,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
243
252
  await sharedProcess.server.client.health({ signal: AbortSignal.timeout(1_000) });
244
253
  return sharedProcess;
245
254
  } catch {
255
+ requireConnectionAvailable();
246
256
  await stop("opencode-health-check-failed");
247
257
  }
248
258
  } else {
@@ -320,6 +330,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
320
330
  }
321
331
 
322
332
  async function acquire(key, prepare, onFailure = async () => {}) {
333
+ requireConnectionAvailable();
323
334
  const pending = processStarts.get(key);
324
335
  if (pending) return pending;
325
336
  const start = Promise.resolve().then(async () => {
@@ -442,6 +453,33 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
442
453
  closed = true;
443
454
  }
444
455
  const application = preparation.release;
456
+ if (typeof input.publish === "function") {
457
+ requireConnectionAvailable();
458
+ connectionChanging = true;
459
+ try {
460
+ await Promise.all([...processStarts.values()].map(start => start.catch(() => null)));
461
+ await sharedProcessStart?.catch(() => null);
462
+ await application.assertConnectionChange?.();
463
+ const targets = [...processes.values()];
464
+ const providerId = text(input.modelProviderId);
465
+ if (!providerId || targets.some(target => text(target.modelProviderId) !== providerId || !application.ownsTarget(target))) {
466
+ throw Object.assign(new Error("Close other OpenCode chats and Colleague conversations, then retry this connection change. The saved connection has not changed."), {
467
+ code: "assistant_opencode_connection_in_use", statusCode: 409
468
+ });
469
+ }
470
+ const results = [];
471
+ for (const target of targets) {
472
+ const proof = await releaseProcessTarget(target, { retainSharedProcess: true }, application);
473
+ if (proof?.exited === false || proof?.ok === false) throw new Error("OpenCode could not stop the selected conversation. Retry the connection change.");
474
+ results.push(proof);
475
+ }
476
+ if (sharedProcess || failedStartup) results.push(await stop("opencode-connection-changed"));
477
+ await input.publish();
478
+ return { closed: results.length, ok: true };
479
+ } finally {
480
+ connectionChanging = false;
481
+ }
482
+ }
445
483
  await Promise.all([...processStarts.entries()]
446
484
  .filter(([key]) => key.includes("\0"))
447
485
  .map(([, start]) => start.catch(() => null)));
@@ -908,13 +946,17 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
908
946
 
909
947
  async function writeConversationProjection(sessionId, messages = null, {
910
948
  inputMessageId = "",
911
- streaming = false
949
+ streaming = false,
950
+ captureFinalAssistantResult = false
912
951
  } = {}, projection) {
913
952
  let failure = "";
953
+ let finalAssistantResult = null;
914
954
  let providerApiFailure = false;
915
955
  const rows = (inputMessageId
916
956
  ? openCodeRowsForInput(messages, inputMessageId)
917
957
  : openCodeMessageRows(messages)).filter((message) => message?.type === "assistant");
958
+ const finalNativeMessage = captureFinalAssistantResult && !streaming
959
+ ? openCodeLastAssistantResult(rows, { readError: projection.readError }).message : null;
918
960
  for (const [index, message] of rows.entries()) {
919
961
  failure ||= projection.readError(message);
920
962
  providerApiFailure ||= openCodeProviderApiFailure(message.error);
@@ -967,9 +1009,31 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
967
1009
  });
968
1010
  projection.store.completeConversationStreamMessage(sessionId, messageId);
969
1011
  await projection.publishTurn(turn);
1012
+ if (message === finalNativeMessage && !projection.readError(message) &&
1013
+ (message.time?.completed || message.finish) && turn) {
1014
+ finalAssistantResult = { inputMessageId: text(inputMessageId), itemId: text(message.id),
1015
+ text: assistantText, conversationTurn: turn };
1016
+ }
970
1017
  }
971
1018
  }
972
- return { failure, providerApiFailure };
1019
+ return { failure, providerApiFailure, ...(captureFinalAssistantResult ? { finalAssistantResult } : {}) };
1020
+ }
1021
+
1022
+ // Evidence belongs to the exact retained native turn. This read never creates,
1023
+ // observes, resumes or projects a native conversation.
1024
+ function readFinalAssistantResult(key, threadId = "", turnId = "") {
1025
+ const turn = turns.get(key);
1026
+ const result = turn?.finalAssistantResult;
1027
+ const target = turn?.finalAssistantResultTarget;
1028
+ if (!target || processes.get(key) !== target || target.abortController.signal.aborted ||
1029
+ !turn.finalAssistantResultProcess || sharedProcess !== turn.finalAssistantResultProcess ||
1030
+ target.upstreamSessionId !== turn.threadId) return null;
1031
+ if (!text(threadId) || !text(turnId) || !result || turn.active || turn.state !== "completed" ||
1032
+ turn.error || turn.observationError || turn.interruptRequested || turn.interruptAcknowledged ||
1033
+ turn.threadId !== text(threadId) || turn.id !== text(turnId) ||
1034
+ result.threadId !== text(threadId) || result.turnId !== text(turnId) ||
1035
+ result.inputMessageId !== text(turn.inputMessageId) || !result.conversationTurn) return null;
1036
+ return structuredClone(result);
973
1037
  }
974
1038
 
975
1039
  async function sendMessage(key, input, options, application) {
@@ -1037,6 +1101,8 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1037
1101
  let dispatchMonitor = null;
1038
1102
  let previousInputMessageId = null;
1039
1103
  let previousAdmission;
1104
+ let recoveryReceipt = null;
1105
+ let recoveryObservationError = null;
1040
1106
  try {
1041
1107
  const preparation = await application.prepare({ overwrite: !currentMonitor, threadId: currentThreadId });
1042
1108
  const preparedTarget = preparation.process ? await acquirePrepared(preparation.process) : preparation.target;
@@ -1053,6 +1119,24 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1053
1119
  await currentMonitor;
1054
1120
  currentMonitor = monitors.get(key);
1055
1121
  }
1122
+ const recoveryTurn = currentMonitor ? turns.get(key) : null;
1123
+ if (recoveryTurn?.recoveryAdmission) {
1124
+ try {
1125
+ recoveryReceipt = await recoveryTurn.recoveryAdmission.promise;
1126
+ currentMonitor = monitors.get(key);
1127
+ if (currentMonitor && !turns.get(key)?.active) {
1128
+ await currentMonitor;
1129
+ currentMonitor = monitors.get(key);
1130
+ }
1131
+ // Stop must not turn a waiting, undispatched input into a fresh turn.
1132
+ recoveryTurn.abortController.signal.throwIfAborted();
1133
+ target.abortController.signal.throwIfAborted();
1134
+ } catch (error) {
1135
+ // This is R's failure, not a rejected or attempted native B input.
1136
+ return { failure: { error, attempted: false, threadId: currentThreadId,
1137
+ turn: openCodeTurnSnapshot(turns.get(key), currentThreadId) } };
1138
+ }
1139
+ }
1056
1140
  admission = Promise.withResolvers();
1057
1141
  // The observer may be between polls when dispatch or persistence fails.
1058
1142
  // Keep the raw promise rejectable without an unhandled rejection.
@@ -1140,7 +1224,44 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1140
1224
  await dispatchMonitor.catch(() => null);
1141
1225
  throw error;
1142
1226
  }
1143
- admission.resolve();
1227
+ if (recoveryReceipt) {
1228
+ const receiptAbort = new AbortController();
1229
+ // The original monitor can fail and retire without aborting its turn.
1230
+ // Its settlement also ends this read; it never creates a successor turn.
1231
+ void dispatchMonitor.then(() => receiptAbort.abort(new Error(
1232
+ dispatchTurn.observationError || dispatchTurn.error || "OpenCode observation ended before input custody was confirmed."
1233
+ )), error => receiptAbort.abort(error));
1234
+ try {
1235
+ const observation = await inspectOpenCodeMessageAdmission(target.server.client, currentThreadId,
1236
+ text(admitted.id), { wait: true, signal: AbortSignal.any([
1237
+ target.abortController.signal, dispatchTurn.abortController.signal, receiptAbort.signal
1238
+ ]) });
1239
+ const message = openCodeMessageRows(observation.messages).find(message =>
1240
+ message.type === "user" && text(message.id) === text(admitted.id));
1241
+ const created = message?.time?.created;
1242
+ const recoveryCreated = recoveryReceipt.time?.created;
1243
+ // Match the native latest-user order, including its ID tie-breaker.
1244
+ if (!Number.isFinite(created) || !Number.isFinite(recoveryCreated) ||
1245
+ !(created > recoveryCreated || (created === recoveryCreated && message.id > recoveryReceipt.id))) {
1246
+ throw Object.assign(new Error(
1247
+ "OpenCode could not confirm that the new input follows its final-answer recovery. The admitted message was not resent."
1248
+ ), { code: "assistant_opencode_observation_lost", details: {}, statusCode: 503 });
1249
+ }
1250
+ } catch (error) {
1251
+ recoveryObservationError = error;
1252
+ } finally {
1253
+ receiptAbort.abort();
1254
+ }
1255
+ }
1256
+ if (recoveryObservationError) {
1257
+ // Native B was dispatched: keep its authored receipt even when observing
1258
+ // its place in native history failed, and use the original cleanup owner.
1259
+ admission.reject(recoveryObservationError);
1260
+ dispatchTurn.abortController.abort(recoveryObservationError);
1261
+ await dispatchMonitor.catch(() => null);
1262
+ } else {
1263
+ admission.resolve();
1264
+ }
1144
1265
  const turn = openCodeTurnSnapshot(turns.get(key), target.upstreamSessionId);
1145
1266
  return {
1146
1267
  conversationTurn,
@@ -1201,30 +1322,43 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1201
1322
  }
1202
1323
 
1203
1324
  function beginMessageMonitor(key, target, admitted, options, projection) {
1204
- target = { ...target };
1205
- const existing = monitors.get(key);
1206
- if (existing) {
1207
- return existing;
1208
- }
1209
- const metadata = projection.prepare(admitted, options);
1210
- const eventStartedAt = Number(admitted.eventStartedAt) || Date.now();
1211
- const startedAt = text(admitted.startedAt) || new Date(eventStartedAt).toISOString();
1212
- const turn = {
1213
- abortController: new AbortController(),
1214
- admission: options.admission,
1215
- active: true,
1216
- error: "",
1217
- eventStartedAt,
1218
- id: text(admitted.id),
1219
- inputMessageId: text(admitted.id),
1220
- startedAt,
1221
- state: options.admission ? "starting" : "active",
1222
- ...metadata.fields,
1223
- threadId: target.upstreamSessionId,
1224
- updatedAt: startedAt
1325
+ const start = () => {
1326
+ target = { ...target };
1327
+ const existing = monitors.get(key);
1328
+ if (existing) {
1329
+ return existing;
1330
+ }
1331
+ const metadata = projection.prepare(admitted, options);
1332
+ const eventStartedAt = Number(admitted.eventStartedAt) || Date.now();
1333
+ const startedAt = text(admitted.startedAt) || new Date(eventStartedAt).toISOString();
1334
+ const turn = {
1335
+ abortController: new AbortController(),
1336
+ admission: options.admission,
1337
+ active: true,
1338
+ error: "",
1339
+ eventStartedAt,
1340
+ id: text(admitted.id),
1341
+ inputMessageId: text(admitted.id),
1342
+ startedAt,
1343
+ state: options.admission ? "starting" : "active",
1344
+ ...metadata.fields,
1345
+ threadId: target.upstreamSessionId,
1346
+ updatedAt: startedAt
1347
+ };
1348
+ const monitor = beginMonitor(key, target, turn, projection.create(target, turn, metadata, options));
1349
+ void monitor.catch(error => projection.onError(error));
1350
+ return monitor;
1225
1351
  };
1226
- const monitor = beginMonitor(key, target, turn, projection.create(target, turn, metadata, options));
1227
- void monitor.catch(error => projection.onError(error));
1352
+ if (typeof projection.runInContext !== "function") return start();
1353
+ let monitor, startFailure;
1354
+ // Context entry invokes this callback synchronously. Do not return the
1355
+ // monitor to an async scope wrapper: retain its identity and error owner.
1356
+ const scope = projection.runInContext(() => {
1357
+ try { monitor = start(); }
1358
+ catch (error) { startFailure = { error }; }
1359
+ });
1360
+ void Promise.resolve(scope).catch(error => projection.onError(error));
1361
+ if (startFailure) throw startFailure.error;
1228
1362
  return monitor;
1229
1363
  }
1230
1364
 
@@ -1232,6 +1366,15 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1232
1366
  observe, observation, eventReady, readiness, finalResponse,
1233
1367
  writeRun, projectMessages, completeResult, onRetired
1234
1368
  }) {
1369
+ delete turn.finalAssistantResult;
1370
+ delete turn.finalAssistantResultTarget;
1371
+ delete turn.finalAssistantResultProcess;
1372
+ const registeredTarget = processes.get(key);
1373
+ const finalAssistantResultTarget = registeredTarget &&
1374
+ registeredTarget.abortController === target.abortController &&
1375
+ registeredTarget.server === target.server &&
1376
+ registeredTarget.upstreamSessionId === target.upstreamSessionId ? registeredTarget : null;
1377
+ const finalAssistantResultProcess = finalAssistantResultTarget ? sharedProcess : null;
1235
1378
  turns.set(key, turn);
1236
1379
  const signal = AbortSignal.any([target.abortController.signal, turn.abortController.signal]);
1237
1380
  const monitor = Promise.resolve().then(async () => {
@@ -1243,6 +1386,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1243
1386
  }
1244
1387
  let finalState = "completed";
1245
1388
  let failure = "";
1389
+ let finalAssistantResult = null;
1246
1390
  let credentialFailure = false;
1247
1391
  let providerApiFailure = false;
1248
1392
  try {
@@ -1285,7 +1429,8 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1285
1429
  continue;
1286
1430
  }
1287
1431
  const projection = await projectMessages(completion.messages, {
1288
- inputMessageId: completion.inputMessageId
1432
+ inputMessageId: completion.inputMessageId,
1433
+ captureFinalAssistantResult: true
1289
1434
  });
1290
1435
  await turn.admission?.promise;
1291
1436
  signal.throwIfAborted();
@@ -1303,6 +1448,13 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1303
1448
  } else if (turn.interruptRequested) {
1304
1449
  finalState = "interrupted";
1305
1450
  }
1451
+ const published = projection.finalAssistantResult;
1452
+ if (finalState === "completed" && !failure && published &&
1453
+ published.inputMessageId === completion.inputMessageId &&
1454
+ published.itemId === completion.result?.turnId &&
1455
+ text(published.text) === text(completion.result?.text)) {
1456
+ finalAssistantResult = { ...published, threadId: turn.threadId, turnId: turn.id };
1457
+ }
1306
1458
  break;
1307
1459
  }
1308
1460
  } catch (error) {
@@ -1341,6 +1493,9 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1341
1493
  }
1342
1494
  turn.error = failure;
1343
1495
  turn.state = finalState;
1496
+ turn.finalAssistantResult = finalState === "completed" && !failure ? finalAssistantResult : null;
1497
+ turn.finalAssistantResultTarget = turn.finalAssistantResult ? finalAssistantResultTarget : null;
1498
+ turn.finalAssistantResultProcess = turn.finalAssistantResult ? finalAssistantResultProcess : null;
1344
1499
  turn.updatedAt = new Date().toISOString();
1345
1500
  await writeRun(turn, finalState, failure).catch(() => null);
1346
1501
  }
@@ -1615,7 +1770,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1615
1770
  ensurePreparedSessionReadiness, inspectSessionReadiness, recoverSessionReadiness, needsObservationRecovery, recoverObservation,
1616
1771
  createConversation, selectConversation, existingConversation, runConversationTurn, readConversation, readPersistentConversation, waitForConversationTurn,
1617
1772
  stopConversation, deleteConversation, sendMessage, hasActiveTemporaryConversation, reconcileSessions, reconcilePreparedSession, recordStoppedObservation, beginMessageMonitor, beginMonitor, writeBindings,
1618
- writeConversationProjection,
1773
+ writeConversationProjection, readFinalAssistantResult,
1619
1774
  projectConversationMessages: projectOpenCodeConversationMessages,
1620
1775
  readSessionState, startPreparedTerminal, attachTerminal, closeTerminal, allowTerminalAttachments,
1621
1776
  turnSnapshot: openCodeTurnSnapshot, providerApiFailure: openCodeProviderApiFailure
@@ -135,18 +135,31 @@ export function openCodeMessageError(message = {}) {
135
135
  }
136
136
 
137
137
  export async function inspectOpenCodeMessageAdmission(client, conversationId, inputMessageId, {
138
- signal = AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS)
138
+ signal = AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS), wait = false, readFailure = () => null
139
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
- }
140
+ do {
141
+ if (wait) signal.throwIfAborted();
142
+ try {
143
+ // Do not create a missing native session or submit another prompt. Absence
144
+ // from bounded history is uncertainty, not proof of non-admission.
145
+ const readSignal = wait
146
+ ? AbortSignal.any([signal, AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS)])
147
+ : signal;
148
+ const messages = await client.messages(conversationId, { limit: 100, order: "desc" }, { signal: readSignal });
149
+ if (wait) signal.throwIfAborted();
150
+ const accepted = openCodeMessageRows(messages).some((message) =>
151
+ message.type === "user" && text(message.id) === inputMessageId);
152
+ if (accepted || !wait) return { accepted, messages };
153
+ } catch {
154
+ if (!wait) return null;
155
+ signal.throwIfAborted();
156
+ }
157
+ const failure = readFailure();
158
+ if (failure && (await client.sessionStatus(conversationId, { signal: AbortSignal.any([
159
+ signal, AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS)
160
+ ]) })).type === "idle") throw failure;
161
+ await delay(250, undefined, { signal });
162
+ } while (wait);
150
163
  }
151
164
 
152
165
  export async function steerOpenCodeTurn(client, conversationId, turn, input, { signal } = {}) {
@@ -381,19 +394,47 @@ export async function waitForOpenCodeFinalResponse(client, conversationId, turn,
381
394
  if (admission !== turn.admission || completion.inputMessageId !== text(turn.inputMessageId)) {
382
395
  continue;
383
396
  }
384
- const admitted = await client.prompt(conversationId, {
385
- agent,
386
- delivery: "queue",
387
- id: recoveryMessageId,
388
- model,
389
- prompt: {
390
- 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."
391
- },
392
- resume: true
393
- }, { signal: options.signal });
394
- if (admission === turn.admission && completion.inputMessageId === text(turn.inputMessageId)) {
395
- turn.inputMessageId = text(admitted?.id) || recoveryMessageId;
396
- turn.updatedAt = new Date().toISOString();
397
+ // The async native prompt ACK does not acknowledge user-row creation.
398
+ // A later authored input joins this exact receipt before its own dispatch.
399
+ const recoveryAdmission = Promise.withResolvers();
400
+ void recoveryAdmission.promise.catch(() => {});
401
+ turn.recoveryAdmission = recoveryAdmission;
402
+ const receiptAbort = new AbortController();
403
+ const receiptSignal = AbortSignal.any([...(options.signal ? [options.signal] : []), receiptAbort.signal]);
404
+ const receipt = inspectOpenCodeMessageAdmission(client, conversationId, recoveryMessageId, {
405
+ signal: receiptSignal, wait: true, readFailure: options.readFailure
406
+ }).then(observation => {
407
+ const message = openCodeMessageRows(observation.messages).find(message =>
408
+ message.type === "user" && text(message.id) === recoveryMessageId);
409
+ recoveryAdmission.resolve(message);
410
+ return message;
411
+ }, error => {
412
+ recoveryAdmission.reject(error);
413
+ throw error;
414
+ });
415
+ void receipt.catch(() => {});
416
+ try {
417
+ const admitted = await client.prompt(conversationId, {
418
+ agent,
419
+ delivery: "queue",
420
+ id: recoveryMessageId,
421
+ model,
422
+ prompt: {
423
+ 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."
424
+ },
425
+ resume: true
426
+ }, { signal: options.signal });
427
+ await receipt;
428
+ options.signal?.throwIfAborted();
429
+ if (admission === turn.admission && completion.inputMessageId === text(turn.inputMessageId)) {
430
+ turn.inputMessageId = text(admitted?.id) || recoveryMessageId;
431
+ turn.updatedAt = new Date().toISOString();
432
+ }
433
+ } catch (error) {
434
+ recoveryAdmission.reject(error);
435
+ throw error;
436
+ } finally {
437
+ receiptAbort.abort();
397
438
  }
398
439
  return waitForCompletion();
399
440
  }
@@ -16,10 +16,14 @@ export function createApiConversationDriver({ connections, apiClientFactory, fet
16
16
  if (!apiClientFactory && typeof connections?.resolve !== "function") throw new TypeError("API conversations require an authorized AI connection resolver.");
17
17
  const maximumOutput = limits.maxOutputCharacters ?? 64_000;
18
18
  const maximumHistory = limits.maxHistoryCharacters ?? 256_000;
19
+ const maximumToolProgress = limits.maxApiToolProgressCharacters;
19
20
  const preserveWhitespace = apiClientFactory?.preserveWhitespace !== false;
20
21
  for (const limit of [maximumOutput, maximumHistory]) {
21
22
  if (!Number.isSafeInteger(limit) || limit < 1) throw new TypeError("Invalid API conversation limit.");
22
23
  }
24
+ if (maximumToolProgress !== undefined && (!Number.isSafeInteger(maximumToolProgress) || maximumToolProgress < 1)) {
25
+ throw new TypeError("Invalid API application tool progress limit.");
26
+ }
23
27
  const driver = Object.freeze({
24
28
  admissionBeforeDispatch: true,
25
29
  attachmentTypes: ["text", "image", "file"],
@@ -102,6 +106,9 @@ export function createApiConversationDriver({ connections, apiClientFactory, fet
102
106
  } else if (finishReason !== "stop") {
103
107
  throw new Error("The model stream ended without confirming a completed answer.");
104
108
  }
109
+ if (calls.length && maximumToolProgress !== undefined && text.length > maximumToolProgress) {
110
+ throw new Error("Application tool progress text exceeds the configured API limit.");
111
+ }
105
112
  if (text.trim() && (calls.length || isAssistantProgressOnlyText(text))) {
106
113
  responseIsCommentary = true;
107
114
  await onMessage({ id, outputId: id, role: "commentary", text, complete: true });
@@ -2,10 +2,11 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { realpath } from "node:fs/promises";
5
- import { bindClaudeConversationAccount, claudeFlagSettings, claudeModelConfiguration, readClaudeCodeAuthStatus } from "../claudeProcess.js";
5
+ import { bindClaudeConversationAccount, claudeConnectionIdentity, claudeFlagSettings, claudeModelConfiguration, readClaudeCodeAuthStatus } from "../claudeProcess.js";
6
6
  import { nativeAiModel, nativeAiProvider } from "../../../shared/nativeProviders.js";
7
7
  import { requireClaudeSessionId, listClaudeConversationStorage } from "../claudeHistory.js";
8
8
  import { createClaudeConversationOwner, claudeNativeMessageId } from "../claudeTurn.js";
9
+ import { CLAUDE_APPLICATION_TOOL_SERVER } from "../claudeTools.js";
9
10
  import { createLocalConversationExecution } from "../localExecution.js";
10
11
  import { validateConversationConfiguration, validateConnectionModel } from "../configuration.js";
11
12
 
@@ -145,6 +146,20 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
145
146
  }
146
147
  const current = entry.command;
147
148
  if (!current) return;
149
+ if (event.type === "provider-event" && typeof current.onNativeToolUse === "function" && event.event.type === "assistant") {
150
+ const prefix = `mcp__${CLAUDE_APPLICATION_TOOL_SERVER}__`;
151
+ for (const block of event.event.message?.content || []) {
152
+ if (block.type !== "tool_use" || block.name === "StructuredOutput") continue;
153
+ if (typeof block.name !== "string" || block.name.length > 256 + prefix.length) continue;
154
+ if (entry.command !== current || current.entry !== entry || !current.admitted || current.signal.aborted) {
155
+ throw new Error("The native Claude tool observation has no current admitted command.");
156
+ }
157
+ const name = block.name.startsWith(prefix) ? block.name.slice(prefix.length) : block.name;
158
+ if (!name || name.length > 256) continue;
159
+ await current.onNativeToolUse({ messageId: current.messageId, threadId: entry.id, name });
160
+ }
161
+ return;
162
+ }
148
163
  if (event.type === "message") await current.onMessage({ ...event.message, transient: !event.message.complete });
149
164
  else if (event.type === "message-complete") await current.onEvent(event);
150
165
  else if ((event.type === "admitted" && current.goal) || event.type === "settled") {
@@ -155,7 +170,8 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
155
170
  return standalone;
156
171
  }
157
172
 
158
- async function bindAccount(entry, configuration, context, signal = AbortSignal.timeout(30_000)) {
173
+ async function bindAccount(entry, configuration, context, signal = AbortSignal.timeout(30_000), requireBound = false, receiptBinding) {
174
+ const accountBinding = requireBound ? receiptBinding : entry.context.binding;
159
175
  let selected;
160
176
  let providerId;
161
177
  let identity;
@@ -163,6 +179,9 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
163
179
  const connection = await connections.resolve({ context, integrationId: configuration.integrationId });
164
180
  signal.throwIfAborted();
165
181
  providerId = connection.providerId;
182
+ if (requireBound && !accountBinding?.connectionIdentities?.[providerId]) {
183
+ throw new Error("The completed Claude response has no saved provider account fingerprint.");
184
+ }
166
185
  const provider = nativeAiProvider(providerId);
167
186
  const model = nativeAiModel(connection.model, providerId);
168
187
  if (providerId !== "anthropic" && (!provider || !model)) throw new Error("This exact provider/model is not supported by the Claude integration. Choose a supported model without renaming it.");
@@ -171,8 +190,11 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
171
190
  selected = claudeModelConfiguration({ providerId, model: connection.model }, {
172
191
  apiKey: connection.apiKey, baseUrl: provider?.claudeBaseUrl || connection.baseURL || "https://api.anthropic.com"
173
192
  });
174
- identity = hash([configRoot, providerId, selected.env.ANTHROPIC_BASE_URL, connection.apiKey]);
193
+ identity = claudeConnectionIdentity(configRoot, providerId, selected.env.ANTHROPIC_BASE_URL, connection.apiKey);
175
194
  } else {
195
+ if (requireBound && !accountBinding?.accountIdentity) {
196
+ throw new Error("The completed Claude response has no saved native account fingerprint.");
197
+ }
176
198
  identity = await accountIdentity(signal);
177
199
  selected = claudeModelConfiguration({ providerId: "anthropic", model: configuration.model || "default" });
178
200
  // Returning to the native host clears connection overrides, while
@@ -180,7 +202,14 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
180
202
  for (const name of Object.keys(selected.env)) if (Object.hasOwn(env, name)) selected.env[name] = env[name];
181
203
  }
182
204
  signal.throwIfAborted();
183
- const previous = providerId ? entry.context.binding.connectionIdentities?.[providerId] : entry.context.binding.accountIdentity;
205
+ const previous = providerId ? accountBinding.connectionIdentities?.[providerId] : accountBinding.accountIdentity;
206
+ const cached = providerId ? entry.context.binding.connectionIdentities?.[providerId] : entry.context.binding.accountIdentity;
207
+ if (requireBound && previous !== cached) {
208
+ throw new Error("The completed Claude response no longer matches its saved account fingerprint.");
209
+ }
210
+ if (requireBound && identity !== previous) {
211
+ throw new Error("The completed Claude response belongs to a different account or provider connection.");
212
+ }
184
213
  await bindClaudeConversationAccount({ accountIdentity: previous,
185
214
  accountIdentities: { [providerId || "anthropic"]: previous }
186
215
  }, { identity, providerId: providerId || "anthropic",
@@ -294,11 +323,17 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
294
323
  let entry;
295
324
  if (!supplied) {
296
325
  requireClaudeSessionId(binding?.conversationId);
297
- if (binding.workdir !== await realpath(workdir) || binding.configRoot !== configRoot) {
326
+ const scopeWorkdir = await realpath(workdir);
327
+ // Original scoped conversations keep native history in the credential
328
+ // home. Both paths must still come from the current authorized host.
329
+ const nativeWorkdir = Object.hasOwn(binding, "scopeWorkdir")
330
+ ? await realpath(env.HOME || homedir()) : scopeWorkdir;
331
+ if (binding.workdir !== nativeWorkdir || binding.configRoot !== configRoot ||
332
+ (Object.hasOwn(binding, "scopeWorkdir") && binding.scopeWorkdir !== scopeWorkdir)) {
298
333
  throw new Error("This Claude conversation belongs to another working directory or credential home. Restore its original host configuration.");
299
334
  }
300
335
  owner = standaloneOwner();
301
- const context = { key: binding.workdir, workdir: binding.workdir, binding, reportFailure,
336
+ const context = { key: scopeWorkdir, workdir: binding.workdir, binding, reportFailure,
302
337
  async updateBinding(patch) {
303
338
  const next = { ...context.binding, ...patch };
304
339
  await writeBinding(next);
@@ -385,13 +420,23 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
385
420
  ...(input.action !== "set" ? { createdAt: goal?.createdAt, objective: goal?.objective } : {}) }, { interrupt, send });
386
421
  return claudeGoalValue(await entry.goals.read(), entry.id);
387
422
  },
388
- async inspectAdmission({ context, representation, ...input }) {
423
+ async inspectAdmission({ context, representation, verifyAccount = false, ...input }) {
389
424
  if (scoped) return unsupported();
390
425
  if (supplied) {
391
426
  await acquire(context);
392
427
  const result = await owner.inspectAdmission(await acquire(context), input);
393
428
  return representation === "native" ? result : { accepted: result.admission === "accepted" };
394
429
  }
430
+ if (verifyAccount === true) {
431
+ const binding = input.receiptBinding;
432
+ if (input.threadId !== entry.id || binding?.conversationId !== entry.id ||
433
+ binding.workdir !== entry.context.binding.workdir || binding.configRoot !== configRoot ||
434
+ Object.hasOwn(binding, "scopeWorkdir") !== Object.hasOwn(entry.context.binding, "scopeWorkdir") ||
435
+ binding.scopeWorkdir !== entry.context.binding.scopeWorkdir) {
436
+ throw new Error("The completed Claude response belongs to a different native conversation or host scope.");
437
+ }
438
+ await bindAccount(entry, input.configuration, context, AbortSignal.timeout(30_000), true, binding);
439
+ }
395
440
  const history = await owner.readHistory(entry);
396
441
  const nativeId = claudeNativeMessageId(input.messageId);
397
442
  return { accepted: history.userIds.includes(nativeId),