@jskit-ai/assistant-core 0.1.204 → 0.1.206

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/App.vue +12 -2
  3. package/fixtures/responsive-assistant/ConversationFixture.vue +79 -2
  4. package/package.json +19 -9
  5. package/src/client/conversation/AssistantConversationElement.vue +107 -41
  6. package/src/client/conversation/AssistantTranscript.vue +192 -18
  7. package/src/server/conversation/claudeClient.js +2 -1
  8. package/src/server/conversation/claudeTurn.js +16 -7
  9. package/src/server/conversation/codexConversations.js +94 -0
  10. package/src/server/conversation/codexProvider.js +15 -5
  11. package/src/server/conversation/codexProviderOwner.js +70 -47
  12. package/src/server/conversation/codexProviderSelection.js +5 -2
  13. package/src/server/conversation/codexRuntimeLifecycle.js +2 -1
  14. package/src/server/conversation/codexTurn.js +10 -1
  15. package/src/server/conversation/codexTurnControl.js +1 -1
  16. package/src/server/conversation/codexTurnRecovery.js +21 -8
  17. package/src/server/conversation/openCodeRuntime.js +107 -64
  18. package/src/server/conversation/openCodeTurn.js +33 -16
  19. package/src/server/conversation/providers/api.js +1 -0
  20. package/src/server/conversation/providers/claudeDriver.js +13 -0
  21. package/src/server/conversation/providers/codex.js +90 -47
  22. package/src/server/conversation/providers/codexDriver.js +4 -1
  23. package/src/server/conversation/providers/opencodeDriver.js +3 -0
  24. package/src/server/conversation/runtime.js +32 -5
  25. package/src/server/hosts/codexEvents.js +15 -0
  26. package/src/server/hosts/codexTurn.js +5 -0
  27. package/src/server/hosts/openCodeClient.js +4 -0
  28. package/src/server/lib/aiConnectionClient.js +9 -11
  29. package/test/aiConnectionClient.test.js +209 -0
  30. package/test/assistantScroll.browser.test.js +437 -9
  31. package/test/claudeClient.test.js +41 -0
  32. package/test/claudeConversationSession.test.js +78 -0
  33. package/test/claudeTurn.test.js +18 -0
  34. package/test/codexConversationSession.test.js +318 -0
  35. package/test/codexTurn.test.js +203 -2
  36. package/test/conversationConfiguration.test.js +27 -0
  37. package/test/conversationReplacement.test.js +76 -1
  38. package/test/conversationRuntime.test.js +173 -4
  39. package/test/conversationSession.test.js +139 -0
  40. package/test/conversationTools.test.js +17 -3
  41. package/test/opencodeConversationSession.test.js +200 -1
  42. package/test/packageMetadata.test.js +75 -0
  43. package/test/serviceToolCatalog.test.js +31 -0
@@ -405,7 +405,10 @@ test("upward scrolling loads older history once and preserves retry and selectio
405
405
  await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
406
406
  await expect(requests).toHaveText("History requests: 3");
407
407
  await external("Exhaust history");
408
- await body.focus();
408
+ await expect.poll(async () => {
409
+ await body.focus();
410
+ return body.evaluate(element => document.activeElement === element);
411
+ }).toBe(true);
409
412
  await body.press("Home");
410
413
  await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(0);
411
414
  await expect(requests).toHaveText("History requests: 3");
@@ -420,6 +423,206 @@ test("upward scrolling loads older history once and preserves retry and selectio
420
423
  }
421
424
  });
422
425
 
426
+ test("retained hidden transcripts preserve readers and follow only at the latest message", {
427
+ skip: !RUN_BROWSER_TEST,
428
+ timeout: 90_000
429
+ }, async () => {
430
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
431
+ const browser = await chromium.launch(createChromiumLaunchOptions());
432
+ try {
433
+ for (const width of [390, 800, 1365]) {
434
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
435
+ await page.goto(`${vite.baseURL}/?controls=1&history=1&retain-hidden=1&initial-hidden=1`);
436
+ const body = page.locator(".assistant-transcript__body");
437
+ const external = name => page.getByRole("button", { name, exact: true }).evaluate(element => element.click());
438
+ await expect(body).toBeAttached();
439
+ await expect(body).toBeHidden();
440
+ await external("Toggle visibility");
441
+ await expect(body).toBeVisible();
442
+ await expect(page.locator(".assistant-transcript__settling")).toHaveCount(0);
443
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
444
+ const draft = page.getByRole("textbox", { name: "Message AI assistant", exact: true });
445
+ await draft.fill("Keep my typed draft");
446
+ await body.hover();
447
+ await page.mouse.wheel(0, -1000);
448
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeGreaterThan(50);
449
+ await body.evaluate(element => { element.scrollTop = 420; });
450
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
451
+ await body.evaluate(element => {
452
+ window.retainedBody = element;
453
+ const top = element.getBoundingClientRect().top;
454
+ const anchor = [...element.querySelectorAll(".assistant-transcript__turn")].find(turn => turn.getBoundingClientRect().bottom > top);
455
+ window.retainedAnchor = { element: anchor, offset: anchor.getBoundingClientRect().top - top };
456
+ });
457
+ await external("Toggle visibility");
458
+ await expect(body).toBeHidden();
459
+ await external("Append reply");
460
+ // Hidden zero-geometry events must not change following or fetch history.
461
+ await body.evaluate(element => element.dispatchEvent(new Event("scroll")));
462
+ await expect(page.locator("output")).toHaveText("History requests: 0");
463
+ await external("Toggle visibility");
464
+ await expect(body).toBeVisible();
465
+ assert.equal(await body.evaluate(element => element === window.retainedBody), true);
466
+ await expect.poll(() => body.evaluate(element => Math.abs(window.retainedAnchor.element.getBoundingClientRect().top - element.getBoundingClientRect().top - window.retainedAnchor.offset))).toBeLessThan(2);
467
+ await expect(draft).toHaveValue("Keep my typed draft");
468
+ await body.hover();
469
+ await page.mouse.wheel(0, 100_000);
470
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
471
+ await external("Toggle visibility");
472
+ await external("Append reply");
473
+ await external("Toggle visibility");
474
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
475
+ await body.hover();
476
+ await page.mouse.wheel(0, -1000);
477
+ await external("Toggle visibility");
478
+ await external("Change conversation");
479
+ await external("Toggle visibility");
480
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
481
+ await expect(page.locator(".assistant-transcript__settling")).toHaveCount(0);
482
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
483
+ await page.close();
484
+ }
485
+ } finally {
486
+ await browser.close();
487
+ await stopProcess(vite);
488
+ }
489
+ });
490
+
491
+ test("transient update errors keep the loaded transcript, reader, draft and focus in a compact banner", {
492
+ skip: !RUN_BROWSER_TEST,
493
+ timeout: 90_000
494
+ }, async () => {
495
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
496
+ const browser = await chromium.launch(createChromiumLaunchOptions());
497
+ try {
498
+ for (const width of [390, 800, 1365]) {
499
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
500
+ await page.goto(`${vite.baseURL}/?controls=1&history=1&update-errors=1`);
501
+ const body = page.locator(".assistant-transcript__body");
502
+ const draft = page.getByRole("textbox", { name: "Message AI assistant", exact: true });
503
+ const external = name => page.getByRole("button", { name, exact: true }).evaluate(element => element.click());
504
+ await expect(page.locator(".assistant-transcript__settling")).toHaveCount(0);
505
+ await draft.fill("Keep my draft while updates reconnect.");
506
+ await body.hover();
507
+ await page.mouse.wheel(0, -1000);
508
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeGreaterThan(50);
509
+ await body.evaluate(element => { element.scrollTop = 420; window.updateErrorBody = element; });
510
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
511
+ await draft.evaluate(element => {
512
+ element.focus({ preventScroll: true });
513
+ element.setSelectionRange(2, 7);
514
+ window.updateErrorDraft = element;
515
+ });
516
+ const rows = await body.locator(".assistant-transcript__turn").count();
517
+ await external("Show update error");
518
+ const banner = page.locator(".assistant-transcript__error");
519
+ await expect(banner).toContainText("Conversation updates are temporarily unavailable. Reload to reconnect; the assistant may still be working.");
520
+ await expect(banner.getByRole("button", { name: "Reload chat", exact: true })).toBeVisible();
521
+ await expect(body).toBeVisible();
522
+ assert.equal(await body.evaluate(element => element === window.updateErrorBody), true);
523
+ await expect(body.locator(".assistant-transcript__turn")).toHaveCount(rows);
524
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
525
+ await expect(draft).toHaveValue("Keep my draft while updates reconnect.");
526
+ await expect(draft).toBeFocused();
527
+ assert.deepEqual(await draft.evaluate(element => [element === window.updateErrorDraft, element.selectionStart, element.selectionEnd]), [true, 2, 7]);
528
+ const transcriptBox = await page.locator(".assistant-transcript").boundingBox();
529
+ const bannerBox = await banner.boundingBox();
530
+ const bodyBox = await body.boundingBox();
531
+ assert.ok(bannerBox.height < transcriptBox.height / 3);
532
+ assert.ok(bodyBox.height > transcriptBox.height / 2);
533
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
534
+ await banner.getByRole("button", { name: "Reload chat", exact: true }).click();
535
+ await expect(banner).toHaveCount(0);
536
+ await expect(page.locator(".assistant-conversation")).toHaveAttribute("data-error-reloads", "1");
537
+ assert.equal(await body.evaluate(element => element === window.updateErrorBody), true);
538
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
539
+ await expect(draft).toHaveValue("Keep my draft while updates reconnect.");
540
+ await expect(page.locator("output")).toHaveText("History requests: 0");
541
+ await body.hover();
542
+ await page.mouse.wheel(0, 100_000);
543
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
544
+ await external("Show update error");
545
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
546
+ await banner.getByRole("button", { name: "Reload chat", exact: true }).click();
547
+ await expect(banner).toHaveCount(0);
548
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
549
+ await expect(page.locator(".assistant-conversation")).toHaveAttribute("data-error-reloads", "2");
550
+ await page.close();
551
+ }
552
+ } finally {
553
+ await browser.close();
554
+ await stopProcess(vite);
555
+ }
556
+ });
557
+
558
+ test("retained hidden history loads restore their original anchor and invalidate retired requests", {
559
+ skip: !RUN_BROWSER_TEST,
560
+ timeout: 90_000
561
+ }, async () => {
562
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
563
+ const browser = await chromium.launch(createChromiumLaunchOptions());
564
+ try {
565
+ for (const width of [390, 800, 1365]) {
566
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
567
+ const body = page.locator(".assistant-transcript__body");
568
+ const external = name => page.getByRole("button", { name, exact: true }).evaluate(element => element.click());
569
+ const load = async () => {
570
+ await page.goto(`${vite.baseURL}/?controls=1&history=1&paged-history=1&retain-hidden=1`);
571
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
572
+ await body.evaluate(element => {
573
+ element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1000 }));
574
+ element.scrollTop = 0;
575
+ element.dispatchEvent(new Event("scroll", { bubbles: true }));
576
+ });
577
+ await expect(page.getByRole("button", { name: "Loading older messages…", exact: true })).toBeDisabled();
578
+ };
579
+ await load();
580
+ const anchor = page.getByText("user message 21.", { exact: false });
581
+ const before = (await anchor.boundingBox()).y;
582
+ await body.evaluate(element => { window.retainedBody = element; });
583
+ await external("Toggle visibility");
584
+ await external("Complete history load");
585
+ await expect(page.getByText("user message 1.", { exact: false })).toHaveCount(1);
586
+ await expect(body).toBeHidden();
587
+ await external("Toggle visibility");
588
+ assert.equal(await body.evaluate(element => element === window.retainedBody), true);
589
+ await expect(body).toBeVisible();
590
+ await expect.poll(async () => Math.abs((await anchor.boundingBox()).y - before)).toBeLessThan(2);
591
+ await expect(page.locator("output")).toHaveText("History requests: 1");
592
+
593
+ await load();
594
+ const errorScrollTop = await body.evaluate(element => element.scrollTop);
595
+ await external("Toggle visibility");
596
+ await external("Fail history load");
597
+ await external("Toggle visibility");
598
+ await expect(page.getByText("History unavailable", { exact: true })).toBeVisible();
599
+ assert.equal(await body.evaluate(element => element.scrollTop), errorScrollTop, "unchanged/error completion preserves the original scrollTop while showing recovery");
600
+ await page.getByRole("button", { name: "Load older messages", exact: true }).evaluate(element => element.click());
601
+ await expect(page.locator("output")).toHaveText("History requests: 2");
602
+ await external("Toggle visibility");
603
+ await external("Change conversation");
604
+ await external("Complete history load");
605
+ await external("Toggle visibility");
606
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
607
+
608
+ await load();
609
+ await body.evaluate(element => { window.retiredBody = element; });
610
+ await external("Toggle visibility");
611
+ await external("Toggle mount");
612
+ await external("Complete history load");
613
+ await external("Toggle mount");
614
+ await external("Toggle visibility");
615
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
616
+ assert.equal(await body.evaluate(element => element === window.retiredBody), false);
617
+ await expect(page.locator("output")).toHaveText("History requests: 1");
618
+ await page.close();
619
+ }
620
+ } finally {
621
+ await browser.close();
622
+ await stopProcess(vite);
623
+ }
624
+ });
625
+
423
626
  test("conversation composer preserves typing and reacts to external state and pane resizing", {
424
627
  skip: !RUN_BROWSER_TEST,
425
628
  timeout: 60_000
@@ -630,17 +833,21 @@ test("an optional avatar clamps in a short conversation without consuming its pr
630
833
  try {
631
834
  for (const width of [390, 800, 1365]) {
632
835
  const page = await browser.newPage({ viewport: { width, height: 900 } });
633
- await page.goto(`${vite.baseURL}/?conversation=1&avatar=1&capabilities=1`);
836
+ await page.goto(`${vite.baseURL}/?conversation=1&avatar=1&avatarTools=1&capabilities=1`);
634
837
  const panel = page.locator(".fixture");
635
838
  const input = page.getByRole("textbox", { name: "Message AI assistant" });
636
839
  const body = page.locator(".assistant-transcript__body");
637
840
  const artwork = page.locator(".assistant-conversation__avatar-visual");
638
- const sizeControl = page.getByRole("button", { name: "Avatar size", exact: true });
841
+ const sizeControl = page.getByRole("button", { name: /^(Show|Minimise) avatar$/u });
842
+ await expect(page.getByRole("button", { name: "Avatar size", exact: true })).toHaveCount(0);
843
+ const avatarTool = page.getByRole("button", { name: "Avatar tool", exact: true, includeHidden: true });
844
+ await expect(sizeControl).toHaveCount(1);
845
+ assert.equal(await sizeControl.evaluate(element => Boolean(element.closest(".assistant-conversation__composer"))), false,
846
+ "Avatar visibility stays outside the original composer");
847
+ assert.equal(await avatarTool.evaluate(element => Boolean(element.closest(".assistant-conversation__avatar"))), true,
848
+ "Avatar tools share the artwork region outside the composer");
639
849
  const selectSize = async name => {
640
- await sizeControl.focus();
641
- await sizeControl.press("Enter");
642
- await page.getByRole("menuitem", { name, exact: true }).click();
643
- await expect(page.getByRole("menu", { name: "Avatar size", exact: true })).toBeHidden();
850
+ await page.getByRole("combobox", { name: "Avatar preference", exact: true }).selectOption(name.toLowerCase());
644
851
  await expect(page.getByText(`Avatar requested: ${name.toLowerCase()}`, { exact: true })).toBeVisible();
645
852
  };
646
853
  const assertComposerFits = async () => {
@@ -648,23 +855,51 @@ test("an optional avatar clamps in a short conversation without consuming its pr
648
855
  const send = await page.getByRole("button", { name: "Send", exact: true }).boundingBox();
649
856
  const feedback = await page.getByText("Describe what you want to change.", { exact: true }).boundingBox();
650
857
  const avatarButton = await sizeControl.boundingBox();
858
+ const toolBounds = await avatarTool.boundingBox();
651
859
  assert.ok(send.y + send.height <= bounds.y + bounds.height + 1, "Send stays inside the supplied container");
652
860
  assert.ok(feedback.y + feedback.height <= bounds.y + bounds.height + 1, "Feedback stays inside the supplied container");
653
861
  assert.ok((await input.boundingBox()).height >= 40);
654
- assert.ok(avatarButton.width >= 48 && avatarButton.height >= 48, "Avatar sizing retains a reachable touch target");
862
+ const avatarExpanded = await sizeControl.getAttribute("aria-expanded") === "true";
863
+ assert.equal(avatarButton.width, avatarExpanded ? 40 : 44, "Expanded minimise is compact; collapsed Show retains its wider target");
864
+ assert.ok(avatarButton.height >= 44, "Avatar visibility retains its original target height");
865
+ assert.ok(toolBounds.width >= 48 && toolBounds.height >= 48, "Avatar tools retain a reachable touch target");
655
866
  await expect.poll(async () => (await body.boundingBox()).height, { message: "The short container retains readable history" }).toBeGreaterThanOrEqual(100);
656
867
  assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
657
868
  };
658
869
  assert.equal(Math.round((await panel.boundingBox()).width), 320);
659
870
  assert.equal(Math.round((await panel.boundingBox()).height), 420);
660
- await expect(artwork).toHaveCSS("height", "64px");
871
+ await expect.poll(async () => (await artwork.boundingBox()).height).toBeGreaterThan(64);
872
+ assert.ok((await artwork.boundingBox()).height <= 96, "Compact artwork preserves the existing short-container clamp");
873
+ await page.getByRole("button", { name: "Resize height", exact: true }).evaluate(element => element.click());
874
+ await expect(artwork).toHaveCSS("height", "96px");
875
+ await page.getByRole("button", { name: "Resize height", exact: true }).evaluate(element => element.click());
876
+ await expect.poll(async () => (await panel.boundingBox()).height).toBe(420);
877
+ await expect(page.locator(".assistant-conversation__avatar")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
878
+ const chatBounds = await page.locator(".assistant-conversation").boundingBox();
879
+ const faceBounds = await artwork.boundingBox();
880
+ const hideBounds = await sizeControl.boundingBox();
881
+ assert.equal(faceBounds.y, chatBounds.y, "The face starts at the top of the chat without a controls row above it");
882
+ const avatarToolBounds = await avatarTool.boundingBox();
883
+ assert.ok(Math.abs(hideBounds.y + hideBounds.height / 2 - avatarToolBounds.y - avatarToolBounds.height / 2) < 1,
884
+ "The round minimise control shares the tools row below the face");
885
+ assert.equal(hideBounds.x, avatarToolBounds.x + avatarToolBounds.width, "Minimise directly follows the supplied tools without a gap");
886
+ await expect(sizeControl).toHaveText("");
887
+ await expect(sizeControl.locator('.assistant-conversation__avatar-size-disc')).toHaveCSS('border-radius', '50%');
661
888
  await expect(page.locator('[data-message-role="assistant"]')).toHaveCount(20);
662
889
  await page.evaluate(() => {
663
890
  window.avatarElements = [document.querySelector(".assistant-conversation"), document.querySelector("textarea"), document.querySelector(".assistant-transcript__body")];
891
+ window.avatarTool = document.querySelector(".assistant-conversation__avatar-presentation .fixture-avatar-tool");
664
892
  });
665
893
  await input.fill("Keep my typed support question.");
666
894
  await input.evaluate(element => element.setSelectionRange(5, 9));
895
+ const compactAvatarBounds = await artwork.boundingBox();
896
+ const composerBounds = [await input.boundingBox(), await page.getByRole("button", { name: "Send", exact: true }).boundingBox()];
897
+ const fixtureLayout = await panel.evaluate(element => ({
898
+ panel: element.getBoundingClientRect().toJSON(),
899
+ controls: document.querySelector(".controls").getBoundingClientRect().toJSON()
900
+ }));
667
901
  await body.evaluate(element => { element.scrollTop = 100; });
902
+ assert.deepEqual(await artwork.boundingBox(), compactAvatarBounds, "The avatar stays fixed within the chat while messages scroll behind it");
668
903
  const before = await body.evaluate(element => element.scrollTop);
669
904
  await selectSize("Large");
670
905
  await expect.poll(async () => (await artwork.boundingBox()).height).toBeLessThan(176);
@@ -673,7 +908,35 @@ test("an optional avatar clamps in a short conversation without consuming its pr
673
908
  await selectSize("Hidden");
674
909
  await expect(artwork).toHaveCount(0);
675
910
  await expect(sizeControl).toBeVisible();
911
+ const showBounds = await sizeControl.boundingBox();
912
+ assert.equal(showBounds.width, 44, "Collapsed Show retains its original wider target");
913
+ assert.ok(showBounds.height >= 44);
914
+ const collapsedControls = await page.locator(".assistant-conversation__avatar-controls").boundingBox();
915
+ assert.equal(collapsedControls.y, chatBounds.y, "The collapsed controls stay at the chat top");
916
+ assert.equal(showBounds.y + showBounds.height / 2, collapsedControls.y + collapsedControls.height / 2,
917
+ "Show aligns with the existing taller host control in the top row");
918
+ assert.equal(showBounds.x + showBounds.width, chatBounds.x + chatBounds.width, "Show stays at the chat right edge");
919
+ await expect(avatarTool).toHaveCount(1);
920
+ await expect(avatarTool).toBeHidden();
921
+ await expect(page.getByRole("button", { name: "Retained avatar control", exact: true })).toBeVisible();
922
+ assert.equal(await page.evaluate(() => window.avatarTool.isConnected), true, "Hiding retains the local tools target");
923
+ if (process.env.JSKIT_ASSISTANT_CORE_BROWSER_ARTIFACTS) {
924
+ await page.screenshot({ path: path.join(process.env.JSKIT_ASSISTANT_CORE_BROWSER_ARTIFACTS, `hidden-${width}.png`) });
925
+ }
926
+ assert.deepEqual([await input.boundingBox(), await page.getByRole("button", { name: "Send", exact: true }).boundingBox()], composerBounds,
927
+ `Collapsing changes no original input or Send rectangle: ${JSON.stringify({ before: fixtureLayout, after: await panel.evaluate(element => ({
928
+ panel: element.getBoundingClientRect().toJSON(), controls: document.querySelector(".controls").getBoundingClientRect().toJSON()
929
+ })) })}`);
930
+ await page.getByRole("button", { name: "Show avatar", exact: true }).click();
931
+ await expect(page.getByText("Avatar requested: large", { exact: true })).toBeVisible();
932
+ await expect(avatarTool).toBeVisible();
933
+ assert.deepEqual([await input.boundingBox(), await page.getByRole("button", { name: "Send", exact: true }).boundingBox()], composerBounds,
934
+ "Showing the last visible size changes no original input or Send rectangle");
935
+ await page.getByRole("button", { name: "Minimise avatar", exact: true }).click();
676
936
  await selectSize("Standard");
937
+ await expect(avatarTool).toBeVisible();
938
+ await expect(avatarTool).toHaveAttribute("data-size", "standard");
939
+ assert.equal(await avatarTool.evaluate(element => element === window.avatarTool), true, "Showing reuses the original tools node");
677
940
  await expect.poll(async () => (await artwork.boundingBox()).height).toBeGreaterThan(0);
678
941
  await selectSize("Large");
679
942
  await input.focus();
@@ -721,3 +984,168 @@ test("an optional avatar clamps in a short conversation without consuming its pr
721
984
  await stopProcess(vite);
722
985
  }
723
986
  });
987
+
988
+
989
+ test("unsent preview edits stay in their bubble and disappear for the canonical message", {
990
+ skip: !RUN_BROWSER_TEST, timeout: 60_000
991
+ }, async () => {
992
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
993
+ const browser = await chromium.launch(createChromiumLaunchOptions());
994
+ try {
995
+ const page = await browser.newPage({ viewport: { width: 320, height: 844 } });
996
+ await page.goto(`${vite.baseURL}/?conversation=1&preview=1`);
997
+ const input = page.getByRole("textbox", { name: "Message AI assistant" });
998
+ const discard = page.getByRole("button", { name: "Discard unsent message", exact: true });
999
+ const edit = page.getByRole("button", { name: "Edit unsent message", exact: true });
1000
+ await page.getByRole("button", { name: "External active", exact: true }).click();
1001
+ await input.fill("Keep this typed draft");
1002
+ await page.getByRole("button", { name: "Add temporary message", exact: true }).click();
1003
+ const bubble = page.locator(".assistant-transcript__message--user");
1004
+ await expect(bubble).toContainText("Exact temporary words");
1005
+ await expect(bubble.getByRole("button", { name: "Discard unsent message" })).toBeVisible();
1006
+ await expect(edit).toBeEnabled();
1007
+ await expect(discard).toBeEnabled();
1008
+ await page.getByRole("button", { name: "Toggle preview eligibility" }).click();
1009
+ await expect(discard).toBeDisabled();
1010
+ await page.getByRole("button", { name: "Toggle preview eligibility" }).click();
1011
+ await discard.click();
1012
+ await expect(bubble).toHaveCount(0);
1013
+ await expect(input).toHaveValue("Keep this typed draft");
1014
+ await page.getByRole("button", { name: "Add temporary message", exact: true }).click();
1015
+ await page.getByRole("button", { name: "Reject preview", exact: true }).click();
1016
+ await expect(bubble).toHaveCount(1);
1017
+ await expect(bubble).toContainText("No active turn; explicit Edit may author a new message.");
1018
+ await expect(bubble.locator(".assistant-transcript__optimistic-actions")).toHaveCount(0);
1019
+ await expect(bubble.locator(".assistant-conversation__preview-actions")).toHaveCount(1);
1020
+ await expect(edit).toBeEnabled();
1021
+ await edit.click();
1022
+ await expect(bubble).not.toContainText("No active turn; explicit Edit may author a new message.");
1023
+ const editor = bubble.getByRole("textbox", { name: "Review your message" });
1024
+ const send = bubble.getByRole("button", { name: "Send", exact: true });
1025
+ await expect(editor).toHaveValue("Exact temporary words");
1026
+ await expect(input).toHaveValue("Keep this typed draft");
1027
+ await expect(page.getByRole("textbox")).toHaveCount(2);
1028
+ await expect(bubble.locator(".assistant-conversation__preview-actions")).toHaveCount(1);
1029
+ await editor.fill("");
1030
+ await expect(editor).toHaveValue("");
1031
+ await expect(bubble).toHaveCount(1);
1032
+ await expect(send).toBeDisabled();
1033
+ await editor.fill("Corrected speech\nwith a second line");
1034
+ await expect(send).toBeEnabled();
1035
+ await expect(input).toHaveValue("Keep this typed draft");
1036
+ await send.click();
1037
+ await expect(bubble).toContainText("Corrected speech");
1038
+ await expect(editor).toHaveCount(0);
1039
+ await expect(discard).toHaveCount(0);
1040
+ await expect(input).toHaveValue("Keep this typed draft");
1041
+ await page.reload();
1042
+ await input.fill("Keep this typed draft");
1043
+ await page.getByRole("button", { name: "External active", exact: true }).click();
1044
+ await page.getByRole("button", { name: "Add temporary message", exact: true }).click();
1045
+ await page.getByRole("button", { name: "Unadmitted preview" }).click();
1046
+ await expect(bubble).toHaveCount(1);
1047
+ await edit.click();
1048
+ await expect(bubble.getByRole("textbox", { name: "Review your message" })).toHaveValue("Exact temporary words");
1049
+ await expect(input).toHaveValue("Keep this typed draft");
1050
+ await page.getByRole("button", { name: "Accept preview" }).click();
1051
+ await expect(bubble).toHaveCount(1);
1052
+ await expect(bubble).toContainText("Exact temporary words");
1053
+ await expect(discard).toHaveCount(0);
1054
+ await expect(edit).toHaveCount(0);
1055
+ await expect(input).toHaveValue("Keep this typed draft");
1056
+ await expect(page.locator("output").last()).toHaveText("active; submitted 0");
1057
+ for (const recovery of ["edit", "discard"]) {
1058
+ await page.reload();
1059
+ await input.fill("Keep this typed draft");
1060
+ await page.getByRole("button", { name: "External active", exact: true }).click();
1061
+ await page.getByRole("button", { name: "Add temporary message", exact: true }).click();
1062
+ await page.getByRole("button", { name: "Reject preview", exact: true }).click();
1063
+ await page.getByRole("button", { name: "Add newer temporary message", exact: true }).click();
1064
+ await expect(bubble).toHaveCount(2);
1065
+ const retained = bubble.first();
1066
+ const newer = bubble.last();
1067
+ await expect(retained).toContainText("No active turn; explicit Edit may author a new message.");
1068
+ await expect(retained.locator(".assistant-transcript__optimistic-actions")).toHaveCount(0);
1069
+ await expect(newer).toContainText("Keep newer live words");
1070
+ await expect(newer.getByRole("button", { name: "Edit unsent message", exact: true })).toBeDisabled();
1071
+ await expect(newer.getByRole("button", { name: "Discard unsent message", exact: true })).toBeEnabled();
1072
+ if (recovery === "edit") {
1073
+ await retained.getByRole("button", { name: "Edit unsent message", exact: true }).click();
1074
+ await expect(newer).toContainText("Keep newer live words");
1075
+ const retainedEditor = retained.getByRole("textbox", { name: "Review your message" });
1076
+ await expect(retainedEditor).toHaveValue("Exact temporary words");
1077
+ await retainedEditor.fill("Edited retained words");
1078
+ await expect(input).toHaveValue("Keep this typed draft");
1079
+ await retained.getByRole("button", { name: "Send", exact: true }).click();
1080
+ await expect(retained).toContainText("Edited retained words");
1081
+ await expect(retained.getByRole("button", { name: "Edit unsent message", exact: true })).toHaveCount(0);
1082
+ await expect(bubble).toHaveCount(2);
1083
+ await expect(newer).toContainText("Keep newer live words");
1084
+ } else {
1085
+ await retained.getByRole("button", { name: "Discard unsent message", exact: true }).click();
1086
+ await expect(bubble).toHaveCount(1);
1087
+ await expect(bubble).toContainText("Keep newer live words");
1088
+ }
1089
+ const current = bubble.last();
1090
+ await expect(current.getByRole("button", { name: "Edit unsent message", exact: true })).toBeEnabled();
1091
+ await expect(input).toHaveValue("Keep this typed draft");
1092
+ await current.getByRole("button", { name: "Discard unsent message", exact: true }).click();
1093
+ await expect(bubble).toHaveCount(recovery === "edit" ? 1 : 0);
1094
+ await expect(input).toHaveValue("Keep this typed draft");
1095
+ }
1096
+ } finally {
1097
+ await browser.close();
1098
+ await stopProcess(vite);
1099
+ }
1100
+ });
1101
+
1102
+
1103
+ test("pending history keeps following the reader when a new reply changes transcript height", {
1104
+ skip: !RUN_BROWSER_TEST,
1105
+ timeout: 90_000
1106
+ }, async () => {
1107
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
1108
+ const browser = await chromium.launch(createChromiumLaunchOptions());
1109
+ try {
1110
+ for (const width of [390, 1365]) {
1111
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
1112
+ const errors = [];
1113
+ page.on("pageerror", error => errors.push(error.message));
1114
+ await page.goto(`${vite.baseURL}/?controls=1&history=1`);
1115
+ const body = page.locator(".assistant-transcript__body");
1116
+ const requests = page.locator("output");
1117
+ const external = name => page.getByRole("button", { name, exact: true }).evaluate(element => element.click());
1118
+ await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
1119
+ await body.hover();
1120
+ await page.mouse.wheel(0, -100);
1121
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeGreaterThan(50);
1122
+ await body.evaluate(element => { element.scrollTop = 420; });
1123
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
1124
+ await page.mouse.wheel(0, -324);
1125
+ const pending = page.getByRole("button", { name: "Loading older messages…", exact: true });
1126
+ await expect(pending).toBeDisabled();
1127
+ await expect(requests).toHaveText("History requests: 1");
1128
+ const height = await body.evaluate(element => element.scrollHeight);
1129
+ await external("Append reply");
1130
+ await expect.poll(() => body.evaluate(element => element.scrollHeight)).toBeGreaterThan(height);
1131
+ await expect.poll(async () => {
1132
+ await body.focus();
1133
+ return body.evaluate(element => document.activeElement === element);
1134
+ }).toBe(true);
1135
+ await body.press("Home");
1136
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(0);
1137
+ const original = page.getByText("Conversation line 1:", { exact: false }).first();
1138
+ const originalTop = (await original.boundingBox()).y;
1139
+ await external("Complete history load");
1140
+ await expect(pending).toHaveCount(0);
1141
+ await expect.poll(async () => Math.abs((await original.boundingBox()).y - originalTop)).toBeLessThan(2);
1142
+ await expect(requests).toHaveText("History requests: 1");
1143
+ assert.deepEqual(errors, []);
1144
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
1145
+ await page.close();
1146
+ }
1147
+ } finally {
1148
+ await browser.close();
1149
+ await stopProcess(vite);
1150
+ }
1151
+ });
@@ -79,6 +79,47 @@ test("Claude control replies bypass slow event persistence while events remain o
79
79
  }
80
80
  });
81
81
 
82
+ test("normal Claude EOF drains held events before closing the half-open stream", async () => {
83
+ const entered = Promise.withResolvers();
84
+ const release = Promise.withResolvers();
85
+ const ended = Promise.withResolvers();
86
+ const observed = [];
87
+ const failures = [];
88
+ const stream = fakeStream(() => {});
89
+ stream.once("end", ended.resolve);
90
+ const client = createClaudeJsonClient({
91
+ stream,
92
+ async onEvent(frame) {
93
+ if (frame.number === 1) {
94
+ entered.resolve();
95
+ await release.promise;
96
+ }
97
+ observed.push(frame.number);
98
+ },
99
+ onFailure(error) { failures.push(error); }
100
+ });
101
+ try {
102
+ stream.push('{"type":"event","number":1}\n{"type":"event","number":2}\n');
103
+ stream.push(null);
104
+ await entered.promise;
105
+ await ended.promise;
106
+ await new Promise(resolve => setImmediate(resolve));
107
+ assert.equal(stream.writableEnded, false, "EOF does not close the original writable side");
108
+ assert.equal(stream.destroyed, false, "The stream survives while event persistence is held");
109
+ assert.deepEqual(observed, []);
110
+ assert.deepEqual(failures, []);
111
+ release.resolve();
112
+ await client.completion;
113
+ assert.deepEqual(observed, [1, 2]);
114
+ assert.equal(failures.length, 1);
115
+ assert.equal(failures[0].code, "assistant_claude_observation_lost");
116
+ assert.equal(stream.destroyed, true, "The original client still owns final teardown");
117
+ } finally {
118
+ release.resolve();
119
+ client.close();
120
+ }
121
+ });
122
+
82
123
  test("Claude interrupt uses the common 30-second control deadline", async (t) => {
83
124
  t.mock.timers.enable({ apis: ["setTimeout"] });
84
125
  const client = createClaudeJsonClient({ stream: fakeStream(() => {}) });
@@ -1199,3 +1199,81 @@ test("the same Claude driver reopens a verified closed conversation after retryi
1199
1199
  await local.close();
1200
1200
  }
1201
1201
  });
1202
+
1203
+
1204
+ for (const change of ["model", "effort"]) test(`Claude honors a consumer's fresh native policy for ${change} changes and restart`, async t => {
1205
+ const f = await fixture(t);
1206
+ const binding = () => f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding);
1207
+ await f.conversation.send(input);
1208
+ const original = await f.conversation.wait();
1209
+ const previous = await binding();
1210
+ const beforeSelection = await f.trace();
1211
+ const oldHistoryPath = path.join(previous.configRoot, "projects", previous.workdir.replace(/[^a-zA-Z0-9]/gu, "-"), `${previous.conversationId}.jsonl`);
1212
+ const oldHistory = await readFile(oldHistoryPath, "utf8");
1213
+ const selected = { ...configuration, [change]: change === "model" ? "another-model" : "low" };
1214
+ const request = { operationId: `fresh-${change}`, expectedSegmentId: original.segmentId,
1215
+ engine: "claude", configuration: selected, retireNative: true };
1216
+ const receipt = await f.conversation.select(request);
1217
+ assert.notEqual(receipt.segmentId, original.segmentId);
1218
+ const inert = await binding();
1219
+ assert.notEqual(inert.conversationId, previous.conversationId);
1220
+ assert.equal(inert.sent, false);
1221
+ assert.equal(inert.configRoot, previous.configRoot);
1222
+ assert.equal(inert.workdir, previous.workdir);
1223
+ const predecessor = await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.predecessors.find(segment => segment.segmentId === original.segmentId));
1224
+ assert.equal(predecessor.binding.conversationId, previous.conversationId);
1225
+ assert.equal(inert.executionId, "");
1226
+ assert.deepEqual(await f.trace(), beforeSelection, "Selecting allocates an inert handle without inference");
1227
+ const retained = await f.conversation.read();
1228
+ assert.equal(retained.id, original.id);
1229
+ assert.deepEqual(retained.conversationLog, original.conversationLog);
1230
+ assert.equal((await f.conversation.select(request)).duplicate, true);
1231
+ await f.conversation.send({ messageId: "changed", text: "Continue after the change" });
1232
+ assert.equal((await f.conversation.wait()).conversationLog.at(-1).metadata.runtime.status, "complete");
1233
+ const fresh = await binding();
1234
+ assert.equal(fresh.conversationId, inert.conversationId);
1235
+ assert.equal(fresh.accountIdentity, previous.accountIdentity);
1236
+ assert.equal(await readFile(oldHistoryPath, "utf8"), oldHistory);
1237
+ const trace = await f.trace();
1238
+ const starts = trace.filter(row => row.args?.includes("--print"));
1239
+ assert.equal(starts.length, 2);
1240
+ assert.equal(starts[1].args[starts[1].args.indexOf("--session-id") + 1], inert.conversationId);
1241
+ assert.equal(starts[1].args.includes("--resume"), false);
1242
+ assert.equal(starts[1].args[starts[1].args.indexOf("--model") + 1], selected.model);
1243
+ assert.equal(trace.filter(row => row.frame?.request?.subtype === "apply_flag_settings").at(-1).frame.request.settings.effortLevel, selected.effort);
1244
+ const nativeMessages = trace.filter(row => row.frame?.type === "user");
1245
+ assert.equal(nativeMessages.length, 2);
1246
+ const handedHistory = JSON.parse(nativeMessages[1].frame.message.content.split("\n").find(line => line.startsWith('{"messages":')));
1247
+ assert.deepEqual(handedHistory.messages.map(({ role, text }) => [role, text]), [["user", "Hello"], ["assistant", "Answer: Hello"]]);
1248
+ assert.deepEqual(handedHistory.removedMessageIds, []);
1249
+ assert.equal(nativeMessages[1].frame.message.content.split("User's message:\n")[1], "Continue after the change");
1250
+ assert.match(nativeMessages[1].frame.message.content, /Hello/);
1251
+ assert.match(nativeMessages[1].frame.message.content, /Answer: Hello/);
1252
+ assert.match(nativeMessages[1].frame.message.content, /Continue after the change/);
1253
+ await f.first.close();
1254
+ const resumed = await f.runtime().open({ id: "conversation" });
1255
+ assert.equal((await resumed.select(request)).duplicate, true);
1256
+ assert.equal((await resumed.send(input)).duplicate, true);
1257
+ assert.equal((await binding()).conversationId, inert.conversationId);
1258
+ await resumed.send({ messageId: "after-restart", text: "Continue after restart" });
1259
+ assert.equal((await resumed.wait()).conversationLog.length, 3);
1260
+ const restarted = await f.trace();
1261
+ assert.equal(restarted.filter(row => row.frame?.type === "user").length, 3);
1262
+ assert.equal(restarted.filter(row => row.frame?.type === "user").at(-1).frame.message.content, "Continue after restart");
1263
+ const restart = restarted.filter(row => row.args?.includes("--print")).at(-1);
1264
+ assert.equal(restart.args[restart.args.indexOf("--resume") + 1], inert.conversationId);
1265
+ const returned = await resumed.select({ operationId: `restore-${change}`, expectedSegmentId: (await resumed.read()).segmentId,
1266
+ engine: "claude", configuration, retireNative: true });
1267
+ assert.notEqual(returned.segmentId, original.segmentId, "The consumer policy cannot restore an older retained segment");
1268
+ assert.notEqual(returned.segmentId, receipt.segmentId);
1269
+ await resumed.send({ messageId: "returned", text: "Back to the original settings" });
1270
+ assert.equal((await resumed.wait()).conversationLog.length, 4);
1271
+ const restored = await binding();
1272
+ assert.notEqual(restored.conversationId, previous.conversationId);
1273
+ assert.notEqual(restored.conversationId, inert.conversationId);
1274
+ assert.equal(restored.accountIdentity, previous.accountIdentity);
1275
+ const restoredPrompt = (await f.trace()).filter(row => row.frame?.type === "user").at(-1).frame.message.content;
1276
+ assert.match(restoredPrompt, /Hello/);
1277
+ assert.match(restoredPrompt, /Continue after the change/);
1278
+ assert.match(restoredPrompt, /Continue after restart/);
1279
+ });