@openchambery/web 1.19.7-beta.9 → 1.19.7

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 (201) hide show
  1. package/dist/assets/AgentAvatar-CX0a1rKE.js +1 -0
  2. package/dist/assets/AssistantView-nVF0kdFh.js +2 -0
  3. package/dist/assets/DiagramView-B2UtQ8mw.js +1 -0
  4. package/dist/assets/FadeInOnReveal-CK4asqVR.js +1 -0
  5. package/dist/assets/Icon-BYM02QyU.js +1 -0
  6. package/dist/assets/MarkdownLoadingSkeleton-DWs4Jg7b.js +3 -0
  7. package/dist/assets/MarkdownRenderer-DT_GN1Ex.js +2 -0
  8. package/dist/assets/MarkdownRendererImpl-Cslh4XN_.js +7 -0
  9. package/dist/assets/MarkstreamRendererImpl-CsNwy1T5.css +1 -0
  10. package/dist/assets/MarkstreamRendererImpl-Ymm12x6J.js +1 -0
  11. package/dist/assets/MobileDetailNavigation-DD29ENgU.js +1 -0
  12. package/dist/assets/MobileShareBridge-CrTe_QND.js +4 -0
  13. package/dist/assets/MobileSurface-DjxchpUC.js +2 -0
  14. package/dist/assets/{MultiRunWindow-Ohj6UbWj.js → MultiRunWindow-jG5dCEMH.js} +1 -1
  15. package/dist/assets/OnboardingScreen-C3-wzeSX.js +1 -0
  16. package/dist/assets/OverlayScrollbar-KUpvYHdv.js +2 -0
  17. package/dist/assets/SettingsView-UKlEewdq.js +25 -0
  18. package/dist/assets/{SettingsWindow-B4lzhAbO.js → SettingsWindow-DO1aAplT.js} +1 -1
  19. package/dist/assets/TerminalView-BG1UPLsz.js +8 -0
  20. package/dist/assets/ToolOutputDialog-CWr5eVTK.js +10 -0
  21. package/dist/assets/appThemeRegistry-BI5drVpC.js +1 -0
  22. package/dist/assets/{button-z0GOBtWi.js → button-C0cth9tl.js} +1 -1
  23. package/dist/assets/clipboard-DJ4nQEuY.js +1 -0
  24. package/dist/assets/{collapsible-D9zJRpeB.js → collapsible-DFT9cE80.js} +1 -1
  25. package/dist/assets/context-menu-CAp74NfV.js +1 -0
  26. package/dist/assets/desktopHosts-CWxex1c3.js +1 -0
  27. package/dist/assets/desktopRelayRestore-9EbBYQ9s.js +1 -0
  28. package/dist/assets/{diagram-CoEqmReY.js → diagram-BryzMuta.js} +1 -1
  29. package/dist/assets/{dialog-x2hirWBd.js → dialog-CgbV5ljg.js} +1 -1
  30. package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
  31. package/dist/assets/{es-C8Sqjsia.js → es-BHqnSjoZ.js} +1 -1
  32. package/dist/assets/fileQueries-BY-9OWzp.js +1 -0
  33. package/dist/assets/fileReferenceActions-BJJtX_S5.js +1 -0
  34. package/dist/assets/{fr-p-Zwm_j2.js → fr-BfrlJY9i.js} +1 -1
  35. package/dist/assets/{gitApi-CtS4kWoo.js → gitApi-D9hFlE3T.js} +3 -3
  36. package/dist/assets/{input-BG3VWTIF.js → input-DFLpqjGR.js} +1 -1
  37. package/dist/assets/insertionBoundaries-D2L7D0Oe.js +4 -0
  38. package/dist/assets/{ja-B6REu1qP.js → ja-DP-7qt-x.js} +1 -1
  39. package/dist/assets/{ko-CJBNH9IZ.js → ko-E2B5vGQP.js} +1 -1
  40. package/dist/assets/main-BRedOs_V.js +169 -0
  41. package/dist/assets/main-DlXpJ0Vw.js +2 -0
  42. package/dist/assets/markdown-worker-BuveaH3O.js +1 -0
  43. package/dist/assets/markdownTheme-CzZm3riB.js +2 -0
  44. package/dist/assets/miniChat-CBLbFpfy.js +2 -0
  45. package/dist/assets/mobile-CoZsWdWk.js +2 -0
  46. package/dist/assets/multirun-BF-I0r2_.js +4 -0
  47. package/dist/assets/outsideFileGrants-DnplRxr-.js +1 -0
  48. package/dist/assets/path-utils-jGozTg9d.js +1 -0
  49. package/dist/assets/persistence-DdUac14I.js +3 -0
  50. package/dist/assets/{pl-B2cBI_7K.js → pl-r8X5gRM5.js} +1 -1
  51. package/dist/assets/platform-B4KKLnZ5.js +2 -0
  52. package/dist/assets/projectMeta-2t4L5_4a.js +3 -0
  53. package/dist/assets/{pt-BR-CrCc9hmt.js → pt-BR-DcpdXCf5.js} +1 -1
  54. package/dist/assets/renderElectronMiniChatApp-fl7O3iHy.js +2 -0
  55. package/dist/assets/renderMobileApp-CKYDwf4U.js +28 -0
  56. package/dist/assets/runtimeConfig-B4eb4ZiU.js +2 -0
  57. package/dist/assets/runtimeEndpointReset-BHuTYyzc.js +147 -0
  58. package/dist/assets/sessionChangeSummary-DOq8EN_H.js +1 -0
  59. package/dist/assets/sessionLookup-DNc0ZeR3.js +1 -0
  60. package/dist/assets/sortable-tabs-strip-C8_9kHqu.js +1 -0
  61. package/dist/assets/src-DasvfkEg.css +2 -0
  62. package/dist/assets/{store-CL3WP48T.js → store-BDFdnk6l.js} +2 -2
  63. package/dist/assets/terminalApi-947l7Ra3.js +1 -0
  64. package/dist/assets/textarea-Cgo_nWUU.js +1 -0
  65. package/dist/assets/toolHelpers-DEvVP9U0.js +2 -0
  66. package/dist/assets/{tooltip-CtH61mQh.js → tooltip-BifLJ8o0.js} +1 -1
  67. package/dist/assets/{uk-C8YKAz0x.js → uk-Di0vl9V-.js} +1 -1
  68. package/dist/assets/url-Pk6tQ1EZ.js +1 -0
  69. package/dist/assets/{ChatContainer-CDExXwgn.js → useAppFontEffects-Dle8xs5h.js} +44 -39
  70. package/dist/assets/useAssistantUIStore-me6s63b2.js +1 -0
  71. package/dist/assets/useDesktopWindowControlsLayout-_QAwKcQn.js +1 -0
  72. package/dist/assets/useEffectiveDirectory-BSyuAgz6.js +1 -0
  73. package/dist/assets/useFontPreferences-BCOBlaAd.js +1 -0
  74. package/dist/assets/useMobileNavigationStore-f8xHJk7C.js +413 -0
  75. package/dist/assets/useSessionAutoCleanup-BHiXaW0J.js +1 -0
  76. package/dist/assets/useUIStore-CuaA8-Hp.js +1 -0
  77. package/dist/assets/useWorkerHighlightedLines-DuvXTSm_.js +7 -0
  78. package/dist/assets/utils-BiK3tBGM.js +1 -0
  79. package/dist/assets/{vendor-markstream-react-CwvbEg9R.js → vendor-markstream-react-C3K8YH0d.js} +4 -4
  80. package/dist/assets/{zh-CN-CCYwUnmB.js → zh-CN-coFCfeQE.js} +1 -1
  81. package/dist/assets/{zh-TW-D8Y6py08.js → zh-TW-oDRZ1-yb.js} +1 -1
  82. package/dist/index.html +9 -11
  83. package/dist/mini-chat.html +9 -11
  84. package/dist/mobile.html +15 -17
  85. package/package.json +2 -1
  86. package/server/index.js +2 -0
  87. package/server/lib/assistant-delivery-parts.js +3 -1
  88. package/server/lib/assistant-delivery-parts.test.js +11 -0
  89. package/server/lib/assistants/DOCUMENTATION.md +76 -3
  90. package/server/lib/assistants/assign.js +225 -0
  91. package/server/lib/assistants/assign.test.js +118 -0
  92. package/server/lib/assistants/bubbles.js +45 -0
  93. package/server/lib/assistants/bubbles.test.js +36 -0
  94. package/server/lib/assistants/cards.js +148 -0
  95. package/server/lib/assistants/cards.test.js +116 -0
  96. package/server/lib/assistants/contact-store.js +284 -0
  97. package/server/lib/assistants/contact-store.test.js +131 -0
  98. package/server/lib/assistants/contact-tools.js +761 -0
  99. package/server/lib/assistants/contact-tools.test.js +328 -0
  100. package/server/lib/assistants/contracts.js +20 -1
  101. package/server/lib/assistants/harness.js +551 -0
  102. package/server/lib/assistants/harness.test.js +607 -0
  103. package/server/lib/assistants/routes.js +44 -4
  104. package/server/lib/assistants/service.js +930 -17
  105. package/server/lib/assistants/service.test.js +1163 -45
  106. package/server/lib/event-stream/DOCUMENTATION.md +12 -1
  107. package/server/lib/event-stream/directory-ws-bridge.js +11 -2
  108. package/server/lib/event-stream/global-ws-bridge.js +19 -3
  109. package/server/lib/event-stream/protocol.js +11 -1
  110. package/server/lib/event-stream/reasoning-projection.js +425 -0
  111. package/server/lib/event-stream/reasoning-projection.test.js +300 -0
  112. package/server/lib/event-stream/runtime.js +10 -0
  113. package/server/lib/event-stream/runtime.test.js +53 -0
  114. package/server/lib/llm/DOCUMENTATION.md +84 -0
  115. package/server/lib/llm/catalog.js +100 -0
  116. package/server/lib/llm/catalog.test.js +78 -0
  117. package/server/lib/llm/completions.js +151 -0
  118. package/server/lib/llm/completions.test.js +127 -0
  119. package/server/lib/llm/generate.js +570 -0
  120. package/server/lib/llm/generate.test.js +459 -0
  121. package/server/lib/llm/routes.js +47 -0
  122. package/server/lib/llm/temp-directory.js +37 -0
  123. package/server/lib/notifications/APNS.md +19 -13
  124. package/server/lib/notifications/DOCUMENTATION.md +4 -3
  125. package/server/lib/notifications/runtime.js +148 -37
  126. package/server/lib/notifications/runtime.test.js +100 -0
  127. package/server/lib/opencode/DOCUMENTATION.md +2 -1
  128. package/server/lib/opencode/feature-routes-runtime.js +30 -0
  129. package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
  130. package/server/lib/opencode/proxy.js +103 -9
  131. package/server/lib/opencode/proxy.test.js +177 -1
  132. package/server/lib/opencode/settings-runtime.js +2 -2
  133. package/server/lib/relay/reasoning-projection.e2e.test.ts +529 -0
  134. package/server/lib/session-goal/DOCUMENTATION.md +8 -1
  135. package/server/lib/session-goal/runtime.js +20 -0
  136. package/server/lib/session-index/DOCUMENTATION.md +3 -1
  137. package/server/lib/session-index/service.js +10 -4
  138. package/server/lib/session-index/service.test.js +37 -0
  139. package/server/lib/session-title/runtime.js +1 -0
  140. package/server/lib/session-title/runtime.test.js +11 -0
  141. package/server/lib/session-turn-pages/DOCUMENTATION.md +2 -1
  142. package/server/lib/session-turn-pages/routes.js +22 -3
  143. package/server/lib/session-turn-pages/routes.test.js +43 -0
  144. package/server/lib/transcript-cache/DOCUMENTATION.md +2 -2
  145. package/server/lib/transcript-cache/routes.js +9 -2
  146. package/server/lib/transcript-cache/routes.test.js +32 -0
  147. package/dist/assets/AgentAvatar-Di8F4QJh.js +0 -1
  148. package/dist/assets/AssistantView-DSxVAAb_.js +0 -1
  149. package/dist/assets/ContextPanel-DgwEIdqR.js +0 -285
  150. package/dist/assets/DiagramView-BKQO61y8.js +0 -1
  151. package/dist/assets/FadeInOnReveal-CV2s6YN8.js +0 -1
  152. package/dist/assets/Icon-CLpn1mIz.js +0 -1
  153. package/dist/assets/MarkdownRendererImpl-CTgmghSh.js +0 -7
  154. package/dist/assets/MarkstreamRendererImpl-BKsc-avF.js +0 -1
  155. package/dist/assets/MarkstreamRendererImpl-CxeWpDCK.css +0 -1
  156. package/dist/assets/MobileDetailNavigation-BcA6rd8Q.js +0 -1
  157. package/dist/assets/MobileShareBridge-w6T2ZrsW.js +0 -4
  158. package/dist/assets/MobileSurface-CG1n7v0h.js +0 -2
  159. package/dist/assets/OnboardingScreen-D6rtE7rj.js +0 -1
  160. package/dist/assets/OverlayScrollbar-DlNZho3q.js +0 -2
  161. package/dist/assets/SettingsView-CiF3r83e.js +0 -25
  162. package/dist/assets/TerminalView-Ctx_Jt_b.js +0 -8
  163. package/dist/assets/ToolOutputDialog-Dv9_fUYl.js +0 -10
  164. package/dist/assets/appThemeRegistry-DnwUIQB9.js +0 -2
  165. package/dist/assets/clipboard-D35XSwID.js +0 -1
  166. package/dist/assets/desktop-CfLlmafK.js +0 -1
  167. package/dist/assets/desktopHosts-C1Ns3DUu.js +0 -1
  168. package/dist/assets/desktopRelayRestore-8pvI_dSc.js +0 -1
  169. package/dist/assets/fileQueries-DGHZqZHc.js +0 -1
  170. package/dist/assets/insertionBoundaries-hK4mQj4W.js +0 -4
  171. package/dist/assets/main-BmbZFoO4.js +0 -29
  172. package/dist/assets/main-BwNhn8ml.js +0 -2
  173. package/dist/assets/markdown-worker-y3ewaqAK.js +0 -4
  174. package/dist/assets/markdownTheme-SPe4jOwX.js +0 -2
  175. package/dist/assets/miniChat-BN89siC3.js +0 -2
  176. package/dist/assets/mobile-DE9rVo5D.js +0 -2
  177. package/dist/assets/multirun-BiEuyDq3.js +0 -4
  178. package/dist/assets/persistence-w-AiX2i4.js +0 -3
  179. package/dist/assets/platform-4eCmsdol.js +0 -2
  180. package/dist/assets/projectMeta-DSLKcH1a.js +0 -3
  181. package/dist/assets/radio-HD5AB2Qf.js +0 -1
  182. package/dist/assets/renderElectronMiniChatApp-f_UxpFjv.js +0 -2
  183. package/dist/assets/renderMobileApp-BAhCai6d.js +0 -28
  184. package/dist/assets/runtime-switch-BkpTTbEi.js +0 -1
  185. package/dist/assets/runtimeConfig-qG_oNBoa.js +0 -2
  186. package/dist/assets/runtimeEndpointReset-BDeKD3EM.js +0 -4
  187. package/dist/assets/shikiHighlight-BphLZnac.js +0 -1
  188. package/dist/assets/sortable-tabs-strip-CNDgyX0B.js +0 -1
  189. package/dist/assets/src-DZt1nWf5.css +0 -2
  190. package/dist/assets/terminalApi-CGtOtc8I.js +0 -1
  191. package/dist/assets/url-KeihbE0d.js +0 -1
  192. package/dist/assets/useAppFontEffects--0dwVTSm.js +0 -7
  193. package/dist/assets/useAssistantUIStore-I0VWrB5V.js +0 -1
  194. package/dist/assets/useDesktopWindowControlsLayout-DsbeBLvv.js +0 -1
  195. package/dist/assets/useEffectiveDirectory-CHvvohcG.js +0 -1
  196. package/dist/assets/useFontPreferences-BLZ3y6mN.js +0 -1
  197. package/dist/assets/useMobileNavigationStore-peTDuC00.js +0 -412
  198. package/dist/assets/useSessionAutoCleanup-D_ZC9E5g.js +0 -1
  199. package/dist/assets/useUIStore-CNLmCoPU.js +0 -1
  200. package/dist/assets/useWorkerHighlightedLines-B1YzuMYR.js +0 -7
  201. package/dist/assets/utils-CTL16t2f.js +0 -1
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { describe, expect, it } from 'vitest';
4
+ import { describe, expect, it, vi } from 'vitest';
5
5
  import { createRequire } from 'node:module';
6
6
  import { createAssistantsService } from './service.js';
7
7
  import { assistantContractFixtures } from './contracts.js';
@@ -11,13 +11,19 @@ const root = () => fs.mkdtempSync(path.join(os.tmpdir(), 'assistants-'));
11
11
  // Behavioral tests enable the global switch after boot; pass enabled:false to assert the fresh-install default.
12
12
  const setup = (directory = root(), client = {}, options = {}) => {
13
13
  const { enabled = true, ...serviceOptions } = options;
14
- 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 } }), ...serviceOptions });
14
+ 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 });
15
15
  if (enabled) {
16
16
  const snapshot = service.snapshot();
17
17
  if (!snapshot.enabled) service.setEnabled({ enabled: true, expectedRevision: snapshot.revision });
18
18
  }
19
19
  return service;
20
20
  };
21
+ /** Wait for the async contact turn after 202 admission (not part of the HTTP body). */
22
+ const settleSend = async (service, assistantID, body) => {
23
+ const sent = await service.send(assistantID, body);
24
+ const settled = await service.whenContactTurnSettled(sent.messageID);
25
+ return { ...sent, settled };
26
+ };
21
27
  const assistantInput = { name: 'A', providerID: 'p', modelID: 'm' };
22
28
 
23
29
  describe('assistants service', () => {
@@ -65,23 +71,200 @@ describe('assistants service', () => {
65
71
  const service = setup(); const assistant = service.createAssistant(assistantInput); const current = await service.ensure(assistant.id); const next = await service.createNew(assistant.id); expect(next.sessionGeneration).toBe(current.sessionGeneration + 1); await expect(service.compact(assistant.id, current)).rejects.toMatchObject({ code: 'revision_conflict' }); expect(await service.compact(assistant.id, next)).toMatchObject({ binding: next, summarized: true }); service.close();
66
72
  });
67
73
 
68
- it('keeps ordinary composer history out of SQLite and restores a 404 binding', async () => {
69
- let gets = 0; let prompts = 0; const directory = root(); const service = setup(directory, { create: async () => ({ data: { id: `ses_${gets + 1}` } }), get: async () => (++gets === 1 ? { data: { id: 'ses_1' } } : { error: { status: 404 } }), promptAsync: async () => (++prompts === 1 ? { error: { status: 404 } } : { data: { info: { id: 'msg_2' } } }) }); const assistant = service.createAssistant(assistantInput); const current = await service.ensure(assistant.id); const sent = await service.send(assistant.id, { ...current, messageID: 'client_1', parts: [{ type: 'text', text: 'hello' }] }); expect(sent.binding.sessionGeneration).toBe(2); const db = new (require('better-sqlite3'))(path.join(directory, 'assistants.sqlite')); expect(db.prepare('SELECT COUNT(*) AS count FROM assistant_turn').get().count).toBe(0); db.close(); service.close();
74
+ it('stores composer turns in the OpenChamber contact transcript, not OpenCode session history', async () => {
75
+ const directory = root();
76
+ const service = setup(directory, {}, {
77
+ runContactTurn: async ({ userText }) => ({ text: `ok ${userText}`, bubbles: [`ok ${userText}`] }),
78
+ });
79
+ const assistant = service.createAssistant(assistantInput);
80
+ const current = await service.ensure(assistant.id);
81
+ const sent = await service.send(assistant.id, { ...current, messageID: 'client_1', parts: [{ type: 'text', text: 'hello' }] });
82
+ expect(sent).toEqual({ binding: current, messageID: 'client_1', admitted: true });
83
+ // Admission resolves before the async turn persists assistant bubbles.
84
+ expect(service.contactMessages(assistant.id, { limit: 50 }).messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([
85
+ { role: 'user', text: 'hello' },
86
+ ]);
87
+ await service.whenContactTurnSettled('client_1');
88
+ const page = service.contactMessages(assistant.id, { limit: 50 });
89
+ expect(page.complete).toBe(true);
90
+ expect(page.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([
91
+ { role: 'user', text: 'hello' },
92
+ { role: 'assistant', text: 'ok hello' },
93
+ ]);
94
+ const db = new (require('better-sqlite3'))(path.join(directory, 'assistants.sqlite'));
95
+ expect(db.prepare('SELECT COUNT(*) AS count FROM assistant_turn').get().count).toBe(0);
96
+ db.close();
97
+ service.close();
98
+ });
99
+
100
+ it('new_conversation clears contact history without calling OpenCode session/new', async () => {
101
+ const directory = root();
102
+ let creates = 0;
103
+ let lastHistory = null;
104
+ const service = setup(directory, {
105
+ create: async () => ({ data: { id: `ses_${++creates}` } }),
106
+ }, {
107
+ runContactTurn: async ({ history, userText, tools }) => {
108
+ lastHistory = history;
109
+ if (userText === '开新对话') {
110
+ const tool = tools.find((item) => item.name === 'new_conversation');
111
+ const result = await tool.execute('reset_1', {});
112
+ return { text: result.content[0].text, bubbles: [result.content[0].text] };
113
+ }
114
+ return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
115
+ },
116
+ });
117
+ const assistant = service.createAssistant(assistantInput);
118
+ await service.ensure(assistant.id);
119
+ expect(creates).toBe(1);
120
+ await settleSend(service, assistant.id, { messageID: 'old_1', parts: [{ type: 'text', text: 'remember this secret' }] });
121
+ await settleSend(service, assistant.id, { messageID: 'old_2', parts: [{ type: 'text', text: 'and this too' }] });
122
+ expect(lastHistory.some((item) => item.content.includes('remember this secret'))).toBe(true);
123
+ const reset = await settleSend(service, assistant.id, { messageID: 'reset_1', parts: [{ type: 'text', text: '开新对话' }] });
124
+ expect(reset).toMatchObject({ admitted: true, messageID: 'reset_1' });
125
+ expect(creates).toBe(1);
126
+ const page = service.contactMessages(assistant.id, { limit: 50 });
127
+ expect(page.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([
128
+ { role: 'user', text: '开新对话' },
129
+ { role: 'assistant', text: 'Started a new conversation. Previous contact messages are cleared.' },
130
+ ]);
131
+ await settleSend(service, assistant.id, { messageID: 'fresh_1', parts: [{ type: 'text', text: 'what did I say before?' }] });
132
+ expect(lastHistory.map((item) => item.content)).toEqual([
133
+ '开新对话',
134
+ 'Started a new conversation. Previous contact messages are cleared.',
135
+ ]);
136
+ expect(lastHistory.some((item) => item.content.includes('remember this secret'))).toBe(false);
137
+ service.close();
138
+ });
139
+
140
+ it('discards leftover pre-reset model text after new_conversation', async () => {
141
+ const directory = root();
142
+ const service = setup(directory, {}, {
143
+ runContactTurn: async ({ userText, tools }) => {
144
+ if (userText === '开新对话') {
145
+ await tools.find((item) => item.name === 'new_conversation').execute('reset_leftover', {});
146
+ return {
147
+ text: 'Started a new conversation. Previous contact messages are cleared.\n\nI still see your dot.png and note.txt.',
148
+ bubbles: [
149
+ 'Started a new conversation. Previous contact messages are cleared.',
150
+ 'I still see your dot.png and note.txt.',
151
+ 'Those attachments are still in context.',
152
+ ],
153
+ cards: [{ type: 'card', cardType: 'session', sessionID: 'ses_stale', directory: '/repo', title: 'Old', status: 'busy' }],
154
+ };
155
+ }
156
+ return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
157
+ },
158
+ });
159
+ const assistant = service.createAssistant(assistantInput);
160
+ await settleSend(service, assistant.id, {
161
+ messageID: 'attach_1',
162
+ parts: [
163
+ { type: 'text', text: 'look at these' },
164
+ { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'dot.png' },
165
+ { type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==', filename: 'note.txt' },
166
+ ],
167
+ });
168
+ await settleSend(service, assistant.id, { messageID: 'reset_leftover', parts: [{ type: 'text', text: '开新对话' }] });
169
+ const page = service.contactMessages(assistant.id, { limit: 50 });
170
+ expect(page.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([
171
+ { role: 'user', text: '开新对话' },
172
+ { role: 'assistant', text: 'Started a new conversation. Previous contact messages are cleared.' },
173
+ ]);
174
+ expect(page.messages.some((message) => message.text.includes('dot.png') || message.text.includes('note.txt'))).toBe(false);
175
+ expect(page.messages.some((message) => message.cards?.length > 0)).toBe(false);
176
+ service.close();
177
+ });
178
+
179
+ it('resetContact empties the transcript for a fresh UI refetch without createNew', async () => {
180
+ const directory = root();
181
+ let creates = 0;
182
+ const service = setup(directory, {
183
+ create: async () => ({ data: { id: `ses_${++creates}` } }),
184
+ });
185
+ const assistant = service.createAssistant(assistantInput);
186
+ await service.ensure(assistant.id);
187
+ await settleSend(service, assistant.id, { messageID: 'keep_1', parts: [{ type: 'text', text: 'hello' }] });
188
+ expect(service.contactMessages(assistant.id, { limit: 50 }).messages.length).toBeGreaterThan(0);
189
+ expect(service.resetContact(assistant.id)).toEqual({ assistantID: assistant.id, reset: true });
190
+ expect(creates).toBe(1);
191
+ expect(service.contactMessages(assistant.id, { limit: 50 })).toMatchObject({
192
+ messages: [],
193
+ complete: true,
194
+ });
195
+ service.close();
196
+ });
197
+
198
+ it('persists mixed text+image+file parts and forwards them to the contact harness', async () => {
199
+ const directory = root();
200
+ let harness;
201
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' };
202
+ const file = { type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==', filename: 'notes.txt' };
203
+ const service = setup(directory, {}, {
204
+ runContactTurn: async (input) => {
205
+ harness = input;
206
+ return { text: 'saw it', bubbles: ['saw it'] };
207
+ },
208
+ });
209
+ const assistant = service.createAssistant(assistantInput);
210
+ await settleSend(service, assistant.id, {
211
+ messageID: 'mixed_1',
212
+ parts: [{ type: 'text', text: 'look' }, image, file],
213
+ });
214
+ expect(harness.userText).toBe('look');
215
+ expect(harness.userParts).toEqual([{ type: 'text', text: 'look' }, image, file]);
216
+ expect(service.contactMessages(assistant.id, { limit: 50 }).messages[0]).toMatchObject({
217
+ role: 'user',
218
+ text: 'look',
219
+ parts: [{ type: 'text', text: 'look' }, image, file],
220
+ });
221
+ service.close();
222
+ const restarted = setup(directory, {}, {
223
+ runContactTurn: async () => ({ text: 'again', bubbles: ['again'] }),
224
+ });
225
+ expect(restarted.contactMessages(assistant.id, { limit: 50 }).messages[0].parts).toEqual([
226
+ { type: 'text', text: 'look' },
227
+ image,
228
+ file,
229
+ ]);
230
+ restarted.close();
231
+ });
232
+
233
+ it('admits a file-only contact send and stores the file part without a fake text row', async () => {
234
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' };
235
+ let harness;
236
+ const service = setup(root(), {}, {
237
+ runContactTurn: async (input) => {
238
+ harness = input;
239
+ return { text: 'got the image', bubbles: ['got the image'] };
240
+ },
241
+ });
242
+ const assistant = service.createAssistant(assistantInput);
243
+ await settleSend(service, assistant.id, { messageID: 'file_only_1', parts: [image] });
244
+ expect(harness.userText).toBe('[attachment]');
245
+ expect(harness.userParts).toEqual([image]);
246
+ const user = service.contactMessages(assistant.id).messages.find((message) => message.role === 'user');
247
+ expect(user.parts).toEqual([image]);
248
+ expect(user.text).toBe('');
249
+ service.close();
70
250
  });
71
251
 
72
252
  it('returns the frozen compact and message admission DTO field sets', async () => {
73
253
  const service = setup(root(), { promptAsync: async () => ({ response: { status: 204 } }) }); const assistant = service.createAssistant(assistantInput); const current = await service.ensure(assistant.id);
74
254
  expect(await service.compact(assistant.id, current)).toEqual({ binding: current, summarized: true });
75
- expect(await service.send(assistant.id, { ...current, messageID: 'client_204', parts: [{ type: 'text', text: 'hello' }] })).toEqual({ binding: current, messageID: 'client_204', admitted: true });
255
+ expect(await settleSend(service, assistant.id, { ...current, messageID: 'client_204', parts: [{ type: 'text', text: 'hello' }] })).toMatchObject({ binding: current, messageID: 'client_204', admitted: true });
76
256
  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();
77
257
  });
78
258
 
79
259
  it('admits 33-part direct messages and 129-part shares', async () => {
80
260
  const prompts = []; const service = setup(root(), { promptAsync: async (input) => { prompts.push(input); return { response: { status: 204 } }; } }); const assistant = service.createAssistant(assistantInput); const binding = await service.ensure(assistant.id);
81
261
  const directParts = Array.from({ length: 33 }, (_, index) => ({ type: 'text', text: String(index) })); const shareParts = Array.from({ length: 129 }, (_, index) => ({ type: 'text', text: String(index) }));
82
- await service.send(assistant.id, { ...binding, messageID: 'parts-33', parts: directParts }); await service.share(assistant.id, { operationID: 'parts-129', payload: { messageID: 'share-parts-129', parts: shareParts } });
262
+ await settleSend(service, assistant.id, { ...binding, messageID: 'parts-33', parts: directParts }); await service.share(assistant.id, { operationID: 'parts-129', payload: { messageID: 'share-parts-129', parts: shareParts } });
83
263
  const deliveryTarget = service.captureQueueDeliveryTarget({ assistantID: assistant.id, scope: { sessionID: binding.sessionID, directory: binding.directory } }); await service.sendWithCapturedConfig({ deliveryTarget, messageID: 'delivery-parts-129', parts: shareParts });
84
- expect(prompts.map((prompt) => prompt.parts.length)).toEqual([33, 129, 129]); service.close();
264
+ // Composer send is the contact harness (no promptAsync). Share + queued
265
+ // delivery still use the legacy OpenCode path; assign uses promptAsync on
266
+ // a dedicated worker session instead.
267
+ expect(prompts.map((prompt) => prompt.parts.length)).toEqual([129, 129]); service.close();
85
268
  });
86
269
 
87
270
  it('rejects 130-part direct messages and shares before claim', async () => {
@@ -166,8 +349,9 @@ describe('assistants service', () => {
166
349
  const assistant = service.createAssistant({ ...assistantInput, variant: 'fast' });
167
350
  expect(assistant.variant).toBe('fast');
168
351
  const binding = await service.ensure(assistant.id);
169
- await service.send(assistant.id, { ...binding, messageID: 'variant-message', parts: [{ type: 'text', text: 'message' }] });
352
+ await settleSend(service, assistant.id, { ...binding, messageID: 'variant-message', parts: [{ type: 'text', text: 'message' }] });
170
353
  await service.share(assistant.id, { operationID: 'variant-share', payload: { messageID: 'variant-share-message', parts: [{ type: 'text', text: 'share' }] } });
354
+ // Composer send is the contact harness. Share still captures the OpenCode variant.
171
355
  expect(prompts).toEqual(expect.arrayContaining([expect.objectContaining({ variant: 'fast' })]));
172
356
  expect(await service.updateAssistant(assistant.id, { expectedRevision: 1, variant: null })).toMatchObject({ variant: null });
173
357
  service.close();
@@ -211,8 +395,8 @@ describe('assistants service', () => {
211
395
  });
212
396
 
213
397
  it('uses the workspace directory for OpenCode skill discovery without catalog injection', async () => {
214
- const directory = root(); const workspace = path.join(directory, 'workspace'); const skill = path.join(workspace, '.agents', 'skills', 'project-skill'); fs.mkdirSync(skill, { recursive: true }); fs.writeFileSync(path.join(skill, 'SKILL.md'), '---\nname: project-skill\ndescription: Project skill\n---\nInstructions'); let created; let prompt; const service = setup(directory, { create: async (input) => { created = input; return { data: { id: 'ses_workspace' } }; }, promptAsync: async (input) => { prompt = input; return { response: { status: 204 } }; } }); const assistant = service.createAssistant({ ...assistantInput, workspacePath: workspace, defaultPrompt: 'Base prompt' }); const current = await service.ensure(assistant.id);
215
- await service.send(assistant.id, { ...current, messageID: 'client_skill', parts: [{ type: 'text', text: 'hello' }] }); expect(created.directory).toBe(fs.realpathSync(workspace)); expect(prompt.directory).toBe(fs.realpathSync(workspace)); expect(prompt.system).toBe('Base prompt'); expect(prompt.system).not.toContain('project-skill'); service.close();
398
+ const directory = root(); const workspace = path.join(directory, 'workspace'); const skill = path.join(workspace, '.agents', 'skills', 'project-skill'); fs.mkdirSync(skill, { recursive: true }); fs.writeFileSync(path.join(skill, 'SKILL.md'), '---\nname: project-skill\ndescription: Project skill\n---\nInstructions'); let created; let harness; const service = setup(directory, { create: async (input) => { created = input; return { data: { id: 'ses_workspace' } }; }, promptAsync: async () => ({ response: { status: 204 } }) }, { runContactTurn: async (input) => { harness = input; return { text: 'ok', bubbles: ['ok'] }; } }); const assistant = service.createAssistant({ ...assistantInput, workspacePath: workspace, defaultPrompt: 'Base prompt' }); const current = await service.ensure(assistant.id);
399
+ await settleSend(service, assistant.id, { ...current, messageID: 'client_skill', parts: [{ type: 'text', text: 'hello' }] }); expect(created.directory).toBe(fs.realpathSync(workspace)); expect(harness.assistant.defaultPrompt).toBe('Base prompt'); expect(harness.assistant.defaultPrompt).not.toContain('project-skill'); service.close();
216
400
  });
217
401
 
218
402
  it('rejects retired skillRoots input', async () => {
@@ -242,46 +426,46 @@ describe('assistants service', () => {
242
426
  service.close();
243
427
  });
244
428
 
245
- it('creates a fresh OpenCode session for every stateless composer send', async () => {
246
- let creates = 0; const prompts = [];
247
- const service = setup(root(), { create: async () => ({ data: { id: `ses_${++creates}` } }), promptAsync: async (input) => { prompts.push(input); return { response: { status: 204 } }; } });
429
+ it('keeps contact composer sends off the OpenCode session binding', async () => {
430
+ let creates = 0;
431
+ const service = setup(root(), { create: async () => ({ data: { id: `ses_${++creates}` } }) });
248
432
  const assistant = service.createAssistant({ ...assistantInput, mode: 'stateless' });
249
433
  const first = await service.ensure(assistant.id);
250
- const sent = await service.send(assistant.id, { ...first, messageID: 'stateless-1', parts: [{ type: 'text', text: 'one' }] });
251
- expect(sent.binding.sessionID).not.toBe(first.sessionID);
252
- expect(sent.binding.sessionGeneration).toBe(first.sessionGeneration + 1);
253
- expect(prompts[0]?.sessionID).toBe(sent.binding.sessionID);
254
- const second = await service.send(assistant.id, { ...sent.binding, messageID: 'stateless-2', parts: [{ type: 'text', text: 'two' }] });
255
- expect(second.binding.sessionID).not.toBe(sent.binding.sessionID);
256
- expect(second.binding.sessionGeneration).toBe(sent.binding.sessionGeneration + 1);
257
- expect(prompts.map((prompt) => prompt.sessionID)).toEqual([sent.binding.sessionID, second.binding.sessionID]);
258
- expect(service.snapshot().assistants[0].historySessionIDs).toEqual([first.sessionID, sent.binding.sessionID]);
434
+ const sent = await settleSend(service, assistant.id, { ...first, messageID: 'stateless-1', parts: [{ type: 'text', text: 'one' }] });
435
+ expect(sent.binding.sessionID).toBe(first.sessionID);
436
+ const second = await settleSend(service, assistant.id, { ...sent.binding, messageID: 'stateless-2', parts: [{ type: 'text', text: 'two' }] });
437
+ expect(second.binding.sessionID).toBe(first.sessionID);
438
+ expect(creates).toBe(1);
439
+ expect(service.contactMessages(assistant.id).messages.map((message) => message.text)).toEqual([
440
+ 'one', 'reply:one', 'two', 'reply:two',
441
+ ]);
259
442
  service.close();
260
443
  });
261
444
 
262
- it('persists every stateless admission in Assistant SQLite before OpenCode history is available', async () => {
263
- let creates = 0; const directory = root(); const tips = [];
264
- const service = setup(directory, {
265
- create: async () => ({ data: { id: `ses_${++creates}` } }),
266
- promptAsync: async () => ({ response: { status: 204 } }),
267
- }, { onRevisionTip: (tip) => tips.push(tip) });
445
+ it('persists every contact admission in Assistant SQLite before a restart', async () => {
446
+ const directory = root(); const tips = [];
447
+ const service = setup(directory, {}, { onRevisionTip: (tip) => tips.push(tip) });
268
448
  const assistant = service.createAssistant({ ...assistantInput, mode: 'stateless' });
269
- const initial = await service.ensure(assistant.id);
270
- const first = await service.send(assistant.id, { ...initial, messageID: 'msg_stateless_1', parts: [{ type: 'text', text: 'one' }] });
271
- service.processEvent({ type: 'message.updated', properties: { info: { id: 'msg_stateless_1', sessionID: first.binding.sessionID, role: 'user', time: { created: 10 } } } });
272
- const second = await service.send(assistant.id, { ...first.binding, messageID: 'msg_stateless_2', parts: [{ type: 'text', text: 'two' }] });
273
- const page = await service.historicalMessages(assistant.id, { limit: 10 });
274
- expect(page.entries.map((entry) => [entry.sessionID, entry.info.id, entry.parts[0]?.text])).toEqual([
275
- [first.binding.sessionID, 'msg_stateless_1', 'one'],
276
- [second.binding.sessionID, 'msg_stateless_2', 'two'],
449
+ await settleSend(service, assistant.id, { messageID: 'msg_stateless_1', parts: [{ type: 'text', text: 'one' }] });
450
+ await settleSend(service, assistant.id, { messageID: 'msg_stateless_2', parts: [{ type: 'text', text: 'two' }] });
451
+ const page = service.contactMessages(assistant.id, { limit: 10 });
452
+ expect(page.messages.map((message) => [message.messageID, message.text])).toEqual([
453
+ ['msg_stateless_1', 'one'],
454
+ ['msg_stateless_1:bubble:1', 'reply:one'],
455
+ ['msg_stateless_2', 'two'],
456
+ ['msg_stateless_2:bubble:1', 'reply:two'],
277
457
  ]);
278
- expect(page.entries.every((entry) => entry.info.role === 'user')).toBe(true);
279
458
  await new Promise((resolve) => setImmediate(resolve));
280
459
  expect(tips.at(-1)?.revision).toBe(service.snapshot().revision);
281
460
  service.close();
282
461
 
283
462
  const restarted = setup(directory);
284
- expect((await restarted.historicalMessages(assistant.id, { limit: 10 })).entries.map((entry) => entry.info.id)).toEqual(['msg_stateless_1', 'msg_stateless_2']);
463
+ expect(restarted.contactMessages(assistant.id, { limit: 10 }).messages.map((message) => message.messageID)).toEqual([
464
+ 'msg_stateless_1',
465
+ 'msg_stateless_1:bubble:1',
466
+ 'msg_stateless_2',
467
+ 'msg_stateless_2:bubble:1',
468
+ ]);
285
469
  restarted.close();
286
470
  });
287
471
 
@@ -325,14 +509,14 @@ describe('assistants service', () => {
325
509
  });
326
510
 
327
511
  it('keeps continuous composer sends on the same binding', async () => {
328
- let creates = 0; const prompts = [];
329
- const service = setup(root(), { create: async () => ({ data: { id: `ses_${++creates}` } }), promptAsync: async (input) => { prompts.push(input); return { response: { status: 204 } }; } });
512
+ let creates = 0;
513
+ const service = setup(root(), { create: async () => ({ data: { id: `ses_${++creates}` } }) });
330
514
  const assistant = service.createAssistant({ ...assistantInput, mode: 'continuous' });
331
515
  const binding = await service.ensure(assistant.id);
332
- const sent = await service.send(assistant.id, { ...binding, messageID: 'continuous-1', parts: [{ type: 'text', text: 'hello' }] });
516
+ const sent = await settleSend(service, assistant.id, { ...binding, messageID: 'continuous-1', parts: [{ type: 'text', text: 'hello' }] });
333
517
  expect(sent.binding).toEqual(binding);
334
- expect(prompts[0]?.sessionID).toBe(binding.sessionID);
335
518
  expect(creates).toBe(1);
519
+ expect(service.contactMessages(assistant.id).messages.map((message) => message.text)).toEqual(['hello', 'reply:hello']);
336
520
  service.close();
337
521
  });
338
522
 
@@ -574,13 +758,23 @@ describe('assistants service', () => {
574
758
  const assistant = service.createAssistant({ ...assistantInput, mode: 'stateless' });
575
759
  const initial = await service.ensure(assistant.id);
576
760
  expect(initial.sessionID).toBe('ses_1');
577
- // Stateless send replaces the binding: execution lives on a fresh session.
578
- const first = await service.send(assistant.id, { ...initial, messageID: 'msg_user_1', parts: [{ type: 'text', text: 'one' }] });
761
+ // Contact send no longer replaces the OpenCode binding. Queued/share
762
+ // delivery still creates a fresh stateless execution session.
763
+ const scope = { sessionID: `assistant:${assistant.id}`, directory: initial.directory };
764
+ const first = await service.sendWithCapturedConfig({
765
+ deliveryTarget: service.captureQueueDeliveryTarget({ assistantID: assistant.id, scope }),
766
+ messageID: 'msg_user_1',
767
+ parts: [{ type: 'text', text: 'one' }],
768
+ });
579
769
  expect(first.binding.sessionID).toBe('ses_2');
580
770
  service.processEvent({ type: 'message.updated', properties: { info: { id: 'msg_user_1', sessionID: first.binding.sessionID, role: 'user', time: { created: 10 } } } });
581
771
  service.processEvent({ type: 'message.updated', properties: { info: { id: 'msg_reply_1', sessionID: first.binding.sessionID, role: 'assistant', time: { created: 20 } } } });
582
772
  service.processEvent({ type: 'message.part.updated', properties: { sessionID: first.binding.sessionID, part: { id: 'part_reply_1', sessionID: first.binding.sessionID, messageID: 'msg_reply_1', type: 'text', text: 'reply-one' } } });
583
- await service.send(assistant.id, { ...first.binding, messageID: 'msg_user_2', parts: [{ type: 'text', text: 'two' }] });
773
+ await service.sendWithCapturedConfig({
774
+ deliveryTarget: service.captureQueueDeliveryTarget({ assistantID: assistant.id, scope }),
775
+ messageID: 'msg_user_2',
776
+ parts: [{ type: 'text', text: 'two' }],
777
+ });
584
778
  const page = await service.historicalMessages(assistant.id, { limit: 10 });
585
779
  expect(page.entries.map((entry) => [entry.sessionID, entry.info.id, entry.info.role])).toEqual(expect.arrayContaining([
586
780
  [first.binding.sessionID, 'msg_user_1', 'user'],
@@ -645,6 +839,930 @@ describe('assistants service', () => {
645
839
  service.close();
646
840
  });
647
841
 
842
+ it('assigns a registered project session and persists the session card', async () => {
843
+ const directory = root();
844
+ const project = path.join(directory, 'app');
845
+ fs.mkdirSync(project, { recursive: true });
846
+ const creates = [];
847
+ const prompts = [];
848
+ const archives = [];
849
+ const service = setup(directory, {
850
+ create: async (input) => {
851
+ creates.push(input);
852
+ return { data: { id: `ses_${creates.length}` } };
853
+ },
854
+ promptAsync: async (input) => {
855
+ prompts.push(input);
856
+ return { response: { status: 204 } };
857
+ },
858
+ update: async (input) => {
859
+ archives.push(input);
860
+ return { data: { id: input.sessionID } };
861
+ },
862
+ }, {
863
+ runContactTurn: async ({ tools, userText }) => {
864
+ const assign = tools.find((tool) => tool.name === 'assign_session');
865
+ const result = await assign.execute('call_1', { prompt: userText, projectPath: project, title: 'Login' });
866
+ return {
867
+ text: 'Opened the login session.',
868
+ bubbles: ['Opened the login session.'],
869
+ cards: result.details.card ? [result.details.card] : [],
870
+ tools,
871
+ };
872
+ },
873
+ });
874
+ const assistant = service.createAssistant(assistantInput);
875
+ const sent = await settleSend(service, assistant.id, {
876
+ messageID: 'client_assign',
877
+ parts: [{ type: 'text', text: 'Fix login' }],
878
+ });
879
+ expect(sent.admitted).toBe(true);
880
+ expect(creates).toEqual([expect.objectContaining({
881
+ directory: fs.realpathSync(project),
882
+ title: 'Login',
883
+ })]);
884
+ expect(creates[0].title).not.toMatch(/^\[Assistant\]/);
885
+ expect(prompts).toEqual([expect.objectContaining({
886
+ sessionID: 'ses_1',
887
+ directory: fs.realpathSync(project),
888
+ parts: [{ type: 'text', text: 'Fix login' }],
889
+ })]);
890
+ expect(archives).toEqual([]);
891
+ const page = service.contactMessages(assistant.id);
892
+ expect(page.messages.map((message) => message.role)).toEqual(['user', 'assistant', 'assistant']);
893
+ expect(page.messages.some((message) => message.role === 'peer')).toBe(false);
894
+ expect(page.messages.at(-1).parts[0]).toMatchObject({
895
+ type: 'card',
896
+ cardType: 'session',
897
+ sessionID: 'ses_1',
898
+ directory: fs.realpathSync(project),
899
+ title: 'Login',
900
+ });
901
+ service.close();
902
+ });
903
+
904
+ it('creates another assistant from create_assistant and persists the assistant card', async () => {
905
+ const directory = root();
906
+ const service = setup(directory, {}, {
907
+ runContactTurn: async ({ tools }) => {
908
+ const create = tools.find((tool) => tool.name === 'create_assistant');
909
+ const result = await create.execute('call_1', { name: 'FlowQA', model: 'opencode-go/deepseek-v4-flash' });
910
+ return {
911
+ text: 'Created FlowQA.',
912
+ bubbles: ['Created FlowQA.'],
913
+ cards: result.details.card ? [result.details.card] : [],
914
+ tools,
915
+ };
916
+ },
917
+ });
918
+ const host = service.createAssistant(assistantInput);
919
+ await settleSend(service, host.id, {
920
+ messageID: 'client_create_assistant',
921
+ parts: [{ type: 'text', text: '建一个助理叫 FlowQA,模型 opencode-go/deepseek-v4-flash' }],
922
+ });
923
+ const created = service.snapshot().assistants.find((item) => item.name === 'FlowQA');
924
+ expect(created).toMatchObject({
925
+ name: 'FlowQA',
926
+ providerID: 'opencode-go',
927
+ modelID: 'deepseek-v4-flash',
928
+ mode: 'continuous',
929
+ });
930
+ const page = service.contactMessages(host.id);
931
+ expect(page.messages.some((message) => message.parts.some((part) => (
932
+ part.type === 'card' && part.cardType === 'assistant' && part.name === 'FlowQA' && part.assistantID === created.id
933
+ )))).toBe(true);
934
+ service.close();
935
+ });
936
+
937
+ it('creates a scheduled task from schedule_task and persists the schedule card', async () => {
938
+ const directory = root();
939
+ const upserts = [];
940
+ const service = setup(directory, {}, {
941
+ listProjects: async () => [{ id: 'proj_app', path: directory }],
942
+ upsertScheduledTask: async (projectID, task) => {
943
+ upserts.push({ projectID, task });
944
+ return {
945
+ created: true,
946
+ task: {
947
+ id: 'task_ping',
948
+ name: task.name,
949
+ schedule: task.schedule,
950
+ execution: task.execution,
951
+ },
952
+ tasks: [],
953
+ };
954
+ },
955
+ syncScheduledTaskProject: async () => true,
956
+ runContactTurn: async ({ tools }) => {
957
+ const schedule = tools.find((tool) => tool.name === 'schedule_task');
958
+ const result = await schedule.execute('call_1', {
959
+ name: 'Daily ping',
960
+ prompt: 'ping',
961
+ time: '18:00',
962
+ timezone: 'Asia/Shanghai',
963
+ });
964
+ return {
965
+ text: 'Scheduled the ping.',
966
+ bubbles: ['Scheduled the ping.'],
967
+ cards: result.details.card ? [result.details.card] : [],
968
+ tools,
969
+ };
970
+ },
971
+ });
972
+ const host = service.createAssistant(assistantInput);
973
+ await settleSend(service, host.id, {
974
+ messageID: 'client_schedule',
975
+ parts: [{ type: 'text', text: '每天 18:00 Asia/Shanghai 排一个 ping 定时任务' }],
976
+ });
977
+ expect(upserts).toEqual([expect.objectContaining({
978
+ projectID: 'proj_app',
979
+ task: expect.objectContaining({
980
+ name: 'Daily ping',
981
+ enabled: true,
982
+ schedule: expect.objectContaining({
983
+ kind: 'daily',
984
+ time: '18:00',
985
+ timezone: 'Asia/Shanghai',
986
+ }),
987
+ execution: expect.objectContaining({
988
+ prompt: 'ping',
989
+ providerID: 'p',
990
+ modelID: 'm',
991
+ }),
992
+ }),
993
+ })]);
994
+ const page = service.contactMessages(host.id);
995
+ expect(page.messages.some((message) => message.parts.some((part) => (
996
+ part.type === 'card' && part.cardType === 'schedule' && part.taskID === 'task_ping' && part.time === '18:00'
997
+ )))).toBe(true);
998
+ expect(service.snapshot().assistants[0].assignedSessionIDs).toEqual([]);
999
+ const owned = await service.listAssistantScheduledTasks(host.id);
1000
+ expect(owned.tasks).toEqual([expect.objectContaining({
1001
+ assistantID: host.id,
1002
+ projectID: 'proj_app',
1003
+ taskID: 'task_ping',
1004
+ task: null,
1005
+ })]);
1006
+ service.close();
1007
+ });
1008
+
1009
+ it('injects registered projects into the contact turn and assigns after a name match', async () => {
1010
+ const directory = root();
1011
+ const project = path.join(directory, 'sample-app');
1012
+ fs.mkdirSync(project, { recursive: true });
1013
+ const creates = [];
1014
+ const harness = [];
1015
+ const service = setup(directory, {
1016
+ create: async (input) => {
1017
+ creates.push(input);
1018
+ return { data: { id: 'ses_named' } };
1019
+ },
1020
+ promptAsync: async () => ({ response: { status: 204 } }),
1021
+ }, {
1022
+ listProjects: async () => [{ id: 'proj_yee', path: project, label: 'OpenChamber Yee' }],
1023
+ getAllowedRoots: () => [project, directory],
1024
+ runContactTurn: async (input) => {
1025
+ harness.push(input);
1026
+ expect(input.projects).toEqual([
1027
+ { id: 'proj_yee', path: project, label: 'OpenChamber Yee' },
1028
+ ]);
1029
+ expect(input.tools.map((tool) => tool.name)).toEqual(expect.arrayContaining([
1030
+ 'list_projects',
1031
+ 'list_sessions',
1032
+ 'assign_session',
1033
+ ]));
1034
+ const listed = await input.tools.find((tool) => tool.name === 'list_projects').execute('call_p', {
1035
+ query: 'openchamer yee',
1036
+ });
1037
+ expect(listed.details.projects).toEqual([
1038
+ { id: 'proj_yee', path: project, label: 'OpenChamber Yee' },
1039
+ ]);
1040
+ const assign = input.tools.find((tool) => tool.name === 'assign_session');
1041
+ const result = await assign.execute('call_a', {
1042
+ prompt: 'Start coding',
1043
+ projectPath: listed.details.projects[0].path,
1044
+ title: 'Yee work',
1045
+ });
1046
+ return {
1047
+ text: 'Opened on OpenChamber Yee.',
1048
+ bubbles: ['Opened on OpenChamber Yee.'],
1049
+ cards: result.details.card ? [result.details.card] : [],
1050
+ };
1051
+ },
1052
+ });
1053
+ const assistant = service.createAssistant(assistantInput);
1054
+ await settleSend(service, assistant.id, {
1055
+ messageID: 'client_named_project',
1056
+ parts: [{ type: 'text', text: '你看看 openchamer yee 项目,在那里开个新会话' }],
1057
+ });
1058
+ expect(harness).toHaveLength(1);
1059
+ expect(creates[0]).toMatchObject({
1060
+ directory: fs.realpathSync(project),
1061
+ title: 'Yee work',
1062
+ });
1063
+ service.close();
1064
+ });
1065
+
1066
+ it('list_sessions failure is not an empty success', async () => {
1067
+ const directory = root();
1068
+ const service = setup(directory, {}, {
1069
+ listProjects: async () => [{ id: 'proj_app', path: directory, label: 'App' }],
1070
+ sessionIndexService: {
1071
+ snapshot: () => {
1072
+ throw new Error('index down');
1073
+ },
1074
+ },
1075
+ runContactTurn: async ({ tools }) => {
1076
+ const list = tools.find((tool) => tool.name === 'list_sessions');
1077
+ const result = await list.execute('call_s', { projectPath: directory });
1078
+ return {
1079
+ text: result.content[0].text,
1080
+ bubbles: [result.content[0].text],
1081
+ cards: [],
1082
+ details: result.details,
1083
+ };
1084
+ },
1085
+ });
1086
+ const assistant = service.createAssistant(assistantInput);
1087
+ await settleSend(service, assistant.id, {
1088
+ messageID: 'client_list_sessions_fail',
1089
+ parts: [{ type: 'text', text: '现有对话' }],
1090
+ });
1091
+ const page = service.contactMessages(assistant.id);
1092
+ expect(page.messages.at(-1).text).toContain('index down');
1093
+ service.close();
1094
+ });
1095
+
1096
+ it('lists sessions from the session index for a project', async () => {
1097
+ const directory = root();
1098
+ const service = setup(directory, {}, {
1099
+ listProjects: async () => [{ id: 'proj_app', path: directory, label: 'App' }],
1100
+ sessionIndexService: {
1101
+ snapshot: () => ({
1102
+ directories: [
1103
+ {
1104
+ directory,
1105
+ sessions: [
1106
+ { id: 'ses_old', title: 'Old', time: { updated: 1 } },
1107
+ { id: 'ses_new', title: 'Login work', time: { updated: 9 } },
1108
+ ],
1109
+ },
1110
+ {
1111
+ directory: path.join(directory, 'other'),
1112
+ sessions: [{ id: 'ses_other', title: 'Other', time: { updated: 20 } }],
1113
+ },
1114
+ ],
1115
+ }),
1116
+ },
1117
+ runContactTurn: async ({ tools }) => {
1118
+ const list = tools.find((tool) => tool.name === 'list_sessions');
1119
+ const result = await list.execute('call_s', { projectPath: directory, query: 'login' });
1120
+ return {
1121
+ text: result.content[0].text,
1122
+ bubbles: [result.content[0].text],
1123
+ cards: [],
1124
+ details: result.details,
1125
+ };
1126
+ },
1127
+ });
1128
+ const assistant = service.createAssistant(assistantInput);
1129
+ await settleSend(service, assistant.id, {
1130
+ messageID: 'client_list_sessions_ok',
1131
+ parts: [{ type: 'text', text: '现有对话 login' }],
1132
+ });
1133
+ const page = service.contactMessages(assistant.id);
1134
+ expect(page.messages.at(-1).text).toContain('ses_new');
1135
+ expect(page.messages.at(-1).text).not.toContain('ses_other');
1136
+ service.close();
1137
+ });
1138
+
1139
+ it('keeps assistant scheduled-task mapping when live project lookup fails', async () => {
1140
+ const directory = root();
1141
+ let failLive = false;
1142
+ const service = setup(directory, {}, {
1143
+ listProjects: async () => {
1144
+ if (failLive) throw new Error('projects unavailable');
1145
+ return [{ id: 'proj_app', path: directory, label: 'App' }];
1146
+ },
1147
+ listScheduledTasks: async () => {
1148
+ if (failLive) throw new Error('tasks unavailable');
1149
+ return [{ id: 'task_ping', name: 'Daily ping', enabled: true }];
1150
+ },
1151
+ upsertScheduledTask: async (_projectID, task) => ({
1152
+ created: true,
1153
+ task: { id: 'task_ping', name: task.name, schedule: task.schedule, execution: task.execution },
1154
+ tasks: [],
1155
+ }),
1156
+ runContactTurn: async ({ tools }) => {
1157
+ const schedule = tools.find((tool) => tool.name === 'schedule_task');
1158
+ const result = await schedule.execute('call_1', {
1159
+ name: 'Daily ping',
1160
+ prompt: 'ping',
1161
+ time: '18:00',
1162
+ timezone: 'Asia/Shanghai',
1163
+ });
1164
+ return {
1165
+ text: 'Scheduled.',
1166
+ bubbles: ['Scheduled.'],
1167
+ cards: result.details.card ? [result.details.card] : [],
1168
+ };
1169
+ },
1170
+ });
1171
+ const host = service.createAssistant(assistantInput);
1172
+ await settleSend(service, host.id, {
1173
+ messageID: 'client_schedule_map',
1174
+ parts: [{ type: 'text', text: '排定时任务' }],
1175
+ });
1176
+ const live = await service.listAssistantScheduledTasks(host.id);
1177
+ expect(live.tasks[0]).toMatchObject({
1178
+ taskID: 'task_ping',
1179
+ projectLabel: 'App',
1180
+ task: expect.objectContaining({ id: 'task_ping', name: 'Daily ping' }),
1181
+ });
1182
+ failLive = true;
1183
+ const kept = await service.listAssistantScheduledTasks(host.id);
1184
+ expect(kept.tasks).toEqual([expect.objectContaining({
1185
+ taskID: 'task_ping',
1186
+ projectID: 'proj_app',
1187
+ projectPath: null,
1188
+ projectLabel: null,
1189
+ task: null,
1190
+ })]);
1191
+ service.close();
1192
+ });
1193
+
1194
+ it('appends assistant and schedule cards without creating session watches', () => {
1195
+ const directory = root();
1196
+ const service = setup(directory);
1197
+ const assistant = service.createAssistant(assistantInput);
1198
+ service.appendContactCard(assistant.id, {
1199
+ cardType: 'assistant',
1200
+ assistantID: 'asst_flow',
1201
+ name: 'FlowQA',
1202
+ providerID: 'opencode-go',
1203
+ modelID: 'deepseek-v4-flash',
1204
+ mode: 'continuous',
1205
+ });
1206
+ service.appendContactCard(assistant.id, {
1207
+ cardType: 'schedule',
1208
+ taskID: 'task_ping',
1209
+ projectID: 'proj_app',
1210
+ name: 'Daily ping',
1211
+ kind: 'daily',
1212
+ time: '18:00',
1213
+ timezone: 'Asia/Shanghai',
1214
+ prompt: 'ping',
1215
+ });
1216
+ expect(service.snapshot().assistants[0].assignedSessionIDs).toEqual([]);
1217
+ expect(service.snapshot().assistants[0].working).toBe(false);
1218
+ const page = service.contactMessages(assistant.id);
1219
+ expect(page.messages.map((message) => message.parts[0]?.cardType)).toEqual(['assistant', 'schedule']);
1220
+ service.close();
1221
+ });
1222
+
1223
+ it('fails assign clearly when no project is registered and does not use assistant-workspaces', async () => {
1224
+ const directory = root();
1225
+ const creates = [];
1226
+ const prompts = [];
1227
+ const service = setup(directory, {
1228
+ create: async (input) => {
1229
+ creates.push(input);
1230
+ return { data: { id: 'ses_should_not' } };
1231
+ },
1232
+ promptAsync: async (input) => {
1233
+ prompts.push(input);
1234
+ return { response: { status: 204 } };
1235
+ },
1236
+ }, {
1237
+ getAllowedRoots: () => [],
1238
+ runContactTurn: async ({ tools }) => {
1239
+ const assign = tools.find((tool) => tool.name === 'assign_session');
1240
+ const result = await assign.execute('call_1', { prompt: 'Fix login' });
1241
+ return {
1242
+ text: result.content[0].text,
1243
+ bubbles: [result.content[0].text],
1244
+ cards: result.details.card ? [result.details.card] : [],
1245
+ tools,
1246
+ };
1247
+ },
1248
+ });
1249
+ const assistant = service.createAssistant(assistantInput);
1250
+ await settleSend(service, assistant.id, {
1251
+ messageID: 'client_no_project',
1252
+ parts: [{ type: 'text', text: 'Fix login' }],
1253
+ });
1254
+ expect(creates).toEqual([]);
1255
+ expect(prompts).toEqual([]);
1256
+ const page = service.contactMessages(assistant.id);
1257
+ expect(page.messages.some((message) => message.parts.some((part) => part.type === 'card'))).toBe(false);
1258
+ expect(page.messages.at(-1).text).toContain('Add a project in Settings');
1259
+ expect(page.messages.at(-1).text).toContain('assistant-workspaces');
1260
+ expect(page.messages.some((message) => message.role === 'peer')).toBe(false);
1261
+ service.close();
1262
+ });
1263
+
1264
+ it('subscribes assigned sessions so idle updates the card, appends a settle message, and clears working', () => {
1265
+ const directory = root();
1266
+ const service = setup(directory);
1267
+ const assistant = service.createAssistant(assistantInput);
1268
+ service.appendContactCard(assistant.id, {
1269
+ cardType: 'session',
1270
+ sessionID: 'ses_work',
1271
+ directory,
1272
+ title: 'Login',
1273
+ status: 'busy',
1274
+ });
1275
+ expect(service.snapshot().assistants[0]).toMatchObject({
1276
+ assignedSessionIDs: ['ses_work'],
1277
+ working: true,
1278
+ });
1279
+ expect(service.processEvent({ type: 'session.idle', properties: { sessionID: 'ses_work' } })).toBe(true);
1280
+ const page = service.contactMessages(assistant.id);
1281
+ expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0]).toMatchObject({
1282
+ type: 'card',
1283
+ cardType: 'session',
1284
+ sessionID: 'ses_work',
1285
+ status: 'complete',
1286
+ });
1287
+ expect(page.messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(1);
1288
+ expect(service.snapshot().assistants[0]).toMatchObject({
1289
+ assignedSessionIDs: [],
1290
+ working: false,
1291
+ });
1292
+ expect(service.processEvent({ type: 'session.idle', properties: { sessionID: 'ses_work' } })).toBe(false);
1293
+ expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(1);
1294
+ service.close();
1295
+ });
1296
+
1297
+ it('reconciles a missed assigned-session idle on boot and the 60s timer', async () => {
1298
+ const directory = root();
1299
+ const first = setup(directory);
1300
+ const assistant = first.createAssistant(assistantInput);
1301
+ first.appendContactCard(assistant.id, {
1302
+ cardType: 'session',
1303
+ sessionID: 'ses_missed',
1304
+ directory,
1305
+ title: 'Login',
1306
+ status: 'busy',
1307
+ });
1308
+ expect(first.snapshot().assistants[0]).toMatchObject({
1309
+ assignedSessionIDs: ['ses_missed'],
1310
+ working: true,
1311
+ });
1312
+ first.close();
1313
+
1314
+ let tick;
1315
+ const service = setup(directory, {
1316
+ get: async ({ sessionID }) => (
1317
+ sessionID === 'ses_missed' || sessionID === 'ses_timer'
1318
+ ? { data: { id: sessionID, status: { type: 'idle' } } }
1319
+ : { data: { id: sessionID } }
1320
+ ),
1321
+ messages: async ({ sessionID }) => (
1322
+ sessionID === 'ses_missed' || sessionID === 'ses_timer'
1323
+ ? { data: [{ info: { id: `msg_${sessionID}`, role: 'assistant', time: { completed: 1 } } }] }
1324
+ : { data: [] }
1325
+ ),
1326
+ }, {
1327
+ setIntervalFn: (fn) => {
1328
+ tick = fn;
1329
+ return 1;
1330
+ },
1331
+ });
1332
+ await service.reconcile();
1333
+ let page = service.contactMessages(assistant.id);
1334
+ expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('complete');
1335
+ expect(page.messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(1);
1336
+ expect(service.snapshot().assistants[0]).toMatchObject({
1337
+ assignedSessionIDs: [],
1338
+ working: false,
1339
+ });
1340
+
1341
+ service.appendContactCard(assistant.id, {
1342
+ cardType: 'session',
1343
+ sessionID: 'ses_timer',
1344
+ directory,
1345
+ title: 'Follow-up',
1346
+ status: 'busy',
1347
+ });
1348
+ expect(service.snapshot().assistants[0].working).toBe(true);
1349
+ await tick();
1350
+ page = service.contactMessages(assistant.id);
1351
+ expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card' && part.sessionID === 'ses_timer'))?.parts[0].status).toBe('complete');
1352
+ expect(page.messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(2);
1353
+ expect(service.snapshot().assistants[0].working).toBe(false);
1354
+ service.close();
1355
+ });
1356
+
1357
+ it('reconciles a missing assigned session as complete and an assistant error as failed', async () => {
1358
+ const directory = root();
1359
+ const service = setup(directory, {
1360
+ get: async ({ sessionID }) => {
1361
+ if (sessionID === 'ses_gone') return { error: { status: 404 } };
1362
+ if (sessionID === 'ses_fail') return { data: { id: 'ses_fail' } };
1363
+ return { data: { id: sessionID } };
1364
+ },
1365
+ messages: async ({ sessionID }) => {
1366
+ if (sessionID === 'ses_fail') return { data: [{ info: { id: 'msg_fail', role: 'assistant', error: { message: 'boom' } } }] };
1367
+ return { data: [] };
1368
+ },
1369
+ });
1370
+ const assistant = service.createAssistant(assistantInput);
1371
+ service.appendContactCard(assistant.id, {
1372
+ cardType: 'session',
1373
+ sessionID: 'ses_gone',
1374
+ directory,
1375
+ title: 'Gone',
1376
+ status: 'busy',
1377
+ });
1378
+ service.appendContactCard(assistant.id, {
1379
+ cardType: 'session',
1380
+ sessionID: 'ses_fail',
1381
+ directory,
1382
+ title: 'Fail',
1383
+ status: 'busy',
1384
+ });
1385
+ await service.reconcile();
1386
+ const cards = service.contactMessages(assistant.id).messages
1387
+ .filter((message) => message.parts.some((part) => part.type === 'card'))
1388
+ .map((message) => message.parts[0]);
1389
+ expect(cards.find((card) => card.sessionID === 'ses_gone')?.status).toBe('complete');
1390
+ expect(cards.find((card) => card.sessionID === 'ses_fail')?.status).toBe('error');
1391
+ const texts = service.contactMessages(assistant.id).messages.map((message) => message.text);
1392
+ expect(texts.filter((text) => text === 'oc.settle.complete')).toHaveLength(1);
1393
+ expect(texts.filter((text) => text === 'oc.settle.error')).toHaveLength(1);
1394
+ expect(service.snapshot().assistants[0]).toMatchObject({
1395
+ assignedSessionIDs: [],
1396
+ working: false,
1397
+ });
1398
+ await service.reconcile();
1399
+ expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(1);
1400
+ expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.error')).toHaveLength(1);
1401
+ service.close();
1402
+ });
1403
+
1404
+ it('does not settle in-flight watches on fetch failure or while the session is still running', async () => {
1405
+ const directory = root();
1406
+ const service = setup(directory, {
1407
+ get: async ({ sessionID }) => {
1408
+ if (sessionID === 'ses_down') throw new Error('network');
1409
+ if (sessionID === 'ses_busy') return { data: { id: 'ses_busy', status: { type: 'busy' } } };
1410
+ if (sessionID === 'ses_idle') return { data: { id: 'ses_idle', status: { type: 'idle' } } };
1411
+ return { data: { id: sessionID } };
1412
+ },
1413
+ messages: async ({ sessionID }) => {
1414
+ if (sessionID === 'ses_idle') return { data: [{ info: { id: 'msg_idle', role: 'assistant', time: { completed: 1 } } }] };
1415
+ if (sessionID === 'ses_busy') return { data: [{ info: { id: 'msg_busy', role: 'assistant' } }] };
1416
+ throw new Error('should not settle from messages after get failure');
1417
+ },
1418
+ });
1419
+ const assistant = service.createAssistant(assistantInput);
1420
+ service.appendContactCard(assistant.id, {
1421
+ cardType: 'session',
1422
+ sessionID: 'ses_down',
1423
+ directory,
1424
+ title: 'Down',
1425
+ status: 'busy',
1426
+ });
1427
+ service.appendContactCard(assistant.id, {
1428
+ cardType: 'session',
1429
+ sessionID: 'ses_busy',
1430
+ directory,
1431
+ title: 'Busy',
1432
+ status: 'busy',
1433
+ });
1434
+ service.appendContactCard(assistant.id, {
1435
+ cardType: 'session',
1436
+ sessionID: 'ses_idle',
1437
+ directory,
1438
+ title: 'Idle',
1439
+ status: 'busy',
1440
+ });
1441
+ await service.reconcile();
1442
+ const cards = Object.fromEntries(service.contactMessages(assistant.id).messages
1443
+ .filter((message) => message.parts.some((part) => part.type === 'card'))
1444
+ .map((message) => [message.parts[0].sessionID, message.parts[0].status]));
1445
+ expect(cards).toMatchObject({
1446
+ ses_down: 'busy',
1447
+ ses_busy: 'busy',
1448
+ ses_idle: 'complete',
1449
+ });
1450
+ expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(1);
1451
+ expect(service.snapshot().assistants[0].assignedSessionIDs.sort()).toEqual(['ses_busy', 'ses_down']);
1452
+ expect(service.snapshot().assistants[0].working).toBe(true);
1453
+ service.close();
1454
+ });
1455
+
1456
+ it('does not rewrite a reconciled session.error as complete on a later idle poll', async () => {
1457
+ const directory = root();
1458
+ let idle = false;
1459
+ const service = setup(directory, {
1460
+ get: async ({ sessionID }) => (
1461
+ sessionID === 'ses_err'
1462
+ ? { data: idle ? { id: 'ses_err', status: { type: 'idle' } } : { id: 'ses_err', error: { message: 'boom' } } }
1463
+ : { data: { id: sessionID } }
1464
+ ),
1465
+ messages: async () => ({ data: [{ info: { id: 'msg_err', role: 'assistant', time: { completed: 1 } } }] }),
1466
+ });
1467
+ const assistant = service.createAssistant(assistantInput);
1468
+ service.appendContactCard(assistant.id, {
1469
+ cardType: 'session',
1470
+ sessionID: 'ses_err',
1471
+ directory,
1472
+ title: 'Err',
1473
+ status: 'busy',
1474
+ });
1475
+ await service.reconcile();
1476
+ expect(service.contactMessages(assistant.id).messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('error');
1477
+ expect(service.contactMessages(assistant.id).messages.some((message) => message.text === 'oc.settle.error')).toBe(true);
1478
+ idle = true;
1479
+ await service.reconcile();
1480
+ expect(service.contactMessages(assistant.id).messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('error');
1481
+ expect(service.contactMessages(assistant.id).messages.some((message) => message.text === 'oc.settle.complete')).toBe(false);
1482
+ expect(service.snapshot().assistants[0]).toMatchObject({
1483
+ assignedSessionIDs: [],
1484
+ working: false,
1485
+ });
1486
+ service.close();
1487
+ });
1488
+
1489
+ it('accepts a session-goal settle into the same card without mutating the worker', () => {
1490
+ const directory = root();
1491
+ const service = setup(directory);
1492
+ const assistant = service.createAssistant(assistantInput);
1493
+ service.appendContactCard(assistant.id, {
1494
+ cardType: 'session',
1495
+ sessionID: 'ses_goal',
1496
+ directory,
1497
+ title: 'Login',
1498
+ status: 'busy',
1499
+ });
1500
+ expect(service.reportAssignedSessionSettle('ses_goal', 'complete')).toBe(true);
1501
+ const page = service.contactMessages(assistant.id);
1502
+ expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('complete');
1503
+ expect(page.messages.some((message) => message.text === 'oc.settle.complete')).toBe(true);
1504
+ expect(service.snapshot().assistants[0].working).toBe(false);
1505
+ service.close();
1506
+ });
1507
+
1508
+ it('maps question and error onto the same card and does not rewrite error as complete', () => {
1509
+ const directory = root();
1510
+ const service = setup(directory);
1511
+ const assistant = service.createAssistant(assistantInput);
1512
+ service.appendContactCard(assistant.id, {
1513
+ cardType: 'session',
1514
+ sessionID: 'ses_ask',
1515
+ directory,
1516
+ title: 'Login',
1517
+ status: 'busy',
1518
+ });
1519
+ expect(service.processEvent({ type: 'question.asked', properties: { sessionID: 'ses_ask' } })).toBe(true);
1520
+ let page = service.contactMessages(assistant.id);
1521
+ expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('question');
1522
+ expect(page.messages.some((message) => message.text === 'oc.settle.question')).toBe(true);
1523
+ expect(service.snapshot().assistants[0]).toMatchObject({
1524
+ assignedSessionIDs: ['ses_ask'],
1525
+ working: false,
1526
+ });
1527
+ expect(service.processEvent({ type: 'session.error', properties: { sessionID: 'ses_ask' } })).toBe(true);
1528
+ page = service.contactMessages(assistant.id);
1529
+ expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('error');
1530
+ expect(page.messages.some((message) => message.text === 'oc.settle.error')).toBe(true);
1531
+ expect(service.processEvent({ type: 'session.idle', properties: { sessionID: 'ses_ask' } })).toBe(false);
1532
+ expect(service.processEvent({ type: 'session.status', properties: { sessionID: 'ses_ask', status: { type: 'idle' } } })).toBe(false);
1533
+ page = service.contactMessages(assistant.id);
1534
+ expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card'))?.parts[0].status).toBe('error');
1535
+ expect(page.messages.some((message) => message.text === 'oc.settle.complete')).toBe(false);
1536
+ expect(service.snapshot().assistants[0]).toMatchObject({
1537
+ assignedSessionIDs: [],
1538
+ working: false,
1539
+ });
1540
+ service.close();
1541
+ });
1542
+
1543
+ it('persists a session card in the contact transcript across reload', () => {
1544
+ const directory = root();
1545
+ const service = setup(directory);
1546
+ const assistant = service.createAssistant(assistantInput);
1547
+ const inserted = service.appendContactCard(assistant.id, {
1548
+ cardType: 'session',
1549
+ sessionID: 'ses_real',
1550
+ directory,
1551
+ title: 'Fix login',
1552
+ status: 'idle',
1553
+ });
1554
+ expect(inserted.card).toMatchObject({
1555
+ type: 'card',
1556
+ cardType: 'session',
1557
+ sessionID: 'ses_real',
1558
+ title: 'Fix login',
1559
+ });
1560
+ const page = service.contactMessages(assistant.id);
1561
+ expect(page.messages).toHaveLength(1);
1562
+ expect(page.messages[0].parts[0]).toMatchObject({
1563
+ type: 'card',
1564
+ cardType: 'session',
1565
+ sessionID: 'ses_real',
1566
+ title: 'Fix login',
1567
+ });
1568
+ service.close();
1569
+ });
1570
+
1571
+ it('sends a read-only peer message from message_assistant without promptAsync', async () => {
1572
+ const prompts = [];
1573
+ const directory = root();
1574
+ const service = setup(directory, { promptAsync: async (input) => { prompts.push(input); return { response: { status: 204 } }; } }, {
1575
+ runContactTurn: async ({ tools }) => {
1576
+ const message = tools.find((tool) => tool.name === 'message_assistant');
1577
+ const result = await message.execute('call_1', { to: 'PeerQA', text: 'hello-from-assistant 写好了' });
1578
+ return {
1579
+ text: result.content[0].text,
1580
+ bubbles: [result.content[0].text],
1581
+ cards: [],
1582
+ tools,
1583
+ };
1584
+ },
1585
+ });
1586
+ const sender = service.createAssistant({ ...assistantInput, name: 'DeepSeekQA' });
1587
+ const recipient = service.createAssistant({ ...assistantInput, name: 'PeerQA' });
1588
+ await settleSend(service, sender.id, {
1589
+ messageID: 'client_message_assistant',
1590
+ parts: [{ type: 'text', text: '给 PeerQA 说一声 hello-from-assistant 写好了' }],
1591
+ });
1592
+ expect(prompts).toEqual([]);
1593
+ expect(service.contactMessages(recipient.id).messages).toEqual([expect.objectContaining({
1594
+ role: 'peer',
1595
+ fromAssistantID: sender.id,
1596
+ fromAssistantName: 'DeepSeekQA',
1597
+ text: 'hello-from-assistant 写好了',
1598
+ })]);
1599
+ const senderPage = service.contactMessages(sender.id);
1600
+ expect(senderPage.messages.some((message) => message.role === 'user')).toBe(true);
1601
+ expect(senderPage.messages.some((message) => message.text.includes('Sent to PeerQA'))).toBe(true);
1602
+ expect(senderPage.messages.some((message) => message.parts.some((part) => part.type === 'card'))).toBe(false);
1603
+ service.close();
1604
+ });
1605
+
1606
+ it('delivers a read-only peer DM into the recipient contact transcript without OpenCode', async () => {
1607
+ const prompts = [];
1608
+ const directory = root();
1609
+ const service = setup(directory, { promptAsync: async (input) => { prompts.push(input); return { response: { status: 204 } }; } });
1610
+ const sender = service.createAssistant({ ...assistantInput, name: 'Sender' });
1611
+ const recipient = service.createAssistant({ ...assistantInput, name: 'Recipient' });
1612
+ const delivered = service.deliverPeerMessage(sender.id, {
1613
+ toAssistantID: recipient.id,
1614
+ text: 'Can you watch the login session?',
1615
+ });
1616
+ expect(delivered).toMatchObject({
1617
+ admitted: true,
1618
+ role: 'peer',
1619
+ fromAssistantID: sender.id,
1620
+ fromAssistantName: 'Sender',
1621
+ toAssistantID: recipient.id,
1622
+ });
1623
+ expect(prompts).toEqual([]);
1624
+ const page = service.contactMessages(recipient.id);
1625
+ expect(page.messages).toHaveLength(1);
1626
+ expect(page.messages[0]).toMatchObject({
1627
+ role: 'peer',
1628
+ fromAssistantID: sender.id,
1629
+ fromAssistantName: 'Sender',
1630
+ text: 'Can you watch the login session?',
1631
+ });
1632
+ expect(service.contactMessages(sender.id).messages).toEqual([]);
1633
+ service.close();
1634
+ });
1635
+
1636
+ it('rejects self-DMs and never treats peer inbox rows as composer turns', async () => {
1637
+ const service = setup();
1638
+ const sender = service.createAssistant({ ...assistantInput, name: 'Sender' });
1639
+ const recipient = service.createAssistant({ ...assistantInput, name: 'Recipient' });
1640
+ expect(() => service.deliverPeerMessage(sender.id, {
1641
+ toAssistantID: sender.id,
1642
+ text: 'loop',
1643
+ })).toThrow('validation_error');
1644
+ service.deliverPeerMessage(sender.id, { toAssistantID: recipient.id, text: 'ping' });
1645
+ const sent = await settleSend(service, recipient.id, {
1646
+ messageID: 'after-peer',
1647
+ parts: [{ type: 'text', text: 'hello' }],
1648
+ });
1649
+ expect(sent.admitted).toBe(true);
1650
+ const roles = service.contactMessages(recipient.id).messages.map((message) => message.role);
1651
+ expect(roles).toEqual(['peer', 'user', 'assistant']);
1652
+ service.close();
1653
+ });
1654
+
1655
+ it('notifies like a contact SMS when a contact turn completes', async () => {
1656
+ const onContactTurnComplete = vi.fn();
1657
+ const service = setup(root(), {}, {
1658
+ onContactTurnComplete,
1659
+ runContactTurn: async () => ({ text: '我去找一下', bubbles: ['我去找一下', 'Opened a coding session.'] }),
1660
+ });
1661
+ const assistant = service.createAssistant({ ...assistantInput, name: '大小白' });
1662
+ await settleSend(service, assistant.id, {
1663
+ messageID: 'notify_1',
1664
+ parts: [{ type: 'text', text: 'hi' }],
1665
+ });
1666
+ expect(onContactTurnComplete).toHaveBeenCalledWith({
1667
+ assistantID: assistant.id,
1668
+ name: '大小白',
1669
+ turnID: 'notify_1',
1670
+ status: 'complete',
1671
+ body: '我去找一下\nOpened a coding session.',
1672
+ });
1673
+ service.close();
1674
+ });
1675
+
1676
+ it('broadcasts contact-turn-start/end and bubble-delta without re-inserting the user row', async () => {
1677
+ const events = [];
1678
+ const directory = root();
1679
+ const service = setup(directory, {}, {
1680
+ onContactTurnEvent: (event) => events.push(event),
1681
+ runContactTurn: async ({ userText, onBubbleDelta }) => {
1682
+ if (typeof onBubbleDelta === 'function') {
1683
+ onBubbleDelta(0, `stream:${userText}`, false);
1684
+ onBubbleDelta(0, '', true);
1685
+ }
1686
+ return { text: `stream:${userText}`, bubbles: [`stream:${userText}`] };
1687
+ },
1688
+ });
1689
+ const assistant = service.createAssistant(assistantInput);
1690
+ const sent = await service.send(assistant.id, {
1691
+ messageID: 'turn_stream_1',
1692
+ parts: [{ type: 'text', text: 'hi' }],
1693
+ });
1694
+ expect(sent).toEqual({
1695
+ binding: expect.objectContaining({ sessionID: null, sessionGeneration: 0 }),
1696
+ messageID: 'turn_stream_1',
1697
+ admitted: true,
1698
+ });
1699
+ expect(sent).not.toHaveProperty('settled');
1700
+ const settled = await service.whenContactTurnSettled('turn_stream_1');
1701
+ expect(settled).toMatchObject({ status: 'complete' });
1702
+ await new Promise((resolve) => setImmediate(resolve));
1703
+ const types = events.map((event) => event.type);
1704
+ expect(types[0]).toBe('openchamber:contact-turn-start');
1705
+ expect(events[0].properties).toMatchObject({
1706
+ assistantID: assistant.id,
1707
+ turnID: 'turn_stream_1',
1708
+ messageID: 'turn_stream_1',
1709
+ occurredAt: expect.any(Number),
1710
+ });
1711
+ expect(events.some((event) => (
1712
+ event.type === 'openchamber:contact-bubble-delta'
1713
+ && event.properties?.bubbleIndex === 0
1714
+ && event.properties?.delta === 'stream:hi'
1715
+ && event.properties?.done === false
1716
+ ))).toBe(true);
1717
+ expect(types.at(-1)).toBe('openchamber:contact-turn-end');
1718
+ expect(events.at(-1).properties).toMatchObject({
1719
+ assistantID: assistant.id,
1720
+ turnID: 'turn_stream_1',
1721
+ status: 'complete',
1722
+ occurredAt: expect.any(Number),
1723
+ });
1724
+ const page = service.contactMessages(assistant.id);
1725
+ expect(page.messages.map((message) => message.messageID)).toEqual([
1726
+ 'turn_stream_1',
1727
+ 'turn_stream_1:bubble:1',
1728
+ ]);
1729
+ const db = new (require('better-sqlite3'))(path.join(directory, 'assistants.sqlite'));
1730
+ expect(db.prepare('SELECT COUNT(*) AS count FROM assistant_contact_message WHERE message_id=?').get('turn_stream_1').count).toBe(1);
1731
+ db.close();
1732
+ service.close();
1733
+ });
1734
+
1735
+ it('admits the user then broadcasts turn-end error when the harness fails', async () => {
1736
+ const events = [];
1737
+ const service = setup(root(), {}, {
1738
+ onContactTurnEvent: (event) => events.push(event),
1739
+ runContactTurn: async () => {
1740
+ const error = new Error('No connected model');
1741
+ error.code = 'no_provider';
1742
+ throw error;
1743
+ },
1744
+ });
1745
+ const assistant = service.createAssistant(assistantInput);
1746
+ const sent = await service.send(assistant.id, {
1747
+ messageID: 'client_no_provider',
1748
+ parts: [{ type: 'text', text: 'hello' }],
1749
+ });
1750
+ expect(sent).toMatchObject({ admitted: true, messageID: 'client_no_provider' });
1751
+ expect(service.contactMessages(assistant.id).messages.map((message) => message.role)).toEqual(['user']);
1752
+ const settled = await service.whenContactTurnSettled('client_no_provider');
1753
+ expect(settled).toMatchObject({ status: 'error', code: 'no_provider' });
1754
+ await new Promise((resolve) => setImmediate(resolve));
1755
+ expect(events.some((event) => event.type === 'openchamber:contact-turn-start')).toBe(true);
1756
+ expect(events.some((event) => (
1757
+ event.type === 'openchamber:contact-turn-end'
1758
+ && event.properties?.status === 'error'
1759
+ && event.properties?.turnID === 'client_no_provider'
1760
+ ))).toBe(true);
1761
+ // User row stays; no assistant bubble on failure.
1762
+ expect(service.contactMessages(assistant.id).messages.map((message) => message.role)).toEqual(['user']);
1763
+ service.close();
1764
+ });
1765
+
648
1766
  it('deletes message/part/backfill mirrors when an assistant is removed', async () => {
649
1767
  const directory = root(); let creates = 0;
650
1768
  const service = setup(directory, {