@jskit-ai/assistant-core 0.1.173 → 0.1.175

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.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.173",
12
- "@jskit-ai/http-runtime": "0.1.193",
13
- "@jskit-ai/kernel": "0.1.195",
11
+ "@jskit-ai/assistant-core": "0.1.175",
12
+ "@jskit-ai/http-runtime": "0.1.195",
13
+ "@jskit-ai/kernel": "0.1.197",
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.12",
18
- "@jskit-ai/connectors-catalog": "0.1.12"
17
+ "@jskit-ai/connectors-core": "0.1.14",
18
+ "@jskit-ai/connectors-catalog": "0.1.14"
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 controls = new URLSearchParams(location.search).has("controls");
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: { turns: conversationTurnsFromMessages(messages.value), scrollKey: scope.value, hasMoreBefore: controls },
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
- messages.value.unshift({ id: "older", role: "assistant", text: "Older conversation paragraph. ".repeat(40), status: "completed" });
21
- complete({ changed: true });
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.173",
3
+ "version": "0.1.175",
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.137",
37
- "@jskit-ai/resource-crud-core": "0.1.137",
36
+ "@jskit-ai/resource-core": "0.1.139",
37
+ "@jskit-ai/resource-crud-core": "0.1.139",
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.193",
47
- "@jskit-ai/kernel": "0.1.195",
46
+ "@jskit-ai/http-runtime": "0.1.195",
47
+ "@jskit-ai/kernel": "0.1.197",
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
- if (!shouldFollow) {
780
- clearLiveBottomScroll();
781
- clearScheduledScrolls();
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
- element.scrollTop = snapshot.scrollTop + (element.scrollHeight - snapshot.scrollHeight);
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 = assistantAttachmentFilesFromClipboardItems(clipboardData?.items);
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 deliverUploadedAttachments(onUploaded, [receipt]);
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
+ const { delta } = codexAppServerNotificationParams(notification);
352
+ return {
353
+ ...base,
354
+ kind: "assistant_delta",
355
+ delta: typeof delta === "string" ? 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,
@@ -1,2 +1,3 @@
1
1
  export { createConversationTranscript } from "./transcript.js";
2
2
  export { createMemoryConversationStorage } from "./memoryStorage.js";
3
+ export { createConversationStreams } from "./streams.js";
@@ -505,7 +505,19 @@ 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 = [
511
+ message.text,
512
+ ...(message.content || [])
513
+ .filter((part) => part?.type === "text")
514
+ .map((part) => part.text)
515
+ ].filter((value) => typeof value === "string" && value.trim());
516
+ return [...new Set(values)].join("\n\n");
517
+ }
518
+
508
519
  export {
520
+ openCodeAssistantMessageText,
509
521
  OPENCODE_RESPONSE_LIMIT_BYTES,
510
522
  createOpenCodeServerClient,
511
523
  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
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./turns.js";
2
+ export * from "./streaming.js";
2
3
  export * from "./longTextBlocks.js";
3
4
  export * from "./numberedQuestionSugar.js";
4
5
  export * from "./answerChoiceSugar.js";
@@ -0,0 +1,30 @@
1
+ function messagesForTurn(turn) {
2
+ return turn.messages || [
3
+ turn.system,
4
+ turn.user,
5
+ ...(turn.thinking || []),
6
+ ...(turn.commentary || []),
7
+ turn.assistant
8
+ ].filter(Boolean);
9
+ }
10
+
11
+ /** Overlay live provider output without changing the saved transcript. */
12
+ export function mergeConversationStream(turns = [], snapshot = {}) {
13
+ const savedIds = new Set(turns.flatMap(messagesForTurn).map((message) => message.messageId));
14
+ const messages = (snapshot?.messages || []).filter((message) => message.text && !savedIds.has(message.messageId));
15
+ if (!messages.length) return turns;
16
+ const result = [...turns];
17
+ for (const message of messages) {
18
+ const tail = result.at(-1);
19
+ const turn = tail && !tail.assistant && !tail.system
20
+ ? { ...tail, messages: [...messagesForTurn(tail)] }
21
+ : { turnId: `stream:${message.messageId}`, messages: [] };
22
+ if (turn.turnId === tail?.turnId) result[result.length - 1] = turn;
23
+ else result.push(turn);
24
+ turn.messages.push(message);
25
+ turn.pending = true;
26
+ if (message.role === "assistant") turn.assistant = message;
27
+ else turn.commentary = [...(turn.commentary || []), message];
28
+ }
29
+ return result;
30
+ }
@@ -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
- await body.hover();
163
- await page.mouse.wheel(0, -100_000);
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
+ });