@openchambery/web 1.19.7-beta.9 → 1.19.8-beta.1

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 (203) hide show
  1. package/dist/assets/AgentAvatar-CX0a1rKE.js +1 -0
  2. package/dist/assets/AssistantView-DkXW9Fzq.js +2 -0
  3. package/dist/assets/DiagramView-C8MZY0fA.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-cxYw8-8G.js +2 -0
  8. package/dist/assets/MarkdownRendererImpl-B39nwiZX.js +7 -0
  9. package/dist/assets/MarkstreamRendererImpl-CdYqdejg.js +1 -0
  10. package/dist/assets/MarkstreamRendererImpl-CsNwy1T5.css +1 -0
  11. package/dist/assets/MobileDetailNavigation-D6ri-ePj.js +1 -0
  12. package/dist/assets/MobileShareBridge-HRTN71wg.js +4 -0
  13. package/dist/assets/MobileSurface-D2yHjnZw.js +2 -0
  14. package/dist/assets/{MultiRunWindow-Ohj6UbWj.js → MultiRunWindow-CTRgfYQD.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-ImI02YSp.js +25 -0
  18. package/dist/assets/{SettingsWindow-B4lzhAbO.js → SettingsWindow-DWo2lUaN.js} +1 -1
  19. package/dist/assets/TerminalView-BseZ5dR-.js +8 -0
  20. package/dist/assets/ToolOutputDialog-BIK3cPqo.js +10 -0
  21. package/dist/assets/appThemeRegistry-4_bwYAWt.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-m09MWmzE.js} +3 -3
  36. package/dist/assets/{input-BG3VWTIF.js → input-DFLpqjGR.js} +1 -1
  37. package/dist/assets/insertionBoundaries-B0fzEL22.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-BYX7_XVg.js +169 -0
  41. package/dist/assets/main-BpbNOMNf.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-CFBgCVoZ.js +2 -0
  45. package/dist/assets/mobile-DcZkxRBz.js +2 -0
  46. package/dist/assets/multirun-CBqJQgS2.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-Ct1qHZhU.js +3 -0
  53. package/dist/assets/{pt-BR-CrCc9hmt.js → pt-BR-DcpdXCf5.js} +1 -1
  54. package/dist/assets/renderElectronMiniChatApp-DxmxYdiE.js +2 -0
  55. package/dist/assets/renderMobileApp-XXw7Fnrr.js +28 -0
  56. package/dist/assets/runtimeConfig-CcIqxMFB.js +2 -0
  57. package/dist/assets/runtimeEndpointReset-CU1HhJfC.js +147 -0
  58. package/dist/assets/sessionChangeSummary-DOq8EN_H.js +1 -0
  59. package/dist/assets/sessionLookup-CNMNR-yh.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-CBLEH9it.js} +44 -39
  70. package/dist/assets/useAssistantUIStore-me6s63b2.js +1 -0
  71. package/dist/assets/useDesktopWindowControlsLayout-DMCmF610.js +1 -0
  72. package/dist/assets/useEffectiveDirectory-CMvw1bLA.js +1 -0
  73. package/dist/assets/useFontPreferences-BCOBlaAd.js +1 -0
  74. package/dist/assets/useMobileNavigationStore-Di9IWKfu.js +413 -0
  75. package/dist/assets/useSessionAutoCleanup-DMyMP_xI.js +1 -0
  76. package/dist/assets/useUIStore-CuaA8-Hp.js +1 -0
  77. package/dist/assets/useWorkerHighlightedLines-uNzY5rmU.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 +77 -4
  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 +764 -0
  99. package/server/lib/assistants/contact-tools.test.js +337 -0
  100. package/server/lib/assistants/contracts.js +20 -1
  101. package/server/lib/assistants/harness.js +575 -0
  102. package/server/lib/assistants/harness.test.js +697 -0
  103. package/server/lib/assistants/pi-tools.js +118 -0
  104. package/server/lib/assistants/pi-tools.test.js +114 -0
  105. package/server/lib/assistants/routes.js +44 -4
  106. package/server/lib/assistants/service.js +930 -17
  107. package/server/lib/assistants/service.test.js +1163 -45
  108. package/server/lib/event-stream/DOCUMENTATION.md +12 -1
  109. package/server/lib/event-stream/directory-ws-bridge.js +11 -2
  110. package/server/lib/event-stream/global-ws-bridge.js +19 -3
  111. package/server/lib/event-stream/protocol.js +11 -1
  112. package/server/lib/event-stream/reasoning-projection.js +425 -0
  113. package/server/lib/event-stream/reasoning-projection.test.js +300 -0
  114. package/server/lib/event-stream/runtime.js +10 -0
  115. package/server/lib/event-stream/runtime.test.js +53 -0
  116. package/server/lib/llm/DOCUMENTATION.md +84 -0
  117. package/server/lib/llm/catalog.js +100 -0
  118. package/server/lib/llm/catalog.test.js +78 -0
  119. package/server/lib/llm/completions.js +151 -0
  120. package/server/lib/llm/completions.test.js +127 -0
  121. package/server/lib/llm/generate.js +570 -0
  122. package/server/lib/llm/generate.test.js +459 -0
  123. package/server/lib/llm/routes.js +47 -0
  124. package/server/lib/llm/temp-directory.js +37 -0
  125. package/server/lib/notifications/APNS.md +19 -13
  126. package/server/lib/notifications/DOCUMENTATION.md +4 -3
  127. package/server/lib/notifications/runtime.js +148 -37
  128. package/server/lib/notifications/runtime.test.js +100 -0
  129. package/server/lib/opencode/DOCUMENTATION.md +2 -1
  130. package/server/lib/opencode/feature-routes-runtime.js +30 -0
  131. package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
  132. package/server/lib/opencode/proxy.js +103 -9
  133. package/server/lib/opencode/proxy.test.js +177 -1
  134. package/server/lib/opencode/settings-runtime.js +2 -2
  135. package/server/lib/relay/reasoning-projection.e2e.test.ts +529 -0
  136. package/server/lib/session-goal/DOCUMENTATION.md +8 -1
  137. package/server/lib/session-goal/runtime.js +20 -0
  138. package/server/lib/session-index/DOCUMENTATION.md +3 -1
  139. package/server/lib/session-index/service.js +10 -4
  140. package/server/lib/session-index/service.test.js +37 -0
  141. package/server/lib/session-title/runtime.js +1 -0
  142. package/server/lib/session-title/runtime.test.js +11 -0
  143. package/server/lib/session-turn-pages/DOCUMENTATION.md +2 -1
  144. package/server/lib/session-turn-pages/routes.js +22 -3
  145. package/server/lib/session-turn-pages/routes.test.js +43 -0
  146. package/server/lib/transcript-cache/DOCUMENTATION.md +2 -2
  147. package/server/lib/transcript-cache/routes.js +9 -2
  148. package/server/lib/transcript-cache/routes.test.js +32 -0
  149. package/dist/assets/AgentAvatar-Di8F4QJh.js +0 -1
  150. package/dist/assets/AssistantView-DSxVAAb_.js +0 -1
  151. package/dist/assets/ContextPanel-DgwEIdqR.js +0 -285
  152. package/dist/assets/DiagramView-BKQO61y8.js +0 -1
  153. package/dist/assets/FadeInOnReveal-CV2s6YN8.js +0 -1
  154. package/dist/assets/Icon-CLpn1mIz.js +0 -1
  155. package/dist/assets/MarkdownRendererImpl-CTgmghSh.js +0 -7
  156. package/dist/assets/MarkstreamRendererImpl-BKsc-avF.js +0 -1
  157. package/dist/assets/MarkstreamRendererImpl-CxeWpDCK.css +0 -1
  158. package/dist/assets/MobileDetailNavigation-BcA6rd8Q.js +0 -1
  159. package/dist/assets/MobileShareBridge-w6T2ZrsW.js +0 -4
  160. package/dist/assets/MobileSurface-CG1n7v0h.js +0 -2
  161. package/dist/assets/OnboardingScreen-D6rtE7rj.js +0 -1
  162. package/dist/assets/OverlayScrollbar-DlNZho3q.js +0 -2
  163. package/dist/assets/SettingsView-CiF3r83e.js +0 -25
  164. package/dist/assets/TerminalView-Ctx_Jt_b.js +0 -8
  165. package/dist/assets/ToolOutputDialog-Dv9_fUYl.js +0 -10
  166. package/dist/assets/appThemeRegistry-DnwUIQB9.js +0 -2
  167. package/dist/assets/clipboard-D35XSwID.js +0 -1
  168. package/dist/assets/desktop-CfLlmafK.js +0 -1
  169. package/dist/assets/desktopHosts-C1Ns3DUu.js +0 -1
  170. package/dist/assets/desktopRelayRestore-8pvI_dSc.js +0 -1
  171. package/dist/assets/fileQueries-DGHZqZHc.js +0 -1
  172. package/dist/assets/insertionBoundaries-hK4mQj4W.js +0 -4
  173. package/dist/assets/main-BmbZFoO4.js +0 -29
  174. package/dist/assets/main-BwNhn8ml.js +0 -2
  175. package/dist/assets/markdown-worker-y3ewaqAK.js +0 -4
  176. package/dist/assets/markdownTheme-SPe4jOwX.js +0 -2
  177. package/dist/assets/miniChat-BN89siC3.js +0 -2
  178. package/dist/assets/mobile-DE9rVo5D.js +0 -2
  179. package/dist/assets/multirun-BiEuyDq3.js +0 -4
  180. package/dist/assets/persistence-w-AiX2i4.js +0 -3
  181. package/dist/assets/platform-4eCmsdol.js +0 -2
  182. package/dist/assets/projectMeta-DSLKcH1a.js +0 -3
  183. package/dist/assets/radio-HD5AB2Qf.js +0 -1
  184. package/dist/assets/renderElectronMiniChatApp-f_UxpFjv.js +0 -2
  185. package/dist/assets/renderMobileApp-BAhCai6d.js +0 -28
  186. package/dist/assets/runtime-switch-BkpTTbEi.js +0 -1
  187. package/dist/assets/runtimeConfig-qG_oNBoa.js +0 -2
  188. package/dist/assets/runtimeEndpointReset-BDeKD3EM.js +0 -4
  189. package/dist/assets/shikiHighlight-BphLZnac.js +0 -1
  190. package/dist/assets/sortable-tabs-strip-CNDgyX0B.js +0 -1
  191. package/dist/assets/src-DZt1nWf5.css +0 -2
  192. package/dist/assets/terminalApi-CGtOtc8I.js +0 -1
  193. package/dist/assets/url-KeihbE0d.js +0 -1
  194. package/dist/assets/useAppFontEffects--0dwVTSm.js +0 -7
  195. package/dist/assets/useAssistantUIStore-I0VWrB5V.js +0 -1
  196. package/dist/assets/useDesktopWindowControlsLayout-DsbeBLvv.js +0 -1
  197. package/dist/assets/useEffectiveDirectory-CHvvohcG.js +0 -1
  198. package/dist/assets/useFontPreferences-BLZ3y6mN.js +0 -1
  199. package/dist/assets/useMobileNavigationStore-peTDuC00.js +0 -412
  200. package/dist/assets/useSessionAutoCleanup-D_ZC9E5g.js +0 -1
  201. package/dist/assets/useUIStore-CNLmCoPU.js +0 -1
  202. package/dist/assets/useWorkerHighlightedLines-B1YzuMYR.js +0 -7
  203. package/dist/assets/utils-CTL16t2f.js +0 -1
@@ -0,0 +1,697 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import {
6
+ CREATE_ASSISTANT_TOOL_NAME,
7
+ MESSAGE_ASSISTANT_TOOL_NAME,
8
+ MISSED_FENCE_RETRY_USER_TEXT,
9
+ MISSED_TOOL_FAILURE_BUBBLE,
10
+ NEW_CONVERSATION_CONFIRM_BUBBLE,
11
+ NEW_CONVERSATION_TOOL_NAME,
12
+ createContactTools,
13
+ } from './contact-tools.js'
14
+ import { createContactStreamFn, runContactTurn } from './harness.js'
15
+ import { PI_CODING_TOOL_NAMES } from './pi-tools.js'
16
+
17
+ describe('createContactStreamFn', () => {
18
+ it('forwards completion text as pi-ai text events', async () => {
19
+ const createChatCompletion = vi.fn(async () => ({
20
+ completion: { choices: [{ message: { content: 'Hi' } }] },
21
+ }))
22
+ const streamFn = createContactStreamFn(createChatCompletion)
23
+ const events = []
24
+ const stream = streamFn(
25
+ { name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
26
+ { messages: [{ role: 'user', content: 'hello', timestamp: 1 }] },
27
+ )
28
+ for await (const event of stream) events.push(event)
29
+ expect(createChatCompletion).toHaveBeenCalledWith(expect.objectContaining({
30
+ body: expect.objectContaining({
31
+ model: expect.any(String),
32
+ messages: expect.any(Array),
33
+ }),
34
+ }))
35
+ expect(createChatCompletion.mock.calls[0][0].body.stream).toBeUndefined()
36
+ expect(events.some((event) => event.type === 'text_delta' && event.delta === 'Hi')).toBe(true)
37
+ expect(events.at(-1).type).toBe('done')
38
+ expect(typeof stream.result).toBe('function')
39
+ await expect(stream.result()).resolves.toMatchObject({ stopReason: 'stop' })
40
+ })
41
+
42
+ it('forwards contact file parts on the last user completion message', async () => {
43
+ const createChatCompletion = vi.fn(async () => ({
44
+ completion: { choices: [{ message: { content: 'saw the image' } }] },
45
+ }))
46
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
47
+ const file = { type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==', filename: 'notes.txt' }
48
+ const streamFn = createContactStreamFn(createChatCompletion, { pendingFileParts: [image, file] })
49
+ const stream = streamFn(
50
+ { name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
51
+ { messages: [{ role: 'user', content: 'look', timestamp: 1 }] },
52
+ )
53
+ for await (const event of stream) void event
54
+ expect(createChatCompletion.mock.calls[0][0].body.messages.at(-1)).toEqual({
55
+ role: 'user',
56
+ content: 'look',
57
+ parts: [image, file],
58
+ })
59
+ })
60
+
61
+ it('keeps thinking off and never installs bash/edit/read/write when sending attachments', async () => {
62
+ const prompt = vi.fn(async function prompt() {
63
+ this.state.messages = [{
64
+ role: 'assistant',
65
+ content: [{ type: 'text', text: 'Got it.' }],
66
+ }]
67
+ })
68
+ function AgentImpl(options) {
69
+ expect(options.initialState.thinkingLevel).toBe('off')
70
+ expect(options.initialState.tools).toEqual([])
71
+ this.state = { ...options.initialState, messages: [] }
72
+ this.prompt = prompt
73
+ }
74
+ const result = await runContactTurn({
75
+ assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
76
+ history: [],
77
+ userText: '[attachment]',
78
+ userParts: [{ type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }],
79
+ createChatCompletion: vi.fn(),
80
+ AgentImpl,
81
+ })
82
+ expect(result.thinkingLevel).toBe('off')
83
+ expect(result.tools).toEqual([])
84
+ expect(prompt).toHaveBeenCalledWith('[attachment]')
85
+ })
86
+
87
+ it('replays an assign_session fence as a toolUse burst, not token SSE', async () => {
88
+ const createChatCompletion = vi.fn(async () => ({
89
+ completion: {
90
+ choices: [{
91
+ message: {
92
+ content: 'On it.\n\n```openchamber-tool\n{"name":"assign_session","arguments":{"prompt":"Fix login"}}\n```',
93
+ },
94
+ }],
95
+ },
96
+ }))
97
+ const streamFn = createContactStreamFn(createChatCompletion)
98
+ const events = []
99
+ const stream = streamFn(
100
+ { name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
101
+ {
102
+ messages: [{ role: 'user', content: 'assign login', timestamp: 1 }],
103
+ tools: [{ name: 'assign_session' }],
104
+ },
105
+ )
106
+ for await (const event of stream) events.push(event)
107
+ expect(events.some((event) => event.type === 'toolcall_end' && event.toolCall?.name === 'assign_session')).toBe(true)
108
+ expect(events.at(-1)).toMatchObject({ type: 'done', reason: 'toolUse' })
109
+ expect(createChatCompletion.mock.calls[0][0].body.stream).toBeUndefined()
110
+ })
111
+
112
+ it('replays a bare {name, arguments} object as a toolUse burst', async () => {
113
+ const createChatCompletion = vi.fn(async () => ({
114
+ completion: {
115
+ choices: [{
116
+ message: {
117
+ content: '好的 {"name":"create_assistant","arguments":{"name":"FlowNL"}}',
118
+ },
119
+ }],
120
+ },
121
+ }))
122
+ const streamFn = createContactStreamFn(createChatCompletion)
123
+ const events = []
124
+ const stream = streamFn(
125
+ { name: 'opencode-go/deepseek-v4-flash', id: 'deepseek-v4-flash', provider: 'openchamber', api: 'openai-completions' },
126
+ {
127
+ messages: [{ role: 'user', content: '新建助理 FlowNL', timestamp: 1 }],
128
+ tools: [{ name: 'create_assistant' }],
129
+ },
130
+ )
131
+ for await (const event of stream) events.push(event)
132
+ expect(events.some((event) => event.type === 'toolcall_end' && event.toolCall?.name === 'create_assistant')).toBe(true)
133
+ expect(events.at(-1)).toMatchObject({ type: 'done', reason: 'toolUse' })
134
+ })
135
+
136
+ it('passes onTextDelta and globalEventHub into createChatCompletion and forwards real deltas', async () => {
137
+ const deltas = []
138
+ const bubbleDeltas = []
139
+ const hub = { subscribeEvent: vi.fn() }
140
+ const createChatCompletion = vi.fn(async ({ onTextDelta }) => {
141
+ onTextDelta?.('Hello')
142
+ onTextDelta?.(' world')
143
+ return { completion: { choices: [{ message: { content: 'Hello world' } }] } }
144
+ })
145
+ const streamFn = createContactStreamFn(createChatCompletion, {
146
+ onTextDelta: (delta) => deltas.push(delta),
147
+ onBubbleDelta: (index, delta, done) => bubbleDeltas.push({ index, delta, done }),
148
+ globalEventHub: hub,
149
+ })
150
+ const stream = streamFn(
151
+ { name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
152
+ { messages: [{ role: 'user', content: 'hi', timestamp: 1 }] },
153
+ )
154
+ for await (const event of stream) void event
155
+ expect(createChatCompletion.mock.calls[0][0].globalEventHub).toBe(hub)
156
+ expect(typeof createChatCompletion.mock.calls[0][0].onTextDelta).toBe('function')
157
+ expect(deltas).toEqual(['Hello', ' world'])
158
+ expect(bubbleDeltas.some((item) => item.done === false)).toBe(false)
159
+ expect(bubbleDeltas).toEqual([{ index: 0, delta: 'Hello world', done: true }])
160
+ })
161
+
162
+ it('stops bubble deltas at a fence start and does not leak tool JSON', async () => {
163
+ const bubbleDeltas = []
164
+ const createChatCompletion = vi.fn(async ({ onTextDelta }) => {
165
+ onTextDelta?.('On it.\n\n')
166
+ onTextDelta?.('```openchamber-tool\n{"name":"assign_session","arguments":{"prompt":"x"}}\n```')
167
+ return {
168
+ completion: {
169
+ choices: [{
170
+ message: {
171
+ content: 'On it.\n\n```openchamber-tool\n{"name":"assign_session","arguments":{"prompt":"x"}}\n```',
172
+ },
173
+ }],
174
+ },
175
+ }
176
+ })
177
+ const streamFn = createContactStreamFn(createChatCompletion, {
178
+ onBubbleDelta: (index, delta, done) => bubbleDeltas.push({ index, delta, done }),
179
+ })
180
+ const stream = streamFn(
181
+ { name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
182
+ {
183
+ messages: [{ role: 'user', content: 'assign', timestamp: 1 }],
184
+ tools: [{ name: 'assign_session' }],
185
+ },
186
+ )
187
+ for await (const event of stream) void event
188
+ const leaked = bubbleDeltas.some((item) => item.delta.includes('openchamber-tool') || item.delta.includes('assign_session'))
189
+ expect(leaked).toBe(false)
190
+ expect(bubbleDeltas).toEqual([{ index: 0, delta: 'On it.', done: true }])
191
+ })
192
+
193
+ it('emits final stripped bubbles as done:true once when no live deltas arrive', async () => {
194
+ const bubbleDeltas = []
195
+ const createChatCompletion = vi.fn(async () => ({
196
+ completion: { choices: [{ message: { content: 'One.\n\nTwo.' } }] },
197
+ }))
198
+ const streamFn = createContactStreamFn(createChatCompletion, {
199
+ onBubbleDelta: (index, delta, done) => bubbleDeltas.push({ index, delta, done }),
200
+ })
201
+ const stream = streamFn(
202
+ { name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
203
+ { messages: [{ role: 'user', content: 'hi', timestamp: 1 }] },
204
+ )
205
+ for await (const event of stream) void event
206
+ expect(bubbleDeltas).toEqual([
207
+ { index: 0, delta: 'One.', done: true },
208
+ { index: 1, delta: 'Two.', done: true },
209
+ ])
210
+ })
211
+ })
212
+
213
+ describe('runContactTurn', () => {
214
+ it('runs pi-agent-core with thinking off and no tools by default', async () => {
215
+ const prompt = vi.fn(async function prompt() {
216
+ this.state.messages = [{
217
+ role: 'assistant',
218
+ content: [{ type: 'text', text: 'Hey.\n\nI can open that session.' }],
219
+ }]
220
+ })
221
+ function AgentImpl(options) {
222
+ expect(options.initialState.thinkingLevel).toBe('off')
223
+ expect(options.initialState.tools).toEqual([])
224
+ this.state = { ...options.initialState, messages: [] }
225
+ this.prompt = prompt
226
+ }
227
+
228
+ const result = await runContactTurn({
229
+ assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
230
+ history: [],
231
+ userText: 'open login',
232
+ createChatCompletion: vi.fn(),
233
+ AgentImpl,
234
+ })
235
+ expect(result.bubbles).toEqual(['Hey.', 'I can open that session.'])
236
+ expect(prompt).toHaveBeenCalled()
237
+ })
238
+
239
+ it('applies workspace, defaultPrompt, pi tools, and merged .agents/.claude skills', async () => {
240
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-assistant-ws-'))
241
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-assistant-home-'))
242
+ const skill = path.join(workspace, '.agents', 'skills', 'project-skill')
243
+ const claude = path.join(workspace, '.claude', 'skills', 'claude-skill')
244
+ const globalAgents = path.join(home, '.agents', 'skills', 'global-skill')
245
+ fs.mkdirSync(skill, { recursive: true })
246
+ fs.mkdirSync(claude, { recursive: true })
247
+ fs.mkdirSync(globalAgents, { recursive: true })
248
+ fs.writeFileSync(path.join(skill, 'SKILL.md'), '---\nname: project-skill\ndescription: Project skill\n---\nDo the project thing\n')
249
+ fs.writeFileSync(path.join(claude, 'SKILL.md'), '---\nname: claude-skill\ndescription: Claude project skill\n---\nDo the claude thing\n')
250
+ fs.writeFileSync(path.join(globalAgents, 'SKILL.md'), '---\nname: global-skill\ndescription: Global agents skill\n---\nDo the global thing\n')
251
+ function AgentImpl(options) {
252
+ expect(options.initialState.thinkingLevel).toBe('off')
253
+ expect(options.initialState.tools.map((tool) => tool.name)).toEqual([...PI_CODING_TOOL_NAMES])
254
+ expect(options.initialState.systemPrompt).toContain(workspace)
255
+ expect(options.initialState.systemPrompt).toContain('Be terse.')
256
+ expect(options.initialState.systemPrompt).toContain('project-skill')
257
+ expect(options.initialState.systemPrompt).toContain('claude-skill')
258
+ expect(options.initialState.systemPrompt).toContain('global-skill')
259
+ expect(options.initialState.systemPrompt).toContain('Never say you have no terminal')
260
+ this.state = { ...options.initialState, messages: [{ role: 'assistant', content: [{ type: 'text', text: 'Ready.' }] }] }
261
+ this.prompt = async () => {}
262
+ }
263
+ try {
264
+ const result = await runContactTurn({
265
+ assistant: {
266
+ providerID: 'openai',
267
+ modelID: 'gpt-5.2',
268
+ defaultPrompt: 'Be terse.',
269
+ effectiveWorkspacePath: workspace,
270
+ },
271
+ history: [],
272
+ userText: 'pwd',
273
+ createChatCompletion: vi.fn(),
274
+ skillHomeDir: home,
275
+ AgentImpl,
276
+ })
277
+ expect(result.bubbles).toEqual(['Ready.'])
278
+ } finally {
279
+ fs.rmSync(workspace, { recursive: true, force: true })
280
+ fs.rmSync(home, { recursive: true, force: true })
281
+ }
282
+ })
283
+
284
+ it('executes bash pwd in the assistant workspace', async () => {
285
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-assistant-pwd-'))
286
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-assistant-pwd-home-'))
287
+ let calls = 0
288
+ const createChatCompletion = vi.fn(async ({ body }) => {
289
+ calls += 1
290
+ if (calls === 1) {
291
+ return {
292
+ completion: {
293
+ choices: [{
294
+ message: { content: '```openchamber-tool\n{"name":"bash","arguments":{"command":"pwd"}}\n```' },
295
+ }],
296
+ },
297
+ }
298
+ }
299
+ const last = body.messages.at(-1)
300
+ const content = typeof last?.content === 'string' ? last.content : ''
301
+ return { completion: { choices: [{ message: { content: content || 'done' } }] } }
302
+ })
303
+ try {
304
+ const result = await runContactTurn({
305
+ assistant: {
306
+ providerID: 'openai',
307
+ modelID: 'gpt-5.2',
308
+ defaultPrompt: '',
309
+ effectiveWorkspacePath: workspace,
310
+ },
311
+ history: [],
312
+ userText: 'pwd',
313
+ createChatCompletion,
314
+ skillHomeDir: home,
315
+ })
316
+ const text = result.bubbles.join('\n')
317
+ const resolved = fs.realpathSync(workspace)
318
+ expect(text.includes(workspace) || text.includes(resolved)).toBe(true)
319
+ } finally {
320
+ fs.rmSync(workspace, { recursive: true, force: true })
321
+ fs.rmSync(home, { recursive: true, force: true })
322
+ }
323
+ })
324
+
325
+ it('attaches OpenChamber tools only and collects session cards from tool results', async () => {
326
+ const assignTool = {
327
+ name: 'assign_session',
328
+ label: 'Assign session',
329
+ description: 'Open a worker session',
330
+ parameters: {},
331
+ execute: vi.fn(),
332
+ }
333
+ function AgentImpl(options) {
334
+ expect(options.initialState.thinkingLevel).toBe('off')
335
+ expect(options.initialState.tools.map((tool) => tool.name)).toEqual(['assign_session'])
336
+ expect(options.initialState.tools.some((tool) => ['bash', 'edit', 'read', 'write'].includes(tool.name))).toBe(false)
337
+ expect(options.initialState.systemPrompt).toContain('assign_session')
338
+ expect(options.initialState.systemPrompt).toContain('create_assistant')
339
+ expect(options.initialState.systemPrompt).toContain('message_assistant')
340
+ expect(options.initialState.systemPrompt).toContain('new_conversation')
341
+ expect(options.initialState.systemPrompt).toContain('list_projects')
342
+ expect(options.initialState.systemPrompt).toContain('list_sessions')
343
+ expect(options.initialState.systemPrompt).toContain('建助理')
344
+ expect(options.initialState.systemPrompt).toContain('开新对话')
345
+ expect(options.initialState.systemPrompt).toContain('说一声')
346
+ expect(options.initialState.systemPrompt).toContain('A reply without the tool call does nothing')
347
+ expect(options.initialState.systemPrompt).toContain('已创建')
348
+ expect(options.initialState.systemPrompt).toContain('cannot see the registered project list')
349
+ this.state = { ...options.initialState, messages: [] }
350
+ this.prompt = async () => {
351
+ this.state.messages = [
352
+ { role: 'assistant', content: [{ type: 'text', text: 'Opening that.' }] },
353
+ {
354
+ role: 'toolResult',
355
+ toolName: 'assign_session',
356
+ content: [{ type: 'text', text: 'opened' }],
357
+ details: {
358
+ card: {
359
+ type: 'card',
360
+ cardType: 'session',
361
+ sessionID: 'ses_1',
362
+ directory: '/repo',
363
+ title: 'Login',
364
+ status: 'busy',
365
+ },
366
+ },
367
+ },
368
+ ]
369
+ }
370
+ }
371
+
372
+ const result = await runContactTurn({
373
+ assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
374
+ history: [],
375
+ userText: 'assign login',
376
+ createChatCompletion: vi.fn(),
377
+ tools: [assignTool, { name: 'bash', execute: vi.fn() }],
378
+ projects: [{ id: 'proj_yee', path: '/repo/sample-app', label: 'OpenChamber Yee' }],
379
+ AgentImpl,
380
+ })
381
+ expect(result.bubbles).toEqual(['opened'])
382
+ expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_1', cardType: 'session' })])
383
+ expect(result.thinkingLevel).toBe('off')
384
+ })
385
+
386
+ it('does not persist pre-tool planning as contact bubbles', async () => {
387
+ function AgentImpl(options) {
388
+ this.state = { ...options.initialState, messages: [] }
389
+ this.prompt = async () => {
390
+ this.state.messages = [
391
+ {
392
+ role: 'assistant',
393
+ content: [
394
+ { type: 'text', text: 'Let me think — I should match openchamber yee and call assign_session.' },
395
+ { type: 'toolCall', id: 'call_1', name: 'assign_session', arguments: { prompt: 'x', projectPath: '/repo' } },
396
+ ],
397
+ },
398
+ {
399
+ role: 'toolResult',
400
+ toolName: 'assign_session',
401
+ content: [{ type: 'text', text: 'Opened a coding session.' }],
402
+ details: {
403
+ card: {
404
+ type: 'card',
405
+ cardType: 'session',
406
+ sessionID: 'ses_plan',
407
+ directory: '/repo',
408
+ title: 'Work',
409
+ status: 'busy',
410
+ },
411
+ },
412
+ },
413
+ ]
414
+ }
415
+ }
416
+ const result = await runContactTurn({
417
+ assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
418
+ history: [],
419
+ userText: 'assign login',
420
+ createChatCompletion: vi.fn(),
421
+ tools: [{ name: 'assign_session', execute: vi.fn() }],
422
+ AgentImpl,
423
+ })
424
+ expect(result.bubbles.join('\n')).not.toMatch(/Let me think|assign_session/)
425
+ expect(result.bubbles).toEqual(['Opened a coding session.'])
426
+ expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_plan' })])
427
+ })
428
+
429
+ it('keeps a short spoken preamble then the tool confirm', async () => {
430
+ function AgentImpl(options) {
431
+ this.state = { ...options.initialState, messages: [] }
432
+ this.prompt = async () => {
433
+ this.state.messages = [
434
+ {
435
+ role: 'assistant',
436
+ content: [
437
+ { type: 'text', text: '我去找一下' },
438
+ { type: 'toolCall', id: 'call_1', name: 'assign_session', arguments: { prompt: 'x' } },
439
+ ],
440
+ },
441
+ {
442
+ role: 'toolResult',
443
+ toolName: 'assign_session',
444
+ content: [{ type: 'text', text: 'Opened a coding session.' }],
445
+ details: {
446
+ card: {
447
+ type: 'card',
448
+ cardType: 'session',
449
+ sessionID: 'ses_speak',
450
+ directory: '/repo',
451
+ title: 'Work',
452
+ status: 'busy',
453
+ },
454
+ },
455
+ },
456
+ ]
457
+ }
458
+ }
459
+ const result = await runContactTurn({
460
+ assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
461
+ history: [],
462
+ userText: 'assign login',
463
+ createChatCompletion: vi.fn(),
464
+ tools: [{ name: 'assign_session', execute: vi.fn() }],
465
+ AgentImpl,
466
+ })
467
+ expect(result.bubbles).toEqual(['我去找一下', 'Opened a coding session.'])
468
+ expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_speak' })])
469
+ })
470
+
471
+ it('injects the registered projects catalog into the system prompt every turn', async () => {
472
+ function AgentImpl(options) {
473
+ expect(options.initialState.systemPrompt).toContain('Registered projects')
474
+ expect(options.initialState.systemPrompt).toContain('OpenChamber Yee')
475
+ expect(options.initialState.systemPrompt).toContain('/repo/sample-app')
476
+ expect(options.initialState.systemPrompt).toContain('never say you cannot see registered projects')
477
+ this.state = { ...options.initialState, messages: [{ role: 'assistant', content: [{ type: 'text', text: 'Found it.' }] }] }
478
+ this.prompt = async () => {}
479
+ }
480
+ const result = await runContactTurn({
481
+ assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
482
+ history: [],
483
+ userText: '找 openchamber yee',
484
+ createChatCompletion: vi.fn(),
485
+ projects: [{ id: 'proj_yee', path: '/repo/sample-app', label: 'OpenChamber Yee' }],
486
+ AgentImpl,
487
+ })
488
+ expect(result.bubbles).toEqual(['Found it.'])
489
+ })
490
+
491
+ it('retries a missed fence once and then executes create_assistant', async () => {
492
+ const createAssistant = vi.fn(async (input) => ({
493
+ id: 'asst_flow',
494
+ name: input.name,
495
+ providerID: input.providerID,
496
+ modelID: input.modelID,
497
+ mode: 'continuous',
498
+ }))
499
+ const tools = createContactTools({
500
+ createAssistant,
501
+ currentAssistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash' },
502
+ })
503
+ const prompts = []
504
+ function AgentImpl(options) {
505
+ expect(options.initialState.thinkingLevel).toBe('off')
506
+ expect(options.initialState.tools.some((tool) => ['bash', 'edit', 'read', 'write'].includes(tool.name))).toBe(false)
507
+ this.state = { ...options.initialState, messages: [] }
508
+ this.prompt = async (text) => {
509
+ prompts.push(text)
510
+ this.state.messages.push({ role: 'user', content: text, timestamp: Date.now() })
511
+ if (text === MISSED_FENCE_RETRY_USER_TEXT) {
512
+ const tool = this.state.tools.find((item) => item.name === CREATE_ASSISTANT_TOOL_NAME)
513
+ const result = await tool.execute('call_retry', { name: 'FlowNL', model: 'opencode-go/deepseek-v4-flash' })
514
+ this.state.messages.push(
515
+ { role: 'assistant', content: [{ type: 'text', text: '' }] },
516
+ {
517
+ role: 'toolResult',
518
+ toolName: CREATE_ASSISTANT_TOOL_NAME,
519
+ content: result.content,
520
+ details: result.details,
521
+ },
522
+ )
523
+ return
524
+ }
525
+ this.state.messages.push({
526
+ role: 'assistant',
527
+ content: [{ type: 'text', text: '好的,我来直接创建这个助理,不开编码会话。' }],
528
+ })
529
+ }
530
+ }
531
+
532
+ const result = await runContactTurn({
533
+ assistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', defaultPrompt: '' },
534
+ history: [],
535
+ userText: '帮我新建一个助理,名叫 FlowNL,不要开编码 session',
536
+ createChatCompletion: vi.fn(),
537
+ tools,
538
+ AgentImpl,
539
+ })
540
+ expect(prompts).toEqual([
541
+ '帮我新建一个助理,名叫 FlowNL,不要开编码 session',
542
+ MISSED_FENCE_RETRY_USER_TEXT,
543
+ ])
544
+ expect(createAssistant).toHaveBeenCalledWith({
545
+ name: 'FlowNL',
546
+ providerID: 'opencode-go',
547
+ modelID: 'deepseek-v4-flash',
548
+ mode: 'continuous',
549
+ })
550
+ expect(result.cards).toEqual([expect.objectContaining({
551
+ cardType: 'assistant',
552
+ name: 'FlowNL',
553
+ assistantID: 'asst_flow',
554
+ })])
555
+ expect(result.bubbles.join('')).not.toContain('好的,我来直接创建')
556
+ expect(result.bubbles.join('')).toContain('Created assistant FlowNL')
557
+ expect(result.thinkingLevel).toBe('off')
558
+ })
559
+
560
+ it('does not fake a card when the missed-fence retry still has no tool', async () => {
561
+ const createAssistant = vi.fn()
562
+ const tools = createContactTools({
563
+ createAssistant,
564
+ currentAssistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash' },
565
+ })
566
+ const prompts = []
567
+ function AgentImpl(options) {
568
+ this.state = { ...options.initialState, messages: [] }
569
+ this.prompt = async (text) => {
570
+ prompts.push(text)
571
+ this.state.messages.push(
572
+ { role: 'user', content: text, timestamp: Date.now() },
573
+ { role: 'assistant', content: [{ type: 'text', text: '好的,已创建。' }] },
574
+ )
575
+ }
576
+ }
577
+
578
+ const result = await runContactTurn({
579
+ assistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', defaultPrompt: '' },
580
+ history: [],
581
+ userText: '帮我新建一个助理,名叫 FlowNL',
582
+ createChatCompletion: vi.fn(),
583
+ tools,
584
+ AgentImpl,
585
+ })
586
+ expect(prompts).toEqual([
587
+ '帮我新建一个助理,名叫 FlowNL',
588
+ MISSED_FENCE_RETRY_USER_TEXT,
589
+ ])
590
+ expect(createAssistant).not.toHaveBeenCalled()
591
+ expect(result.cards).toEqual([])
592
+ expect(result.bubbles).toEqual([MISSED_TOOL_FAILURE_BUBBLE])
593
+ expect(result.bubbles.join('')).not.toContain('已创建')
594
+ })
595
+
596
+ it('retries a missed fence once and then executes message_assistant', async () => {
597
+ const deliverPeerMessage = vi.fn(async (input) => ({
598
+ admitted: true,
599
+ role: 'peer',
600
+ toAssistantID: input.toAssistantID,
601
+ }))
602
+ const tools = createContactTools({
603
+ deliverPeerMessage,
604
+ listAssistants: async () => [{ id: 'asst_peer', name: 'PeerQA' }],
605
+ currentAssistant: { id: 'asst_host', name: 'DeepSeekQA', providerID: 'opencode-go', modelID: 'deepseek-v4-flash' },
606
+ })
607
+ const prompts = []
608
+ function AgentImpl(options) {
609
+ this.state = { ...options.initialState, messages: [] }
610
+ this.prompt = async (text) => {
611
+ prompts.push(text)
612
+ this.state.messages.push({ role: 'user', content: text, timestamp: Date.now() })
613
+ if (text === MISSED_FENCE_RETRY_USER_TEXT) {
614
+ const tool = this.state.tools.find((item) => item.name === MESSAGE_ASSISTANT_TOOL_NAME)
615
+ const result = await tool.execute('call_retry', { to: 'PeerQA', text: 'hello-from-assistant 写好了' })
616
+ this.state.messages.push(
617
+ { role: 'assistant', content: [{ type: 'text', text: '' }] },
618
+ {
619
+ role: 'toolResult',
620
+ toolName: MESSAGE_ASSISTANT_TOOL_NAME,
621
+ content: result.content,
622
+ details: result.details,
623
+ },
624
+ )
625
+ return
626
+ }
627
+ this.state.messages.push({
628
+ role: 'assistant',
629
+ content: [{ type: 'text', text: '好的,我去说一声。' }],
630
+ })
631
+ }
632
+ }
633
+
634
+ const result = await runContactTurn({
635
+ assistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', defaultPrompt: '' },
636
+ history: [],
637
+ userText: '给 PeerQA 说一声 hello-from-assistant 写好了',
638
+ createChatCompletion: vi.fn(),
639
+ tools,
640
+ AgentImpl,
641
+ })
642
+ expect(prompts).toEqual([
643
+ '给 PeerQA 说一声 hello-from-assistant 写好了',
644
+ MISSED_FENCE_RETRY_USER_TEXT,
645
+ ])
646
+ expect(deliverPeerMessage).toHaveBeenCalledWith({
647
+ toAssistantID: 'asst_peer',
648
+ text: 'hello-from-assistant 写好了',
649
+ })
650
+ expect(result.cards).toEqual([])
651
+ expect(result.bubbles.join('')).toContain('Sent to PeerQA')
652
+ expect(result.bubbles.join('')).not.toContain('好的,我去说一声')
653
+ })
654
+
655
+ it('keeps only the new_conversation confirm and drops leftover attachment text', async () => {
656
+ const resetContact = vi.fn(async () => ({ reset: true }))
657
+ const tools = createContactTools({ resetContact })
658
+ function AgentImpl(options) {
659
+ this.state = { ...options.initialState, messages: options.initialState.messages }
660
+ this.prompt = async () => {
661
+ const tool = this.state.tools.find((item) => item.name === NEW_CONVERSATION_TOOL_NAME)
662
+ const result = await tool.execute('call_reset', {})
663
+ this.state.messages = [
664
+ ...this.state.messages,
665
+ {
666
+ role: 'assistant',
667
+ content: [{ type: 'text', text: 'I still see your dot.png and note.txt from earlier.' }],
668
+ },
669
+ {
670
+ role: 'toolResult',
671
+ toolName: NEW_CONVERSATION_TOOL_NAME,
672
+ content: result.content,
673
+ details: result.details,
674
+ },
675
+ ]
676
+ }
677
+ }
678
+
679
+ const result = await runContactTurn({
680
+ assistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', defaultPrompt: '' },
681
+ history: [
682
+ { role: 'user', content: 'look', parts: [{ type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'dot.png' }] },
683
+ { role: 'assistant', content: 'got the image' },
684
+ ],
685
+ userText: '开新对话',
686
+ createChatCompletion: vi.fn(),
687
+ tools,
688
+ AgentImpl,
689
+ })
690
+ expect(resetContact).toHaveBeenCalledTimes(1)
691
+ expect(result.reset).toBe(true)
692
+ expect(result.cards).toEqual([])
693
+ expect(result.bubbles).toEqual([NEW_CONVERSATION_CONFIRM_BUBBLE])
694
+ expect(result.bubbles.join('')).not.toContain('dot.png')
695
+ expect(result.bubbles.join('')).not.toContain('note.txt')
696
+ })
697
+ })