@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
@@ -12,10 +12,53 @@ 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;
18
- const service = createAssistantsService({ dbPath: path.join(directory, 'assistants.sqlite'), dataDir: directory, getAllowedRoots: () => [directory], buildOpenCodeUrl: () => 'http://127.0.0.1:1', getOpenCodeAuthHeaders: () => ({}), clientFactory: () => ({ session: { create: async () => ({ data: { id: crypto.randomUUID() } }), get: async () => ({ data: { id: 'present' } }), update: async () => ({ data: { id: 'archived' } }), promptAsync: async () => ({ data: { info: { id: 'msg_1' } } }), summarize: async () => ({ data: true }), ...client } }), runContactTurn: serviceOptions.runContactTurn ?? (async ({ userText }) => ({ text: `reply:${userText}`, bubbles: [`reply:${userText}`] })), ...serviceOptions });
33
+ const {
34
+ provider = undefined,
35
+ config = undefined,
36
+ session: sessionOverrides = {},
37
+ ...sessionClient
38
+ } = client && typeof client === 'object' ? client : {};
39
+ const service = createAssistantsService({
40
+ dbPath: path.join(directory, 'assistants.sqlite'),
41
+ dataDir: directory,
42
+ getAllowedRoots: () => [directory],
43
+ buildOpenCodeUrl: () => 'http://127.0.0.1:1',
44
+ getOpenCodeAuthHeaders: () => ({}),
45
+ clientFactory: () => ({
46
+ session: {
47
+ create: async () => ({ data: { id: crypto.randomUUID() } }),
48
+ get: async () => ({ data: { id: 'present' } }),
49
+ update: async () => ({ data: { id: 'archived' } }),
50
+ promptAsync: async () => ({ data: { info: { id: 'msg_1' } } }),
51
+ summarize: async () => ({ data: true }),
52
+ delete: async () => ({ data: true }),
53
+ ...sessionClient,
54
+ ...sessionOverrides,
55
+ },
56
+ ...(provider ? { provider } : {}),
57
+ ...(config ? { config } : {}),
58
+ }),
59
+ runContactTurn: serviceOptions.runContactTurn ?? (async ({ userText }) => ({ text: `reply:${userText}`, bubbles: [`reply:${userText}`] })),
60
+ ...serviceOptions,
61
+ });
19
62
  if (enabled) {
20
63
  const snapshot = service.snapshot();
21
64
  if (!snapshot.enabled) service.setEnabled({ enabled: true, expectedRevision: snapshot.revision });
@@ -83,7 +126,7 @@ describe('assistants service', () => {
83
126
  const assistant = service.createAssistant(assistantInput);
84
127
  const current = await service.ensure(assistant.id);
85
128
  const sent = await service.send(assistant.id, { ...current, messageID: 'client_1', parts: [{ type: 'text', text: 'hello' }] });
86
- expect(sent).toEqual({ binding: current, messageID: 'client_1', admitted: true });
129
+ expect(sent).toEqual({ binding: current, messageID: 'client_1', admitted: true, revision: expect.any(Number) });
87
130
  // Admission resolves before the async turn persists assistant bubbles.
88
131
  expect(service.contactMessages(assistant.id, { limit: 50 }).messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([
89
132
  { role: 'user', text: 'hello' },
@@ -101,6 +144,80 @@ describe('assistants service', () => {
101
144
  service.close();
102
145
  });
103
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
+
104
221
  it('new_conversation clears LLM memory while keeping the full transcript', async () => {
105
222
  const directory = root();
106
223
  let creates = 0;
@@ -169,8 +286,8 @@ describe('assistants service', () => {
169
286
  messageID: 'attach_1',
170
287
  parts: [
171
288
  { type: 'text', text: 'look at these' },
172
- { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'dot.png' },
173
- { 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' },
174
291
  ],
175
292
  });
176
293
  await settleSend(service, assistant.id, { messageID: 'reset_leftover', parts: [{ type: 'text', text: '开新对话' }] });
@@ -222,11 +339,13 @@ describe('assistants service', () => {
222
339
  assistantID: assistant.id,
223
340
  reset: true,
224
341
  historyCleared: true,
342
+ generation: 1,
225
343
  });
226
344
  expect(creates).toBe(1);
227
345
  expect(service.contactMessages(assistant.id, { limit: 50 })).toMatchObject({
228
346
  messages: [],
229
347
  complete: true,
348
+ generation: 1,
230
349
  });
231
350
  await settleSend(service, assistant.id, { messageID: 'again_1', parts: [{ type: 'text', text: 'hello again' }] });
232
351
  await settleSend(service, assistant.id, { messageID: 'wipe_1', parts: [{ type: 'text', text: '清空聊天记录' }] });
@@ -249,22 +368,26 @@ describe('assistants service', () => {
249
368
  service.close();
250
369
  });
251
370
 
252
- 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 () => {
253
372
  const directory = root();
254
373
  const wipeAttempts = [];
255
374
  const service = setup(directory, {}, {
256
375
  runContactTurn: async ({ userText, tools }) => {
257
- if (userText === '清除记忆' || userText === '不要清除聊天记录') {
376
+ if (userText === '请处理一下') {
258
377
  const tool = tools.find((item) => item.name === 'clear_chat_history');
259
- const result = await tool.execute(`malicious_${userText}`, {});
378
+ const result = await tool.execute('model_chose_wipe', {});
260
379
  wipeAttempts.push({
261
- userText,
262
380
  error: result.details?.error || null,
381
+ denied: result.details?.denied === true,
263
382
  historyCleared: result.details?.historyCleared === true,
383
+ terminate: result.terminate,
384
+ text: result.content?.[0]?.text,
264
385
  });
265
386
  return {
266
- text: `blocked:${userText}`,
267
- bubbles: [`blocked:${userText}`],
387
+ text: result.content[0].text,
388
+ bubbles: [result.content[0].text],
389
+ reset: true,
390
+ historyCleared: true,
268
391
  messages: [{
269
392
  role: 'toolResult',
270
393
  toolName: 'clear_chat_history',
@@ -277,17 +400,187 @@ describe('assistants service', () => {
277
400
  });
278
401
  const assistant = service.createAssistant(assistantInput);
279
402
  await settleSend(service, assistant.id, { messageID: 'seed_keep', parts: [{ type: 'text', text: 'keep-me' }] });
280
- await settleSend(service, assistant.id, { messageID: 'bad_memory', parts: [{ type: 'text', text: '清除记忆' }] });
281
- await settleSend(service, assistant.id, { messageID: 'bad_negation', parts: [{ type: 'text', text: '不要清除聊天记录' }] });
282
- expect(wipeAttempts).toEqual([
283
- { userText: '清除记忆', error: 'validation_error', historyCleared: false },
284
- { userText: '不要清除聊天记录', error: 'validation_error', historyCleared: false },
285
- ]);
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
+ }]);
286
411
  const texts = service.contactMessages(assistant.id, { limit: 50 }).messages.map((message) => message.text);
287
- expect(texts).toContain('keep-me');
288
- expect(texts).toContain('清除记忆');
289
- expect(texts).toContain('不要清除聊天记录');
290
- 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();
291
584
  service.close();
292
585
  });
293
586
 
@@ -457,8 +750,8 @@ describe('assistants service', () => {
457
750
  it('persists mixed text+image+file parts and forwards them to the contact harness', async () => {
458
751
  const directory = root();
459
752
  let harness;
460
- const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' };
461
- 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' };
462
755
  const service = setup(directory, {}, {
463
756
  runContactTurn: async (input) => {
464
757
  harness = input;
@@ -471,11 +764,17 @@ describe('assistants service', () => {
471
764
  parts: [{ type: 'text', text: 'look' }, image, file],
472
765
  });
473
766
  expect(harness.userText).toBe('look');
474
- 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,/) });
475
771
  expect(service.contactMessages(assistant.id, { limit: 50 }).messages[0]).toMatchObject({
476
772
  role: 'user',
477
- text: 'look',
478
- 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
+ ],
479
778
  });
480
779
  service.close();
481
780
  const restarted = setup(directory, {}, {
@@ -483,14 +782,14 @@ describe('assistants service', () => {
483
782
  });
484
783
  expect(restarted.contactMessages(assistant.id, { limit: 50 }).messages[0].parts).toEqual([
485
784
  { type: 'text', text: 'look' },
486
- image,
487
- 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_/) }),
488
787
  ]);
489
788
  restarted.close();
490
789
  });
491
790
 
492
791
  it('admits a file-only contact send and stores the file part without a fake text row', async () => {
493
- 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' };
494
793
  let harness;
495
794
  const service = setup(root(), {}, {
496
795
  runContactTurn: async (input) => {
@@ -501,9 +800,14 @@ describe('assistants service', () => {
501
800
  const assistant = service.createAssistant(assistantInput);
502
801
  await settleSend(service, assistant.id, { messageID: 'file_only_1', parts: [image] });
503
802
  expect(harness.userText).toBe('[attachment]');
504
- 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
+ ]);
505
806
  const user = service.contactMessages(assistant.id).messages.find((message) => message.role === 'user');
506
- 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();
507
811
  expect(user.text).toBe('');
508
812
  service.close();
509
813
  });
@@ -512,7 +816,7 @@ describe('assistants service', () => {
512
816
  const service = setup(root(), { promptAsync: async () => ({ response: { status: 204 } }) }); const assistant = service.createAssistant(assistantInput); const current = await service.ensure(assistant.id);
513
817
  expect(await service.compact(assistant.id, current)).toEqual({ binding: current, summarized: true });
514
818
  expect(await settleSend(service, assistant.id, { ...current, messageID: 'client_204', parts: [{ type: 'text', text: 'hello' }] })).toMatchObject({ binding: current, messageID: 'client_204', admitted: true });
515
- expect(Object.keys(assistantContractFixtures.assistant)).toContain('managedWorkspacePath'); expect(Object.keys(assistantContractFixtures.assistant)).not.toContain('skillRoots'); expect(Object.keys(assistantContractFixtures.compactResponse).sort()).toEqual(['binding', 'summarized']); expect(Object.keys(assistantContractFixtures.messageAdmission).sort()).toEqual(['admitted', 'binding', 'messageID']); service.close();
819
+ expect(Object.keys(assistantContractFixtures.assistant)).toContain('managedWorkspacePath'); expect(Object.keys(assistantContractFixtures.assistant)).not.toContain('skillRoots'); expect(Object.keys(assistantContractFixtures.compactResponse).sort()).toEqual(['binding', 'summarized']); expect(Object.keys(assistantContractFixtures.messageAdmission).sort()).toEqual(['admitted', 'binding', 'messageID', 'revision']); service.close();
516
820
  });
517
821
 
518
822
  it('admits 33-part direct messages and 129-part shares', async () => {
@@ -1203,6 +1507,314 @@ describe('assistants service', () => {
1203
1507
  service.close();
1204
1508
  });
1205
1509
 
1510
+ it.each([
1511
+ { error: { status: 404 } },
1512
+ { data: { info: { id: 'different_message' }, parts: [] } },
1513
+ { data: { parts: [] } },
1514
+ ])('preserves ambiguous admission until lookup identifies the exact message: %j', async (lookupResult) => {
1515
+ const directory = root();
1516
+ const project = path.join(directory, 'app');
1517
+ fs.mkdirSync(project, { recursive: true });
1518
+ const creates = [];
1519
+ const prompts = [];
1520
+ const deletes = [];
1521
+ const lookups = [];
1522
+ let promptCalls = 0;
1523
+ const service = setup(directory, {
1524
+ create: async (input) => {
1525
+ creates.push(input);
1526
+ return { data: { id: 'ses_lookup' } };
1527
+ },
1528
+ promptAsync: async (input) => {
1529
+ prompts.push(input);
1530
+ promptCalls += 1;
1531
+ if (promptCalls === 1) {
1532
+ const error = new TypeError('fetch failed');
1533
+ error.code = 'ECONNRESET';
1534
+ throw error;
1535
+ }
1536
+ return { response: { status: 204 } };
1537
+ },
1538
+ delete: async (input) => {
1539
+ deletes.push(input);
1540
+ return { data: true };
1541
+ },
1542
+ message: async (input) => {
1543
+ lookups.push(input);
1544
+ // Each response leaves the requested message unconfirmed.
1545
+ return lookupResult;
1546
+ },
1547
+ }, {
1548
+ runContactTurn: async ({ tools }) => {
1549
+ const assign = tools.find((tool) => tool.name === 'assign_session');
1550
+ const first = await assign.execute('call_1', {
1551
+ prompt: 'Fix',
1552
+ projectPath: project,
1553
+ variant: 'fast',
1554
+ });
1555
+ expect(first.details).toMatchObject({
1556
+ error: 'prompt_ambiguous',
1557
+ ambiguous: true,
1558
+ sessionID: 'ses_lookup',
1559
+ });
1560
+ expect(typeof first.details.messageID).toBe('string');
1561
+ expect(deletes).toEqual([]);
1562
+ const second = await assign.execute('call_2', {
1563
+ prompt: 'Fix',
1564
+ projectPath: project,
1565
+ variant: 'fast',
1566
+ });
1567
+ expect(second.details.assigned.sessionID).toBe('ses_lookup');
1568
+ return {
1569
+ text: second.content[0].text,
1570
+ bubbles: [second.content[0].text],
1571
+ cards: second.details.card ? [second.details.card] : [],
1572
+ };
1573
+ },
1574
+ });
1575
+ const assistant = service.createAssistant({ ...assistantInput, variant: 'fast' });
1576
+ await settleSend(service, assistant.id, {
1577
+ messageID: 'client_assign_lookup',
1578
+ parts: [{ type: 'text', text: 'Fix' }],
1579
+ });
1580
+ expect(creates).toHaveLength(1);
1581
+ expect(prompts[0]).toMatchObject({
1582
+ sessionID: 'ses_lookup',
1583
+ variant: 'fast',
1584
+ });
1585
+ // Retry reuses the same worker session (no second create).
1586
+ expect(prompts.length).toBeGreaterThanOrEqual(2);
1587
+ expect(prompts.every((item) => item.sessionID === 'ses_lookup')).toBe(true);
1588
+ expect(lookups.length).toBeGreaterThanOrEqual(1);
1589
+ expect(deletes).toEqual([]);
1590
+ service.close();
1591
+ });
1592
+
1593
+ it('assign forwards current-turn images and explicit worker model into promptAsync without changing the contact', async () => {
1594
+ const directory = root();
1595
+ const project = path.join(directory, 'app');
1596
+ fs.mkdirSync(project, { recursive: true });
1597
+ const image = { type: 'file', mime: 'image/png', url: FIXTURE_PNG_DATA_URL, filename: 'card.png' };
1598
+ const creates = [];
1599
+ const prompts = [];
1600
+ const deletes = [];
1601
+ const service = setup(directory, {
1602
+ create: async (input) => {
1603
+ creates.push(input);
1604
+ return { data: { id: `ses_${creates.length}` } };
1605
+ },
1606
+ promptAsync: async (input) => {
1607
+ prompts.push(input);
1608
+ return { response: { status: 204 } };
1609
+ },
1610
+ delete: async (input) => {
1611
+ deletes.push(input);
1612
+ return { data: true };
1613
+ },
1614
+ provider: {
1615
+ list: async () => ({ data: { connected: ['xai', 'opencode-go'] } }),
1616
+ },
1617
+ config: {
1618
+ providers: async () => ({
1619
+ data: {
1620
+ providers: [
1621
+ {
1622
+ id: 'xai',
1623
+ name: 'xAI',
1624
+ models: {
1625
+ 'grok-4.6': {
1626
+ id: 'grok-4.6',
1627
+ name: 'Grok 4.6',
1628
+ modalities: { input: ['text', 'image'] },
1629
+ },
1630
+ },
1631
+ },
1632
+ {
1633
+ id: 'opencode-go',
1634
+ name: 'OpenCode Go',
1635
+ models: {
1636
+ 'deepseek-v4-flash': { id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
1637
+ },
1638
+ },
1639
+ ],
1640
+ },
1641
+ }),
1642
+ },
1643
+ }, {
1644
+ runContactTurn: async ({ tools, userText, connectedModels }) => {
1645
+ expect(connectedModels.some((entry) => entry.providerID === 'xai' && entry.modelID === 'grok-4.6' && entry.acceptsImages)).toBe(true);
1646
+ const assign = tools.find((tool) => tool.name === 'assign_session');
1647
+ const result = await assign.execute('call_1', {
1648
+ prompt: userText,
1649
+ projectPath: project,
1650
+ title: 'Card width',
1651
+ model: 'xai/grok-4.6',
1652
+ });
1653
+ return {
1654
+ text: result.content[0].text,
1655
+ bubbles: [result.content[0].text],
1656
+ cards: result.details.card ? [result.details.card] : [],
1657
+ tools,
1658
+ };
1659
+ },
1660
+ });
1661
+ const assistant = service.createAssistant(assistantInput);
1662
+ expect(assistant.providerID).toBe('p');
1663
+ expect(assistant.modelID).toBe('m');
1664
+ const sent = await settleSend(service, assistant.id, {
1665
+ messageID: 'client_assign_image_model',
1666
+ parts: [{ type: 'text', text: '修移动端卡片宽度' }, image],
1667
+ });
1668
+ expect(sent.settled.status).toBe('complete');
1669
+ expect(creates).toHaveLength(1);
1670
+ expect(prompts).toEqual([expect.objectContaining({
1671
+ sessionID: 'ses_1',
1672
+ model: { providerID: 'xai', modelID: 'grok-4.6' },
1673
+ parts: [
1674
+ { type: 'text', text: '修移动端卡片宽度' },
1675
+ expect.objectContaining({
1676
+ type: 'file',
1677
+ mime: 'image/png',
1678
+ filename: 'card.png',
1679
+ url: expect.stringMatching(/^data:image\/png;base64,/),
1680
+ }),
1681
+ ],
1682
+ })]);
1683
+ expect(deletes).toEqual([]);
1684
+ expect(service.snapshot().assistants[0]).toMatchObject({
1685
+ id: assistant.id,
1686
+ providerID: 'p',
1687
+ modelID: 'm',
1688
+ });
1689
+ service.close();
1690
+ });
1691
+
1692
+ it('invalid explicit worker model fails closed with no session create', async () => {
1693
+ const directory = root();
1694
+ const project = path.join(directory, 'app');
1695
+ fs.mkdirSync(project, { recursive: true });
1696
+ const creates = [];
1697
+ const service = setup(directory, {
1698
+ create: async (input) => {
1699
+ creates.push(input);
1700
+ return { data: { id: `ses_${creates.length}` } };
1701
+ },
1702
+ promptAsync: async () => ({ response: { status: 204 } }),
1703
+ provider: {
1704
+ list: async () => ({ data: { connected: ['xai'] } }),
1705
+ },
1706
+ config: {
1707
+ providers: async () => ({
1708
+ data: {
1709
+ providers: [{
1710
+ id: 'xai',
1711
+ name: 'xAI',
1712
+ models: { 'grok-4.6': { id: 'grok-4.6', name: 'Grok 4.6', modalities: { input: ['text', 'image'] } } },
1713
+ }],
1714
+ },
1715
+ }),
1716
+ },
1717
+ }, {
1718
+ runContactTurn: async ({ tools }) => {
1719
+ const assign = tools.find((tool) => tool.name === 'assign_session');
1720
+ const result = await assign.execute('call_1', {
1721
+ prompt: 'Fix width',
1722
+ projectPath: project,
1723
+ model: 'missing/not-real',
1724
+ });
1725
+ expect(result.details.error).toBe('model_not_found');
1726
+ return {
1727
+ text: result.content[0].text,
1728
+ bubbles: [result.content[0].text],
1729
+ cards: [],
1730
+ tools,
1731
+ };
1732
+ },
1733
+ });
1734
+ const assistant = service.createAssistant(assistantInput);
1735
+ await settleSend(service, assistant.id, {
1736
+ messageID: 'client_assign_bad_model',
1737
+ parts: [{ type: 'text', text: '用 missing 模型建会话' }],
1738
+ });
1739
+ expect(creates).toEqual([]);
1740
+ expect(service.snapshot().assistants[0]).toMatchObject({ providerID: 'p', modelID: 'm' });
1741
+ service.close();
1742
+ });
1743
+
1744
+ it('cleans up the worker session when create succeeds but promptAsync fails definitively (4xx)', async () => {
1745
+ const directory = root();
1746
+ const project = path.join(directory, 'app');
1747
+ fs.mkdirSync(project, { recursive: true });
1748
+ const creates = [];
1749
+ const deletes = [];
1750
+ const service = setup(directory, {
1751
+ create: async (input) => {
1752
+ creates.push(input);
1753
+ return { data: { id: 'ses_fail_prompt' } };
1754
+ },
1755
+ promptAsync: async () => ({ error: { status: 400 }, response: { status: 400 } }),
1756
+ delete: async (input) => {
1757
+ deletes.push(input);
1758
+ return { data: true };
1759
+ },
1760
+ }, {
1761
+ runContactTurn: async ({ tools, userText }) => {
1762
+ const assign = tools.find((tool) => tool.name === 'assign_session');
1763
+ const result = await assign.execute('call_1', { prompt: userText, projectPath: project });
1764
+ expect(result.details.error).toBe('upstream_error');
1765
+ return {
1766
+ text: result.content[0].text,
1767
+ bubbles: [result.content[0].text],
1768
+ cards: [],
1769
+ tools,
1770
+ };
1771
+ },
1772
+ });
1773
+ const assistant = service.createAssistant(assistantInput);
1774
+ await settleSend(service, assistant.id, {
1775
+ messageID: 'client_assign_prompt_fail',
1776
+ parts: [{ type: 'text', text: 'Fix after create' }],
1777
+ });
1778
+ expect(creates).toHaveLength(1);
1779
+ expect(deletes).toEqual([expect.objectContaining({ sessionID: 'ses_fail_prompt' })]);
1780
+ service.close();
1781
+ });
1782
+
1783
+ it('keeps default-model assign behavior when no explicit worker model is requested', async () => {
1784
+ const directory = root();
1785
+ const project = path.join(directory, 'app');
1786
+ fs.mkdirSync(project, { recursive: true });
1787
+ const prompts = [];
1788
+ const service = setup(directory, {
1789
+ create: async () => ({ data: { id: 'ses_default' } }),
1790
+ promptAsync: async (input) => {
1791
+ prompts.push(input);
1792
+ return { response: { status: 204 } };
1793
+ },
1794
+ }, {
1795
+ runContactTurn: async ({ tools, userText }) => {
1796
+ const assign = tools.find((tool) => tool.name === 'assign_session');
1797
+ const result = await assign.execute('call_1', { prompt: userText, projectPath: project });
1798
+ return {
1799
+ text: result.content[0].text,
1800
+ bubbles: [result.content[0].text],
1801
+ cards: result.details.card ? [result.details.card] : [],
1802
+ tools,
1803
+ };
1804
+ },
1805
+ });
1806
+ const assistant = service.createAssistant(assistantInput);
1807
+ await settleSend(service, assistant.id, {
1808
+ messageID: 'client_assign_default_model',
1809
+ parts: [{ type: 'text', text: '建会话修一下' }],
1810
+ });
1811
+ expect(prompts).toEqual([expect.objectContaining({
1812
+ model: { providerID: 'p', modelID: 'm' },
1813
+ parts: [{ type: 'text', text: '建会话修一下' }],
1814
+ })]);
1815
+ service.close();
1816
+ });
1817
+
1206
1818
  it('creates another assistant from create_assistant and persists the assistant card', async () => {
1207
1819
  const directory = root();
1208
1820
  const service = setup(directory, {}, {
@@ -1576,7 +2188,9 @@ describe('assistants service', () => {
1576
2188
  });
1577
2189
  expect(service.snapshot().assistants[0]).toMatchObject({
1578
2190
  assignedSessionIDs: ['ses_work'],
1579
- working: true,
2191
+ // assigned busy keeps the session card in flight; list green dot is contact-turn only
2192
+ working: false,
2193
+ activeContactTurn: null,
1580
2194
  });
1581
2195
  expect(service.processEvent({ type: 'session.idle', properties: { sessionID: 'ses_work' } })).toBe(true);
1582
2196
  const page = service.contactMessages(assistant.id);
@@ -1609,7 +2223,8 @@ describe('assistants service', () => {
1609
2223
  });
1610
2224
  expect(first.snapshot().assistants[0]).toMatchObject({
1611
2225
  assignedSessionIDs: ['ses_missed'],
1612
- working: true,
2226
+ working: false,
2227
+ activeContactTurn: null,
1613
2228
  });
1614
2229
  first.close();
1615
2230
 
@@ -1647,7 +2262,8 @@ describe('assistants service', () => {
1647
2262
  title: 'Follow-up',
1648
2263
  status: 'busy',
1649
2264
  });
1650
- expect(service.snapshot().assistants[0].working).toBe(true);
2265
+ expect(service.snapshot().assistants[0].working).toBe(false);
2266
+ expect(service.snapshot().assistants[0].assignedSessionIDs).toEqual(['ses_timer']);
1651
2267
  await tick();
1652
2268
  page = service.contactMessages(assistant.id);
1653
2269
  expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card' && part.sessionID === 'ses_timer'))?.parts[0].status).toBe('complete');
@@ -1751,7 +2367,7 @@ describe('assistants service', () => {
1751
2367
  });
1752
2368
  expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(1);
1753
2369
  expect(service.snapshot().assistants[0].assignedSessionIDs.sort()).toEqual(['ses_busy', 'ses_down']);
1754
- expect(service.snapshot().assistants[0].working).toBe(true);
2370
+ expect(service.snapshot().assistants[0].working).toBe(false);
1755
2371
  service.close();
1756
2372
  });
1757
2373
 
@@ -1997,11 +2613,24 @@ describe('assistants service', () => {
1997
2613
  binding: expect.objectContaining({ sessionID: null, sessionGeneration: 0 }),
1998
2614
  messageID: 'turn_stream_1',
1999
2615
  admitted: true,
2616
+ revision: expect.any(Number),
2000
2617
  });
2001
2618
  expect(sent).not.toHaveProperty('settled');
2619
+ expect(service.snapshot().assistants[0]).toMatchObject({
2620
+ working: true,
2621
+ activeContactTurn: expect.objectContaining({
2622
+ turnID: 'turn_stream_1',
2623
+ messageID: 'turn_stream_1',
2624
+ status: expect.stringMatching(/^(queued|running)$/),
2625
+ }),
2626
+ });
2002
2627
  const settled = await service.whenContactTurnSettled('turn_stream_1');
2003
2628
  expect(settled).toMatchObject({ status: 'complete' });
2004
2629
  await new Promise((resolve) => setImmediate(resolve));
2630
+ expect(service.snapshot().assistants[0]).toMatchObject({
2631
+ working: false,
2632
+ activeContactTurn: null,
2633
+ });
2005
2634
  const types = events.map((event) => event.type);
2006
2635
  expect(types[0]).toBe('openchamber:contact-turn-start');
2007
2636
  expect(events[0].properties).toMatchObject({
@@ -2034,6 +2663,179 @@ describe('assistants service', () => {
2034
2663
  service.close();
2035
2664
  });
2036
2665
 
2666
+ it('replays same messageID+payload admission once and conflicts on payload mismatch', async () => {
2667
+ const directory = root();
2668
+ let release;
2669
+ const blocked = new Promise((resolve) => { release = resolve; });
2670
+ let runs = 0;
2671
+ const service = setup(directory, {}, {
2672
+ runContactTurn: async () => {
2673
+ runs += 1;
2674
+ await blocked;
2675
+ return { text: 'once', bubbles: ['once'] };
2676
+ },
2677
+ });
2678
+ const assistant = service.createAssistant(assistantInput);
2679
+ const first = await service.send(assistant.id, {
2680
+ messageID: 'idem_1',
2681
+ parts: [{ type: 'text', text: 'same' }],
2682
+ });
2683
+ expect(first).toMatchObject({ admitted: true, messageID: 'idem_1', revision: expect.any(Number) });
2684
+ const replay = await service.send(assistant.id, {
2685
+ messageID: 'idem_1',
2686
+ parts: [{ type: 'text', text: 'same' }],
2687
+ });
2688
+ expect(replay).toMatchObject({ admitted: true, messageID: 'idem_1', replayed: true });
2689
+ await expect(service.send(assistant.id, {
2690
+ messageID: 'idem_1',
2691
+ parts: [{ type: 'text', text: 'different' }],
2692
+ })).rejects.toMatchObject({ code: 'idempotency_conflict' });
2693
+ release();
2694
+ await service.whenContactTurnSettled('idem_1');
2695
+ expect(runs).toBe(1);
2696
+ const after = await service.send(assistant.id, {
2697
+ messageID: 'idem_1',
2698
+ parts: [{ type: 'text', text: 'same' }],
2699
+ });
2700
+ expect(after).toMatchObject({ admitted: true, replayed: true });
2701
+ expect(runs).toBe(1);
2702
+ service.close();
2703
+ });
2704
+
2705
+ it('persists a durable contact error bubble so query recovery works without SSE', async () => {
2706
+ const service = setup(root(), {}, {
2707
+ runContactTurn: async () => {
2708
+ const error = new Error('No connected model');
2709
+ error.code = 'no_provider';
2710
+ throw error;
2711
+ },
2712
+ });
2713
+ const assistant = service.createAssistant(assistantInput);
2714
+ await service.send(assistant.id, {
2715
+ messageID: 'fail_persist_1',
2716
+ parts: [{ type: 'text', text: 'hi' }],
2717
+ });
2718
+ await service.whenContactTurnSettled('fail_persist_1');
2719
+ const page = service.contactMessages(assistant.id);
2720
+ expect(page.messages.map((message) => ({ role: message.role, status: message.status, text: message.text }))).toEqual([
2721
+ { role: 'user', status: 'complete', text: 'hi' },
2722
+ { role: 'assistant', status: 'error', text: 'No connected model' },
2723
+ ]);
2724
+ expect(service.snapshot().assistants[0]).toMatchObject({ working: false, activeContactTurn: null });
2725
+ service.close();
2726
+ });
2727
+
2728
+ it('bumps revision when queued contact turn becomes running', async () => {
2729
+ const directory = root();
2730
+ let release;
2731
+ const blocked = new Promise((resolve) => { release = resolve; });
2732
+ const tips = [];
2733
+ const service = setup(directory, {}, {
2734
+ onRevisionTip: (tip) => tips.push(tip),
2735
+ runContactTurn: async () => {
2736
+ await blocked;
2737
+ return { text: 'ok', bubbles: ['ok'] };
2738
+ },
2739
+ });
2740
+ const assistant = service.createAssistant(assistantInput);
2741
+ const before = service.snapshot().revision;
2742
+ await service.send(assistant.id, {
2743
+ messageID: 'run_bump_1',
2744
+ parts: [{ type: 'text', text: 'hi' }],
2745
+ });
2746
+ await new Promise((resolve) => setImmediate(resolve));
2747
+ await new Promise((resolve) => setImmediate(resolve));
2748
+ expect(service.snapshot().assistants[0].activeContactTurn?.status).toBe('running');
2749
+ expect(service.snapshot().revision).toBeGreaterThan(before);
2750
+ release();
2751
+ await service.whenContactTurnSettled('run_bump_1');
2752
+ expect(tips.length).toBeGreaterThan(0);
2753
+ service.close();
2754
+ });
2755
+
2756
+ it('keeps snapshot working authoritative until settle and clears it on server process restart', async () => {
2757
+ const directory = root();
2758
+ let releaseTurn;
2759
+ const blocked = new Promise((resolve) => { releaseTurn = resolve; });
2760
+ const service = setup(directory, {}, {
2761
+ runContactTurn: async () => {
2762
+ await blocked;
2763
+ return { text: 'done', bubbles: ['done'] };
2764
+ },
2765
+ });
2766
+ const assistant = service.createAssistant(assistantInput);
2767
+ await service.send(assistant.id, {
2768
+ messageID: 'turn_auth_1',
2769
+ parts: [{ type: 'text', text: 'hold' }],
2770
+ });
2771
+ expect(service.snapshot().assistants[0]).toMatchObject({
2772
+ working: true,
2773
+ activeContactTurn: {
2774
+ turnID: 'turn_auth_1',
2775
+ messageID: 'turn_auth_1',
2776
+ status: expect.stringMatching(/^(queued|running)$/),
2777
+ admittedAt: expect.any(Number),
2778
+ },
2779
+ });
2780
+ // Same process: snapshot is the APP-restart recovery source.
2781
+ expect(service.snapshot().assistants[0].working).toBe(true);
2782
+ releaseTurn();
2783
+ await service.whenContactTurnSettled('turn_auth_1');
2784
+ expect(service.snapshot().assistants[0]).toMatchObject({
2785
+ working: false,
2786
+ activeContactTurn: null,
2787
+ });
2788
+ service.close();
2789
+
2790
+ // New process (server restart): process-local activity is gone — never permanently green.
2791
+ const restarted = setup(directory, {}, {
2792
+ runContactTurn: async () => ({ text: 'again', bubbles: ['again'] }),
2793
+ });
2794
+ expect(restarted.snapshot().assistants[0]).toMatchObject({
2795
+ working: false,
2796
+ activeContactTurn: null,
2797
+ });
2798
+ restarted.close();
2799
+ });
2800
+
2801
+ it('isolates contact working per assistant and clears working on harness error', async () => {
2802
+ const directory = root();
2803
+ let releaseA;
2804
+ const blockedA = new Promise((resolve) => { releaseA = resolve; });
2805
+ const service = setup(directory, {}, {
2806
+ runContactTurn: async ({ userText }) => {
2807
+ if (userText === 'hold-a') {
2808
+ await blockedA;
2809
+ return { text: 'a-done', bubbles: ['a-done'] };
2810
+ }
2811
+ const error = new Error('No connected model');
2812
+ error.code = 'no_provider';
2813
+ throw error;
2814
+ },
2815
+ });
2816
+ const first = service.createAssistant({ ...assistantInput, name: 'A' });
2817
+ const second = service.createAssistant({ ...assistantInput, name: 'B' });
2818
+ await service.send(first.id, {
2819
+ messageID: 'turn_a',
2820
+ parts: [{ type: 'text', text: 'hold-a' }],
2821
+ });
2822
+ await service.send(second.id, {
2823
+ messageID: 'turn_b',
2824
+ parts: [{ type: 'text', text: 'fail-b' }],
2825
+ });
2826
+ const snap = () => Object.fromEntries(service.snapshot().assistants.map((item) => [item.id, item]));
2827
+ expect(snap()[first.id].working).toBe(true);
2828
+ expect(snap()[first.id].activeContactTurn?.turnID).toBe('turn_a');
2829
+ await service.whenContactTurnSettled('turn_b');
2830
+ expect(snap()[second.id].working).toBe(false);
2831
+ expect(snap()[second.id].activeContactTurn).toBeNull();
2832
+ expect(snap()[first.id].working).toBe(true);
2833
+ releaseA();
2834
+ await service.whenContactTurnSettled('turn_a');
2835
+ expect(snap()[first.id].working).toBe(false);
2836
+ service.close();
2837
+ });
2838
+
2037
2839
  it('admits the user then broadcasts turn-end error when the harness fails', async () => {
2038
2840
  const events = [];
2039
2841
  const service = setup(root(), {}, {
@@ -2051,17 +2853,25 @@ describe('assistants service', () => {
2051
2853
  });
2052
2854
  expect(sent).toMatchObject({ admitted: true, messageID: 'client_no_provider' });
2053
2855
  expect(service.contactMessages(assistant.id).messages.map((message) => message.role)).toEqual(['user']);
2856
+ expect(service.snapshot().assistants[0].working).toBe(true);
2054
2857
  const settled = await service.whenContactTurnSettled('client_no_provider');
2055
2858
  expect(settled).toMatchObject({ status: 'error', code: 'no_provider' });
2056
2859
  await new Promise((resolve) => setImmediate(resolve));
2860
+ expect(service.snapshot().assistants[0]).toMatchObject({
2861
+ working: false,
2862
+ activeContactTurn: null,
2863
+ });
2057
2864
  expect(events.some((event) => event.type === 'openchamber:contact-turn-start')).toBe(true);
2058
2865
  expect(events.some((event) => (
2059
2866
  event.type === 'openchamber:contact-turn-end'
2060
2867
  && event.properties?.status === 'error'
2061
2868
  && event.properties?.turnID === 'client_no_provider'
2062
2869
  ))).toBe(true);
2063
- // User row stays; no assistant bubble on failure.
2064
- expect(service.contactMessages(assistant.id).messages.map((message) => message.role)).toEqual(['user']);
2870
+ // User row stays; durable error bubble enables query recovery without SSE.
2871
+ expect(service.contactMessages(assistant.id).messages.map((message) => ({ role: message.role, status: message.status }))).toEqual([
2872
+ { role: 'user', status: 'complete' },
2873
+ { role: 'assistant', status: 'error' },
2874
+ ]);
2065
2875
  service.close();
2066
2876
  });
2067
2877
 
@@ -2089,4 +2899,93 @@ describe('assistants service', () => {
2089
2899
  db.close();
2090
2900
  service.close();
2091
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
+ });
2092
2991
  });