@jskit-ai/assistant-core 0.1.172 → 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/fixtures/responsive-assistant/ProgressFixture.vue +61 -0
- package/fixtures/responsive-assistant/main.js +3 -1
- package/package.json +5 -5
- package/src/client/conversation/AssistantConversationElement.vue +5 -2
- package/src/client/conversation/AssistantQuestionInputs.vue +1 -9
- package/src/client/conversation/AssistantTranscript.vue +159 -179
- 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 +200 -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>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
import AssistantConversationElement from "../../src/client/conversation/AssistantConversationElement.vue";
|
|
4
|
+
import { conversationTurnsFromMessages } from "../../src/shared/conversation/turns.js";
|
|
5
|
+
|
|
6
|
+
const working = ref(true);
|
|
7
|
+
const draft = ref("");
|
|
8
|
+
const scope = ref("first");
|
|
9
|
+
const combined = ref(false);
|
|
10
|
+
const messages = ref([1, 2, 3].map(index => ({
|
|
11
|
+
messageId: `progress-${index}`, role: "thinking", text: `Reasoning ${index}`
|
|
12
|
+
})));
|
|
13
|
+
let sequence = 3;
|
|
14
|
+
function append(role) {
|
|
15
|
+
sequence += 1;
|
|
16
|
+
messages.value.push({ messageId: `message-${sequence}`, role, text: `${role} ${sequence}` });
|
|
17
|
+
}
|
|
18
|
+
const adapter = computed(() => ({
|
|
19
|
+
conversation: {
|
|
20
|
+
working: working.value, scrollKey: scope.value, hasMoreBefore: true,
|
|
21
|
+
turns: combined.value
|
|
22
|
+
? conversationTurnsFromMessages(messages.value)
|
|
23
|
+
: messages.value.map(message => ({
|
|
24
|
+
turnId: `row-${message.messageId}`,
|
|
25
|
+
...(message.role === "system" || message.role === "user"
|
|
26
|
+
? { [message.role]: { messageId: message.messageId, text: message.text } } : {}),
|
|
27
|
+
messages: [message]
|
|
28
|
+
}))
|
|
29
|
+
},
|
|
30
|
+
composer: { draft: draft.value, canSend: Boolean(draft.value), canStop: working.value },
|
|
31
|
+
actions: {
|
|
32
|
+
setDraft(value) { draft.value = value; },
|
|
33
|
+
stop() { working.value = false; },
|
|
34
|
+
loadMore({ complete }) {
|
|
35
|
+
messages.value.unshift({ messageId: `older-${sequence++}`, role: "thinking", text: "Older reasoning" });
|
|
36
|
+
complete({ changed: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}));
|
|
40
|
+
</script>
|
|
41
|
+
<template>
|
|
42
|
+
<v-app>
|
|
43
|
+
<v-main>
|
|
44
|
+
<main class="progress-fixture">
|
|
45
|
+
<nav>
|
|
46
|
+
<button @click="working = !working">Toggle working</button>
|
|
47
|
+
<button @click="combined = !combined">Change storage rows</button>
|
|
48
|
+
<button @click="scope = scope === 'first' ? 'second' : 'first'">Change conversation</button>
|
|
49
|
+
<button v-for="role in ['thinking', 'commentary', 'assistant', 'user', 'system']" :key="role" @click="append(role)">Append {{ role }}</button>
|
|
50
|
+
</nav>
|
|
51
|
+
<AssistantConversationElement :adapter="adapter" />
|
|
52
|
+
</main>
|
|
53
|
+
</v-main>
|
|
54
|
+
</v-app>
|
|
55
|
+
</template>
|
|
56
|
+
<style scoped>
|
|
57
|
+
.progress-fixture { display: flex; flex-direction: column; height: 100dvh; padding: 8px; }
|
|
58
|
+
nav { display: flex; flex-wrap: wrap; gap: 8px; flex: 0 0 auto; }
|
|
59
|
+
nav button { padding: 8px; min-height: 48px; border: 1px solid; }
|
|
60
|
+
.assistant-conversation { flex: 1; }
|
|
61
|
+
</style>
|
|
@@ -6,6 +6,7 @@ import * as directives from "vuetify/directives";
|
|
|
6
6
|
import { aliases as mdiAliases, mdi } from "vuetify/iconsets/mdi-svg";
|
|
7
7
|
import App from "./App.vue";
|
|
8
8
|
import ConversationFixture from "./ConversationFixture.vue";
|
|
9
|
+
import ProgressFixture from "./ProgressFixture.vue";
|
|
9
10
|
|
|
10
11
|
const vuetify = createVuetify({
|
|
11
12
|
components,
|
|
@@ -20,6 +21,7 @@ const vuetify = createVuetify({
|
|
|
20
21
|
}
|
|
21
22
|
});
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
const query = new URLSearchParams(location.search);
|
|
25
|
+
createApp(query.has("progress") ? ProgressFixture : query.has("conversation") ? ConversationFixture : App)
|
|
24
26
|
.use(vuetify)
|
|
25
27
|
.mount("#app");
|
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
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<section class="assistant-conversation" :aria-label="label">
|
|
3
3
|
<AssistantTranscript
|
|
4
|
-
v-bind="adapter.conversation" class="assistant-conversation__transcript"
|
|
4
|
+
v-bind="adapter.conversation" :working="working" class="assistant-conversation__transcript"
|
|
5
5
|
@load-more="adapter.actions?.loadMore?.($event)" @reload="adapter.actions?.reload?.()"
|
|
6
6
|
@resend-turn="adapter.actions?.resend?.($event)" @cancel-turn="adapter.actions?.cancel?.($event)"
|
|
7
7
|
@edit-turn="adapter.actions?.edit?.($event)" @link-click="adapter.actions?.openLink?.($event)"
|
|
@@ -115,9 +115,12 @@ const fileInput = ref(null);
|
|
|
115
115
|
const modelsOpen = ref(false);
|
|
116
116
|
const delivery = ref(null);
|
|
117
117
|
const statusId = `assistant-status-${useId()}`;
|
|
118
|
+
const working = computed(() => props.adapter.conversation.working ?? Boolean(
|
|
119
|
+
props.adapter.composer?.canStop || props.adapter.conversation.turns?.some(turn => turn.pending)
|
|
120
|
+
));
|
|
118
121
|
const activity = computed(() => props.adapter.activity ?? {
|
|
119
122
|
label: props.adapter.composer?.stopPending ? "Stopping…"
|
|
120
|
-
:
|
|
123
|
+
: working.value
|
|
121
124
|
? "Assistant is working…" : props.adapter.composer?.pending ? "Sending to assistant…" : ""
|
|
122
125
|
});
|
|
123
126
|
const suggestionPreview = computed(() => !props.adapter.composer?.draft && props.adapter.suggestions?.visible
|
|
@@ -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
|
}
|
|
@@ -98,12 +98,12 @@
|
|
|
98
98
|
</div>
|
|
99
99
|
|
|
100
100
|
<article
|
|
101
|
-
v-for="
|
|
102
|
-
:key="
|
|
101
|
+
v-for="entry in displayEntries"
|
|
102
|
+
:key="entry.key"
|
|
103
103
|
class="assistant-transcript__turn"
|
|
104
104
|
>
|
|
105
105
|
<div
|
|
106
|
-
v-if="
|
|
106
|
+
v-if="entry.role === 'system'"
|
|
107
107
|
class="assistant-transcript__system"
|
|
108
108
|
>
|
|
109
109
|
<v-icon
|
|
@@ -114,55 +114,55 @@
|
|
|
114
114
|
<div class="assistant-transcript__system-body">
|
|
115
115
|
<div class="assistant-transcript__system-meta">
|
|
116
116
|
<span>{{ systemLabel }}</span>
|
|
117
|
-
<time v-if="
|
|
117
|
+
<time v-if="entry.message.displayAt">{{ entry.message.displayAt }}</time>
|
|
118
118
|
</div>
|
|
119
|
-
<slot name="system-message" :message="
|
|
119
|
+
<slot name="system-message" :message="entry.message"><LongTextPreviewBlocks compact :blocks="entry.message.blocks" @link-click="handleLongTextLinkClick" /></slot>
|
|
120
120
|
</div>
|
|
121
121
|
</div>
|
|
122
122
|
|
|
123
123
|
<div
|
|
124
|
-
v-if="
|
|
124
|
+
v-else-if="entry.role === 'user'"
|
|
125
125
|
class="assistant-transcript__message-row assistant-transcript__message-row--user"
|
|
126
126
|
>
|
|
127
127
|
<div class="assistant-transcript__message assistant-transcript__message--user">
|
|
128
128
|
<div
|
|
129
129
|
class="assistant-transcript__user-content"
|
|
130
130
|
>
|
|
131
|
-
<p v-if="userMessageFormat === 'plain'" class="assistant-transcript__plain-user">{{ userMessageExpanded(turn) ?
|
|
131
|
+
<p v-if="userMessageFormat === 'plain'" class="assistant-transcript__plain-user">{{ userMessageExpanded(entry.turn) ? entry.message.text : userMessagePreviewText(entry.message.text) || entry.message.text }}</p>
|
|
132
132
|
<LongTextPreviewBlocks
|
|
133
133
|
v-else
|
|
134
|
-
:blocks="userMessageExpanded(turn) ?
|
|
134
|
+
:blocks="userMessageExpanded(entry.turn) ? entry.message.blocks : (entry.message.previewBlocks || entry.message.blocks)"
|
|
135
135
|
@link-click="handleLongTextLinkClick"
|
|
136
136
|
/>
|
|
137
137
|
</div>
|
|
138
138
|
<button
|
|
139
|
-
v-if="userMessageCollapsible(
|
|
140
|
-
:aria-expanded="userMessageExpanded(turn)"
|
|
139
|
+
v-if="userMessageCollapsible(entry.message)"
|
|
140
|
+
:aria-expanded="userMessageExpanded(entry.turn)"
|
|
141
141
|
class="assistant-transcript__user-content-toggle"
|
|
142
142
|
type="button"
|
|
143
|
-
@click="toggleUserMessage(turn)"
|
|
143
|
+
@click="toggleUserMessage(entry.turn)"
|
|
144
144
|
>
|
|
145
|
-
{{ userMessageExpanded(turn) ? "Show less" : "Read more" }}
|
|
145
|
+
{{ userMessageExpanded(entry.turn) ? "Show less" : "Read more" }}
|
|
146
146
|
</button>
|
|
147
|
-
<slot name="attachments" :items="
|
|
147
|
+
<slot name="attachments" :items="entry.message.attachments" :message="entry.message"><AssistantMessageAttachments :attachments="entry.message.attachments || []" /></slot>
|
|
148
148
|
<div
|
|
149
|
-
v-if="
|
|
149
|
+
v-if="entry.message.displayAt"
|
|
150
150
|
class="assistant-transcript__message-footer assistant-transcript__message-footer--user"
|
|
151
151
|
>
|
|
152
|
-
<time v-if="
|
|
152
|
+
<time v-if="entry.message.displayAt">{{ entry.message.displayAt }}</time>
|
|
153
153
|
</div>
|
|
154
154
|
<div
|
|
155
|
-
v-if="turn.optimistic?.status === 'failed'"
|
|
155
|
+
v-if="entry.turn.optimistic?.status === 'failed'"
|
|
156
156
|
class="assistant-transcript__optimistic-failure"
|
|
157
157
|
>
|
|
158
|
-
<span>{{ turn.optimistic.error || "Message could not be sent." }}</span>
|
|
158
|
+
<span>{{ entry.turn.optimistic.error || "Message could not be sent." }}</span>
|
|
159
159
|
<div class="assistant-transcript__optimistic-actions">
|
|
160
160
|
<v-btn
|
|
161
161
|
color="primary"
|
|
162
162
|
size="x-small"
|
|
163
163
|
type="button"
|
|
164
164
|
variant="tonal"
|
|
165
|
-
@click="emit('resend-turn', turn.optimistic.id)"
|
|
165
|
+
@click="emit('resend-turn', entry.turn.optimistic.id)"
|
|
166
166
|
>
|
|
167
167
|
Resend
|
|
168
168
|
</v-btn>
|
|
@@ -170,7 +170,7 @@
|
|
|
170
170
|
size="x-small"
|
|
171
171
|
type="button"
|
|
172
172
|
variant="text"
|
|
173
|
-
@click="emit('cancel-turn', turn.optimistic.id)"
|
|
173
|
+
@click="emit('cancel-turn', entry.turn.optimistic.id)"
|
|
174
174
|
>
|
|
175
175
|
Cancel
|
|
176
176
|
</v-btn>
|
|
@@ -178,7 +178,7 @@
|
|
|
178
178
|
size="x-small"
|
|
179
179
|
type="button"
|
|
180
180
|
variant="text"
|
|
181
|
-
@click="emit('edit-turn', turn.optimistic.id)"
|
|
181
|
+
@click="emit('edit-turn', entry.turn.optimistic.id)"
|
|
182
182
|
>
|
|
183
183
|
Edit
|
|
184
184
|
</v-btn>
|
|
@@ -190,84 +190,79 @@
|
|
|
190
190
|
</span>
|
|
191
191
|
</div>
|
|
192
192
|
|
|
193
|
-
<
|
|
194
|
-
v-
|
|
195
|
-
:key="entry.key"
|
|
193
|
+
<AssistantProgress
|
|
194
|
+
v-else-if="entry.role === 'thinking'"
|
|
195
|
+
:key="`${scrollKey}:${entry.key}`"
|
|
196
|
+
class="assistant-transcript__thinking"
|
|
197
|
+
:messages="entry.messages"
|
|
198
|
+
:pending="isWorking && entry === displayEntries.at(-1)"
|
|
199
|
+
:preview-limit="progressPreviewLimit"
|
|
200
|
+
:aria-label="`${assistantLabel} progress`"
|
|
201
|
+
/>
|
|
202
|
+
<div
|
|
203
|
+
v-else
|
|
204
|
+
class="assistant-transcript__message-row assistant-transcript__message-row--assistant"
|
|
205
|
+
:data-message-role="entry.role"
|
|
196
206
|
>
|
|
197
|
-
<
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
:preview-limit="progressPreviewLimit"
|
|
204
|
-
:aria-label="`${assistantLabel} progress`"
|
|
205
|
-
/>
|
|
206
|
-
<div
|
|
207
|
-
v-else
|
|
208
|
-
class="assistant-transcript__message-row assistant-transcript__message-row--assistant"
|
|
209
|
-
:data-message-role="entry.role"
|
|
210
|
-
>
|
|
211
|
-
<div class="assistant-transcript__assistant-header">
|
|
212
|
-
<span class="assistant-transcript__avatar assistant-transcript__avatar--assistant">
|
|
213
|
-
<v-icon :icon="mdiRobotOutline" size="16" />
|
|
214
|
-
</span>
|
|
215
|
-
<div class="assistant-transcript__message-header">
|
|
216
|
-
<span>{{ assistantLabel }}</span>
|
|
217
|
-
</div>
|
|
207
|
+
<div class="assistant-transcript__assistant-header">
|
|
208
|
+
<span class="assistant-transcript__avatar assistant-transcript__avatar--assistant">
|
|
209
|
+
<v-icon :icon="mdiRobotOutline" size="16" />
|
|
210
|
+
</span>
|
|
211
|
+
<div class="assistant-transcript__message-header">
|
|
212
|
+
<span>{{ assistantLabel }}</span>
|
|
218
213
|
</div>
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
214
|
+
</div>
|
|
215
|
+
<div class="assistant-transcript__message assistant-transcript__message--assistant">
|
|
216
|
+
<LongTextPreviewBlocks
|
|
217
|
+
v-if="entry.message.blocks.length"
|
|
218
|
+
:blocks="entry.message.blocks"
|
|
219
|
+
@link-click="handleLongTextLinkClick"
|
|
220
|
+
/>
|
|
221
|
+
<ol
|
|
222
|
+
v-if="entry.message.questions.length"
|
|
223
|
+
class="assistant-transcript__questions"
|
|
224
|
+
>
|
|
225
|
+
<li
|
|
226
|
+
v-for="question in entry.message.questions"
|
|
227
|
+
:key="question.name"
|
|
228
|
+
class="assistant-transcript__question"
|
|
228
229
|
>
|
|
229
|
-
<
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
230
|
+
<span class="assistant-transcript__question-number">{{ question.number }}</span>
|
|
231
|
+
<div class="assistant-transcript__question-content">
|
|
232
|
+
<span class="assistant-transcript__question-text">
|
|
233
|
+
<LongTextInlineParts
|
|
234
|
+
:text="question.label"
|
|
235
|
+
@link-click="handleLongTextLinkClick"
|
|
236
|
+
/>
|
|
237
|
+
</span>
|
|
238
|
+
<ul
|
|
239
|
+
v-if="question.choices.length"
|
|
240
|
+
class="assistant-transcript__question-choices"
|
|
241
|
+
>
|
|
242
|
+
<li v-for="choice in question.choices" :key="choice.value">
|
|
237
243
|
<LongTextInlineParts
|
|
238
|
-
:text="
|
|
244
|
+
:text="choice.label"
|
|
239
245
|
@link-click="handleLongTextLinkClick"
|
|
240
|
-
|
|
241
|
-
</
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
:blocks="entry.message.outroBlocks"
|
|
259
|
-
@link-click="handleLongTextLinkClick"
|
|
260
|
-
/>
|
|
261
|
-
<slot name="message-actions" :message="entry.message" :turn="turn" />
|
|
262
|
-
</div>
|
|
263
|
-
<div
|
|
264
|
-
v-if="entry.message.displayAt"
|
|
265
|
-
class="assistant-transcript__message-footer assistant-transcript__message-footer--assistant"
|
|
266
|
-
>
|
|
267
|
-
<time>{{ entry.message.displayAt }}</time>
|
|
268
|
-
</div>
|
|
246
|
+
/><span v-if="choice.recommended"> · Recommended</span>
|
|
247
|
+
</li>
|
|
248
|
+
</ul>
|
|
249
|
+
</div>
|
|
250
|
+
</li>
|
|
251
|
+
</ol>
|
|
252
|
+
<LongTextPreviewBlocks
|
|
253
|
+
v-if="entry.message.outroBlocks.length"
|
|
254
|
+
:blocks="entry.message.outroBlocks"
|
|
255
|
+
@link-click="handleLongTextLinkClick"
|
|
256
|
+
/>
|
|
257
|
+
<slot name="message-actions" :message="entry.message" :turn="entry.turn" />
|
|
258
|
+
</div>
|
|
259
|
+
<div
|
|
260
|
+
v-if="entry.message.displayAt"
|
|
261
|
+
class="assistant-transcript__message-footer assistant-transcript__message-footer--assistant"
|
|
262
|
+
>
|
|
263
|
+
<time>{{ entry.message.displayAt }}</time>
|
|
269
264
|
</div>
|
|
270
|
-
</
|
|
265
|
+
</div>
|
|
271
266
|
</article>
|
|
272
267
|
|
|
273
268
|
<div
|
|
@@ -303,6 +298,7 @@ import {
|
|
|
303
298
|
|
|
304
299
|
const props = defineProps({
|
|
305
300
|
systemLabel: { type: String, default: "Status" },
|
|
301
|
+
working: { type: Boolean, default: undefined },
|
|
306
302
|
progressPreviewLimit: { type: Number, default: 2 },
|
|
307
303
|
userMessageFormat: { type: String, default: "formatted", validator: (value) => ["plain", "formatted"].includes(value) },
|
|
308
304
|
assistantLabel: {
|
|
@@ -373,6 +369,7 @@ const DISPLAY_MESSAGE_CACHE_LIMIT = 500;
|
|
|
373
369
|
const bodyElement = ref(null);
|
|
374
370
|
const bottomElement = ref(null);
|
|
375
371
|
const expandedUserMessages = ref(new Set());
|
|
372
|
+
const isWorking = computed(() => props.working ?? props.turns.some(turn => turn.pending));
|
|
376
373
|
const followingLatest = ref(true);
|
|
377
374
|
const initialScrollSettled = ref(false);
|
|
378
375
|
const userScrollIntent = ref(false);
|
|
@@ -383,7 +380,9 @@ let initialScrollVersion = 0;
|
|
|
383
380
|
let loadMoreScrollSnapshot = null;
|
|
384
381
|
let loadMoreRequestVersion = 0;
|
|
385
382
|
let pendingTailFollow = false;
|
|
383
|
+
let previousScrollTop = 0;
|
|
386
384
|
const USER_SCROLL_INTENT_RESET_MS = 600;
|
|
385
|
+
const LOAD_MORE_THRESHOLD_PX = 160;
|
|
387
386
|
const KEYBOARD_SCROLL_KEYS = new Set([
|
|
388
387
|
" ",
|
|
389
388
|
"ArrowDown",
|
|
@@ -530,66 +529,49 @@ function conversationMessageKey(message = {}, index = 0) {
|
|
|
530
529
|
].join(":");
|
|
531
530
|
}
|
|
532
531
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
532
|
+
// Storage rows are not visual boundaries. Only an intervening message ends a
|
|
533
|
+
// reasoning group, including when history is prepended or execution continues.
|
|
534
|
+
const displayEntries = computed(() => {
|
|
535
|
+
const entries = [];
|
|
536
|
+
for (const [index, turn] of props.turns.entries()) {
|
|
537
|
+
const turnId = String(turn.turnId || index + 1);
|
|
538
|
+
const messages = [
|
|
539
|
+
turn.system && { ...turn.system, role: "system" },
|
|
540
|
+
turn.user && { ...turn.user, role: "user" },
|
|
541
|
+
...conversationAgentMessages(turn)
|
|
542
|
+
].filter(Boolean);
|
|
543
|
+
for (const [messageIndex, message] of messages.entries()) {
|
|
544
|
+
const role = String(message.role || "").trim();
|
|
545
|
+
const key = `${turnId}:${conversationMessageKey(message, messageIndex)}`;
|
|
546
|
+
const displayed = role === "thinking" ? displayThinkingMessage(message) : displayMessage(message, {
|
|
547
|
+
allowNumberedQuestions: role !== "user" && role !== "system",
|
|
548
|
+
preserveParagraphLineBreaks: role === "user",
|
|
549
|
+
previewUserMessage: role === "user"
|
|
550
|
+
}, key);
|
|
551
|
+
if (!displayed) continue;
|
|
552
|
+
const previous = entries.at(-1);
|
|
553
|
+
if (role === "thinking" && previous?.role === "thinking") {
|
|
554
|
+
previous.messages.push({ ...displayed, key });
|
|
555
|
+
} else {
|
|
556
|
+
entries.push({
|
|
557
|
+
key, role, turn: { ...turn, turnId }, message: displayed,
|
|
558
|
+
...(role === "thinking" ? { messages: [{ ...displayed, key }] } : {})
|
|
559
|
+
});
|
|
550
560
|
}
|
|
551
|
-
timeline.push({
|
|
552
|
-
key: `thinking:${keyedMessage.key}`,
|
|
553
|
-
messages: [keyedMessage],
|
|
554
|
-
role
|
|
555
|
-
});
|
|
556
|
-
continue;
|
|
557
561
|
}
|
|
558
|
-
timeline.push({
|
|
559
|
-
key: conversationMessageKey(message, index),
|
|
560
|
-
message: displayMessage(message, {
|
|
561
|
-
allowNumberedQuestions: true
|
|
562
|
-
}, `${turnKey}:agent:${conversationMessageKey(message, index)}`),
|
|
563
|
-
role
|
|
564
|
-
});
|
|
565
562
|
}
|
|
566
|
-
|
|
567
|
-
|
|
563
|
+
// Anchor a progress group to the following message, or to the transcript end.
|
|
564
|
+
// Appending progress or prepending history then preserves its expansion state.
|
|
565
|
+
for (const [index, entry] of entries.entries()) {
|
|
566
|
+
if (entry.role === "thinking") entry.key = `progress:${entries[index + 1]?.key || "tail"}`;
|
|
567
|
+
}
|
|
568
|
+
return entries;
|
|
569
|
+
});
|
|
568
570
|
|
|
569
571
|
function handleLongTextLinkClick(payload) {
|
|
570
572
|
emit("link-click", payload);
|
|
571
573
|
}
|
|
572
574
|
|
|
573
|
-
const displayTurns = computed(() => (Array.isArray(props.turns) ? props.turns : [])
|
|
574
|
-
.map((turn, index) => {
|
|
575
|
-
const turnId = String(turn.turnId || index + 1);
|
|
576
|
-
return {
|
|
577
|
-
...turn,
|
|
578
|
-
agentTimeline: displayAgentTimeline(turn, turnId),
|
|
579
|
-
optimistic: turn.optimistic && typeof turn.optimistic === "object" && !Array.isArray(turn.optimistic)
|
|
580
|
-
? turn.optimistic
|
|
581
|
-
: null,
|
|
582
|
-
pending: turn.pending === true,
|
|
583
|
-
system: displayMessage(turn.system, {}, `${turnId}:system`),
|
|
584
|
-
turnId,
|
|
585
|
-
user: displayMessage(turn.user, {
|
|
586
|
-
preserveParagraphLineBreaks: true,
|
|
587
|
-
previewUserMessage: true
|
|
588
|
-
}, `${turnId}:user`)
|
|
589
|
-
};
|
|
590
|
-
})
|
|
591
|
-
.filter((turn) => turn.system || turn.user || turn.agentTimeline.length));
|
|
592
|
-
|
|
593
575
|
function userMessageCollapsible(message = null) {
|
|
594
576
|
return Array.isArray(message?.previewBlocks);
|
|
595
577
|
}
|
|
@@ -618,11 +600,11 @@ function toggleUserMessage(turn = {}) {
|
|
|
618
600
|
|
|
619
601
|
const loadingIndicatorVisible = computed(() => Boolean(
|
|
620
602
|
props.loading &&
|
|
621
|
-
!
|
|
603
|
+
!displayEntries.value.length
|
|
622
604
|
));
|
|
623
605
|
const initialScrollPending = computed(() => Boolean(
|
|
624
606
|
props.visible &&
|
|
625
|
-
|
|
607
|
+
displayEntries.value.length &&
|
|
626
608
|
!initialScrollSettled.value
|
|
627
609
|
));
|
|
628
610
|
|
|
@@ -638,40 +620,24 @@ function messageScrollKey(message = null) {
|
|
|
638
620
|
].join("/");
|
|
639
621
|
}
|
|
640
622
|
|
|
641
|
-
function agentTimelineScrollKey(timeline = []) {
|
|
642
|
-
return (Array.isArray(timeline) ? timeline : []).map((entry) => (
|
|
643
|
-
entry?.role === "thinking"
|
|
644
|
-
? (entry.messages || []).map(messageScrollKey).join("+")
|
|
645
|
-
: messageScrollKey(entry?.message)
|
|
646
|
-
)).join("|");
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
function latestRenderedTailScrollKey(turns = []) {
|
|
650
|
-
const turn = Array.isArray(turns) ? turns.at(-1) : null;
|
|
651
|
-
if (!turn) {
|
|
652
|
-
return "";
|
|
653
|
-
}
|
|
654
|
-
const timeline = Array.isArray(turn.agentTimeline) ? turn.agentTimeline : [];
|
|
655
|
-
return [
|
|
656
|
-
turn.turnId,
|
|
657
|
-
messageScrollKey(turn.system),
|
|
658
|
-
messageScrollKey(turn.user),
|
|
659
|
-
turn.optimistic?.id || "",
|
|
660
|
-
turn.optimistic?.status || "",
|
|
661
|
-
turn.optimistic?.error || "",
|
|
662
|
-
turn.pending ? "pending" : "settled",
|
|
663
|
-
agentTimelineScrollKey(timeline)
|
|
664
|
-
].join(":");
|
|
665
|
-
}
|
|
666
|
-
|
|
667
623
|
const timelineScrollTrigger = computed(() => [
|
|
668
624
|
props.visible ? "visible" : "hidden",
|
|
669
625
|
props.error ? "error" : "body",
|
|
670
626
|
loadingIndicatorVisible.value ? "loading" : "ready",
|
|
671
|
-
|
|
627
|
+
displayEntries.value.length ? "has-messages" : "empty",
|
|
672
628
|
props.scrollKey
|
|
673
629
|
].join(":"));
|
|
674
|
-
const latestRenderedTailKey = computed(() =>
|
|
630
|
+
const latestRenderedTailKey = computed(() => {
|
|
631
|
+
const entry = displayEntries.value.at(-1);
|
|
632
|
+
if (!entry) return "";
|
|
633
|
+
return [
|
|
634
|
+
entry.key,
|
|
635
|
+
isWorking.value,
|
|
636
|
+
entry.role === "thinking" ? entry.messages.map(messageScrollKey).join("+") : messageScrollKey(entry.message),
|
|
637
|
+
entry.turn.optimistic?.status || "",
|
|
638
|
+
entry.turn.optimistic?.error || ""
|
|
639
|
+
].join(":");
|
|
640
|
+
});
|
|
675
641
|
const autoScrollEnabled = computed(() => Boolean(
|
|
676
642
|
props.visible &&
|
|
677
643
|
followingLatest.value
|
|
@@ -802,6 +768,13 @@ function clearLiveBottomScroll() {
|
|
|
802
768
|
|
|
803
769
|
function updateLatestFollowFromScroll(event = {}) {
|
|
804
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
|
+
}
|
|
805
778
|
const shouldFollow = scrollElementNearBottom(target);
|
|
806
779
|
if (!shouldFollow && !userScrollIntent.value && followingLatest.value) {
|
|
807
780
|
return;
|
|
@@ -812,14 +785,20 @@ function updateLatestFollowFromScroll(event = {}) {
|
|
|
812
785
|
resumePendingTailFollow();
|
|
813
786
|
return;
|
|
814
787
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
788
|
+
clearLiveBottomScroll();
|
|
789
|
+
clearScheduledScrolls();
|
|
790
|
+
if (
|
|
791
|
+
scrolledUp &&
|
|
792
|
+
initialScrollSettled.value &&
|
|
793
|
+
scrollTop <= LOAD_MORE_THRESHOLD_PX &&
|
|
794
|
+
!props.loadMoreError
|
|
795
|
+
) {
|
|
796
|
+
requestLoadMore();
|
|
818
797
|
}
|
|
819
798
|
}
|
|
820
799
|
|
|
821
800
|
function requestLoadMore() {
|
|
822
|
-
if (!props.hasMoreBefore || props.loadingMore) {
|
|
801
|
+
if (!props.hasMoreBefore || props.loadingMore || loadMoreScrollSnapshot) {
|
|
823
802
|
return;
|
|
824
803
|
}
|
|
825
804
|
followingLatest.value = false;
|
|
@@ -863,7 +842,8 @@ async function completeLoadMoreRequest(version, changed) {
|
|
|
863
842
|
}
|
|
864
843
|
const element = bodyElement.value;
|
|
865
844
|
if (element) {
|
|
866
|
-
|
|
845
|
+
previousScrollTop = snapshot.scrollTop + (element.scrollHeight - snapshot.scrollHeight);
|
|
846
|
+
element.scrollTop = previousScrollTop;
|
|
867
847
|
}
|
|
868
848
|
clearLoadMoreScrollSnapshot();
|
|
869
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,111 @@ 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
|
+
|
|
57
|
+
test("adjacent reasoning stays grouped across storage rows, history pages and execution changes", {
|
|
58
|
+
skip: !RUN_BROWSER_TEST,
|
|
59
|
+
timeout: 90_000
|
|
60
|
+
}, async () => {
|
|
61
|
+
const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
|
|
62
|
+
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
63
|
+
try {
|
|
64
|
+
for (const width of [390, 800, 1365]) {
|
|
65
|
+
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
66
|
+
const errors = [];
|
|
67
|
+
page.on("pageerror", error => errors.push(error.message));
|
|
68
|
+
await page.goto(`${vite.baseURL}/?progress=1`);
|
|
69
|
+
const groups = page.locator(".assistant-progress");
|
|
70
|
+
const summaries = page.locator(".assistant-progress__message");
|
|
71
|
+
const input = page.getByRole("textbox", { name: "Message AI assistant" });
|
|
72
|
+
const external = name => page.getByRole("button", { name, exact: true }).evaluate(element => element.click());
|
|
73
|
+
await expect(groups).toHaveCount(1);
|
|
74
|
+
await expect(summaries).toHaveText(["Reasoning 2", "Reasoning 3"]);
|
|
75
|
+
await input.fill("Keep my draft");
|
|
76
|
+
await input.evaluate(element => element.setSelectionRange(2, 5));
|
|
77
|
+
await external("Change storage rows");
|
|
78
|
+
await expect(groups).toHaveCount(1);
|
|
79
|
+
await expect(summaries).toHaveText(["Reasoning 2", "Reasoning 3"]);
|
|
80
|
+
await external("Change storage rows");
|
|
81
|
+
await external("Toggle working");
|
|
82
|
+
await expect(summaries).toHaveCount(0);
|
|
83
|
+
await expect(page.getByRole("button", { name: "Show all 3 progress updates" })).toBeVisible();
|
|
84
|
+
await external("Toggle working");
|
|
85
|
+
await expect(summaries).toHaveText(["Reasoning 2", "Reasoning 3"]);
|
|
86
|
+
await external("Show all 3 progress updates");
|
|
87
|
+
await expect(summaries).toHaveCount(3);
|
|
88
|
+
await external("Load older messages");
|
|
89
|
+
await expect(groups).toHaveCount(1);
|
|
90
|
+
await expect(summaries).toHaveText(["Older reasoning", "Reasoning 1", "Reasoning 2", "Reasoning 3"]);
|
|
91
|
+
await external("Append thinking");
|
|
92
|
+
await expect(summaries).toHaveCount(5);
|
|
93
|
+
await expect(input).toBeFocused();
|
|
94
|
+
await expect(input).toHaveValue("Keep my draft");
|
|
95
|
+
assert.deepEqual(await input.evaluate(element => [element.selectionStart, element.selectionEnd]), [2, 5]);
|
|
96
|
+
await external("Show latest 2 progress updates");
|
|
97
|
+
await external("Toggle working");
|
|
98
|
+
await expect(summaries).toHaveCount(0);
|
|
99
|
+
await external("Show all 5 progress updates");
|
|
100
|
+
await external("Change conversation");
|
|
101
|
+
await expect(summaries).toHaveCount(0);
|
|
102
|
+
await external("Toggle working");
|
|
103
|
+
|
|
104
|
+
// Every visible non-reasoning message separates groups, regardless of rows.
|
|
105
|
+
for (const [index, role] of ["commentary", "assistant", "user", "system"].entries()) {
|
|
106
|
+
await external(`Append ${role}`);
|
|
107
|
+
await expect(summaries).toHaveCount(0);
|
|
108
|
+
await external("Append thinking");
|
|
109
|
+
await external("Append thinking");
|
|
110
|
+
await expect(groups).toHaveCount(index + 2);
|
|
111
|
+
await expect(summaries).toHaveCount(2);
|
|
112
|
+
}
|
|
113
|
+
await external("Change storage rows");
|
|
114
|
+
await expect(groups).toHaveCount(5);
|
|
115
|
+
await expect(summaries).toHaveCount(2);
|
|
116
|
+
const bodyText = await page.locator(".assistant-transcript__body").textContent();
|
|
117
|
+
assert.ok(bodyText.indexOf("commentary 6") < bodyText.indexOf("assistant 9"));
|
|
118
|
+
assert.ok(bodyText.indexOf("assistant 9") < bodyText.indexOf("user 12"));
|
|
119
|
+
assert.ok(bodyText.indexOf("user 12") < bodyText.indexOf("system 15"));
|
|
120
|
+
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
|
121
|
+
assert.deepEqual(errors, []);
|
|
122
|
+
await page.close();
|
|
123
|
+
}
|
|
124
|
+
} finally {
|
|
125
|
+
await browser.close();
|
|
126
|
+
await stopProcess(vite);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
25
130
|
async function assertResponsiveAssistant(page, viewport) {
|
|
26
131
|
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
27
132
|
await page.goto("/");
|
|
@@ -86,8 +191,8 @@ test("conversation scrolling preserves history anchors and resets expansion on s
|
|
|
86
191
|
await page.goto(`${vite.baseURL}/?controls=1`);
|
|
87
192
|
const body = page.locator(".assistant-transcript__body");
|
|
88
193
|
await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
|
|
89
|
-
|
|
90
|
-
await
|
|
194
|
+
// Keep the explicit button path covered independently of automatic loading.
|
|
195
|
+
await body.evaluate(element => { element.scrollTop = 0; });
|
|
91
196
|
await expect.poll(() => body.evaluate((element) => element.scrollTop)).toBe(0);
|
|
92
197
|
await page.getByRole("button", { name: "Read more", exact: true }).click();
|
|
93
198
|
await expect(page.getByRole("button", { name: "Show less", exact: true })).toBeVisible();
|
|
@@ -114,6 +219,99 @@ test("conversation scrolling preserves history anchors and resets expansion on s
|
|
|
114
219
|
}
|
|
115
220
|
});
|
|
116
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
|
+
|
|
117
315
|
test("conversation composer preserves typing and reacts to external state and pane resizing", {
|
|
118
316
|
skip: !RUN_BROWSER_TEST,
|
|
119
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
|
+
});
|