@jskit-ai/assistant-core 0.2.24 → 0.2.26

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 (27) hide show
  1. package/CHANGELOG.md +6 -1
  2. package/examples/conversation/package.json +6 -6
  3. package/package.json +8 -8
  4. package/src/client/components/AssistantSettingsFormCard.vue +14 -9
  5. package/src/client/conversation/AssistantAttachmentQueue.vue +2 -4
  6. package/src/client/conversation/AssistantConversationElement.vue +2 -2
  7. package/src/client/conversation/AssistantMessageAttachments.vue +2 -2
  8. package/src/client/conversation/AssistantModelControl.vue +5 -4
  9. package/src/client/conversation/AssistantPromptInput.vue +24 -18
  10. package/src/client/conversation/AssistantTranscript.css +7 -15
  11. package/src/client/conversation/LongTextInlineParts.vue +2 -3
  12. package/src/client/conversation/LongTextPreviewBlocks.vue +7 -6
  13. package/src/server/conversation/codexProviderOwner.js +6 -1
  14. package/src/server/conversation/conversationEvents.js +30 -2
  15. package/src/server/conversation/conversationHandles.js +1 -1
  16. package/src/server/lib/providers/openAiCompatibleClient.js +12 -8
  17. package/test/assistantScroll.browser.test.js +53 -103
  18. package/test/claudeConversationSession.test.js +16 -1
  19. package/test/componentContracts.test.js +44 -0
  20. package/test/conversationAvatarThumbnail.test.js +3 -4
  21. package/test/conversationSession.test.js +63 -0
  22. package/test/fixtures/nestedListQuestionMessage.md +19 -19
  23. package/test/longTextBlocks.test.js +14 -14
  24. package/test/messageDelivery.browser.test.js +12 -14
  25. package/test/openAiLoading.test.js +25 -0
  26. package/test/opencodeConversationSession.test.js +20 -4
  27. package/test/transcriptTheme.browser.test.js +6 -6
@@ -3,16 +3,14 @@ import path from "node:path";
3
3
  import process from "node:process";
4
4
  import test from "node:test";
5
5
  import { fileURLToPath } from "node:url";
6
- import { chromium, expect } from "@playwright/test";
7
- import {
8
- createChromiumLaunchOptions,
9
- startViteFixture,
10
- stopProcess
11
- } from "../../../tooling/testUtils/browserFixture.mjs";
6
+ import { expect } from "../../../tooling/testUtils/browserExpect.mjs";
7
+ import { sharedBrowserFixture } from "../../../tooling/testUtils/browserFixture.mjs";
12
8
 
13
9
  const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
14
10
  const FIXTURE_ROOT = path.resolve(TEST_DIRECTORY, "../fixtures/responsive-assistant");
15
11
  const RUN_BROWSER_TEST = process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION === "1";
12
+ // Every test reads the same fixture app, so the file shares one server and one Chromium; each test gets its own pages.
13
+ const openFixture = sharedBrowserFixture({ fixtureRoot: FIXTURE_ROOT });
16
14
  // Turns use content-visibility estimates. Moving the reader materializes rows
17
15
  // near the new position, and Chromium's scroll anchoring then shifts scrollTop
18
16
  // to keep the same content in view. Hold a reading position only once that
@@ -109,9 +107,8 @@ const VIEWPORTS = Object.freeze([
109
107
 
110
108
  test("progress previews omit expandable details without hiding the current update", {
111
109
  skip: !RUN_BROWSER_TEST, timeout: 60_000
112
- }, async () => {
113
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
114
- const browser = await chromium.launch(createChromiumLaunchOptions());
110
+ }, async (t) => {
111
+ const { vite, browser } = await openFixture(t);
115
112
  try {
116
113
  for (const width of [390, 800, 1365]) {
117
114
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -137,15 +134,13 @@ test("progress previews omit expandable details without hiding the current updat
137
134
  }
138
135
  } finally {
139
136
  await browser.close();
140
- await stopProcess(vite);
141
137
  }
142
138
  });
143
139
 
144
140
  test("saved reply attribution remains when the current assistant changes", {
145
141
  skip: !RUN_BROWSER_TEST, timeout: 60_000
146
- }, async () => {
147
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
148
- const browser = await chromium.launch(createChromiumLaunchOptions());
142
+ }, async (t) => {
143
+ const { vite, browser } = await openFixture(t);
149
144
  try {
150
145
  const page = await browser.newPage();
151
146
  await page.goto(`${vite.baseURL}/?conversation=1&attribution=1`);
@@ -157,15 +152,13 @@ test("saved reply attribution remains when the current assistant changes", {
157
152
  await expect(names).toHaveText(["First assistant", "Message assistant", "Next assistant"]);
158
153
  } finally {
159
154
  await browser.close();
160
- await stopProcess(vite);
161
155
  }
162
156
  });
163
157
 
164
158
  test("streaming answers grow in one bubble, preserve drafts and settle once at every width", {
165
159
  skip: !RUN_BROWSER_TEST, timeout: 90_000
166
- }, async () => {
167
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
168
- const browser = await chromium.launch(createChromiumLaunchOptions());
160
+ }, async (t) => {
161
+ const { vite, browser } = await openFixture(t);
169
162
  try {
170
163
  for (const width of [390, 800, 1365]) {
171
164
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -189,16 +182,14 @@ test("streaming answers grow in one bubble, preserve drafts and settle once at e
189
182
  }
190
183
  } finally {
191
184
  await browser.close();
192
- await stopProcess(vite);
193
185
  }
194
186
  });
195
187
 
196
188
  test("adjacent reasoning stays grouped across storage rows, history pages and execution changes", {
197
189
  skip: !RUN_BROWSER_TEST,
198
190
  timeout: 90_000
199
- }, async () => {
200
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
201
- const browser = await chromium.launch(createChromiumLaunchOptions());
191
+ }, async (t) => {
192
+ const { vite, browser } = await openFixture(t);
202
193
  try {
203
194
  for (const width of [390, 800, 1365]) {
204
195
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -262,7 +253,6 @@ test("adjacent reasoning stays grouped across storage rows, history pages and ex
262
253
  }
263
254
  } finally {
264
255
  await browser.close();
265
- await stopProcess(vite);
266
256
  }
267
257
  });
268
258
 
@@ -286,13 +276,11 @@ test("AssistantConversationElement keeps long conversations scrollable across re
286
276
  skip: RUN_BROWSER_TEST
287
277
  ? false
288
278
  : "set JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION=1 to run assistant-core browser integration",
289
- timeout: 180_000
290
- }, async () => {
291
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
292
- let browser = null;
279
+ timeout: 90_000
280
+ }, async (t) => {
281
+ const { vite, browser } = await openFixture(t);
293
282
 
294
283
  try {
295
- browser = await chromium.launch(createChromiumLaunchOptions());
296
284
  const context = await browser.newContext({
297
285
  baseURL: vite.baseURL,
298
286
  locale: "en-US"
@@ -315,16 +303,14 @@ test("AssistantConversationElement keeps long conversations scrollable across re
315
303
  );
316
304
  } finally {
317
305
  await browser?.close();
318
- await stopProcess(vite);
319
306
  }
320
307
  });
321
308
 
322
309
  test("conversation scrolling preserves history anchors and resets expansion on selection", {
323
310
  skip: !RUN_BROWSER_TEST,
324
311
  timeout: 60_000
325
- }, async () => {
326
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
327
- const browser = await chromium.launch(createChromiumLaunchOptions());
312
+ }, async (t) => {
313
+ const { vite, browser } = await openFixture(t);
328
314
  try {
329
315
  const page = await browser.newPage();
330
316
  await page.goto(`${vite.baseURL}/?controls=1`);
@@ -375,16 +361,14 @@ test("conversation scrolling preserves history anchors and resets expansion on s
375
361
  assert.equal(await page.evaluate(() => window.revealPlaceholders), 0, "reopening loaded text does not flash a loading placeholder");
376
362
  } finally {
377
363
  await browser.close();
378
- await stopProcess(vite);
379
364
  }
380
365
  });
381
366
 
382
367
  test("a delayed final history page preserves the visible message at every width", {
383
368
  skip: !RUN_BROWSER_TEST,
384
369
  timeout: 60_000
385
- }, async () => {
386
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
387
- const browser = await chromium.launch(createChromiumLaunchOptions());
370
+ }, async (t) => {
371
+ const { vite, browser } = await openFixture(t);
388
372
  try {
389
373
  for (const width of [390, 800, 1365]) {
390
374
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -410,16 +394,14 @@ test("a delayed final history page preserves the visible message at every width"
410
394
  }
411
395
  } finally {
412
396
  await browser.close();
413
- await stopProcess(vite);
414
397
  }
415
398
  });
416
399
 
417
400
  test("upward scrolling loads older history once and preserves retry and selection behavior", {
418
401
  skip: !RUN_BROWSER_TEST,
419
402
  timeout: 90_000
420
- }, async () => {
421
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
422
- const browser = await chromium.launch(createChromiumLaunchOptions());
403
+ }, async (t) => {
404
+ const { vite, browser } = await openFixture(t);
423
405
  try {
424
406
  for (const width of [390, 800, 1365]) {
425
407
  const touch = width < 1000;
@@ -504,16 +486,14 @@ test("upward scrolling loads older history once and preserves retry and selectio
504
486
  }
505
487
  } finally {
506
488
  await browser.close();
507
- await stopProcess(vite);
508
489
  }
509
490
  });
510
491
 
511
492
  test("a reader's own upward scrolling leaves the latest message from its first frame", {
512
493
  skip: !RUN_BROWSER_TEST,
513
494
  timeout: 60_000
514
- }, async () => {
515
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
516
- const browser = await chromium.launch(createChromiumLaunchOptions());
495
+ }, async (t) => {
496
+ const { vite, browser } = await openFixture(t);
517
497
  try {
518
498
  for (const width of [390, 1365]) {
519
499
  const touch = width < 1000;
@@ -580,16 +560,14 @@ test("a reader's own upward scrolling leaves the latest message from its first f
580
560
  }
581
561
  } finally {
582
562
  await browser.close();
583
- await stopProcess(vite);
584
563
  }
585
564
  });
586
565
 
587
566
  test("a reader moved away from the end without a scroll direction stays there", {
588
567
  skip: !RUN_BROWSER_TEST,
589
568
  timeout: 60_000
590
- }, async () => {
591
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
592
- const browser = await chromium.launch(createChromiumLaunchOptions());
569
+ }, async (t) => {
570
+ const { vite, browser } = await openFixture(t);
593
571
  try {
594
572
  const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
595
573
  const errors = [];
@@ -618,16 +596,14 @@ test("a reader moved away from the end without a scroll direction stays there",
618
596
  assert.deepEqual(errors, []);
619
597
  } finally {
620
598
  await browser.close();
621
- await stopProcess(vite);
622
599
  }
623
600
  });
624
601
 
625
602
  test("retained hidden transcripts preserve readers and follow only at the latest message", {
626
603
  skip: !RUN_BROWSER_TEST,
627
604
  timeout: 90_000
628
- }, async () => {
629
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
630
- const browser = await chromium.launch(createChromiumLaunchOptions());
605
+ }, async (t) => {
606
+ const { vite, browser } = await openFixture(t);
631
607
  try {
632
608
  for (const width of [390, 800, 1365]) {
633
609
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -683,16 +659,14 @@ test("retained hidden transcripts preserve readers and follow only at the latest
683
659
  }
684
660
  } finally {
685
661
  await browser.close();
686
- await stopProcess(vite);
687
662
  }
688
663
  });
689
664
 
690
665
  test("transient update errors keep the loaded transcript, reader, draft and focus in a compact banner", {
691
666
  skip: !RUN_BROWSER_TEST,
692
667
  timeout: 90_000
693
- }, async () => {
694
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
695
- const browser = await chromium.launch(createChromiumLaunchOptions());
668
+ }, async (t) => {
669
+ const { vite, browser } = await openFixture(t);
696
670
  try {
697
671
  for (const width of [390, 800, 1365]) {
698
672
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -750,16 +724,14 @@ test("transient update errors keep the loaded transcript, reader, draft and focu
750
724
  }
751
725
  } finally {
752
726
  await browser.close();
753
- await stopProcess(vite);
754
727
  }
755
728
  });
756
729
 
757
730
  test("retained hidden history loads restore their original anchor and invalidate retired requests", {
758
731
  skip: !RUN_BROWSER_TEST,
759
732
  timeout: 90_000
760
- }, async () => {
761
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
762
- const browser = await chromium.launch(createChromiumLaunchOptions());
733
+ }, async (t) => {
734
+ const { vite, browser } = await openFixture(t);
763
735
  try {
764
736
  for (const width of [390, 800, 1365]) {
765
737
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -818,16 +790,14 @@ test("retained hidden history loads restore their original anchor and invalidate
818
790
  }
819
791
  } finally {
820
792
  await browser.close();
821
- await stopProcess(vite);
822
793
  }
823
794
  });
824
795
 
825
796
  test("conversation composer preserves typing and reacts to external state and pane resizing", {
826
797
  skip: !RUN_BROWSER_TEST,
827
798
  timeout: 60_000
828
- }, async () => {
829
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
830
- const browser = await chromium.launch(createChromiumLaunchOptions());
799
+ }, async (t) => {
800
+ const { vite, browser } = await openFixture(t);
831
801
  try {
832
802
  const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
833
803
  await page.goto(`${vite.baseURL}/?conversation=1`);
@@ -872,16 +842,14 @@ test("conversation composer preserves typing and reacts to external state and pa
872
842
  }
873
843
  } finally {
874
844
  await browser.close();
875
- await stopProcess(vite);
876
845
  }
877
846
  });
878
847
 
879
848
  test("working state appears before output and suggestion agents and activity rendering are replaceable", {
880
849
  skip: !RUN_BROWSER_TEST,
881
850
  timeout: 60_000
882
- }, async () => {
883
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
884
- const browser = await chromium.launch(createChromiumLaunchOptions());
851
+ }, async (t) => {
852
+ const { vite, browser } = await openFixture(t);
885
853
  try {
886
854
  const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
887
855
  await page.goto(`${vite.baseURL}/?conversation=1&support=1`);
@@ -910,7 +878,6 @@ test("working state appears before output and suggestion agents and activity ren
910
878
  await expect(input).toHaveValue("Keep typing while waiting");
911
879
  } finally {
912
880
  await browser.close();
913
- await stopProcess(vite);
914
881
  }
915
882
  });
916
883
 
@@ -918,9 +885,8 @@ test("working state appears before output and suggestion agents and activity ren
918
885
  test("long goal objectives keep Start reachable on short phone, tablet and desktop screens", {
919
886
  skip: !RUN_BROWSER_TEST,
920
887
  timeout: 60_000
921
- }, async () => {
922
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
923
- const browser = await chromium.launch(createChromiumLaunchOptions());
888
+ }, async (t) => {
889
+ const { vite, browser } = await openFixture(t);
924
890
  try {
925
891
  const objective = "Complete the requested work and verify the result. ".repeat(80).slice(0, 3990);
926
892
  for (const width of [390, 800, 1280]) {
@@ -947,16 +913,14 @@ test("long goal objectives keep Start reachable on short phone, tablet and deskt
947
913
  }
948
914
  } finally {
949
915
  await browser.close();
950
- await stopProcess(vite);
951
916
  }
952
917
  });
953
918
 
954
919
  test("optional goals show running and paused lights, count only active time, and adapt to pane width", {
955
920
  skip: !RUN_BROWSER_TEST,
956
921
  timeout: 60_000
957
- }, async () => {
958
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
959
- const browser = await chromium.launch(createChromiumLaunchOptions());
922
+ }, async (t) => {
923
+ const { vite, browser } = await openFixture(t);
960
924
  try {
961
925
  const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
962
926
  await page.clock.install();
@@ -1035,7 +999,6 @@ test("optional goals show running and paused lights, count only active time, and
1035
999
  await expect(input).toHaveValue("Preserve this draft and caret");
1036
1000
  } finally {
1037
1001
  await browser.close();
1038
- await stopProcess(vite);
1039
1002
  }
1040
1003
  });
1041
1004
 
@@ -1043,9 +1006,8 @@ test("optional goals show running and paused lights, count only active time, and
1043
1006
  test("shared model choices and file delivery preserve a responsive composer", {
1044
1007
  skip: !RUN_BROWSER_TEST,
1045
1008
  timeout: 60_000
1046
- }, async () => {
1047
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
1048
- const browser = await chromium.launch(createChromiumLaunchOptions());
1009
+ }, async (t) => {
1010
+ const { vite, browser } = await openFixture(t);
1049
1011
  try {
1050
1012
  const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
1051
1013
  await page.goto(`${vite.baseURL}/?conversation=1&capabilities=1`);
@@ -1084,15 +1046,13 @@ test("shared model choices and file delivery preserve a responsive composer", {
1084
1046
  assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
1085
1047
  } finally {
1086
1048
  await browser.close();
1087
- await stopProcess(vite);
1088
1049
  }
1089
1050
  });
1090
1051
 
1091
1052
  test("an optional avatar clamps in a short conversation without consuming its preference or draft", {
1092
1053
  skip: !RUN_BROWSER_TEST, timeout: 90_000
1093
- }, async () => {
1094
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
1095
- const browser = await chromium.launch(createChromiumLaunchOptions());
1054
+ }, async (t) => {
1055
+ const { vite, browser } = await openFixture(t);
1096
1056
  try {
1097
1057
  for (const width of [390, 800, 1365]) {
1098
1058
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -1122,8 +1082,7 @@ test("an optional avatar clamps in a short conversation without consuming its pr
1122
1082
  assert.ok(send.y + send.height <= bounds.y + bounds.height + 1, "Send stays inside the supplied container");
1123
1083
  assert.ok(feedback.y + feedback.height <= bounds.y + bounds.height + 1, "Feedback stays inside the supplied container");
1124
1084
  assert.ok((await input.boundingBox()).height >= 40);
1125
- const avatarExpanded = await sizeControl.getAttribute("aria-expanded") === "true";
1126
- assert.equal(avatarButton.width, avatarExpanded ? 40 : 44, "Expanded minimise is compact; collapsed Show retains its wider target");
1085
+ assert.equal(Math.round(avatarButton.width), 40, "Avatar visibility is a 40dp icon button, Show and Minimise alike");
1127
1086
  assert.equal(Math.round(avatarButton.height), 40, "Avatar visibility is a 40dp icon button, with no fixed height");
1128
1087
  const avatarTarget = await sizeControl.evaluate((element) => Number.parseFloat(window.getComputedStyle(element, "::before").height));
1129
1088
  assert.ok(avatarTarget >= 48, "Avatar visibility keeps Material's 48dp touch target");
@@ -1175,7 +1134,7 @@ test("an optional avatar clamps in a short conversation without consuming its pr
1175
1134
  await expect(artwork).toHaveCount(0);
1176
1135
  await expect(sizeControl).toBeVisible();
1177
1136
  const showBounds = await sizeControl.boundingBox();
1178
- assert.equal(showBounds.width, 44, "Collapsed Show retains its original wider target");
1137
+ assert.equal(Math.round(showBounds.width), 40, "Collapsed Show is the small icon button, as Minimise is");
1179
1138
  assert.equal(Math.round(showBounds.height), 40, "Collapsed Show keeps the small icon button's height");
1180
1139
  assert.ok(await sizeControl.evaluate(element => Number.parseFloat(getComputedStyle(element, "::before").height)) >= 48,
1181
1140
  "Collapsed Show keeps a 48dp touch target");
@@ -1249,16 +1208,14 @@ test("an optional avatar clamps in a short conversation without consuming its pr
1249
1208
  }
1250
1209
  } finally {
1251
1210
  await browser.close();
1252
- await stopProcess(vite);
1253
1211
  }
1254
1212
  });
1255
1213
 
1256
1214
 
1257
1215
  test("unsent preview edits stay in their bubble and disappear for the canonical message", {
1258
1216
  skip: !RUN_BROWSER_TEST, timeout: 60_000
1259
- }, async () => {
1260
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
1261
- const browser = await chromium.launch(createChromiumLaunchOptions());
1217
+ }, async (t) => {
1218
+ const { vite, browser } = await openFixture(t);
1262
1219
  try {
1263
1220
  const page = await browser.newPage({ viewport: { width: 320, height: 844 } });
1264
1221
  await page.goto(`${vite.baseURL}/?conversation=1&preview=1`);
@@ -1363,7 +1320,6 @@ test("unsent preview edits stay in their bubble and disappear for the canonical
1363
1320
  }
1364
1321
  } finally {
1365
1322
  await browser.close();
1366
- await stopProcess(vite);
1367
1323
  }
1368
1324
  });
1369
1325
 
@@ -1371,9 +1327,8 @@ test("unsent preview edits stay in their bubble and disappear for the canonical
1371
1327
  test("pending history keeps following the reader when a new reply changes transcript height", {
1372
1328
  skip: !RUN_BROWSER_TEST,
1373
1329
  timeout: 90_000
1374
- }, async () => {
1375
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
1376
- const browser = await chromium.launch(createChromiumLaunchOptions());
1330
+ }, async (t) => {
1331
+ const { vite, browser } = await openFixture(t);
1377
1332
  try {
1378
1333
  for (const width of [390, 1365]) {
1379
1334
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -1414,16 +1369,14 @@ test("pending history keeps following the reader when a new reply changes transc
1414
1369
  }
1415
1370
  } finally {
1416
1371
  await browser.close();
1417
- await stopProcess(vite);
1418
1372
  }
1419
1373
  });
1420
1374
 
1421
1375
 
1422
1376
  test("connection warnings keep avatar controls and Reload reachable without replacing history", {
1423
1377
  skip: !RUN_BROWSER_TEST, timeout: 90_000
1424
- }, async () => {
1425
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
1426
- const browser = await chromium.launch(createChromiumLaunchOptions());
1378
+ }, async (t) => {
1379
+ const { vite, browser } = await openFixture(t);
1427
1380
  try {
1428
1381
  for (const width of [320, 800]) {
1429
1382
  const page = await browser.newPage({ viewport: { width, height: 1000 } });
@@ -1498,16 +1451,14 @@ test("connection warnings keep avatar controls and Reload reachable without repl
1498
1451
  }
1499
1452
  } finally {
1500
1453
  await browser.close();
1501
- await stopProcess(vite);
1502
1454
  }
1503
1455
  });
1504
1456
 
1505
1457
 
1506
1458
  test("short connection warnings preserve full text and recovery targets at phone widths", {
1507
1459
  skip: !RUN_BROWSER_TEST, timeout: 90_000
1508
- }, async () => {
1509
- const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
1510
- const browser = await chromium.launch(createChromiumLaunchOptions());
1460
+ }, async (t) => {
1461
+ const { vite, browser } = await openFixture(t);
1511
1462
  try {
1512
1463
  for (const width of [320, 390]) {
1513
1464
  const page = await browser.newPage({ viewport: { width, height: 768 } });
@@ -1580,6 +1531,5 @@ test("short connection warnings preserve full text and recovery targets at phone
1580
1531
  }
1581
1532
  } finally {
1582
1533
  await browser.close();
1583
- await stopProcess(vite);
1584
1534
  }
1585
1535
  });
@@ -759,8 +759,23 @@ test("Claude cancellation waits for an application effect after closing the nati
759
759
  });
760
760
 
761
761
  test("the common request deadline stops an unfinished native turn and retains its admitted history", async t => {
762
- const f = await fixture(t, { limits: { timeoutMs: 2000 } });
762
+ // The deadline also covers starting the native process and admitting the turn, which took up to 8.4 s (median 1.3 to
763
+ // 2.5 s) with 4 to 8 copies of this test running on an 8-thread host. Rather than a deadline long enough to outlast
764
+ // that, the test holds the request's deadline timer and runs it once the turn is admitted, so it always stops an
765
+ // admitted turn and never waits for the clock.
766
+ const timeoutMs = 10_000;
767
+ const setTimer = globalThis.setTimeout;
768
+ let deadline = null;
769
+ t.mock.method(globalThis, "setTimeout", (callback, delay, ...args) => {
770
+ if (deadline || delay !== timeoutMs) return setTimer(callback, delay, ...args);
771
+ deadline = () => callback(...args);
772
+ return setTimer(() => {}, 0);
773
+ });
774
+ const f = await fixture(t, { limits: { timeoutMs } });
763
775
  await f.conversation.send({ messageId: "waiting", text: "wait" });
776
+ assert.equal(typeof deadline, "function", "The request armed its deadline with the configured time limit");
777
+ assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1, "The turn was admitted");
778
+ deadline();
764
779
  const snapshot = await f.conversation.wait();
765
780
  assert.equal(snapshot.conversationLog[0].metadata.runtime.status, "failed");
766
781
  assert.match(snapshot.error, /time limit/);
@@ -11,3 +11,47 @@ test("client entry points expose one conversation implementation and retain sett
11
11
  const conversation = await readFile(new URL("../src/client/conversation/index.js", import.meta.url), "utf8");
12
12
  assert.match(conversation, /AssistantConversationElement/);
13
13
  });
14
+
15
+ // Material 3: one container at most, the composer an outlined text field, and no heading inside a titled pane.
16
+ test("the transcript, composer and settings card each draw one Material 3 container", async () => {
17
+ const transcript = await readFile(new URL("../src/client/conversation/AssistantTranscript.css", import.meta.url), "utf8");
18
+ const block = (css, selector) => new RegExp(`(?:^|\\n)${selector.replaceAll(".", "\\.")} \\{([^}]*)\\}`, "u").exec(css)?.[1] ?? "";
19
+ assert.doesNotMatch(block(transcript, ".assistant-transcript"), /border/u, "the transcript area is not outlined");
20
+ assert.doesNotMatch(block(transcript, ".assistant-transcript__welcome"), /\bborder:/u, "the welcome bubble is a tonal fill only");
21
+ const prompt = await readFile(new URL("../src/client/conversation/AssistantPromptInput.vue", import.meta.url), "utf8");
22
+ const field = block(prompt, ".assistant-prompt-input__field");
23
+ assert.match(field, /border: 1px solid rgb\(var\(--v-theme-outline\)\);/u);
24
+ assert.match(field, /border-radius: 4px;/u);
25
+ assert.match(block(prompt, ".assistant-prompt-input__footer"), /padding: 0 12px 12px;/u);
26
+ const card = await readFile(new URL("../src/client/components/AssistantSettingsFormCard.vue", import.meta.url), "utf8");
27
+ assert.doesNotMatch(card, /<v-card-title|<v-card-subtitle|<v-divider/u);
28
+ assert.match(card, /:aria-label="props\.title"/u);
29
+ // Given a formId, the page's app bar carries Save: the form takes the id and the card draws no Save of its own.
30
+ assert.match(card, /<v-form :id="props\.formId \|\| undefined"/u);
31
+ assert.match(card, /<div v-if="!props\.formId \|\| !props\.addEdit\.canSave"/u);
32
+ });
33
+
34
+ // Material 3 tonal containers: inside the conversation only the composer, an outlined text field, draws an outline;
35
+ // attachments, questions, code, tables, details and the model menu are surface-container tones.
36
+ test("the conversation's containers are surface-container tones, not outlined boxes", async () => {
37
+ const folder = new URL("../src/client/conversation/", import.meta.url);
38
+ const files = ["AssistantTranscript.css", "AssistantAttachmentQueue.vue", "AssistantMessageAttachments.vue",
39
+ "AssistantModelControl.vue", "LongTextPreviewBlocks.vue", "LongTextInlineParts.vue", "AssistantPromptInput.vue"];
40
+ const outlines = [];
41
+ for (const file of files) {
42
+ const source = await readFile(new URL(file, folder), "utf8");
43
+ for (const [declaration] of source.matchAll(/\bborder(?:-(?:top|bottom|left|right|inline|block)(?:-(?:start|end))?)?: [^;]*\d[^;]*;/gu)) {
44
+ if (!/: 0;/u.test(declaration)) outlines.push(`${file}: ${declaration}`);
45
+ }
46
+ if (file === "AssistantModelControl.vue") {
47
+ assert.doesNotMatch(source, /<v-sheet[^>]*\n\s*border\n/u, "the model menu has no outline");
48
+ assert.match(source, /color="surface-container"\s+:elevation="2"/u);
49
+ }
50
+ }
51
+ assert.deepEqual(outlines, [
52
+ "AssistantModelControl.vue: border-bottom: 1px solid rgb(var(--v-theme-outline-variant));",
53
+ "AssistantModelControl.vue: border-top: 1px solid rgb(var(--v-theme-outline-variant));",
54
+ "LongTextPreviewBlocks.vue: border-bottom: 1px solid rgb(var(--v-theme-outline-variant));",
55
+ "AssistantPromptInput.vue: border: 1px solid rgb(var(--v-theme-outline));"
56
+ ], "only outline-variant dividers and the composer's outlined field draw lines");
57
+ });
@@ -40,7 +40,6 @@ async function renderShowAvatarButton(element, slots) {
40
40
  assert.equal(buttons.length, 1, "One Show avatar button while the avatar is hidden");
41
41
  const [, attributes, content] = buttons[0];
42
42
  assert.match(attributes, /title="jskit.assistant.chat.showAvatar"/u);
43
- assert.match(attributes, /width="44"/u);
44
43
  return content;
45
44
  }
46
45
 
@@ -61,10 +60,10 @@ test("the hidden avatar's Show button shows the avatar-thumbnail slot, or the ac
61
60
  assert.doesNotMatch(fallback, /fixture-thumbnail/u);
62
61
  });
63
62
 
64
- test("the avatar's Show and Minimise buttons set no fixed height, so material-web gives them the 48dp touch target", () => {
63
+ test("the avatar's Show and Minimise buttons set no fixed size, so material-web gives them the 48dp touch target", () => {
65
64
  const source = readFileSync(new URL(elementUrl), "utf8");
66
65
  const buttons = [...source.matchAll(/<v-btn\b[^>]*>/gu)].map(([tag]) => tag).filter((tag) => tag.includes("assistant-conversation__avatar-size"));
67
66
  assert.equal(buttons.length, 2);
68
- for (const tag of buttons) assert.doesNotMatch(tag, /(?:^|\s):?(?:min-)?height=/u);
69
- assert.doesNotMatch(source, /\.assistant-conversation__avatar-size\s*\{[^}]*height/u);
67
+ for (const tag of buttons) assert.doesNotMatch(tag, /(?:^|\s):?(?:min-)?(?:height|width)=/u);
68
+ assert.doesNotMatch(source, /\.assistant-conversation__avatar-size\s*\{[^}]*(?:height|width)/u);
70
69
  });
@@ -75,6 +75,69 @@ test("API runtime persists one admission for concurrent identical sends and stre
75
75
  assert.equal(f.requests.length, 1);
76
76
  });
77
77
 
78
+ // A reply streamed as `deltas` chunks to one subscriber, each written once the one before it
79
+ // was heard, with Date frozen from the send on (a streamed delta reuses a subscriber's
80
+ // authorization while it is under a second old). `during` runs before the reply finishes.
81
+ async function streamedReply(t, deltas, { subscriberAllowed = () => true, during } = {}) {
82
+ const source = new TransformStream();
83
+ const writer = source.writable.getWriter();
84
+ const write = text => writer.write(new TextEncoder().encode(text));
85
+ let subscribeChecks = 0;
86
+ const f = await fixture(t, {
87
+ authorize: ({ operation }) => operation !== "subscribe" || (subscribeChecks += 1, subscriberAllowed()),
88
+ fetch: () => new Response(source.readable, { headers: { "content-type": "text/event-stream" } })
89
+ });
90
+ const heard = [];
91
+ let deltaHeard = Promise.withResolvers();
92
+ await f.conversation.subscribe(event => {
93
+ heard.push(event);
94
+ if (event.type === "message" && event.status === "inProgress") deltaHeard.resolve();
95
+ });
96
+ t.mock.timers.enable({ apis: ["Date"], now: Date.now() });
97
+ await f.conversation.send(input);
98
+ for (let index = 0; index < deltas; index += 1) {
99
+ deltaHeard = Promise.withResolvers();
100
+ await write(event(`w${index} `));
101
+ await deltaHeard.promise;
102
+ }
103
+ await during?.({ heard, delta: text => write(event(text)) });
104
+ await write(event("", true));
105
+ await writer.close();
106
+ const result = await f.conversation.wait();
107
+ t.mock.timers.reset();
108
+ return { result, heard, subscribeChecks };
109
+ }
110
+
111
+ const words = count => Array.from({ length: count }, (_, index) => `w${index}`).join(" ");
112
+
113
+ test("a streamed reply authorizes its subscriber as often for 200 deltas as for 10", async t => {
114
+ const short = await streamedReply(t, 10);
115
+ const long = await streamedReply(t, 200);
116
+ assert.equal(short.result.conversationLog[0].assistant.text.trim(), words(10));
117
+ assert.equal(long.result.conversationLog[0].assistant.text.trim(), words(200));
118
+ assert.equal(long.heard.filter(event => event.type === "message" && event.status === "inProgress").length, 200,
119
+ "The subscriber hears every delta");
120
+ assert.equal(long.heard.at(-1).type, "settled");
121
+ assert.equal(long.subscribeChecks, short.subscribeChecks, "Streamed deltas reuse the subscriber's authorization for a second");
122
+ });
123
+
124
+ test("a subscriber whose access is revoked stops hearing a streamed reply within a second", async t => {
125
+ let allowed = true;
126
+ let heardAtRevocation = 0;
127
+ const { result, heard } = await streamedReply(t, 3, {
128
+ subscriberAllowed: () => allowed,
129
+ during: async ({ heard: heardSoFar, delta }) => {
130
+ allowed = false;
131
+ heardAtRevocation = heardSoFar.length;
132
+ t.mock.timers.tick(1000);
133
+ await delta("after revocation");
134
+ }
135
+ });
136
+ assert.equal(result.conversationLog[0].assistant.text, "w0 w1 w2 after revocation", "The turn itself continues");
137
+ assert.ok(heardAtRevocation > 3);
138
+ assert.deepEqual(heard.slice(heardAtRevocation), [], "Nothing reaches the subscriber once its authorization is a second old");
139
+ });
140
+
78
141
  test("saved history and current instructions use the existing authorized model connection", async t => {
79
142
  const f = await fixture(t);
80
143
  await f.conversation.send(input);