@jskit-ai/assistant-core 0.1.204 → 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 (43) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/App.vue +12 -2
  3. package/fixtures/responsive-assistant/ConversationFixture.vue +79 -2
  4. package/package.json +19 -9
  5. package/src/client/conversation/AssistantConversationElement.vue +107 -41
  6. package/src/client/conversation/AssistantTranscript.vue +192 -18
  7. package/src/server/conversation/claudeClient.js +2 -1
  8. package/src/server/conversation/claudeTurn.js +16 -7
  9. package/src/server/conversation/codexConversations.js +94 -0
  10. package/src/server/conversation/codexProvider.js +15 -5
  11. package/src/server/conversation/codexProviderOwner.js +70 -47
  12. package/src/server/conversation/codexProviderSelection.js +5 -2
  13. package/src/server/conversation/codexRuntimeLifecycle.js +2 -1
  14. package/src/server/conversation/codexTurn.js +10 -1
  15. package/src/server/conversation/codexTurnControl.js +1 -1
  16. package/src/server/conversation/codexTurnRecovery.js +21 -8
  17. package/src/server/conversation/openCodeRuntime.js +107 -64
  18. package/src/server/conversation/openCodeTurn.js +33 -16
  19. package/src/server/conversation/providers/api.js +1 -0
  20. package/src/server/conversation/providers/claudeDriver.js +13 -0
  21. package/src/server/conversation/providers/codex.js +90 -47
  22. package/src/server/conversation/providers/codexDriver.js +4 -1
  23. package/src/server/conversation/providers/opencodeDriver.js +3 -0
  24. package/src/server/conversation/runtime.js +32 -5
  25. package/src/server/hosts/codexEvents.js +15 -0
  26. package/src/server/hosts/codexTurn.js +5 -0
  27. package/src/server/hosts/openCodeClient.js +4 -0
  28. package/src/server/lib/aiConnectionClient.js +9 -11
  29. package/test/aiConnectionClient.test.js +209 -0
  30. package/test/assistantScroll.browser.test.js +437 -9
  31. package/test/claudeClient.test.js +41 -0
  32. package/test/claudeConversationSession.test.js +78 -0
  33. package/test/claudeTurn.test.js +18 -0
  34. package/test/codexConversationSession.test.js +318 -0
  35. package/test/codexTurn.test.js +203 -2
  36. package/test/conversationConfiguration.test.js +27 -0
  37. package/test/conversationReplacement.test.js +76 -1
  38. package/test/conversationRuntime.test.js +173 -4
  39. package/test/conversationSession.test.js +139 -0
  40. package/test/conversationTools.test.js +17 -3
  41. package/test/opencodeConversationSession.test.js +200 -1
  42. package/test/packageMetadata.test.js +75 -0
  43. package/test/serviceToolCatalog.test.js +31 -0
@@ -1,8 +1,9 @@
1
1
  <template>
2
2
  <section
3
- v-if="visible"
3
+ v-if="visible || retainWhenHidden"
4
+ v-show="visible"
4
5
  class="assistant-transcript"
5
- :class="`assistant-transcript--${variant}`"
6
+ :class="[`assistant-transcript--${variant}`, { 'assistant-transcript--retained': retainWhenHidden, 'assistant-transcript--error': error }]"
6
7
  aria-label="Conversation history"
7
8
  >
8
9
  <v-btn
@@ -39,15 +40,23 @@
39
40
 
40
41
  <v-alert
41
42
  v-if="error"
43
+ class="assistant-transcript__error"
42
44
  density="compact"
43
45
  type="warning"
44
46
  variant="tonal"
45
47
  >
46
- {{ error }}
48
+ <div class="assistant-transcript__error-content">
49
+ <span>{{ error }}</span>
50
+ <v-btn
51
+ v-if="errorReloadable && !reloadable" :disabled="reloading" :min-height="48" size="small"
52
+ type="button" variant="text" @click="emit('reload')"
53
+ >
54
+ Reload chat
55
+ </v-btn>
56
+ </div>
47
57
  </v-alert>
48
58
 
49
59
  <div
50
- v-else
51
60
  ref="bodyElement"
52
61
  aria-label="Conversation messages"
53
62
  class="assistant-transcript__body"
@@ -136,7 +145,13 @@
136
145
  <div
137
146
  class="assistant-transcript__user-content"
138
147
  >
139
- <p v-if="userMessageFormat === 'plain'" class="assistant-transcript__plain-user">{{ userMessageExpanded(entry.turn) ? entry.message.text : userMessagePreviewText(entry.message.text) || entry.message.text }}</p>
148
+ <v-textarea
149
+ v-if="entry.turn.preview && previewForTurn(entry.turn)?.actions?.editing"
150
+ class="assistant-transcript__preview-editor" :model-value="previewForTurn(entry.turn).text" label="Review your message"
151
+ :disabled="!previewForTurn(entry.turn).actions.canEdit" density="compact" :rows="1" :max-rows="2"
152
+ auto-grow hide-details @update:model-value="previewForTurn(entry.turn).actions.update(entry.turn.turnId, $event)"
153
+ />
154
+ <p v-else-if="userMessageFormat === 'plain'" class="assistant-transcript__plain-user">{{ userMessageExpanded(entry.turn) ? entry.message.text : userMessagePreviewText(entry.message.text) || entry.message.text }}</p>
140
155
  <LongTextPreviewBlocks
141
156
  v-else
142
157
  :blocks="userMessageExpanded(entry.turn) ? entry.message.blocks : (entry.message.previewBlocks || entry.message.blocks)"
@@ -160,12 +175,13 @@
160
175
  <time v-if="entry.message.displayAt">{{ entry.message.displayAt }}</time>
161
176
  <span v-if="entry.turn.optimistic?.status === 'pending'" role="status">Pending</span>
162
177
  </div>
178
+ <slot name="user-message-actions" :message="entry.message" :turn="entry.turn" />
163
179
  <div
164
180
  v-if="entry.turn.optimistic?.status === 'failed'"
165
181
  class="assistant-transcript__optimistic-failure"
166
182
  >
167
183
  <span role="status">Failed: {{ entry.turn.optimistic.error || "Message could not be sent." }}</span>
168
- <div class="assistant-transcript__optimistic-actions">
184
+ <div v-if="!entry.turn.preview" class="assistant-transcript__optimistic-actions">
169
185
  <v-btn
170
186
  class="assistant-transcript__delivery-action"
171
187
  color="primary"
@@ -311,8 +327,14 @@ import {
311
327
  normalizeThinkingMessageText
312
328
  } from "../../shared/conversation/thinkingText.js";
313
329
 
330
+ function previewForTurn(turn) {
331
+ const preview = turn.previewMessage || props.previewMessage;
332
+ return (preview?.messageId || preview?.id) === turn.turnId ? preview : null;
333
+ }
334
+
314
335
  const props = defineProps({
315
336
  systemLabel: { type: String, default: "Status" },
337
+ previewMessage: { type: Object, default: null },
316
338
  working: { type: Boolean, default: undefined },
317
339
  progressPreviewLimit: { type: Number, default: 2 },
318
340
  userMessageFormat: { type: String, default: "formatted", validator: (value) => ["plain", "formatted"].includes(value) },
@@ -324,6 +346,10 @@ const props = defineProps({
324
346
  default: "",
325
347
  type: String
326
348
  },
349
+ errorReloadable: {
350
+ default: false,
351
+ type: Boolean
352
+ },
327
353
  followLatestKey: {
328
354
  default: 0,
329
355
  type: [Number, String]
@@ -352,6 +378,10 @@ const props = defineProps({
352
378
  default: false,
353
379
  type: Boolean
354
380
  },
381
+ retainWhenHidden: {
382
+ default: false,
383
+ type: Boolean
384
+ },
355
385
  scrollKey: {
356
386
  default: "",
357
387
  type: [Number, String]
@@ -393,6 +423,7 @@ let liveScrollFrame = 0;
393
423
  let userScrollIntentTimer = null;
394
424
  let initialScrollVersion = 0;
395
425
  let loadMoreScrollSnapshot = null;
426
+ let retainedReaderScrollSnapshot = null;
396
427
  let loadMoreRequestVersion = 0;
397
428
  let pendingTailFollow = false;
398
429
  let previousScrollTop = 0;
@@ -636,7 +667,6 @@ function messageScrollKey(message = null) {
636
667
  }
637
668
 
638
669
  const timelineScrollTrigger = computed(() => [
639
- props.error ? "error" : "body",
640
670
  loadingIndicatorVisible.value ? "loading" : "ready",
641
671
  displayEntries.value.length ? "has-messages" : "empty",
642
672
  props.scrollKey
@@ -685,6 +715,7 @@ function resumePendingTailFollow() {
685
715
  }
686
716
 
687
717
  function markUserScrollIntent() {
718
+ retainedReaderScrollSnapshot = null;
688
719
  userScrollIntent.value = true;
689
720
  clearLiveBottomScroll();
690
721
  clearScheduledScrolls();
@@ -722,6 +753,7 @@ function scrollToLatestMessageAfterLayout({
722
753
  force = false
723
754
  } = {}) {
724
755
  if (force) {
756
+ retainedReaderScrollSnapshot = null;
725
757
  followingLatest.value = true;
726
758
  clearUserScrollIntent();
727
759
  }
@@ -734,6 +766,9 @@ function queueInitialBottomScroll() {
734
766
  const version = initialScrollVersion + 1;
735
767
  initialScrollVersion = version;
736
768
  initialScrollSettled.value = false;
769
+ if (props.retainWhenHidden && !props.visible) {
770
+ return;
771
+ }
737
772
  void scrollToLatestMessageAfterLayout({
738
773
  behavior: "auto",
739
774
  force: true
@@ -752,10 +787,14 @@ function queueLiveBottomScroll({
752
787
  return;
753
788
  }
754
789
  if (force) {
790
+ retainedReaderScrollSnapshot = null;
755
791
  pendingTailFollow = false;
756
792
  followingLatest.value = true;
757
793
  clearUserScrollIntent();
758
794
  }
795
+ if (props.retainWhenHidden && !props.visible) {
796
+ return;
797
+ }
759
798
  if (liveScrollFrame) {
760
799
  return;
761
800
  }
@@ -781,14 +820,22 @@ function clearLiveBottomScroll() {
781
820
  }
782
821
 
783
822
  function updateLatestFollowFromScroll(event = {}) {
823
+ if (props.retainWhenHidden && !props.visible) {
824
+ return;
825
+ }
784
826
  const target = event?.currentTarget || bodyElement.value;
827
+ if (retainedReaderScrollSnapshot?.restoring) {
828
+ previousScrollTop = target?.scrollTop || 0;
829
+ return;
830
+ }
785
831
  const scrollTop = target?.scrollTop || 0;
786
832
  const scrolledUp = scrollTop < previousScrollTop;
787
833
  previousScrollTop = scrollTop;
788
834
  // Keep following the reader while the request is pending, before rows prepend.
789
- if (loadMoreScrollSnapshot && target.scrollHeight === loadMoreScrollSnapshot.scrollHeight) {
835
+ if (loadMoreScrollSnapshot && !loadMoreScrollSnapshot.completed) {
790
836
  loadMoreScrollSnapshot.scrollTop = scrollTop;
791
837
  loadMoreScrollSnapshot.anchor = visibleHistoryAnchor(target);
838
+ loadMoreScrollSnapshot.scrollHeight = target.scrollHeight;
792
839
  }
793
840
  const shouldFollow = scrollElementNearBottom(target);
794
841
  if (!shouldFollow && !userScrollIntent.value && followingLatest.value) {
@@ -823,8 +870,42 @@ function visibleHistoryAnchor(element) {
823
870
  return null;
824
871
  }
825
872
 
873
+ async function restoreRetainedReaderScroll() {
874
+ const snapshot = retainedReaderScrollSnapshot;
875
+ snapshot.restoring = true;
876
+ await nextTick();
877
+ for (let pass = 0; pass < 2; pass += 1) {
878
+ const element = bodyElement.value;
879
+ if (
880
+ retainedReaderScrollSnapshot !== snapshot ||
881
+ element !== snapshot.body ||
882
+ snapshot.scrollKey !== props.scrollKey ||
883
+ !props.retainWhenHidden ||
884
+ !props.visible ||
885
+ followingLatest.value ||
886
+ loadMoreScrollSnapshot
887
+ ) {
888
+ if (retainedReaderScrollSnapshot === snapshot) retainedReaderScrollSnapshot = null;
889
+ return;
890
+ }
891
+ const anchor = snapshot.anchor;
892
+ if (anchor && element.contains(anchor.element)) {
893
+ // Materialize the retained row before reading the browser's adjusted scrollTop.
894
+ const offset = anchor.element.getBoundingClientRect().top - element.getBoundingClientRect().top;
895
+ previousScrollTop = element.scrollTop + offset - anchor.offset;
896
+ } else {
897
+ previousScrollTop = snapshot.scrollTop + (element.scrollHeight - snapshot.scrollHeight);
898
+ }
899
+ element.scrollTop = previousScrollTop;
900
+ if (pass === 0 && typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
901
+ await new Promise(resolve => window.requestAnimationFrame(() => window.requestAnimationFrame(resolve)));
902
+ }
903
+ }
904
+ if (retainedReaderScrollSnapshot === snapshot) retainedReaderScrollSnapshot = null;
905
+ }
906
+
826
907
  function requestLoadMore() {
827
- if (!props.hasMoreBefore || props.loadingMore || loadMoreScrollSnapshot) {
908
+ if ((props.retainWhenHidden && !props.visible) || !props.hasMoreBefore || props.loadingMore || loadMoreScrollSnapshot) {
828
909
  return;
829
910
  }
830
911
  followingLatest.value = false;
@@ -863,24 +944,43 @@ async function completeLoadMoreRequest(version, changed) {
863
944
  clearLoadMoreScrollSnapshot();
864
945
  return;
865
946
  }
947
+ snapshot.completed = true;
866
948
  await nextTick();
867
949
  if (loadMoreScrollSnapshot?.version !== version) {
868
950
  return;
869
951
  }
870
- const element = bodyElement.value;
871
- if (element) {
872
- // Offscreen turns use estimated heights. Preserve the visible turn instead
873
- // of moving by the total height difference, which can change during layout.
874
- const anchor = snapshot.anchor;
875
- previousScrollTop = anchor && element.contains(anchor.element)
876
- ? element.scrollTop + anchor.element.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset
877
- : snapshot.scrollTop + (element.scrollHeight - snapshot.scrollHeight);
878
- element.scrollTop = previousScrollTop;
952
+ const passes = props.retainWhenHidden ? 2 : 1;
953
+ for (let pass = 0; pass < passes; pass += 1) {
954
+ if (
955
+ (props.retainWhenHidden && !props.visible) ||
956
+ loadMoreScrollSnapshot?.version !== version ||
957
+ snapshot.scrollKey !== props.scrollKey
958
+ ) {
959
+ return;
960
+ }
961
+ const element = bodyElement.value;
962
+ if (element) {
963
+ // Offscreen turns use estimated heights. Preserve the visible turn instead
964
+ // of moving by the total height difference, which can change during layout.
965
+ const anchor = snapshot.anchor;
966
+ if (anchor && element.contains(anchor.element)) {
967
+ const offset = anchor.element.getBoundingClientRect().top - element.getBoundingClientRect().top;
968
+ previousScrollTop = element.scrollTop + offset - anchor.offset;
969
+ } else {
970
+ previousScrollTop = snapshot.scrollTop + (element.scrollHeight - snapshot.scrollHeight);
971
+ }
972
+ element.scrollTop = previousScrollTop;
973
+ }
974
+ if (pass + 1 < passes && typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
975
+ // Reopening materializes previously hidden content-visibility estimates.
976
+ await new Promise(resolve => window.requestAnimationFrame(() => window.requestAnimationFrame(resolve)));
977
+ }
879
978
  }
880
979
  clearLoadMoreScrollSnapshot();
881
980
  }
882
981
 
883
982
  onBeforeUnmount(() => {
983
+ retainedReaderScrollSnapshot = null;
884
984
  clearLiveBottomScroll();
885
985
  clearUserScrollIntent();
886
986
  clearLoadMoreScrollSnapshot();
@@ -923,6 +1023,7 @@ watch(() => [
923
1023
  });
924
1024
 
925
1025
  watch(timelineScrollTrigger, () => {
1026
+ retainedReaderScrollSnapshot = null;
926
1027
  clearLoadMoreScrollSnapshot();
927
1028
  queueInitialBottomScroll();
928
1029
  }, {
@@ -930,7 +1031,50 @@ watch(timelineScrollTrigger, () => {
930
1031
  immediate: true
931
1032
  });
932
1033
 
1034
+ watch(() => props.error, () => {
1035
+ if (followingLatest.value) queueLiveBottomScroll();
1036
+ }, { flush: "post" });
1037
+
1038
+ // Capture before this transcript or its parent's v-show removes its geometry.
1039
+ watch(() => props.visible, (visible) => {
1040
+ if (visible) return;
1041
+ retainedReaderScrollSnapshot = null;
1042
+ const element = bodyElement.value;
1043
+ if (
1044
+ props.retainWhenHidden &&
1045
+ initialScrollSettled.value &&
1046
+ !followingLatest.value &&
1047
+ !loadMoreScrollSnapshot &&
1048
+ element?.clientHeight > 0
1049
+ ) {
1050
+ retainedReaderScrollSnapshot = {
1051
+ anchor: visibleHistoryAnchor(element),
1052
+ body: element,
1053
+ scrollHeight: element.scrollHeight,
1054
+ scrollKey: props.scrollKey,
1055
+ scrollTop: element.scrollTop
1056
+ };
1057
+ }
1058
+ }, { flush: "sync" });
1059
+
933
1060
  watch(() => props.visible, (visible) => {
1061
+ if (props.retainWhenHidden) {
1062
+ if (!visible) {
1063
+ clearLiveBottomScroll();
1064
+ clearScheduledScrolls();
1065
+ clearUserScrollIntent();
1066
+ initialScrollVersion += 1;
1067
+ } else if (loadMoreScrollSnapshot?.completed) {
1068
+ void completeLoadMoreRequest(loadMoreScrollSnapshot.version, true);
1069
+ } else if (!initialScrollSettled.value) {
1070
+ queueInitialBottomScroll();
1071
+ } else if (retainedReaderScrollSnapshot) {
1072
+ void restoreRetainedReaderScroll();
1073
+ } else {
1074
+ queueLiveBottomScroll();
1075
+ }
1076
+ return;
1077
+ }
934
1078
  clearLoadMoreScrollSnapshot();
935
1079
  if (visible) queueLiveBottomScroll({ force: true });
936
1080
  }, { flush: "post" });
@@ -958,6 +1102,31 @@ watch(() => props.visible, (visible) => {
958
1102
  z-index: 1;
959
1103
  }
960
1104
 
1105
+ .assistant-transcript--error {
1106
+ grid-template-rows: auto minmax(0, 1fr);
1107
+ }
1108
+
1109
+ .assistant-transcript__error {
1110
+ font-size: 0.85rem;
1111
+ min-width: 0;
1112
+ overflow-wrap: anywhere;
1113
+ }
1114
+
1115
+ .assistant-transcript__error-content {
1116
+ align-items: flex-start;
1117
+ display: flex;
1118
+ gap: 0.5rem;
1119
+ }
1120
+
1121
+ .assistant-transcript__error-content > span {
1122
+ flex: 1 1 auto;
1123
+ min-width: 0;
1124
+ }
1125
+
1126
+ .assistant-transcript__error-content > .v-btn {
1127
+ flex: 0 0 auto;
1128
+ }
1129
+
961
1130
  .assistant-transcript__settling {
962
1131
  background: rgb(var(--v-theme-surface));
963
1132
  inset: 0;
@@ -1043,6 +1212,11 @@ watch(() => props.visible, (visible) => {
1043
1212
  min-width: 0;
1044
1213
  }
1045
1214
 
1215
+ .assistant-transcript--retained .assistant-transcript__turn {
1216
+ /* Late offscreen height estimates cannot move a restored reader anchor. */
1217
+ content-visibility: visible;
1218
+ }
1219
+
1046
1220
  .assistant-transcript__message-row {
1047
1221
  align-items: start;
1048
1222
  display: grid;
@@ -156,7 +156,8 @@ function createClaudeJsonClient({ stream, onEvent = async () => {}, onControlReq
156
156
 
157
157
  const completion = (async () => {
158
158
  try {
159
- for await (const frame of readClaudeJsonFrames(stream)) {
159
+ // Teardown belongs to this client after queued events finish, not the iterator.
160
+ for await (const frame of readClaudeJsonFrames(stream.iterator({ destroyOnReturn: false }))) {
160
161
  if (frame.type === "control_response") {
161
162
  const response = frame.response;
162
163
  const pending = requests.get(response?.request_id);
@@ -903,9 +903,17 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
903
903
  if (frame.state === "queued" || frame.type === "user") {
904
904
  const pending = admissions.get(uuid);
905
905
  if (pending) {
906
- await pending.accept();
907
- admissions.delete(uuid);
908
- pending.resolve();
906
+ // Once acknowledged, storage owns the admission decision. Its write
907
+ // must not race a native-ack timeout and a second submission.
908
+ clearTimeout(pending.timer);
909
+ try {
910
+ await pending.accept();
911
+ admissions.delete(uuid);
912
+ pending.resolve();
913
+ } catch (error) {
914
+ pending.reject(error);
915
+ throw error;
916
+ }
909
917
  }
910
918
  await publish({ type: "admitted", messageId: uuid, userEcho: frame.type === "user" });
911
919
  }
@@ -959,10 +967,11 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
959
967
  await publishMessage(block);
960
968
  }
961
969
  } else if (frame.type === "result") {
962
- result = typeof frame.structured_output === "object" ? JSON.stringify(frame.structured_output) : String(frame.result || "");
963
- checkOutput(result);
970
+ const output = typeof frame.structured_output === "object" ? JSON.stringify(frame.structured_output) : String(frame.result || "");
971
+ checkOutput(output);
964
972
  const failed = frame.is_error || frame.subtype !== "success";
965
- const message = failed ? (frame.errors || []).join("; ") || result || frame.subtype : "";
973
+ const message = failed ? (frame.errors || []).join("; ") || output || frame.subtype : "";
974
+ result = failed ? "" : output;
966
975
  if (result && ![...messages.values()].some((block) => block.complete && block.role === "assistant" && block.text === result)) {
967
976
  await publishMessage({ id: `claude_${frame.uuid || turnId}_result`, role: "assistant", text: result, complete: true });
968
977
  }
@@ -1038,12 +1047,12 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1038
1047
  if (!uuid || typeof accept !== "function" || admissions.has(uuid)) throw new TypeError("A distinct message id and admission writer are required.");
1039
1048
  const admitted = Promise.withResolvers();
1040
1049
  void admitted.promise.catch(() => {});
1041
- admissions.set(uuid, { ...admitted, accept });
1042
1050
  inFlight.add(uuid);
1043
1051
  const timer = setTimeout(() => admitted.reject(Object.assign(
1044
1052
  failure("Claude has not acknowledged this prompt. Its delivery is uncertain.", "assistant_claude_admission_unknown"),
1045
1053
  { delivery: "uncertain" }
1046
1054
  )), timeoutMs);
1055
+ admissions.set(uuid, { ...admitted, accept, timer });
1047
1056
  try {
1048
1057
  await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId }), admitted.promise]);
1049
1058
  } finally {
@@ -298,6 +298,99 @@ export function createCodexConversationCommands({
298
298
  });
299
299
  }
300
300
 
301
+ async function runDetachedCodexAppServerConversation(sessionId, input = {}, options = {}) {
302
+ const preparation = conversationPreparation.detached(sessionId, input);
303
+ const { admission } = preparation;
304
+ if (admission.ok === false) return admission;
305
+ try {
306
+ return await codexAppServerConversationResult(async () => {
307
+ const unavailable = preparation.result;
308
+ if (unavailable) return unavailable;
309
+ const prompt = preparation.prompt;
310
+ const context = await codexAppServerConversationContext(sessionId, input, options);
311
+ if (context.ok === false) return context;
312
+ const { provider, runtime, workdir } = context;
313
+ const executionProfile = input.executionProfile && typeof input.executionProfile === "object" &&
314
+ !Array.isArray(input.executionProfile) ? input.executionProfile : null;
315
+ const helperTurn = Boolean(executionProfile);
316
+ const emitDetachedEvent = event => {
317
+ if (typeof options.onEvent === "function") options.onEvent(event);
318
+ };
319
+ const onRetired = ({ threadId }) => emitDetachedEvent({ threadId, type: "thread-retired" });
320
+ if (helperTurn) {
321
+ await assertCodexAppServerHelperAccountIdentity(provider, input.expectedAccountIdentitySignature, helperOwnershipError);
322
+ }
323
+ const execution = preparation.execution(context);
324
+ const threadSettings = helperTurn ? null : await prepareCodexAppServerConversationThread(
325
+ provider, execution.threadPreparation, conversationPreparation.isolation
326
+ );
327
+ const requestedThreadId = normalizeCodexRunText(input.threadId || input.codexSessionId);
328
+ let helper = null;
329
+ let thread;
330
+ if (helperTurn) {
331
+ const scope = { executionProfile, onRetired, projectRuntimeRoot: normalizeCodexRunText(runtime?.stateRoot),
332
+ projectContextRoot: normalizeCodexRunText(runtime?.projectContextRoot), provider, sessionId, requestedThreadId, workdir,
333
+ ...(requestedThreadId ? { record: helperLifecycle.threadForOperation({ executionProfile,
334
+ projectRuntimeRoot: normalizeCodexRunText(runtime?.stateRoot), provider, sessionId,
335
+ threadId: requestedThreadId, workdir }) } : {}) };
336
+ helper = await helperLifecycle.prepareDetached(scope, execution.helperPreparation);
337
+ thread = helper.thread;
338
+ } else {
339
+ thread = await acquireCodexAppServerDetachedThread(provider, requestedThreadId, threadSettings, input);
340
+ }
341
+ const threadId = codexAppServerDetachedThreadId(thread, requestedThreadId);
342
+ emitDetachedEvent({ threadId, type: "thread" });
343
+ const requestedTimeoutMs = Number(input.timeoutMs || 0);
344
+ const profileTimeoutMs = Number(executionProfile?.limits?.timeoutMs || 0);
345
+ let turnId = "";
346
+ const result = await runDetachedCodexAppServerTurn({
347
+ provider, threadId, onEvent: emitDetachedEvent,
348
+ timeoutMs: helperTurn
349
+ ? Math.min(requestedTimeoutMs > 0 ? requestedTimeoutMs : profileTimeoutMs, profileTimeoutMs)
350
+ : requestedTimeoutMs > 0 ? requestedTimeoutMs : CODEX_APP_SERVER_DETACHED_TURN_TIMEOUT_MS,
351
+ async onFailure(error, status) {
352
+ await helper?.discard();
353
+ return execution.failure(error, status);
354
+ }
355
+ }, async failDispatch => {
356
+ let dispatched;
357
+ if (helperTurn) {
358
+ dispatched = await helper.dispatch(threadId, failDispatch);
359
+ } else {
360
+ try {
361
+ dispatched = await dispatchCodexAppServerDetachedTurn({ provider, prompt, threadId }, execution.authorized);
362
+ } catch (error) {
363
+ await failDispatch(error);
364
+ }
365
+ }
366
+ turnId = dispatched.turnId;
367
+ return dispatched;
368
+ });
369
+ if (helperTurn) {
370
+ try {
371
+ await assertCodexAppServerHelperAccountIdentity(provider, input.expectedAccountIdentitySignature, helperOwnershipError);
372
+ } catch (error) {
373
+ await helper.discard();
374
+ throw error;
375
+ }
376
+ try {
377
+ execution.validateOutput(result.text);
378
+ } catch (error) {
379
+ await helper.discard();
380
+ throw error;
381
+ }
382
+ await helper.complete();
383
+ }
384
+ emitDetachedEvent({ status: result.status || "completed", text: result.text,
385
+ threadId, turnId: result.turnId || turnId, type: "completed" });
386
+ return { ok: true, text: result.text, threadId, turnId: result.turnId || turnId,
387
+ ...(helperTurn ? { inputCharacters: prompt.length, outputCharacters: result.text.length, usage: result.usage || null } : {}) };
388
+ });
389
+ } finally {
390
+ admission.release();
391
+ }
392
+ }
393
+
301
394
  async function startCodexAppServerConversationTurn(sessionId, input = {}, options = {}) {
302
395
  const messageId = normalizeCodexRunText(input.messageId);
303
396
  return withCodexAppServerConversationTurnStart(sessionId, input, () => codexAppServerConversationResult(async () => {
@@ -1063,6 +1156,7 @@ export function createCodexConversationCommands({
1063
1156
  codexAppServerConversationContext,
1064
1157
  codexAppServerEphemeralScopeContext,
1065
1158
  createCodexAppServerConversation,
1159
+ runDetachedCodexAppServerConversation,
1066
1160
  startCodexAppServerConversationTurn,
1067
1161
  readCodexAppServerConversation,
1068
1162
  waitForCodexAppServerConversationTurn,
@@ -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)