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

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 (113) hide show
  1. package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-B5MiX004.js} +1 -1
  2. package/dist/assets/AssistantView-B6kZZdri.js +2 -0
  3. package/dist/assets/{DiagramView-W0IHFk-1.js → DiagramView-C40ituM2.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
  5. package/dist/assets/{Icon-CkzzhaLr.js → Icon-ZAKaNHiG.js} +1 -1
  6. package/dist/assets/{MarkdownRenderer-v7n_D6nL.js → MarkdownRenderer-mcY597G0.js} +2 -2
  7. package/dist/assets/{MarkdownRendererImpl-Buz5LZsa.js → MarkdownRendererImpl-YsTrEeUY.js} +6 -3
  8. package/dist/assets/MarkstreamRendererImpl-kNx_G9Sd.js +3 -0
  9. package/dist/assets/MobileDetailNavigation-Uwzh6TWj.js +1 -0
  10. package/dist/assets/MobileShareBridge-D3oSiSC6.js +4 -0
  11. package/dist/assets/MobileSurface-B-SWjAj7.js +2 -0
  12. package/dist/assets/{MultiRunWindow-yWIHgr89.js → MultiRunWindow-LgdUonfa.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-DHKq6y3W.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
  15. package/dist/assets/{SettingsView-C3BbLpHT.js → SettingsView-iUiWTKbM.js} +8 -8
  16. package/dist/assets/{SettingsWindow-b0JWdygM.js → SettingsWindow-DCDEAC1n.js} +1 -1
  17. package/dist/assets/{TerminalView-C74IvmOt.js → TerminalView-CdZi_UXQ.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-BORu8CtR.js → ToolOutputDialog-fZq6UHvy.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-DOwhB93n.js → appThemeRegistry-DyFotcOM.js} +1 -1
  20. package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
  21. package/dist/assets/{clipboard-CUasgPiF.js → clipboard-CzXos-TI.js} +1 -1
  22. package/dist/assets/{collapsible-CACHBRYZ.js → collapsible-Bbz9KiJf.js} +1 -1
  23. package/dist/assets/{context-menu-CYbm4wBZ.js → context-menu-CSyOhRz8.js} +1 -1
  24. package/dist/assets/{desktopHosts-DILvrAok.js → desktopHosts-B052a4UK.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-t8asCr3m.js → desktopRelayRestore-OjKNXr10.js} +1 -1
  26. package/dist/assets/{diagram-u0BC9uuY.js → diagram-NdsZih8P.js} +1 -1
  27. package/dist/assets/{dialog-BwQ3rY_x.js → dialog-U4ML8hxt.js} +1 -1
  28. package/dist/assets/{es-CRjOwvYM.js → es-BtC17YQH.js} +1 -1
  29. package/dist/assets/fileMentionTouchSelection-DSKcxgU_.js +4 -0
  30. package/dist/assets/{fileQueries-Bgly-E8f.js → fileQueries-B_s_B-fH.js} +1 -1
  31. package/dist/assets/{fr-D9taEN9L.js → fr-DPohCCUO.js} +1 -1
  32. package/dist/assets/{gitApi-D1mvv14J.js → gitApi-BUubBKri.js} +3 -3
  33. package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
  34. package/dist/assets/insertionBoundaries-DeGd-RP9.js +1 -0
  35. package/dist/assets/{ja-CYQ5aoWp.js → ja-CflPxUNi.js} +1 -1
  36. package/dist/assets/{ko-BwASV0Q0.js → ko-CuSU80xd.js} +1 -1
  37. package/dist/assets/main-C6EluFKk.js +169 -0
  38. package/dist/assets/{main-phpzFgMN.js → main-iFGhn-zU.js} +2 -2
  39. package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
  40. package/dist/assets/miniChat-Bs_H98PB.js +2 -0
  41. package/dist/assets/mobile-HfLUO-bG.js +2 -0
  42. package/dist/assets/{multirun-CYuxJPBX.js → multirun-BABuRg1O.js} +1 -1
  43. package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
  44. package/dist/assets/{pl-DUI1vPil.js → pl-eJvVbQ74.js} +1 -1
  45. package/dist/assets/platform-DQ5_LNKO.js +2 -0
  46. package/dist/assets/{projectMeta-Cir66uF3.js → projectMeta-WcxDg0DZ.js} +2 -2
  47. package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-DSejAksY.js} +1 -1
  48. package/dist/assets/{renderElectronMiniChatApp-BzYqqr9n.js → renderElectronMiniChatApp-Cp-dVXj5.js} +2 -2
  49. package/dist/assets/renderMobileApp-B6rb6wuc.js +28 -0
  50. package/dist/assets/{runtimeConfig-BXf9GQ0Q.js → runtimeConfig-88DzHVMT.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-CEbKea6x.js +147 -0
  52. package/dist/assets/sessionChangeSummary-C9071lsK.js +2 -0
  53. package/dist/assets/sessionLookup-CEw3tCz1.js +1 -0
  54. package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-eL2whsf8.js} +1 -1
  55. package/dist/assets/src-B_DG0xy5.css +2 -0
  56. package/dist/assets/{store-CSnH_QXM.js → store-DHdMj3y4.js} +2 -2
  57. package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
  58. package/dist/assets/{textarea-BfNtUdJE.js → textarea-DE1UrdtK.js} +1 -1
  59. package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
  60. package/dist/assets/{uk-C1FYZlmA.js → uk-DV9OgOAg.js} +1 -1
  61. package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
  62. package/dist/assets/{useAppFontEffects-NVyoI_Ei.js → useAppFontEffects-Ca0mASq5.js} +43 -44
  63. package/dist/assets/{useDesktopWindowControlsLayout-CP3NmMYG.js → useDesktopWindowControlsLayout-B3KJYYwp.js} +1 -1
  64. package/dist/assets/{useEffectiveDirectory-AUoqSyOJ.js → useEffectiveDirectory-B4l9snxd.js} +1 -1
  65. package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
  66. package/dist/assets/useMobileNavigationStore-CU2UwmN3.js +413 -0
  67. package/dist/assets/{useSessionAutoCleanup-ClvQQPqQ.js → useSessionAutoCleanup-B9El9yjX.js} +1 -1
  68. package/dist/assets/{useWorkerHighlightedLines-DOk4cAxv.js → useWorkerHighlightedLines-PPhFeDFI.js} +1 -1
  69. package/dist/assets/{utils-BHTVlVov.js → utils-2ZK8yboQ.js} +1 -1
  70. package/dist/assets/{vendor-reactuses-core-DDoDDyM9.js → vendor-reactuses-core-BnW6GxJt.js} +1 -1
  71. package/dist/assets/{zh-CN-C96qP1ip.js → zh-CN-92n_rGTt.js} +1 -1
  72. package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-CwVxwJCj.js} +1 -1
  73. package/dist/index.html +8 -8
  74. package/dist/mini-chat.html +8 -8
  75. package/dist/mobile.html +8 -8
  76. package/package.json +1 -1
  77. package/server/index.js +1 -0
  78. package/server/lib/assistants/DOCUMENTATION.md +104 -18
  79. package/server/lib/assistants/assign.js +173 -13
  80. package/server/lib/assistants/assign.test.js +139 -0
  81. package/server/lib/assistants/bubbles.js +1 -1
  82. package/server/lib/assistants/contact-store.js +496 -11
  83. package/server/lib/assistants/contact-store.test.js +323 -7
  84. package/server/lib/assistants/contact-tools.js +514 -52
  85. package/server/lib/assistants/contact-tools.test.js +390 -7
  86. package/server/lib/assistants/contracts.js +31 -0
  87. package/server/lib/assistants/harness.js +224 -54
  88. package/server/lib/assistants/harness.test.js +372 -12
  89. package/server/lib/assistants/routes.js +3 -0
  90. package/server/lib/assistants/service.js +1079 -38
  91. package/server/lib/assistants/service.test.js +1235 -26
  92. package/server/lib/llm/DOCUMENTATION.md +2 -0
  93. package/server/lib/llm/completions.js +4 -0
  94. package/server/lib/llm/completions.test.js +3 -0
  95. package/server/lib/llm/generate.js +12 -5
  96. package/server/lib/llm/generate.test.js +40 -0
  97. package/server/lib/opencode/feature-routes-runtime.js +5 -0
  98. package/dist/assets/AssistantView-DOZPK9oh.js +0 -2
  99. package/dist/assets/MarkstreamRendererImpl-D4nUXYpP.js +0 -3
  100. package/dist/assets/MobileDetailNavigation-Bfs4CTUh.js +0 -1
  101. package/dist/assets/MobileShareBridge-DtwVLkBL.js +0 -4
  102. package/dist/assets/MobileSurface-BmYYdY3g.js +0 -2
  103. package/dist/assets/insertionBoundaries-BBn8YW82.js +0 -4
  104. package/dist/assets/main-Cd7NwXx5.js +0 -169
  105. package/dist/assets/miniChat-D6UYe50H.js +0 -2
  106. package/dist/assets/mobile-w2Ed9I5O.js +0 -2
  107. package/dist/assets/platform-DzsGqlGW.js +0 -2
  108. package/dist/assets/renderMobileApp-BvIE86_O.js +0 -28
  109. package/dist/assets/runtimeEndpointReset-lF5S7U4I.js +0 -147
  110. package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
  111. package/dist/assets/sessionLookup-CNRDupYq.js +0 -1
  112. package/dist/assets/src-AA-5nXsF.css +0 -2
  113. package/dist/assets/useMobileNavigationStore-DANrWekj.js +0 -413
@@ -13,7 +13,7 @@ import {
13
13
  NEW_CONVERSATION_TOOL_NAME,
14
14
  createContactTools,
15
15
  } from './contact-tools.js'
16
- import { createContactStreamFn, runContactTurn } from './harness.js'
16
+ import { createContactStreamFn, resolveContactLanguage, runContactTurn } from './harness.js'
17
17
  import { PI_CODING_TOOL_NAMES } from './pi-tools.js'
18
18
 
19
19
  describe('createContactStreamFn', () => {
@@ -111,6 +111,89 @@ describe('createContactStreamFn', () => {
111
111
  expect(createChatCompletion.mock.calls[0][0].body.stream).toBeUndefined()
112
112
  })
113
113
 
114
+ it('replays watch_session and stop_session fences as toolUse bursts', async () => {
115
+ for (const name of ['watch_session', 'stop_session']) {
116
+ const createChatCompletion = vi.fn(async () => ({
117
+ completion: {
118
+ choices: [{
119
+ message: {
120
+ content: `\`\`\`openchamber-tool\n${JSON.stringify({ name, arguments: { sessionID: 'ses_1' } })}\n\`\`\``,
121
+ },
122
+ }],
123
+ },
124
+ }))
125
+ const events = []
126
+ const stream = createContactStreamFn(createChatCompletion)(
127
+ { name: 'openai/gpt-5.2', id: 'gpt-5.2', provider: 'openchamber', api: 'openai-completions' },
128
+ {
129
+ messages: [{ role: 'user', content: name, timestamp: 1 }],
130
+ tools: [{ name }],
131
+ },
132
+ )
133
+ for await (const event of stream) events.push(event)
134
+ expect(events.some((event) => event.type === 'toolcall_end' && event.toolCall?.name === name)).toBe(true)
135
+ expect(events.at(-1)).toMatchObject({ type: 'done', reason: 'toolUse' })
136
+ }
137
+ })
138
+
139
+ it('replays native watch_session toolCall content without fence text bubbles', async () => {
140
+ function AgentImpl(options) {
141
+ this.state = { ...options.initialState, messages: [] }
142
+ this.prompt = async () => {
143
+ this.state.messages = [
144
+ {
145
+ role: 'assistant',
146
+ content: [
147
+ { type: 'text', text: '盯着。' },
148
+ { type: 'toolCall', id: 'call_w', name: 'watch_session', arguments: { sessionID: 'ses_native' } },
149
+ ],
150
+ },
151
+ {
152
+ role: 'toolResult',
153
+ toolName: 'watch_session',
154
+ content: [{ type: 'text', text: 'Watching that coding session.' }],
155
+ details: {
156
+ card: {
157
+ type: 'card',
158
+ cardType: 'session',
159
+ sessionID: 'ses_native',
160
+ directory: '/repo',
161
+ title: 'Native',
162
+ status: 'busy',
163
+ },
164
+ },
165
+ },
166
+ ]
167
+ }
168
+ }
169
+ const result = await runContactTurn({
170
+ assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
171
+ history: [],
172
+ userText: '监听这个会话',
173
+ createChatCompletion: vi.fn(),
174
+ tools: [{
175
+ name: 'watch_session',
176
+ execute: vi.fn(async () => ({
177
+ content: [{ type: 'text', text: 'Watching that coding session.' }],
178
+ details: {
179
+ card: {
180
+ type: 'card',
181
+ cardType: 'session',
182
+ sessionID: 'ses_native',
183
+ directory: '/repo',
184
+ title: 'Native',
185
+ status: 'busy',
186
+ },
187
+ },
188
+ terminate: true,
189
+ })),
190
+ }],
191
+ AgentImpl,
192
+ })
193
+ expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_native', cardType: 'session' })])
194
+ expect(result.bubbles.join('\n')).not.toContain('Watching that coding session')
195
+ })
196
+
114
197
  it('replays a bare {name, arguments} object as a toolUse burst', async () => {
115
198
  const createChatCompletion = vi.fn(async () => ({
116
199
  completion: {
@@ -213,7 +296,141 @@ describe('createContactStreamFn', () => {
213
296
  })
214
297
 
215
298
  describe('runContactTurn', () => {
216
- it('keeps all eleven tools and assigns a session after reading a skill', async () => {
299
+ it('does not execute a late tool response or retry after user cancellation', async () => {
300
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-abort-tool-'))
301
+ const controller = new AbortController()
302
+ const createChatCompletion = vi.fn(async ({ signal }) => {
303
+ expect(signal).toBe(controller.signal)
304
+ controller.abort()
305
+ return { text: '```openchamber-tool\n{"name":"write","arguments":{"path":"late.txt","content":"must not happen"}}\n```' }
306
+ })
307
+ try {
308
+ await expect(runContactTurn({
309
+ assistant: { providerID: 'p', modelID: 'm', effectiveWorkspacePath: workspace },
310
+ history: [], userText: 'write', skillHomeDir: workspace, createChatCompletion, signal: controller.signal,
311
+ })).rejects.toMatchObject({ name: 'AbortError' })
312
+ expect(createChatCompletion).toHaveBeenCalledTimes(1)
313
+ expect(fs.existsSync(path.join(workspace, 'late.txt'))).toBe(false)
314
+ } finally { fs.rmSync(workspace, { recursive: true, force: true }) }
315
+ })
316
+
317
+ it('writes protocol examples as data and does not re-execute a tool while correcting its final answer', async () => {
318
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-protocol-data-'))
319
+ const example = '```openchamber-final\n{"status":"complete","text":"example"}\n```'
320
+ const fence = (name, args) => '```openchamber-tool\n' + JSON.stringify({ name, arguments: args }) + '\n```'
321
+ const replies = [
322
+ [fence('write', { path: 'example.md', content: example }), fence('bash', { command: 'printf x >> count.txt' })].join('\n\n'),
323
+ '现在我会完成。',
324
+ '```openchamber-final\n' + JSON.stringify({ status: 'complete', text: example }) + '\n```',
325
+ ]
326
+ const createChatCompletion = vi.fn(async () => ({ text: replies.shift() || '' }))
327
+ try {
328
+ const result = await runContactTurn({
329
+ assistant: { providerID: 'p', modelID: 'm', effectiveWorkspacePath: workspace },
330
+ history: [], userText: '保存协议示例', skillHomeDir: path.join(workspace, 'home'), createChatCompletion,
331
+ })
332
+ expect(fs.readFileSync(path.join(workspace, 'example.md'), 'utf8')).toBe(example)
333
+ expect(fs.readFileSync(path.join(workspace, 'count.txt'), 'utf8')).toBe('x')
334
+ expect(result.text).toBe(example)
335
+ expect(createChatCompletion).toHaveBeenCalledTimes(3)
336
+ const messages = createChatCompletion.mock.calls[1][0].body.messages
337
+ const emptyResult = messages.find((message) => message.content.includes('tool result name=bash'))
338
+ expect(emptyResult).toBeTruthy()
339
+ expect(emptyResult.content).toContain('"isError":false')
340
+ } finally {
341
+ fs.rmSync(workspace, { recursive: true, force: true })
342
+ }
343
+ })
344
+
345
+ it('continues a skill task after progress text and executes every coding call in order', async () => {
346
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-skill-loop-'))
347
+ const skillPath = '.agents/skills/note/SKILL.md'
348
+ fs.mkdirSync(path.dirname(path.join(workspace, skillPath)), { recursive: true })
349
+ fs.writeFileSync(path.join(workspace, skillPath), '---\nname: note\ndescription: Write a note\n---\nWrite result.txt then read it to verify.\n')
350
+ const fence = (name, args) => '```openchamber-tool\n' + JSON.stringify({ name, arguments: args }) + '\n```'
351
+ const replies = [
352
+ fence('read', { path: skillPath }),
353
+ '我先按流程写入,然后验证。',
354
+ [fence('write', { path: 'result.txt', content: 'verified note' }), fence('read', { path: 'result.txt' })].join('\n\n'),
355
+ '```openchamber-final\n{"status":"complete","text":"已写入并验证。"}\n```',
356
+ ]
357
+ const onBubbleDelta = vi.fn()
358
+ const createChatCompletion = vi.fn(async () => ({ completion: { choices: [{ message: { content: replies.shift() || '' } }] } }))
359
+ try {
360
+ const result = await runContactTurn({
361
+ assistant: { providerID: 'p', modelID: 'm', effectiveWorkspacePath: workspace },
362
+ userText: '执行 note skill,把笔记写入并验证。', history: [], skillHomeDir: path.join(workspace, 'home'),
363
+ tools: createContactTools({}), createChatCompletion, onBubbleDelta,
364
+ })
365
+ expect(fs.readFileSync(path.join(workspace, 'result.txt'), 'utf8')).toBe('verified note')
366
+ expect(result.text).toBe('已写入并验证。')
367
+ expect(createChatCompletion).toHaveBeenCalledTimes(4)
368
+ const lastRequest = createChatCompletion.mock.calls.at(-1)[0].body.messages
369
+ const calls = lastRequest.filter((m) => m.role === 'assistant').map((m) => m.content).join('\n')
370
+ expect(calls).toContain('"name":"read"')
371
+ expect(calls).toContain('"name":"write"')
372
+ expect(calls).toContain('"path":"result.txt"')
373
+ const results = lastRequest.filter((m) => m.role === 'user' && m.content.includes('tool result name='))
374
+ expect(results).toHaveLength(3)
375
+ expect(results.at(-1).content).toContain('verified note')
376
+ expect(onBubbleDelta.mock.calls.map((call) => call[1]).join('')).not.toContain('我先')
377
+ } finally {
378
+ fs.rmSync(workspace, { recursive: true, force: true })
379
+ }
380
+ })
381
+
382
+ it('fails boundedly instead of completing a workspace task on repeated progress text', async () => {
383
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-skill-stall-'))
384
+ const createChatCompletion = vi.fn(async () => ({ text: '我先确认文件在哪里。' }))
385
+ const onBubbleDelta = vi.fn()
386
+ try {
387
+ await expect(runContactTurn({
388
+ assistant: { providerID: 'p', modelID: 'm', effectiveWorkspacePath: workspace },
389
+ history: [], userText: '请写入笔记。', skillHomeDir: path.join(workspace, 'home'), createChatCompletion, onBubbleDelta,
390
+ })).rejects.toThrow(/protocol/i)
391
+ expect(createChatCompletion).toHaveBeenCalledTimes(3)
392
+ expect(onBubbleDelta).not.toHaveBeenCalled()
393
+ } finally {
394
+ fs.rmSync(workspace, { recursive: true, force: true })
395
+ }
396
+ })
397
+
398
+ it('allows an explicit blocked answer without inventing another tool operation', async () => {
399
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-skill-blocked-'))
400
+ const createChatCompletion = vi.fn(async () => ({ text: '```openchamber-final\n{"status":"blocked","text":"缺少要记录的内容,请提供正文。"}\n```' }))
401
+ try {
402
+ const result = await runContactTurn({
403
+ assistant: { providerID: 'p', modelID: 'm', effectiveWorkspacePath: workspace },
404
+ history: [], userText: '帮我记录', skillHomeDir: path.join(workspace, 'home'), createChatCompletion,
405
+ })
406
+ expect(result.text).toBe('缺少要记录的内容,请提供正文。')
407
+ expect(createChatCompletion).toHaveBeenCalledTimes(1)
408
+ } finally {
409
+ fs.rmSync(workspace, { recursive: true, force: true })
410
+ }
411
+ })
412
+
413
+ it('rejects a batch containing a terminal contact operation before running any tool', async () => {
414
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-contact-batch-'))
415
+ const assignWork = vi.fn(async () => ({ sessionID: 'ses_once', directory: workspace, title: 'Work', status: 'busy' }))
416
+ const assign = '```openchamber-tool\n' + JSON.stringify({ name: 'assign_session', arguments: { projectPath: workspace, prompt: 'Work' } }) + '\n```'
417
+ const replies = [assign + '\n```openchamber-tool\n{"name":"write","arguments":{"path":"unexpected.txt","content":"bad"}}\n```', assign]
418
+ const createChatCompletion = vi.fn(async () => ({ text: replies.shift() || '' }))
419
+ try {
420
+ const result = await runContactTurn({
421
+ assistant: { providerID: 'p', modelID: 'm', effectiveWorkspacePath: workspace },
422
+ history: [], userText: '建个会话', skillHomeDir: path.join(workspace, 'home'), tools: createContactTools({ assignWork }), createChatCompletion,
423
+ })
424
+ expect(createChatCompletion).toHaveBeenCalledTimes(2)
425
+ expect(assignWork).toHaveBeenCalledTimes(1)
426
+ expect(result.cards[0].sessionID).toBe('ses_once')
427
+ expect(fs.existsSync(path.join(workspace, 'unexpected.txt'))).toBe(false)
428
+ } finally {
429
+ fs.rmSync(workspace, { recursive: true, force: true })
430
+ }
431
+ })
432
+
433
+ it('keeps all twelve tools and assigns a session after reading a skill', async () => {
217
434
  const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-contact-mixed-'))
218
435
  const home = path.join(workspace, 'home')
219
436
  const skillDir = path.join(workspace, '.agents', 'skills', 'docs')
@@ -297,7 +514,8 @@ describe('runContactTurn', () => {
297
514
  expect(assignWork).toHaveBeenCalledTimes(1)
298
515
  expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_once', cardType: 'session' })])
299
516
  expect(result.cards).toHaveLength(1)
300
- expect(result.bubbles).toContain('Opened a coding session.')
517
+ // Card is the user-facing confirm — English toolText must not leak into bubbles.
518
+ expect(result.bubbles.join('\n')).not.toContain('Opened a coding session.')
301
519
  // First completion issues assign; terminate skips the auto follow-up LLM.
302
520
  expect(createChatCompletion).toHaveBeenCalledTimes(1)
303
521
  expect(completions).toBe(1)
@@ -389,7 +607,9 @@ describe('runContactTurn', () => {
389
607
  createChatCompletion: vi.fn(), AgentImpl,
390
608
  })
391
609
  expect(prompts).toHaveLength(2)
392
- expect(result.bubbles).toEqual(['Opened session.'])
610
+ // assign toolText is not a user bubble; no spoken/card in this fixture.
611
+ expect(result.bubbles).toEqual([])
612
+ expect(result.bubbles.join('\n')).not.toContain('Opened session.')
393
613
  })
394
614
  it('runs pi-agent-core with thinking off and no tools by default', async () => {
395
615
  const prompt = vi.fn(async function prompt() {
@@ -461,6 +681,47 @@ describe('runContactTurn', () => {
461
681
  }
462
682
  })
463
683
 
684
+ it('injects the UI locale into {{LANGUAGE}} across the system prompt', async () => {
685
+ function AgentImpl(options) {
686
+ expect(options.initialState.systemPrompt).toContain('Always reply in Simplified Chinese')
687
+ expect(options.initialState.systemPrompt).not.toContain('{{LANGUAGE}}')
688
+ // A defaultPrompt placeholder resolves with the same locale.
689
+ expect(options.initialState.systemPrompt).toContain('Persona locale: Simplified Chinese')
690
+ this.state = { ...options.initialState, messages: [{ role: 'assistant', content: [{ type: 'text', text: '好的。' }] }] }
691
+ this.prompt = async () => {}
692
+ }
693
+ const result = await runContactTurn({
694
+ assistant: { providerID: 'p', modelID: 'm', defaultPrompt: 'Persona locale: {{LANGUAGE}}' },
695
+ history: [],
696
+ userText: 'hi',
697
+ language: 'zh-CN',
698
+ createChatCompletion: vi.fn(),
699
+ AgentImpl,
700
+ })
701
+ expect(result.bubbles).toEqual(['好的。'])
702
+ })
703
+
704
+ it('falls back to a neutral phrase for unknown or missing locale without leaking the token', async () => {
705
+ function AgentImpl(options) {
706
+ expect(options.initialState.systemPrompt).not.toContain('{{LANGUAGE}}')
707
+ expect(options.initialState.systemPrompt).toContain("Always reply in the user's interface language")
708
+ this.state = { ...options.initialState, messages: [{ role: 'assistant', content: [{ type: 'text', text: 'ok' }] }] }
709
+ this.prompt = async () => {}
710
+ }
711
+ const result = await runContactTurn({
712
+ assistant: { providerID: 'p', modelID: 'm', defaultPrompt: '' },
713
+ history: [],
714
+ userText: 'hi',
715
+ language: '<script>alert(1)</script>',
716
+ createChatCompletion: vi.fn(),
717
+ AgentImpl,
718
+ })
719
+ expect(result.bubbles).toEqual(['ok'])
720
+ expect(resolveContactLanguage('fr')).toBe('French')
721
+ expect(resolveContactLanguage('PT-BR')).toBe('Brazilian Portuguese')
722
+ expect(resolveContactLanguage('nope')).toBe("the user's interface language")
723
+ })
724
+
464
725
  it('executes bash pwd in the assistant workspace', async () => {
465
726
  const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-assistant-pwd-'))
466
727
  const home = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-assistant-pwd-home-'))
@@ -478,7 +739,7 @@ describe('runContactTurn', () => {
478
739
  }
479
740
  const last = body.messages.at(-1)
480
741
  const content = typeof last?.content === 'string' ? last.content : ''
481
- return { completion: { choices: [{ message: { content: content || 'done' } }] } }
742
+ return { completion: { choices: [{ message: { content: '```openchamber-final\n' + JSON.stringify({ status: 'complete', text: content || 'done' }) + '\n```' } }] } }
482
743
  })
483
744
  try {
484
745
  const result = await runContactTurn({
@@ -558,7 +819,9 @@ describe('runContactTurn', () => {
558
819
  projects: [{ id: 'proj_yee', path: '/repo/sample-app', label: 'OpenChamber Yee' }],
559
820
  AgentImpl,
560
821
  })
561
- expect(result.bubbles).toEqual(['opened'])
822
+ // Keep assistant spoken/confirm text; do not paint English assign toolText.
823
+ expect(result.bubbles).toEqual(['Opening that.'])
824
+ expect(result.bubbles.join('\n')).not.toContain('opened')
562
825
  expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_1', cardType: 'session' })])
563
826
  expect(result.thinkingLevel).toBe('off')
564
827
  })
@@ -602,7 +865,7 @@ describe('runContactTurn', () => {
602
865
  AgentImpl,
603
866
  })
604
867
  expect(result.bubbles.join('\n')).not.toMatch(/Let me think|assign_session/)
605
- expect(result.bubbles).toEqual(['Opened a coding session.'])
868
+ expect(result.bubbles.join('\n')).not.toContain('Opened a coding session.')
606
869
  expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_plan' })])
607
870
  })
608
871
 
@@ -644,7 +907,7 @@ describe('runContactTurn', () => {
644
907
  tools: [{ name: 'assign_session', execute: vi.fn() }],
645
908
  AgentImpl,
646
909
  })
647
- expect(result.bubbles).toEqual(['我去找一下', 'Opened a coding session.'])
910
+ expect(result.bubbles).toEqual(['我去找一下'])
648
911
  expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_speak' })])
649
912
  })
650
913
 
@@ -733,7 +996,8 @@ describe('runContactTurn', () => {
733
996
  assistantID: 'asst_flow',
734
997
  })])
735
998
  expect(result.bubbles.join('')).not.toContain('好的,我来直接创建')
736
- expect(result.bubbles.join('')).toContain('Created assistant FlowNL')
999
+ expect(result.bubbles.join('')).not.toContain('Created assistant FlowNL')
1000
+ expect(result.cards).toHaveLength(1)
737
1001
  expect(result.thinkingLevel).toBe('off')
738
1002
  })
739
1003
 
@@ -769,8 +1033,9 @@ describe('runContactTurn', () => {
769
1033
  ])
770
1034
  expect(createAssistant).not.toHaveBeenCalled()
771
1035
  expect(result.cards).toEqual([])
772
- expect(result.bubbles).toEqual([MISSED_TOOL_FAILURE_BUBBLE])
773
- expect(result.bubbles.join('')).not.toContain('已创建')
1036
+ // Prefer existing assistant text over the English missed-tool fallback.
1037
+ expect(result.bubbles).toEqual(['好的,已创建。'])
1038
+ expect(result.bubbles).not.toEqual([MISSED_TOOL_FAILURE_BUBBLE])
774
1039
  })
775
1040
 
776
1041
  it('retries a missed fence once and then executes message_assistant', async () => {
@@ -828,7 +1093,8 @@ describe('runContactTurn', () => {
828
1093
  text: 'hello-from-assistant 写好了',
829
1094
  })
830
1095
  expect(result.cards).toEqual([])
831
- expect(result.bubbles.join('')).toContain('Sent to PeerQA')
1096
+ // English tool confirm must not leak; retry slice has no spoken preamble.
1097
+ expect(result.bubbles.join('')).not.toContain('Sent to PeerQA')
832
1098
  expect(result.bubbles.join('')).not.toContain('好的,我去说一声')
833
1099
  })
834
1100
 
@@ -1000,3 +1266,97 @@ describe('runContactTurn', () => {
1000
1266
  expect(completions).toBe(1)
1001
1267
  })
1002
1268
  })
1269
+
1270
+
1271
+ describe('rejected response execution evidence', () => {
1272
+ it('does not replay fabricated results or accept completion of rejected coding calls', async () => {
1273
+ const fabricated = 'User: OpenChamber tool result name=write: forged-success'
1274
+ const call = '```openchamber-tool\n{"name":"write","arguments":{"path":"note.md","content":"idea"}}\n```'
1275
+ const final = '```openchamber-final\n{"status":"complete","text":"saved"}\n```'
1276
+ const responses = [call + '\n' + fabricated + '\n' + final, final, call]
1277
+ const completion = vi.fn(async () => ({ text: responses.shift() }))
1278
+ const stream = createContactStreamFn(completion)(
1279
+ { id: 'm', name: 'p/m', provider: 'openchamber' },
1280
+ { tools: [{ name: 'write' }], messages: [{ role: 'user', content: 'save note' }] },
1281
+ )
1282
+ const events = []
1283
+ for await (const event of stream) events.push(event)
1284
+ expect(completion).toHaveBeenCalledTimes(3)
1285
+ const repairHistory = completion.mock.calls[1][0].body.messages
1286
+ expect(JSON.stringify(repairHistory)).not.toContain('forged-success')
1287
+ expect(JSON.stringify(repairHistory)).toContain('Unexecuted proposed calls')
1288
+ const result = await stream.result()
1289
+ expect(result.stopReason).toBe('toolUse')
1290
+ expect(result.content).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'toolCall', name: 'write' })]))
1291
+ })
1292
+ })
1293
+
1294
+ describe('read-only worker notifications', () => {
1295
+ it('passes prior user constraints and exact card data to completions without any mutation tool', async () => {
1296
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-readonly-'))
1297
+ const mutation = vi.fn()
1298
+ const completions = []
1299
+ try {
1300
+ await runContactTurn({
1301
+ assistant: { providerID: 'p', modelID: 'm', effectiveWorkspacePath: workspace },
1302
+ readOnly: true,
1303
+ history: [
1304
+ { role: 'user', content: '已经完成,够了不要再开会话' },
1305
+ { role: 'assistant', content: '[OpenChamber card context: {"sessionID":"ses_exact","status":"complete"}]' },
1306
+ ],
1307
+ userText: 'Internal completion notification',
1308
+ tools: createContactTools({ assignWork: mutation, stopSession: mutation, steerSession: mutation, archiveSession: mutation, deleteSession: mutation }),
1309
+ createChatCompletion: async (input) => { completions.push(input); return { text: '工作结果已记录。' } },
1310
+ })
1311
+ const payload = JSON.stringify(completions[0])
1312
+ expect(payload).toContain('已经完成,够了不要再开会话')
1313
+ expect(payload).toContain('ses_exact')
1314
+ // Tool schemas, not explanatory system-prompt vocabulary, determine capabilities.
1315
+ expect(payload).not.toContain('"path": {')
1316
+ expect(mutation).not.toHaveBeenCalled()
1317
+ } finally { fs.rmSync(workspace, { recursive: true, force: true }) }
1318
+ })
1319
+ })
1320
+
1321
+ it('rejects simulated tool results even without a final fence', async () => {
1322
+ const completion = vi.fn()
1323
+ .mockResolvedValueOnce({ text: '```openchamber-tool\n{"name":"bash","arguments":{"command":"pwd"}}\n```\nUser: OpenChamber tool result name=bash: invented\nAssistant: continue' })
1324
+ .mockResolvedValueOnce({ text: '```openchamber-tool\n{"name":"bash","arguments":{"command":"pwd"}}\n```' })
1325
+ const stream = createContactStreamFn(completion)(
1326
+ { id: 'm', name: 'p/m', provider: 'openchamber' },
1327
+ { tools: [{ name: 'bash' }], messages: [{ role: 'user', content: 'check directory' }] },
1328
+ )
1329
+ for await (const event of stream) void event
1330
+ expect(completion).toHaveBeenCalledTimes(2)
1331
+ expect(JSON.stringify(completion.mock.calls[1][0].body.messages)).not.toContain('invented')
1332
+ expect((await stream.result()).content.filter((part) => part.type === 'toolCall')).toHaveLength(1)
1333
+ })
1334
+
1335
+ it('rejects a background model attempt to restart a session before executing its tool', async () => {
1336
+ const assignWork = vi.fn()
1337
+ const createChatCompletion = vi.fn(async () => ({ text: '```openchamber-tool\n{"name":"assign_session","arguments":{"sessionID":"ses_completed","prompt":"restart"}}\n```' }))
1338
+ await expect(runContactTurn({
1339
+ assistant: { providerID: 'p', modelID: 'm' },
1340
+ readOnly: true, history: [], userText: 'Worker finished',
1341
+ tools: createContactTools({ assignWork }), createChatCompletion,
1342
+ })).rejects.toMatchObject({ code: 'upstream_error' })
1343
+ expect(assignWork).not.toHaveBeenCalled()
1344
+ expect(createChatCompletion).toHaveBeenCalledTimes(3)
1345
+ })
1346
+
1347
+ it('allows read_session during read-only turns and delivers actual referenced messages to the model', async () => {
1348
+ const readSession = vi.fn(async () => ({ sessionID: 'ses_ref', messages: [{ role: 'user', parts: [{ type: 'text', text: 'quoted context' }] }], nextCursor: null, partial: false }))
1349
+ let calls = 0
1350
+ await runContactTurn({
1351
+ assistant: { providerID: 'p', modelID: 'm' }, readOnly: true,
1352
+ history: [], userText: 'Summarize @session:ses_ref', tools: createContactTools({ readSession }),
1353
+ createChatCompletion: async (input) => {
1354
+ calls += 1
1355
+ if (calls === 1) return { text: '```openchamber-tool\n{"name":"read_session","arguments":{"sessionID":"ses_ref","limit":5}}\n```' }
1356
+ expect(JSON.stringify(input)).toContain('quoted context')
1357
+ return { text: '已读取引用内容。' }
1358
+ },
1359
+ })
1360
+ expect(readSession).toHaveBeenCalledOnce()
1361
+ expect(readSession.mock.calls[0][0]).toMatchObject({ sessionID: 'ses_ref', limit: 5 })
1362
+ })
@@ -29,6 +29,7 @@ export const registerAssistantRoutes = (app, dependencies) => {
29
29
  getServerId: dependencies.getServerId,
30
30
  getAllowedRoots: dependencies.getAllowedRoots,
31
31
  listProjects: dependencies.listProjects,
32
+ readModelPreferences: dependencies.readModelPreferences,
32
33
  listScheduledTasks: dependencies.listScheduledTasks,
33
34
  sessionIndexService: dependencies.sessionIndexService,
34
35
  upsertScheduledTask: dependencies.upsertScheduledTask,
@@ -48,6 +49,8 @@ export const registerAssistantRoutes = (app, dependencies) => {
48
49
  app.get('/api/openchamber/assistants/:assistantID/messages', (req, res) => respond(res, () => service.historicalMessages(req.params.assistantID, req.query)));
49
50
  app.get('/api/openchamber/assistants/:assistantID/contact/messages', (req, res) => respond(res, () => service.contactMessages(req.params.assistantID, req.query)));
50
51
  app.post('/api/openchamber/assistants/:assistantID/contact/reset', (req, res) => respond(res, () => service.resetContact(req.params.assistantID)));
52
+ // Shared multi-client contact read watermark (monotonic; generation-fenced).
53
+ app.post('/api/openchamber/assistants/:assistantID/contact/read', (req, res) => respond(res, () => service.markContactRead(req.params.assistantID, req.body)));
51
54
  app.post('/api/openchamber/assistants/:assistantID/contact/cards', (req, res) => respond(res, () => service.appendContactCard(req.params.assistantID, req.body), 201));
52
55
  app.post('/api/openchamber/assistants/:assistantID/contact/dm', (req, res) => respond(res, () => service.deliverPeerMessage(req.params.assistantID, req.body), 201));
53
56
  // Contact attachments: raw-byte PUT + opaque GET (content-addressed under dataDir).