@jskit-ai/assistant-core 0.1.223 → 0.1.224
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 +1 -1
- package/package.json +1 -1
- package/src/client/conversation/AssistantGoalControl.vue +8 -8
- package/src/server/conversation/claudeHistory.js +9 -3
- package/src/server/conversation/claudeProcess.js +60 -20
- package/src/server/conversation/claudeTurn.js +190 -41
- package/src/server/conversation/codexProvider.js +67 -3
- package/src/server/conversation/providers/claude.js +5 -1
- package/src/server/conversation/providers/claudeDriver.js +5 -1
- package/src/server/conversation/providers/codex.js +325 -216
- package/src/server/conversation/providers/codexDriver.js +14 -1
- package/src/server/conversation/providers/scoped.js +39 -23
- package/src/server/conversation/runtime.js +20 -1
- package/test/claudeConversationSession.test.js +3 -2
- package/test/claudeProcess.test.js +139 -1
- package/test/claudeTurn.test.js +253 -3
- package/test/codexConversationSession.test.js +27 -4
- package/test/codexGoal.test.js +152 -0
- package/test/conversationRuntime.test.js +168 -4
- package/test/conversationSession.test.js +48 -0
|
@@ -71,9 +71,17 @@ export async function runClaudeRenewalTurn(conversation, {
|
|
|
71
71
|
let result;
|
|
72
72
|
try {
|
|
73
73
|
if (accepted) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
if (entry.turn?.id === uuid && entry.turn.active && entry.process) {
|
|
75
|
+
result = await conversation.wait({ conversationId: entry.id, timeoutMs });
|
|
76
|
+
if (result.status !== "completed") throw createError(result.error || "Claude did not complete the renewal turn.", failedCode);
|
|
77
|
+
} else {
|
|
78
|
+
if (entry.turn?.id !== uuid || entry.turn.state !== "completed" || entry.turn.active) {
|
|
79
|
+
throw createError("The accepted Claude renewal turn has no confirmed completion; it will not be submitted again.", unreadableCode);
|
|
80
|
+
}
|
|
81
|
+
result = { text: history.messages.filter((message) => message.userId === uuid && message.role === "assistant").map((message) => message.text).join("\n"),
|
|
82
|
+
threadId: entry.id, turnId: uuid, reconciled: true };
|
|
83
|
+
if (!result.text) throw createError("The accepted Claude renewal turn has no readable result; it will not be submitted again.", unreadableCode);
|
|
84
|
+
}
|
|
77
85
|
} else {
|
|
78
86
|
if (entry.turn?.active && entry.process) throw createError("Stop the current turn before renewing this conversation.");
|
|
79
87
|
await conversation.send({ message: prompt, messageId: clientMessageId, outputSchema }, { renewal: true });
|
|
@@ -146,6 +154,7 @@ export function createClaudeConversationOwner({
|
|
|
146
154
|
|
|
147
155
|
function snapshot(entry) {
|
|
148
156
|
return entry?.turn ? { ...entry.turn, threadId: entry.id,
|
|
157
|
+
...(entry.turn.active && entry.observationError ? { status: "observation_lost", error: entry.observationError } : {}),
|
|
149
158
|
phase: entry.turn.active && !entry.observationError ? text(entry.turn.phase) : "" } : null;
|
|
150
159
|
}
|
|
151
160
|
|
|
@@ -204,7 +213,7 @@ export function createClaudeConversationOwner({
|
|
|
204
213
|
if (!main && !saved && !create && !recoveringCleanup) throw createError("This Claude conversation is unavailable.");
|
|
205
214
|
const state = saved ? JSON.parse(saved) : {};
|
|
206
215
|
const entry = { id, key, main, persistent: state.persistent === true, context, process: null, startupCleanup: null,
|
|
207
|
-
executionId: state.executionId || (recoveringCleanup ? cleanupExecutionId : ""),
|
|
216
|
+
executionId: state.executionId || (recoveringCleanup ? cleanupExecutionId : ""), preparationRevision: 0,
|
|
208
217
|
accountIdentities: state.accountIdentities || (state.accountIdentity ? { anthropic: state.accountIdentity } : {}),
|
|
209
218
|
accountIdentity: state.accountIdentity || "", sent: state.sent === true, nativeWorkdir: state.nativeWorkdir || nativeWorkdir,
|
|
210
219
|
lastMessageId: state.lastMessageId || "", turn: state.turnId ? {
|
|
@@ -219,7 +228,8 @@ export function createClaudeConversationOwner({
|
|
|
219
228
|
getProcess: () => entry.stopping ? null : entry.process,
|
|
220
229
|
isActive: () => store.releaseExecution ? entry.nativeTurn.read().active : Boolean(entry.turn?.active),
|
|
221
230
|
startProcess: input => startProcess(entry, input),
|
|
222
|
-
stopProcess: reason => entry.process || entry.executionId
|
|
231
|
+
stopProcess: reason => entry.process || entry.executionId
|
|
232
|
+
? entry.nativeTurn.stopProcess(reason, { preparing: true }) : Promise.resolve()
|
|
223
233
|
}
|
|
224
234
|
});
|
|
225
235
|
entry.goals = createClaudeConversationGoals({ conversationId: id,
|
|
@@ -255,7 +265,7 @@ export function createClaudeConversationOwner({
|
|
|
255
265
|
if (entry.accountIdentity !== identity) entry.nativeTurn.invalidateFinalAssistantResult();
|
|
256
266
|
try {
|
|
257
267
|
await bindClaudeConversationAccount(entry, { identity, providerId,
|
|
258
|
-
stop: (entry, reason) => entry.nativeTurn.stopProcess(reason), save: saveAccount });
|
|
268
|
+
stop: (entry, reason) => entry.nativeTurn.stopProcess(reason, { preparing: true }), save: saveAccount });
|
|
259
269
|
} catch (failure) {
|
|
260
270
|
if (failure.code !== "claude_account_changed") throw failure;
|
|
261
271
|
throw createError(failure.message, account.changedCode || failure.code);
|
|
@@ -273,12 +283,17 @@ export function createClaudeConversationOwner({
|
|
|
273
283
|
}
|
|
274
284
|
|
|
275
285
|
async function ensureProcess(entry, input = {}) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
286
|
+
const revision = entry.preparationRevision;
|
|
287
|
+
const application = preparation.checkConfiguration?.(entry, input);
|
|
288
|
+
if (entry.disposed || closing || closingSessions.has(entry.context.key) || application?.sessionClosing) {
|
|
289
|
+
throw createError("This session is closing.");
|
|
279
290
|
}
|
|
280
291
|
if (preparation.account) await bindAccount(entry);
|
|
281
|
-
|
|
292
|
+
const configuration = await preparation.configuration(entry, input);
|
|
293
|
+
if (revision !== entry.preparationRevision || entry.disposed || closing || closingSessions.has(entry.context.key)) {
|
|
294
|
+
throw createError("Claude preparation was stopped while this session was closing or changing.");
|
|
295
|
+
}
|
|
296
|
+
return entry.nativeTurn.prepare(configuration);
|
|
282
297
|
}
|
|
283
298
|
|
|
284
299
|
async function stopForTerminal(entry, application) {
|
|
@@ -301,6 +316,13 @@ export function createClaudeConversationOwner({
|
|
|
301
316
|
|
|
302
317
|
async function startProcess(entry, configuration) {
|
|
303
318
|
const context = entry.context;
|
|
319
|
+
const revision = entry.preparationRevision;
|
|
320
|
+
const assertPreparing = () => {
|
|
321
|
+
if (revision !== entry.preparationRevision || entry.disposed || closing || closingSessions.has(context.key)) {
|
|
322
|
+
throw createError("Claude preparation was stopped while this session was closing or changing.");
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
assertPreparing();
|
|
304
326
|
let prepared = process.prepare ? process.prepare(entry, configuration, context) : { workdir: entry.nativeWorkdir };
|
|
305
327
|
if (prepared && typeof prepared.then === "function") prepared = await prepared;
|
|
306
328
|
entry.nativeWorkdir = prepared.workdir;
|
|
@@ -310,14 +332,17 @@ export function createClaudeConversationOwner({
|
|
|
310
332
|
entry.profile = configuration.profile;
|
|
311
333
|
let options = process.configure(entry, configuration, { context, prepared });
|
|
312
334
|
if (options && typeof options.then === "function") options = await options;
|
|
335
|
+
assertPreparing();
|
|
313
336
|
try { entry.process = await createProcess({ ...options,
|
|
314
337
|
workdir: entry.nativeWorkdir, sessionId: entry.id, resume: entry.sent,
|
|
315
338
|
onStarted: async (executionId, stop) => {
|
|
316
339
|
if (typeof stop === "function") entry.startupCleanup = { executionId, stop };
|
|
317
|
-
if (entry.executionId
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
340
|
+
if (entry.executionId !== executionId) {
|
|
341
|
+
entry.executionId = executionId;
|
|
342
|
+
await store.save(entry, { execution: true });
|
|
343
|
+
await onEvent(entry, { type: "execution", context, executionId });
|
|
344
|
+
}
|
|
345
|
+
assertPreparing();
|
|
321
346
|
},
|
|
322
347
|
async onEvent(frame) {
|
|
323
348
|
const current = entry.command;
|
|
@@ -388,6 +413,11 @@ export function createClaudeConversationOwner({
|
|
|
388
413
|
}
|
|
389
414
|
throw error;
|
|
390
415
|
}
|
|
416
|
+
try { assertPreparing(); }
|
|
417
|
+
catch (error) {
|
|
418
|
+
await entry.nativeTurn.stopProcess(error.message);
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
391
421
|
if (entry.startupCleanup?.executionId === entry.executionId) entry.startupCleanup = null;
|
|
392
422
|
return entry.process;
|
|
393
423
|
}
|
|
@@ -624,9 +654,13 @@ export function createClaudeConversationOwner({
|
|
|
624
654
|
}
|
|
625
655
|
|
|
626
656
|
async function drain(key) {
|
|
627
|
-
|
|
657
|
+
const selected = [...entries.values()].filter(entry => entry.context.key === key);
|
|
658
|
+
// Startup already lends cleanup before initialization completes. Stop it
|
|
659
|
+
// first; waiting for the preparation queue first can exhaust host shutdown.
|
|
660
|
+
for (const entry of selected) await entry.nativeTurn.stopProcess();
|
|
661
|
+
await Promise.allSettled(selected.map(entry => entry.nativeTurn.whenIdle()));
|
|
628
662
|
const proofs = [];
|
|
629
|
-
for (const entry of
|
|
663
|
+
for (const entry of selected) proofs.push(await entry.nativeTurn.stopProcess());
|
|
630
664
|
return proofs;
|
|
631
665
|
}
|
|
632
666
|
|
|
@@ -637,6 +671,8 @@ export function createClaudeConversationOwner({
|
|
|
637
671
|
async function close(entry) {
|
|
638
672
|
await entry.nativeTurn.stopProcess("Conversation closed.");
|
|
639
673
|
entry.disposed = true;
|
|
674
|
+
await entry.nativeTurn.whenIdle().catch(() => {});
|
|
675
|
+
await entry.nativeTurn.stopProcess("Conversation closed.");
|
|
640
676
|
if (entries.get(entry.key) === entry) entries.delete(entry.key);
|
|
641
677
|
}
|
|
642
678
|
|
|
@@ -850,7 +886,11 @@ export function createClaudeConversationOwner({
|
|
|
850
886
|
await cancellation?.catch(() => {});
|
|
851
887
|
if (active.entry) {
|
|
852
888
|
try {
|
|
853
|
-
|
|
889
|
+
// A failed Stop already retained custody for the caller's retry.
|
|
890
|
+
// Do not race that retry with another implicit cleanup attempt.
|
|
891
|
+
if ((problem || signal.aborted) && !problem?.cleanupFailed) {
|
|
892
|
+
await active.entry.nativeTurn.stopProcess(problem?.message || "Work stopped.");
|
|
893
|
+
}
|
|
854
894
|
} catch (error) {
|
|
855
895
|
if (problem && error !== problem) error.cause ||= problem;
|
|
856
896
|
problem = error;
|
|
@@ -880,6 +920,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
880
920
|
const instructions = process ? createClaudeConversationAdapter(process) : null;
|
|
881
921
|
const publish = entry ? publishEntryEvent : onEvent;
|
|
882
922
|
const messages = new Map();
|
|
923
|
+
const pendingAssistantMessages = new Map();
|
|
924
|
+
const assistantRoles = new Map();
|
|
883
925
|
const admissions = new Map();
|
|
884
926
|
const inFlight = new Set();
|
|
885
927
|
const tasks = new Set();
|
|
@@ -890,6 +932,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
890
932
|
let phase = "";
|
|
891
933
|
let outcome;
|
|
892
934
|
let active = false;
|
|
935
|
+
let generation = 0;
|
|
893
936
|
let stopped = false;
|
|
894
937
|
let cancelling = false;
|
|
895
938
|
let steering;
|
|
@@ -898,6 +941,11 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
898
941
|
let acceptedInput = null;
|
|
899
942
|
let publishedAssistant = null;
|
|
900
943
|
let finalAssistantResult = null;
|
|
944
|
+
let resolvedCompletion;
|
|
945
|
+
let latestSettlement;
|
|
946
|
+
// A common command fences its first checkpoint; later native continuations
|
|
947
|
+
// keep the same input but cannot enqueue onto that already resolved command.
|
|
948
|
+
let checkpointedCommand;
|
|
901
949
|
|
|
902
950
|
function clearFinalAssistantResult() {
|
|
903
951
|
publishedAssistant = null;
|
|
@@ -945,17 +993,21 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
945
993
|
|
|
946
994
|
function resolveCompletion(result) {
|
|
947
995
|
entry.completion?.resolve(result);
|
|
996
|
+
resolvedCompletion = entry.completion;
|
|
948
997
|
if (entry.command?.finishIfCurrent) entry.command.completion.resolve(result);
|
|
949
998
|
}
|
|
950
999
|
|
|
951
|
-
async function updateState(state, message = "") {
|
|
952
|
-
if (!entry.turn) return;
|
|
1000
|
+
async function updateState(state, message = "", expectedGeneration = generation) {
|
|
1001
|
+
if (!entry.turn || expectedGeneration !== generation) return false;
|
|
953
1002
|
const active = ["starting", "active", "finalizing"].includes(state);
|
|
954
1003
|
const checkpoint = onEvent({ type: "before-state", state, active, message });
|
|
955
1004
|
if (checkpoint) await checkpoint;
|
|
1005
|
+
if (expectedGeneration !== generation) return false;
|
|
956
1006
|
setState(state, message);
|
|
957
1007
|
await onEvent({ type: "save" });
|
|
1008
|
+
if (expectedGeneration !== generation) return false;
|
|
958
1009
|
await onEvent({ type: "state", state, active, message });
|
|
1010
|
+
return expectedGeneration === generation;
|
|
959
1011
|
}
|
|
960
1012
|
|
|
961
1013
|
function setState(state, message = "") {
|
|
@@ -965,12 +1017,13 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
965
1017
|
phase: active && !entry.observationError ? String(entry.turn.phase ?? "").trim() : "" });
|
|
966
1018
|
}
|
|
967
1019
|
|
|
968
|
-
async function stopProcess(reason = "") {
|
|
1020
|
+
async function stopProcess(reason = "", { preparing = false } = {}) {
|
|
1021
|
+
if (!preparing) entry.preparationRevision++;
|
|
969
1022
|
invalidateFinalAssistantResult();
|
|
970
1023
|
// Runtime bindings commit execution release before dropping the handle.
|
|
971
1024
|
// Retained host entries keep their original post-proof checkpoint order.
|
|
972
1025
|
const binding = Boolean(process.releaseExecution);
|
|
973
|
-
if (
|
|
1026
|
+
if (entry.stopPending) return entry.stopPending;
|
|
974
1027
|
const active = read().active;
|
|
975
1028
|
if (!binding) entry.stopping = true;
|
|
976
1029
|
const operation = async () => {
|
|
@@ -1015,39 +1068,50 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1015
1068
|
if (entry.startupCleanup === startup) entry.startupCleanup = null;
|
|
1016
1069
|
return entry.exitProof;
|
|
1017
1070
|
};
|
|
1018
|
-
if (
|
|
1019
|
-
entry.stopPending = operation().catch(error => { error.cleanupFailed = true; throw error; })
|
|
1071
|
+
entry.stopPending = operation().catch(error => { if (binding) error.cleanupFailed = true; throw error; })
|
|
1020
1072
|
.finally(() => { entry.stopPending = null; });
|
|
1021
1073
|
return entry.stopPending;
|
|
1022
1074
|
}
|
|
1023
1075
|
|
|
1024
1076
|
async function publishEntryEvent(event, deferred = false) {
|
|
1025
1077
|
if (entry.stopping) return;
|
|
1078
|
+
if (event.type === "settled" && event.generation !== generation) return;
|
|
1026
1079
|
const binding = Boolean(process.releaseExecution);
|
|
1027
1080
|
const command = entry.command;
|
|
1028
|
-
if (event.type === "settled" && !binding && !deferred && command?.finishIfCurrent) {
|
|
1081
|
+
if (event.type === "settled" && !binding && !deferred && command?.finishIfCurrent && command !== checkpointedCommand) {
|
|
1029
1082
|
const delivery = command.delivery;
|
|
1030
1083
|
const completion = entry.completion;
|
|
1031
1084
|
const owner = acceptedInput;
|
|
1032
1085
|
// Release the native queue before the common run joins reserved steering.
|
|
1033
1086
|
// That same accepted delivery then performs the original retained checkpoint.
|
|
1034
1087
|
command.completion.resolve(async () => {
|
|
1035
|
-
if (entry.command !== command || command.delivery !== delivery || entry.completion !== completion ||
|
|
1036
|
-
command.signal.aborted || !currentReceiptOwner(owner)) {
|
|
1037
|
-
clearFinalAssistantResult();
|
|
1038
|
-
throw failure("Claude's completion belongs to a retired native input.");
|
|
1039
|
-
}
|
|
1040
1088
|
try {
|
|
1041
|
-
|
|
1042
|
-
|
|
1089
|
+
for (;;) {
|
|
1090
|
+
if (entry.command !== command || command.delivery !== delivery || entry.completion !== completion ||
|
|
1091
|
+
command.signal.aborted || !currentReceiptOwner(owner)) {
|
|
1092
|
+
throw failure("Claude's completion belongs to a retired native input.");
|
|
1093
|
+
}
|
|
1094
|
+
// Follow-ups share this input, but can finish while its checkpoint
|
|
1095
|
+
// awaits storage. Apply the latest settlement after the same fence.
|
|
1096
|
+
if (active) return readResult();
|
|
1097
|
+
const settlement = latestSettlement;
|
|
1098
|
+
await publishEntryEvent(settlement, true);
|
|
1099
|
+
if (settlement === latestSettlement) return readResult();
|
|
1100
|
+
}
|
|
1043
1101
|
} catch (error) {
|
|
1044
1102
|
clearFinalAssistantResult();
|
|
1045
1103
|
throw error;
|
|
1104
|
+
} finally {
|
|
1105
|
+
checkpointedCommand = command;
|
|
1046
1106
|
}
|
|
1047
1107
|
});
|
|
1048
1108
|
return;
|
|
1049
1109
|
}
|
|
1050
|
-
if (event.type === "
|
|
1110
|
+
if (event.type === "continued") {
|
|
1111
|
+
if (entry.completion === resolvedCompletion) entry.completion = Promise.withResolvers();
|
|
1112
|
+
if (binding) setState("active");
|
|
1113
|
+
else await updateState("active");
|
|
1114
|
+
} else if (event.type === "admitted") {
|
|
1051
1115
|
if (event.userEcho) entry.sent = true;
|
|
1052
1116
|
if (!binding) await onEvent({ type: "save" });
|
|
1053
1117
|
} else if (event.type === "phase") {
|
|
@@ -1056,7 +1120,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1056
1120
|
} else if (event.type === "settled") {
|
|
1057
1121
|
const state = event.status === "interrupted" ? "interrupted" : event.status === "failed" ? "failed" : "completed";
|
|
1058
1122
|
if (binding) setState(state, event.error);
|
|
1059
|
-
else await updateState(state, event.error);
|
|
1123
|
+
else if (!await updateState(state, event.error, event.generation)) return;
|
|
1060
1124
|
}
|
|
1061
1125
|
const published = await onEvent(event);
|
|
1062
1126
|
if (event.type === "provider-event") {
|
|
@@ -1065,6 +1129,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1065
1129
|
if (change) await onEvent({ type: "provider-update", change });
|
|
1066
1130
|
}
|
|
1067
1131
|
if (event.type === "settled") {
|
|
1132
|
+
if (event.generation !== generation) return;
|
|
1068
1133
|
if (event.source === "result") await onEvent({ type: "provider-update", change: "goal" });
|
|
1069
1134
|
resolveCompletion(binding ? event : readResult());
|
|
1070
1135
|
}
|
|
@@ -1091,11 +1156,38 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1091
1156
|
return conversationTurn;
|
|
1092
1157
|
}
|
|
1093
1158
|
|
|
1159
|
+
async function flushAssistantMessages(apiMessageId, role) {
|
|
1160
|
+
assistantRoles.set(apiMessageId, role);
|
|
1161
|
+
const pending = pendingAssistantMessages.get(apiMessageId) || [];
|
|
1162
|
+
pendingAssistantMessages.delete(apiMessageId);
|
|
1163
|
+
for (const block of pending) {
|
|
1164
|
+
await publishMessage({ ...block, role });
|
|
1165
|
+
if (block.outputId) {
|
|
1166
|
+
if (block.outputId !== block.id) messages.delete(block.outputId);
|
|
1167
|
+
await publish({ type: "message-complete", messageId: block.outputId });
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1094
1172
|
async function receive(frame) {
|
|
1095
1173
|
if (stopped) return;
|
|
1096
1174
|
if (frame.session_id && frame.session_id !== conversationId) throw failure("Claude returned a different conversation id.");
|
|
1097
1175
|
if (frame.parent_tool_use_id) return; // Nested agents do not replace the main reply.
|
|
1098
1176
|
await publish({ type: "provider-event", event: frame });
|
|
1177
|
+
const continuation = (frame.type === "user" && frame.origin?.kind === "task-notification") ||
|
|
1178
|
+
(frame.type === "command_lifecycle" && frame.state === "started") ||
|
|
1179
|
+
(frame.type === "stream_event" && frame.event?.type === "message_start") || frame.type === "assistant";
|
|
1180
|
+
if (continuation && turnId && !cancelling) {
|
|
1181
|
+
const wasActive = active;
|
|
1182
|
+
if (outcome || !active) {
|
|
1183
|
+
generation++;
|
|
1184
|
+
outcome = null;
|
|
1185
|
+
result = "";
|
|
1186
|
+
clearFinalAssistantResult();
|
|
1187
|
+
}
|
|
1188
|
+
active = true;
|
|
1189
|
+
if (!wasActive) await publish({ type: "continued", source: frame.type });
|
|
1190
|
+
}
|
|
1099
1191
|
if (frame.type === "command_lifecycle" || frame.type === "user") {
|
|
1100
1192
|
const uuid = frame.type === "user" ? frame.uuid : frame.command_uuid;
|
|
1101
1193
|
if (frame.type === "user" && !inFlight.has(uuid) && !admissions.has(uuid)) return;
|
|
@@ -1135,9 +1227,23 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1135
1227
|
if (frame.subtype === "local_command_output" && frame.content) {
|
|
1136
1228
|
await publishMessage({ id: `claude_${frame.uuid}`, role: "commentary", text: frame.content, complete: true });
|
|
1137
1229
|
}
|
|
1138
|
-
if (frame.subtype === "
|
|
1230
|
+
if (frame.subtype === "background_tasks_changed") {
|
|
1231
|
+
tasks.clear();
|
|
1232
|
+
for (const task of frame.tasks || []) if (task.task_id && !task.ambient) tasks.add(task.task_id);
|
|
1233
|
+
}
|
|
1234
|
+
if (frame.subtype === "task_started" && frame.task_id && !frame.ambient) tasks.add(frame.task_id);
|
|
1139
1235
|
if (["task_notification", "task_updated"].includes(frame.subtype) &&
|
|
1140
1236
|
["completed", "failed", "stopped", "killed"].includes(frame.status || frame.patch?.status)) tasks.delete(frame.task_id);
|
|
1237
|
+
if (["background_tasks_changed", "task_started", "task_notification", "task_updated"].includes(frame.subtype)) {
|
|
1238
|
+
if (tasks.size && turnId && !active && !cancelling) {
|
|
1239
|
+
generation++;
|
|
1240
|
+
active = true;
|
|
1241
|
+
clearFinalAssistantResult();
|
|
1242
|
+
await publish({ type: "continued", source: frame.subtype });
|
|
1243
|
+
}
|
|
1244
|
+
await publish({ type: "diagnostic", stage: "background_tasks", nativeEvent: frame.subtype,
|
|
1245
|
+
active, pendingCommands: inFlight.size, backgroundTasks: tasks.size });
|
|
1246
|
+
}
|
|
1141
1247
|
} else if (frame.type === "stream_event") {
|
|
1142
1248
|
const event = frame.event || {};
|
|
1143
1249
|
if (event.type === "message_start") messageId = event.message?.id || frame.uuid;
|
|
@@ -1155,10 +1261,18 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1155
1261
|
await publish({ type: block.role === "thinking" ? "thinking" : "text", text: delta, messageId: id });
|
|
1156
1262
|
}
|
|
1157
1263
|
} else if (event.type === "content_block_stop") {
|
|
1158
|
-
|
|
1159
|
-
|
|
1264
|
+
if (![...pendingAssistantMessages.values()].some(blocks => blocks.some(block => block.outputId === id))) {
|
|
1265
|
+
messages.delete(id);
|
|
1266
|
+
await publish({ type: "message-complete", messageId: id });
|
|
1267
|
+
}
|
|
1268
|
+
} else if (event.type === "message_delta" && event.delta?.stop_reason) {
|
|
1269
|
+
await flushAssistantMessages(messageId, event.delta.stop_reason === "tool_use" ? "commentary" : "assistant");
|
|
1160
1270
|
}
|
|
1161
1271
|
} else if (frame.type === "assistant") {
|
|
1272
|
+
const apiMessageId = frame.message?.id;
|
|
1273
|
+
if (apiMessageId && frame.message.content?.some(block => block.type === "tool_use")) {
|
|
1274
|
+
await flushAssistantMessages(apiMessageId, "commentary");
|
|
1275
|
+
}
|
|
1162
1276
|
for (const block of claudeMessageBlocks(frame)) {
|
|
1163
1277
|
checkOutput(block.text, block.role);
|
|
1164
1278
|
// Native split frames arrive before their block's stop event. Keep that
|
|
@@ -1171,12 +1285,26 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1171
1285
|
Number.isSafeInteger(frame.apiBlockIndex) && frame.apiBlockIndex >= 0 &&
|
|
1172
1286
|
live[0].id === `${prefix}${frame.apiBlockIndex}`)) block.outputId = live[0].id;
|
|
1173
1287
|
}
|
|
1174
|
-
|
|
1288
|
+
if (apiMessageId && block.role === "assistant") {
|
|
1289
|
+
const role = assistantRoles.get(apiMessageId);
|
|
1290
|
+
if (role) await publishMessage({ ...block, role });
|
|
1291
|
+
else {
|
|
1292
|
+
const pending = pendingAssistantMessages.get(apiMessageId) || [];
|
|
1293
|
+
pending.push(block);
|
|
1294
|
+
pendingAssistantMessages.set(apiMessageId, pending);
|
|
1295
|
+
}
|
|
1296
|
+
} else await publishMessage(block);
|
|
1297
|
+
}
|
|
1298
|
+
if (apiMessageId && frame.message.stop_reason) {
|
|
1299
|
+
await flushAssistantMessages(apiMessageId, frame.message.stop_reason === "tool_use" ? "commentary" : "assistant");
|
|
1175
1300
|
}
|
|
1176
1301
|
} else if (frame.type === "result") {
|
|
1302
|
+
const failed = frame.is_error || frame.subtype !== "success";
|
|
1303
|
+
const interrupted = ["aborted_streaming", "aborted_tools"].includes(frame.terminal_reason);
|
|
1304
|
+
const role = failed || interrupted ? "commentary" : "assistant";
|
|
1305
|
+
for (const id of pendingAssistantMessages.keys()) await flushAssistantMessages(id, role);
|
|
1177
1306
|
const output = typeof frame.structured_output === "object" ? JSON.stringify(frame.structured_output) : String(frame.result || "");
|
|
1178
1307
|
checkOutput(output);
|
|
1179
|
-
const failed = frame.is_error || frame.subtype !== "success";
|
|
1180
1308
|
const message = failed ? (frame.errors || []).join("; ") || output || frame.subtype : "";
|
|
1181
1309
|
result = failed ? "" : output;
|
|
1182
1310
|
if (result && ![...messages.values()].some((block) => block.complete && block.role === "assistant" && block.text === result)) {
|
|
@@ -1188,11 +1316,12 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1188
1316
|
return;
|
|
1189
1317
|
}
|
|
1190
1318
|
if (cancelling) return;
|
|
1191
|
-
const interrupted = ["aborted_streaming", "aborted_tools"].includes(frame.terminal_reason);
|
|
1192
1319
|
outcome = { status: interrupted ? "interrupted" : failed ? "failed" : "completed", error: message };
|
|
1320
|
+
await publish({ type: "diagnostic", stage: "native_result", status: outcome.status,
|
|
1321
|
+
terminalReason: frame.terminal_reason, active, pendingCommands: inFlight.size, backgroundTasks: tasks.size });
|
|
1193
1322
|
}
|
|
1194
1323
|
if ((frame.type === "system" || frame.type === "result") &&
|
|
1195
|
-
outcome && inFlight.size === 0 && tasks.size === 0) {
|
|
1324
|
+
active && !cancelling && outcome && inFlight.size === 0 && tasks.size === 0) {
|
|
1196
1325
|
active = false;
|
|
1197
1326
|
phase = "";
|
|
1198
1327
|
const candidate = publishedAssistant;
|
|
@@ -1201,12 +1330,14 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1201
1330
|
? { threadId: conversationId, turnId, inputMessageId: candidate.owner.commandId,
|
|
1202
1331
|
itemId: candidate.itemId, outputId: candidate.outputId, text: candidate.text,
|
|
1203
1332
|
conversationTurn: candidate.conversationTurn } : null;
|
|
1204
|
-
|
|
1333
|
+
latestSettlement = { type: "settled", ...read(), generation, source: frame.type };
|
|
1334
|
+
try { await publish(latestSettlement); }
|
|
1205
1335
|
catch (error) { clearFinalAssistantResult(); throw error; }
|
|
1206
1336
|
}
|
|
1207
1337
|
}
|
|
1208
1338
|
|
|
1209
1339
|
function stop(reason = "Claude stopped before acknowledging the prompt.") {
|
|
1340
|
+
generation++;
|
|
1210
1341
|
stopped = true;
|
|
1211
1342
|
inputOwner = null;
|
|
1212
1343
|
acceptedInput = null;
|
|
@@ -1217,6 +1348,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1217
1348
|
admissions.clear();
|
|
1218
1349
|
inFlight.clear();
|
|
1219
1350
|
tasks.clear();
|
|
1351
|
+
pendingAssistantMessages.clear();
|
|
1352
|
+
assistantRoles.clear();
|
|
1220
1353
|
steering?.reject(failure(reason));
|
|
1221
1354
|
if (active) outcome = { status: "interrupted", error: reason };
|
|
1222
1355
|
active = false;
|
|
@@ -1242,12 +1375,15 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1242
1375
|
begin({ id, maxOutputCharacters = 4 * 1024 * 1024 } = {}) {
|
|
1243
1376
|
if (active) throw failure("This Claude turn is still working.");
|
|
1244
1377
|
if (!id || !Number.isSafeInteger(maxOutputCharacters) || maxOutputCharacters < 1) throw new TypeError("Invalid Claude turn configuration.");
|
|
1378
|
+
generation++;
|
|
1245
1379
|
inputOwner = null;
|
|
1246
1380
|
acceptedInput = null;
|
|
1247
1381
|
clearFinalAssistantResult();
|
|
1248
1382
|
turnId = id;
|
|
1249
1383
|
maximumOutput = maxOutputCharacters;
|
|
1250
1384
|
messages.clear();
|
|
1385
|
+
pendingAssistantMessages.clear();
|
|
1386
|
+
assistantRoles.clear();
|
|
1251
1387
|
result = "";
|
|
1252
1388
|
phase = "";
|
|
1253
1389
|
messageId = "";
|
|
@@ -1260,6 +1396,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1260
1396
|
reset() {
|
|
1261
1397
|
if (active || inFlight.size || tasks.size) throw failure("Stop Claude before resetting its turn view.");
|
|
1262
1398
|
messages.clear();
|
|
1399
|
+
pendingAssistantMessages.clear();
|
|
1400
|
+
assistantRoles.clear();
|
|
1263
1401
|
result = "";
|
|
1264
1402
|
outcome = null;
|
|
1265
1403
|
inputOwner = null;
|
|
@@ -1293,6 +1431,12 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1293
1431
|
},
|
|
1294
1432
|
async interruptGeneration(client, { timeoutMs = 30_000 } = {}) {
|
|
1295
1433
|
if (!active || steering || cancelling) throw failure("This Claude turn cannot accept steering now.");
|
|
1434
|
+
// A completed main loop can still own background work. There is no
|
|
1435
|
+
// generation left to interrupt or second terminal result to wait for.
|
|
1436
|
+
if (outcome && inFlight.size === 0) {
|
|
1437
|
+
await publish({ type: "diagnostic", stage: "steering_background_only", backgroundTasks: tasks.size });
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1296
1440
|
// Streaming user input otherwise queues behind the entire running turn.
|
|
1297
1441
|
// Claude's supported steering path interrupts generation, then continues
|
|
1298
1442
|
// the same native history with the new instruction.
|
|
@@ -1301,12 +1445,17 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1301
1445
|
steering = Promise.withResolvers();
|
|
1302
1446
|
let timer;
|
|
1303
1447
|
try {
|
|
1448
|
+
await publish({ type: "diagnostic", stage: "steering_requested", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
|
|
1304
1449
|
await Promise.all([client.interrupt(), Promise.race([
|
|
1305
1450
|
steering.promise,
|
|
1306
1451
|
new Promise((_, reject) => {
|
|
1307
1452
|
timer = setTimeout(() => reject(failure("Claude did not confirm the interrupted generation.")), timeoutMs);
|
|
1308
1453
|
})
|
|
1309
1454
|
])]);
|
|
1455
|
+
await publish({ type: "diagnostic", stage: "steering_confirmed", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
await publish({ type: "diagnostic", stage: "steering_failed", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
|
|
1458
|
+
throw error;
|
|
1310
1459
|
}
|
|
1311
1460
|
finally { clearTimeout(timer); steering = null; }
|
|
1312
1461
|
},
|
|
@@ -1320,6 +1320,7 @@ export class CodexAppServerAgentProvider {
|
|
|
1320
1320
|
this.connectPromise = null;
|
|
1321
1321
|
this.connectionGeneration = 0;
|
|
1322
1322
|
this.notificationSubscribers = new Set();
|
|
1323
|
+
this.settingsWaiters = new Set();
|
|
1323
1324
|
this.helperAuth = null;
|
|
1324
1325
|
this.helperAuthBlocked = false;
|
|
1325
1326
|
this.initializeResult = null;
|
|
@@ -1335,7 +1336,7 @@ export class CodexAppServerAgentProvider {
|
|
|
1335
1336
|
runRequest: (run, reason) => this.runRequest(run, reason),
|
|
1336
1337
|
timeoutMs: options.threadControlTimeoutMs,
|
|
1337
1338
|
environment: options.threadEnv,
|
|
1338
|
-
prepareEnvironment: (environment) => this.options.prepareThreadEnvironment(environment),
|
|
1339
|
+
prepareEnvironment: (environment) => this.options.prepareThreadEnvironment?.(environment) || environment,
|
|
1339
1340
|
prepareParameters: (params) => this.options.prepareThreadParams?.(params),
|
|
1340
1341
|
prepareResume: (id, params, context) => this.options.prepareThreadResumeParams?.(id, params, context),
|
|
1341
1342
|
beforeResume: (id) => this.options.beforeResumeThread?.(id),
|
|
@@ -1353,6 +1354,7 @@ export class CodexAppServerAgentProvider {
|
|
|
1353
1354
|
for (const pending of probe.pending) this.publishNotification(pending);
|
|
1354
1355
|
}
|
|
1355
1356
|
},
|
|
1357
|
+
publishGoal: (threadId, goal) => this.publishNotification({ method: "thread/goal/updated", params: { threadId, goal } }),
|
|
1356
1358
|
log: (event, fields, context) => host.log?.(event, fields, {
|
|
1357
1359
|
...context, connectionGeneration: this.connectionGeneration
|
|
1358
1360
|
})
|
|
@@ -1707,7 +1709,10 @@ export class CodexAppServerAgentProvider {
|
|
|
1707
1709
|
const client = new CodexAppServerJsonRpcClient({
|
|
1708
1710
|
endpoint: runtime.endpoint,
|
|
1709
1711
|
onDisconnect: (error) => {
|
|
1710
|
-
if (this.client === client)
|
|
1712
|
+
if (this.client === client) {
|
|
1713
|
+
for (const reject of this.settingsWaiters) reject(error);
|
|
1714
|
+
this.failObservation(error);
|
|
1715
|
+
}
|
|
1711
1716
|
},
|
|
1712
1717
|
...(this.isHelperProvider()
|
|
1713
1718
|
? { maxMessageBytes: CODEX_APP_SERVER_HELPER_MAX_MESSAGE_BYTES }
|
|
@@ -1793,6 +1798,10 @@ export class CodexAppServerAgentProvider {
|
|
|
1793
1798
|
|
|
1794
1799
|
publishNotification(notification) {
|
|
1795
1800
|
const threadId = notification.params?.threadId;
|
|
1801
|
+
if (notification.method === "thread/goal/updated") {
|
|
1802
|
+
notification = { ...notification, params: { ...notification.params,
|
|
1803
|
+
goal: this.conversationRuntime.projectGoal(threadId, notification.params.goal) } };
|
|
1804
|
+
}
|
|
1796
1805
|
const turnId = notification.params?.turnId;
|
|
1797
1806
|
const item = notification.params?.item;
|
|
1798
1807
|
if (item?.type === "commandExecution" && item.processId && item.source === "unifiedExecStartup") {
|
|
@@ -2209,10 +2218,63 @@ export class CodexAppServerAgentProvider {
|
|
|
2209
2218
|
|
|
2210
2219
|
async readGoal(threadId = "", { signal } = {}) {
|
|
2211
2220
|
const client = await this.activeClient();
|
|
2212
|
-
|
|
2221
|
+
const result = await this.runRequest(
|
|
2213
2222
|
() => client.request("thread/goal/get", { threadId: normalizeAgentText(threadId) }, { signal }),
|
|
2214
2223
|
"codex-app-server-goal-read"
|
|
2215
2224
|
);
|
|
2225
|
+
return { ...result, goal: this.conversationRuntime.projectGoal(threadId, result.goal) };
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
async updateThreadSettings(threadId, { model, effort } = {}, { signal } = {}) {
|
|
2229
|
+
if (!normalizeAgentText(threadId) || !normalizeAgentText(model)) {
|
|
2230
|
+
throw new TypeError("Codex settings require an existing thread and a model.");
|
|
2231
|
+
}
|
|
2232
|
+
const client = await this.activeClient();
|
|
2233
|
+
threadId = normalizeAgentText(threadId);
|
|
2234
|
+
const requested = { model, effort, ...(effort ? { config: { model_reasoning_effort: effort } } : {}) };
|
|
2235
|
+
const params = await this.options.prepareThreadParams?.(requested) || requested;
|
|
2236
|
+
const update = async selected => {
|
|
2237
|
+
const current = await this.activeClient();
|
|
2238
|
+
const applied = Promise.withResolvers();
|
|
2239
|
+
// A successful RPC only queues the update. Observe its applied snapshot
|
|
2240
|
+
// before resuming a goal or accepting a dependent settings operation.
|
|
2241
|
+
const deadline = AbortSignal.any([AbortSignal.timeout(this.options.requestTimeoutMs || 10_000), ...(signal ? [signal] : [])]);
|
|
2242
|
+
const abort = () => applied.reject(deadline.reason);
|
|
2243
|
+
const unsubscribe = current.subscribe(notification => {
|
|
2244
|
+
const settings = notification.params?.threadSettings;
|
|
2245
|
+
if (notification.method === "thread/settings/updated" && notification.params?.threadId === threadId &&
|
|
2246
|
+
settings?.model === selected.model && (!selected.effort || settings.effort === selected.effort)) applied.resolve();
|
|
2247
|
+
});
|
|
2248
|
+
this.settingsWaiters.add(applied.reject);
|
|
2249
|
+
deadline.addEventListener("abort", abort, { once: true });
|
|
2250
|
+
void applied.promise.catch(() => {});
|
|
2251
|
+
try {
|
|
2252
|
+
deadline.throwIfAborted();
|
|
2253
|
+
const { thread } = await this.runRequest(() => current.request("thread/read", {
|
|
2254
|
+
threadId, includeTurns: false
|
|
2255
|
+
}, { signal: deadline }), "codex-app-server-settings-read");
|
|
2256
|
+
// Native no-op updates may emit no settings event. A current snapshot
|
|
2257
|
+
// also confirms values already applied by the provider reload.
|
|
2258
|
+
if (thread?.model === selected.model && (!selected.effort || thread.reasoningEffort === selected.effort) &&
|
|
2259
|
+
(!selected.modelProvider || thread.modelProvider === selected.modelProvider)) return {};
|
|
2260
|
+
const result = await this.runRequest(() => current.request("thread/settings/update", {
|
|
2261
|
+
threadId, model: selected.model, ...(selected.effort ? { effort: selected.effort } : {})
|
|
2262
|
+
}, { signal: deadline }), "codex-app-server-settings-update");
|
|
2263
|
+
await applied.promise;
|
|
2264
|
+
return result;
|
|
2265
|
+
} finally {
|
|
2266
|
+
unsubscribe();
|
|
2267
|
+
deadline.removeEventListener("abort", abort);
|
|
2268
|
+
this.settingsWaiters.delete(applied.reject);
|
|
2269
|
+
}
|
|
2270
|
+
};
|
|
2271
|
+
if (params.modelProvider || this.conversationRuntime.hasPendingSettings(threadId)) {
|
|
2272
|
+
const { thread } = await client.request("thread/read", { threadId, includeTurns: false }, { signal });
|
|
2273
|
+
if (params.modelProvider !== thread?.modelProvider || this.conversationRuntime.hasPendingSettings(threadId)) {
|
|
2274
|
+
return this.conversationRuntime.scheduleSettings(threadId, params, update);
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
return update(params);
|
|
2216
2278
|
}
|
|
2217
2279
|
|
|
2218
2280
|
async setGoal(threadId, { objective, tokenBudget } = {}, { signal } = {}) {
|
|
@@ -2752,6 +2814,8 @@ export class CodexAppServerAgentProvider {
|
|
|
2752
2814
|
|
|
2753
2815
|
close() {
|
|
2754
2816
|
this.options.onClose?.();
|
|
2817
|
+
for (const reject of this.settingsWaiters) reject(new Error("The assistant connection closed before confirming its settings."));
|
|
2818
|
+
this.settingsWaiters.clear();
|
|
2755
2819
|
this.conversationRuntime.close();
|
|
2756
2820
|
this.threadControlProbes.clear();
|
|
2757
2821
|
this.commandExecutions.clear();
|