@jskit-ai/assistant-core 0.1.173 → 0.1.174
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 +46 -5
- package/package.json +5 -5
- package/src/client/conversation/AssistantQuestionInputs.vue +1 -9
- package/src/client/conversation/AssistantTranscript.vue +21 -5
- package/src/client/conversation/useAssistantAttachments.js +2 -13
- package/src/server/conversation/codexEvents.js +17 -0
- package/src/server/conversation/index.js +1 -0
- package/src/server/conversation/openCodeClient.js +9 -0
- package/src/server/conversation/streams.js +52 -0
- package/src/shared/conversation/index.js +1 -0
- package/src/shared/conversation/streaming.js +24 -0
- package/test/assistantScroll.browser.test.js +127 -2
- package/test/conversationStreaming.test.js +70 -0
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
"build": "vite build"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
12
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
13
|
-
"@jskit-ai/kernel": "0.1.
|
|
11
|
+
"@jskit-ai/assistant-core": "0.1.174",
|
|
12
|
+
"@jskit-ai/http-runtime": "0.1.194",
|
|
13
|
+
"@jskit-ai/kernel": "0.1.196",
|
|
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.
|
|
18
|
-
"@jskit-ai/connectors-catalog": "0.1.
|
|
17
|
+
"@jskit-ai/connectors-core": "0.1.13",
|
|
18
|
+
"@jskit-ai/connectors-catalog": "0.1.13"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@vitejs/plugin-vue": "^6.0.7",
|
|
@@ -2,23 +2,54 @@
|
|
|
2
2
|
import { computed, ref } from "vue";
|
|
3
3
|
import AssistantConversationElement from "../../src/client/conversation/AssistantConversationElement.vue";
|
|
4
4
|
import { conversationTurnsFromMessages } from "../../src/shared/conversation/turns.js";
|
|
5
|
+
import { mergeConversationStream } from "../../src/shared/conversation/streaming.js";
|
|
5
6
|
const draft = ref("");
|
|
6
|
-
const
|
|
7
|
+
const query = new URLSearchParams(location.search);
|
|
8
|
+
const controls = query.has("controls");
|
|
9
|
+
const deferredHistory = query.has("history");
|
|
10
|
+
const streaming = query.has("streaming");
|
|
11
|
+
const liveText = ref("");
|
|
12
|
+
const liveMessage = computed(() => ({ messageId: "stream-answer", role: "assistant", text: liveText.value, status: "inProgress" }));
|
|
7
13
|
const scope = ref("one");
|
|
14
|
+
const loadingMore = ref(false);
|
|
15
|
+
const loadMoreError = ref("");
|
|
16
|
+
const historyRequests = ref(0);
|
|
17
|
+
const hasMoreBefore = ref(controls);
|
|
18
|
+
let completeHistory = null;
|
|
8
19
|
const messages = ref(Array.from({ length: 70 }, (_, index) => ({
|
|
9
20
|
id: `message-${index}`, role: index % 2 ? "assistant" : "user", status: "completed",
|
|
10
21
|
text: `Conversation line ${index + 1}: responsive scroll containment keeps the composer visible.\n\nMore text with **formatting**, a [link](https://example.com), and a_long_word_that_must_wrap_in_narrow_panes_without_overflow.`
|
|
11
22
|
})));
|
|
12
23
|
if (controls) messages.value[0].text = messages.value[0].text.repeat(4);
|
|
24
|
+
if (streaming) messages.value.push({ id: "stream-question", role: "user", text: "Show the answer as it arrives." });
|
|
25
|
+
function finishStream() {
|
|
26
|
+
messages.value.push({ ...liveMessage.value, status: "completed" });
|
|
27
|
+
liveText.value = "";
|
|
28
|
+
}
|
|
29
|
+
function finishHistory(error = "") {
|
|
30
|
+
loadMoreError.value = error;
|
|
31
|
+
if (!error) {
|
|
32
|
+
messages.value.unshift({ id: `older-${historyRequests.value}`, role: "assistant", text: "Older conversation paragraph. ".repeat(40), status: "completed" });
|
|
33
|
+
}
|
|
34
|
+
loadingMore.value = false;
|
|
35
|
+
completeHistory({ changed: !error });
|
|
36
|
+
completeHistory = null;
|
|
37
|
+
}
|
|
13
38
|
const adapter = computed(() => ({
|
|
14
|
-
conversation: {
|
|
39
|
+
conversation: {
|
|
40
|
+
turns: mergeConversationStream(conversationTurnsFromMessages(messages.value), { messages: [liveMessage.value] }), scrollKey: scope.value,
|
|
41
|
+
hasMoreBefore: hasMoreBefore.value, loadingMore: loadingMore.value, loadMoreError: loadMoreError.value
|
|
42
|
+
},
|
|
15
43
|
composer: { draft: draft.value, canSend: Boolean(draft.value.trim()), rows: 2 },
|
|
16
44
|
actions: {
|
|
17
45
|
setDraft(value) { draft.value = value; },
|
|
18
46
|
submit() { draft.value = ""; },
|
|
19
47
|
loadMore({ complete }) {
|
|
20
|
-
|
|
21
|
-
|
|
48
|
+
historyRequests.value += 1;
|
|
49
|
+
loadingMore.value = true;
|
|
50
|
+
loadMoreError.value = "";
|
|
51
|
+
completeHistory = complete;
|
|
52
|
+
if (!deferredHistory) finishHistory();
|
|
22
53
|
}
|
|
23
54
|
}
|
|
24
55
|
}));
|
|
@@ -27,10 +58,20 @@ const adapter = computed(() => ({
|
|
|
27
58
|
<v-app>
|
|
28
59
|
<v-main>
|
|
29
60
|
<main class="assistant-responsive-fixture">
|
|
61
|
+
<nav v-if="streaming">
|
|
62
|
+
<button @click="liveText += 'Growing answer. '">Receive text</button>
|
|
63
|
+
<button @click="finishStream">Finish answer</button>
|
|
64
|
+
</nav>
|
|
30
65
|
<nav v-if="controls">
|
|
31
66
|
<button @click="scope = scope === 'one' ? 'two' : 'one'">Change conversation</button>
|
|
32
67
|
<button @click="messages.push({ id: `added-${messages.length}`, role: 'assistant', text: 'New reply. '.repeat(30), status: 'completed' })">Append reply</button>
|
|
33
68
|
</nav>
|
|
69
|
+
<nav v-if="deferredHistory">
|
|
70
|
+
<button :disabled="!loadingMore" @click="finishHistory()">Complete history load</button>
|
|
71
|
+
<button :disabled="!loadingMore" @click="finishHistory('History unavailable')">Fail history load</button>
|
|
72
|
+
<button @click="hasMoreBefore = false">Exhaust history</button>
|
|
73
|
+
<output>History requests: {{ historyRequests }}</output>
|
|
74
|
+
</nav>
|
|
34
75
|
<AssistantConversationElement :adapter="adapter" class="assistant-responsive-fixture__conversation" />
|
|
35
76
|
</main>
|
|
36
77
|
</v-main>
|
|
@@ -39,6 +80,6 @@ const adapter = computed(() => ({
|
|
|
39
80
|
<style>
|
|
40
81
|
html, body, #app { height: 100%; margin: 0; min-height: 0; }
|
|
41
82
|
.assistant-responsive-fixture { display: flex; flex-direction: column; height: 100dvh; min-height: 0; padding: .5rem; }
|
|
42
|
-
.assistant-responsive-fixture nav { display: flex; flex: 0 0 auto; gap: 1rem; }
|
|
83
|
+
.assistant-responsive-fixture nav { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: 1rem; }
|
|
43
84
|
.assistant-responsive-fixture__conversation { flex: 1 1 auto; }
|
|
44
85
|
</style>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.174",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"@ai-sdk/perplexity": "^4.0.43",
|
|
34
34
|
"@ai-sdk/togetherai": "^3.0.49",
|
|
35
35
|
"@ai-sdk/xai": "^4.0.58",
|
|
36
|
-
"@jskit-ai/resource-core": "0.1.
|
|
37
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
36
|
+
"@jskit-ai/resource-core": "0.1.138",
|
|
37
|
+
"@jskit-ai/resource-crud-core": "0.1.138",
|
|
38
38
|
"@mdi/js": "^7.4.47",
|
|
39
39
|
"@openrouter/ai-sdk-provider": "^3.0.0",
|
|
40
40
|
"ai": "^7.0.101",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"ws": "^8.20.1"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
47
|
-
"@jskit-ai/kernel": "0.1.
|
|
46
|
+
"@jskit-ai/http-runtime": "0.1.194",
|
|
47
|
+
"@jskit-ai/kernel": "0.1.196",
|
|
48
48
|
"vue": "^3.5.13",
|
|
49
49
|
"vuetify": "^4.0.0"
|
|
50
50
|
},
|
|
@@ -18,6 +18,7 @@ const emit = defineEmits(["dismiss"]);
|
|
|
18
18
|
<div class="assistant-questions__question-fields-header">
|
|
19
19
|
<v-btn
|
|
20
20
|
aria-label="Answer normally instead"
|
|
21
|
+
class="ml-auto"
|
|
21
22
|
color="primary"
|
|
22
23
|
:prepend-icon="mdiPencilOutline"
|
|
23
24
|
size="small"
|
|
@@ -99,15 +100,6 @@ const emit = defineEmits(["dismiss"]);
|
|
|
99
100
|
min-width: 0;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
.assistant-questions__question-fields-header > span {
|
|
103
|
-
min-width: 0;
|
|
104
|
-
overflow-wrap: anywhere;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
.assistant-questions__question-fields-header :deep(.v-btn) {
|
|
108
|
-
margin-left: auto;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
103
|
.assistant-questions__question-field {
|
|
112
104
|
min-width: 0;
|
|
113
105
|
}
|
|
@@ -380,7 +380,9 @@ let initialScrollVersion = 0;
|
|
|
380
380
|
let loadMoreScrollSnapshot = null;
|
|
381
381
|
let loadMoreRequestVersion = 0;
|
|
382
382
|
let pendingTailFollow = false;
|
|
383
|
+
let previousScrollTop = 0;
|
|
383
384
|
const USER_SCROLL_INTENT_RESET_MS = 600;
|
|
385
|
+
const LOAD_MORE_THRESHOLD_PX = 160;
|
|
384
386
|
const KEYBOARD_SCROLL_KEYS = new Set([
|
|
385
387
|
" ",
|
|
386
388
|
"ArrowDown",
|
|
@@ -766,6 +768,13 @@ function clearLiveBottomScroll() {
|
|
|
766
768
|
|
|
767
769
|
function updateLatestFollowFromScroll(event = {}) {
|
|
768
770
|
const target = event?.currentTarget || bodyElement.value;
|
|
771
|
+
const scrollTop = target?.scrollTop || 0;
|
|
772
|
+
const scrolledUp = scrollTop < previousScrollTop;
|
|
773
|
+
previousScrollTop = scrollTop;
|
|
774
|
+
// Keep following the reader while the request is pending, before rows prepend.
|
|
775
|
+
if (loadMoreScrollSnapshot && target.scrollHeight === loadMoreScrollSnapshot.scrollHeight) {
|
|
776
|
+
loadMoreScrollSnapshot.scrollTop = scrollTop;
|
|
777
|
+
}
|
|
769
778
|
const shouldFollow = scrollElementNearBottom(target);
|
|
770
779
|
if (!shouldFollow && !userScrollIntent.value && followingLatest.value) {
|
|
771
780
|
return;
|
|
@@ -776,14 +785,20 @@ function updateLatestFollowFromScroll(event = {}) {
|
|
|
776
785
|
resumePendingTailFollow();
|
|
777
786
|
return;
|
|
778
787
|
}
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
788
|
+
clearLiveBottomScroll();
|
|
789
|
+
clearScheduledScrolls();
|
|
790
|
+
if (
|
|
791
|
+
scrolledUp &&
|
|
792
|
+
initialScrollSettled.value &&
|
|
793
|
+
scrollTop <= LOAD_MORE_THRESHOLD_PX &&
|
|
794
|
+
!props.loadMoreError
|
|
795
|
+
) {
|
|
796
|
+
requestLoadMore();
|
|
782
797
|
}
|
|
783
798
|
}
|
|
784
799
|
|
|
785
800
|
function requestLoadMore() {
|
|
786
|
-
if (!props.hasMoreBefore || props.loadingMore) {
|
|
801
|
+
if (!props.hasMoreBefore || props.loadingMore || loadMoreScrollSnapshot) {
|
|
787
802
|
return;
|
|
788
803
|
}
|
|
789
804
|
followingLatest.value = false;
|
|
@@ -827,7 +842,8 @@ async function completeLoadMoreRequest(version, changed) {
|
|
|
827
842
|
}
|
|
828
843
|
const element = bodyElement.value;
|
|
829
844
|
if (element) {
|
|
830
|
-
|
|
845
|
+
previousScrollTop = snapshot.scrollTop + (element.scrollHeight - snapshot.scrollHeight);
|
|
846
|
+
element.scrollTop = previousScrollTop;
|
|
831
847
|
}
|
|
832
848
|
clearLoadMoreScrollSnapshot();
|
|
833
849
|
}
|
|
@@ -39,13 +39,9 @@ function assistantAttachmentFilesFromDropEvent(event) {
|
|
|
39
39
|
return assistantAttachmentFiles(dataTransfer?.files);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function assistantAttachmentFilesFromClipboardItems(items = []) {
|
|
43
|
-
return assistantAttachmentFilesFromTransferItems(items);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
42
|
function assistantAttachmentFilesFromPasteEvent(event) {
|
|
47
43
|
const clipboardData = event?.clipboardData;
|
|
48
|
-
const itemFiles =
|
|
44
|
+
const itemFiles = assistantAttachmentFilesFromTransferItems(clipboardData?.items);
|
|
49
45
|
if (itemFiles.length > 0) {
|
|
50
46
|
return itemFiles;
|
|
51
47
|
}
|
|
@@ -105,13 +101,6 @@ function attachmentIdentity(attachment = {}) {
|
|
|
105
101
|
);
|
|
106
102
|
}
|
|
107
103
|
|
|
108
|
-
async function deliverUploadedAttachments(onUploaded, uploaded = []) {
|
|
109
|
-
if (uploaded.length > 0) {
|
|
110
|
-
return await onUploaded(uploaded);
|
|
111
|
-
}
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
104
|
const ASSISTANT_ATTACHMENT_MAX_BYTES = 100_000_000;
|
|
116
105
|
const ASSISTANT_ATTACHMENT_MAX_ITEMS = 10;
|
|
117
106
|
const ASSISTANT_ATTACHMENT_UPLOAD_CONCURRENCY = 2;
|
|
@@ -400,7 +389,7 @@ function useAssistantAttachments({
|
|
|
400
389
|
attachments.value.push(receipt);
|
|
401
390
|
}
|
|
402
391
|
try {
|
|
403
|
-
const handoff = await
|
|
392
|
+
const handoff = await onUploaded([receipt]);
|
|
404
393
|
const accepted = handoff?.accepted === true;
|
|
405
394
|
if (row.activeHandoffToken !== token || !row.retained || row.phase === "cancelled") {
|
|
406
395
|
removeReadyReceipt(row);
|
|
@@ -347,6 +347,23 @@ function classifyCodexAppServerEvent(notification = {}) {
|
|
|
347
347
|
turnId: codexAppServerNotificationTurnId(notification)
|
|
348
348
|
};
|
|
349
349
|
|
|
350
|
+
if (method === "item/agentMessage/delta") {
|
|
351
|
+
return {
|
|
352
|
+
...base,
|
|
353
|
+
kind: "assistant_delta",
|
|
354
|
+
delta: typeof codexAppServerNotificationParams(notification).delta === "string"
|
|
355
|
+
? codexAppServerNotificationParams(notification).delta : ""
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (method === "item/started" && itemType === "agentMessage") {
|
|
360
|
+
return {
|
|
361
|
+
...base,
|
|
362
|
+
kind: "assistant_started",
|
|
363
|
+
role: phase === "commentary" ? "commentary" : "assistant"
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
350
367
|
if (method === "error") {
|
|
351
368
|
return {
|
|
352
369
|
...base,
|
|
@@ -505,7 +505,16 @@ function createOpenCodeServerClient({
|
|
|
505
505
|
});
|
|
506
506
|
}
|
|
507
507
|
|
|
508
|
+
/** Read text snapshots without trimming the whitespace at a streaming boundary. */
|
|
509
|
+
function openCodeAssistantMessageText(message = {}) {
|
|
510
|
+
const values = [message.text, ...(message.content || [])
|
|
511
|
+
.filter((part) => part?.type === "text").map((part) => part.text)]
|
|
512
|
+
.filter((value) => typeof value === "string" && value.trim());
|
|
513
|
+
return [...new Set(values)].join("\n\n");
|
|
514
|
+
}
|
|
515
|
+
|
|
508
516
|
export {
|
|
517
|
+
openCodeAssistantMessageText,
|
|
509
518
|
OPENCODE_RESPONSE_LIMIT_BYTES,
|
|
510
519
|
createOpenCodeServerClient,
|
|
511
520
|
readBoundedResponse
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Transient provider output. Applications own scope, admission and final persistence. */
|
|
2
|
+
export function createConversationStreams({ clock = () => new Date() } = {}) {
|
|
3
|
+
const streams = new Map();
|
|
4
|
+
let revision = 0;
|
|
5
|
+
|
|
6
|
+
function read(scope) {
|
|
7
|
+
const stream = streams.get(scope);
|
|
8
|
+
return {
|
|
9
|
+
revision,
|
|
10
|
+
messages: stream ? [...stream.messages.values()].map((message) => ({ ...message })) : []
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function update(scope, { turnId, messageId, role = "assistant", text, delta, at } = {}) {
|
|
15
|
+
if (!turnId || !messageId || !["assistant", "commentary"].includes(role)) return null;
|
|
16
|
+
let stream = streams.get(scope);
|
|
17
|
+
if (!stream || stream.turnId !== turnId) {
|
|
18
|
+
stream = { turnId, messages: new Map(), completed: new Set() };
|
|
19
|
+
streams.set(scope, stream);
|
|
20
|
+
}
|
|
21
|
+
if (stream.completed.has(messageId)) return null;
|
|
22
|
+
const previous = stream.messages.get(messageId);
|
|
23
|
+
const next = {
|
|
24
|
+
messageId,
|
|
25
|
+
role: previous?.role || role,
|
|
26
|
+
at: previous?.at || at || clock().toISOString(),
|
|
27
|
+
text: typeof delta === "string" ? (previous?.text || "") + delta : (text ?? previous?.text ?? ""),
|
|
28
|
+
status: "inProgress"
|
|
29
|
+
};
|
|
30
|
+
if (previous?.text === next.text && previous.role === next.role) return null;
|
|
31
|
+
stream.messages.set(messageId, next);
|
|
32
|
+
revision += 1;
|
|
33
|
+
return read(scope);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function complete(scope, messageId) {
|
|
37
|
+
const stream = streams.get(scope);
|
|
38
|
+
if (!stream) return read(scope);
|
|
39
|
+
stream.completed.add(messageId);
|
|
40
|
+
stream.messages.delete(messageId);
|
|
41
|
+
revision += 1;
|
|
42
|
+
return read(scope);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function clear(scope) {
|
|
46
|
+
streams.delete(scope);
|
|
47
|
+
revision += 1;
|
|
48
|
+
return read(scope);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { read, update, complete, clear };
|
|
52
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
function messagesForTurn(turn) {
|
|
2
|
+
return turn.messages || [turn.system, turn.user, ...(turn.thinking || []), ...(turn.commentary || []), turn.assistant].filter(Boolean);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/** Overlay live provider output without changing the saved transcript. */
|
|
6
|
+
export function mergeConversationStream(turns = [], snapshot = {}) {
|
|
7
|
+
const savedIds = new Set(turns.flatMap((turn) => messagesForTurn(turn).map((message) => message.messageId)));
|
|
8
|
+
const messages = (snapshot?.messages || []).filter((message) => message.text && !savedIds.has(message.messageId));
|
|
9
|
+
if (!messages.length) return turns;
|
|
10
|
+
const result = [...turns];
|
|
11
|
+
for (const message of messages) {
|
|
12
|
+
const tail = result.at(-1);
|
|
13
|
+
const turn = tail && !tail.assistant && !tail.system
|
|
14
|
+
? { ...tail, messages: [...messagesForTurn(tail)] }
|
|
15
|
+
: { turnId: `stream:${message.messageId}`, messages: [] };
|
|
16
|
+
if (turn.turnId === tail?.turnId) result[result.length - 1] = turn;
|
|
17
|
+
else result.push(turn);
|
|
18
|
+
turn.messages.push(message);
|
|
19
|
+
turn.pending = true;
|
|
20
|
+
if (message.role === "assistant") turn.assistant = message;
|
|
21
|
+
else turn.commentary = [...(turn.commentary || []), message];
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
@@ -22,6 +22,38 @@ const VIEWPORTS = Object.freeze([
|
|
|
22
22
|
Object.freeze({ name: "drawer", width: 360, height: 600 })
|
|
23
23
|
]);
|
|
24
24
|
|
|
25
|
+
test("streaming answers grow in one bubble, preserve drafts and settle once at every width", {
|
|
26
|
+
skip: !RUN_BROWSER_TEST, timeout: 90_000
|
|
27
|
+
}, async () => {
|
|
28
|
+
const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
|
|
29
|
+
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
30
|
+
try {
|
|
31
|
+
for (const width of [390, 800, 1365]) {
|
|
32
|
+
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
33
|
+
await page.goto(`${vite.baseURL}/?streaming=1`);
|
|
34
|
+
const input = page.getByRole("textbox", { name: "Message AI assistant" });
|
|
35
|
+
await input.fill("Keep this draft");
|
|
36
|
+
await input.evaluate(element => element.setSelectionRange(2, 5));
|
|
37
|
+
const receive = () => page.getByRole("button", { name: "Receive text" }).evaluate(element => element.click());
|
|
38
|
+
await receive();
|
|
39
|
+
const answer = page.locator(".assistant-transcript__body").getByText("Growing answer.", { exact: true });
|
|
40
|
+
await expect(answer).toHaveCount(1);
|
|
41
|
+
await receive();
|
|
42
|
+
await expect(page.getByText("Growing answer. Growing answer.", { exact: true })).toHaveCount(1);
|
|
43
|
+
await expect(input).toBeFocused();
|
|
44
|
+
assert.deepEqual(await input.evaluate(element => [element.selectionStart, element.selectionEnd]), [2, 5]);
|
|
45
|
+
await page.getByRole("button", { name: "Finish answer" }).evaluate(element => element.click());
|
|
46
|
+
await expect(page.getByText("Growing answer. Growing answer.", { exact: true })).toHaveCount(1);
|
|
47
|
+
await expect(input).toHaveValue("Keep this draft");
|
|
48
|
+
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
|
49
|
+
await page.close();
|
|
50
|
+
}
|
|
51
|
+
} finally {
|
|
52
|
+
await browser.close();
|
|
53
|
+
await stopProcess(vite);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
25
57
|
test("adjacent reasoning stays grouped across storage rows, history pages and execution changes", {
|
|
26
58
|
skip: !RUN_BROWSER_TEST,
|
|
27
59
|
timeout: 90_000
|
|
@@ -159,8 +191,8 @@ test("conversation scrolling preserves history anchors and resets expansion on s
|
|
|
159
191
|
await page.goto(`${vite.baseURL}/?controls=1`);
|
|
160
192
|
const body = page.locator(".assistant-transcript__body");
|
|
161
193
|
await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
|
|
162
|
-
|
|
163
|
-
await
|
|
194
|
+
// Keep the explicit button path covered independently of automatic loading.
|
|
195
|
+
await body.evaluate(element => { element.scrollTop = 0; });
|
|
164
196
|
await expect.poll(() => body.evaluate((element) => element.scrollTop)).toBe(0);
|
|
165
197
|
await page.getByRole("button", { name: "Read more", exact: true }).click();
|
|
166
198
|
await expect(page.getByRole("button", { name: "Show less", exact: true })).toBeVisible();
|
|
@@ -187,6 +219,99 @@ test("conversation scrolling preserves history anchors and resets expansion on s
|
|
|
187
219
|
}
|
|
188
220
|
});
|
|
189
221
|
|
|
222
|
+
test("upward scrolling loads older history once and preserves retry and selection behavior", {
|
|
223
|
+
skip: !RUN_BROWSER_TEST,
|
|
224
|
+
timeout: 90_000
|
|
225
|
+
}, async () => {
|
|
226
|
+
const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
|
|
227
|
+
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
228
|
+
try {
|
|
229
|
+
for (const width of [390, 800, 1365]) {
|
|
230
|
+
const touch = width < 1000;
|
|
231
|
+
const page = await browser.newPage({ viewport: { width, height: 900 }, hasTouch: touch, isMobile: touch });
|
|
232
|
+
const errors = [];
|
|
233
|
+
page.on("pageerror", error => errors.push(error.message));
|
|
234
|
+
await page.goto(`${vite.baseURL}/?controls=1&history=1`);
|
|
235
|
+
const body = page.locator(".assistant-transcript__body");
|
|
236
|
+
const requests = page.locator("output");
|
|
237
|
+
const external = name => page.getByRole("button", { name, exact: true }).evaluate(element => element.click());
|
|
238
|
+
await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
|
|
239
|
+
await expect(requests).toHaveText("History requests: 0");
|
|
240
|
+
// Detach from initial follow before positioning above the history boundary.
|
|
241
|
+
await body.hover();
|
|
242
|
+
await page.mouse.wheel(0, -100);
|
|
243
|
+
await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeGreaterThan(50);
|
|
244
|
+
await body.evaluate(element => { element.scrollTop = 420; });
|
|
245
|
+
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
|
|
246
|
+
await expect(requests).toHaveText("History requests: 0");
|
|
247
|
+
if (touch) {
|
|
248
|
+
const bounds = await body.boundingBox();
|
|
249
|
+
const cdp = await page.context().newCDPSession(page);
|
|
250
|
+
const x = bounds.x + bounds.width / 2;
|
|
251
|
+
const y = bounds.y + 40;
|
|
252
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
|
|
253
|
+
for (let offset = 20; offset <= 380; offset += 20) {
|
|
254
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + offset }] });
|
|
255
|
+
await page.evaluate(() => new Promise(requestAnimationFrame));
|
|
256
|
+
}
|
|
257
|
+
const stopped = body.evaluate(element => new Promise(resolve => {
|
|
258
|
+
element.addEventListener("scrollend", () => resolve(), { once: true });
|
|
259
|
+
}));
|
|
260
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
|
261
|
+
await stopped;
|
|
262
|
+
await cdp.detach();
|
|
263
|
+
} else {
|
|
264
|
+
await body.hover();
|
|
265
|
+
await page.mouse.wheel(0, -380);
|
|
266
|
+
}
|
|
267
|
+
const pending = page.getByRole("button", { name: "Loading older messages…", exact: true });
|
|
268
|
+
await expect(pending).toBeDisabled();
|
|
269
|
+
await expect(requests).toHaveText("History requests: 1");
|
|
270
|
+
// Repeated events while waiting must not issue another request.
|
|
271
|
+
await body.evaluate(element => {
|
|
272
|
+
element.dispatchEvent(new Event("scroll"));
|
|
273
|
+
element.dispatchEvent(new Event("scroll"));
|
|
274
|
+
});
|
|
275
|
+
const original = page.getByText("Conversation line 1:", { exact: false }).first();
|
|
276
|
+
const originalTop = (await original.boundingBox()).y;
|
|
277
|
+
await external("Complete history load");
|
|
278
|
+
await expect(pending).toHaveCount(0);
|
|
279
|
+
await expect.poll(async () => Math.abs((await original.boundingBox()).y - originalTop)).toBeLessThan(2);
|
|
280
|
+
await expect(requests).toHaveText("History requests: 1");
|
|
281
|
+
|
|
282
|
+
// Keyboard navigation also requests older history.
|
|
283
|
+
await body.focus();
|
|
284
|
+
await body.press("Home");
|
|
285
|
+
await expect(requests).toHaveText("History requests: 2");
|
|
286
|
+
await external("Fail history load");
|
|
287
|
+
await expect(page.getByText("History unavailable", { exact: true })).toBeVisible();
|
|
288
|
+
await body.press("ArrowDown");
|
|
289
|
+
await body.press("Home");
|
|
290
|
+
await expect(requests).toHaveText("History requests: 2");
|
|
291
|
+
await page.getByRole("button", { name: "Load older messages", exact: true }).click();
|
|
292
|
+
await expect(requests).toHaveText("History requests: 3");
|
|
293
|
+
await external("Complete history load");
|
|
294
|
+
await expect(page.getByText("History unavailable", { exact: true })).toHaveCount(0);
|
|
295
|
+
|
|
296
|
+
await external("Change conversation");
|
|
297
|
+
await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
|
|
298
|
+
await expect(requests).toHaveText("History requests: 3");
|
|
299
|
+
await external("Exhaust history");
|
|
300
|
+
await body.focus();
|
|
301
|
+
await body.press("Home");
|
|
302
|
+
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(0);
|
|
303
|
+
await expect(requests).toHaveText("History requests: 3");
|
|
304
|
+
await expect(page.getByRole("button", { name: "Load older messages", exact: true })).toHaveCount(0);
|
|
305
|
+
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
|
306
|
+
assert.deepEqual(errors, []);
|
|
307
|
+
await page.close();
|
|
308
|
+
}
|
|
309
|
+
} finally {
|
|
310
|
+
await browser.close();
|
|
311
|
+
await stopProcess(vite);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
190
315
|
test("conversation composer preserves typing and reacts to external state and pane resizing", {
|
|
191
316
|
skip: !RUN_BROWSER_TEST,
|
|
192
317
|
timeout: 60_000
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createConversationStreams } from "../src/server/conversation/streams.js";
|
|
4
|
+
import { classifyCodexAppServerEvent } from "../src/server/conversation/codexEvents.js";
|
|
5
|
+
import { openCodeAssistantMessageText } from "../src/server/conversation/openCodeClient.js";
|
|
6
|
+
import { mergeConversationStream } from "../src/shared/conversation/streaming.js";
|
|
7
|
+
|
|
8
|
+
test("Codex chunks retain whitespace and item identity; completed items reject late chunks", () => {
|
|
9
|
+
const streams = createConversationStreams();
|
|
10
|
+
const params = { threadId: "thread", turnId: "turn", itemId: "answer" };
|
|
11
|
+
const started = classifyCodexAppServerEvent({ method: "item/started", params: {
|
|
12
|
+
...params, item: { id: "answer", type: "agentMessage", phase: "final_answer" }
|
|
13
|
+
} });
|
|
14
|
+
assert.equal(started.kind, "assistant_started");
|
|
15
|
+
streams.update("session", { ...started, messageId: started.itemId });
|
|
16
|
+
for (const delta of ["Hello", " ", "world", "\n\n", "```js\n x()\n``` "]) {
|
|
17
|
+
const event = classifyCodexAppServerEvent({ method: "item/agentMessage/delta", params: { ...params, delta } });
|
|
18
|
+
assert.equal(event.kind, "assistant_delta");
|
|
19
|
+
assert.equal(event.delta, delta);
|
|
20
|
+
streams.update("session", { ...event, messageId: event.itemId });
|
|
21
|
+
}
|
|
22
|
+
const snapshot = streams.read("session");
|
|
23
|
+
assert.equal(snapshot.messages[0].text, "Hello world\n\n```js\n x()\n``` ");
|
|
24
|
+
assert.equal(snapshot.messages[0].status, "inProgress");
|
|
25
|
+
snapshot.messages[0].text = "Mutation must not change the stream";
|
|
26
|
+
assert.match(streams.read("session").messages[0].text, /^Hello world/);
|
|
27
|
+
const completed = streams.complete("session", "answer");
|
|
28
|
+
assert.deepEqual(completed.messages, []);
|
|
29
|
+
assert.equal(streams.update("session", { ...params, messageId: "answer", delta: "late" }), null);
|
|
30
|
+
assert.deepEqual(streams.read("other-session").messages, []);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("OpenCode text snapshots replace partial text and unchanged snapshots emit nothing", () => {
|
|
34
|
+
const streams = createConversationStreams();
|
|
35
|
+
const message = { turnId: "turn", messageId: "answer" };
|
|
36
|
+
const text = openCodeAssistantMessageText({ content: [{ type: "reasoning", text: "private" }, { type: "text", text: "Hello " }] });
|
|
37
|
+
assert.equal(text, "Hello ");
|
|
38
|
+
streams.update("one", { ...message, text });
|
|
39
|
+
assert.equal(streams.update("one", { ...message, text }), null);
|
|
40
|
+
const next = streams.update("one", { ...message, text: "Hello world" });
|
|
41
|
+
assert.equal(next.messages[0].text, "Hello world");
|
|
42
|
+
assert.deepEqual(streams.clear("one").messages, []);
|
|
43
|
+
streams.update("one", { ...message, turnId: "next", text: "New answer" });
|
|
44
|
+
assert.equal(streams.read("one").messages.length, 1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("commentary keeps its role and saved answers replace live text without mutating history", () => {
|
|
48
|
+
const streams = createConversationStreams();
|
|
49
|
+
const started = classifyCodexAppServerEvent({ method: "item/started", params: {
|
|
50
|
+
threadId: "thread", turnId: "turn", item: { id: "comment", type: "agentMessage", phase: "commentary" }
|
|
51
|
+
} });
|
|
52
|
+
streams.update("one", { ...started, messageId: started.itemId });
|
|
53
|
+
streams.update("one", { turnId: "turn", messageId: "comment", delta: "Checking" });
|
|
54
|
+
assert.equal(streams.read("one").messages[0].role, "commentary");
|
|
55
|
+
streams.complete("one", "comment");
|
|
56
|
+
streams.update("one", { turnId: "turn", messageId: "answer", text: "Partial" });
|
|
57
|
+
const user = { role: "user", messageId: "user", text: "Question" };
|
|
58
|
+
const turns = [{ turnId: "000001", user, messages: [user] }];
|
|
59
|
+
const live = mergeConversationStream(turns, streams.read("one"));
|
|
60
|
+
assert.equal(live[0].turnId, "000001");
|
|
61
|
+
assert.equal(live[0].assistant.text, "Partial");
|
|
62
|
+
assert.equal(live[0].pending, true);
|
|
63
|
+
assert.equal(turns[0].messages.length, 1);
|
|
64
|
+
const final = { role: "assistant", messageId: "answer", text: "Final answer" };
|
|
65
|
+
const saved = [{ ...turns[0], assistant: final, messages: [user, final] }];
|
|
66
|
+
assert.equal(mergeConversationStream(saved, streams.read("one")), saved);
|
|
67
|
+
const withoutMessages = [{ turnId: "000001", user, assistant: final }];
|
|
68
|
+
assert.equal(mergeConversationStream(withoutMessages, streams.read("one")), withoutMessages);
|
|
69
|
+
assert.equal(mergeConversationStream([{ turnId: "000001", user }], streams.read("one"))[0].messages[0], user);
|
|
70
|
+
});
|