@jskit-ai/assistant-core 0.1.212 → 0.1.214

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +7 -1
  3. package/package.json +5 -5
  4. package/src/client/conversation/AssistantConversationElement.vue +5 -1
  5. package/src/client/conversation/AssistantTranscript.vue +10 -1
  6. package/src/server/conversation/agentRun.js +38 -11
  7. package/src/server/conversation/claudeHistory.js +40 -5
  8. package/src/server/conversation/claudeProcess.js +28 -4
  9. package/src/server/conversation/claudeTools.js +11 -2
  10. package/src/server/conversation/claudeTurn.js +213 -23
  11. package/src/server/conversation/codexConfiguration.js +1 -1
  12. package/src/server/conversation/codexHistoryAdapter.js +11 -4
  13. package/src/server/conversation/codexNotificationQueue.js +11 -2
  14. package/src/server/conversation/codexObservationControl.js +8 -8
  15. package/src/server/conversation/codexOutput.js +20 -15
  16. package/src/server/conversation/codexProvider.js +40 -3
  17. package/src/server/conversation/codexProviderOwner.js +110 -1
  18. package/src/server/conversation/codexTurn.js +3 -0
  19. package/src/server/conversation/continuity.js +24 -6
  20. package/src/server/conversation/index.js +1 -1
  21. package/src/server/conversation/nativeHistoryExport.js +97 -0
  22. package/src/server/conversation/openCodeClient.js +47 -1
  23. package/src/server/conversation/openCodeRuntime.js +183 -28
  24. package/src/server/conversation/openCodeTurn.js +65 -24
  25. package/src/server/conversation/providers/api.js +7 -0
  26. package/src/server/conversation/providers/claudeDriver.js +52 -7
  27. package/src/server/conversation/providers/codex.js +19 -2
  28. package/src/server/conversation/providers/codexDriver.js +92 -14
  29. package/src/server/conversation/providers/opencodeDriver.js +31 -9
  30. package/src/server/conversation/runtime.js +424 -65
  31. package/src/server/conversation/runtimeStateUpgrade.js +77 -2
  32. package/src/server/conversation/streams.js +25 -5
  33. package/src/server/conversation/tools.js +2 -2
  34. package/src/server/conversation/transcript.js +23 -8
  35. package/src/server/hosts/claudeHistory.js +1 -0
  36. package/src/server/hosts/claudeProcess.js +1 -0
  37. package/src/server/hosts/codexProvider.js +3 -0
  38. package/src/server/hosts/nativeHistory.js +1 -0
  39. package/src/server/hosts/openCodeClient.js +1 -0
  40. package/src/server/hosts/openCodeTurn.js +1 -0
  41. package/src/server/index.js +1 -1
  42. package/src/server/lib/assistantToolLoop.js +99 -2
  43. package/test/aiConnectionClient.test.js +57 -0
  44. package/test/assistantBoundedToolLoop.test.js +332 -0
  45. package/test/assistantScroll.browser.test.js +151 -0
  46. package/test/claudeConversationSession.test.js +1417 -3
  47. package/test/claudeProcess.test.js +175 -0
  48. package/test/claudeTurn.test.js +466 -0
  49. package/test/codexConversationSession.test.js +1257 -12
  50. package/test/codexHistoryAdapter.test.js +74 -1
  51. package/test/codexTurn.test.js +282 -1
  52. package/test/conversation.test.js +83 -0
  53. package/test/conversationAgentRun.test.js +181 -0
  54. package/test/conversationRuntime.test.js +95 -0
  55. package/test/conversationRuntimeStateUpgrade.test.js +89 -0
  56. package/test/conversationStreaming.test.js +85 -0
  57. package/test/conversationTools.test.js +165 -1
  58. package/test/openCodeClient.test.js +186 -0
  59. package/test/openCodeRuntime.test.js +372 -0
  60. package/test/opencodeConversationSession.test.js +637 -5
@@ -149,8 +149,20 @@ export function createClaudeConversationOwner({
149
149
  phase: entry.turn.active && !entry.observationError ? text(entry.turn.phase) : "" } : null;
150
150
  }
151
151
 
152
+ function currentApplicationCommand(entry, command, owner = command?.nativeOwner) {
153
+ const native = entry?.nativeTurn.read();
154
+ return Boolean(owner && entry && !closing && !closingSessions.has(entry.context.key) &&
155
+ entries.get(entry.key) === entry && entry.command === command && command.entry === entry &&
156
+ command.admitted && !command.signal.aborted && !command.toolFailure &&
157
+ !entry.disposed && !entry.stopping && !entry.stopPending && !entry.observationError &&
158
+ entry.process === owner.process && entry.executionId === owner.executionId &&
159
+ entry.accountIdentity === owner.accountIdentity && entry.id === owner.threadId &&
160
+ entry.turn?.id === owner.turnId && entry.turn.active && native.turnId === owner.turnId && native.active);
161
+ }
162
+
152
163
  function readRetainedTurn(contextKey, conversationId, state) {
153
164
  const entry = entries.get(`${contextKey}\0${conversationId}`);
165
+ if (state.current) return currentApplicationCommand(entry, entry?.command) ? snapshot(entry) : null;
154
166
  const saved = state.saved;
155
167
  return state.nativeResult?.turn || (entry ? snapshot(entry) : saved?.turnId ? {
156
168
  id: saved.turnId, threadId: conversationId, state: saved.state,
@@ -158,6 +170,15 @@ export function createClaudeConversationOwner({
158
170
  } : null);
159
171
  }
160
172
 
173
+ // This lookup uses only the existing current entry and native receipt owner.
174
+ function readFinalAssistantResult(contextKey, conversationId = "", turnId = "") {
175
+ if (!text(contextKey) || !text(conversationId) || !text(turnId) || closing || closingSessions.has(contextKey)) return null;
176
+ const entry = entries.get(`${contextKey}\0${conversationId}`);
177
+ if (!entry || entry.disposed || entry.stopping || entry.observationError ||
178
+ entry.id !== conversationId || entry.context.key !== contextKey || entry.turn?.id !== turnId) return null;
179
+ return entry.nativeTurn.readFinalAssistantResult(conversationId, turnId);
180
+ }
181
+
161
182
  function readHistory(entry) {
162
183
  return readClaudeHistory({ configRoot, workdir: entry.nativeWorkdir, conversationId: entry.id });
163
184
  }
@@ -182,7 +203,7 @@ export function createClaudeConversationOwner({
182
203
  if (saved && typeof saved.then === "function") saved = await saved;
183
204
  if (!main && !saved && !create && !recoveringCleanup) throw createError("This Claude conversation is unavailable.");
184
205
  const state = saved ? JSON.parse(saved) : {};
185
- const entry = { id, key, main, persistent: state.persistent === true, context, process: null,
206
+ const entry = { id, key, main, persistent: state.persistent === true, context, process: null, startupCleanup: null,
186
207
  executionId: state.executionId || (recoveringCleanup ? cleanupExecutionId : ""),
187
208
  accountIdentities: state.accountIdentities || (state.accountIdentity ? { anthropic: state.accountIdentity } : {}),
188
209
  accountIdentity: state.accountIdentity || "", sent: state.sent === true, nativeWorkdir: state.nativeWorkdir || nativeWorkdir,
@@ -231,6 +252,7 @@ export function createClaudeConversationOwner({
231
252
  const account = preparation.account(entry);
232
253
  const identity = await account.identity;
233
254
  const providerId = account.providerId;
255
+ if (entry.accountIdentity !== identity) entry.nativeTurn.invalidateFinalAssistantResult();
234
256
  try {
235
257
  await bindClaudeConversationAccount(entry, { identity, providerId,
236
258
  stop: (entry, reason) => entry.nativeTurn.stopProcess(reason), save: saveAccount });
@@ -290,7 +312,9 @@ export function createClaudeConversationOwner({
290
312
  if (options && typeof options.then === "function") options = await options;
291
313
  try { entry.process = await createProcess({ ...options,
292
314
  workdir: entry.nativeWorkdir, sessionId: entry.id, resume: entry.sent,
293
- onStarted: async executionId => {
315
+ onStarted: async (executionId, stop) => {
316
+ if (typeof stop === "function") entry.startupCleanup = { executionId, stop };
317
+ if (entry.executionId === executionId) return;
294
318
  entry.executionId = executionId;
295
319
  await store.save(entry, { execution: true });
296
320
  await onEvent(entry, { type: "execution", context, executionId });
@@ -310,10 +334,27 @@ export function createClaudeConversationOwner({
310
334
  }
311
335
  await entry.nativeTurn.receive(frame);
312
336
  },
313
- ...(store.releaseExecution ? {
337
+ ...(store.releaseExecution || entry.command?.tools ? {
314
338
  async onControlRequest(request, { signal }) {
315
339
  const active = entry.command;
316
- const work = claudeApplicationToolResponse(request, { schemas: entry.toolSchemas || [], turn: active, signal });
340
+ const suppliedTools = !store.releaseExecution && active?.tools;
341
+ const owner = active?.nativeOwner;
342
+ const work = claudeApplicationToolResponse(request, { schemas: entry.toolSchemas || [], turn: active, signal,
343
+ ...(suppliedTools ? {
344
+ assertCurrent(native) {
345
+ if (!currentApplicationCommand(entry, active, owner) || active.nativeOwner !== owner ||
346
+ native.messageId !== active.messageId) throw new Error("Claude's application call belongs to a retired input.");
347
+ },
348
+ onExecutionFailure(error, native) {
349
+ // Already-invoked old work retains its original result. Only
350
+ // this still-current authored input can fail this native run.
351
+ if (currentApplicationCommand(entry, active, owner) && active.nativeOwner === owner &&
352
+ native.messageId === active.messageId) {
353
+ active.toolFailure = error;
354
+ active.failure.reject(error);
355
+ }
356
+ }
357
+ } : {}) });
317
358
  active?.toolWork.add(work);
318
359
  try { return await work; }
319
360
  finally { active?.toolWork.delete(work); }
@@ -331,13 +372,27 @@ export function createClaudeConversationOwner({
331
372
  await entry.nativeTurn.stopProcess(error.message);
332
373
  }
333
374
  }); } catch (error) {
334
- if (store.releaseExecution && error.stopProof?.scopeEmpty) await store.releaseExecution(entry);
375
+ if (error.executionId && error.stopProof?.scopeEmpty !== true && !entry.executionId) {
376
+ // Streaming startup can fail after host admission, before onStarted.
377
+ // Retain that custody through the same binding owner for Stop recovery.
378
+ error.cleanupFailed = true;
379
+ entry.executionId = error.executionId;
380
+ try {
381
+ await store.save(entry, { execution: true });
382
+ await onEvent(entry, { type: "execution", context, executionId: entry.executionId });
383
+ } catch (bindingError) { error.bindingError = bindingError; }
384
+ }
385
+ if (store.releaseExecution && error.stopProof?.scopeEmpty) {
386
+ await store.releaseExecution(entry);
387
+ if (entry.startupCleanup?.executionId === error.executionId) entry.startupCleanup = null;
388
+ }
335
389
  throw error;
336
390
  }
391
+ if (entry.startupCleanup?.executionId === entry.executionId) entry.startupCleanup = null;
337
392
  return entry.process;
338
393
  }
339
394
 
340
- async function send(entry, input = {}, { renewal = false, command } = {}) {
395
+ async function send(entry, input = {}, { renewal = false, command, applicationCommand } = {}) {
341
396
  const message = command ? input.text : text(input.message || input.prompt);
342
397
  if (!command && !message) throw createError("Enter a message for Claude.");
343
398
  const context = entry.context;
@@ -386,8 +441,17 @@ export function createClaudeConversationOwner({
386
441
  entry.onEvent = context.onEvent;
387
442
  entry.lastMessageId = uuid;
388
443
  const actorMetadata = command ? undefined : await onEvent(entry, { type: "message-metadata", input, context });
444
+ const nativeOwner = applicationCommand ? { process: native, executionId: entry.executionId,
445
+ accountIdentity: entry.accountIdentity, threadId: entry.id, turnId: entry.turn.id } : null;
389
446
  let conversationTurn;
447
+ function deliveryResult() {
448
+ return { ok: true, delivered: true, deliveryMode: steering ? "steer" : "new_turn",
449
+ thread: { id: entry.id }, turn: snapshot(entry), workdir: context.workdir, conversationTurn };
450
+ }
390
451
  async function accept() {
452
+ // The new native ACK retires old effect custody while its canonical
453
+ // admission/commit is awaited; it cannot retag a delayed old failure.
454
+ if (applicationCommand) applicationCommand.active.nativeOwner = null;
391
455
  if (command) {
392
456
  if (!steering) await onEvent(entry, { type: "binding-admitted" });
393
457
  await command.accept();
@@ -401,6 +465,22 @@ export function createClaudeConversationOwner({
401
465
  if (admission) conversationTurn = await admission;
402
466
  }
403
467
  if (!command) await entry.nativeTurn.updateState("active");
468
+ if (applicationCommand && conversationTurn) {
469
+ const value = deliveryResult();
470
+ applicationCommand.active.delivery = { value, completion: entry.completion?.promise };
471
+ // The original canonical admission and publication precede Core's same
472
+ // accepted-request gate; native tools cannot race an unaccepted user.
473
+ await applicationCommand.accept({ nativeResult: { value }, conversationTurn, nativeTurnId: entry.turn?.id });
474
+ if (entry.process !== nativeOwner.process || entry.executionId !== nativeOwner.executionId ||
475
+ entry.accountIdentity !== nativeOwner.accountIdentity || entry.id !== nativeOwner.threadId ||
476
+ entry.turn?.id !== nativeOwner.turnId || entry.disposed || entry.stopping || entry.stopPending) {
477
+ throw new Error("Claude's admitted tools no longer belong to this native process.");
478
+ }
479
+ applicationCommand.active.nativeOwner = nativeOwner;
480
+ applicationCommand.active.messageId = messageId;
481
+ if (steering) applicationCommand.active.nativeToolCount = 0;
482
+ applicationCommand.active.admitted = true;
483
+ }
404
484
  }
405
485
  try {
406
486
  if (command) command.attempted = true;
@@ -411,8 +491,7 @@ export function createClaudeConversationOwner({
411
491
  if (!command) await entry.nativeTurn.stopProcess(error.message);
412
492
  throw error;
413
493
  }
414
- return { ok: true, delivered: true, deliveryMode: steering ? "steer" : "new_turn",
415
- thread: { id: entry.id }, turn: snapshot(entry), workdir: context.workdir, conversationTurn };
494
+ return deliveryResult();
416
495
  }
417
496
 
418
497
  async function startTurn(entry, input = {}) {
@@ -641,10 +720,19 @@ export function createClaudeConversationOwner({
641
720
  }
642
721
  command.signal.throwIfAborted();
643
722
  const entry = await acquire(command.context);
644
- const value = await send(entry, { ...command.input.nativeMessage, onPromptSending: command.beforeDispatch });
723
+ const active = control.current;
724
+ const applicationCommand = active?.tools ? { active, accept: command.accept } : null;
725
+ if (applicationCommand) {
726
+ if (entry.disposed || entry.command && entry.command !== active) throw new Error("This Claude conversation is closed or already working.");
727
+ if (active.entry && active.entry !== entry) throw new Error("Claude's application tools belong to another native conversation.");
728
+ active.entry = entry;
729
+ entry.command = active;
730
+ entry.toolSchemas = active.tools.schemas;
731
+ }
732
+ const value = await send(entry, { ...command.input.nativeMessage, onPromptSending: command.beforeDispatch }, { applicationCommand });
645
733
  const completion = value.conversationTurn ? entry.completion?.promise : null;
646
734
  const delivered = { value, completion };
647
- if (value.delivered === true && value.conversationTurn) {
735
+ if (!applicationCommand && value.delivered === true && value.conversationTurn) {
648
736
  if (control.current) control.current.delivery = delivered;
649
737
  await command.accept({ nativeResult: { value }, conversationTurn: value.conversationTurn, nativeTurnId: value.turn?.id });
650
738
  }
@@ -660,10 +748,10 @@ export function createClaudeConversationOwner({
660
748
  async function run(control, command, { acquire, reportFailure, entry: selectedEntry, maximumOutput, maximumToolCalls = 32, timeoutMs } = {}) {
661
749
  if (control.disposed || control.current) throw new Error("This Claude conversation is closed or already working.");
662
750
  const binding = Boolean(store.releaseExecution);
663
- const { configuration, context, input, tools, signal, beforeDispatch, accept, onMessage, onEvent } = command;
751
+ const { configuration, context, input, tools, signal, beforeDispatch, accept, onMessage, onEvent, onNativeToolUse } = command;
664
752
  const completion = Promise.withResolvers();
665
753
  completion.promise.catch(() => {});
666
- const active = { completion, onMessage, onEvent, signal, tools, admitted: false, nativeTools: new Map(),
754
+ const active = { completion, onMessage, onEvent, onNativeToolUse, signal, tools, admitted: false, nativeTools: new Map(),
667
755
  messageId: input.messageId, goal: input.goal, nativeToolCount: 0, toolWork: new Set(), maximumToolCalls,
668
756
  context, failure: Promise.withResolvers() };
669
757
  let failure;
@@ -734,12 +822,29 @@ export function createClaudeConversationOwner({
734
822
  if (binding) {
735
823
  try {
736
824
  if (problem || signal.aborted) await (cancellation || entry.nativeTurn.stopProcess(problem?.message || "Work stopped."));
737
- } catch (error) { problem = error; }
825
+ } catch (error) {
826
+ if (problem && error !== problem) error.cause ||= problem;
827
+ problem = error;
828
+ }
738
829
  finally {
739
830
  await Promise.allSettled([...active.toolWork]);
740
831
  if (entry?.command === active) entry.command = null;
741
832
  }
742
- } else await cancellation?.catch(() => {});
833
+ } else {
834
+ await cancellation?.catch(() => {});
835
+ if (tools && active.entry) {
836
+ try {
837
+ if (problem || signal.aborted) await active.entry.nativeTurn.stopProcess(problem?.message || "Work stopped.");
838
+ } catch (error) {
839
+ if (problem && error !== problem) error.cause ||= problem;
840
+ problem = error;
841
+ }
842
+ finally {
843
+ await Promise.allSettled([...active.toolWork]);
844
+ if (active.entry.command === active) active.entry.command = null;
845
+ }
846
+ }
847
+ }
743
848
  if (control.current === active) control.current = null;
744
849
  if (problem) {
745
850
  if (binding && nativeDispatch.attempted && !nativeDispatch.admitted) problem.delivery = "uncertain";
@@ -748,7 +853,7 @@ export function createClaudeConversationOwner({
748
853
  if (!binding) return { value: delivered.value };
749
854
  }
750
855
 
751
- return Object.freeze({ entries, closingSessions, get closing() { return closing; }, createAccountQueries, open, acquire, createConversation, snapshot, readRetainedTurn, readHistory, ensureReady, ensureProcess, stopForTerminal, prepareTerminal, send, startTurn,
856
+ return Object.freeze({ applicationToolsSupported: true, entries, closingSessions, get closing() { return closing; }, createAccountQueries, open, acquire, createConversation, snapshot, readRetainedTurn, readFinalAssistantResult, readHistory, ensureReady, ensureProcess, stopForTerminal, prepareTerminal, send, startTurn,
752
857
  interrupt, read, inspectAdmission, hasActiveTemporaryConversation, hasActiveConversation, retireConversationHistory, reconcileSessions, restoreSessionEntries, closeSession, closeProject, invalidateRuntimes, drain, forget, close, deleteConversation, recover, stopSelected, wait, stopConversation,
753
858
  readGoal, updateGoal, renewal, run, steer, cancel });
754
859
  }
@@ -773,6 +878,36 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
773
878
  let cancelling = false;
774
879
  let steering;
775
880
  let maximumOutput = 4 * 1024 * 1024;
881
+ let inputOwner = null;
882
+ let acceptedInput = null;
883
+ let publishedAssistant = null;
884
+ let finalAssistantResult = null;
885
+
886
+ function clearFinalAssistantResult() {
887
+ publishedAssistant = null;
888
+ finalAssistantResult = null;
889
+ }
890
+
891
+ function invalidateFinalAssistantResult() {
892
+ inputOwner = null;
893
+ acceptedInput = null;
894
+ clearFinalAssistantResult();
895
+ }
896
+
897
+ function currentReceiptOwner(owner) {
898
+ return Boolean(entry && owner && inputOwner === owner && acceptedInput === owner &&
899
+ owner.commandId === currentCommandId && entry.process && entry.process === owner.process &&
900
+ entry.executionId === owner.executionId && entry.accountIdentity === owner.accountIdentity &&
901
+ entry.turn?.id === turnId && !entry.stopping && !entry.stopPending && !entry.disposed &&
902
+ !entry.observationError && !entry.command?.toolWork.size && !entry.command?.toolFailure);
903
+ }
904
+
905
+ function readFinalAssistantResult(threadId, expectedTurnId) {
906
+ if (threadId !== conversationId || expectedTurnId !== turnId || !finalAssistantResult ||
907
+ active || stopped || cancelling || outcome?.status !== "completed" || outcome.error ||
908
+ inFlight.size || tasks.size || admissions.size || !currentReceiptOwner(acceptedInput)) return null;
909
+ return structuredClone(finalAssistantResult);
910
+ }
776
911
 
777
912
  function read() {
778
913
  return { turnId, active, text: result, phase, status: active ? "inProgress" : outcome?.status || "ready",
@@ -810,6 +945,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
810
945
  }
811
946
 
812
947
  async function stopProcess(reason = "") {
948
+ invalidateFinalAssistantResult();
813
949
  // Runtime bindings commit execution release before dropping the handle.
814
950
  // Retained host entries keep their original post-proof checkpoint order.
815
951
  const binding = Boolean(process.releaseExecution);
@@ -817,10 +953,11 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
817
953
  const active = read().active;
818
954
  if (!binding) entry.stopping = true;
819
955
  const operation = async () => {
956
+ const startup = entry.startupCleanup?.executionId === entry.executionId ? entry.startupCleanup : null;
820
957
  let proof;
821
958
  try {
822
959
  if (!binding || entry.process || entry.executionId) {
823
- proof = await stopClaudeCodeProcess({ process: entry.process, executionId: entry.executionId,
960
+ proof = await stopClaudeCodeProcess({ process: entry.process || startup, executionId: entry.executionId,
824
961
  stopExecution: process.stopExecution });
825
962
  }
826
963
  } catch (error) {
@@ -836,12 +973,25 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
836
973
  entry.exitProof = proof;
837
974
  stop(binding ? reason : reason || "Claude stopped before acknowledging the prompt.");
838
975
  if (binding) {
976
+ if (entry.startupCleanup === startup) entry.startupCleanup = null;
839
977
  if (active) entry.completion?.resolve({ status: "interrupted", error: reason || "Work stopped." });
840
978
  return;
841
979
  }
842
- if (entry.turn?.active) await updateState("interrupted", reason);
843
- else await onEvent({ type: "save" });
980
+ if (entry.turn?.active) {
981
+ try { await updateState("interrupted", reason); }
982
+ catch (error) {
983
+ // Native cleanup is verified even when the host refuses its checkpoint.
984
+ // Retain that refusal without publishing a completed answer or active work.
985
+ setState("interrupted", error.message);
986
+ await onEvent({ type: "save" });
987
+ await onEvent({ type: "state", state: "interrupted", active: false, message: error.message });
988
+ entry.completion?.resolve(readResult());
989
+ if (entry.startupCleanup === startup) entry.startupCleanup = null;
990
+ throw error;
991
+ }
992
+ } else await onEvent({ type: "save" });
844
993
  entry.completion?.resolve(readResult());
994
+ if (entry.startupCleanup === startup) entry.startupCleanup = null;
845
995
  return entry.exitProof;
846
996
  };
847
997
  if (!binding) return operation();
@@ -864,7 +1014,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
864
1014
  if (binding) setState(state, event.error);
865
1015
  else await updateState(state, event.error);
866
1016
  }
867
- await onEvent(event);
1017
+ const published = await onEvent(event);
868
1018
  if (event.type === "provider-event") {
869
1019
  const frame = event.event;
870
1020
  const change = frame.type === "active_goal" ? "goal" : frame.type === "rate_limit_event" ? "usage" : "";
@@ -874,6 +1024,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
874
1024
  if (event.source === "result") await onEvent({ type: "provider-update", change: "goal" });
875
1025
  entry.completion?.resolve(binding ? event : readResult());
876
1026
  }
1027
+ return published;
877
1028
  }
878
1029
 
879
1030
  function checkOutput(value, role = "assistant") {
@@ -884,7 +1035,16 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
884
1035
 
885
1036
  async function publishMessage(message) {
886
1037
  messages.set(message.id, message);
887
- await publish({ type: "message", message });
1038
+ const owner = acceptedInput;
1039
+ const completeAssistant = message.complete && message.role === "assistant";
1040
+ if (completeAssistant) clearFinalAssistantResult();
1041
+ const conversationTurn = await publish({ type: "message", message });
1042
+ if (completeAssistant && message.text && currentReceiptOwner(owner) && !stopped && !cancelling &&
1043
+ conversationTurn && typeof conversationTurn === "object" && !Array.isArray(conversationTurn)) {
1044
+ publishedAssistant = { owner, itemId: message.id, outputId: message.outputId || message.id,
1045
+ text: message.text, conversationTurn: structuredClone(conversationTurn) };
1046
+ }
1047
+ return conversationTurn;
888
1048
  }
889
1049
 
890
1050
  async function receive(frame) {
@@ -908,9 +1068,12 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
908
1068
  clearTimeout(pending.timer);
909
1069
  try {
910
1070
  await pending.accept();
1071
+ if (inputOwner === pending.receiptOwner && !stopped && !cancelling) acceptedInput = pending.receiptOwner;
911
1072
  admissions.delete(uuid);
912
1073
  pending.resolve();
913
1074
  } catch (error) {
1075
+ acceptedInput = null;
1076
+ clearFinalAssistantResult();
914
1077
  pending.reject(error);
915
1078
  throw error;
916
1079
  }
@@ -988,12 +1151,22 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
988
1151
  outcome && inFlight.size === 0 && tasks.size === 0) {
989
1152
  active = false;
990
1153
  phase = "";
991
- await publish({ type: "settled", ...read(), source: frame.type });
1154
+ const candidate = publishedAssistant;
1155
+ finalAssistantResult = outcome.status === "completed" && !outcome.error && !stopped && !cancelling &&
1156
+ admissions.size === 0 && candidate && currentReceiptOwner(candidate.owner) && candidate.text === result
1157
+ ? { threadId: conversationId, turnId, inputMessageId: candidate.owner.commandId,
1158
+ itemId: candidate.itemId, outputId: candidate.outputId, text: candidate.text,
1159
+ conversationTurn: candidate.conversationTurn } : null;
1160
+ try { await publish({ type: "settled", ...read(), source: frame.type }); }
1161
+ catch (error) { clearFinalAssistantResult(); throw error; }
992
1162
  }
993
1163
  }
994
1164
 
995
1165
  function stop(reason = "Claude stopped before acknowledging the prompt.") {
996
1166
  stopped = true;
1167
+ inputOwner = null;
1168
+ acceptedInput = null;
1169
+ clearFinalAssistantResult();
997
1170
  for (const pending of admissions.values()) {
998
1171
  pending.reject(failure(reason));
999
1172
  }
@@ -1008,12 +1181,13 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1008
1181
 
1009
1182
  async function interrupt(client) {
1010
1183
  cancelling = true;
1184
+ clearFinalAssistantResult();
1011
1185
  if (client) await client.interrupt();
1012
1186
  // The process host still must drain its owned scope and call stop().
1013
1187
  }
1014
1188
 
1015
1189
  return Object.freeze({
1016
- receive, read, stop, interrupt,
1190
+ receive, read, stop, interrupt, readFinalAssistantResult, invalidateFinalAssistantResult,
1017
1191
  ...(entry ? { readResult, updateState, stopProcess } : {}),
1018
1192
  ...(instructions ? {
1019
1193
  prepare: instructions.prepare,
@@ -1024,6 +1198,9 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1024
1198
  begin({ id, maxOutputCharacters = 4 * 1024 * 1024 } = {}) {
1025
1199
  if (active) throw failure("This Claude turn is still working.");
1026
1200
  if (!id || !Number.isSafeInteger(maxOutputCharacters) || maxOutputCharacters < 1) throw new TypeError("Invalid Claude turn configuration.");
1201
+ inputOwner = null;
1202
+ acceptedInput = null;
1203
+ clearFinalAssistantResult();
1027
1204
  turnId = id;
1028
1205
  maximumOutput = maxOutputCharacters;
1029
1206
  messages.clear();
@@ -1041,10 +1218,18 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1041
1218
  messages.clear();
1042
1219
  result = "";
1043
1220
  outcome = null;
1221
+ inputOwner = null;
1222
+ acceptedInput = null;
1223
+ clearFinalAssistantResult();
1044
1224
  },
1045
1225
  async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000 } = {}) {
1046
1226
  if (!active || stopped || cancelling) throw failure("Claude has no active turn for this message.");
1047
1227
  if (!uuid || typeof accept !== "function" || admissions.has(uuid)) throw new TypeError("A distinct message id and admission writer are required.");
1228
+ clearFinalAssistantResult();
1229
+ acceptedInput = null;
1230
+ inputOwner = entry ? { commandId: uuid, process: entry.process, executionId: entry.executionId,
1231
+ accountIdentity: entry.accountIdentity } : null;
1232
+ const receiptOwner = inputOwner;
1048
1233
  const admitted = Promise.withResolvers();
1049
1234
  void admitted.promise.catch(() => {});
1050
1235
  inFlight.add(uuid);
@@ -1052,9 +1237,12 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1052
1237
  failure("Claude has not acknowledged this prompt. Its delivery is uncertain.", "assistant_claude_admission_unknown"),
1053
1238
  { delivery: "uncertain" }
1054
1239
  )), timeoutMs);
1055
- admissions.set(uuid, { ...admitted, accept, timer });
1240
+ admissions.set(uuid, { ...admitted, accept, timer, receiptOwner });
1056
1241
  try {
1057
1242
  await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId }), admitted.promise]);
1243
+ } catch (error) {
1244
+ if (inputOwner === receiptOwner) invalidateFinalAssistantResult();
1245
+ throw error;
1058
1246
  } finally {
1059
1247
  clearTimeout(timer);
1060
1248
  }
@@ -1064,6 +1252,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1064
1252
  // Streaming user input otherwise queues behind the entire running turn.
1065
1253
  // Claude's supported steering path interrupts generation, then continues
1066
1254
  // the same native history with the new instruction.
1255
+ acceptedInput = null;
1256
+ clearFinalAssistantResult();
1067
1257
  steering = Promise.withResolvers();
1068
1258
  let timer;
1069
1259
  try {
@@ -943,7 +943,7 @@ export function codexProviderModelCatalog({ models }) {
943
943
  supports_reasoning_summaries: false,
944
944
  default_reasoning_summary: "none",
945
945
  support_verbosity: false,
946
- truncation_policy: { mode: "bytes", limit: 10000 },
946
+ truncation_policy: { mode: "tokens", limit: 10000 },
947
947
  context_window: model.contextWindow,
948
948
  max_context_window: model.contextWindow,
949
949
  effective_context_window_percent: 95,
@@ -1,4 +1,5 @@
1
1
  import { open, realpath } from "node:fs/promises";
2
+ import { isUtf8 } from "node:buffer";
2
3
  import { createServer } from "node:http";
3
4
  import path from "node:path";
4
5
  import { Readable } from "node:stream";
@@ -120,8 +121,9 @@ function retainedUserRecord(item) {
120
121
  }
121
122
 
122
123
  // Scan a fixed snapshot without retaining the lifetime transcript. A single
123
- // record still has a transport-sized bound; incomplete native appends are ignored.
124
- async function* readCodexHistoryRows(file, start, end, signal) {
124
+ // record still has a transport-sized bound; recovery ignores incomplete appends.
125
+ // Stopped offline inspection can require complete rows without changing recovery.
126
+ async function* readCodexHistoryRows(file, start, end, signal, { strictComplete = false } = {}) {
125
127
  const chunk = Buffer.alloc(64 * 1024);
126
128
  let pending = [];
127
129
  let size = 0;
@@ -141,7 +143,11 @@ async function* readCodexHistoryRows(file, start, end, signal) {
141
143
  pending.push(Buffer.from(chunk.subarray(from, until)));
142
144
  if (complete) {
143
145
  signal.throwIfAborted();
144
- if (size) yield { row: JSON.parse(Buffer.concat(pending, size).toString("utf8")), offset: lineStart };
146
+ if (size) {
147
+ const bytes = Buffer.concat(pending, size);
148
+ if (strictComplete && !isUtf8(bytes)) throw compactionHistoryError("the saved history contains invalid UTF-8.");
149
+ yield { row: JSON.parse(bytes.toString("utf8")), offset: lineStart };
150
+ }
145
151
  pending = [];
146
152
  size = 0;
147
153
  lineStart = offset + newline + 1;
@@ -150,6 +156,7 @@ async function* readCodexHistoryRows(file, start, end, signal) {
150
156
  }
151
157
  offset += bytesRead;
152
158
  }
159
+ if (strictComplete && size) throw compactionHistoryError("the saved history has an incomplete final record.");
153
160
  }
154
161
 
155
162
  // Native compaction keeps the original rollout on disk. Foreign providers
@@ -456,4 +463,4 @@ async function prepareCodexHistory(params, client, { baseUrl, modelProviderId, .
456
463
  } };
457
464
  }
458
465
 
459
- export { startCodexHistoryAdapter, translateCodexHistory, prepareCodexHistory };
466
+ export { startCodexHistoryAdapter, translateCodexHistory, prepareCodexHistory, readCodexHistoryRows };
@@ -40,8 +40,7 @@ export function createCodexAppServerNotificationQueue({
40
40
  });
41
41
  }
42
42
 
43
- async function drainCodexAppServerNotificationTasks(sessionId = "") {
44
- const taskSessionKey = namespace(sessionId);
43
+ async function drainCodexAppServerNotificationTasksForKey(taskSessionKey) {
45
44
  while (true) {
46
45
  const task = codexAppServerNotificationTasks.get(taskSessionKey);
47
46
  if (!task) {
@@ -51,6 +50,15 @@ export function createCodexAppServerNotificationQueue({
51
50
  }
52
51
  }
53
52
 
53
+ async function drainCodexAppServerNotificationTasks(sessionId = "") {
54
+ return drainCodexAppServerNotificationTasksForKey(namespace(sessionId));
55
+ }
56
+
57
+ function captureCodexAppServerNotificationDrain(sessionId = "") {
58
+ const taskSessionKey = namespace(sessionId);
59
+ return () => drainCodexAppServerNotificationTasksForKey(taskSessionKey);
60
+ }
61
+
54
62
  function queueCodexAppServerStream(context, classification, deliver) {
55
63
  if (isClosing() || !classification.itemId || !classification.turnId) return;
56
64
  const pending = codexAppServerPendingStreams.get(context.sessionKey);
@@ -97,6 +105,7 @@ export function createCodexAppServerNotificationQueue({
97
105
  pendingReasoning: codexAppServerPendingReasoning,
98
106
  run: runCodexAppServerNotificationTask,
99
107
  drain: drainCodexAppServerNotificationTasks,
108
+ captureDrain: captureCodexAppServerNotificationDrain,
100
109
  stream: queueCodexAppServerStream,
101
110
  reasoning: queueCodexAppServerReasoning
102
111
  };
@@ -39,19 +39,19 @@ export function createCodexObservationControl({
39
39
  function createCodexAppServerObservation(sessionId, context = {}) {
40
40
  const { projectContext, target, retireProvider } = context;
41
41
  return {
42
- prepare({ provider, error }) {
42
+ prepare({ provider, error, threadIds }) {
43
43
  return runInContext(projectContext, async () => {
44
44
  const managed = target(error);
45
45
  const threads = new Map();
46
46
  for (const entry of codexAppServerConversations.get(namespace(sessionId))?.values() || []) {
47
- if (entry.provider !== provider) continue;
47
+ if (entry.provider !== provider || threadIds && !threadIds.includes(entry.conversationId)) continue;
48
48
  threads.set(entry.conversationId, entry.runId);
49
49
  if (codexAppServerConversationTurnIsActive(entry.status) || entry.goal?.status === "active") {
50
50
  entry.error = managed.pendingMessage;
51
51
  }
52
52
  }
53
53
  // A helper owns only its own threads. Main chat belongs to its managed provider.
54
- if (managed.threadId) {
54
+ if (managed.threadId && (!threadIds || threadIds.includes(managed.threadId))) {
55
55
  const runtime = await createRuntime();
56
56
  const turn = turnState(await runtime.getSession(sessionId));
57
57
  threads.set(managed.threadId, turn.turnId);
@@ -66,10 +66,10 @@ export function createCodexObservationControl({
66
66
  return threads;
67
67
  });
68
68
  },
69
- barrier({ error }) {
69
+ barrier({ error, threadIds }) {
70
70
  return runInContext(projectContext, async () => {
71
71
  const managed = target(error);
72
- if (!managed.threadId) return;
72
+ if (!managed.threadId || threadIds && !threadIds.includes(managed.threadId)) return;
73
73
  const runtime = await createRuntime();
74
74
  const turn = turnState(await runtime.getSession(sessionId));
75
75
  if (!turn.active && !codexAppServerTurnOwnsActiveGoal(turn, managed.threadId)) return;
@@ -79,12 +79,12 @@ export function createCodexObservationControl({
79
79
  });
80
80
  });
81
81
  },
82
- complete({ provider, error, sharedStop }) {
82
+ complete({ provider, error, sharedStop, threadIds }) {
83
83
  return runInContext(projectContext, async () => {
84
84
  const managed = target(error);
85
85
  let stoppedRun = null;
86
86
  let conversationStream = null;
87
- if (managed.threadId) {
87
+ if (managed.threadId && (!threadIds || threadIds.includes(managed.threadId))) {
88
88
  const runtime = await createRuntime();
89
89
  stoppedRun = await runtime.store.mutateSession(sessionId, async () => {
90
90
  const run = await readCodexAppServerAgentRunForSession(runtime.store, sessionId);
@@ -105,7 +105,7 @@ export function createCodexObservationControl({
105
105
  if (sharedStop) await retireProvider(provider);
106
106
  else provider.observationFailure = null;
107
107
  for (const entry of codexAppServerConversations.get(namespace(sessionId))?.values() || []) {
108
- if (entry.provider === provider && (
108
+ if (entry.provider === provider && (!threadIds || threadIds.includes(entry.conversationId)) && (
109
109
  codexAppServerConversationTurnIsActive(entry.status) || entry.goal?.status === "active"
110
110
  )) {
111
111
  entry.status = "interrupted";