@openchambery/web 1.19.7-beta.13 → 1.19.7-beta.14

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 (118) hide show
  1. package/dist/assets/{AgentAvatar-Dp_hbVcD.js → AgentAvatar-BsU82CEP.js} +1 -1
  2. package/dist/assets/AssistantView-CKNR7FFf.js +2 -0
  3. package/dist/assets/{DiagramView-iX3nhWaN.js → DiagramView-Db1VvSIR.js} +1 -1
  4. package/dist/assets/{MarkdownRendererImpl-tDry9J76.js → MarkdownRendererImpl-NBG7O4aX.js} +1 -1
  5. package/dist/assets/{MarkstreamRendererImpl-BDVGi72n.js → MarkstreamRendererImpl-Bs829ng-.js} +1 -1
  6. package/dist/assets/MobileDetailNavigation-CooM1BN7.js +1 -0
  7. package/dist/assets/{MobileShareBridge-BQ79cuPX.js → MobileShareBridge-CcG4lLaD.js} +3 -3
  8. package/dist/assets/MobileSurface-c_7RkYa2.js +2 -0
  9. package/dist/assets/{MultiRunWindow-CzMbzYD3.js → MultiRunWindow-Ds8nyikm.js} +1 -1
  10. package/dist/assets/{OnboardingScreen-CuvjeXSo.js → OnboardingScreen-BNEtPmvw.js} +1 -1
  11. package/dist/assets/SettingsView-Dac1dDOs.js +25 -0
  12. package/dist/assets/{SettingsWindow-DqwoBYvU.js → SettingsWindow-CHnMwzd6.js} +1 -1
  13. package/dist/assets/{TerminalView-4pFqXfvj.js → TerminalView-DfGueSOI.js} +1 -1
  14. package/dist/assets/{ToolOutputDialog-GmD4kIoH.js → ToolOutputDialog-BrR9iqiW.js} +1 -1
  15. package/dist/assets/{appThemeRegistry-BlEYFhXM.js → appThemeRegistry-BEG7NRFm.js} +2 -2
  16. package/dist/assets/{clipboard-Bu0lpyX1.js → clipboard-BXxLshVq.js} +1 -1
  17. package/dist/assets/context-menu-Dpjla5p4.js +1 -0
  18. package/dist/assets/{dialog-D21YndEx.js → dialog-BkhHBTd4.js} +1 -1
  19. package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
  20. package/dist/assets/{es-C8Sqjsia.js → es-BHqnSjoZ.js} +1 -1
  21. package/dist/assets/{fr-p-Zwm_j2.js → fr-BfrlJY9i.js} +1 -1
  22. package/dist/assets/{gitApi-CciB055B.js → gitApi-Br5EdS29.js} +3 -3
  23. package/dist/assets/insertionBoundaries-ncyYkgWy.js +4 -0
  24. package/dist/assets/{ja-B6REu1qP.js → ja-DP-7qt-x.js} +1 -1
  25. package/dist/assets/{ko-CJBNH9IZ.js → ko-E2B5vGQP.js} +1 -1
  26. package/dist/assets/main-DX_9iX64.js +169 -0
  27. package/dist/assets/{main-DYFky6Uj.js → main-Dl-XWJxN.js} +2 -2
  28. package/dist/assets/miniChat-Cho-Pywx.js +2 -0
  29. package/dist/assets/mobile-1_JD1EGE.js +2 -0
  30. package/dist/assets/multirun-ByFn3xhO.js +4 -0
  31. package/dist/assets/{pl-B2cBI_7K.js → pl-r8X5gRM5.js} +1 -1
  32. package/dist/assets/projectMeta-BHw8kRhQ.js +3 -0
  33. package/dist/assets/{pt-BR-CrCc9hmt.js → pt-BR-DcpdXCf5.js} +1 -1
  34. package/dist/assets/renderElectronMiniChatApp-CvIxnUQY.js +2 -0
  35. package/dist/assets/renderMobileApp-DscicBzZ.js +28 -0
  36. package/dist/assets/{runtimeConfig-DNsnUkyp.js → runtimeConfig-Dkt3p8CZ.js} +1 -1
  37. package/dist/assets/runtimeEndpointReset-hBFsO83_.js +147 -0
  38. package/dist/assets/sessionChangeSummary-CX2MJIkc.js +1 -0
  39. package/dist/assets/sessionLookup-jCEot_fX.js +1 -0
  40. package/dist/assets/{sortable-tabs-strip-Biow4Fqu.js → sortable-tabs-strip-C5gGnikj.js} +1 -1
  41. package/dist/assets/src-KcdsQIuP.css +2 -0
  42. package/dist/assets/{store-CL3WP48T.js → store-BDFdnk6l.js} +2 -2
  43. package/dist/assets/textarea-CIO34TZz.js +1 -0
  44. package/dist/assets/{uk-C8YKAz0x.js → uk-Di0vl9V-.js} +1 -1
  45. package/dist/assets/{ChatContainer-C04WuLo8.js → useAppFontEffects-kJR0NlNH.js} +44 -39
  46. package/dist/assets/useDesktopWindowControlsLayout-BmbOASBQ.js +1 -0
  47. package/dist/assets/useEffectiveDirectory-DOToiRZd.js +1 -0
  48. package/dist/assets/useMobileNavigationStore-CEmn-d1X.js +412 -0
  49. package/dist/assets/useSessionAutoCleanup-DcmXMo-p.js +1 -0
  50. package/dist/assets/{useWorkerHighlightedLines-BaNK-wuY.js → useWorkerHighlightedLines-r5Ww4sY7.js} +1 -1
  51. package/dist/assets/{zh-CN-CCYwUnmB.js → zh-CN-coFCfeQE.js} +1 -1
  52. package/dist/assets/{zh-TW-D8Y6py08.js → zh-TW-oDRZ1-yb.js} +1 -1
  53. package/dist/index.html +3 -3
  54. package/dist/mini-chat.html +3 -3
  55. package/dist/mobile.html +3 -3
  56. package/package.json +2 -1
  57. package/server/index.js +2 -0
  58. package/server/lib/assistant-delivery-parts.js +3 -1
  59. package/server/lib/assistant-delivery-parts.test.js +11 -0
  60. package/server/lib/assistants/DOCUMENTATION.md +76 -3
  61. package/server/lib/assistants/assign.js +225 -0
  62. package/server/lib/assistants/assign.test.js +118 -0
  63. package/server/lib/assistants/bubbles.js +45 -0
  64. package/server/lib/assistants/bubbles.test.js +36 -0
  65. package/server/lib/assistants/cards.js +148 -0
  66. package/server/lib/assistants/cards.test.js +116 -0
  67. package/server/lib/assistants/contact-store.js +284 -0
  68. package/server/lib/assistants/contact-store.test.js +131 -0
  69. package/server/lib/assistants/contact-tools.js +761 -0
  70. package/server/lib/assistants/contact-tools.test.js +328 -0
  71. package/server/lib/assistants/contracts.js +20 -1
  72. package/server/lib/assistants/harness.js +551 -0
  73. package/server/lib/assistants/harness.test.js +607 -0
  74. package/server/lib/assistants/routes.js +44 -4
  75. package/server/lib/assistants/service.js +930 -17
  76. package/server/lib/assistants/service.test.js +1163 -45
  77. package/server/lib/llm/DOCUMENTATION.md +84 -0
  78. package/server/lib/llm/catalog.js +100 -0
  79. package/server/lib/llm/catalog.test.js +78 -0
  80. package/server/lib/llm/completions.js +151 -0
  81. package/server/lib/llm/completions.test.js +127 -0
  82. package/server/lib/llm/generate.js +570 -0
  83. package/server/lib/llm/generate.test.js +459 -0
  84. package/server/lib/llm/routes.js +47 -0
  85. package/server/lib/llm/temp-directory.js +37 -0
  86. package/server/lib/notifications/runtime.js +52 -0
  87. package/server/lib/notifications/runtime.test.js +39 -0
  88. package/server/lib/opencode/feature-routes-runtime.js +30 -0
  89. package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
  90. package/server/lib/session-goal/DOCUMENTATION.md +8 -1
  91. package/server/lib/session-goal/runtime.js +20 -0
  92. package/server/lib/session-index/DOCUMENTATION.md +3 -1
  93. package/server/lib/session-index/service.js +10 -4
  94. package/server/lib/session-index/service.test.js +37 -0
  95. package/server/lib/session-title/runtime.js +1 -0
  96. package/server/lib/session-title/runtime.test.js +11 -0
  97. package/dist/assets/AssistantView-CQHey6je.js +0 -1
  98. package/dist/assets/ContextPanel-BJEFzgz7.js +0 -285
  99. package/dist/assets/MobileDetailNavigation-Dcl7Upvc.js +0 -1
  100. package/dist/assets/MobileSurface-CpAbBAQ5.js +0 -2
  101. package/dist/assets/SettingsView-DRiEDi23.js +0 -25
  102. package/dist/assets/insertionBoundaries-DcS10A9Q.js +0 -4
  103. package/dist/assets/main-Coj1Z4LN.js +0 -29
  104. package/dist/assets/miniChat-DK-d_3-7.js +0 -2
  105. package/dist/assets/mobile-8QzXy5wz.js +0 -2
  106. package/dist/assets/multirun-BFHWrDOR.js +0 -4
  107. package/dist/assets/projectMeta-CEGa4Czs.js +0 -3
  108. package/dist/assets/radio-Bw11T40N.js +0 -1
  109. package/dist/assets/renderElectronMiniChatApp-_fchgm-8.js +0 -2
  110. package/dist/assets/renderMobileApp-B1j1BUXB.js +0 -28
  111. package/dist/assets/runtimeEndpointReset-C-Sm4wpj.js +0 -4
  112. package/dist/assets/shikiHighlight-GaNLBDUk.js +0 -1
  113. package/dist/assets/src-BGao3JmJ.css +0 -2
  114. package/dist/assets/useAppFontEffects-D0UWTJui.js +0 -7
  115. package/dist/assets/useDesktopWindowControlsLayout-_5M7NHJ8.js +0 -1
  116. package/dist/assets/useEffectiveDirectory-DqOLKISv.js +0 -1
  117. package/dist/assets/useMobileNavigationStore-DxH1DjU-.js +0 -412
  118. package/dist/assets/useSessionAutoCleanup-Dpp0-zw2.js +0 -1
@@ -0,0 +1,607 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ CREATE_ASSISTANT_TOOL_NAME,
4
+ MESSAGE_ASSISTANT_TOOL_NAME,
5
+ MISSED_FENCE_RETRY_USER_TEXT,
6
+ MISSED_TOOL_FAILURE_BUBBLE,
7
+ NEW_CONVERSATION_CONFIRM_BUBBLE,
8
+ NEW_CONVERSATION_TOOL_NAME,
9
+ createContactTools,
10
+ } from './contact-tools.js'
11
+ import { createContactStreamFn, runContactTurn } from './harness.js'
12
+
13
+ describe('createContactStreamFn', () => {
14
+ it('forwards completion text as pi-ai text events', async () => {
15
+ const createChatCompletion = vi.fn(async () => ({
16
+ completion: { choices: [{ message: { content: 'Hi' } }] },
17
+ }))
18
+ const streamFn = createContactStreamFn(createChatCompletion)
19
+ const events = []
20
+ const stream = streamFn(
21
+ { name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
22
+ { messages: [{ role: 'user', content: 'hello', timestamp: 1 }] },
23
+ )
24
+ for await (const event of stream) events.push(event)
25
+ expect(createChatCompletion).toHaveBeenCalledWith(expect.objectContaining({
26
+ body: expect.objectContaining({
27
+ model: expect.any(String),
28
+ messages: expect.any(Array),
29
+ }),
30
+ }))
31
+ expect(createChatCompletion.mock.calls[0][0].body.stream).toBeUndefined()
32
+ expect(events.some((event) => event.type === 'text_delta' && event.delta === 'Hi')).toBe(true)
33
+ expect(events.at(-1).type).toBe('done')
34
+ expect(typeof stream.result).toBe('function')
35
+ await expect(stream.result()).resolves.toMatchObject({ stopReason: 'stop' })
36
+ })
37
+
38
+ it('forwards contact file parts on the last user completion message', async () => {
39
+ const createChatCompletion = vi.fn(async () => ({
40
+ completion: { choices: [{ message: { content: 'saw the image' } }] },
41
+ }))
42
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
43
+ const file = { type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==', filename: 'notes.txt' }
44
+ const streamFn = createContactStreamFn(createChatCompletion, { pendingFileParts: [image, file] })
45
+ const stream = streamFn(
46
+ { name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
47
+ { messages: [{ role: 'user', content: 'look', timestamp: 1 }] },
48
+ )
49
+ for await (const event of stream) void event
50
+ expect(createChatCompletion.mock.calls[0][0].body.messages.at(-1)).toEqual({
51
+ role: 'user',
52
+ content: 'look',
53
+ parts: [image, file],
54
+ })
55
+ })
56
+
57
+ it('keeps thinking off and never installs bash/edit/read/write when sending attachments', async () => {
58
+ const prompt = vi.fn(async function prompt() {
59
+ this.state.messages = [{
60
+ role: 'assistant',
61
+ content: [{ type: 'text', text: 'Got it.' }],
62
+ }]
63
+ })
64
+ function AgentImpl(options) {
65
+ expect(options.initialState.thinkingLevel).toBe('off')
66
+ expect(options.initialState.tools).toEqual([])
67
+ this.state = { ...options.initialState, messages: [] }
68
+ this.prompt = prompt
69
+ }
70
+ const result = await runContactTurn({
71
+ assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
72
+ history: [],
73
+ userText: '[attachment]',
74
+ userParts: [{ type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }],
75
+ createChatCompletion: vi.fn(),
76
+ AgentImpl,
77
+ })
78
+ expect(result.thinkingLevel).toBe('off')
79
+ expect(result.tools).toEqual([])
80
+ expect(prompt).toHaveBeenCalledWith('[attachment]')
81
+ })
82
+
83
+ it('replays an assign_session fence as a toolUse burst, not token SSE', async () => {
84
+ const createChatCompletion = vi.fn(async () => ({
85
+ completion: {
86
+ choices: [{
87
+ message: {
88
+ content: 'On it.\n\n```openchamber-tool\n{"name":"assign_session","arguments":{"prompt":"Fix login"}}\n```',
89
+ },
90
+ }],
91
+ },
92
+ }))
93
+ const streamFn = createContactStreamFn(createChatCompletion)
94
+ const events = []
95
+ const stream = streamFn(
96
+ { name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
97
+ {
98
+ messages: [{ role: 'user', content: 'assign login', timestamp: 1 }],
99
+ tools: [{ name: 'assign_session' }],
100
+ },
101
+ )
102
+ for await (const event of stream) events.push(event)
103
+ expect(events.some((event) => event.type === 'toolcall_end' && event.toolCall?.name === 'assign_session')).toBe(true)
104
+ expect(events.at(-1)).toMatchObject({ type: 'done', reason: 'toolUse' })
105
+ expect(createChatCompletion.mock.calls[0][0].body.stream).toBeUndefined()
106
+ })
107
+
108
+ it('replays a bare {name, arguments} object as a toolUse burst', async () => {
109
+ const createChatCompletion = vi.fn(async () => ({
110
+ completion: {
111
+ choices: [{
112
+ message: {
113
+ content: '好的 {"name":"create_assistant","arguments":{"name":"FlowNL"}}',
114
+ },
115
+ }],
116
+ },
117
+ }))
118
+ const streamFn = createContactStreamFn(createChatCompletion)
119
+ const events = []
120
+ const stream = streamFn(
121
+ { name: 'opencode-go/deepseek-v4-flash', id: 'deepseek-v4-flash', provider: 'openchamber', api: 'openai-completions' },
122
+ {
123
+ messages: [{ role: 'user', content: '新建助理 FlowNL', timestamp: 1 }],
124
+ tools: [{ name: 'create_assistant' }],
125
+ },
126
+ )
127
+ for await (const event of stream) events.push(event)
128
+ expect(events.some((event) => event.type === 'toolcall_end' && event.toolCall?.name === 'create_assistant')).toBe(true)
129
+ expect(events.at(-1)).toMatchObject({ type: 'done', reason: 'toolUse' })
130
+ })
131
+
132
+ it('passes onTextDelta and globalEventHub into createChatCompletion and forwards real deltas', async () => {
133
+ const deltas = []
134
+ const bubbleDeltas = []
135
+ const hub = { subscribeEvent: vi.fn() }
136
+ const createChatCompletion = vi.fn(async ({ onTextDelta }) => {
137
+ onTextDelta?.('Hello')
138
+ onTextDelta?.(' world')
139
+ return { completion: { choices: [{ message: { content: 'Hello world' } }] } }
140
+ })
141
+ const streamFn = createContactStreamFn(createChatCompletion, {
142
+ onTextDelta: (delta) => deltas.push(delta),
143
+ onBubbleDelta: (index, delta, done) => bubbleDeltas.push({ index, delta, done }),
144
+ globalEventHub: hub,
145
+ })
146
+ const stream = streamFn(
147
+ { name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
148
+ { messages: [{ role: 'user', content: 'hi', timestamp: 1 }] },
149
+ )
150
+ for await (const event of stream) void event
151
+ expect(createChatCompletion.mock.calls[0][0].globalEventHub).toBe(hub)
152
+ expect(typeof createChatCompletion.mock.calls[0][0].onTextDelta).toBe('function')
153
+ expect(deltas).toEqual(['Hello', ' world'])
154
+ expect(bubbleDeltas.some((item) => item.done === false)).toBe(false)
155
+ expect(bubbleDeltas).toEqual([{ index: 0, delta: 'Hello world', done: true }])
156
+ })
157
+
158
+ it('stops bubble deltas at a fence start and does not leak tool JSON', async () => {
159
+ const bubbleDeltas = []
160
+ const createChatCompletion = vi.fn(async ({ onTextDelta }) => {
161
+ onTextDelta?.('On it.\n\n')
162
+ onTextDelta?.('```openchamber-tool\n{"name":"assign_session","arguments":{"prompt":"x"}}\n```')
163
+ return {
164
+ completion: {
165
+ choices: [{
166
+ message: {
167
+ content: 'On it.\n\n```openchamber-tool\n{"name":"assign_session","arguments":{"prompt":"x"}}\n```',
168
+ },
169
+ }],
170
+ },
171
+ }
172
+ })
173
+ const streamFn = createContactStreamFn(createChatCompletion, {
174
+ onBubbleDelta: (index, delta, done) => bubbleDeltas.push({ index, delta, done }),
175
+ })
176
+ const stream = streamFn(
177
+ { name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
178
+ {
179
+ messages: [{ role: 'user', content: 'assign', timestamp: 1 }],
180
+ tools: [{ name: 'assign_session' }],
181
+ },
182
+ )
183
+ for await (const event of stream) void event
184
+ const leaked = bubbleDeltas.some((item) => item.delta.includes('openchamber-tool') || item.delta.includes('assign_session'))
185
+ expect(leaked).toBe(false)
186
+ expect(bubbleDeltas).toEqual([{ index: 0, delta: 'On it.', done: true }])
187
+ })
188
+
189
+ it('emits final stripped bubbles as done:true once when no live deltas arrive', async () => {
190
+ const bubbleDeltas = []
191
+ const createChatCompletion = vi.fn(async () => ({
192
+ completion: { choices: [{ message: { content: 'One.\n\nTwo.' } }] },
193
+ }))
194
+ const streamFn = createContactStreamFn(createChatCompletion, {
195
+ onBubbleDelta: (index, delta, done) => bubbleDeltas.push({ index, delta, done }),
196
+ })
197
+ const stream = streamFn(
198
+ { name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
199
+ { messages: [{ role: 'user', content: 'hi', timestamp: 1 }] },
200
+ )
201
+ for await (const event of stream) void event
202
+ expect(bubbleDeltas).toEqual([
203
+ { index: 0, delta: 'One.', done: true },
204
+ { index: 1, delta: 'Two.', done: true },
205
+ ])
206
+ })
207
+ })
208
+
209
+ describe('runContactTurn', () => {
210
+ it('runs pi-agent-core with thinking off and no tools by default', async () => {
211
+ const prompt = vi.fn(async function prompt() {
212
+ this.state.messages = [{
213
+ role: 'assistant',
214
+ content: [{ type: 'text', text: 'Hey.\n\nI can open that session.' }],
215
+ }]
216
+ })
217
+ function AgentImpl(options) {
218
+ expect(options.initialState.thinkingLevel).toBe('off')
219
+ expect(options.initialState.tools).toEqual([])
220
+ this.state = { ...options.initialState, messages: [] }
221
+ this.prompt = prompt
222
+ }
223
+
224
+ const result = await runContactTurn({
225
+ assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
226
+ history: [],
227
+ userText: 'open login',
228
+ createChatCompletion: vi.fn(),
229
+ AgentImpl,
230
+ })
231
+ expect(result.bubbles).toEqual(['Hey.', 'I can open that session.'])
232
+ expect(prompt).toHaveBeenCalled()
233
+ })
234
+
235
+ it('attaches OpenChamber tools only and collects session cards from tool results', async () => {
236
+ const assignTool = {
237
+ name: 'assign_session',
238
+ label: 'Assign session',
239
+ description: 'Open a worker session',
240
+ parameters: {},
241
+ execute: vi.fn(),
242
+ }
243
+ function AgentImpl(options) {
244
+ expect(options.initialState.thinkingLevel).toBe('off')
245
+ expect(options.initialState.tools.map((tool) => tool.name)).toEqual(['assign_session'])
246
+ expect(options.initialState.tools.some((tool) => ['bash', 'edit', 'read', 'write'].includes(tool.name))).toBe(false)
247
+ expect(options.initialState.systemPrompt).toContain('assign_session')
248
+ expect(options.initialState.systemPrompt).toContain('create_assistant')
249
+ expect(options.initialState.systemPrompt).toContain('message_assistant')
250
+ expect(options.initialState.systemPrompt).toContain('new_conversation')
251
+ expect(options.initialState.systemPrompt).toContain('list_projects')
252
+ expect(options.initialState.systemPrompt).toContain('list_sessions')
253
+ expect(options.initialState.systemPrompt).toContain('建助理')
254
+ expect(options.initialState.systemPrompt).toContain('开新对话')
255
+ expect(options.initialState.systemPrompt).toContain('说一声')
256
+ expect(options.initialState.systemPrompt).toContain('A reply without the tool call does nothing')
257
+ expect(options.initialState.systemPrompt).toContain('已创建')
258
+ expect(options.initialState.systemPrompt).toContain('cannot see the registered project list')
259
+ this.state = { ...options.initialState, messages: [] }
260
+ this.prompt = async () => {
261
+ this.state.messages = [
262
+ { role: 'assistant', content: [{ type: 'text', text: 'Opening that.' }] },
263
+ {
264
+ role: 'toolResult',
265
+ toolName: 'assign_session',
266
+ content: [{ type: 'text', text: 'opened' }],
267
+ details: {
268
+ card: {
269
+ type: 'card',
270
+ cardType: 'session',
271
+ sessionID: 'ses_1',
272
+ directory: '/repo',
273
+ title: 'Login',
274
+ status: 'busy',
275
+ },
276
+ },
277
+ },
278
+ ]
279
+ }
280
+ }
281
+
282
+ const result = await runContactTurn({
283
+ assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
284
+ history: [],
285
+ userText: 'assign login',
286
+ createChatCompletion: vi.fn(),
287
+ tools: [assignTool, { name: 'bash', execute: vi.fn() }],
288
+ projects: [{ id: 'proj_yee', path: '/repo/openchamber-yee', label: 'OpenChamber Yee' }],
289
+ AgentImpl,
290
+ })
291
+ expect(result.bubbles).toEqual(['opened'])
292
+ expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_1', cardType: 'session' })])
293
+ expect(result.thinkingLevel).toBe('off')
294
+ })
295
+
296
+ it('does not persist pre-tool planning as contact bubbles', async () => {
297
+ function AgentImpl(options) {
298
+ this.state = { ...options.initialState, messages: [] }
299
+ this.prompt = async () => {
300
+ this.state.messages = [
301
+ {
302
+ role: 'assistant',
303
+ content: [
304
+ { type: 'text', text: 'Let me think — I should match openchamber yee and call assign_session.' },
305
+ { type: 'toolCall', id: 'call_1', name: 'assign_session', arguments: { prompt: 'x', projectPath: '/repo' } },
306
+ ],
307
+ },
308
+ {
309
+ role: 'toolResult',
310
+ toolName: 'assign_session',
311
+ content: [{ type: 'text', text: 'Opened a coding session.' }],
312
+ details: {
313
+ card: {
314
+ type: 'card',
315
+ cardType: 'session',
316
+ sessionID: 'ses_plan',
317
+ directory: '/repo',
318
+ title: 'Work',
319
+ status: 'busy',
320
+ },
321
+ },
322
+ },
323
+ ]
324
+ }
325
+ }
326
+ const result = await runContactTurn({
327
+ assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
328
+ history: [],
329
+ userText: 'assign login',
330
+ createChatCompletion: vi.fn(),
331
+ tools: [{ name: 'assign_session', execute: vi.fn() }],
332
+ AgentImpl,
333
+ })
334
+ expect(result.bubbles.join('\n')).not.toMatch(/Let me think|assign_session/)
335
+ expect(result.bubbles).toEqual(['Opened a coding session.'])
336
+ expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_plan' })])
337
+ })
338
+
339
+ it('keeps a short spoken preamble then the tool confirm', async () => {
340
+ function AgentImpl(options) {
341
+ this.state = { ...options.initialState, messages: [] }
342
+ this.prompt = async () => {
343
+ this.state.messages = [
344
+ {
345
+ role: 'assistant',
346
+ content: [
347
+ { type: 'text', text: '我去找一下' },
348
+ { type: 'toolCall', id: 'call_1', name: 'assign_session', arguments: { prompt: 'x' } },
349
+ ],
350
+ },
351
+ {
352
+ role: 'toolResult',
353
+ toolName: 'assign_session',
354
+ content: [{ type: 'text', text: 'Opened a coding session.' }],
355
+ details: {
356
+ card: {
357
+ type: 'card',
358
+ cardType: 'session',
359
+ sessionID: 'ses_speak',
360
+ directory: '/repo',
361
+ title: 'Work',
362
+ status: 'busy',
363
+ },
364
+ },
365
+ },
366
+ ]
367
+ }
368
+ }
369
+ const result = await runContactTurn({
370
+ assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
371
+ history: [],
372
+ userText: 'assign login',
373
+ createChatCompletion: vi.fn(),
374
+ tools: [{ name: 'assign_session', execute: vi.fn() }],
375
+ AgentImpl,
376
+ })
377
+ expect(result.bubbles).toEqual(['我去找一下', 'Opened a coding session.'])
378
+ expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_speak' })])
379
+ })
380
+
381
+ it('injects the registered projects catalog into the system prompt every turn', async () => {
382
+ function AgentImpl(options) {
383
+ expect(options.initialState.systemPrompt).toContain('Registered projects')
384
+ expect(options.initialState.systemPrompt).toContain('OpenChamber Yee')
385
+ expect(options.initialState.systemPrompt).toContain('/repo/openchamber-yee')
386
+ expect(options.initialState.systemPrompt).toContain('never say you cannot see registered projects')
387
+ this.state = { ...options.initialState, messages: [{ role: 'assistant', content: [{ type: 'text', text: 'Found it.' }] }] }
388
+ this.prompt = async () => {}
389
+ }
390
+ const result = await runContactTurn({
391
+ assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
392
+ history: [],
393
+ userText: '找 openchamber yee',
394
+ createChatCompletion: vi.fn(),
395
+ projects: [{ id: 'proj_yee', path: '/repo/openchamber-yee', label: 'OpenChamber Yee' }],
396
+ AgentImpl,
397
+ })
398
+ expect(result.bubbles).toEqual(['Found it.'])
399
+ })
400
+
401
+ it('retries a missed fence once and then executes create_assistant', async () => {
402
+ const createAssistant = vi.fn(async (input) => ({
403
+ id: 'asst_flow',
404
+ name: input.name,
405
+ providerID: input.providerID,
406
+ modelID: input.modelID,
407
+ mode: 'continuous',
408
+ }))
409
+ const tools = createContactTools({
410
+ createAssistant,
411
+ currentAssistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash' },
412
+ })
413
+ const prompts = []
414
+ function AgentImpl(options) {
415
+ expect(options.initialState.thinkingLevel).toBe('off')
416
+ expect(options.initialState.tools.some((tool) => ['bash', 'edit', 'read', 'write'].includes(tool.name))).toBe(false)
417
+ this.state = { ...options.initialState, messages: [] }
418
+ this.prompt = async (text) => {
419
+ prompts.push(text)
420
+ this.state.messages.push({ role: 'user', content: text, timestamp: Date.now() })
421
+ if (text === MISSED_FENCE_RETRY_USER_TEXT) {
422
+ const tool = this.state.tools.find((item) => item.name === CREATE_ASSISTANT_TOOL_NAME)
423
+ const result = await tool.execute('call_retry', { name: 'FlowNL', model: 'opencode-go/deepseek-v4-flash' })
424
+ this.state.messages.push(
425
+ { role: 'assistant', content: [{ type: 'text', text: '' }] },
426
+ {
427
+ role: 'toolResult',
428
+ toolName: CREATE_ASSISTANT_TOOL_NAME,
429
+ content: result.content,
430
+ details: result.details,
431
+ },
432
+ )
433
+ return
434
+ }
435
+ this.state.messages.push({
436
+ role: 'assistant',
437
+ content: [{ type: 'text', text: '好的,我来直接创建这个助理,不开编码会话。' }],
438
+ })
439
+ }
440
+ }
441
+
442
+ const result = await runContactTurn({
443
+ assistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', defaultPrompt: '' },
444
+ history: [],
445
+ userText: '帮我新建一个助理,名叫 FlowNL,不要开编码 session',
446
+ createChatCompletion: vi.fn(),
447
+ tools,
448
+ AgentImpl,
449
+ })
450
+ expect(prompts).toEqual([
451
+ '帮我新建一个助理,名叫 FlowNL,不要开编码 session',
452
+ MISSED_FENCE_RETRY_USER_TEXT,
453
+ ])
454
+ expect(createAssistant).toHaveBeenCalledWith({
455
+ name: 'FlowNL',
456
+ providerID: 'opencode-go',
457
+ modelID: 'deepseek-v4-flash',
458
+ mode: 'continuous',
459
+ })
460
+ expect(result.cards).toEqual([expect.objectContaining({
461
+ cardType: 'assistant',
462
+ name: 'FlowNL',
463
+ assistantID: 'asst_flow',
464
+ })])
465
+ expect(result.bubbles.join('')).not.toContain('好的,我来直接创建')
466
+ expect(result.bubbles.join('')).toContain('Created assistant FlowNL')
467
+ expect(result.thinkingLevel).toBe('off')
468
+ })
469
+
470
+ it('does not fake a card when the missed-fence retry still has no tool', async () => {
471
+ const createAssistant = vi.fn()
472
+ const tools = createContactTools({
473
+ createAssistant,
474
+ currentAssistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash' },
475
+ })
476
+ const prompts = []
477
+ function AgentImpl(options) {
478
+ this.state = { ...options.initialState, messages: [] }
479
+ this.prompt = async (text) => {
480
+ prompts.push(text)
481
+ this.state.messages.push(
482
+ { role: 'user', content: text, timestamp: Date.now() },
483
+ { role: 'assistant', content: [{ type: 'text', text: '好的,已创建。' }] },
484
+ )
485
+ }
486
+ }
487
+
488
+ const result = await runContactTurn({
489
+ assistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', defaultPrompt: '' },
490
+ history: [],
491
+ userText: '帮我新建一个助理,名叫 FlowNL',
492
+ createChatCompletion: vi.fn(),
493
+ tools,
494
+ AgentImpl,
495
+ })
496
+ expect(prompts).toEqual([
497
+ '帮我新建一个助理,名叫 FlowNL',
498
+ MISSED_FENCE_RETRY_USER_TEXT,
499
+ ])
500
+ expect(createAssistant).not.toHaveBeenCalled()
501
+ expect(result.cards).toEqual([])
502
+ expect(result.bubbles).toEqual([MISSED_TOOL_FAILURE_BUBBLE])
503
+ expect(result.bubbles.join('')).not.toContain('已创建')
504
+ })
505
+
506
+ it('retries a missed fence once and then executes message_assistant', async () => {
507
+ const deliverPeerMessage = vi.fn(async (input) => ({
508
+ admitted: true,
509
+ role: 'peer',
510
+ toAssistantID: input.toAssistantID,
511
+ }))
512
+ const tools = createContactTools({
513
+ deliverPeerMessage,
514
+ listAssistants: async () => [{ id: 'asst_peer', name: 'PeerQA' }],
515
+ currentAssistant: { id: 'asst_host', name: 'DeepSeekQA', providerID: 'opencode-go', modelID: 'deepseek-v4-flash' },
516
+ })
517
+ const prompts = []
518
+ function AgentImpl(options) {
519
+ this.state = { ...options.initialState, messages: [] }
520
+ this.prompt = async (text) => {
521
+ prompts.push(text)
522
+ this.state.messages.push({ role: 'user', content: text, timestamp: Date.now() })
523
+ if (text === MISSED_FENCE_RETRY_USER_TEXT) {
524
+ const tool = this.state.tools.find((item) => item.name === MESSAGE_ASSISTANT_TOOL_NAME)
525
+ const result = await tool.execute('call_retry', { to: 'PeerQA', text: 'hello-from-assistant 写好了' })
526
+ this.state.messages.push(
527
+ { role: 'assistant', content: [{ type: 'text', text: '' }] },
528
+ {
529
+ role: 'toolResult',
530
+ toolName: MESSAGE_ASSISTANT_TOOL_NAME,
531
+ content: result.content,
532
+ details: result.details,
533
+ },
534
+ )
535
+ return
536
+ }
537
+ this.state.messages.push({
538
+ role: 'assistant',
539
+ content: [{ type: 'text', text: '好的,我去说一声。' }],
540
+ })
541
+ }
542
+ }
543
+
544
+ const result = await runContactTurn({
545
+ assistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', defaultPrompt: '' },
546
+ history: [],
547
+ userText: '给 PeerQA 说一声 hello-from-assistant 写好了',
548
+ createChatCompletion: vi.fn(),
549
+ tools,
550
+ AgentImpl,
551
+ })
552
+ expect(prompts).toEqual([
553
+ '给 PeerQA 说一声 hello-from-assistant 写好了',
554
+ MISSED_FENCE_RETRY_USER_TEXT,
555
+ ])
556
+ expect(deliverPeerMessage).toHaveBeenCalledWith({
557
+ toAssistantID: 'asst_peer',
558
+ text: 'hello-from-assistant 写好了',
559
+ })
560
+ expect(result.cards).toEqual([])
561
+ expect(result.bubbles.join('')).toContain('Sent to PeerQA')
562
+ expect(result.bubbles.join('')).not.toContain('好的,我去说一声')
563
+ })
564
+
565
+ it('keeps only the new_conversation confirm and drops leftover attachment text', async () => {
566
+ const resetContact = vi.fn(async () => ({ reset: true }))
567
+ const tools = createContactTools({ resetContact })
568
+ function AgentImpl(options) {
569
+ this.state = { ...options.initialState, messages: options.initialState.messages }
570
+ this.prompt = async () => {
571
+ const tool = this.state.tools.find((item) => item.name === NEW_CONVERSATION_TOOL_NAME)
572
+ const result = await tool.execute('call_reset', {})
573
+ this.state.messages = [
574
+ ...this.state.messages,
575
+ {
576
+ role: 'assistant',
577
+ content: [{ type: 'text', text: 'I still see your dot.png and note.txt from earlier.' }],
578
+ },
579
+ {
580
+ role: 'toolResult',
581
+ toolName: NEW_CONVERSATION_TOOL_NAME,
582
+ content: result.content,
583
+ details: result.details,
584
+ },
585
+ ]
586
+ }
587
+ }
588
+
589
+ const result = await runContactTurn({
590
+ assistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', defaultPrompt: '' },
591
+ history: [
592
+ { role: 'user', content: 'look', parts: [{ type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'dot.png' }] },
593
+ { role: 'assistant', content: 'got the image' },
594
+ ],
595
+ userText: '开新对话',
596
+ createChatCompletion: vi.fn(),
597
+ tools,
598
+ AgentImpl,
599
+ })
600
+ expect(resetContact).toHaveBeenCalledTimes(1)
601
+ expect(result.reset).toBe(true)
602
+ expect(result.cards).toEqual([])
603
+ expect(result.bubbles).toEqual([NEW_CONVERSATION_CONFIRM_BUBBLE])
604
+ expect(result.bubbles.join('')).not.toContain('dot.png')
605
+ expect(result.bubbles.join('')).not.toContain('note.txt')
606
+ })
607
+ })
@@ -1,17 +1,57 @@
1
1
  import { AssistantError, createAssistantsService } from './service.js';
2
+ import { createChatCompletion } from '../llm/completions.js';
3
+ import { ensureLlmTempDirectory } from '../llm/temp-directory.js';
4
+ import { setAssignedSessionSettleHandler } from '../session-goal/runtime.js';
5
+ import { createOpencodeClient } from '@opencode-ai/sdk/v2';
2
6
 
3
- const respond = (res, work, success = 200) => Promise.resolve().then(work).then((body) => res.status(success).json(body)).catch((error) => { const code = error instanceof AssistantError ? error.code : 'internal_error'; const status = code === 'not_found' ? 404 : ['revision_conflict', 'idempotency_conflict'].includes(code) ? 409 : code === 'assistant_disabled' ? 403 : code === 'upstream_error' ? 502 : 400; res.status(status).json({ ok: false, error: code }); });
7
+ const respond = (res, work, success = 200) => Promise.resolve().then(work).then((body) => res.status(success).json(body)).catch((error) => { const code = error instanceof AssistantError ? error.code : error?.code || 'internal_error'; const status = code === 'not_found' ? 404 : ['revision_conflict', 'idempotency_conflict'].includes(code) ? 409 : code === 'assistant_disabled' ? 403 : code === 'no_provider' ? 400 : code === 'upstream_error' ? 502 : 400; res.status(status).json({ ok: false, error: code, message: typeof error?.message === 'string' && error.message.trim() ? error.message : code }); });
4
8
  const gone = (_req, res) => res.status(410).json({ ok: false, error: 'assistant_topics_retired' });
5
9
 
6
10
  export const registerAssistantRoutes = (app, dependencies) => {
7
- const service = createAssistantsService({ dbPath: dependencies.dbPath, dataDir: dependencies.openchamberDataDir, buildOpenCodeUrl: dependencies.buildOpenCodeUrl, getOpenCodeAuthHeaders: dependencies.getOpenCodeAuthHeaders, getServerId: dependencies.getServerId, getAllowedRoots: dependencies.getAllowedRoots, globalEventHub: dependencies.globalEventHub, onRevisionTip: dependencies.onRevisionTip });
11
+ const boundCompletion = (input) => createChatCompletion({
12
+ ...input,
13
+ buildOpenCodeUrl: dependencies.buildOpenCodeUrl,
14
+ getOpenCodeAuthHeaders: dependencies.getOpenCodeAuthHeaders,
15
+ clientFactory: () => createOpencodeClient({
16
+ baseUrl: dependencies.buildOpenCodeUrl('/', '').replace(/\/$/, ''),
17
+ headers: dependencies.getOpenCodeAuthHeaders(),
18
+ }),
19
+ ensureTempDirectory: ensureLlmTempDirectory,
20
+ // Throwaway generate may subscribe for real token deltas (contact harness).
21
+ globalEventHub: input?.globalEventHub ?? dependencies.globalEventHub,
22
+ });
23
+ const service = createAssistantsService({
24
+ dbPath: dependencies.dbPath,
25
+ dataDir: dependencies.openchamberDataDir,
26
+ buildOpenCodeUrl: dependencies.buildOpenCodeUrl,
27
+ getOpenCodeAuthHeaders: dependencies.getOpenCodeAuthHeaders,
28
+ getServerId: dependencies.getServerId,
29
+ getAllowedRoots: dependencies.getAllowedRoots,
30
+ listProjects: dependencies.listProjects,
31
+ listScheduledTasks: dependencies.listScheduledTasks,
32
+ sessionIndexService: dependencies.sessionIndexService,
33
+ upsertScheduledTask: dependencies.upsertScheduledTask,
34
+ syncScheduledTaskProject: dependencies.syncScheduledTaskProject,
35
+ globalEventHub: dependencies.globalEventHub,
36
+ onRevisionTip: dependencies.onRevisionTip,
37
+ onContactTurnEvent: dependencies.onContactTurnEvent,
38
+ onContactTurnComplete: dependencies.onContactTurnComplete,
39
+ createChatCompletion: boundCompletion,
40
+ });
41
+ setAssignedSessionSettleHandler(({ sessionId, status }) => service.reportAssignedSessionSettle(sessionId, status));
8
42
  app.use('/api/openchamber/assistants', (req, res, next) => Promise.resolve(dependencies.refreshAllowedRoots?.()).then(next).catch((error) => respond(res, () => { throw error; })));
9
43
  app.get('/api/openchamber/assistants/capability', (_req, res) => respond(res, () => service.capability()));
10
44
  app.get('/api/openchamber/assistants', (_req, res) => respond(res, () => service.snapshot())); app.get('/api/openchamber/assistants/snapshot', (_req, res) => respond(res, () => service.snapshot())); app.put('/api/openchamber/assistants/settings', (req, res) => respond(res, () => service.setEnabled(req.body)));
11
45
  app.post('/api/openchamber/assistants', (req, res) => respond(res, () => service.createAssistant(req.body), 201)); app.patch('/api/openchamber/assistants/:assistantID', (req, res) => respond(res, () => service.updateAssistant(req.params.assistantID, req.body))); app.delete('/api/openchamber/assistants/:assistantID', (req, res) => respond(res, () => service.removeAssistant(req.params.assistantID, req.body?.expectedRevision)));
12
46
  app.post('/api/openchamber/assistants/:assistantID/session/ensure', (req, res) => respond(res, () => service.ensure(req.params.assistantID))); app.post('/api/openchamber/assistants/:assistantID/session/new', (req, res) => respond(res, () => service.createNew(req.params.assistantID))); app.post('/api/openchamber/assistants/:assistantID/session/compact', (req, res) => respond(res, () => service.compact(req.params.assistantID, req.body))); app.post('/api/openchamber/assistants/:assistantID/session/abort', (req, res) => respond(res, () => service.abort(req.params.assistantID, req.body)));
13
47
  app.get('/api/openchamber/assistants/:assistantID/messages', (req, res) => respond(res, () => service.historicalMessages(req.params.assistantID, req.query)));
14
- app.post('/api/openchamber/assistants/:assistantID/messages', (req, res) => respond(res, () => service.send(req.params.assistantID, req.body))); app.post('/api/openchamber/assistants/:assistantID/share', (req, res) => respond(res, () => service.share(req.params.assistantID, req.body), 202)); app.get('/api/openchamber/assistants/share-operations/:operationID', (req, res) => respond(res, () => { const operation = service.shareOperation(req.params.operationID); if (!operation) throw new AssistantError('not_found'); return operation; }));
48
+ app.get('/api/openchamber/assistants/:assistantID/contact/messages', (req, res) => respond(res, () => service.contactMessages(req.params.assistantID, req.query)));
49
+ app.post('/api/openchamber/assistants/:assistantID/contact/reset', (req, res) => respond(res, () => service.resetContact(req.params.assistantID)));
50
+ app.post('/api/openchamber/assistants/:assistantID/contact/cards', (req, res) => respond(res, () => service.appendContactCard(req.params.assistantID, req.body), 201));
51
+ app.post('/api/openchamber/assistants/:assistantID/contact/dm', (req, res) => respond(res, () => service.deliverPeerMessage(req.params.assistantID, req.body), 201));
52
+ app.get('/api/openchamber/assistants/:assistantID/scheduled-tasks', (req, res) => respond(res, () => service.listAssistantScheduledTasks(req.params.assistantID)));
53
+ // Contact send admits the user message at 202; assistant bubbles stream on SSE.
54
+ app.post('/api/openchamber/assistants/:assistantID/messages', (req, res) => respond(res, () => service.send(req.params.assistantID, req.body), 202)); app.post('/api/openchamber/assistants/:assistantID/share', (req, res) => respond(res, () => service.share(req.params.assistantID, req.body), 202)); app.get('/api/openchamber/assistants/share-operations/:operationID', (req, res) => respond(res, () => { const operation = service.shareOperation(req.params.operationID); if (!operation) throw new AssistantError('not_found'); return operation; }));
15
55
  app.use('/api/openchamber/assistants/topics', gone); app.use('/api/openchamber/assistants/:assistantID/topics', gone);
16
- return { service, close: () => service.close() };
56
+ return { service, close: () => { setAssignedSessionSettleHandler(null); service.close(); } };
17
57
  };