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

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 (147) hide show
  1. package/dist/assets/{AgentAvatar-CiQIIM0d.js → AgentAvatar-mbvX7YlW.js} +1 -1
  2. package/dist/assets/AssistantView-DOZPK9oh.js +2 -0
  3. package/dist/assets/DiagramView-W0IHFk-1.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-v7n_D6nL.js +2 -0
  7. package/dist/assets/MarkdownRendererImpl-Buz5LZsa.js +7 -0
  8. package/dist/assets/{MarkstreamRendererImpl-B-odd2Gz.js → MarkstreamRendererImpl-D4nUXYpP.js} +1 -1
  9. package/dist/assets/MobileDetailNavigation-Bfs4CTUh.js +1 -0
  10. package/dist/assets/MobileShareBridge-DtwVLkBL.js +4 -0
  11. package/dist/assets/MobileSurface-BmYYdY3g.js +2 -0
  12. package/dist/assets/{MultiRunWindow-BT5Dwgzu.js → MultiRunWindow-yWIHgr89.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-C3BbLpHT.js +25 -0
  16. package/dist/assets/{SettingsWindow-DfEGfyr8.js → SettingsWindow-b0JWdygM.js} +1 -1
  17. package/dist/assets/TerminalView-C74IvmOt.js +8 -0
  18. package/dist/assets/ToolOutputDialog-BORu8CtR.js +10 -0
  19. package/dist/assets/{appThemeRegistry-CIZ-shMD.js → appThemeRegistry-DOwhB93n.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-D1mvv14J.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-BBn8YW82.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-Cd7NwXx5.js +169 -0
  40. package/dist/assets/main-phpzFgMN.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-D6UYe50H.js +2 -0
  45. package/dist/assets/mobile-w2Ed9I5O.js +2 -0
  46. package/dist/assets/{multirun-CxhTsmRq.js → multirun-CYuxJPBX.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-Cir66uF3.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-BzYqqr9n.js +2 -0
  54. package/dist/assets/renderMobileApp-BvIE86_O.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-BXf9GQ0Q.js +2 -0
  59. package/dist/assets/runtimeEndpointReset-lF5S7U4I.js +147 -0
  60. package/dist/assets/{sessionChangeSummary-DDVT7O5J.js → sessionChangeSummary-DQDZ5HW-.js} +1 -1
  61. package/dist/assets/{sessionLookup-C8VZn0gJ.js → sessionLookup-CNRDupYq.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-NVyoI_Ei.js} +32 -32
  71. package/dist/assets/useAssistantUIStore-CLPCNoj_.js +1 -0
  72. package/dist/assets/useDesktopWindowControlsLayout-CP3NmMYG.js +1 -0
  73. package/dist/assets/{useEffectiveDirectory-DJSYYkVp.js → useEffectiveDirectory-AUoqSyOJ.js} +1 -1
  74. package/dist/assets/useFontPreferences-CB-cGo3I.js +1 -0
  75. package/dist/assets/useMobileNavigationStore-DANrWekj.js +413 -0
  76. package/dist/assets/useSessionAutoCleanup-ClvQQPqQ.js +2 -0
  77. package/dist/assets/useUIStore-CxOoCv7B.js +1 -0
  78. package/dist/assets/useWorkerHighlightedLines-DOk4cAxv.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/runtimeEndpointReset-BBLFgl85.js +0 -147
  138. package/dist/assets/sortable-tabs-strip-s7UUxsa6.js +0 -1
  139. package/dist/assets/src-BpHDMgud.css +0 -2
  140. package/dist/assets/terminalApi-C54_rKEK.js +0 -1
  141. package/dist/assets/useAssistantUIStore-Cg9bsT4b.js +0 -1
  142. package/dist/assets/useDesktopWindowControlsLayout-DoygEcN4.js +0 -1
  143. package/dist/assets/useFontPreferences-B5_apbBC.js +0 -1
  144. package/dist/assets/useMobileNavigationStore-DuTu0Mzh.js +0 -413
  145. package/dist/assets/useSessionAutoCleanup-C-L9teva.js +0 -1
  146. package/dist/assets/useUIStore-DwpQTjLO.js +0 -1
  147. package/dist/assets/useWorkerHighlightedLines-DxTfgdEi.js +0 -7
@@ -12,6 +12,21 @@ import {
12
12
 
13
13
  const require = createRequire(import.meta.url);
14
14
  const root = () => fs.mkdtempSync(path.join(os.tmpdir(), 'assistants-'));
15
+ /** Minimal valid 1×1 PNG (strict data-URL / image magic fixtures). */
16
+ const FIXTURE_PNG = Buffer.from([
17
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
18
+ 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
19
+ 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
20
+ 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
21
+ 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
22
+ 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
23
+ 0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xfe,
24
+ 0xd4, 0xef, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
25
+ 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
26
+ ]);
27
+ const FIXTURE_PNG_DATA_URL = `data:image/png;base64,${FIXTURE_PNG.toString('base64')}`;
28
+ /** Valid text/plain data URL body "x". */
29
+ const FIXTURE_TEXT_DATA_URL = 'data:text/plain;base64,eA==';
15
30
  // Behavioral tests enable the global switch after boot; pass enabled:false to assert the fresh-install default.
16
31
  const setup = (directory = root(), client = {}, options = {}) => {
17
32
  const { enabled = true, ...serviceOptions } = options;
@@ -129,6 +144,80 @@ describe('assistants service', () => {
129
144
  service.close();
130
145
  });
131
146
 
147
+ it('exposes latestMessagePreview on create/update/snapshot from contact transcript', async () => {
148
+ const directory = root();
149
+ const service = setup(directory, {}, {
150
+ runContactTurn: async ({ userText }) => ({ text: `ok ${userText}`, bubbles: [`ok ${userText}`] }),
151
+ });
152
+ const created = service.createAssistant(assistantInput);
153
+ expect(created.latestMessagePreview).toBeNull();
154
+ expect(service.snapshot().assistants[0].latestMessagePreview).toBeNull();
155
+
156
+ await settleSend(service, created.id, { messageID: 'prev_u1', parts: [{ type: 'text', text: 'hello preview' }] });
157
+ const afterUser = service.snapshot().assistants[0].latestMessagePreview;
158
+ // After turn settle the assistant reply is newest.
159
+ expect(afterUser).toMatchObject({
160
+ role: 'assistant',
161
+ text: 'ok hello preview',
162
+ fallbackKind: null,
163
+ });
164
+ expect(typeof afterUser.messageID).toBe('string');
165
+ expect(Number.isFinite(afterUser.ordinal)).toBe(true);
166
+
167
+ const updated = await service.updateAssistant(created.id, {
168
+ expectedRevision: service.snapshot().assistants[0].revision,
169
+ name: 'Renamed',
170
+ });
171
+ expect(updated.latestMessagePreview).toMatchObject({ text: 'ok hello preview', role: 'assistant' });
172
+
173
+ // Clear-memory keeps transcript → preview stays on last bubble.
174
+ const memServiceClose = service;
175
+ // use existing service with clear tool path
176
+ memServiceClose.close();
177
+ const service2 = setup(directory, {}, {
178
+ runContactTurn: async ({ userText, tools }) => {
179
+ if (userText === '开新对话') {
180
+ await tools.find((item) => item.name === 'new_conversation').execute('mem', {});
181
+ return { text: NEW_CONVERSATION_CONFIRM_BUBBLE, bubbles: [NEW_CONVERSATION_CONFIRM_BUBBLE], reset: true };
182
+ }
183
+ if (userText === '清空聊天记录') {
184
+ const tool = tools.find((item) => item.name === 'clear_chat_history');
185
+ const result = await tool.execute('wipe', {});
186
+ return {
187
+ text: result.content[0].text,
188
+ bubbles: [result.content[0].text],
189
+ reset: true,
190
+ historyCleared: true,
191
+ messages: [{ role: 'toolResult', toolName: 'clear_chat_history', details: result.details }],
192
+ };
193
+ }
194
+ return { text: `ok ${userText}`, bubbles: [`ok ${userText}`] };
195
+ },
196
+ });
197
+ const id = service2.snapshot().assistants[0].id;
198
+ expect(service2.snapshot().assistants[0].latestMessagePreview?.text).toBe('ok hello preview');
199
+ await settleSend(service2, id, { messageID: 'prev_mem', parts: [{ type: 'text', text: '开新对话' }] });
200
+ expect(service2.snapshot().assistants[0].latestMessagePreview?.text).toBe(NEW_CONVERSATION_CONFIRM_BUBBLE);
201
+ // Transcript still has older rows after clear-memory.
202
+ expect(service2.contactMessages(id, { limit: 50 }).messages.some((m) => m.text === 'hello preview')).toBe(true);
203
+
204
+ await settleSend(service2, id, { messageID: 'prev_wipe', parts: [{ type: 'text', text: '清空聊天记录' }] });
205
+ expect(service2.snapshot().assistants[0].latestMessagePreview).toMatchObject({
206
+ text: CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
207
+ role: 'assistant',
208
+ });
209
+ expect(service2.contactMessages(id, { limit: 50 }).messages.some((m) => m.text === 'hello preview')).toBe(false);
210
+
211
+ service2.close();
212
+ const cold = setup(directory, {}, {
213
+ runContactTurn: async ({ userText }) => ({ text: `ok ${userText}`, bubbles: [`ok ${userText}`] }),
214
+ });
215
+ expect(cold.snapshot().assistants[0].latestMessagePreview).toMatchObject({
216
+ text: CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
217
+ });
218
+ cold.close();
219
+ });
220
+
132
221
  it('new_conversation clears LLM memory while keeping the full transcript', async () => {
133
222
  const directory = root();
134
223
  let creates = 0;
@@ -197,8 +286,8 @@ describe('assistants service', () => {
197
286
  messageID: 'attach_1',
198
287
  parts: [
199
288
  { type: 'text', text: 'look at these' },
200
- { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'dot.png' },
201
- { type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==', filename: 'note.txt' },
289
+ { type: 'file', mime: 'image/png', url: FIXTURE_PNG_DATA_URL, filename: 'dot.png' },
290
+ { type: 'file', mime: 'text/plain', url: FIXTURE_TEXT_DATA_URL, filename: 'note.txt' },
202
291
  ],
203
292
  });
204
293
  await settleSend(service, assistant.id, { messageID: 'reset_leftover', parts: [{ type: 'text', text: '开新对话' }] });
@@ -250,11 +339,13 @@ describe('assistants service', () => {
250
339
  assistantID: assistant.id,
251
340
  reset: true,
252
341
  historyCleared: true,
342
+ generation: 1,
253
343
  });
254
344
  expect(creates).toBe(1);
255
345
  expect(service.contactMessages(assistant.id, { limit: 50 })).toMatchObject({
256
346
  messages: [],
257
347
  complete: true,
348
+ generation: 1,
258
349
  });
259
350
  await settleSend(service, assistant.id, { messageID: 'again_1', parts: [{ type: 'text', text: 'hello again' }] });
260
351
  await settleSend(service, assistant.id, { messageID: 'wipe_1', parts: [{ type: 'text', text: '清空聊天记录' }] });
@@ -277,22 +368,26 @@ describe('assistants service', () => {
277
368
  service.close();
278
369
  });
279
370
 
280
- it('rejects malicious clear_chat_history without explicit wipe intent and keeps transcript', async () => {
371
+ it('clear_chat_history executes when the model calls it without a user-text auth gate', async () => {
281
372
  const directory = root();
282
373
  const wipeAttempts = [];
283
374
  const service = setup(directory, {}, {
284
375
  runContactTurn: async ({ userText, tools }) => {
285
- if (userText === '清除记忆' || userText === '不要清除聊天记录') {
376
+ if (userText === '请处理一下') {
286
377
  const tool = tools.find((item) => item.name === 'clear_chat_history');
287
- const result = await tool.execute(`malicious_${userText}`, {});
378
+ const result = await tool.execute('model_chose_wipe', {});
288
379
  wipeAttempts.push({
289
- userText,
290
380
  error: result.details?.error || null,
381
+ denied: result.details?.denied === true,
291
382
  historyCleared: result.details?.historyCleared === true,
383
+ terminate: result.terminate,
384
+ text: result.content?.[0]?.text,
292
385
  });
293
386
  return {
294
- text: `blocked:${userText}`,
295
- bubbles: [`blocked:${userText}`],
387
+ text: result.content[0].text,
388
+ bubbles: [result.content[0].text],
389
+ reset: true,
390
+ historyCleared: true,
296
391
  messages: [{
297
392
  role: 'toolResult',
298
393
  toolName: 'clear_chat_history',
@@ -305,17 +400,187 @@ describe('assistants service', () => {
305
400
  });
306
401
  const assistant = service.createAssistant(assistantInput);
307
402
  await settleSend(service, assistant.id, { messageID: 'seed_keep', parts: [{ type: 'text', text: 'keep-me' }] });
308
- await settleSend(service, assistant.id, { messageID: 'bad_memory', parts: [{ type: 'text', text: '清除记忆' }] });
309
- await settleSend(service, assistant.id, { messageID: 'bad_negation', parts: [{ type: 'text', text: '不要清除聊天记录' }] });
310
- expect(wipeAttempts).toEqual([
311
- { userText: '清除记忆', error: 'validation_error', historyCleared: false },
312
- { userText: '不要清除聊天记录', error: 'validation_error', historyCleared: false },
313
- ]);
403
+ await settleSend(service, assistant.id, { messageID: 'model_wipe', parts: [{ type: 'text', text: '请处理一下' }] });
404
+ expect(wipeAttempts).toEqual([{
405
+ error: null,
406
+ denied: false,
407
+ historyCleared: true,
408
+ terminate: true,
409
+ text: CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
410
+ }]);
314
411
  const texts = service.contactMessages(assistant.id, { limit: 50 }).messages.map((message) => message.text);
315
- expect(texts).toContain('keep-me');
316
- expect(texts).toContain('清除记忆');
317
- expect(texts).toContain('不要清除聊天记录');
318
- expect(texts).not.toContain(CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE);
412
+ expect(texts).not.toContain('keep-me');
413
+ expect(texts).toEqual(['请处理一下', CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE]);
414
+ expect(texts.some((text) => /not cleared|denied|new_conversation instead/i.test(text))).toBe(false);
415
+ service.close();
416
+ });
417
+
418
+ it('tool wipe keeps later-admitted queued B and replays B only once', async () => {
419
+ const directory = root();
420
+ const turnRuns = [];
421
+ let aReachedTurn;
422
+ const aReachedGate = new Promise((resolve) => { aReachedTurn = resolve; });
423
+ let bAdmitted;
424
+ const bAdmittedGate = new Promise((resolve) => { bAdmitted = resolve; });
425
+ const service = setup(directory, {}, {
426
+ runContactTurn: async ({ userText, tools }) => {
427
+ turnRuns.push(userText);
428
+ if (userText === '清空聊天记录') {
429
+ aReachedTurn();
430
+ await bAdmittedGate;
431
+ const tool = tools.find((item) => item.name === 'clear_chat_history');
432
+ const result = await tool.execute('wipe_queue_a', {});
433
+ return {
434
+ text: result.content[0].text,
435
+ bubbles: [result.content[0].text],
436
+ reset: true,
437
+ historyCleared: true,
438
+ messages: [{
439
+ role: 'toolResult',
440
+ toolName: 'clear_chat_history',
441
+ details: result.details,
442
+ }],
443
+ };
444
+ }
445
+ return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
446
+ },
447
+ });
448
+ const assistant = service.createAssistant(assistantInput);
449
+ await settleSend(service, assistant.id, { messageID: 'seed_queue', parts: [{ type: 'text', text: 'seed before wipe' }] });
450
+ // A pause -> B admission -> A wipe -> B complete -> replay B once.
451
+ const aSend = service.send(assistant.id, { messageID: 'wipe_a', parts: [{ type: 'text', text: '清空聊天记录' }] });
452
+ await aReachedGate;
453
+ const bSend = service.send(assistant.id, { messageID: 'queued_b', parts: [{ type: 'text', text: 'queued B keep me' }] });
454
+ await bSend;
455
+ bAdmitted();
456
+ await aSend;
457
+ await service.whenContactTurnSettled('wipe_a');
458
+ await service.whenContactTurnSettled('queued_b');
459
+ // Same messageID + payload replays admission only — no second lane run.
460
+ const replay = await service.send(assistant.id, { messageID: 'queued_b', parts: [{ type: 'text', text: 'queued B keep me' }] });
461
+ expect(replay).toMatchObject({ admitted: true, replayed: true, messageID: 'queued_b' });
462
+ const page = service.contactMessages(assistant.id, { limit: 50 });
463
+ const texts = page.messages.map((message) => message.text);
464
+ expect(texts).not.toContain('seed before wipe');
465
+ expect(texts).toContain('queued B keep me');
466
+ expect(texts).toContain('reply:queued B keep me');
467
+ expect(texts).toContain(CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE);
468
+ // Wipe user kept in place (no re-admit reorder before B).
469
+ const wipeIdx = page.messages.findIndex((message) => message.messageID === 'wipe_a');
470
+ const bIdx = page.messages.findIndex((message) => message.messageID === 'queued_b');
471
+ expect(wipeIdx).toBeGreaterThanOrEqual(0);
472
+ expect(bIdx).toBeGreaterThan(wipeIdx);
473
+ expect(turnRuns.filter((text) => text === 'queued B keep me')).toHaveLength(1);
474
+ expect(page.messages.filter((message) => message.messageID === 'queued_b')).toHaveLength(1);
475
+ service.close();
476
+ });
477
+
478
+ it('tool wipe drops late prior-lane assistant/cards after reverse admit order and keeps C once', async () => {
479
+ const directory = root();
480
+ const turnRuns = [];
481
+ const cHistories = [];
482
+ let aReachedTurn;
483
+ const aReachedGate = new Promise((resolve) => { aReachedTurn = resolve; });
484
+ let lateWritten;
485
+ const lateWrittenGate = new Promise((resolve) => { lateWritten = resolve; });
486
+ let wipeMayRun;
487
+ const wipeMayRunGate = new Promise((resolve) => { wipeMayRun = resolve; });
488
+ const service = setup(directory, {}, {
489
+ runContactTurn: async ({ history, userText, tools }) => {
490
+ turnRuns.push(userText);
491
+ if (userText === 'ordinary A still running') {
492
+ aReachedTurn();
493
+ await lateWrittenGate;
494
+ // A completes after wipe already ran — its returned bubbles must not
495
+ // resurrect deleted late residue (wipe already cleared transcript).
496
+ return { text: 'late-A-final-bubble-should-not-matter', bubbles: ['late-A-final-bubble-should-not-matter'] };
497
+ }
498
+ if (userText === '清空聊天记录') {
499
+ await wipeMayRunGate;
500
+ const tool = tools.find((item) => item.name === 'clear_chat_history');
501
+ const result = await tool.execute('wipe_reverse', {});
502
+ return {
503
+ text: result.content[0].text,
504
+ bubbles: [result.content[0].text],
505
+ reset: true,
506
+ historyCleared: true,
507
+ messages: [{
508
+ role: 'toolResult',
509
+ toolName: 'clear_chat_history',
510
+ details: result.details,
511
+ }],
512
+ };
513
+ }
514
+ if (userText === 'queued C keep me') {
515
+ cHistories.push(history.map((item) => item.content));
516
+ }
517
+ return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
518
+ },
519
+ });
520
+ const assistant = service.createAssistant(assistantInput);
521
+ await settleSend(service, assistant.id, { messageID: 'seed_rev', parts: [{ type: 'text', text: 'seed secret reverse' }] });
522
+ // A pause -> wipe B admit -> C admit -> late A reply/card/watch -> B wipe -> C.
523
+ const aSend = service.send(assistant.id, { messageID: 'ord_a', parts: [{ type: 'text', text: 'ordinary A still running' }] });
524
+ await aReachedGate;
525
+ const bSend = service.send(assistant.id, { messageID: 'wipe_b', parts: [{ type: 'text', text: '清空聊天记录' }] });
526
+ await bSend;
527
+ const cSend = service.send(assistant.id, { messageID: 'queued_c', parts: [{ type: 'text', text: 'queued C keep me' }] });
528
+ await cSend;
529
+ // Late residue from A while still paused (ordinal after wipe+C users).
530
+ service.appendContactCard(assistant.id, {
531
+ cardType: 'session',
532
+ sessionID: 'ses_late_a',
533
+ directory: directory,
534
+ title: 'late A card residue',
535
+ status: 'busy',
536
+ messageID: 'late_a_card',
537
+ });
538
+ const Database = require('better-sqlite3');
539
+ const db = new Database(path.join(directory, 'assistants.sqlite'));
540
+ const ordinal = Number(db.prepare(
541
+ 'SELECT COALESCE(MAX(ordinal), 0) + 1 AS next FROM assistant_contact_message WHERE assistant_id=?',
542
+ ).get(assistant.id).next);
543
+ db.prepare(
544
+ 'INSERT INTO assistant_contact_message(message_id,assistant_id,role,turn_id,bubble_index,created_at,ordinal,status) VALUES (?,?,?,?,?,?,?,?)',
545
+ ).run('late_a_reply', assistant.id, 'assistant', 'ord_a', 0, Date.now(), ordinal, 'complete');
546
+ db.prepare(
547
+ 'INSERT INTO assistant_contact_part(message_id,part_id,ordinal,part_json) VALUES (?,?,?,?)',
548
+ ).run('late_a_reply', 'p1', 1, JSON.stringify({ type: 'text', text: 'late-A-reply-residue' }));
549
+ db.close();
550
+ lateWritten();
551
+ wipeMayRun();
552
+ await aSend;
553
+ await service.whenContactTurnSettled('ord_a');
554
+ await service.whenContactTurnSettled('wipe_b');
555
+ await service.whenContactTurnSettled('queued_c');
556
+ const replay = await service.send(assistant.id, { messageID: 'queued_c', parts: [{ type: 'text', text: 'queued C keep me' }] });
557
+ expect(replay).toMatchObject({ admitted: true, replayed: true, messageID: 'queued_c' });
558
+ const page = service.contactMessages(assistant.id, { limit: 50 });
559
+ const texts = page.messages.map((message) => message.text);
560
+ expect(texts).not.toContain('seed secret reverse');
561
+ expect(texts).not.toContain('late-A-reply-residue');
562
+ expect(texts).not.toContain('late-A-final-bubble-should-not-matter');
563
+ expect(page.messages.some((message) => (message.cards || []).some((card) => card.sessionID === 'ses_late_a'))).toBe(false);
564
+ expect(texts).toContain('queued C keep me');
565
+ expect(texts).toContain('reply:queued C keep me');
566
+ expect(texts).toContain(CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE);
567
+ // Wipe user kept before C (no re-admit reorder).
568
+ const wipeIdx = page.messages.findIndex((message) => message.messageID === 'wipe_b');
569
+ const cIdx = page.messages.findIndex((message) => message.messageID === 'queued_c');
570
+ expect(wipeIdx).toBeGreaterThanOrEqual(0);
571
+ expect(cIdx).toBeGreaterThan(wipeIdx);
572
+ expect(turnRuns.filter((text) => text === 'queued C keep me')).toHaveLength(1);
573
+ expect(page.messages.filter((message) => message.messageID === 'queued_c')).toHaveLength(1);
574
+ // C LLM history must not see late A residue or pre-wipe seed.
575
+ const cHist = cHistories[0] || [];
576
+ expect(cHist.some((content) => String(content).includes('late-A'))).toBe(false);
577
+ expect(cHist.some((content) => String(content).includes('seed secret reverse'))).toBe(false);
578
+ const dbAfter = new Database(path.join(directory, 'assistants.sqlite'));
579
+ const watches = dbAfter.prepare(
580
+ 'SELECT session_id FROM assistant_contact_watch WHERE assistant_id=?',
581
+ ).all(assistant.id);
582
+ expect(watches).toEqual([]);
583
+ dbAfter.close();
319
584
  service.close();
320
585
  });
321
586
 
@@ -485,8 +750,8 @@ describe('assistants service', () => {
485
750
  it('persists mixed text+image+file parts and forwards them to the contact harness', async () => {
486
751
  const directory = root();
487
752
  let harness;
488
- const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' };
489
- const file = { type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==', filename: 'notes.txt' };
753
+ const image = { type: 'file', mime: 'image/png', url: FIXTURE_PNG_DATA_URL, filename: 'shot.png' };
754
+ const file = { type: 'file', mime: 'text/plain', url: FIXTURE_TEXT_DATA_URL, filename: 'notes.txt' };
490
755
  const service = setup(directory, {}, {
491
756
  runContactTurn: async (input) => {
492
757
  harness = input;
@@ -499,11 +764,17 @@ describe('assistants service', () => {
499
764
  parts: [{ type: 'text', text: 'look' }, image, file],
500
765
  });
501
766
  expect(harness.userText).toBe('look');
502
- expect(harness.userParts).toEqual([{ type: 'text', text: 'look' }, image, file]);
767
+ // Harness sees materialized data URLs; DB stores attachment descriptors (no raw url).
768
+ expect(harness.userParts[0]).toEqual({ type: 'text', text: 'look' });
769
+ expect(harness.userParts[1]).toMatchObject({ type: 'file', mime: 'image/png', filename: 'shot.png', url: expect.stringMatching(/^data:image\/png;base64,/) });
770
+ expect(harness.userParts[2]).toMatchObject({ type: 'file', mime: 'text/plain', filename: 'notes.txt', url: expect.stringMatching(/^data:text\/plain;base64,/) });
503
771
  expect(service.contactMessages(assistant.id, { limit: 50 }).messages[0]).toMatchObject({
504
772
  role: 'user',
505
- text: 'look',
506
- parts: [{ type: 'text', text: 'look' }, image, file],
773
+ parts: [
774
+ { type: 'text', text: 'look' },
775
+ { type: 'file', mime: 'image/png', filename: 'shot.png', attachmentID: expect.stringMatching(/^att_/) },
776
+ { type: 'file', mime: 'text/plain', filename: 'notes.txt', attachmentID: expect.stringMatching(/^att_/) },
777
+ ],
507
778
  });
508
779
  service.close();
509
780
  const restarted = setup(directory, {}, {
@@ -511,14 +782,14 @@ describe('assistants service', () => {
511
782
  });
512
783
  expect(restarted.contactMessages(assistant.id, { limit: 50 }).messages[0].parts).toEqual([
513
784
  { type: 'text', text: 'look' },
514
- image,
515
- file,
785
+ expect.objectContaining({ type: 'file', mime: 'image/png', filename: 'shot.png', attachmentID: expect.stringMatching(/^att_/) }),
786
+ expect.objectContaining({ type: 'file', mime: 'text/plain', filename: 'notes.txt', attachmentID: expect.stringMatching(/^att_/) }),
516
787
  ]);
517
788
  restarted.close();
518
789
  });
519
790
 
520
791
  it('admits a file-only contact send and stores the file part without a fake text row', async () => {
521
- const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' };
792
+ const image = { type: 'file', mime: 'image/png', url: FIXTURE_PNG_DATA_URL, filename: 'shot.png' };
522
793
  let harness;
523
794
  const service = setup(root(), {}, {
524
795
  runContactTurn: async (input) => {
@@ -529,9 +800,14 @@ describe('assistants service', () => {
529
800
  const assistant = service.createAssistant(assistantInput);
530
801
  await settleSend(service, assistant.id, { messageID: 'file_only_1', parts: [image] });
531
802
  expect(harness.userText).toBe('[attachment]');
532
- expect(harness.userParts).toEqual([image]);
803
+ expect(harness.userParts).toEqual([
804
+ expect.objectContaining({ type: 'file', mime: 'image/png', filename: 'shot.png', url: expect.stringMatching(/^data:image\/png;base64,/) }),
805
+ ]);
533
806
  const user = service.contactMessages(assistant.id).messages.find((message) => message.role === 'user');
534
- expect(user.parts).toEqual([image]);
807
+ expect(user.parts).toEqual([
808
+ expect.objectContaining({ type: 'file', mime: 'image/png', filename: 'shot.png', attachmentID: expect.stringMatching(/^att_/) }),
809
+ ]);
810
+ expect(user.parts[0].url).toBeUndefined();
535
811
  expect(user.text).toBe('');
536
812
  service.close();
537
813
  });
@@ -1318,7 +1594,7 @@ describe('assistants service', () => {
1318
1594
  const directory = root();
1319
1595
  const project = path.join(directory, 'app');
1320
1596
  fs.mkdirSync(project, { recursive: true });
1321
- const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'card.png' };
1597
+ const image = { type: 'file', mime: 'image/png', url: FIXTURE_PNG_DATA_URL, filename: 'card.png' };
1322
1598
  const creates = [];
1323
1599
  const prompts = [];
1324
1600
  const deletes = [];
@@ -1396,7 +1672,12 @@ describe('assistants service', () => {
1396
1672
  model: { providerID: 'xai', modelID: 'grok-4.6' },
1397
1673
  parts: [
1398
1674
  { type: 'text', text: '修移动端卡片宽度' },
1399
- image,
1675
+ expect.objectContaining({
1676
+ type: 'file',
1677
+ mime: 'image/png',
1678
+ filename: 'card.png',
1679
+ url: expect.stringMatching(/^data:image\/png;base64,/),
1680
+ }),
1400
1681
  ],
1401
1682
  })]);
1402
1683
  expect(deletes).toEqual([]);
@@ -2618,4 +2899,93 @@ describe('assistants service', () => {
2618
2899
  db.close();
2619
2900
  service.close();
2620
2901
  });
2902
+
2903
+ it('pages contact messages 20/20/5 with generation+revision and exact messageID admission', async () => {
2904
+ const directory = root();
2905
+ const service = setup(directory, {}, {
2906
+ runContactTurn: async ({ userText }) => ({ text: `r:${userText}`, bubbles: [`r:${userText}`] }),
2907
+ });
2908
+ const assistant = service.createAssistant(assistantInput);
2909
+ const other = service.createAssistant({ ...assistantInput, name: 'B' });
2910
+ // 45 user admits → 45 user + 45 assistant = 90 rows; page three windows of 20/20/5 on users alone
2911
+ // Seed via direct store through many settled turns would be slow; insert via contactMessages path
2912
+ // by settling 22 turns (44 rows) then more via appendContactCard for density.
2913
+ for (let i = 0; i < 22; i += 1) {
2914
+ await settleSend(service, assistant.id, {
2915
+ messageID: `page_u_${i}`,
2916
+ parts: [{ type: 'text', text: `u-${i}` }],
2917
+ });
2918
+ }
2919
+ // 22 users + 22 assistants = 44; add one more card-only for 45th
2920
+ service.appendContactCard(assistant.id, {
2921
+ cardType: 'session',
2922
+ sessionID: 'ses_page_extra',
2923
+ directory,
2924
+ title: 'extra',
2925
+ status: 'complete',
2926
+ messageID: 'page_card_45',
2927
+ });
2928
+
2929
+ // Newest window first (chat tail), ascending within the page.
2930
+ const defaultPage = service.contactMessages(assistant.id);
2931
+ expect(defaultPage.messages).toHaveLength(20);
2932
+ expect(defaultPage.complete).toBe(false);
2933
+ expect(defaultPage.generation).toBe(0);
2934
+ expect(defaultPage.revision).toEqual(expect.any(Number));
2935
+ expect(defaultPage.messages.at(-1).messageID).toBe('page_card_45');
2936
+
2937
+ const second = service.contactMessages(assistant.id, { before: defaultPage.nextCursor, limit: 20 });
2938
+ expect(second.messages).toHaveLength(20);
2939
+ expect(second.complete).toBe(false);
2940
+
2941
+ const third = service.contactMessages(assistant.id, { before: second.nextCursor, limit: 20 });
2942
+ expect(third.messages.length).toBeGreaterThanOrEqual(5);
2943
+ expect(third.complete).toBe(true);
2944
+ expect(third.nextCursor).toBeNull();
2945
+ expect(third.messages[0].text).toBe('u-0');
2946
+
2947
+ // Concurrent append does not break a prior cursor keyset for older pages.
2948
+ await settleSend(service, assistant.id, {
2949
+ messageID: 'page_concurrent',
2950
+ parts: [{ type: 'text', text: 'concurrent-append' }],
2951
+ });
2952
+ const stillSecond = service.contactMessages(assistant.id, { before: defaultPage.nextCursor, limit: 20 });
2953
+ expect(stillSecond.messages[0].messageID).toBe(second.messages[0].messageID);
2954
+
2955
+ // Exact admission: deep/old message still found; missing / cross-assistant empty.
2956
+ const deepID = third.messages[0].messageID;
2957
+ const exact = service.contactMessages(assistant.id, { messageID: deepID });
2958
+ expect(exact).toMatchObject({ nextCursor: null, complete: true, generation: 0 });
2959
+ expect(exact.messages).toHaveLength(1);
2960
+ expect(exact.messages[0].messageID).toBe(deepID);
2961
+ expect(service.contactMessages(assistant.id, { messageID: 'nope' }).messages).toEqual([]);
2962
+ expect(service.contactMessages(other.id, { messageID: deepID }).messages).toEqual([]);
2963
+
2964
+ // clear-memory keeps generation + cursor validity
2965
+ const beforeGen = service.contactMessages(assistant.id).generation;
2966
+ service.clearContactMemory(assistant.id);
2967
+ expect(service.contactMessages(assistant.id, { before: defaultPage.nextCursor, limit: 5 }).generation).toBe(beforeGen);
2968
+
2969
+ // reset bumps generation → old cursor conflicts
2970
+ const wiped = service.resetContact(assistant.id);
2971
+ expect(wiped.generation).toBe(1);
2972
+ expect(() => service.contactMessages(assistant.id, { before: defaultPage.nextCursor })).toThrowError(
2973
+ expect.objectContaining({ code: 'contact_generation_conflict' }),
2974
+ );
2975
+ expect(service.contactMessages(assistant.id)).toMatchObject({
2976
+ messages: [],
2977
+ complete: true,
2978
+ generation: 1,
2979
+ });
2980
+
2981
+ // invalid cursor
2982
+ expect(() => service.contactMessages(assistant.id, { before: '%%%' })).toThrowError(
2983
+ expect.objectContaining({ code: 'validation_error' }),
2984
+ );
2985
+ expect(() => service.contactMessages(assistant.id, { before: 'x', messageID: 'y' })).toThrowError(
2986
+ expect.objectContaining({ code: 'validation_error' }),
2987
+ );
2988
+
2989
+ service.close();
2990
+ });
2621
2991
  });