@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
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.205",
12
- "@jskit-ai/http-runtime": "0.1.225",
13
- "@jskit-ai/kernel": "0.1.227",
11
+ "@jskit-ai/assistant-core": "0.1.206",
12
+ "@jskit-ai/http-runtime": "0.1.226",
13
+ "@jskit-ai/kernel": "0.1.228",
14
14
  "@mdi/js": "^7.4.47",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.1.44",
18
- "@jskit-ai/connectors-catalog": "0.1.44"
17
+ "@jskit-ai/connectors-core": "0.1.45",
18
+ "@jskit-ai/connectors-catalog": "0.1.45"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
@@ -4,8 +4,12 @@ import AssistantConversationElement from "../../src/client/conversation/Assistan
4
4
  import { conversationTurnsFromMessages } from "../../src/shared/conversation/turns.js";
5
5
  import { mergeConversationStream } from "../../src/shared/conversation/streaming.js";
6
6
  const draft = ref("");
7
- const visible = ref(true);
7
+ const error = ref("");
8
+ const reloads = ref(0);
8
9
  const query = new URLSearchParams(location.search);
10
+ const visible = ref(!query.has("initial-hidden"));
11
+ const retainWhenHidden = query.has("retain-hidden");
12
+ const mounted = ref(true);
9
13
  const controls = query.has("controls");
10
14
  const deferredHistory = query.has("history");
11
15
  const pagedHistory = query.has("paged-history");
@@ -52,12 +56,16 @@ function finishHistory(error = "") {
52
56
  const adapter = computed(() => ({
53
57
  conversation: {
54
58
  visible: visible.value,
59
+ retainWhenHidden,
60
+ error: error.value,
61
+ errorReloadable: true,
55
62
  turns: mergeConversationStream(conversationTurnsFromMessages(messages.value), { messages: [liveMessage.value] }), scrollKey: scope.value,
56
63
  hasMoreBefore: hasMoreBefore.value, loadingMore: loadingMore.value, loadMoreError: loadMoreError.value
57
64
  },
58
65
  composer: { draft: draft.value, canSend: Boolean(draft.value.trim()), rows: 2 },
59
66
  actions: {
60
67
  setDraft(value) { draft.value = value; },
68
+ reload() { reloads.value += 1; error.value = ""; },
61
69
  submit() { draft.value = ""; },
62
70
  loadMore({ complete }) {
63
71
  historyRequests.value += 1;
@@ -81,6 +89,8 @@ const adapter = computed(() => ({
81
89
  <button @click="visible = !visible">Toggle visibility</button>
82
90
  <button @click="scope = scope === 'one' ? 'two' : 'one'">Change conversation</button>
83
91
  <button @click="messages.push({ id: `added-${messages.length}`, role: 'assistant', text: 'New reply. '.repeat(30), status: 'completed' })">Append reply</button>
92
+ <button v-if="query.has('update-errors')" @click="error = 'Conversation updates are temporarily unavailable. Reload to reconnect; the assistant may still be working.'">Show update error</button>
93
+ <button v-if="retainWhenHidden" @click="mounted = !mounted">Toggle mount</button>
84
94
  </nav>
85
95
  <nav v-if="deferredHistory">
86
96
  <button :disabled="!loadingMore" @click="finishHistory()">Complete history load</button>
@@ -88,7 +98,7 @@ const adapter = computed(() => ({
88
98
  <button @click="hasMoreBefore = false">Exhaust history</button>
89
99
  <output>History requests: {{ historyRequests }}</output>
90
100
  </nav>
91
- <AssistantConversationElement v-show="visible" :adapter="adapter" class="assistant-responsive-fixture__conversation" />
101
+ <AssistantConversationElement v-if="mounted" v-show="visible" :adapter="adapter" :data-error-reloads="reloads" class="assistant-responsive-fixture__conversation" />
92
102
  </main>
93
103
  </v-main>
94
104
  </v-app>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.205",
3
+ "version": "0.1.206",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -32,7 +32,9 @@
32
32
  "./server/codex-provider": "./src/server/hosts/codexProvider.js",
33
33
  "./server/native-history": "./src/server/hosts/nativeHistory.js",
34
34
  "./testing/native-codex": "./src/testing/nativeCodex.js",
35
- "./testing/native-opencode": "./src/testing/nativeOpenCode.js"
35
+ "./testing/native-opencode": "./src/testing/nativeOpenCode.js",
36
+ "./server/codex-client": "./src/server/conversation/codexClient.js",
37
+ "./client/conversation-submit": "./src/client/conversation/submitText.js"
36
38
  },
37
39
  "dependencies": {
38
40
  "@ai-sdk/anthropic": "^4.0.53",
@@ -45,8 +47,8 @@
45
47
  "@ai-sdk/perplexity": "^4.0.43",
46
48
  "@ai-sdk/togetherai": "^3.0.49",
47
49
  "@ai-sdk/xai": "^4.0.58",
48
- "@jskit-ai/resource-core": "0.1.169",
49
- "@jskit-ai/resource-crud-core": "0.1.169",
50
+ "@jskit-ai/resource-core": "0.1.170",
51
+ "@jskit-ai/resource-crud-core": "0.1.170",
50
52
  "@mdi/js": "^7.4.47",
51
53
  "@openrouter/ai-sdk-provider": "^3.0.0",
52
54
  "ai": "^7.0.101",
@@ -55,8 +57,8 @@
55
57
  "ws": "^8.20.1"
56
58
  },
57
59
  "peerDependencies": {
58
- "@jskit-ai/http-runtime": "0.1.225",
59
- "@jskit-ai/kernel": "0.1.227",
60
+ "@jskit-ai/http-runtime": "0.1.226",
61
+ "@jskit-ai/kernel": "0.1.228",
60
62
  "vue": "^3.5.13",
61
63
  "vuetify": "^4.0.0"
62
64
  },
@@ -126,15 +128,15 @@
126
128
  },
127
129
  {
128
130
  "subpath": "./server/codex-events",
129
- "summary": "Advanced native host: Codex event identities, thread-history projections and context-refresh facts for native hosts."
131
+ "summary": "Advanced native host: Original Codex event classification, text, status and identity helpers, thread-history projections and context-refresh facts."
130
132
  },
131
133
  {
132
134
  "subpath": "./server/codex-turn",
133
- "summary": "Advanced native host: Original Codex run, receipt, observation, goal, steering, control, scoped-turn and renewal coordination."
135
+ "summary": "Advanced native host: Original detached-turn watcher and status predicates, plus Codex run, receipt, observation, goal, steering, control, scoped-turn and renewal coordination."
134
136
  },
135
137
  {
136
138
  "subpath": "./server/opencode-client",
137
- "summary": "Advanced native host: OpenCode native history retirement for existing hosts."
139
+ "summary": "Advanced native host: Original OpenCode loopback client, bounded response and assistant text helpers, plus native history retirement."
138
140
  },
139
141
  {
140
142
  "subpath": "./server/claude-history",
@@ -187,6 +189,14 @@
187
189
  {
188
190
  "subpath": "./testing/native-opencode",
189
191
  "summary": "Testing: Original OpenCode loopback client for isolated host integration tests; tests own endpoints, authorization and cleanup."
192
+ },
193
+ {
194
+ "subpath": "./server/codex-client",
195
+ "summary": "Advanced native host: Original Codex JSON-RPC client and endpoint socket-path helper; hosts retain authorization, transport limits and native lifecycle coordination."
196
+ },
197
+ {
198
+ "subpath": "./client/conversation-submit",
199
+ "summary": "Optional UI: Original text submission helper forwards companion text through the application draft and canonical send action; also exported by ./client/conversation."
190
200
  }
191
201
  ],
192
202
  "capabilities": []
@@ -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"
@@ -337,6 +346,10 @@ const props = defineProps({
337
346
  default: "",
338
347
  type: String
339
348
  },
349
+ errorReloadable: {
350
+ default: false,
351
+ type: Boolean
352
+ },
340
353
  followLatestKey: {
341
354
  default: 0,
342
355
  type: [Number, String]
@@ -365,6 +378,10 @@ const props = defineProps({
365
378
  default: false,
366
379
  type: Boolean
367
380
  },
381
+ retainWhenHidden: {
382
+ default: false,
383
+ type: Boolean
384
+ },
368
385
  scrollKey: {
369
386
  default: "",
370
387
  type: [Number, String]
@@ -406,6 +423,7 @@ let liveScrollFrame = 0;
406
423
  let userScrollIntentTimer = null;
407
424
  let initialScrollVersion = 0;
408
425
  let loadMoreScrollSnapshot = null;
426
+ let retainedReaderScrollSnapshot = null;
409
427
  let loadMoreRequestVersion = 0;
410
428
  let pendingTailFollow = false;
411
429
  let previousScrollTop = 0;
@@ -649,7 +667,6 @@ function messageScrollKey(message = null) {
649
667
  }
650
668
 
651
669
  const timelineScrollTrigger = computed(() => [
652
- props.error ? "error" : "body",
653
670
  loadingIndicatorVisible.value ? "loading" : "ready",
654
671
  displayEntries.value.length ? "has-messages" : "empty",
655
672
  props.scrollKey
@@ -698,6 +715,7 @@ function resumePendingTailFollow() {
698
715
  }
699
716
 
700
717
  function markUserScrollIntent() {
718
+ retainedReaderScrollSnapshot = null;
701
719
  userScrollIntent.value = true;
702
720
  clearLiveBottomScroll();
703
721
  clearScheduledScrolls();
@@ -735,6 +753,7 @@ function scrollToLatestMessageAfterLayout({
735
753
  force = false
736
754
  } = {}) {
737
755
  if (force) {
756
+ retainedReaderScrollSnapshot = null;
738
757
  followingLatest.value = true;
739
758
  clearUserScrollIntent();
740
759
  }
@@ -747,6 +766,9 @@ function queueInitialBottomScroll() {
747
766
  const version = initialScrollVersion + 1;
748
767
  initialScrollVersion = version;
749
768
  initialScrollSettled.value = false;
769
+ if (props.retainWhenHidden && !props.visible) {
770
+ return;
771
+ }
750
772
  void scrollToLatestMessageAfterLayout({
751
773
  behavior: "auto",
752
774
  force: true
@@ -765,10 +787,14 @@ function queueLiveBottomScroll({
765
787
  return;
766
788
  }
767
789
  if (force) {
790
+ retainedReaderScrollSnapshot = null;
768
791
  pendingTailFollow = false;
769
792
  followingLatest.value = true;
770
793
  clearUserScrollIntent();
771
794
  }
795
+ if (props.retainWhenHidden && !props.visible) {
796
+ return;
797
+ }
772
798
  if (liveScrollFrame) {
773
799
  return;
774
800
  }
@@ -794,14 +820,22 @@ function clearLiveBottomScroll() {
794
820
  }
795
821
 
796
822
  function updateLatestFollowFromScroll(event = {}) {
823
+ if (props.retainWhenHidden && !props.visible) {
824
+ return;
825
+ }
797
826
  const target = event?.currentTarget || bodyElement.value;
827
+ if (retainedReaderScrollSnapshot?.restoring) {
828
+ previousScrollTop = target?.scrollTop || 0;
829
+ return;
830
+ }
798
831
  const scrollTop = target?.scrollTop || 0;
799
832
  const scrolledUp = scrollTop < previousScrollTop;
800
833
  previousScrollTop = scrollTop;
801
834
  // Keep following the reader while the request is pending, before rows prepend.
802
- if (loadMoreScrollSnapshot && target.scrollHeight === loadMoreScrollSnapshot.scrollHeight) {
835
+ if (loadMoreScrollSnapshot && !loadMoreScrollSnapshot.completed) {
803
836
  loadMoreScrollSnapshot.scrollTop = scrollTop;
804
837
  loadMoreScrollSnapshot.anchor = visibleHistoryAnchor(target);
838
+ loadMoreScrollSnapshot.scrollHeight = target.scrollHeight;
805
839
  }
806
840
  const shouldFollow = scrollElementNearBottom(target);
807
841
  if (!shouldFollow && !userScrollIntent.value && followingLatest.value) {
@@ -836,8 +870,42 @@ function visibleHistoryAnchor(element) {
836
870
  return null;
837
871
  }
838
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
+
839
907
  function requestLoadMore() {
840
- if (!props.hasMoreBefore || props.loadingMore || loadMoreScrollSnapshot) {
908
+ if ((props.retainWhenHidden && !props.visible) || !props.hasMoreBefore || props.loadingMore || loadMoreScrollSnapshot) {
841
909
  return;
842
910
  }
843
911
  followingLatest.value = false;
@@ -876,24 +944,43 @@ async function completeLoadMoreRequest(version, changed) {
876
944
  clearLoadMoreScrollSnapshot();
877
945
  return;
878
946
  }
947
+ snapshot.completed = true;
879
948
  await nextTick();
880
949
  if (loadMoreScrollSnapshot?.version !== version) {
881
950
  return;
882
951
  }
883
- const element = bodyElement.value;
884
- if (element) {
885
- // Offscreen turns use estimated heights. Preserve the visible turn instead
886
- // of moving by the total height difference, which can change during layout.
887
- const anchor = snapshot.anchor;
888
- previousScrollTop = anchor && element.contains(anchor.element)
889
- ? element.scrollTop + anchor.element.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset
890
- : snapshot.scrollTop + (element.scrollHeight - snapshot.scrollHeight);
891
- 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
+ }
892
978
  }
893
979
  clearLoadMoreScrollSnapshot();
894
980
  }
895
981
 
896
982
  onBeforeUnmount(() => {
983
+ retainedReaderScrollSnapshot = null;
897
984
  clearLiveBottomScroll();
898
985
  clearUserScrollIntent();
899
986
  clearLoadMoreScrollSnapshot();
@@ -936,6 +1023,7 @@ watch(() => [
936
1023
  });
937
1024
 
938
1025
  watch(timelineScrollTrigger, () => {
1026
+ retainedReaderScrollSnapshot = null;
939
1027
  clearLoadMoreScrollSnapshot();
940
1028
  queueInitialBottomScroll();
941
1029
  }, {
@@ -943,7 +1031,50 @@ watch(timelineScrollTrigger, () => {
943
1031
  immediate: true
944
1032
  });
945
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.
946
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
+
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
+ }
947
1078
  clearLoadMoreScrollSnapshot();
948
1079
  if (visible) queueLiveBottomScroll({ force: true });
949
1080
  }, { flush: "post" });
@@ -971,6 +1102,31 @@ watch(() => props.visible, (visible) => {
971
1102
  z-index: 1;
972
1103
  }
973
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
+
974
1130
  .assistant-transcript__settling {
975
1131
  background: rgb(var(--v-theme-surface));
976
1132
  inset: 0;
@@ -1056,6 +1212,11 @@ watch(() => props.visible, (visible) => {
1056
1212
  min-width: 0;
1057
1213
  }
1058
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
+
1059
1220
  .assistant-transcript__message-row {
1060
1221
  align-items: start;
1061
1222
  display: grid;
@@ -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,