@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.
- package/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/App.vue +12 -2
- package/fixtures/responsive-assistant/ConversationFixture.vue +79 -2
- package/package.json +19 -9
- package/src/client/conversation/AssistantConversationElement.vue +107 -41
- package/src/client/conversation/AssistantTranscript.vue +192 -18
- package/src/server/conversation/claudeClient.js +2 -1
- package/src/server/conversation/claudeTurn.js +16 -7
- package/src/server/conversation/codexConversations.js +94 -0
- package/src/server/conversation/codexProvider.js +15 -5
- package/src/server/conversation/codexProviderOwner.js +70 -47
- package/src/server/conversation/codexProviderSelection.js +5 -2
- package/src/server/conversation/codexRuntimeLifecycle.js +2 -1
- package/src/server/conversation/codexTurn.js +10 -1
- package/src/server/conversation/codexTurnControl.js +1 -1
- package/src/server/conversation/codexTurnRecovery.js +21 -8
- package/src/server/conversation/openCodeRuntime.js +107 -64
- package/src/server/conversation/openCodeTurn.js +33 -16
- package/src/server/conversation/providers/api.js +1 -0
- package/src/server/conversation/providers/claudeDriver.js +13 -0
- package/src/server/conversation/providers/codex.js +90 -47
- package/src/server/conversation/providers/codexDriver.js +4 -1
- package/src/server/conversation/providers/opencodeDriver.js +3 -0
- package/src/server/conversation/runtime.js +32 -5
- package/src/server/hosts/codexEvents.js +15 -0
- package/src/server/hosts/codexTurn.js +5 -0
- package/src/server/hosts/openCodeClient.js +4 -0
- package/src/server/lib/aiConnectionClient.js +9 -11
- package/test/aiConnectionClient.test.js +209 -0
- package/test/assistantScroll.browser.test.js +437 -9
- package/test/claudeClient.test.js +41 -0
- package/test/claudeConversationSession.test.js +78 -0
- package/test/claudeTurn.test.js +18 -0
- package/test/codexConversationSession.test.js +318 -0
- package/test/codexTurn.test.js +203 -2
- package/test/conversationConfiguration.test.js +27 -0
- package/test/conversationReplacement.test.js +76 -1
- package/test/conversationRuntime.test.js +173 -4
- package/test/conversationSession.test.js +139 -0
- package/test/conversationTools.test.js +17 -3
- package/test/opencodeConversationSession.test.js +200 -1
- package/test/packageMetadata.test.js +75 -0
- 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
|
-
|
|
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
|
-
<
|
|
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 &&
|
|
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
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
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
|
-
|
|
907
|
-
|
|
908
|
-
pending.
|
|
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
|
-
|
|
963
|
-
checkOutput(
|
|
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("; ") ||
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|