@jskit-ai/assistant-core 0.1.205 → 0.1.206

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 (39) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/App.vue +12 -2
  3. package/package.json +19 -9
  4. package/src/client/conversation/AssistantTranscript.vue +177 -16
  5. package/src/server/conversation/claudeTurn.js +16 -7
  6. package/src/server/conversation/codexConversations.js +94 -0
  7. package/src/server/conversation/codexProvider.js +15 -5
  8. package/src/server/conversation/codexProviderOwner.js +70 -47
  9. package/src/server/conversation/codexProviderSelection.js +5 -2
  10. package/src/server/conversation/codexRuntimeLifecycle.js +2 -1
  11. package/src/server/conversation/codexTurn.js +10 -1
  12. package/src/server/conversation/codexTurnControl.js +1 -1
  13. package/src/server/conversation/codexTurnRecovery.js +21 -8
  14. package/src/server/conversation/openCodeRuntime.js +107 -64
  15. package/src/server/conversation/openCodeTurn.js +33 -16
  16. package/src/server/conversation/providers/api.js +1 -0
  17. package/src/server/conversation/providers/claudeDriver.js +13 -0
  18. package/src/server/conversation/providers/codex.js +90 -47
  19. package/src/server/conversation/providers/codexDriver.js +4 -1
  20. package/src/server/conversation/providers/opencodeDriver.js +3 -0
  21. package/src/server/conversation/runtime.js +32 -5
  22. package/src/server/hosts/codexEvents.js +15 -0
  23. package/src/server/hosts/codexTurn.js +5 -0
  24. package/src/server/hosts/openCodeClient.js +4 -0
  25. package/src/server/lib/aiConnectionClient.js +9 -11
  26. package/test/aiConnectionClient.test.js +209 -0
  27. package/test/assistantScroll.browser.test.js +255 -1
  28. package/test/claudeConversationSession.test.js +78 -0
  29. package/test/claudeTurn.test.js +18 -0
  30. package/test/codexConversationSession.test.js +318 -0
  31. package/test/codexTurn.test.js +203 -2
  32. package/test/conversationConfiguration.test.js +27 -0
  33. package/test/conversationReplacement.test.js +76 -1
  34. package/test/conversationRuntime.test.js +173 -4
  35. package/test/conversationSession.test.js +139 -0
  36. package/test/conversationTools.test.js +17 -3
  37. package/test/opencodeConversationSession.test.js +200 -1
  38. package/test/packageMetadata.test.js +75 -0
  39. package/test/serviceToolCatalog.test.js +31 -0
@@ -1279,6 +1279,10 @@ export class CodexAppServerAgentProvider {
1279
1279
  prepareHistory: (params, client, context) => this.withHistoryAdapter(params, client, context),
1280
1280
  threadParameters: codexAppServerThreadRequestParams,
1281
1281
  verifyEnvironment: (...args) => this.#verifyThreadEnvironment(...args),
1282
+ recordInterruption: (threadId, turnId) => {
1283
+ const key = JSON.stringify([threadId, turnId]);
1284
+ this.interruptedTurns.set(key, this.interruptedTurns.get(key) || "control_reconfiguration");
1285
+ },
1282
1286
  onRecoveryFailure: (id) => {
1283
1287
  const probe = this.threadControlProbes.get(id);
1284
1288
  if (probe && !probe.verified) {
@@ -1292,7 +1296,7 @@ export class CodexAppServerAgentProvider {
1292
1296
  });
1293
1297
  this.threadControlProbes = new Map();
1294
1298
  this.commandExecutions = new Map();
1295
- this.interruptedTurns = new Set();
1299
+ this.interruptedTurns = new Map();
1296
1300
  this.commandStopFailure = null;
1297
1301
  }
1298
1302
 
@@ -2574,16 +2578,22 @@ export class CodexAppServerAgentProvider {
2574
2578
  };
2575
2579
  }
2576
2580
 
2577
- async interruptTurn(threadId = "", turnId = "") {
2581
+ interruptionOutcome(threadId = "", turnId = "") {
2582
+ const key = JSON.stringify([normalizeAgentText(threadId), normalizeAgentText(turnId)]);
2583
+ return this.interruptedTurns.get(key) || "";
2584
+ }
2585
+
2586
+ async interruptTurn(threadId = "", turnId = "", { outcome = "" } = {}) {
2578
2587
  const client = await this.activeClient();
2579
2588
  return this.runRequest(
2580
- () => this.#interruptTurn(client, normalizeAgentText(threadId), normalizeAgentText(turnId)),
2589
+ () => this.#interruptTurn(client, normalizeAgentText(threadId), normalizeAgentText(turnId), undefined, outcome),
2581
2590
  "codex-app-server-turn-interrupt"
2582
2591
  );
2583
2592
  }
2584
2593
 
2585
- async #interruptTurn(client, threadId, turnId, signal) {
2586
- this.interruptedTurns.add(JSON.stringify([threadId, turnId]));
2594
+ async #interruptTurn(client, threadId, turnId, signal, outcome = "") {
2595
+ const key = JSON.stringify([threadId, turnId]);
2596
+ this.interruptedTurns.set(key, outcome || this.interruptedTurns.get(key) || "");
2587
2597
  const result = await client.request("turn/interrupt", { threadId, turnId }, { signal });
2588
2598
  await Promise.all([...this.commandExecutions.values()]
2589
2599
  .filter((command) => command.threadId === threadId && command.turnId === turnId)
@@ -8,7 +8,7 @@ import { codexAppServerProjectHookTrustConfig } from "./codexConfiguration.js";
8
8
  // inspect the complete inventory by native runtime identity.
9
9
  const codexAppServerProviders = new Map();
10
10
  const codexAppServerOwnedRuntimes = new Map();
11
- let codexAppServerProviderLifecycle = Promise.resolve();
11
+ const codexAppServerRuntimeLifecycles = new Map();
12
12
  const normalizeText = value => String(value || "").trim();
13
13
 
14
14
  export function codexAppServerOwnedRuntimeKey(providerKey = "", providerOptions = {}) {
@@ -18,6 +18,18 @@ export function codexAppServerOwnedRuntimeKey(providerKey = "", providerOptions
18
18
  : `provider:${normalizeText(providerKey)}`;
19
19
  }
20
20
 
21
+ function withRuntimeLifecycle(runtimeKey, operation) {
22
+ const previous = codexAppServerRuntimeLifecycles.get(runtimeKey) || Promise.resolve();
23
+ const run = previous.catch(() => null).then(operation);
24
+ const tail = run.catch(() => null).finally(() => {
25
+ if (codexAppServerRuntimeLifecycles.get(runtimeKey) === tail) {
26
+ codexAppServerRuntimeLifecycles.delete(runtimeKey);
27
+ }
28
+ });
29
+ codexAppServerRuntimeLifecycles.set(runtimeKey, tail);
30
+ return run;
31
+ }
32
+
21
33
  export function codexAppServerRuntimeStopWasVerified(result = {}) {
22
34
  return result?.stopped === true ||
23
35
  result?.processExitVerified === true ||
@@ -57,6 +69,7 @@ export function createCodexAppServerProviderOwner({
57
69
  const records = new Map();
58
70
  const lifecycleTasks = new Set();
59
71
  const runtimeAcquisitions = new Set();
72
+ let codexAppServerProviderLifecycle = Promise.resolve();
60
73
  let closing = false;
61
74
  let runtimeLifecycle = null;
62
75
  let codexAppServerChatModelCatalog = null;
@@ -99,8 +112,12 @@ export function createCodexAppServerProviderOwner({
99
112
  return relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative);
100
113
  }
101
114
 
102
- function withLifecycle(operation) {
103
- const run = codexAppServerProviderLifecycle.catch(() => null).then(operation);
115
+ function withLifecycle(operation, providerKey = "", providerOptions = null) {
116
+ const run = codexAppServerProviderLifecycle.catch(() => null).then(() => {
117
+ if (!providerKey && !providerOptions) return operation();
118
+ const options = providerOptions || records.get(normalizeText(providerKey))?.providerOptions;
119
+ return withRuntimeLifecycle(codexAppServerOwnedRuntimeKey(providerKey, options), operation);
120
+ });
104
121
  const tracked = run.catch(() => null).finally(() => {
105
122
  lifecycleTasks.delete(tracked);
106
123
  });
@@ -259,12 +276,12 @@ export function createCodexAppServerProviderOwner({
259
276
  async function ensureSession(context, mainThreadId = "") {
260
277
  const sessionId = context.sessionId;
261
278
  const providerOptions = context.providerOptions;
262
- let provider = await withLifecycle(() => providerForSession(context));
279
+ const providerKey = context.providerKey;
280
+ let provider = await withLifecycle(() => providerForSession(context), providerKey, providerOptions);
263
281
  if (provider.observationFailure) {
264
282
  await provider.failObservation(provider.observationFailure);
265
- provider = await withLifecycle(() => providerForSession(context));
283
+ provider = await withLifecycle(() => providerForSession(context), providerKey, providerOptions);
266
284
  }
267
- const providerKey = context.providerKey;
268
285
  if (mainThreadId) {
269
286
  const fields = context.keyFields(providerKey);
270
287
  context.runOwner.runtimeLifecycle.rememberManagedSession(providerKey, {
@@ -425,7 +442,8 @@ export function createCodexAppServerProviderOwner({
425
442
 
426
443
  function stopCachedProvider(providerKey = "", options = {}) {
427
444
  return withLifecycle(
428
- () => stopCachedProviderUnlocked(providerKey, options)
445
+ () => stopCachedProviderUnlocked(providerKey, options),
446
+ normalizeText(providerKey)
429
447
  );
430
448
  }
431
449
 
@@ -496,47 +514,50 @@ export function createCodexAppServerProviderOwner({
496
514
  assertOpen();
497
515
  signal?.throwIfAborted();
498
516
  const providerOptions = await prepareProviderOptions();
499
- const existing = codexAppServerOwnedRuntimes.get(
500
- codexAppServerOwnedRuntimeKey("", providerOptions)
501
- );
502
- const provider = existing?.provider || providerFactory(providerOptions);
503
- let runtime = null;
504
- let catalog = null;
505
- let identity = "";
506
- try {
507
- identity = JSON.stringify(await provider.currentRuntimeInfo());
508
- signal?.throwIfAborted();
509
- if (codexAppServerChatModelCatalog?.identity === identity &&
510
- codexAppServerChatModelCatalog.expiresAt > Date.now()) {
511
- return codexAppServerChatModelCatalog.value;
512
- }
513
- if (!existing?.provider) {
514
- runtime = await acquireRuntime({
515
- operation: () => provider.ensureRuntime(),
516
- provider,
517
- providerOptions
518
- });
519
- }
520
- catalog = await provider.listModels({ includeHidden: false, limit: 100 }, { signal });
521
- } finally {
522
- if (!existing?.provider) {
523
- try {
524
- if ((runtime || provider.runtime)?.reused === false) {
525
- await stopOwnedRuntime({ provider, resources });
526
- } else {
527
- forgetOwnedRuntime(provider);
517
+ return withRuntimeLifecycle(codexAppServerOwnedRuntimeKey("", providerOptions), async () => {
518
+ const existing = codexAppServerOwnedRuntimes.get(
519
+ codexAppServerOwnedRuntimeKey("", providerOptions)
520
+ );
521
+ const provider = existing?.provider || providerFactory(providerOptions);
522
+ let runtime = null;
523
+ let catalog = null;
524
+ let identity = "";
525
+ try {
526
+ assertOpen();
527
+ identity = JSON.stringify(await provider.currentRuntimeInfo());
528
+ signal?.throwIfAborted();
529
+ if (codexAppServerChatModelCatalog?.identity === identity &&
530
+ codexAppServerChatModelCatalog.expiresAt > Date.now()) {
531
+ return codexAppServerChatModelCatalog.value;
532
+ }
533
+ if (!existing?.provider) {
534
+ runtime = await acquireRuntime({
535
+ operation: () => provider.ensureRuntime(),
536
+ provider,
537
+ providerOptions
538
+ });
539
+ }
540
+ catalog = await provider.listModels({ includeHidden: false, limit: 100 }, { signal });
541
+ } finally {
542
+ if (!existing?.provider) {
543
+ try {
544
+ if ((runtime || provider.runtime)?.reused === false) {
545
+ await stopOwnedRuntime({ provider, resources });
546
+ } else {
547
+ forgetOwnedRuntime(provider);
548
+ }
549
+ } finally {
550
+ provider.close();
528
551
  }
529
- } finally {
530
- provider.close();
531
552
  }
532
553
  }
533
- }
534
- codexAppServerChatModelCatalog = {
535
- expiresAt: Date.now() + cacheMs,
536
- identity,
537
- value: catalog
538
- };
539
- return catalog;
554
+ codexAppServerChatModelCatalog = {
555
+ expiresAt: Date.now() + cacheMs,
556
+ identity,
557
+ value: catalog
558
+ };
559
+ return catalog;
560
+ });
540
561
  });
541
562
  }
542
563
 
@@ -939,8 +960,10 @@ export function createCodexAppServerProviderOwner({
939
960
  return withLifecycle(async () => {
940
961
  const prepared = preparation.read();
941
962
  if (Object.hasOwn(prepared, "value")) return prepared.value;
942
- const evidence = await releasePersistedRuntimeProof(prepared.runtimeOptions, prepared);
943
- return prepared.complete(evidence);
963
+ return withRuntimeLifecycle(codexAppServerOwnedRuntimeKey("", prepared.runtimeOptions), async () => {
964
+ const evidence = await releasePersistedRuntimeProof(prepared.runtimeOptions, prepared);
965
+ return prepared.complete(evidence);
966
+ });
944
967
  });
945
968
  }
946
969
 
@@ -196,8 +196,11 @@ export function createCodexProviderSelection({
196
196
  restoration.projectRuntimeRoot, restoration.context
197
197
  ));
198
198
  const providerOptions = await preparation.providerOptions;
199
- const provider = await providerSessions.owner.withLifecycle(() =>
200
- providerSessions.owner.providerForSession(providerSessions.context(sessionId, providerOptions))
199
+ const context = providerSessions.context(sessionId, providerOptions);
200
+ const provider = await providerSessions.owner.withLifecycle(
201
+ () => providerSessions.owner.providerForSession(context),
202
+ context.providerKey,
203
+ providerOptions
201
204
  );
202
205
  return readCodexAppServerAccountIdentity(provider, helperOwnershipError);
203
206
  }
@@ -50,7 +50,8 @@ export function createCodexRuntimeLifecycle({
50
50
 
51
51
  function retireAndCloseCodexAppServerProvider(providerKey = "", options = {}) {
52
52
  return providerSessions.owner.withLifecycle(
53
- () => retireAndCloseCodexAppServerProviderUnlocked(providerKey, options)
53
+ () => retireAndCloseCodexAppServerProviderUnlocked(providerKey, options),
54
+ normalizeText(providerKey)
54
55
  );
55
56
  }
56
57
 
@@ -118,7 +118,14 @@ export function createCodexAppServerRunOwner({
118
118
  providerSessions = { ...providerSessions, managed: providerSessions.managed || new Map() };
119
119
  }
120
120
  const liveProgressMaxLength = 320;
121
- const turnOutcomes = { PROVIDER_FAILURE: "provider_failure", RESPONSE_DELIVERY_FAILURE: "response_delivery_failure", SERVICE_RESTART: "service_restart", USER_CANCELLED: "user_cancelled" };
121
+ const turnOutcomes = {
122
+ PROVIDER_FAILURE: "provider_failure",
123
+ RESPONSE_DELIVERY_FAILURE: "response_delivery_failure",
124
+ SERVICE_RESTART: "service_restart",
125
+ USER_CANCELLED: "user_cancelled",
126
+ CONTROL_RECONFIGURATION: "control_reconfiguration",
127
+ INTERRUPTED: "interrupted"
128
+ };
122
129
  const finalizingGraceMs = 10000;
123
130
  const snapshotRecoveryItemLimit = 25;
124
131
  const helperOwnershipError = createCodexHelperOwnershipError({ errorPrefix });
@@ -398,6 +405,7 @@ export function createCodexAppServerRunOwner({
398
405
  codexAppServerConversationContext,
399
406
  codexAppServerEphemeralScopeContext,
400
407
  createCodexAppServerConversation,
408
+ runDetachedCodexAppServerConversation,
401
409
  startCodexAppServerConversationTurn,
402
410
  readCodexAppServerConversation,
403
411
  waitForCodexAppServerConversationTurn,
@@ -580,6 +588,7 @@ export function createCodexAppServerRunOwner({
580
588
  observeConversation: observeCodexConversation,
581
589
  readPersistentConversation: readPersistentCodexConversation,
582
590
  createConversation: createCodexAppServerConversation,
591
+ runDetachedConversation: runDetachedCodexAppServerConversation,
583
592
  steerConversationTurn: steerCodexAppServerConversationTurn,
584
593
  startConversationTurn: startCodexAppServerConversationTurn,
585
594
  acquireDetachedThread: acquireCodexAppServerDetachedThread,
@@ -185,7 +185,7 @@ export function createCodexTurnControl({
185
185
  return { value: codexAppServerFrozenTurnInterruptResponse({ threadId, turnId }) };
186
186
  }
187
187
  try {
188
- result = await provider.interruptTurn(threadId, turnId);
188
+ result = await provider.interruptTurn(threadId, turnId, { outcome: turnOutcomes.USER_CANCELLED });
189
189
  } catch (error) {
190
190
  if (error?.code === `${provider?.errorPrefix ?? errorPrefix}codex_command_stop_unconfirmed`) throw error;
191
191
  requestError = error;
@@ -812,11 +812,14 @@ export function createCodexTurnRecovery({
812
812
  );
813
813
  }
814
814
 
815
- function codexAppServerStoppedTurnMessage(status = "", error = "") {
815
+ function codexAppServerStoppedTurnMessage(status = "", error = "", outcome = "") {
816
816
  const normalizedStatus = normalizeCodexRunText(status);
817
- const base = normalizedStatus === "interrupted"
818
- ? "Codex app-server was interrupted before completing this turn."
819
- : "Codex app-server failed before completing this turn.";
817
+ let base = "Codex app-server failed before completing this turn.";
818
+ if (normalizedStatus === "interrupted") {
819
+ base = outcome === turnOutcomes.CONTROL_RECONFIGURATION
820
+ ? "Codex app-server was interrupted while its controls were restored."
821
+ : "Codex app-server was interrupted before completing this turn.";
822
+ }
820
823
  const normalizedError = normalizeCodexRunText(error);
821
824
  return normalizedError ? `${base} ${normalizedError}` : base;
822
825
  }
@@ -824,7 +827,7 @@ export function createCodexTurnRecovery({
824
827
  async function stopCodexAppServerTurnWithProviderFailure(sessionId = "", threadId = "", turnId = "", {
825
828
  error = "",
826
829
  ok = false,
827
- outcome = turnOutcomes.PROVIDER_FAILURE,
830
+ outcome = "",
828
831
  provider = null,
829
832
  status = "failed",
830
833
  usageLimitExceeded = false,
@@ -834,6 +837,16 @@ export function createCodexTurnRecovery({
834
837
  const normalizedThreadId = normalizeCodexRunText(threadId);
835
838
  const normalizedTurnId = normalizeCodexRunText(turnId);
836
839
  const normalizedStatus = normalizeCodexRunText(status) || "failed";
840
+ let resolvedOutcome = normalizeCodexRunText(outcome);
841
+ if (!resolvedOutcome) {
842
+ if (normalizedStatus === "interrupted") {
843
+ const recorded = provider?.interruptionOutcome?.(normalizedThreadId, normalizedTurnId);
844
+ resolvedOutcome = [turnOutcomes.USER_CANCELLED, turnOutcomes.CONTROL_RECONFIGURATION].includes(recorded)
845
+ ? recorded : turnOutcomes.INTERRUPTED;
846
+ } else {
847
+ resolvedOutcome = turnOutcomes.PROVIDER_FAILURE;
848
+ }
849
+ }
837
850
  const runtime = await createRuntime();
838
851
  const session = await runtime.getSession(normalizedSessionId);
839
852
  const turn = turnState(session);
@@ -875,13 +888,13 @@ export function createCodexTurnRecovery({
875
888
  status: "inProgress"
876
889
  };
877
890
  }
878
- const message = codexAppServerStoppedTurnMessage(normalizedStatus, error);
891
+ const message = codexAppServerStoppedTurnMessage(normalizedStatus, error, resolvedOutcome);
879
892
  await outcomeNotice(
880
893
  runtime,
881
894
  normalizedSessionId,
882
895
  normalizedThreadId,
883
896
  normalizedTurnId,
884
- outcome,
897
+ resolvedOutcome,
885
898
  error,
886
899
  { usageLimitExceeded }
887
900
  );
@@ -890,7 +903,7 @@ export function createCodexTurnRecovery({
890
903
  status: normalizedStatus,
891
904
  threadId: normalizedThreadId,
892
905
  turnId: normalizedTurnId,
893
- turnOutcome: outcome
906
+ turnOutcome: resolvedOutcome
894
907
  });
895
908
  cleanupCodexAppServerUntrackedTurn(normalizedThreadId, normalizedTurnId);
896
909
  return {
@@ -994,7 +994,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
994
994
  currentTurn.state = "interrupted";
995
995
  await application.writeRun(currentTurn, "interrupted", currentTurn.observationError);
996
996
  }
997
- const currentMonitor = monitors.get(key);
997
+ let currentMonitor = monitors.get(key);
998
998
  let currentThreadId = input.threadId;
999
999
  const ownershipMatchesTurn = Boolean(
1000
1000
  currentMonitor &&
@@ -1030,6 +1030,9 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1030
1030
  let target = null;
1031
1031
  let admission = null;
1032
1032
  let dispatchTurn = null;
1033
+ let dispatchMonitor = null;
1034
+ let previousInputMessageId = null;
1035
+ let previousAdmission;
1033
1036
  try {
1034
1037
  const preparation = await application.prepare({ overwrite: !currentMonitor, threadId: currentThreadId });
1035
1038
  const preparedTarget = preparation.process ? await acquirePrepared(preparation.process) : preparation.target;
@@ -1039,15 +1042,33 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1039
1042
  startingTurn.threadId = currentThreadId;
1040
1043
  }
1041
1044
  const prompt = await application.prompt();
1045
+ // Preparation can outlive the preceding turn's final projection. Do not
1046
+ // attach a new input to a monitor that has already claimed retirement.
1047
+ currentMonitor = monitors.get(key);
1048
+ if (currentMonitor && !turns.get(key)?.active) {
1049
+ await currentMonitor;
1050
+ currentMonitor = monitors.get(key);
1051
+ }
1052
+ admission = Promise.withResolvers();
1053
+ // The observer may be between polls when dispatch or persistence fails.
1054
+ // Keep the raw promise rejectable without an unhandled rejection.
1055
+ void admission.promise.catch(() => {});
1042
1056
  let eventReady = null;
1043
- if (!currentMonitor) {
1044
- admission = Promise.withResolvers();
1057
+ if (currentMonitor) {
1058
+ dispatchTurn = turns.get(key);
1059
+ previousInputMessageId = dispatchTurn.inputMessageId;
1060
+ previousAdmission = dispatchTurn.admission;
1061
+ dispatchTurn.admission = admission;
1062
+ dispatchTurn.inputMessageId = providerMessageId;
1063
+ dispatchTurn.updatedAt = new Date().toISOString();
1064
+ dispatchMonitor = currentMonitor;
1065
+ } else {
1045
1066
  eventReady = Promise.withResolvers();
1046
- beginMessageMonitor(key, target, { id: providerMessageId, eventStartedAt, startedAt }, {
1067
+ dispatchMonitor = beginMessageMonitor(key, target, { id: providerMessageId, eventStartedAt, startedAt }, {
1047
1068
  ...options, admission, eventReady
1048
1069
  }, application.monitor);
1070
+ dispatchTurn = turns.get(key);
1049
1071
  }
1050
- dispatchTurn = turns.get(key);
1051
1072
  admitted = await dispatchOpenCodeTurn(null, null, dispatchTurn, {
1052
1073
  get agent() { return prompt.agent; },
1053
1074
  delivery: currentMonitor ? "steer" : "queue",
@@ -1070,11 +1091,23 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1070
1091
  authorizeAttachments: prompt.authorizeAttachments
1071
1092
  });
1072
1093
  } catch (error) {
1073
- if (error?.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 408) {
1074
- await input.onPromptRejected?.();
1094
+ const rejected = error?.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 408;
1095
+ const retainPreviousInput = previousInputMessageId !== null &&
1096
+ (dispatchTurn?.promptAttempted !== true || rejected);
1097
+ if (retainPreviousInput) {
1098
+ if (dispatchTurn.admission === admission && dispatchTurn.inputMessageId === providerMessageId) {
1099
+ dispatchTurn.inputMessageId = previousInputMessageId;
1100
+ dispatchTurn.admission = previousAdmission;
1101
+ }
1102
+ admission.resolve();
1103
+ } else {
1104
+ // Attempted unknown admission must retain the new input and use the
1105
+ // same observer's original Stop/error recovery, never roll back to A.
1106
+ admission?.reject(error);
1107
+ dispatchTurn?.abortController?.abort(error);
1075
1108
  }
1076
- admission?.reject(error);
1077
- if (admission) await monitors.get(key);
1109
+ if (rejected) await input.onPromptRejected?.();
1110
+ if (admission && !retainPreviousInput) await dispatchMonitor;
1078
1111
  if (startingTurn && !admission && !monitors.has(key)) {
1079
1112
  startingTurn.active = false;
1080
1113
  startingTurn.error = text(error?.message) || "OpenCode prompt delivery failed.";
@@ -1089,26 +1122,21 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1089
1122
  return { failure: { error, attempted: dispatchTurn?.promptAttempted === true, threadId: currentThreadId,
1090
1123
  turn: openCodeTurnSnapshot(turns.get(key) || startingTurn, currentThreadId) } };
1091
1124
  }
1125
+ if (dispatchTurn.admission === admission && dispatchTurn.inputMessageId === providerMessageId) {
1126
+ dispatchTurn.inputMessageId = text(admitted.id);
1127
+ dispatchTurn.updatedAt = new Date().toISOString();
1128
+ }
1092
1129
  let conversationTurn;
1093
1130
  try {
1094
1131
  conversationTurn = await application.commit(admitted);
1095
1132
  } catch (error) {
1096
- admission?.reject(error);
1133
+ admission.reject(error);
1134
+ dispatchTurn.abortController.abort(error);
1135
+ // Preserve the persistence error while joining the original recovery.
1136
+ await dispatchMonitor.catch(() => null);
1097
1137
  throw error;
1098
1138
  }
1099
- const activeMonitor = monitors.get(key);
1100
- if (activeMonitor) {
1101
- const activeTurn = turns.get(key);
1102
- activeTurn.inputMessageId = text(admitted.id);
1103
- activeTurn.updatedAt = new Date().toISOString();
1104
- } else {
1105
- beginMessageMonitor(key, target, {
1106
- ...admitted,
1107
- eventStartedAt,
1108
- startedAt
1109
- }, options, application.monitor);
1110
- }
1111
- admission?.resolve();
1139
+ admission.resolve();
1112
1140
  const turn = openCodeTurnSnapshot(turns.get(key), target.upstreamSessionId);
1113
1141
  return {
1114
1142
  conversationTurn,
@@ -1218,47 +1246,60 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1218
1246
  signal.throwIfAborted();
1219
1247
  turn.state = "active";
1220
1248
  await writeRun(turn, "active");
1221
- const completion = await waitForOpenCodeFinalResponse(
1222
- target.server.client,
1223
- target.upstreamSessionId,
1224
- turn,
1225
- {
1226
- ...finalResponse,
1227
- // Preserve the reasoning-only completion before the native recovery
1228
- // input replaces the projection boundary. Replayed writes are no-ops.
1229
- beforeRecovery: async completion => {
1230
- await projectMessages(completion.messages, { inputMessageId: turn.inputMessageId });
1231
- await finalResponse.beforeRecovery?.(completion);
1232
- },
1233
- onMessages: async (messages, inputMessageId) => {
1234
- const latest = openCodeLastAssistantResult(messages, { readError: finalResponse.readError }).message;
1235
- const phase = latest?.summary === true && Number(latest.time?.created) >= turn.eventStartedAt &&
1236
- !latest.time?.completed && !latest.finish && !latest.error ? "compacting" : "";
1237
- if (turn.active && !signal.aborted && text(turn.phase) !== phase) {
1238
- turn.phase = phase;
1239
- turn.updatedAt = new Date().toISOString();
1240
- await writeRun(turn, turn.state);
1241
- }
1242
- return projectMessages(messages, { inputMessageId, streaming: true });
1243
- },
1244
- readFailure: events.readFailure,
1245
- signal
1249
+ for (;;) {
1250
+ const completion = await waitForOpenCodeFinalResponse(
1251
+ target.server.client,
1252
+ target.upstreamSessionId,
1253
+ turn,
1254
+ {
1255
+ ...finalResponse,
1256
+ // Preserve the reasoning-only completion before the native recovery
1257
+ // input replaces the projection boundary. Replayed writes are no-ops.
1258
+ beforeRecovery: async completion => {
1259
+ await projectMessages(completion.messages, { inputMessageId: completion.inputMessageId });
1260
+ await finalResponse.beforeRecovery?.(completion);
1261
+ },
1262
+ onMessages: async (messages, inputMessageId) => {
1263
+ const latest = openCodeLastAssistantResult(messages, { readError: finalResponse.readError }).message;
1264
+ const phase = latest?.summary === true && Number(latest.time?.created) >= turn.eventStartedAt &&
1265
+ !latest.time?.completed && !latest.finish && !latest.error ? "compacting" : "";
1266
+ if (turn.active && !signal.aborted && text(turn.phase) !== phase) {
1267
+ turn.phase = phase;
1268
+ turn.updatedAt = new Date().toISOString();
1269
+ await writeRun(turn, turn.state);
1270
+ }
1271
+ return projectMessages(messages, { inputMessageId, streaming: true });
1272
+ },
1273
+ readFailure: events.readFailure,
1274
+ signal
1275
+ }
1276
+ );
1277
+ const admission = turn.admission;
1278
+ await admission?.promise;
1279
+ signal.throwIfAborted();
1280
+ if (admission !== turn.admission || completion.inputMessageId !== text(turn.inputMessageId)) {
1281
+ continue;
1246
1282
  }
1247
- );
1248
- const projection = await projectMessages(completion.messages, {
1249
- inputMessageId: turn.inputMessageId
1250
- });
1251
- signal.throwIfAborted();
1252
- failure = projection.failure || text(events.readFailure()?.message);
1253
- providerApiFailure = projection.providerApiFailure || openCodeProviderApiFailure(events.readFailure());
1254
- if (!failure && !turn.interruptRequested && !text(completion.result?.text)) {
1255
- failure = "OpenCode finished without a user-facing final response. Please send your message again.";
1256
- finalState = "failed";
1257
- } else if (failure) {
1258
- credentialFailure = openCodeCredentialFailure(failure);
1259
- finalState = "failed";
1260
- } else if (turn.interruptRequested) {
1261
- finalState = "interrupted";
1283
+ const projection = await projectMessages(completion.messages, {
1284
+ inputMessageId: completion.inputMessageId
1285
+ });
1286
+ await turn.admission?.promise;
1287
+ signal.throwIfAborted();
1288
+ if (admission !== turn.admission || completion.inputMessageId !== text(turn.inputMessageId)) {
1289
+ continue;
1290
+ }
1291
+ failure = projection.failure || text(events.readFailure()?.message);
1292
+ providerApiFailure = projection.providerApiFailure || openCodeProviderApiFailure(events.readFailure());
1293
+ if (!failure && !turn.interruptRequested && !text(completion.result?.text)) {
1294
+ failure = "OpenCode finished without a user-facing final response. Please send your message again.";
1295
+ finalState = "failed";
1296
+ } else if (failure) {
1297
+ credentialFailure = openCodeCredentialFailure(failure);
1298
+ finalState = "failed";
1299
+ } else if (turn.interruptRequested) {
1300
+ finalState = "interrupted";
1301
+ }
1302
+ break;
1262
1303
  }
1263
1304
  } catch (error) {
1264
1305
  if (turn.interruptAcknowledged) {
@@ -1287,11 +1328,13 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1287
1328
  }
1288
1329
  }
1289
1330
  } finally {
1331
+ // Claim retirement before awaited cleanup/checkpointing. A new Send
1332
+ // joins this monitor's cleanup instead of steering an unobserved turn.
1333
+ turn.active = finalState === "active";
1290
1334
  await events.close();
1291
1335
  if (credentialFailure || finalState === "failed") {
1292
1336
  failure = await completeResult(turn, { credentialFailure, providerApiFailure, finalState, failure });
1293
1337
  }
1294
- turn.active = finalState === "active";
1295
1338
  turn.error = failure;
1296
1339
  turn.state = finalState;
1297
1340
  turn.updatedAt = new Date().toISOString();