@openchambery/web 1.19.13-beta.1 → 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 (146) hide show
  1. package/dist/assets/{AgentAvatar-CiQIIM0d.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-CeKHYyKZ.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-B-odd2Gz.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-BT5Dwgzu.js → MultiRunWindow-DDq6C8t7.js} +1 -1
  13. package/dist/assets/OnboardingScreen-j0iCyH7f.js +1 -0
  14. package/dist/assets/{OverlayScrollbar-DUghqFGg.js → OverlayScrollbar-CDC5B3RH.js} +1 -1
  15. package/dist/assets/SettingsView-DFWWMvAj.js +25 -0
  16. package/dist/assets/{SettingsWindow-DfEGfyr8.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-CIZ-shMD.js → appThemeRegistry-UIrP0-5h.js} +1 -1
  20. package/dist/assets/assistant-attachment-cache-C0aRnFkj.js +1 -0
  21. package/dist/assets/{button-DFPOdZ0g.js → button-xx1UOBA5.js} +1 -1
  22. package/dist/assets/{clipboard-C05anBwU.js → clipboard-CUasgPiF.js} +1 -1
  23. package/dist/assets/{collapsible-oGbEvbe1.js → collapsible-CACHBRYZ.js} +1 -1
  24. package/dist/assets/{context-menu-DGz4_2hs.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-Csk5GpDC.js → diagram-u0BC9uuY.js} +1 -1
  28. package/dist/assets/{dialog-rrqB-H6q.js → dialog-BwQ3rY_x.js} +1 -1
  29. package/dist/assets/dualLimitLru-D8Sphgua.js +1 -0
  30. package/dist/assets/{es-FE0sJ9EC.js → es-CRjOwvYM.js} +1 -1
  31. package/dist/assets/fileQueries-Bgly-E8f.js +1 -0
  32. package/dist/assets/{fr-DNDCSfGt.js → fr-D9taEN9L.js} +1 -1
  33. package/dist/assets/{gitApi-_P0FsoXe.js → gitApi-Bu4W3bgD.js} +1 -1
  34. package/dist/assets/imageSource-BlAJJUEI.js +1 -0
  35. package/dist/assets/{input-BO3r20op.js → input-CI730njs.js} +1 -1
  36. package/dist/assets/insertionBoundaries-B1AqxsBD.js +4 -0
  37. package/dist/assets/{ja-BDwrk0cM.js → ja-CYQ5aoWp.js} +1 -1
  38. package/dist/assets/{ko-BeeU2o4I.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-CxhTsmRq.js → multirun-CG_FCb_Z.js} +1 -1
  47. package/dist/assets/persistence-YbQKzHUQ.js +3 -0
  48. package/dist/assets/{pl-BYuzpcI5.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-Bn7cwEzv.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-BBLFgl85.js → runtimeEndpointReset-CSub_ecV.js} +6 -6
  60. package/dist/assets/{sessionChangeSummary-DDVT7O5J.js → sessionChangeSummary-DQDZ5HW-.js} +1 -1
  61. package/dist/assets/{sessionLookup-C8VZn0gJ.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-n94dSb3M.js → store-CSnH_QXM.js} +2 -2
  65. package/dist/assets/terminalApi-noa4Cdbh.js +1 -0
  66. package/dist/assets/{textarea-BjX-KhMN.js → textarea-BfNtUdJE.js} +1 -1
  67. package/dist/assets/{tooltip-CQGqGAG_.js → tooltip-DPxumadF.js} +1 -1
  68. package/dist/assets/{uk-CwpbHEa3.js → uk-C1FYZlmA.js} +1 -1
  69. package/dist/assets/{url-BMjqccCo.js → url-CVhsI9kR.js} +1 -1
  70. package/dist/assets/{useAppFontEffects-BIFw36SA.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-DJSYYkVp.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-81nUI_xp.js → utils-BHTVlVov.js} +1 -1
  80. package/dist/assets/{zh-CN-BpzFZ-LU.js → zh-CN-C96qP1ip.js} +1 -1
  81. package/dist/assets/{zh-TW-Bopgc5Eo.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/lib/assistant-delivery-parts.js +19 -1
  87. package/server/lib/assistants/DOCUMENTATION.md +39 -7
  88. package/server/lib/assistants/cards.js +16 -2
  89. package/server/lib/assistants/cards.test.js +16 -1
  90. package/server/lib/assistants/contact-attachments.http.test.js +436 -0
  91. package/server/lib/assistants/contact-attachments.js +814 -0
  92. package/server/lib/assistants/contact-attachments.p1.test.js +307 -0
  93. package/server/lib/assistants/contact-attachments.test.js +249 -0
  94. package/server/lib/assistants/contact-store.js +494 -25
  95. package/server/lib/assistants/contact-store.test.js +403 -1
  96. package/server/lib/assistants/contact-tools.js +6 -5
  97. package/server/lib/assistants/contact-tools.test.js +24 -6
  98. package/server/lib/assistants/contracts.js +41 -1
  99. package/server/lib/assistants/harness.test.js +124 -0
  100. package/server/lib/assistants/routes.js +46 -1
  101. package/server/lib/assistants/service.js +258 -66
  102. package/server/lib/assistants/service.test.js +400 -30
  103. package/server/lib/fs/prompt-attachment-store.js +663 -0
  104. package/server/lib/fs/prompt-attachment-store.test.js +263 -0
  105. package/server/lib/fs/routes.js +28 -72
  106. package/server/lib/fs/routes.test.js +15 -30
  107. package/server/lib/opencode/core-routes.js +2 -1
  108. package/dist/assets/AssistantView-YmeyxehG.js +0 -2
  109. package/dist/assets/DiagramView-BpZb-G9P.js +0 -1
  110. package/dist/assets/FadeInOnReveal-nf6-ZCrs.js +0 -1
  111. package/dist/assets/MarkdownRenderer-wpeGdciL.js +0 -2
  112. package/dist/assets/MarkdownRendererImpl-Peu0GVSQ.js +0 -7
  113. package/dist/assets/MobileDetailNavigation-CtYke_1C.js +0 -1
  114. package/dist/assets/MobileShareBridge-DIfOl8EZ.js +0 -4
  115. package/dist/assets/MobileSurface-_KoX75Ff.js +0 -2
  116. package/dist/assets/OnboardingScreen-BPusUgFG.js +0 -1
  117. package/dist/assets/SettingsView-BwQpbIZl.js +0 -25
  118. package/dist/assets/TerminalView-8402vpcO.js +0 -8
  119. package/dist/assets/ToolOutputDialog-B5RdkhsG.js +0 -10
  120. package/dist/assets/desktopHosts-BVJXQq1S.js +0 -1
  121. package/dist/assets/desktopRelayRestore-RDfuj1M7.js +0 -1
  122. package/dist/assets/fileQueries-BcdWOPlD.js +0 -1
  123. package/dist/assets/insertionBoundaries-CWoioTdE.js +0 -4
  124. package/dist/assets/main-CLj_WS1o.js +0 -2
  125. package/dist/assets/main-CnSZ89pD.js +0 -169
  126. package/dist/assets/markdown-worker-CZWE1F1M.js +0 -2
  127. package/dist/assets/markdownHeightCache-Cdd3hkMl.js +0 -3
  128. package/dist/assets/markdownTheme-DIB8WVmz.js +0 -2
  129. package/dist/assets/miniChat-B9c3y2pD.js +0 -2
  130. package/dist/assets/mobile-DvbXUfNS.js +0 -2
  131. package/dist/assets/persistence-CVTWFaqe.js +0 -3
  132. package/dist/assets/platform-BY_Gtsqr.js +0 -2
  133. package/dist/assets/projectMeta-Dl5iPCB6.js +0 -3
  134. package/dist/assets/renderElectronMiniChatApp-CkJ51qCh.js +0 -2
  135. package/dist/assets/renderMobileApp-DcjQPhWw.js +0 -28
  136. package/dist/assets/runtimeConfig-Bsk2kWsW.js +0 -2
  137. package/dist/assets/sortable-tabs-strip-s7UUxsa6.js +0 -1
  138. package/dist/assets/src-BpHDMgud.css +0 -2
  139. package/dist/assets/terminalApi-C54_rKEK.js +0 -1
  140. package/dist/assets/useAssistantUIStore-Cg9bsT4b.js +0 -1
  141. package/dist/assets/useDesktopWindowControlsLayout-DoygEcN4.js +0 -1
  142. package/dist/assets/useFontPreferences-B5_apbBC.js +0 -1
  143. package/dist/assets/useMobileNavigationStore-DuTu0Mzh.js +0 -413
  144. package/dist/assets/useSessionAutoCleanup-C-L9teva.js +0 -1
  145. package/dist/assets/useUIStore-DwpQTjLO.js +0 -1
  146. package/dist/assets/useWorkerHighlightedLines-DxTfgdEi.js +0 -7
@@ -3,22 +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,
14
23
  createActiveContactTurn,
24
+ decodeContactCursor,
15
25
  deleteContactMessages,
26
+ encodeContactCursor,
16
27
  ensureContactSchema,
17
28
  getContactContextBoundary,
29
+ getContactGeneration,
30
+ getLatestContactMessagePreview,
31
+ getLatestContactMessagePreviews,
18
32
  insertContactMessage,
19
33
  listContactMessages,
20
34
  nextContactOrdinal,
21
35
  projectActiveContactTurn,
36
+ sanitizeContactPreviewText,
22
37
  trimContactHistoryForLlm,
23
38
  } from './contact-store.js';
24
39
 
@@ -154,6 +169,52 @@ describe('contact LLM history trim', () => {
154
169
  db.close();
155
170
  });
156
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
+
157
218
  it('clearContactMemory keeps transcript rows and only advances the LLM boundary', () => {
158
219
  const db = openDb();
159
220
  const assistantID = 'asst_memory';
@@ -266,3 +327,344 @@ describe('contact LLM history trim', () => {
266
327
  db.close();
267
328
  });
268
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
+ });
@@ -370,10 +370,10 @@ const hasAssignSessionIntent = (text) => hasIntentMatch(ASSIGN_SESSION_INTENT, t
370
370
 
371
371
  /**
372
372
  * True when userText has an explicit, non-negated transcript-wipe phrase.
373
- * Server core uses this to authorize clear_chat_history / resetContact — the
374
- * model cannot self-authorize through tool parameters.
373
+ * Tool-selection hint only (missed-fence retry / detectRequestedContactTools).
374
+ * Invoking clear_chat_history executes the wipe — no separate text gate.
375
375
  */
376
- export function userTextAuthorizesClearChatHistory(userText) {
376
+ export function userTextRequestsClearChatHistory(userText) {
377
377
  return hasIntentMatch(CLEAR_CHAT_HISTORY_INTENT, typeof userText === 'string' ? userText : '');
378
378
  }
379
379
 
@@ -389,8 +389,8 @@ export function detectRequestedContactTools(userText, allowedNames = []) {
389
389
  const text = typeof userText === 'string' ? userText : '';
390
390
  const requested = [];
391
391
  // Prefer explicit wipe over the safe clear-memory default when both could match.
392
- // Negation (不要清除聊天记录) must not authorize delete.
393
- const wantsClearHistory = allowed.has(CLEAR_CHAT_HISTORY_TOOL_NAME) && userTextAuthorizesClearChatHistory(text);
392
+ // Negation (不要清除聊天记录) is not a wipe request hint.
393
+ const wantsClearHistory = allowed.has(CLEAR_CHAT_HISTORY_TOOL_NAME) && userTextRequestsClearChatHistory(text);
394
394
  if (wantsClearHistory) {
395
395
  requested.push(CLEAR_CHAT_HISTORY_TOOL_NAME);
396
396
  } else if (allowed.has(NEW_CONVERSATION_TOOL_NAME) && hasNewConversationIntent(text)) {
@@ -799,6 +799,7 @@ export function createContactTools({
799
799
  if (typeof resetContact !== 'function') {
800
800
  throw new AssignError('upstream_error', 'Clearing chat history is unavailable.');
801
801
  }
802
+ // Model selected the tool → execute wipe. Storage failures surface as errors.
802
803
  await resetContact();
803
804
  return {
804
805
  content: [{ type: 'text', text: CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE }],
@@ -13,6 +13,7 @@ import {
13
13
  NEW_CONVERSATION_TOOL_NAME,
14
14
  SCHEDULE_TASK_TOOL_NAME,
15
15
  confirmBubbleAfterContactReset,
16
+ contactTurnHasSuccessfulReset,
16
17
  createContactTools,
17
18
  detectRequestedContactTools,
18
19
  filterRegisteredProjects,
@@ -25,7 +26,7 @@ import {
25
26
  resolveContactProviderModel,
26
27
  resolvePeerAssistant,
27
28
  stripContactToolFences,
28
- userTextAuthorizesClearChatHistory,
29
+ userTextRequestsClearChatHistory,
29
30
  } from './contact-tools.js';
30
31
  import { attachmentScopeKey } from './assign.js';
31
32
 
@@ -120,11 +121,11 @@ describe('contact tool protocol', () => {
120
121
  expect(detectRequestedContactTools('开新对话', tools)).not.toContain(ASSIGN_SESSION_TOOL_NAME);
121
122
  expect(detectRequestedContactTools('开新对话', tools)).not.toContain(CLEAR_CHAT_HISTORY_TOOL_NAME);
122
123
  expect(detectRequestedContactTools('不要开编码 session', tools)).toEqual([]);
123
- expect(userTextAuthorizesClearChatHistory('清除聊天记录')).toBe(true);
124
- expect(userTextAuthorizesClearChatHistory('清空聊天记录')).toBe(true);
125
- expect(userTextAuthorizesClearChatHistory('清除记忆')).toBe(false);
126
- expect(userTextAuthorizesClearChatHistory('不要清除聊天记录')).toBe(false);
127
- expect(userTextAuthorizesClearChatHistory('开新对话')).toBe(false);
124
+ expect(userTextRequestsClearChatHistory('清除聊天记录')).toBe(true);
125
+ expect(userTextRequestsClearChatHistory('清空聊天记录')).toBe(true);
126
+ expect(userTextRequestsClearChatHistory('清除记忆')).toBe(false);
127
+ expect(userTextRequestsClearChatHistory('不要清除聊天记录')).toBe(false);
128
+ expect(userTextRequestsClearChatHistory('开新对话')).toBe(false);
128
129
  });
129
130
 
130
131
  it('parses bash fences when the pi coding tools are allowed', () => {
@@ -292,6 +293,23 @@ describe('createContactTools', () => {
292
293
  expect(wiped.details).toMatchObject({ reset: true, historyCleared: true });
293
294
  expect(wiped.terminate).toBe(true);
294
295
  expect(wiped.content[0].text).toBe(CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE);
296
+ expect(wiped.content[0].text).not.toMatch(/not cleared|denied|new_conversation instead/i);
297
+ expect(contactTurnHasSuccessfulReset([{
298
+ role: 'toolResult',
299
+ toolName: CLEAR_CHAT_HISTORY_TOOL_NAME,
300
+ details: wiped.details,
301
+ }])).toBe(true);
302
+
303
+ // Storage failure still surfaces as a tool error (no silent denial protocol).
304
+ resetContact.mockRejectedValueOnce(Object.assign(new Error('sqlite busy'), { code: 'upstream_error' }));
305
+ const failed = await tools.find((tool) => tool.name === CLEAR_CHAT_HISTORY_TOOL_NAME).execute('call_wipe_fail', {});
306
+ expect(failed.details.error).toBe('upstream_error');
307
+ expect(failed.terminate).toBe(true);
308
+ expect(contactTurnHasSuccessfulReset([{
309
+ role: 'toolResult',
310
+ toolName: CLEAR_CHAT_HISTORY_TOOL_NAME,
311
+ details: failed.details,
312
+ }])).toBe(false);
295
313
 
296
314
  const created = await tools.find((tool) => tool.name === CREATE_ASSISTANT_TOOL_NAME).execute('call_1', { name: 'FlowQA', model: 'opencode-go/deepseek-v4-flash' });
297
315
  expect(created.details.card).toMatchObject({
@@ -1,11 +1,51 @@
1
1
  export const assistantContractFixtures = Object.freeze({
2
- assistant: Object.freeze({ id: 'assistant_fixture', revision: 4, enabled: true, name: 'Fixture', defaultPrompt: '', workspacePath: null, managedWorkspacePath: '/data/assistant-workspaces/assistant_fixture', effectiveWorkspacePath: '/workspace', providerID: 'provider_fixture', modelID: 'model_fixture', agent: null, variant: null, mode: 'continuous', sessionID: 'ses_fixture', sessionGeneration: 4, historySessionIDs: Object.freeze([]), historySessionCount: 0, assignedSessionIDs: Object.freeze([]), working: false, activeContactTurn: null, createdAt: 1234, updatedAt: 1234, tombstoneAt: null }),
2
+ assistant: Object.freeze({
3
+ id: 'assistant_fixture',
4
+ revision: 4,
5
+ enabled: true,
6
+ name: 'Fixture',
7
+ defaultPrompt: '',
8
+ workspacePath: null,
9
+ managedWorkspacePath: '/data/assistant-workspaces/assistant_fixture',
10
+ effectiveWorkspacePath: '/workspace',
11
+ providerID: 'provider_fixture',
12
+ modelID: 'model_fixture',
13
+ agent: null,
14
+ variant: null,
15
+ mode: 'continuous',
16
+ sessionID: 'ses_fixture',
17
+ sessionGeneration: 4,
18
+ historySessionIDs: Object.freeze([]),
19
+ historySessionCount: 0,
20
+ assignedSessionIDs: Object.freeze([]),
21
+ working: false,
22
+ activeContactTurn: null,
23
+ /** Last visible contact bubble for list desc; null when transcript empty. */
24
+ latestMessagePreview: Object.freeze({
25
+ messageID: 'msg_contact_fixture',
26
+ ordinal: 2,
27
+ role: 'assistant',
28
+ text: 'Fixture reply',
29
+ fallbackKind: null,
30
+ }),
31
+ createdAt: 1234,
32
+ updatedAt: 1234,
33
+ tombstoneAt: null,
34
+ }),
3
35
  sessionBinding: Object.freeze({ sessionID: 'ses_fixture', directory: '/workspace', sessionGeneration: 4 }),
4
36
  compactResponse: Object.freeze({ binding: Object.freeze({ sessionID: 'ses_fixture', directory: '/workspace', sessionGeneration: 4 }), summarized: true }),
5
37
  messageAdmission: Object.freeze({ binding: Object.freeze({ sessionID: 'ses_fixture', directory: '/workspace', sessionGeneration: 4 }), messageID: 'msg_fixture', admitted: true, revision: 4 }),
6
38
  abortResponse: Object.freeze({ binding: Object.freeze({ sessionID: 'ses_fixture', directory: '/workspace', sessionGeneration: 4 }), aborted: true }),
7
39
  shareOperation: Object.freeze({ operationID: 'share_fixture', assistantID: 'assistant_fixture', sessionID: 'ses_fixture', messageID: 'msg_fixture', state: 'running', phase: 'submitted', attempt: 1, leaseExpiresAt: 1234, errorCode: null }),
8
40
  historicalMessages: Object.freeze({ entries: Object.freeze([Object.freeze({ sessionID: 'ses_fixture', directory: null, info: Object.freeze({ id: 'msg_fixture', sessionID: 'ses_fixture', role: 'assistant', time: Object.freeze({ created: 1234 }) }), parts: Object.freeze([]) })]), nextCursor: null, complete: true }),
41
+ /** Frozen GET /assistants/:id/contact/messages page envelope (UI contract). */
42
+ contactMessagesPage: Object.freeze({
43
+ messages: Object.freeze([]),
44
+ nextCursor: null,
45
+ complete: true,
46
+ generation: 0,
47
+ revision: 1,
48
+ }),
9
49
  assistantScheduledTasks: Object.freeze({
10
50
  tasks: Object.freeze([
11
51
  Object.freeze({