@openchambery/web 1.19.13 → 1.19.15-beta.1

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 (120) hide show
  1. package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-m1jNq4bX.js} +1 -1
  2. package/dist/assets/AssistantView-D5rEZ-Ss.js +2 -0
  3. package/dist/assets/{DiagramView-BdI4PT2H.js → DiagramView-DaB9Sb7S.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
  5. package/dist/assets/Icon-D1BcR8Tz.js +1 -0
  6. package/dist/assets/MarkdownRenderer-emLeoNjI.js +2 -0
  7. package/dist/assets/{MarkdownRendererImpl-2ewbRZE2.js → MarkdownRendererImpl-BWCAPlqn.js} +3 -3
  8. package/dist/assets/{MarkstreamRendererImpl-DXewO7S_.js → MarkstreamRendererImpl-BD_NFKUv.js} +2 -2
  9. package/dist/assets/MobileDetailNavigation-DqXVAIap.js +1 -0
  10. package/dist/assets/MobileShareBridge-Bk2fdkJk.js +4 -0
  11. package/dist/assets/MobileSurface-C4mpcOCh.js +2 -0
  12. package/dist/assets/{MultiRunWindow-C8ukIdLA.js → MultiRunWindow-rs49_K1E.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-bIWeMLVx.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
  15. package/dist/assets/SettingsView-b8LUjoad.js +25 -0
  16. package/dist/assets/{SettingsWindow-CSePZ_V8.js → SettingsWindow-D8JFlPdA.js} +1 -1
  17. package/dist/assets/{TerminalView-BmSIrZ81.js → TerminalView-DCd-Godi.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-2BYaWLo1.js → ToolOutputDialog-B50L_nUP.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-Dk4gBaOu.js → appThemeRegistry-BWtCaTMe.js} +1 -1
  20. package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
  21. package/dist/assets/{clipboard-CUasgPiF.js → clipboard-GUUNlWIv.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-DhMGbF_B.js} +1 -1
  28. package/dist/assets/{es-CRjOwvYM.js → es-BQb6I8gl.js} +2 -2
  29. package/dist/assets/fileMentionTouchSelection-DKYkoypI.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-BjGouC_l.js} +2 -2
  32. package/dist/assets/{gitApi-DtPf-h-O.js → gitApi-D4PyYgLZ.js} +3 -3
  33. package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
  34. package/dist/assets/insertionBoundaries-Bnnx03Wj.js +1 -0
  35. package/dist/assets/{ja-CYQ5aoWp.js → ja-Cw9zliYY.js} +2 -2
  36. package/dist/assets/{ko-BwASV0Q0.js → ko-Dyz0Dk9b.js} +2 -2
  37. package/dist/assets/main-BRiQTNFX.js +169 -0
  38. package/dist/assets/{main-Ci3JmtpW.js → main-CnY0LWNc.js} +2 -2
  39. package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
  40. package/dist/assets/miniChat-B0pw_mUs.js +2 -0
  41. package/dist/assets/mobile-BKHmxwXz.js +2 -0
  42. package/dist/assets/{multirun-CmaTeZON.js → multirun-BQKqVfFT.js} +1 -1
  43. package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
  44. package/dist/assets/{pl-DUI1vPil.js → pl-C7EY-dTH.js} +1 -1
  45. package/dist/assets/platform-DQ5_LNKO.js +2 -0
  46. package/dist/assets/{projectMeta-cw9nrwpr.js → projectMeta-BvG8sdtC.js} +2 -2
  47. package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-B1uHpjFW.js} +2 -2
  48. package/dist/assets/renderElectronMiniChatApp-E5a0yy7E.js +2 -0
  49. package/dist/assets/renderMobileApp-DT0L4Ah6.js +28 -0
  50. package/dist/assets/{runtimeConfig-DI8l0VyZ.js → runtimeConfig-utWzrDwa.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-DE6lwHyg.js +147 -0
  52. package/dist/assets/sessionChangeSummary-BG7l2heP.js +2 -0
  53. package/dist/assets/sessionLookup-12zVOR0J.js +1 -0
  54. package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-dYW6SH62.js} +1 -1
  55. package/dist/assets/src-CFUi4riW.css +2 -0
  56. package/dist/assets/{store-CSnH_QXM.js → store-ClnJdJSj.js} +3 -3
  57. package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
  58. package/dist/assets/{textarea-BfNtUdJE.js → textarea-B9CmN13Z.js} +1 -1
  59. package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
  60. package/dist/assets/{uk-C1FYZlmA.js → uk-DgG8v2md.js} +2 -2
  61. package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
  62. package/dist/assets/{useAppFontEffects-BDFUND4v.js → useAppFontEffects-BhYuP4L2.js} +43 -44
  63. package/dist/assets/useDesktopWindowControlsLayout-DPNMRkrI.js +1 -0
  64. package/dist/assets/{useEffectiveDirectory-e9HjIqGz.js → useEffectiveDirectory-Ji8AKgzY.js} +1 -1
  65. package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
  66. package/dist/assets/useMobileNavigationStore-BYdpuwL7.js +413 -0
  67. package/dist/assets/useSessionAutoCleanup-CADZCvZL.js +2 -0
  68. package/dist/assets/{useWorkerHighlightedLines-DTGE0CDs.js → useWorkerHighlightedLines-CuG88Lsj.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-C6OFKjeT.js} +2 -2
  72. package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-BVP99aMr.js} +2 -2
  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 +120 -22
  79. package/server/lib/assistants/assign.js +117 -12
  80. package/server/lib/assistants/assign.test.js +79 -0
  81. package/server/lib/assistants/bubbles.js +1 -1
  82. package/server/lib/assistants/contact-store.js +520 -11
  83. package/server/lib/assistants/contact-store.test.js +362 -12
  84. package/server/lib/assistants/contact-tools.js +305 -54
  85. package/server/lib/assistants/contact-tools.test.js +239 -7
  86. package/server/lib/assistants/contracts.js +31 -0
  87. package/server/lib/assistants/harness.js +295 -132
  88. package/server/lib/assistants/harness.test.js +662 -10
  89. package/server/lib/assistants/pi-tools.js +2 -0
  90. package/server/lib/assistants/pi-tools.test.js +8 -0
  91. package/server/lib/assistants/routes.js +3 -0
  92. package/server/lib/assistants/service.js +861 -79
  93. package/server/lib/assistants/service.test.js +1234 -8
  94. package/server/lib/llm/DOCUMENTATION.md +10 -3
  95. package/server/lib/llm/completions.js +4 -0
  96. package/server/lib/llm/completions.test.js +3 -0
  97. package/server/lib/llm/generate.js +57 -11
  98. package/server/lib/llm/generate.test.js +92 -0
  99. package/server/lib/opencode/feature-routes-runtime.js +5 -0
  100. package/dist/assets/AssistantView-CKmIS1cS.js +0 -2
  101. package/dist/assets/Icon-CkzzhaLr.js +0 -1
  102. package/dist/assets/MarkdownRenderer-CLQ4Z8Z0.js +0 -2
  103. package/dist/assets/MobileDetailNavigation-BGaAyuBH.js +0 -1
  104. package/dist/assets/MobileShareBridge-BAZoyeIP.js +0 -4
  105. package/dist/assets/MobileSurface-BVb3xNWt.js +0 -2
  106. package/dist/assets/SettingsView-CkQ61pbr.js +0 -25
  107. package/dist/assets/insertionBoundaries-BMhzPFft.js +0 -4
  108. package/dist/assets/main-BjeOhdcG.js +0 -169
  109. package/dist/assets/miniChat-BYrjYEXJ.js +0 -2
  110. package/dist/assets/mobile-BVMNnPK2.js +0 -2
  111. package/dist/assets/platform-DzsGqlGW.js +0 -2
  112. package/dist/assets/renderElectronMiniChatApp-COwia8Cs.js +0 -2
  113. package/dist/assets/renderMobileApp-CXwwpY1t.js +0 -28
  114. package/dist/assets/runtimeEndpointReset-BkAQXmXJ.js +0 -147
  115. package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
  116. package/dist/assets/sessionLookup-Imc995rW.js +0 -1
  117. package/dist/assets/src-AA-5nXsF.css +0 -2
  118. package/dist/assets/useDesktopWindowControlsLayout-C5hZD1co.js +0 -1
  119. package/dist/assets/useMobileNavigationStore-CYbDxRTM.js +0 -413
  120. package/dist/assets/useSessionAutoCleanup-BGy9_AhN.js +0 -2
@@ -17,22 +17,33 @@ 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,
29
+ deleteContactTurnAssistantOutputs,
26
30
  encodeContactCursor,
27
31
  ensureContactSchema,
28
32
  getContactContextBoundary,
29
33
  getContactGeneration,
34
+ getContactReadTip,
35
+ getContactReadWatermark,
36
+ getContactUnreadSnapshots,
30
37
  getLatestContactMessagePreview,
31
38
  getLatestContactMessagePreviews,
32
39
  insertContactMessage,
40
+ isContactUnreadCountableMessage,
41
+ isContactVisiblePart,
33
42
  listContactMessages,
43
+ migrateContactReadStateDefaultRead,
34
44
  nextContactOrdinal,
35
45
  projectActiveContactTurn,
46
+ resetContactReadWatermarkForGeneration,
36
47
  sanitizeContactPreviewText,
37
48
  trimContactHistoryForLlm,
38
49
  } from './contact-store.js';
@@ -47,14 +58,16 @@ const openDb = () => {
47
58
  return db;
48
59
  };
49
60
 
50
- const insert = (db, assistantID, { role, text = '', parts, status = 'complete', fromAssistantID = null, fromAssistantName = null }) => {
51
- const messageID = crypto.randomUUID();
61
+ const insert = (db, assistantID, {
62
+ role, text = '', parts, status = 'complete', fromAssistantID = null, fromAssistantName = null, turnID = null, messageID = null,
63
+ }) => {
64
+ const id = typeof messageID === 'string' && messageID ? messageID : crypto.randomUUID();
52
65
  const ordinal = nextContactOrdinal(db, assistantID);
53
66
  insertContactMessage(db, {
54
- messageID,
67
+ messageID: id,
55
68
  assistantID,
56
69
  role,
57
- turnID: messageID,
70
+ turnID: typeof turnID === 'string' && turnID ? turnID : id,
58
71
  bubbleIndex: 0,
59
72
  createdAt: Date.now(),
60
73
  ordinal,
@@ -63,35 +76,35 @@ const insert = (db, assistantID, { role, text = '', parts, status = 'complete',
63
76
  fromAssistantID,
64
77
  fromAssistantName,
65
78
  });
66
- return { messageID, ordinal };
79
+ return { messageID: id, ordinal };
67
80
  };
68
81
 
69
82
  describe('contact LLM history trim', () => {
70
83
  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);
84
+ expect(CONTACT_LLM_MAX_TURNS).toBe(32);
85
+ expect(CONTACT_LLM_MAX_CHARS).toBe(48_000);
86
+ expect(CONTACT_LLM_FETCH_LIMIT).toBe(100);
74
87
  expect(CONTACT_LLM_FILE_CHAR_WEIGHT).toBe(80);
75
88
  });
76
89
 
77
90
  it('keeps the newest user+assistant turns and leaves older SQLite rows for the UI', () => {
78
91
  const db = openDb();
79
92
  const assistantID = 'asst_trim';
80
- for (let index = 0; index < 12; index += 1) {
93
+ for (let index = 0; index < 36; index += 1) {
81
94
  insert(db, assistantID, { role: 'user', text: `user-${index}` });
82
95
  insert(db, assistantID, { role: 'assistant', text: `assistant-${index}` });
83
96
  }
84
97
  const history = contactHistoryForLlm(db, assistantID);
85
98
  expect(history).toHaveLength(CONTACT_LLM_MAX_TURNS * 2);
86
99
  expect(history[0]).toEqual({ role: 'user', content: 'user-4' });
87
- expect(history.at(-1)).toEqual({ role: 'assistant', content: 'assistant-11' });
100
+ expect(history.at(-1)).toEqual({ role: 'assistant', content: 'assistant-35' });
88
101
  const page = listContactMessages(db, assistantID, { limit: 100 });
89
- expect(page.messages).toHaveLength(24);
102
+ expect(page.messages).toHaveLength(72);
90
103
  expect(page.messages[0].text).toBe('user-0');
91
104
  db.close();
92
105
  });
93
106
 
94
- it('drops peer, error, and pure-card rows from the LLM window', () => {
107
+ it('keeps card identity and drops peer and error rows from the LLM window', () => {
95
108
  const db = openDb();
96
109
  const assistantID = 'asst_filter';
97
110
  insert(db, assistantID, { role: 'user', text: 'keep-user' });
@@ -105,6 +118,7 @@ describe('contact LLM history trim', () => {
105
118
  expect(contactHistoryForLlm(db, assistantID)).toEqual([
106
119
  { role: 'user', content: 'keep-user' },
107
120
  { role: 'assistant', content: 'keep-assistant' },
121
+ { role: 'assistant', content: expect.stringContaining('\"sessionID\":\"ses_1\"') },
108
122
  ]);
109
123
  expect(listContactMessages(db, assistantID, { limit: 50 }).messages).toHaveLength(5);
110
124
  db.close();
@@ -169,6 +183,37 @@ describe('contact LLM history trim', () => {
169
183
  db.close();
170
184
  });
171
185
 
186
+ it('deleteContactTurnAssistantOutputs removes only this turn assistant rows', () => {
187
+ const db = openDb();
188
+ const assistantID = 'asst_turn_out';
189
+ const oldUser = insert(db, assistantID, { role: 'user', text: 'old', turnID: 'turn_old' });
190
+ insert(db, assistantID, { role: 'assistant', text: 'old-reply', turnID: 'turn_old' });
191
+ const clearing = insert(db, assistantID, { role: 'user', text: '开新对话', turnID: 'turn_reset' });
192
+ insert(db, assistantID, { role: 'assistant', text: 'preamble', turnID: 'turn_reset' });
193
+ insert(db, assistantID, {
194
+ role: 'peer',
195
+ text: 'peer keep',
196
+ turnID: 'turn_reset',
197
+ fromAssistantID: 'asst_other',
198
+ fromAssistantName: 'Other',
199
+ });
200
+ const queued = insert(db, assistantID, { role: 'user', text: 'queued', turnID: 'turn_q' });
201
+ const removed = deleteContactTurnAssistantOutputs(db, assistantID, 'turn_reset');
202
+ expect(removed).toBeGreaterThan(0);
203
+ const page = listContactMessages(db, assistantID, { limit: 50 });
204
+ expect(page.messages.map((message) => ({ role: message.role, text: message.text, turn: message.turnID }))).toEqual([
205
+ { role: 'user', text: 'old', turn: 'turn_old' },
206
+ { role: 'assistant', text: 'old-reply', turn: 'turn_old' },
207
+ { role: 'user', text: '开新对话', turn: 'turn_reset' },
208
+ { role: 'peer', text: 'peer keep', turn: 'turn_reset' },
209
+ { role: 'user', text: 'queued', turn: 'turn_q' },
210
+ ]);
211
+ expect(page.messages[0].messageID).toBe(oldUser.messageID);
212
+ expect(page.messages[2].messageID).toBe(clearing.messageID);
213
+ expect(page.messages[4].messageID).toBe(queued.messageID);
214
+ db.close();
215
+ });
216
+
172
217
  it('deleteContactMessages upToOrdinal keeps wipe user + later users; drops late assistant/cards', () => {
173
218
  const db = openDb();
174
219
  const assistantID = 'asst_wipe_bound';
@@ -474,6 +519,7 @@ describe('contact latest message preview', () => {
474
519
  expect(getLatestContactMessagePreview(db, assistantID)).toEqual({
475
520
  messageID: latest.messageID,
476
521
  ordinal: latest.ordinal,
522
+ createdAt: latest.createdAt,
477
523
  role: 'user',
478
524
  text: 'newest user line',
479
525
  fallbackKind: null,
@@ -495,6 +541,7 @@ describe('contact latest message preview', () => {
495
541
  expect(getLatestContactMessagePreview(db, assistantID)).toEqual({
496
542
  messageID: peer.messageID,
497
543
  ordinal: peer.ordinal,
544
+ createdAt: peer.createdAt,
498
545
  role: 'user',
499
546
  text: 'hello from peer',
500
547
  fallbackKind: null,
@@ -638,6 +685,7 @@ describe('contact latest message preview', () => {
638
685
  expect(preview).toMatchObject({
639
686
  messageID: latest.messageID,
640
687
  ordinal: latest.ordinal,
688
+ createdAt: latest.createdAt,
641
689
  role: 'user',
642
690
  text: 'visible-after-settles',
643
691
  fallbackKind: null,
@@ -668,3 +716,305 @@ describe('contact latest message preview', () => {
668
716
  db.close();
669
717
  });
670
718
  });
719
+
720
+ describe('contact read watermark / unread', () => {
721
+ it('counts only complete/error assistant and peer replies; ignores user and settle-only', () => {
722
+ expect(isContactVisiblePart({ type: 'text', text: 'hi' })).toBe(true);
723
+ expect(isContactVisiblePart({ type: 'text', text: ' ' })).toBe(false);
724
+ expect(isContactVisiblePart({ type: 'text', text: CONTACT_SETTLE_TEXT.complete })).toBe(false);
725
+ expect(isContactVisiblePart({ type: 'file', mime: 'image/png' })).toBe(true);
726
+ expect(isContactUnreadCountableMessage({
727
+ role: 'assistant',
728
+ status: 'complete',
729
+ text: 'hi',
730
+ parts: [{ type: 'text', text: 'hi' }],
731
+ })).toBe(true);
732
+ expect(isContactUnreadCountableMessage({
733
+ role: 'assistant',
734
+ status: 'error',
735
+ text: 'boom',
736
+ parts: [{ type: 'text', text: 'boom' }],
737
+ })).toBe(true);
738
+ expect(isContactUnreadCountableMessage({
739
+ role: 'peer',
740
+ status: 'complete',
741
+ text: 'dm',
742
+ parts: [{ type: 'text', text: 'dm' }],
743
+ })).toBe(true);
744
+ expect(isContactUnreadCountableMessage({
745
+ role: 'user',
746
+ status: 'complete',
747
+ text: 'me',
748
+ parts: [{ type: 'text', text: 'me' }],
749
+ })).toBe(false);
750
+ expect(isContactUnreadCountableMessage({
751
+ role: 'assistant',
752
+ status: 'complete',
753
+ text: CONTACT_SETTLE_TEXT.complete,
754
+ parts: [{ type: 'text', text: CONTACT_SETTLE_TEXT.complete }],
755
+ })).toBe(false);
756
+ // Blank / whitespace-only is not countable.
757
+ expect(isContactUnreadCountableMessage({
758
+ role: 'assistant',
759
+ status: 'complete',
760
+ text: '',
761
+ parts: [{ type: 'text', text: '' }],
762
+ })).toBe(false);
763
+ expect(isContactUnreadCountableMessage({
764
+ role: 'assistant',
765
+ status: 'complete',
766
+ text: ' ',
767
+ parts: [{ type: 'text', text: ' ' }],
768
+ })).toBe(false);
769
+ // Settle beside real body still counts (per-part, not joined-text startsWith).
770
+ expect(isContactUnreadCountableMessage({
771
+ role: 'assistant',
772
+ status: 'complete',
773
+ parts: [
774
+ { type: 'text', text: CONTACT_SETTLE_TEXT.complete },
775
+ { type: 'text', text: 'spoken summary' },
776
+ ],
777
+ })).toBe(true);
778
+ // Settle prefix + file/card hybrid counts.
779
+ expect(isContactUnreadCountableMessage({
780
+ role: 'assistant',
781
+ status: 'complete',
782
+ parts: [
783
+ { type: 'text', text: CONTACT_SETTLE_TEXT.error },
784
+ { type: 'file', mime: 'text/plain', filename: 'a.txt' },
785
+ ],
786
+ })).toBe(true);
787
+
788
+ const db = openDb();
789
+ const assistantID = 'asst_unread_count';
790
+ insert(db, assistantID, { role: 'user', text: 'u1' });
791
+ insert(db, assistantID, { role: 'assistant', text: 'a1' });
792
+ insert(db, assistantID, { role: 'assistant', text: CONTACT_SETTLE_TEXT.complete });
793
+ insert(db, assistantID, { role: 'peer', text: 'p1', fromAssistantID: 'other', fromAssistantName: 'Peer' });
794
+ insert(db, assistantID, { role: 'assistant', text: 'failed', status: 'error' });
795
+ // No watermark → all countable unread.
796
+ expect(countContactUnread(db, assistantID)).toBe(3);
797
+ db.close();
798
+ });
799
+
800
+ it('SQL single/batch counts match visible-part rules for blank, settle prefix, and mixed parts', () => {
801
+ const db = openDb();
802
+ const assistantID = 'asst_unread_visible';
803
+ insert(db, assistantID, { role: 'assistant', text: '' });
804
+ insert(db, assistantID, { role: 'assistant', text: ' ' });
805
+ insert(db, assistantID, { role: 'assistant', text: CONTACT_SETTLE_TEXT.complete });
806
+ insert(db, assistantID, {
807
+ role: 'assistant',
808
+ parts: [
809
+ { type: 'text', text: CONTACT_SETTLE_TEXT.question },
810
+ { type: 'text', text: 'need your input' },
811
+ ],
812
+ });
813
+ insert(db, assistantID, {
814
+ role: 'assistant',
815
+ parts: [
816
+ { type: 'text', text: CONTACT_SETTLE_TEXT.complete },
817
+ createSessionCardPart({
818
+ sessionID: 'ses_visible',
819
+ directory: '/tmp/proj',
820
+ title: 'Work',
821
+ status: 'complete',
822
+ }),
823
+ ],
824
+ });
825
+ insert(db, assistantID, { role: 'assistant', text: 'plain' });
826
+ // blank×2 + settle-only excluded; mixed text, settle+card, plain → 3
827
+ expect(countContactUnread(db, assistantID)).toBe(3);
828
+ expect(getContactUnreadSnapshots(db, [assistantID]).get(assistantID)?.unreadCount).toBe(3);
829
+
830
+ const tip = getContactReadTip(db, assistantID);
831
+ advanceContactReadWatermark(db, assistantID, {
832
+ generation: tip.generation,
833
+ ordinal: tip.ordinal,
834
+ messageID: tip.messageID,
835
+ }, { updatedAt: 1 });
836
+ expect(countContactUnread(db, assistantID)).toBe(0);
837
+ expect(getContactUnreadSnapshots(db, [assistantID]).get(assistantID)?.unreadCount).toBe(0);
838
+ db.close();
839
+ });
840
+
841
+ it('migrates legacy rows to default-read and only new messages count', () => {
842
+ const db = openDb();
843
+ const assistantID = 'asst_migrate_read';
844
+ const a1 = insert(db, assistantID, { role: 'assistant', text: 'old-a' });
845
+ const a2 = insert(db, assistantID, { role: 'assistant', text: 'old-b' });
846
+ expect(countContactUnread(db, assistantID)).toBe(2);
847
+ const migrated = migrateContactReadStateDefaultRead(db, { updatedAt: 1 });
848
+ expect(migrated.seeded).toBe(1);
849
+ expect(countContactUnread(db, assistantID)).toBe(0);
850
+ const watermark = getContactReadWatermark(db, assistantID);
851
+ expect(watermark).toMatchObject({
852
+ ordinal: a2.ordinal,
853
+ messageID: a2.messageID,
854
+ generation: 0,
855
+ });
856
+ // Re-seed is idempotent.
857
+ expect(migrateContactReadStateDefaultRead(db, { updatedAt: 2 }).seeded).toBe(0);
858
+
859
+ insert(db, assistantID, { role: 'user', text: 'new-u' });
860
+ const newer = insert(db, assistantID, { role: 'assistant', text: 'new-a' });
861
+ expect(countContactUnread(db, assistantID)).toBe(1);
862
+ expect(getContactReadTip(db, assistantID)).toMatchObject({
863
+ ordinal: newer.ordinal,
864
+ messageID: newer.messageID,
865
+ });
866
+ void a1;
867
+ db.close();
868
+ });
869
+
870
+ it('advances monotonically, clamps to tip, and rejects stale generation', () => {
871
+ const db = openDb();
872
+ const assistantID = 'asst_mono';
873
+ const first = insert(db, assistantID, { role: 'assistant', text: 'one' });
874
+ const second = insert(db, assistantID, { role: 'assistant', text: 'two' });
875
+ const third = insert(db, assistantID, { role: 'assistant', text: 'three' });
876
+
877
+ const toFirst = advanceContactReadWatermark(db, assistantID, {
878
+ generation: 0,
879
+ ordinal: first.ordinal,
880
+ messageID: first.messageID,
881
+ }, { updatedAt: 10 });
882
+ expect(toFirst.changed).toBe(true);
883
+ expect(toFirst.unreadCount).toBe(2);
884
+
885
+ // Equal → no-op.
886
+ const same = advanceContactReadWatermark(db, assistantID, {
887
+ generation: 0,
888
+ ordinal: first.ordinal,
889
+ messageID: first.messageID,
890
+ }, { updatedAt: 11 });
891
+ expect(same.changed).toBe(false);
892
+ expect(same.unreadCount).toBe(2);
893
+
894
+ // Late lower cursor cannot erase newer unread.
895
+ const staleLower = advanceContactReadWatermark(db, assistantID, {
896
+ generation: 0,
897
+ ordinal: 0,
898
+ messageID: '',
899
+ }, { updatedAt: 12 });
900
+ expect(staleLower.changed).toBe(false);
901
+ expect(getContactReadWatermark(db, assistantID).ordinal).toBe(first.ordinal);
902
+
903
+ // Forward to second.
904
+ const toSecond = advanceContactReadWatermark(db, assistantID, {
905
+ generation: 0,
906
+ ordinal: second.ordinal,
907
+ messageID: second.messageID,
908
+ }, { updatedAt: 13 });
909
+ expect(toSecond.changed).toBe(true);
910
+ expect(toSecond.unreadCount).toBe(1);
911
+
912
+ // Future cursor clamps to tip (third).
913
+ const overshoot = advanceContactReadWatermark(db, assistantID, {
914
+ generation: 0,
915
+ ordinal: third.ordinal + 99,
916
+ messageID: 'zzz_future',
917
+ }, { updatedAt: 14 });
918
+ expect(overshoot.changed).toBe(true);
919
+ expect(overshoot.readWatermark).toMatchObject({
920
+ ordinal: third.ordinal,
921
+ messageID: third.messageID,
922
+ });
923
+ expect(overshoot.unreadCount).toBe(0);
924
+
925
+ const generation = bumpContactGeneration(db, assistantID);
926
+ expect(generation).toBe(1);
927
+ resetContactReadWatermarkForGeneration(db, assistantID, generation, { updatedAt: 15 });
928
+ expect(() => advanceContactReadWatermark(db, assistantID, {
929
+ generation: 0,
930
+ ordinal: third.ordinal,
931
+ messageID: third.messageID,
932
+ })).toThrow(expect.objectContaining({ code: 'contact_generation_conflict' }));
933
+
934
+ // After generation reset, previous tip rows that remain are unread again.
935
+ expect(countContactUnread(db, assistantID)).toBe(3);
936
+ expect(compareContactReadCursor(
937
+ { ordinal: 1, messageID: 'a' },
938
+ { ordinal: 1, messageID: 'b' },
939
+ )).toBeLessThan(0);
940
+ db.close();
941
+ });
942
+
943
+ it('batches unread snapshots without per-assistant N+1 shape', () => {
944
+ const db = openDb();
945
+ const a = 'asst_batch_a';
946
+ const b = 'asst_batch_b';
947
+ insert(db, a, { role: 'assistant', text: 'a1' });
948
+ insert(db, a, { role: 'assistant', text: 'a2' });
949
+ insert(db, b, { role: 'peer', text: 'b1', fromAssistantID: 'x', fromAssistantName: 'X' });
950
+ const batch = getContactUnreadSnapshots(db, [a, b, 'asst_empty']);
951
+ expect(batch.get(a)?.unreadCount).toBe(2);
952
+ expect(batch.get(b)?.unreadCount).toBe(1);
953
+ expect(batch.get('asst_empty')?.unreadCount).toBe(0);
954
+ expect(batch.get(a)?.readTip?.ordinal).toBeGreaterThan(0);
955
+ expect(batch.get(a)?.readWatermark?.ordinal).toBe(0);
956
+ db.close();
957
+ });
958
+
959
+ it('pushes watermark keyset into SQL so fully-read history does not scan parts (scale + plan)', () => {
960
+ const db = openDb();
961
+ const assistantID = 'asst_unread_scale';
962
+ const otherID = 'asst_unread_scale_b';
963
+ for (let i = 0; i < 240; i += 1) {
964
+ insert(db, assistantID, { role: 'assistant', text: `hist-${i}` });
965
+ }
966
+ insert(db, otherID, { role: 'assistant', text: 'other-only' });
967
+ const tip = getContactReadTip(db, assistantID);
968
+ advanceContactReadWatermark(db, assistantID, {
969
+ generation: tip.generation,
970
+ ordinal: tip.ordinal,
971
+ messageID: tip.messageID,
972
+ }, { updatedAt: 1 });
973
+ // Fully read: single + batch both 0; other assistant still 1.
974
+ expect(countContactUnread(db, assistantID)).toBe(0);
975
+ const fullyRead = getContactUnreadSnapshots(db, [assistantID, otherID]);
976
+ expect(fullyRead.get(assistantID)?.unreadCount).toBe(0);
977
+ expect(fullyRead.get(otherID)?.unreadCount).toBe(1);
978
+
979
+ insert(db, assistantID, { role: 'assistant', text: CONTACT_SETTLE_TEXT.complete });
980
+ insert(db, assistantID, { role: 'assistant', text: '' });
981
+ insert(db, assistantID, { role: 'assistant', text: 'new-visible' });
982
+ expect(countContactUnread(db, assistantID)).toBe(1);
983
+ expect(getContactUnreadSnapshots(db, [assistantID]).get(assistantID)?.unreadCount).toBe(1);
984
+
985
+ // Keyset-bounded probe must use the page index path (not a full-table sort of history).
986
+ const plan = db.prepare(
987
+ `EXPLAIN QUERY PLAN
988
+ SELECT COUNT(*) AS count
989
+ FROM assistant_contact_message m
990
+ WHERE m.assistant_id = ?
991
+ AND m.role IN ('assistant', 'peer')
992
+ AND m.status IN ('complete', 'error')
993
+ AND (m.ordinal > ? OR (m.ordinal = ? AND m.message_id > ?))`,
994
+ ).all(assistantID, tip.ordinal, tip.ordinal, tip.messageID);
995
+ const planText = plan.map((row) => `${row.detail || ''}`).join('\n').toLowerCase();
996
+ expect(planText).toMatch(/assistant_contact_message/);
997
+ expect(
998
+ planText.includes('assistant_id')
999
+ || planText.includes('assistant_contact_message_page')
1000
+ || planText.includes('using index')
1001
+ || planText.includes('search'),
1002
+ ).toBe(true);
1003
+ // Batch VALUES+join form also stays index-friendly on assistant_id.
1004
+ const batchPlan = db.prepare(
1005
+ `EXPLAIN QUERY PLAN
1006
+ WITH marks(assistant_id, mark_ordinal, mark_message_id) AS (VALUES (?, ?, ?))
1007
+ SELECT m.assistant_id, COUNT(*) AS count
1008
+ FROM assistant_contact_message m
1009
+ INNER JOIN marks ON marks.assistant_id = m.assistant_id
1010
+ WHERE m.role IN ('assistant', 'peer')
1011
+ AND m.status IN ('complete', 'error')
1012
+ AND (m.ordinal > marks.mark_ordinal
1013
+ OR (m.ordinal = marks.mark_ordinal AND m.message_id > marks.mark_message_id))
1014
+ GROUP BY m.assistant_id`,
1015
+ ).all(assistantID, tip.ordinal, tip.messageID);
1016
+ const batchPlanText = batchPlan.map((row) => `${row.detail || ''}`).join('\n').toLowerCase();
1017
+ expect(batchPlanText).toMatch(/assistant_contact_message/);
1018
+ db.close();
1019
+ });
1020
+ });