@openchambery/web 1.19.13-beta.3 → 1.19.13-beta.5

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 (113) hide show
  1. package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-B5MiX004.js} +1 -1
  2. package/dist/assets/AssistantView-B6kZZdri.js +2 -0
  3. package/dist/assets/{DiagramView-W0IHFk-1.js → DiagramView-C40ituM2.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
  5. package/dist/assets/{Icon-CkzzhaLr.js → Icon-ZAKaNHiG.js} +1 -1
  6. package/dist/assets/{MarkdownRenderer-v7n_D6nL.js → MarkdownRenderer-mcY597G0.js} +2 -2
  7. package/dist/assets/{MarkdownRendererImpl-Buz5LZsa.js → MarkdownRendererImpl-YsTrEeUY.js} +6 -3
  8. package/dist/assets/MarkstreamRendererImpl-kNx_G9Sd.js +3 -0
  9. package/dist/assets/MobileDetailNavigation-Uwzh6TWj.js +1 -0
  10. package/dist/assets/MobileShareBridge-D3oSiSC6.js +4 -0
  11. package/dist/assets/MobileSurface-B-SWjAj7.js +2 -0
  12. package/dist/assets/{MultiRunWindow-yWIHgr89.js → MultiRunWindow-LgdUonfa.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-DHKq6y3W.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
  15. package/dist/assets/{SettingsView-C3BbLpHT.js → SettingsView-iUiWTKbM.js} +8 -8
  16. package/dist/assets/{SettingsWindow-b0JWdygM.js → SettingsWindow-DCDEAC1n.js} +1 -1
  17. package/dist/assets/{TerminalView-C74IvmOt.js → TerminalView-CdZi_UXQ.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-BORu8CtR.js → ToolOutputDialog-fZq6UHvy.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-DOwhB93n.js → appThemeRegistry-DyFotcOM.js} +1 -1
  20. package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
  21. package/dist/assets/{clipboard-CUasgPiF.js → clipboard-CzXos-TI.js} +1 -1
  22. package/dist/assets/{collapsible-CACHBRYZ.js → collapsible-Bbz9KiJf.js} +1 -1
  23. package/dist/assets/{context-menu-CYbm4wBZ.js → context-menu-CSyOhRz8.js} +1 -1
  24. package/dist/assets/{desktopHosts-DILvrAok.js → desktopHosts-B052a4UK.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-t8asCr3m.js → desktopRelayRestore-OjKNXr10.js} +1 -1
  26. package/dist/assets/{diagram-u0BC9uuY.js → diagram-NdsZih8P.js} +1 -1
  27. package/dist/assets/{dialog-BwQ3rY_x.js → dialog-U4ML8hxt.js} +1 -1
  28. package/dist/assets/{es-CRjOwvYM.js → es-BtC17YQH.js} +1 -1
  29. package/dist/assets/fileMentionTouchSelection-DSKcxgU_.js +4 -0
  30. package/dist/assets/{fileQueries-Bgly-E8f.js → fileQueries-B_s_B-fH.js} +1 -1
  31. package/dist/assets/{fr-D9taEN9L.js → fr-DPohCCUO.js} +1 -1
  32. package/dist/assets/{gitApi-D1mvv14J.js → gitApi-BUubBKri.js} +3 -3
  33. package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
  34. package/dist/assets/insertionBoundaries-DeGd-RP9.js +1 -0
  35. package/dist/assets/{ja-CYQ5aoWp.js → ja-CflPxUNi.js} +1 -1
  36. package/dist/assets/{ko-BwASV0Q0.js → ko-CuSU80xd.js} +1 -1
  37. package/dist/assets/main-C6EluFKk.js +169 -0
  38. package/dist/assets/{main-phpzFgMN.js → main-iFGhn-zU.js} +2 -2
  39. package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
  40. package/dist/assets/miniChat-Bs_H98PB.js +2 -0
  41. package/dist/assets/mobile-HfLUO-bG.js +2 -0
  42. package/dist/assets/{multirun-CYuxJPBX.js → multirun-BABuRg1O.js} +1 -1
  43. package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
  44. package/dist/assets/{pl-DUI1vPil.js → pl-eJvVbQ74.js} +1 -1
  45. package/dist/assets/platform-DQ5_LNKO.js +2 -0
  46. package/dist/assets/{projectMeta-Cir66uF3.js → projectMeta-WcxDg0DZ.js} +2 -2
  47. package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-DSejAksY.js} +1 -1
  48. package/dist/assets/{renderElectronMiniChatApp-BzYqqr9n.js → renderElectronMiniChatApp-Cp-dVXj5.js} +2 -2
  49. package/dist/assets/renderMobileApp-B6rb6wuc.js +28 -0
  50. package/dist/assets/{runtimeConfig-BXf9GQ0Q.js → runtimeConfig-88DzHVMT.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-CEbKea6x.js +147 -0
  52. package/dist/assets/sessionChangeSummary-C9071lsK.js +2 -0
  53. package/dist/assets/sessionLookup-CEw3tCz1.js +1 -0
  54. package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-eL2whsf8.js} +1 -1
  55. package/dist/assets/src-B_DG0xy5.css +2 -0
  56. package/dist/assets/{store-CSnH_QXM.js → store-DHdMj3y4.js} +2 -2
  57. package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
  58. package/dist/assets/{textarea-BfNtUdJE.js → textarea-DE1UrdtK.js} +1 -1
  59. package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
  60. package/dist/assets/{uk-C1FYZlmA.js → uk-DV9OgOAg.js} +1 -1
  61. package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
  62. package/dist/assets/{useAppFontEffects-NVyoI_Ei.js → useAppFontEffects-Ca0mASq5.js} +43 -44
  63. package/dist/assets/{useDesktopWindowControlsLayout-CP3NmMYG.js → useDesktopWindowControlsLayout-B3KJYYwp.js} +1 -1
  64. package/dist/assets/{useEffectiveDirectory-AUoqSyOJ.js → useEffectiveDirectory-B4l9snxd.js} +1 -1
  65. package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
  66. package/dist/assets/useMobileNavigationStore-CU2UwmN3.js +413 -0
  67. package/dist/assets/{useSessionAutoCleanup-ClvQQPqQ.js → useSessionAutoCleanup-B9El9yjX.js} +1 -1
  68. package/dist/assets/{useWorkerHighlightedLines-DOk4cAxv.js → useWorkerHighlightedLines-PPhFeDFI.js} +1 -1
  69. package/dist/assets/{utils-BHTVlVov.js → utils-2ZK8yboQ.js} +1 -1
  70. package/dist/assets/{vendor-reactuses-core-DDoDDyM9.js → vendor-reactuses-core-BnW6GxJt.js} +1 -1
  71. package/dist/assets/{zh-CN-C96qP1ip.js → zh-CN-92n_rGTt.js} +1 -1
  72. package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-CwVxwJCj.js} +1 -1
  73. package/dist/index.html +8 -8
  74. package/dist/mini-chat.html +8 -8
  75. package/dist/mobile.html +8 -8
  76. package/package.json +1 -1
  77. package/server/index.js +1 -0
  78. package/server/lib/assistants/DOCUMENTATION.md +104 -18
  79. package/server/lib/assistants/assign.js +173 -13
  80. package/server/lib/assistants/assign.test.js +139 -0
  81. package/server/lib/assistants/bubbles.js +1 -1
  82. package/server/lib/assistants/contact-store.js +496 -11
  83. package/server/lib/assistants/contact-store.test.js +323 -7
  84. package/server/lib/assistants/contact-tools.js +514 -52
  85. package/server/lib/assistants/contact-tools.test.js +390 -7
  86. package/server/lib/assistants/contracts.js +31 -0
  87. package/server/lib/assistants/harness.js +224 -54
  88. package/server/lib/assistants/harness.test.js +372 -12
  89. package/server/lib/assistants/routes.js +3 -0
  90. package/server/lib/assistants/service.js +1079 -38
  91. package/server/lib/assistants/service.test.js +1235 -26
  92. package/server/lib/llm/DOCUMENTATION.md +2 -0
  93. package/server/lib/llm/completions.js +4 -0
  94. package/server/lib/llm/completions.test.js +3 -0
  95. package/server/lib/llm/generate.js +12 -5
  96. package/server/lib/llm/generate.test.js +40 -0
  97. package/server/lib/opencode/feature-routes-runtime.js +5 -0
  98. package/dist/assets/AssistantView-DOZPK9oh.js +0 -2
  99. package/dist/assets/MarkstreamRendererImpl-D4nUXYpP.js +0 -3
  100. package/dist/assets/MobileDetailNavigation-Bfs4CTUh.js +0 -1
  101. package/dist/assets/MobileShareBridge-DtwVLkBL.js +0 -4
  102. package/dist/assets/MobileSurface-BmYYdY3g.js +0 -2
  103. package/dist/assets/insertionBoundaries-BBn8YW82.js +0 -4
  104. package/dist/assets/main-Cd7NwXx5.js +0 -169
  105. package/dist/assets/miniChat-D6UYe50H.js +0 -2
  106. package/dist/assets/mobile-w2Ed9I5O.js +0 -2
  107. package/dist/assets/platform-DzsGqlGW.js +0 -2
  108. package/dist/assets/renderMobileApp-BvIE86_O.js +0 -28
  109. package/dist/assets/runtimeEndpointReset-lF5S7U4I.js +0 -147
  110. package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
  111. package/dist/assets/sessionLookup-CNRDupYq.js +0 -1
  112. package/dist/assets/src-AA-5nXsF.css +0 -2
  113. package/dist/assets/useMobileNavigationStore-DANrWekj.js +0 -413
@@ -17,9 +17,12 @@ import {
17
17
  CONTACT_PAGE_MAX_LIMIT,
18
18
  CONTACT_PREVIEW_MAX_CHARS,
19
19
  CONTACT_SETTLE_TEXT,
20
+ advanceContactReadWatermark,
20
21
  bumpContactGeneration,
21
22
  clearContactMemory,
23
+ compareContactReadCursor,
22
24
  contactHistoryForLlm,
25
+ countContactUnread,
23
26
  createActiveContactTurn,
24
27
  decodeContactCursor,
25
28
  deleteContactMessages,
@@ -27,12 +30,19 @@ import {
27
30
  ensureContactSchema,
28
31
  getContactContextBoundary,
29
32
  getContactGeneration,
33
+ getContactReadTip,
34
+ getContactReadWatermark,
35
+ getContactUnreadSnapshots,
30
36
  getLatestContactMessagePreview,
31
37
  getLatestContactMessagePreviews,
32
38
  insertContactMessage,
39
+ isContactUnreadCountableMessage,
40
+ isContactVisiblePart,
33
41
  listContactMessages,
42
+ migrateContactReadStateDefaultRead,
34
43
  nextContactOrdinal,
35
44
  projectActiveContactTurn,
45
+ resetContactReadWatermarkForGeneration,
36
46
  sanitizeContactPreviewText,
37
47
  trimContactHistoryForLlm,
38
48
  } from './contact-store.js';
@@ -68,30 +78,30 @@ const insert = (db, assistantID, { role, text = '', parts, status = 'complete',
68
78
 
69
79
  describe('contact LLM history trim', () => {
70
80
  it('documents the budget constants in one place', () => {
71
- expect(CONTACT_LLM_MAX_TURNS).toBe(8);
72
- expect(CONTACT_LLM_MAX_CHARS).toBe(6_000);
73
- expect(CONTACT_LLM_FETCH_LIMIT).toBe(40);
81
+ expect(CONTACT_LLM_MAX_TURNS).toBe(32);
82
+ expect(CONTACT_LLM_MAX_CHARS).toBe(48_000);
83
+ expect(CONTACT_LLM_FETCH_LIMIT).toBe(100);
74
84
  expect(CONTACT_LLM_FILE_CHAR_WEIGHT).toBe(80);
75
85
  });
76
86
 
77
87
  it('keeps the newest user+assistant turns and leaves older SQLite rows for the UI', () => {
78
88
  const db = openDb();
79
89
  const assistantID = 'asst_trim';
80
- for (let index = 0; index < 12; index += 1) {
90
+ for (let index = 0; index < 36; index += 1) {
81
91
  insert(db, assistantID, { role: 'user', text: `user-${index}` });
82
92
  insert(db, assistantID, { role: 'assistant', text: `assistant-${index}` });
83
93
  }
84
94
  const history = contactHistoryForLlm(db, assistantID);
85
95
  expect(history).toHaveLength(CONTACT_LLM_MAX_TURNS * 2);
86
96
  expect(history[0]).toEqual({ role: 'user', content: 'user-4' });
87
- expect(history.at(-1)).toEqual({ role: 'assistant', content: 'assistant-11' });
97
+ expect(history.at(-1)).toEqual({ role: 'assistant', content: 'assistant-35' });
88
98
  const page = listContactMessages(db, assistantID, { limit: 100 });
89
- expect(page.messages).toHaveLength(24);
99
+ expect(page.messages).toHaveLength(72);
90
100
  expect(page.messages[0].text).toBe('user-0');
91
101
  db.close();
92
102
  });
93
103
 
94
- it('drops peer, error, and pure-card rows from the LLM window', () => {
104
+ it('keeps card identity and drops peer and error rows from the LLM window', () => {
95
105
  const db = openDb();
96
106
  const assistantID = 'asst_filter';
97
107
  insert(db, assistantID, { role: 'user', text: 'keep-user' });
@@ -105,6 +115,7 @@ describe('contact LLM history trim', () => {
105
115
  expect(contactHistoryForLlm(db, assistantID)).toEqual([
106
116
  { role: 'user', content: 'keep-user' },
107
117
  { role: 'assistant', content: 'keep-assistant' },
118
+ { role: 'assistant', content: expect.stringContaining('\"sessionID\":\"ses_1\"') },
108
119
  ]);
109
120
  expect(listContactMessages(db, assistantID, { limit: 50 }).messages).toHaveLength(5);
110
121
  db.close();
@@ -474,6 +485,7 @@ describe('contact latest message preview', () => {
474
485
  expect(getLatestContactMessagePreview(db, assistantID)).toEqual({
475
486
  messageID: latest.messageID,
476
487
  ordinal: latest.ordinal,
488
+ createdAt: latest.createdAt,
477
489
  role: 'user',
478
490
  text: 'newest user line',
479
491
  fallbackKind: null,
@@ -495,6 +507,7 @@ describe('contact latest message preview', () => {
495
507
  expect(getLatestContactMessagePreview(db, assistantID)).toEqual({
496
508
  messageID: peer.messageID,
497
509
  ordinal: peer.ordinal,
510
+ createdAt: peer.createdAt,
498
511
  role: 'user',
499
512
  text: 'hello from peer',
500
513
  fallbackKind: null,
@@ -638,6 +651,7 @@ describe('contact latest message preview', () => {
638
651
  expect(preview).toMatchObject({
639
652
  messageID: latest.messageID,
640
653
  ordinal: latest.ordinal,
654
+ createdAt: latest.createdAt,
641
655
  role: 'user',
642
656
  text: 'visible-after-settles',
643
657
  fallbackKind: null,
@@ -668,3 +682,305 @@ describe('contact latest message preview', () => {
668
682
  db.close();
669
683
  });
670
684
  });
685
+
686
+ describe('contact read watermark / unread', () => {
687
+ it('counts only complete/error assistant and peer replies; ignores user and settle-only', () => {
688
+ expect(isContactVisiblePart({ type: 'text', text: 'hi' })).toBe(true);
689
+ expect(isContactVisiblePart({ type: 'text', text: ' ' })).toBe(false);
690
+ expect(isContactVisiblePart({ type: 'text', text: CONTACT_SETTLE_TEXT.complete })).toBe(false);
691
+ expect(isContactVisiblePart({ type: 'file', mime: 'image/png' })).toBe(true);
692
+ expect(isContactUnreadCountableMessage({
693
+ role: 'assistant',
694
+ status: 'complete',
695
+ text: 'hi',
696
+ parts: [{ type: 'text', text: 'hi' }],
697
+ })).toBe(true);
698
+ expect(isContactUnreadCountableMessage({
699
+ role: 'assistant',
700
+ status: 'error',
701
+ text: 'boom',
702
+ parts: [{ type: 'text', text: 'boom' }],
703
+ })).toBe(true);
704
+ expect(isContactUnreadCountableMessage({
705
+ role: 'peer',
706
+ status: 'complete',
707
+ text: 'dm',
708
+ parts: [{ type: 'text', text: 'dm' }],
709
+ })).toBe(true);
710
+ expect(isContactUnreadCountableMessage({
711
+ role: 'user',
712
+ status: 'complete',
713
+ text: 'me',
714
+ parts: [{ type: 'text', text: 'me' }],
715
+ })).toBe(false);
716
+ expect(isContactUnreadCountableMessage({
717
+ role: 'assistant',
718
+ status: 'complete',
719
+ text: CONTACT_SETTLE_TEXT.complete,
720
+ parts: [{ type: 'text', text: CONTACT_SETTLE_TEXT.complete }],
721
+ })).toBe(false);
722
+ // Blank / whitespace-only is not countable.
723
+ expect(isContactUnreadCountableMessage({
724
+ role: 'assistant',
725
+ status: 'complete',
726
+ text: '',
727
+ parts: [{ type: 'text', text: '' }],
728
+ })).toBe(false);
729
+ expect(isContactUnreadCountableMessage({
730
+ role: 'assistant',
731
+ status: 'complete',
732
+ text: ' ',
733
+ parts: [{ type: 'text', text: ' ' }],
734
+ })).toBe(false);
735
+ // Settle beside real body still counts (per-part, not joined-text startsWith).
736
+ expect(isContactUnreadCountableMessage({
737
+ role: 'assistant',
738
+ status: 'complete',
739
+ parts: [
740
+ { type: 'text', text: CONTACT_SETTLE_TEXT.complete },
741
+ { type: 'text', text: 'spoken summary' },
742
+ ],
743
+ })).toBe(true);
744
+ // Settle prefix + file/card hybrid counts.
745
+ expect(isContactUnreadCountableMessage({
746
+ role: 'assistant',
747
+ status: 'complete',
748
+ parts: [
749
+ { type: 'text', text: CONTACT_SETTLE_TEXT.error },
750
+ { type: 'file', mime: 'text/plain', filename: 'a.txt' },
751
+ ],
752
+ })).toBe(true);
753
+
754
+ const db = openDb();
755
+ const assistantID = 'asst_unread_count';
756
+ insert(db, assistantID, { role: 'user', text: 'u1' });
757
+ insert(db, assistantID, { role: 'assistant', text: 'a1' });
758
+ insert(db, assistantID, { role: 'assistant', text: CONTACT_SETTLE_TEXT.complete });
759
+ insert(db, assistantID, { role: 'peer', text: 'p1', fromAssistantID: 'other', fromAssistantName: 'Peer' });
760
+ insert(db, assistantID, { role: 'assistant', text: 'failed', status: 'error' });
761
+ // No watermark → all countable unread.
762
+ expect(countContactUnread(db, assistantID)).toBe(3);
763
+ db.close();
764
+ });
765
+
766
+ it('SQL single/batch counts match visible-part rules for blank, settle prefix, and mixed parts', () => {
767
+ const db = openDb();
768
+ const assistantID = 'asst_unread_visible';
769
+ insert(db, assistantID, { role: 'assistant', text: '' });
770
+ insert(db, assistantID, { role: 'assistant', text: ' ' });
771
+ insert(db, assistantID, { role: 'assistant', text: CONTACT_SETTLE_TEXT.complete });
772
+ insert(db, assistantID, {
773
+ role: 'assistant',
774
+ parts: [
775
+ { type: 'text', text: CONTACT_SETTLE_TEXT.question },
776
+ { type: 'text', text: 'need your input' },
777
+ ],
778
+ });
779
+ insert(db, assistantID, {
780
+ role: 'assistant',
781
+ parts: [
782
+ { type: 'text', text: CONTACT_SETTLE_TEXT.complete },
783
+ createSessionCardPart({
784
+ sessionID: 'ses_visible',
785
+ directory: '/tmp/proj',
786
+ title: 'Work',
787
+ status: 'complete',
788
+ }),
789
+ ],
790
+ });
791
+ insert(db, assistantID, { role: 'assistant', text: 'plain' });
792
+ // blank×2 + settle-only excluded; mixed text, settle+card, plain → 3
793
+ expect(countContactUnread(db, assistantID)).toBe(3);
794
+ expect(getContactUnreadSnapshots(db, [assistantID]).get(assistantID)?.unreadCount).toBe(3);
795
+
796
+ const tip = getContactReadTip(db, assistantID);
797
+ advanceContactReadWatermark(db, assistantID, {
798
+ generation: tip.generation,
799
+ ordinal: tip.ordinal,
800
+ messageID: tip.messageID,
801
+ }, { updatedAt: 1 });
802
+ expect(countContactUnread(db, assistantID)).toBe(0);
803
+ expect(getContactUnreadSnapshots(db, [assistantID]).get(assistantID)?.unreadCount).toBe(0);
804
+ db.close();
805
+ });
806
+
807
+ it('migrates legacy rows to default-read and only new messages count', () => {
808
+ const db = openDb();
809
+ const assistantID = 'asst_migrate_read';
810
+ const a1 = insert(db, assistantID, { role: 'assistant', text: 'old-a' });
811
+ const a2 = insert(db, assistantID, { role: 'assistant', text: 'old-b' });
812
+ expect(countContactUnread(db, assistantID)).toBe(2);
813
+ const migrated = migrateContactReadStateDefaultRead(db, { updatedAt: 1 });
814
+ expect(migrated.seeded).toBe(1);
815
+ expect(countContactUnread(db, assistantID)).toBe(0);
816
+ const watermark = getContactReadWatermark(db, assistantID);
817
+ expect(watermark).toMatchObject({
818
+ ordinal: a2.ordinal,
819
+ messageID: a2.messageID,
820
+ generation: 0,
821
+ });
822
+ // Re-seed is idempotent.
823
+ expect(migrateContactReadStateDefaultRead(db, { updatedAt: 2 }).seeded).toBe(0);
824
+
825
+ insert(db, assistantID, { role: 'user', text: 'new-u' });
826
+ const newer = insert(db, assistantID, { role: 'assistant', text: 'new-a' });
827
+ expect(countContactUnread(db, assistantID)).toBe(1);
828
+ expect(getContactReadTip(db, assistantID)).toMatchObject({
829
+ ordinal: newer.ordinal,
830
+ messageID: newer.messageID,
831
+ });
832
+ void a1;
833
+ db.close();
834
+ });
835
+
836
+ it('advances monotonically, clamps to tip, and rejects stale generation', () => {
837
+ const db = openDb();
838
+ const assistantID = 'asst_mono';
839
+ const first = insert(db, assistantID, { role: 'assistant', text: 'one' });
840
+ const second = insert(db, assistantID, { role: 'assistant', text: 'two' });
841
+ const third = insert(db, assistantID, { role: 'assistant', text: 'three' });
842
+
843
+ const toFirst = advanceContactReadWatermark(db, assistantID, {
844
+ generation: 0,
845
+ ordinal: first.ordinal,
846
+ messageID: first.messageID,
847
+ }, { updatedAt: 10 });
848
+ expect(toFirst.changed).toBe(true);
849
+ expect(toFirst.unreadCount).toBe(2);
850
+
851
+ // Equal → no-op.
852
+ const same = advanceContactReadWatermark(db, assistantID, {
853
+ generation: 0,
854
+ ordinal: first.ordinal,
855
+ messageID: first.messageID,
856
+ }, { updatedAt: 11 });
857
+ expect(same.changed).toBe(false);
858
+ expect(same.unreadCount).toBe(2);
859
+
860
+ // Late lower cursor cannot erase newer unread.
861
+ const staleLower = advanceContactReadWatermark(db, assistantID, {
862
+ generation: 0,
863
+ ordinal: 0,
864
+ messageID: '',
865
+ }, { updatedAt: 12 });
866
+ expect(staleLower.changed).toBe(false);
867
+ expect(getContactReadWatermark(db, assistantID).ordinal).toBe(first.ordinal);
868
+
869
+ // Forward to second.
870
+ const toSecond = advanceContactReadWatermark(db, assistantID, {
871
+ generation: 0,
872
+ ordinal: second.ordinal,
873
+ messageID: second.messageID,
874
+ }, { updatedAt: 13 });
875
+ expect(toSecond.changed).toBe(true);
876
+ expect(toSecond.unreadCount).toBe(1);
877
+
878
+ // Future cursor clamps to tip (third).
879
+ const overshoot = advanceContactReadWatermark(db, assistantID, {
880
+ generation: 0,
881
+ ordinal: third.ordinal + 99,
882
+ messageID: 'zzz_future',
883
+ }, { updatedAt: 14 });
884
+ expect(overshoot.changed).toBe(true);
885
+ expect(overshoot.readWatermark).toMatchObject({
886
+ ordinal: third.ordinal,
887
+ messageID: third.messageID,
888
+ });
889
+ expect(overshoot.unreadCount).toBe(0);
890
+
891
+ const generation = bumpContactGeneration(db, assistantID);
892
+ expect(generation).toBe(1);
893
+ resetContactReadWatermarkForGeneration(db, assistantID, generation, { updatedAt: 15 });
894
+ expect(() => advanceContactReadWatermark(db, assistantID, {
895
+ generation: 0,
896
+ ordinal: third.ordinal,
897
+ messageID: third.messageID,
898
+ })).toThrow(expect.objectContaining({ code: 'contact_generation_conflict' }));
899
+
900
+ // After generation reset, previous tip rows that remain are unread again.
901
+ expect(countContactUnread(db, assistantID)).toBe(3);
902
+ expect(compareContactReadCursor(
903
+ { ordinal: 1, messageID: 'a' },
904
+ { ordinal: 1, messageID: 'b' },
905
+ )).toBeLessThan(0);
906
+ db.close();
907
+ });
908
+
909
+ it('batches unread snapshots without per-assistant N+1 shape', () => {
910
+ const db = openDb();
911
+ const a = 'asst_batch_a';
912
+ const b = 'asst_batch_b';
913
+ insert(db, a, { role: 'assistant', text: 'a1' });
914
+ insert(db, a, { role: 'assistant', text: 'a2' });
915
+ insert(db, b, { role: 'peer', text: 'b1', fromAssistantID: 'x', fromAssistantName: 'X' });
916
+ const batch = getContactUnreadSnapshots(db, [a, b, 'asst_empty']);
917
+ expect(batch.get(a)?.unreadCount).toBe(2);
918
+ expect(batch.get(b)?.unreadCount).toBe(1);
919
+ expect(batch.get('asst_empty')?.unreadCount).toBe(0);
920
+ expect(batch.get(a)?.readTip?.ordinal).toBeGreaterThan(0);
921
+ expect(batch.get(a)?.readWatermark?.ordinal).toBe(0);
922
+ db.close();
923
+ });
924
+
925
+ it('pushes watermark keyset into SQL so fully-read history does not scan parts (scale + plan)', () => {
926
+ const db = openDb();
927
+ const assistantID = 'asst_unread_scale';
928
+ const otherID = 'asst_unread_scale_b';
929
+ for (let i = 0; i < 240; i += 1) {
930
+ insert(db, assistantID, { role: 'assistant', text: `hist-${i}` });
931
+ }
932
+ insert(db, otherID, { role: 'assistant', text: 'other-only' });
933
+ const tip = getContactReadTip(db, assistantID);
934
+ advanceContactReadWatermark(db, assistantID, {
935
+ generation: tip.generation,
936
+ ordinal: tip.ordinal,
937
+ messageID: tip.messageID,
938
+ }, { updatedAt: 1 });
939
+ // Fully read: single + batch both 0; other assistant still 1.
940
+ expect(countContactUnread(db, assistantID)).toBe(0);
941
+ const fullyRead = getContactUnreadSnapshots(db, [assistantID, otherID]);
942
+ expect(fullyRead.get(assistantID)?.unreadCount).toBe(0);
943
+ expect(fullyRead.get(otherID)?.unreadCount).toBe(1);
944
+
945
+ insert(db, assistantID, { role: 'assistant', text: CONTACT_SETTLE_TEXT.complete });
946
+ insert(db, assistantID, { role: 'assistant', text: '' });
947
+ insert(db, assistantID, { role: 'assistant', text: 'new-visible' });
948
+ expect(countContactUnread(db, assistantID)).toBe(1);
949
+ expect(getContactUnreadSnapshots(db, [assistantID]).get(assistantID)?.unreadCount).toBe(1);
950
+
951
+ // Keyset-bounded probe must use the page index path (not a full-table sort of history).
952
+ const plan = db.prepare(
953
+ `EXPLAIN QUERY PLAN
954
+ SELECT COUNT(*) AS count
955
+ FROM assistant_contact_message m
956
+ WHERE m.assistant_id = ?
957
+ AND m.role IN ('assistant', 'peer')
958
+ AND m.status IN ('complete', 'error')
959
+ AND (m.ordinal > ? OR (m.ordinal = ? AND m.message_id > ?))`,
960
+ ).all(assistantID, tip.ordinal, tip.ordinal, tip.messageID);
961
+ const planText = plan.map((row) => `${row.detail || ''}`).join('\n').toLowerCase();
962
+ expect(planText).toMatch(/assistant_contact_message/);
963
+ expect(
964
+ planText.includes('assistant_id')
965
+ || planText.includes('assistant_contact_message_page')
966
+ || planText.includes('using index')
967
+ || planText.includes('search'),
968
+ ).toBe(true);
969
+ // Batch VALUES+join form also stays index-friendly on assistant_id.
970
+ const batchPlan = db.prepare(
971
+ `EXPLAIN QUERY PLAN
972
+ WITH marks(assistant_id, mark_ordinal, mark_message_id) AS (VALUES (?, ?, ?))
973
+ SELECT m.assistant_id, COUNT(*) AS count
974
+ FROM assistant_contact_message m
975
+ INNER JOIN marks ON marks.assistant_id = m.assistant_id
976
+ WHERE m.role IN ('assistant', 'peer')
977
+ AND m.status IN ('complete', 'error')
978
+ AND (m.ordinal > marks.mark_ordinal
979
+ OR (m.ordinal = marks.mark_ordinal AND m.message_id > marks.mark_message_id))
980
+ GROUP BY m.assistant_id`,
981
+ ).all(assistantID, tip.ordinal, tip.messageID);
982
+ const batchPlanText = batchPlan.map((row) => `${row.detail || ''}`).join('\n').toLowerCase();
983
+ expect(batchPlanText).toMatch(/assistant_contact_message/);
984
+ db.close();
985
+ });
986
+ });