@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.
- package/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/ConversationFixture.vue +7 -1
- package/package.json +5 -5
- package/src/client/conversation/AssistantConversationElement.vue +5 -1
- package/src/client/conversation/AssistantTranscript.vue +10 -1
- package/src/server/conversation/agentRun.js +38 -11
- package/src/server/conversation/claudeHistory.js +40 -5
- package/src/server/conversation/claudeProcess.js +28 -4
- package/src/server/conversation/claudeTools.js +11 -2
- package/src/server/conversation/claudeTurn.js +213 -23
- package/src/server/conversation/codexConfiguration.js +1 -1
- package/src/server/conversation/codexHistoryAdapter.js +11 -4
- package/src/server/conversation/codexNotificationQueue.js +11 -2
- package/src/server/conversation/codexObservationControl.js +8 -8
- package/src/server/conversation/codexOutput.js +20 -15
- package/src/server/conversation/codexProvider.js +40 -3
- package/src/server/conversation/codexProviderOwner.js +110 -1
- package/src/server/conversation/codexTurn.js +3 -0
- package/src/server/conversation/continuity.js +24 -6
- package/src/server/conversation/index.js +1 -1
- package/src/server/conversation/nativeHistoryExport.js +97 -0
- package/src/server/conversation/openCodeClient.js +47 -1
- package/src/server/conversation/openCodeRuntime.js +183 -28
- package/src/server/conversation/openCodeTurn.js +65 -24
- package/src/server/conversation/providers/api.js +7 -0
- package/src/server/conversation/providers/claudeDriver.js +52 -7
- package/src/server/conversation/providers/codex.js +19 -2
- package/src/server/conversation/providers/codexDriver.js +92 -14
- package/src/server/conversation/providers/opencodeDriver.js +31 -9
- package/src/server/conversation/runtime.js +424 -65
- package/src/server/conversation/runtimeStateUpgrade.js +77 -2
- package/src/server/conversation/streams.js +25 -5
- package/src/server/conversation/tools.js +2 -2
- package/src/server/conversation/transcript.js +23 -8
- package/src/server/hosts/claudeHistory.js +1 -0
- package/src/server/hosts/claudeProcess.js +1 -0
- package/src/server/hosts/codexProvider.js +3 -0
- package/src/server/hosts/nativeHistory.js +1 -0
- package/src/server/hosts/openCodeClient.js +1 -0
- package/src/server/hosts/openCodeTurn.js +1 -0
- package/src/server/index.js +1 -1
- package/src/server/lib/assistantToolLoop.js +99 -2
- package/test/aiConnectionClient.test.js +57 -0
- package/test/assistantBoundedToolLoop.test.js +332 -0
- package/test/assistantScroll.browser.test.js +151 -0
- package/test/claudeConversationSession.test.js +1417 -3
- package/test/claudeProcess.test.js +175 -0
- package/test/claudeTurn.test.js +466 -0
- package/test/codexConversationSession.test.js +1257 -12
- package/test/codexHistoryAdapter.test.js +74 -1
- package/test/codexTurn.test.js +282 -1
- package/test/conversation.test.js +83 -0
- package/test/conversationAgentRun.test.js +181 -0
- package/test/conversationRuntime.test.js +95 -0
- package/test/conversationRuntimeStateUpgrade.test.js +89 -0
- package/test/conversationStreaming.test.js +85 -0
- package/test/conversationTools.test.js +165 -1
- package/test/openCodeClient.test.js +186 -0
- package/test/openCodeRuntime.test.js +372 -0
- 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
|
-
|
|
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
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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 =
|
|
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 ?
|
|
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
|
-
|
|
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:
|
|
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),
|