@openchambery/web 1.19.12 → 1.19.13-beta.2

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 (168) hide show
  1. package/dist/assets/{AgentAvatar-CX0a1rKE.js → AgentAvatar-mbvX7YlW.js} +1 -1
  2. package/dist/assets/AssistantView-D5c557Oe.js +2 -0
  3. package/dist/assets/DiagramView-CjNUrHsU.js +1 -0
  4. package/dist/assets/FadeInOnReveal-moBbL8Ll.js +1 -0
  5. package/dist/assets/{Icon-BYM02QyU.js → Icon-CkzzhaLr.js} +1 -1
  6. package/dist/assets/MarkdownRenderer-BHfbAwVO.js +2 -0
  7. package/dist/assets/MarkdownRendererImpl-CkBoUibc.js +7 -0
  8. package/dist/assets/{MarkstreamRendererImpl-BFdJzxia.js → MarkstreamRendererImpl-BrXUOI9g.js} +1 -1
  9. package/dist/assets/MobileDetailNavigation-aY9-HwBN.js +1 -0
  10. package/dist/assets/MobileShareBridge-CRt_8DmM.js +4 -0
  11. package/dist/assets/MobileSurface-CIhtF-CU.js +2 -0
  12. package/dist/assets/{MultiRunWindow-C3p3lj3U.js → MultiRunWindow-DDq6C8t7.js} +1 -1
  13. package/dist/assets/OnboardingScreen-j0iCyH7f.js +1 -0
  14. package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-CDC5B3RH.js} +1 -1
  15. package/dist/assets/SettingsView-DFWWMvAj.js +25 -0
  16. package/dist/assets/{SettingsWindow-8IDuPZA5.js → SettingsWindow-CnYfBplB.js} +1 -1
  17. package/dist/assets/TerminalView-D5CQFVVm.js +8 -0
  18. package/dist/assets/ToolOutputDialog-VMI7TQFT.js +10 -0
  19. package/dist/assets/{appThemeRegistry-B87OLIgc.js → appThemeRegistry-UIrP0-5h.js} +1 -1
  20. package/dist/assets/assistant-attachment-cache-C0aRnFkj.js +1 -0
  21. package/dist/assets/{button-C0cth9tl.js → button-xx1UOBA5.js} +1 -1
  22. package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-CUasgPiF.js} +1 -1
  23. package/dist/assets/{collapsible-DFT9cE80.js → collapsible-CACHBRYZ.js} +1 -1
  24. package/dist/assets/{context-menu-CAp74NfV.js → context-menu-CYbm4wBZ.js} +1 -1
  25. package/dist/assets/desktopHosts-DILvrAok.js +1 -0
  26. package/dist/assets/desktopRelayRestore-t8asCr3m.js +1 -0
  27. package/dist/assets/{diagram-BryzMuta.js → diagram-u0BC9uuY.js} +1 -1
  28. package/dist/assets/{dialog-CgbV5ljg.js → dialog-BwQ3rY_x.js} +1 -1
  29. package/dist/assets/dualLimitLru-D8Sphgua.js +1 -0
  30. package/dist/assets/{es-BHqnSjoZ.js → es-CRjOwvYM.js} +1 -1
  31. package/dist/assets/fileQueries-Bgly-E8f.js +1 -0
  32. package/dist/assets/{fr-BfrlJY9i.js → fr-D9taEN9L.js} +1 -1
  33. package/dist/assets/{gitApi-tTtHQVA3.js → gitApi-Bu4W3bgD.js} +1 -1
  34. package/dist/assets/imageSource-BlAJJUEI.js +1 -0
  35. package/dist/assets/{input-DFLpqjGR.js → input-CI730njs.js} +1 -1
  36. package/dist/assets/insertionBoundaries-B1AqxsBD.js +4 -0
  37. package/dist/assets/{ja-DP-7qt-x.js → ja-CYQ5aoWp.js} +1 -1
  38. package/dist/assets/{ko-E2B5vGQP.js → ko-BwASV0Q0.js} +1 -1
  39. package/dist/assets/main-CqcB6tKc.js +169 -0
  40. package/dist/assets/main-DG-k0j8A.js +2 -0
  41. package/dist/assets/markdown-worker-CptoICmq.js +2 -0
  42. package/dist/assets/markdownHeightCache-DEjGKLv8.js +3 -0
  43. package/dist/assets/markdownTheme-DOfGg-A7.js +1 -0
  44. package/dist/assets/miniChat-BQtYQrsh.js +2 -0
  45. package/dist/assets/mobile-8iyqtXkr.js +2 -0
  46. package/dist/assets/{multirun-B0V3hdnD.js → multirun-CG_FCb_Z.js} +1 -1
  47. package/dist/assets/persistence-YbQKzHUQ.js +3 -0
  48. package/dist/assets/{pl-r8X5gRM5.js → pl-DUI1vPil.js} +1 -1
  49. package/dist/assets/platform-DzsGqlGW.js +2 -0
  50. package/dist/assets/projectMeta-DMpMDWE1.js +3 -0
  51. package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-CZQFJllP.js} +1 -1
  52. package/dist/assets/relay-image-stream-DM7atqJs.js +2 -0
  53. package/dist/assets/renderElectronMiniChatApp-DBzRdJl8.js +2 -0
  54. package/dist/assets/renderMobileApp-CIseqCyn.js +28 -0
  55. package/dist/assets/runtime-auth-D66yq6ie.js +1 -0
  56. package/dist/assets/runtime-fetch-zIalUnbb.js +1 -0
  57. package/dist/assets/runtime-switch-LvWDU-SL.js +1 -0
  58. package/dist/assets/runtimeConfig-D8nJYB-e.js +2 -0
  59. package/dist/assets/runtimeEndpointReset-CSub_ecV.js +147 -0
  60. package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DQDZ5HW-.js} +1 -1
  61. package/dist/assets/{sessionLookup-B6P_aCun.js → sessionLookup-BiBD-4wC.js} +1 -1
  62. package/dist/assets/sortable-tabs-strip-Dom6Sthu.js +1 -0
  63. package/dist/assets/src-AA-5nXsF.css +2 -0
  64. package/dist/assets/{store-BDFdnk6l.js → store-CSnH_QXM.js} +2 -2
  65. package/dist/assets/terminalApi-noa4Cdbh.js +1 -0
  66. package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BfNtUdJE.js} +1 -1
  67. package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-DPxumadF.js} +1 -1
  68. package/dist/assets/{uk-Di0vl9V-.js → uk-C1FYZlmA.js} +1 -1
  69. package/dist/assets/{url-Pk6tQ1EZ.js → url-CVhsI9kR.js} +1 -1
  70. package/dist/assets/{useAppFontEffects-Dmes1chr.js → useAppFontEffects-qHt5uaFN.js} +32 -32
  71. package/dist/assets/useAssistantUIStore-CLPCNoj_.js +1 -0
  72. package/dist/assets/useDesktopWindowControlsLayout-BipyIXiK.js +1 -0
  73. package/dist/assets/{useEffectiveDirectory-CU2ChP0J.js → useEffectiveDirectory-DSNCCbCt.js} +1 -1
  74. package/dist/assets/useFontPreferences-CB-cGo3I.js +1 -0
  75. package/dist/assets/useMobileNavigationStore-C1NJGAik.js +413 -0
  76. package/dist/assets/useSessionAutoCleanup-Dcn6Unvd.js +2 -0
  77. package/dist/assets/useUIStore-CxOoCv7B.js +1 -0
  78. package/dist/assets/useWorkerHighlightedLines-CzpT4M7V.js +7 -0
  79. package/dist/assets/{utils-BiK3tBGM.js → utils-BHTVlVov.js} +1 -1
  80. package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-C96qP1ip.js} +1 -1
  81. package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-BsRk1Rbb.js} +1 -1
  82. package/dist/index.html +12 -9
  83. package/dist/mini-chat.html +12 -9
  84. package/dist/mobile.html +12 -9
  85. package/package.json +1 -1
  86. package/server/index.js +19 -0
  87. package/server/lib/assistant-delivery-parts.js +19 -1
  88. package/server/lib/assistants/DOCUMENTATION.md +56 -11
  89. package/server/lib/assistants/assign.js +455 -30
  90. package/server/lib/assistants/assign.test.js +351 -0
  91. package/server/lib/assistants/cards.js +16 -2
  92. package/server/lib/assistants/cards.test.js +16 -1
  93. package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
  94. package/server/lib/assistants/contact-attachments.http.test.js +436 -0
  95. package/server/lib/assistants/contact-attachments.js +814 -0
  96. package/server/lib/assistants/contact-attachments.p1.test.js +307 -0
  97. package/server/lib/assistants/contact-attachments.test.js +249 -0
  98. package/server/lib/assistants/contact-store.js +552 -19
  99. package/server/lib/assistants/contact-store.test.js +415 -1
  100. package/server/lib/assistants/contact-tools.js +114 -14
  101. package/server/lib/assistants/contact-tools.test.js +135 -6
  102. package/server/lib/assistants/contracts.js +42 -2
  103. package/server/lib/assistants/harness.js +4 -1
  104. package/server/lib/assistants/harness.test.js +124 -0
  105. package/server/lib/assistants/routes.js +46 -1
  106. package/server/lib/assistants/service.js +569 -84
  107. package/server/lib/assistants/service.test.js +936 -37
  108. package/server/lib/fs/prompt-attachment-store.js +663 -0
  109. package/server/lib/fs/prompt-attachment-store.test.js +263 -0
  110. package/server/lib/fs/routes.js +28 -72
  111. package/server/lib/fs/routes.test.js +15 -30
  112. package/server/lib/llm/catalog.js +91 -16
  113. package/server/lib/llm/catalog.test.js +152 -11
  114. package/server/lib/notifications/APNS.md +20 -4
  115. package/server/lib/notifications/DOCUMENTATION.md +5 -0
  116. package/server/lib/notifications/apns-runtime.js +136 -18
  117. package/server/lib/notifications/apns-runtime.test.js +128 -0
  118. package/server/lib/notifications/apns-titles.js +21 -1
  119. package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
  120. package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
  121. package/server/lib/notifications/runtime.js +93 -2
  122. package/server/lib/notifications/runtime.test.js +167 -4
  123. package/server/lib/opencode/core-routes.js +2 -1
  124. package/server/lib/opencode/settings-helpers.js +12 -0
  125. package/server/lib/opencode/settings-runtime.js +16 -0
  126. package/server/lib/opencode/settings-runtime.test.js +21 -0
  127. package/server/lib/scheduled-tasks/runtime.js +21 -0
  128. package/server/lib/scheduled-tasks/runtime.test.js +24 -0
  129. package/dist/assets/AssistantView-DxIghtun.js +0 -2
  130. package/dist/assets/DiagramView-BeUgp_Y0.js +0 -1
  131. package/dist/assets/FadeInOnReveal-CTlNE4Hg.js +0 -1
  132. package/dist/assets/MarkdownRenderer-C4MGa_kQ.js +0 -2
  133. package/dist/assets/MarkdownRendererImpl-DB4Q3kGa.js +0 -7
  134. package/dist/assets/MobileDetailNavigation-B_RwWOwv.js +0 -1
  135. package/dist/assets/MobileShareBridge-UiOLx0-2.js +0 -4
  136. package/dist/assets/MobileSurface-CNChj_dX.js +0 -2
  137. package/dist/assets/OnboardingScreen-DS2nugLX.js +0 -1
  138. package/dist/assets/SettingsView-BXfKsz0J.js +0 -25
  139. package/dist/assets/TerminalView-BiKCueth.js +0 -8
  140. package/dist/assets/ToolOutputDialog-BC2F_Fdl.js +0 -10
  141. package/dist/assets/desktopHosts-Dc2PS66M.js +0 -1
  142. package/dist/assets/desktopRelayRestore-T9y0orqa.js +0 -1
  143. package/dist/assets/fileQueries-BY-9OWzp.js +0 -1
  144. package/dist/assets/insertionBoundaries-CCQpZ5zn.js +0 -4
  145. package/dist/assets/main-C_PUoT3E.js +0 -169
  146. package/dist/assets/main-Dkg31fRY.js +0 -2
  147. package/dist/assets/markdown-worker-CTsrtpPh.js +0 -2
  148. package/dist/assets/markdownHeightCache-CYDlisxr.js +0 -3
  149. package/dist/assets/markdownTheme-DyJ4Gfcj.js +0 -2
  150. package/dist/assets/miniChat-C9QMj8T8.js +0 -2
  151. package/dist/assets/mobile-Z_X7CbzB.js +0 -2
  152. package/dist/assets/persistence-DdUac14I.js +0 -3
  153. package/dist/assets/platform-B4KKLnZ5.js +0 -2
  154. package/dist/assets/projectMeta-DRW9MQ4z.js +0 -3
  155. package/dist/assets/renderElectronMiniChatApp-Dn1Su1dd.js +0 -2
  156. package/dist/assets/renderMobileApp-BG1JJt1Q.js +0 -28
  157. package/dist/assets/runtimeConfig-C6matIEB.js +0 -2
  158. package/dist/assets/runtimeEndpointReset-kgwmUJDb.js +0 -147
  159. package/dist/assets/sortable-tabs-strip-C8_9kHqu.js +0 -1
  160. package/dist/assets/src-B-P5qIBc.css +0 -2
  161. package/dist/assets/terminalApi-947l7Ra3.js +0 -1
  162. package/dist/assets/useAssistantUIStore-me6s63b2.js +0 -1
  163. package/dist/assets/useDesktopWindowControlsLayout-DuZ6URPR.js +0 -1
  164. package/dist/assets/useFontPreferences-BCOBlaAd.js +0 -1
  165. package/dist/assets/useMobileNavigationStore-C8_J-qTp.js +0 -413
  166. package/dist/assets/useSessionAutoCleanup-FOQeu1_T.js +0 -1
  167. package/dist/assets/useUIStore-CuaA8-Hp.js +0 -1
  168. package/dist/assets/useWorkerHighlightedLines-GBqKnki1.js +0 -7
@@ -3,20 +3,37 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { createRequire } from 'node:module';
5
5
  import { describe, expect, it } from 'vitest';
6
- import { createSessionCardPart } from './cards.js';
6
+ import {
7
+ createAssistantCardPart,
8
+ createScheduleCardPart,
9
+ createSessionCardPart,
10
+ } from './cards.js';
7
11
  import {
8
12
  CONTACT_LLM_FETCH_LIMIT,
9
13
  CONTACT_LLM_FILE_CHAR_WEIGHT,
10
14
  CONTACT_LLM_MAX_CHARS,
11
15
  CONTACT_LLM_MAX_TURNS,
16
+ CONTACT_PAGE_DEFAULT_LIMIT,
17
+ CONTACT_PAGE_MAX_LIMIT,
18
+ CONTACT_PREVIEW_MAX_CHARS,
19
+ CONTACT_SETTLE_TEXT,
20
+ bumpContactGeneration,
12
21
  clearContactMemory,
13
22
  contactHistoryForLlm,
23
+ createActiveContactTurn,
24
+ decodeContactCursor,
14
25
  deleteContactMessages,
26
+ encodeContactCursor,
15
27
  ensureContactSchema,
16
28
  getContactContextBoundary,
29
+ getContactGeneration,
30
+ getLatestContactMessagePreview,
31
+ getLatestContactMessagePreviews,
17
32
  insertContactMessage,
18
33
  listContactMessages,
19
34
  nextContactOrdinal,
35
+ projectActiveContactTurn,
36
+ sanitizeContactPreviewText,
20
37
  trimContactHistoryForLlm,
21
38
  } from './contact-store.js';
22
39
 
@@ -152,6 +169,52 @@ describe('contact LLM history trim', () => {
152
169
  db.close();
153
170
  });
154
171
 
172
+ it('deleteContactMessages upToOrdinal keeps wipe user + later users; drops late assistant/cards', () => {
173
+ const db = openDb();
174
+ const assistantID = 'asst_wipe_bound';
175
+ insert(db, assistantID, { role: 'user', text: 'seed-old' });
176
+ insert(db, assistantID, { role: 'assistant', text: 'seed-reply' });
177
+ const wipeTurn = insert(db, assistantID, { role: 'user', text: '清空聊天记录' });
178
+ const queued = insert(db, assistantID, {
179
+ role: 'user',
180
+ text: 'queued-B',
181
+ parts: [{ type: 'text', text: 'queued-B' }, { type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==', filename: 'b.txt' }],
182
+ });
183
+ // Late write from a prior still-running lane — ordinal after wipe user.
184
+ insert(db, assistantID, { role: 'assistant', text: 'late-A-reply-should-die' });
185
+ insert(db, assistantID, {
186
+ role: 'assistant',
187
+ text: '',
188
+ parts: [createSessionCardPart({
189
+ sessionID: 'ses_late_a',
190
+ directory: '/repo',
191
+ title: 'late A card',
192
+ status: 'busy',
193
+ })],
194
+ });
195
+ insert(db, assistantID, {
196
+ role: 'peer',
197
+ text: 'peer leftover',
198
+ fromAssistantID: 'asst_other',
199
+ fromAssistantName: 'Other',
200
+ });
201
+ deleteContactMessages(db, assistantID, { upToOrdinal: wipeTurn.ordinal });
202
+ const page = listContactMessages(db, assistantID, { limit: 50 });
203
+ expect(page.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([
204
+ { role: 'user', text: '清空聊天记录' },
205
+ { role: 'user', text: 'queued-B' },
206
+ ]);
207
+ expect(page.messages[0].messageID).toBe(wipeTurn.messageID);
208
+ expect(page.messages[1].messageID).toBe(queued.messageID);
209
+ expect(page.messages[1].parts.some((part) => part.type === 'file' && part.filename === 'b.txt')).toBe(true);
210
+ expect(page.messages.some((message) => message.cards?.length > 0)).toBe(false);
211
+ expect(page.messages.some((message) => (message.text || '').includes('late-A'))).toBe(false);
212
+ expect(page.messages.some((message) => message.role === 'peer')).toBe(false);
213
+ expect(getContactContextBoundary(db, assistantID)).toBe(0);
214
+ expect(contactHistoryForLlm(db, assistantID).some((item) => String(item.content).includes('late-A'))).toBe(false);
215
+ db.close();
216
+ });
217
+
155
218
  it('clearContactMemory keeps transcript rows and only advances the LLM boundary', () => {
156
219
  const db = openDb();
157
220
  const assistantID = 'asst_memory';
@@ -231,6 +294,16 @@ describe('contact LLM history trim', () => {
231
294
  reopened.close();
232
295
  });
233
296
 
297
+ it('projects the earliest active contact turn for snapshot recovery', () => {
298
+ expect(createActiveContactTurn({ turnID: '', messageID: 'm', status: 'queued', admittedAt: 1 })).toBeNull();
299
+ expect(createActiveContactTurn({ turnID: 't', messageID: 'm', status: 'settled', admittedAt: 1 })).toBeNull();
300
+ const queued = createActiveContactTurn({ turnID: 't1', messageID: 'm1', status: 'queued', admittedAt: 20 });
301
+ const running = createActiveContactTurn({ turnID: 't0', messageID: 'm0', status: 'running', admittedAt: 10 });
302
+ expect(projectActiveContactTurn([queued, running])).toEqual(running);
303
+ expect(projectActiveContactTurn(new Map([['t1', queued]]))).toEqual(queued);
304
+ expect(projectActiveContactTurn([])).toBeNull();
305
+ });
306
+
234
307
  it('keeps the previous boundary when clearContactMemory write fails inside a transaction', () => {
235
308
  const db = openDb();
236
309
  const assistantID = 'asst_fail';
@@ -254,3 +327,344 @@ describe('contact LLM history trim', () => {
254
327
  db.close();
255
328
  });
256
329
  });
330
+
331
+ describe('contact messages page keyset', () => {
332
+ it('pages 45 rows as 20/20/5 with opaque cursors and ascending pages', () => {
333
+ const db = openDb();
334
+ const assistantID = 'asst_page';
335
+ const ids = [];
336
+ for (let i = 0; i < 45; i += 1) {
337
+ ids.push(insert(db, assistantID, { role: i % 2 === 0 ? 'user' : 'assistant', text: `m-${i}` }).messageID);
338
+ }
339
+ expect(CONTACT_PAGE_DEFAULT_LIMIT).toBe(20);
340
+ expect(CONTACT_PAGE_MAX_LIMIT).toBe(100);
341
+
342
+ // First page is the newest window (chat tail), ascending within the page.
343
+ const first = listContactMessages(db, assistantID, { limit: 20 });
344
+ expect(first.messages).toHaveLength(20);
345
+ expect(first.complete).toBe(false);
346
+ expect(first.generation).toBe(0);
347
+ expect(first.messages[0].text).toBe('m-25');
348
+ expect(first.messages.at(-1).text).toBe('m-44');
349
+ expect(first.nextCursor).toEqual(expect.any(String));
350
+ const cursor1 = decodeContactCursor(first.nextCursor);
351
+ expect(cursor1).toMatchObject({
352
+ v: 1,
353
+ assistantID,
354
+ generation: 0,
355
+ messageID: ids[25],
356
+ });
357
+
358
+ const second = listContactMessages(db, assistantID, { before: first.nextCursor, limit: 20 });
359
+ expect(second.messages).toHaveLength(20);
360
+ expect(second.complete).toBe(false);
361
+ expect(second.messages[0].text).toBe('m-5');
362
+ expect(second.messages.at(-1).text).toBe('m-24');
363
+
364
+ const third = listContactMessages(db, assistantID, { before: second.nextCursor, limit: 20 });
365
+ expect(third.messages).toHaveLength(5);
366
+ expect(third.complete).toBe(true);
367
+ expect(third.nextCursor).toBeNull();
368
+ expect(third.messages.map((m) => m.text)).toEqual(['m-0', 'm-1', 'm-2', 'm-3', 'm-4']);
369
+ db.close();
370
+ });
371
+
372
+ it('rejects illegal cursors and cross-assistant cursor assistantID', () => {
373
+ const db = openDb();
374
+ insert(db, 'asst_a', { role: 'user', text: 'a' });
375
+ insert(db, 'asst_b', { role: 'user', text: 'b' });
376
+ expect(() => listContactMessages(db, 'asst_a', { before: 'not-base64!!!' })).toThrowError(
377
+ expect.objectContaining({ code: 'validation_error' }),
378
+ );
379
+ const foreign = encodeContactCursor({
380
+ assistantID: 'asst_b',
381
+ generation: 0,
382
+ ordinal: 1,
383
+ messageID: 'x',
384
+ });
385
+ expect(() => listContactMessages(db, 'asst_a', { before: foreign })).toThrowError(
386
+ expect.objectContaining({ code: 'validation_error' }),
387
+ );
388
+ expect(() => listContactMessages(db, 'asst_a', { before: 'x', messageID: 'y' })).toThrowError(
389
+ expect.objectContaining({ code: 'validation_error' }),
390
+ );
391
+ db.close();
392
+ });
393
+
394
+ it('bumps generation on wipe path only; clear-memory keeps generation and cursors', () => {
395
+ const db = openDb();
396
+ const assistantID = 'asst_gen';
397
+ for (let i = 0; i < 5; i += 1) insert(db, assistantID, { role: 'user', text: `g-${i}` });
398
+ const page0 = listContactMessages(db, assistantID, { limit: 2 });
399
+ expect(page0.generation).toBe(0);
400
+ const cursor = page0.nextCursor;
401
+
402
+ clearContactMemory(db, assistantID, { updatedAt: 1 });
403
+ expect(getContactGeneration(db, assistantID)).toBe(0);
404
+ expect(listContactMessages(db, assistantID, { before: cursor, limit: 2 }).messages).toHaveLength(2);
405
+
406
+ expect(bumpContactGeneration(db, assistantID)).toBe(1);
407
+ deleteContactMessages(db, assistantID);
408
+ expect(getContactGeneration(db, assistantID)).toBe(1);
409
+ expect(() => listContactMessages(db, assistantID, { before: cursor, limit: 2 })).toThrowError(
410
+ expect.objectContaining({ code: 'contact_generation_conflict' }),
411
+ );
412
+ db.close();
413
+ });
414
+
415
+ it('exact messageID is assistant-scoped, missing returns empty, works past 100 rows', () => {
416
+ const db = openDb();
417
+ const assistantID = 'asst_exact';
418
+ const other = 'asst_other';
419
+ const ids = [];
420
+ for (let i = 0; i < 120; i += 1) {
421
+ ids.push(insert(db, assistantID, { role: 'user', text: `e-${i}` }).messageID);
422
+ }
423
+ const foreign = insert(db, other, { role: 'user', text: 'foreign' });
424
+
425
+ const hit = listContactMessages(db, assistantID, { messageID: ids[110] });
426
+ expect(hit).toMatchObject({
427
+ nextCursor: null,
428
+ complete: true,
429
+ generation: 0,
430
+ });
431
+ expect(hit.messages).toHaveLength(1);
432
+ expect(hit.messages[0].text).toBe('e-110');
433
+
434
+ expect(listContactMessages(db, assistantID, { messageID: foreign.messageID }).messages).toEqual([]);
435
+ expect(listContactMessages(db, assistantID, { messageID: 'missing_id' }).messages).toEqual([]);
436
+ expect(listContactMessages(db, other, { messageID: foreign.messageID }).messages[0].text).toBe('foreign');
437
+ db.close();
438
+ });
439
+
440
+ it('isolates pages across assistants under concurrent appends', () => {
441
+ const db = openDb();
442
+ for (let i = 0; i < 10; i += 1) {
443
+ insert(db, 'asst_left', { role: 'user', text: `L-${i}` });
444
+ insert(db, 'asst_right', { role: 'user', text: `R-${i}` });
445
+ }
446
+ const left = listContactMessages(db, 'asst_left', { limit: 5 });
447
+ const right = listContactMessages(db, 'asst_right', { limit: 5 });
448
+ expect(left.messages.every((m) => m.assistantID === 'asst_left')).toBe(true);
449
+ expect(right.messages.every((m) => m.assistantID === 'asst_right')).toBe(true);
450
+ // Newest window first.
451
+ expect(left.messages.map((m) => m.text)).toEqual(['L-5', 'L-6', 'L-7', 'L-8', 'L-9']);
452
+ insert(db, 'asst_left', { role: 'user', text: 'L-append' });
453
+ const left2 = listContactMessages(db, 'asst_left', { before: left.nextCursor, limit: 5 });
454
+ expect(left2.messages.map((m) => m.text)).toEqual(['L-0', 'L-1', 'L-2', 'L-3', 'L-4']);
455
+ expect(listContactMessages(db, 'asst_right', { limit: 100 }).messages).toHaveLength(10);
456
+ db.close();
457
+ });
458
+ });
459
+
460
+ describe('contact latest message preview', () => {
461
+ it('returns null when the assistant has no contact rows', () => {
462
+ const db = openDb();
463
+ expect(getLatestContactMessagePreview(db, 'asst_empty')).toBeNull();
464
+ expect(getLatestContactMessagePreviews(db, ['asst_empty']).get('asst_empty')).toBeNull();
465
+ db.close();
466
+ });
467
+
468
+ it('picks the newest user or assistant text by ordinal (not createdAt heuristics)', () => {
469
+ const db = openDb();
470
+ const assistantID = 'asst_preview_text';
471
+ insert(db, assistantID, { role: 'user', text: 'older user' });
472
+ insert(db, assistantID, { role: 'assistant', text: 'older assistant' });
473
+ const latest = insert(db, assistantID, { role: 'user', text: 'newest user line' });
474
+ expect(getLatestContactMessagePreview(db, assistantID)).toEqual({
475
+ messageID: latest.messageID,
476
+ ordinal: latest.ordinal,
477
+ role: 'user',
478
+ text: 'newest user line',
479
+ fallbackKind: null,
480
+ });
481
+ db.close();
482
+ });
483
+
484
+ it('skips settle-only markers and maps peer DM to user role', () => {
485
+ const db = openDb();
486
+ const assistantID = 'asst_preview_peer';
487
+ insert(db, assistantID, { role: 'user', text: 'seed' });
488
+ insert(db, assistantID, { role: 'assistant', text: CONTACT_SETTLE_TEXT.complete });
489
+ const peer = insert(db, assistantID, {
490
+ role: 'peer',
491
+ text: 'hello from peer',
492
+ fromAssistantID: 'asst_other',
493
+ fromAssistantName: 'Other',
494
+ });
495
+ expect(getLatestContactMessagePreview(db, assistantID)).toEqual({
496
+ messageID: peer.messageID,
497
+ ordinal: peer.ordinal,
498
+ role: 'user',
499
+ text: 'hello from peer',
500
+ fallbackKind: null,
501
+ });
502
+ db.close();
503
+ });
504
+
505
+ it('keeps durable error body text as the preview', () => {
506
+ const db = openDb();
507
+ const assistantID = 'asst_preview_err';
508
+ insert(db, assistantID, { role: 'user', text: 'hi' });
509
+ const err = insert(db, assistantID, {
510
+ role: 'assistant',
511
+ text: 'No connected model',
512
+ status: 'error',
513
+ });
514
+ expect(getLatestContactMessagePreview(db, assistantID)).toMatchObject({
515
+ messageID: err.messageID,
516
+ role: 'assistant',
517
+ text: 'No connected model',
518
+ fallbackKind: null,
519
+ });
520
+ db.close();
521
+ });
522
+
523
+ it('uses attachment and card fallbackKind when there is no spoken text', () => {
524
+ const db = openDb();
525
+ const assistantID = 'asst_preview_fb';
526
+ insert(db, assistantID, {
527
+ role: 'user',
528
+ text: '',
529
+ parts: [{ type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }],
530
+ });
531
+ expect(getLatestContactMessagePreview(db, assistantID)).toMatchObject({
532
+ role: 'user',
533
+ text: 'shot.png',
534
+ fallbackKind: 'image',
535
+ });
536
+ insert(db, assistantID, {
537
+ role: 'user',
538
+ text: '',
539
+ parts: [{ type: 'file', mime: 'application/pdf', url: 'data:application/pdf;base64,aa', filename: 'doc.pdf' }],
540
+ });
541
+ expect(getLatestContactMessagePreview(db, assistantID)).toMatchObject({
542
+ fallbackKind: 'file',
543
+ text: 'doc.pdf',
544
+ });
545
+ insert(db, assistantID, {
546
+ role: 'assistant',
547
+ text: '',
548
+ parts: [createSessionCardPart({
549
+ sessionID: 'ses_1',
550
+ directory: '/repo',
551
+ title: 'Fix login',
552
+ status: 'busy',
553
+ })],
554
+ });
555
+ expect(getLatestContactMessagePreview(db, assistantID)).toMatchObject({
556
+ role: 'assistant',
557
+ fallbackKind: 'session',
558
+ text: 'Fix login',
559
+ });
560
+ insert(db, assistantID, {
561
+ role: 'assistant',
562
+ text: '',
563
+ parts: [createAssistantCardPart({
564
+ assistantID: 'asst_x',
565
+ name: 'Flow',
566
+ providerID: 'p',
567
+ modelID: 'm',
568
+ })],
569
+ });
570
+ expect(getLatestContactMessagePreview(db, assistantID)).toMatchObject({
571
+ fallbackKind: 'assistant',
572
+ text: 'Flow',
573
+ });
574
+ insert(db, assistantID, {
575
+ role: 'assistant',
576
+ text: '',
577
+ parts: [createScheduleCardPart({
578
+ taskID: 'task_1',
579
+ projectID: 'proj_1',
580
+ name: 'Daily ping',
581
+ })],
582
+ });
583
+ expect(getLatestContactMessagePreview(db, assistantID)).toMatchObject({
584
+ fallbackKind: 'schedule',
585
+ text: 'Daily ping',
586
+ });
587
+ db.close();
588
+ });
589
+
590
+ it('bounds and sanitizes long / base64 text', () => {
591
+ const db = openDb();
592
+ const assistantID = 'asst_preview_bound';
593
+ const long = `hello ${'字'.repeat(CONTACT_PREVIEW_MAX_CHARS + 80)}`;
594
+ const dirty = `see data:image/png;base64,${'A'.repeat(80)} trailing`;
595
+ expect(sanitizeContactPreviewText(long).length).toBeLessThanOrEqual(CONTACT_PREVIEW_MAX_CHARS);
596
+ expect(sanitizeContactPreviewText(dirty)).not.toContain('base64');
597
+ insert(db, assistantID, { role: 'assistant', text: long });
598
+ const preview = getLatestContactMessagePreview(db, assistantID);
599
+ expect(preview.text.length).toBeLessThanOrEqual(CONTACT_PREVIEW_MAX_CHARS);
600
+ expect(preview.text.startsWith('hello')).toBe(true);
601
+ db.close();
602
+ });
603
+
604
+ it('batches previews across assistants without mixing identities', () => {
605
+ const db = openDb();
606
+ insert(db, 'asst_a', { role: 'user', text: 'from-a' });
607
+ insert(db, 'asst_b', { role: 'assistant', text: 'from-b' });
608
+ insert(db, 'asst_c', { role: 'user', text: 'unused' });
609
+ deleteContactMessages(db, 'asst_c');
610
+ const map = getLatestContactMessagePreviews(db, ['asst_a', 'asst_b', 'asst_c', 'asst_missing']);
611
+ expect(map.get('asst_a').text).toBe('from-a');
612
+ expect(map.get('asst_b').text).toBe('from-b');
613
+ expect(map.get('asst_c')).toBeNull();
614
+ expect(map.get('asst_missing')).toBeNull();
615
+ db.close();
616
+ });
617
+
618
+ it('finds latest visible text after a long settle tail and large history without full-table rank', () => {
619
+ const db = openDb();
620
+ const assistantID = 'asst_preview_deep';
621
+ for (let i = 0; i < 120; i += 1) {
622
+ insert(db, assistantID, { role: 'user', text: `hist-${i}` });
623
+ insert(db, assistantID, { role: 'assistant', text: `reply-${i}` });
624
+ }
625
+ const latest = insert(db, assistantID, { role: 'user', text: 'visible-after-settles' });
626
+ // More settle markers than CONTACT_PREVIEW_CANDIDATE_LIMIT after the real message.
627
+ for (let i = 0; i < 40; i += 1) {
628
+ insert(db, assistantID, {
629
+ role: 'assistant',
630
+ text: CONTACT_SETTLE_TEXT.complete,
631
+ });
632
+ insert(db, assistantID, {
633
+ role: 'assistant',
634
+ text: CONTACT_SETTLE_TEXT.error,
635
+ });
636
+ }
637
+ const preview = getLatestContactMessagePreview(db, assistantID);
638
+ expect(preview).toMatchObject({
639
+ messageID: latest.messageID,
640
+ ordinal: latest.ordinal,
641
+ role: 'user',
642
+ text: 'visible-after-settles',
643
+ fallbackKind: null,
644
+ });
645
+ const batch = getLatestContactMessagePreviews(db, [assistantID, 'asst_other_empty']);
646
+ expect(batch.get(assistantID)?.text).toBe('visible-after-settles');
647
+ expect(batch.get('asst_other_empty')).toBeNull();
648
+
649
+ // Probe must be an assistant_id range scan on the page index, not a full sort.
650
+ const plan = db.prepare(
651
+ `EXPLAIN QUERY PLAN
652
+ SELECT message_id, assistant_id, role, ordinal, status
653
+ FROM assistant_contact_message m
654
+ WHERE m.assistant_id = ?
655
+ AND m.role IN ('user', 'assistant', 'peer')
656
+ ORDER BY m.ordinal DESC, m.message_id DESC
657
+ LIMIT ?`,
658
+ ).all(assistantID, 24);
659
+ const planText = plan.map((row) => `${row.detail || ''}`).join('\n').toLowerCase();
660
+ expect(planText).toMatch(/assistant_contact_message/);
661
+ // Must not be a whole-table SCAN without using the assistant_id path.
662
+ expect(
663
+ planText.includes('assistant_id')
664
+ || planText.includes('assistant_contact_message_page')
665
+ || planText.includes('using index')
666
+ || planText.includes('search'),
667
+ ).toBe(true);
668
+ db.close();
669
+ });
670
+ });