@jskit-ai/assistant-core 0.1.213 → 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 +16 -2
- package/src/server/conversation/claudeTurn.js +28 -8
- 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 +132 -24
- 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 +418 -64
- 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 +1329 -3
- package/test/claudeProcess.test.js +101 -0
- package/test/claudeTurn.test.js +31 -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 +112 -1
- package/test/openCodeClient.test.js +186 -0
- package/test/openCodeRuntime.test.js +78 -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)));
|
|
@@ -1063,6 +1101,8 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
1063
1101
|
let dispatchMonitor = null;
|
|
1064
1102
|
let previousInputMessageId = null;
|
|
1065
1103
|
let previousAdmission;
|
|
1104
|
+
let recoveryReceipt = null;
|
|
1105
|
+
let recoveryObservationError = null;
|
|
1066
1106
|
try {
|
|
1067
1107
|
const preparation = await application.prepare({ overwrite: !currentMonitor, threadId: currentThreadId });
|
|
1068
1108
|
const preparedTarget = preparation.process ? await acquirePrepared(preparation.process) : preparation.target;
|
|
@@ -1079,6 +1119,24 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
1079
1119
|
await currentMonitor;
|
|
1080
1120
|
currentMonitor = monitors.get(key);
|
|
1081
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
|
+
}
|
|
1082
1140
|
admission = Promise.withResolvers();
|
|
1083
1141
|
// The observer may be between polls when dispatch or persistence fails.
|
|
1084
1142
|
// Keep the raw promise rejectable without an unhandled rejection.
|
|
@@ -1166,7 +1224,44 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
1166
1224
|
await dispatchMonitor.catch(() => null);
|
|
1167
1225
|
throw error;
|
|
1168
1226
|
}
|
|
1169
|
-
|
|
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
|
+
}
|
|
1170
1265
|
const turn = openCodeTurnSnapshot(turns.get(key), target.upstreamSessionId);
|
|
1171
1266
|
return {
|
|
1172
1267
|
conversationTurn,
|
|
@@ -1227,30 +1322,43 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
1227
1322
|
}
|
|
1228
1323
|
|
|
1229
1324
|
function beginMessageMonitor(key, target, admitted, options, projection) {
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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;
|
|
1251
1351
|
};
|
|
1252
|
-
|
|
1253
|
-
|
|
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;
|
|
1254
1362
|
return monitor;
|
|
1255
1363
|
}
|
|
1256
1364
|
|
|
@@ -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),
|
|
@@ -10,6 +10,7 @@ export function createCodexConversationAdapter(host) {
|
|
|
10
10
|
const instructionPrompts = new Map();
|
|
11
11
|
const tasks = new Map();
|
|
12
12
|
const goalChanges = new Map();
|
|
13
|
+
const threadGenerations = new Map();
|
|
13
14
|
let contextGeneration = 0;
|
|
14
15
|
|
|
15
16
|
function instructionEnvironment(params, environment) {
|
|
@@ -38,6 +39,7 @@ export function createCodexConversationAdapter(host) {
|
|
|
38
39
|
// Detaching alone is insufficient when another subscriber retains it: prove
|
|
39
40
|
// the effective managed environment before recording a new binding.
|
|
40
41
|
async function withThreadContext(threadId, params, operation) {
|
|
42
|
+
const threadGeneration = threadGenerations.get(threadId) || 0;
|
|
41
43
|
const previous = tasks.get(threadId) || Promise.resolve();
|
|
42
44
|
const task = previous.catch(() => null).then(() => host.runRequest(async () => {
|
|
43
45
|
const client = await host.client();
|
|
@@ -48,10 +50,11 @@ export function createCodexConversationAdapter(host) {
|
|
|
48
50
|
params = await prepareInstructions(retainInstalledInstructions
|
|
49
51
|
? { ...params, systemPrompt: undefined, developerInstructions: bound.params.developerInstructions }
|
|
50
52
|
: params, threadId);
|
|
51
|
-
|
|
53
|
+
let signal = AbortSignal.timeout(host.timeoutMs || 10_000);
|
|
54
|
+
const controlDeadline = performance.now() + (host.timeoutMs || 10_000);
|
|
52
55
|
const assertRecoveryOpen = () => {
|
|
53
56
|
signal.throwIfAborted();
|
|
54
|
-
if (generation !== contextGeneration) {
|
|
57
|
+
if (generation !== contextGeneration || threadGeneration !== (threadGenerations.get(threadId) || 0)) {
|
|
55
58
|
throw new Error("The assistant connection closed during recovery.");
|
|
56
59
|
}
|
|
57
60
|
};
|
|
@@ -65,6 +68,7 @@ export function createCodexConversationAdapter(host) {
|
|
|
65
68
|
if (bound && params.developerInstructions != null && instructionPrompts.get(threadId) !== params.developerInstructions) {
|
|
66
69
|
throw new Error("Start a new ephemeral conversation to change its instructions.");
|
|
67
70
|
}
|
|
71
|
+
assertRecoveryOpen();
|
|
68
72
|
return operation(threadParameters({ ...bound?.params, ...params }, {}));
|
|
69
73
|
}
|
|
70
74
|
let environment = instructionEnvironment(params, fixedPolicy?.set || await host.prepareEnvironment(
|
|
@@ -138,7 +142,15 @@ export function createCodexConversationAdapter(host) {
|
|
|
138
142
|
if (retainInstalledInstructions && (!bindingMatches || nativeStatus !== "active")) {
|
|
139
143
|
// An active healthy turn keeps its installed prompt. Real control
|
|
140
144
|
// recovery and the next idle admission still read current instructions.
|
|
145
|
+
assertRecoveryOpen();
|
|
146
|
+
const remainingControlMs = Math.floor(controlDeadline - performance.now());
|
|
147
|
+
if (remainingControlMs <= 0) {
|
|
148
|
+
throw new DOMException("The operation was aborted due to timeout", "TimeoutError");
|
|
149
|
+
}
|
|
150
|
+
// Instruction composition preceded the control clock originally. Only
|
|
151
|
+
// this deferred host read is excluded; spent native time is retained.
|
|
141
152
|
params = await prepareInstructions(requestedParams, threadId);
|
|
153
|
+
signal = AbortSignal.timeout(remainingControlMs);
|
|
142
154
|
environment = instructionEnvironment(params, environment);
|
|
143
155
|
requestParams = { ...requestParams,
|
|
144
156
|
...(Object.hasOwn(params, "developerInstructions") ? { developerInstructions: params.developerInstructions } : {}),
|
|
@@ -157,6 +169,7 @@ export function createCodexConversationAdapter(host) {
|
|
|
157
169
|
goal.createdAt === original.createdAt && goal.objective === original.objective;
|
|
158
170
|
const log = (event, fields = {}) => host.log?.(event, fields, { threadId, environment });
|
|
159
171
|
try {
|
|
172
|
+
assertRecoveryOpen();
|
|
160
173
|
if (instructionOnly) {
|
|
161
174
|
// A native goal can start work while the host reads its prompt.
|
|
162
175
|
nativeStatus = await readStatus();
|
|
@@ -282,6 +295,10 @@ export function createCodexConversationAdapter(host) {
|
|
|
282
295
|
bindings.set(id, binding);
|
|
283
296
|
if (binding.params.developerInstructions != null) instructionPrompts.set(id, binding.params.developerInstructions);
|
|
284
297
|
},
|
|
298
|
+
invalidateThread(id) {
|
|
299
|
+
threadGenerations.set(id, (threadGenerations.get(id) || 0) + 1);
|
|
300
|
+
bindings.delete(id);
|
|
301
|
+
},
|
|
285
302
|
goalChanged: (id) => goalChanges.set(id, (goalChanges.get(id) || 0) + 1),
|
|
286
303
|
get bindingCount() { return bindings.size; },
|
|
287
304
|
get threadEnvironments() { return bindings; },
|