@openchambery/web 1.19.15-beta.4 → 1.19.15

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 (52) hide show
  1. package/dist/assets/{AssistantView-bxT9mr97.js → AssistantView-xdL1EvpR.js} +2 -2
  2. package/dist/assets/{DiagramView-DJPrdklU.js → DiagramView-DPgkYU-_.js} +1 -1
  3. package/dist/assets/MarkdownRenderer-CP7UVZdr.js +2 -0
  4. package/dist/assets/{MarkdownRendererImpl-CBdwjEMH.js → MarkdownRendererImpl-BB1Q1RWk.js} +1 -1
  5. package/dist/assets/{MarkstreamRendererImpl-CHwYXkzv.js → MarkstreamRendererImpl-BjQrUIcq.js} +1 -1
  6. package/dist/assets/{MobileDetailNavigation-DLmATOi0.js → MobileDetailNavigation-Cyzs6cuR.js} +1 -1
  7. package/dist/assets/{MobileShareBridge-BbvoAeXg.js → MobileShareBridge-A-Z6jMUn.js} +1 -1
  8. package/dist/assets/MobileSurface-DjDa20gx.js +2 -0
  9. package/dist/assets/{MultiRunWindow-B7XPMkdc.js → MultiRunWindow-Cg5x-kJV.js} +1 -1
  10. package/dist/assets/{SettingsView-DABJm3u8.js → SettingsView-CYrTHDdp.js} +1 -1
  11. package/dist/assets/{SettingsWindow-D7n0m-52.js → SettingsWindow-CF-0Zgxr.js} +1 -1
  12. package/dist/assets/{TerminalView-DERnAkUi.js → TerminalView-CShou18D.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-Bx3plCCc.js → ToolOutputDialog-BvmxlNOa.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-BdHiNL-J.js → appThemeRegistry-iqSTUpPj.js} +1 -1
  15. package/dist/assets/{fileMentionTouchSelection-B3sIMQCF.js → fileMentionTouchSelection-CddGH2aH.js} +1 -1
  16. package/dist/assets/{gitApi-CDc5ynsh.js → gitApi-BwuaHHRQ.js} +1 -1
  17. package/dist/assets/{insertionBoundaries-3yNePddt.js → insertionBoundaries-og6NBgdn.js} +1 -1
  18. package/dist/assets/{main-5GGJlyEB.js → main-31g3JiQx.js} +4 -4
  19. package/dist/assets/{main-C_21O0IU.js → main-VD-QBM47.js} +2 -2
  20. package/dist/assets/{miniChat-Ti_tFK96.js → miniChat-DwCMTamY.js} +2 -2
  21. package/dist/assets/{mobile-BUYP8DBL.js → mobile-C7q1yowe.js} +2 -2
  22. package/dist/assets/{multirun-CXazK6qX.js → multirun-DcN5T0lN.js} +1 -1
  23. package/dist/assets/{projectMeta-sXx-r5jn.js → projectMeta-moNJYCK4.js} +1 -1
  24. package/dist/assets/{renderElectronMiniChatApp-CLx8jNP4.js → renderElectronMiniChatApp-DVkgKA6A.js} +2 -2
  25. package/dist/assets/{renderMobileApp-BGKxwMFU.js → renderMobileApp-BgEXZhCO.js} +4 -4
  26. package/dist/assets/{runtimeConfig-YCy1Bcb2.js → runtimeConfig-B49cxtgI.js} +1 -1
  27. package/dist/assets/{runtimeEndpointReset-Dh3YvmRM.js → runtimeEndpointReset-9K_Dj1qR.js} +1 -1
  28. package/dist/assets/{sessionLookup-BzkA2pF0.js → sessionLookup-m3h8c4xR.js} +1 -1
  29. package/dist/assets/{useAppFontEffects-BLw4Av7h.js → useAppFontEffects-DRFi4TCE.js} +5 -5
  30. package/dist/assets/{useDesktopWindowControlsLayout-DNDIYvwP.js → useDesktopWindowControlsLayout-BdNR3zMN.js} +1 -1
  31. package/dist/assets/{useEffectiveDirectory-B_3sINMx.js → useEffectiveDirectory-COoPja_2.js} +1 -1
  32. package/dist/assets/{useMobileNavigationStore-BBVnSn9p.js → useMobileNavigationStore-Cn0-ozUB.js} +1 -1
  33. package/dist/assets/{useSessionAutoCleanup-SsGxsSRB.js → useSessionAutoCleanup-XwgyzsEd.js} +1 -1
  34. package/dist/assets/{useWorkerHighlightedLines-WCuVI6O_.js → useWorkerHighlightedLines-GgcqEoKY.js} +1 -1
  35. package/dist/index.html +2 -2
  36. package/dist/mini-chat.html +2 -2
  37. package/dist/mobile.html +2 -2
  38. package/package.json +1 -1
  39. package/server/lib/assistants/DOCUMENTATION.md +31 -6
  40. package/server/lib/assistants/contact-tools.js +1 -16
  41. package/server/lib/assistants/contact-tools.test.js +0 -27
  42. package/server/lib/assistants/harness.js +140 -133
  43. package/server/lib/assistants/harness.test.js +207 -43
  44. package/server/lib/assistants/service.js +10 -33
  45. package/server/lib/assistants/service.test.js +75 -3
  46. package/server/lib/llm/DOCUMENTATION.md +8 -1
  47. package/server/lib/llm/completions.js +5 -0
  48. package/server/lib/llm/completions.test.js +19 -0
  49. package/server/lib/llm/generate.js +5 -1
  50. package/server/lib/llm/generate.test.js +23 -0
  51. package/dist/assets/MarkdownRenderer-4gjE7aM5.js +0 -2
  52. package/dist/assets/MobileSurface-BBOx43VU.js +0 -2
@@ -3,13 +3,10 @@ import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { describe, expect, it, vi } from 'vitest'
5
5
  import {
6
- CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
7
6
  CLEAR_CHAT_HISTORY_TOOL_NAME,
8
7
  CREATE_ASSISTANT_TOOL_NAME,
9
8
  MESSAGE_ASSISTANT_TOOL_NAME,
10
9
  MISSED_FENCE_RETRY_USER_TEXT,
11
- MISSED_TOOL_FAILURE_BUBBLE,
12
- NEW_CONVERSATION_CONFIRM_BUBBLE,
13
10
  NEW_CONVERSATION_TOOL_NAME,
14
11
  createContactTools,
15
12
  } from './contact-tools.js'
@@ -22,6 +19,161 @@ import {
22
19
  import { PI_CODING_TOOL_NAMES } from './pi-tools.js'
23
20
 
24
21
  describe('createContactStreamFn', () => {
22
+ it('forwards the assistant variant while keeping private reasoning out of bubbles', async () => {
23
+ const onBubbleDelta = vi.fn();
24
+ const completion = vi.fn(async ({ onTextDelta }) => {
25
+ onTextDelta('Private reasoning');
26
+ return { text: '```openchamber-final\n{"status":"complete","text":"已完成。"}\n```' };
27
+ });
28
+ const result = await runContactTurn({
29
+ assistant: { providerID: 'p', modelID: 'm', variant: 'high' },
30
+ history: [], userText: '你好', createChatCompletion: completion, onBubbleDelta,
31
+ });
32
+ expect(completion.mock.calls.length).toBeGreaterThan(0);
33
+ for (const [request] of completion.mock.calls) expect(request.body.variant).toBe('high');
34
+ expect(result.bubbles).toEqual(['已完成。']);
35
+ expect(JSON.stringify(onBubbleDelta.mock.calls)).not.toContain('Private reasoning');
36
+ });
37
+
38
+ it('publishes a model-authored message while generation is pending, before the tool or final response', async () => {
39
+ let release;
40
+ const gate = new Promise((resolve) => { release = resolve })
41
+ const onBubbleDelta = vi.fn()
42
+ const prefix = '```openchamber-message\n{"text":"我先检查配置,再保存这次修改。"}\n```\n'
43
+ const tool = '```openchamber-tool\n{"name":"read","arguments":{"path":"config.json"}}\n```'
44
+ const createChatCompletion = vi.fn(async ({ onTextDelta }) => {
45
+ for (const char of prefix) onTextDelta(char)
46
+ await gate
47
+ onTextDelta(tool)
48
+ return { text: prefix + tool }
49
+ })
50
+ const stream = createContactStreamFn(createChatCompletion, { onBubbleDelta })(
51
+ { name: 'p/m', id: 'm', provider: 'openchamber', api: 'openai-completions' },
52
+ { messages: [], tools: [{ name: 'read' }] },
53
+ )
54
+ try {
55
+ await vi.waitFor(() => expect(onBubbleDelta).toHaveBeenCalledWith(0, '我先检查配置,再保存这次修改。', true))
56
+ expect(onBubbleDelta).toHaveBeenCalledTimes(1)
57
+ } finally { release() }
58
+ const result = await stream.result()
59
+ expect(projectStreamedContactTurnBubbles([result])).toEqual(['我先检查配置,再保存这次修改。'])
60
+ expect(result.content.at(-1)).toMatchObject({ type: 'toolCall', name: 'read' })
61
+ expect(onBubbleDelta).toHaveBeenCalledTimes(1)
62
+ })
63
+
64
+ it('keeps raw tokens, reasoning, partial public records and nested tool examples private', async () => {
65
+ const onBubbleDelta = vi.fn()
66
+ const controller = new AbortController()
67
+ const stream = createContactStreamFn(async ({ onTextDelta }) => {
68
+ onTextDelta('Private reasoning\n```openchamber-message\n{"text":"not public"}\n```')
69
+ controller.abort()
70
+ return { text: '' }
71
+ }, { onBubbleDelta, signal: controller.signal })(
72
+ { name: 'p/m', id: 'm', provider: 'openchamber', api: 'openai-completions' }, { messages: [] },
73
+ )
74
+ await stream.result()
75
+ expect(onBubbleDelta).not.toHaveBeenCalled()
76
+ })
77
+
78
+ it('uses the model reply after saving a prompt and keeps the stock tool confirmation private', async () => {
79
+ const tools = createContactTools({
80
+ updateAssistantSettings: async () => ({ updated: true, defaultPrompt: '简洁回复' }),
81
+ currentAssistant: { id: 'a', name: 'Config' },
82
+ })
83
+ const completion = vi.fn(async ({ body }) => {
84
+ const result = body.messages.find((m) => m.content.includes('OpenChamber tool result name=update_default_prompt'))
85
+ if (result) {
86
+ expect(result.content).toContain('Default prompt saved.')
87
+ return { text: '```openchamber-final\n{"status":"complete","text":"提示词已保存,接下来的对话会按这个设置回复。"}\n```' }
88
+ }
89
+ return { text: '```openchamber-message\n{"text":"我来保存这个默认提示词。"}\n```\n```openchamber-tool\n{"name":"update_default_prompt","arguments":{"prompt":"简洁回复"}}\n```' }
90
+ })
91
+ const onBubbleDelta = vi.fn()
92
+ const result = await runContactTurn({
93
+ assistant: { providerID: 'p', modelID: 'm' }, history: [], userText: '保存默认提示词',
94
+ tools, createChatCompletion: completion, language: 'zh-CN', onBubbleDelta,
95
+ })
96
+ expect(completion).toHaveBeenCalledTimes(2)
97
+ expect(result.bubbles).toEqual(['我来保存这个默认提示词。', '提示词已保存,接下来的对话会按这个设置回复。'])
98
+ expect(onBubbleDelta.mock.calls.map((call) => call[1])).toEqual(result.bubbles)
99
+ expect(result.text).not.toContain('Default prompt saved.')
100
+ })
101
+
102
+ it('preserves a published message on abort and suppresses later public records and tools', async () => {
103
+ const controller = new AbortController()
104
+ const onBubbleDelta = vi.fn()
105
+ const prefix = '```openchamber-message\n{"text":"我先确认设置。"}\n```\n'
106
+ const stream = createContactStreamFn(async ({ onTextDelta }) => {
107
+ onTextDelta(prefix)
108
+ controller.abort()
109
+ onTextDelta('```openchamber-message\n{"text":"这条迟到消息应丢弃。"}\n```')
110
+ return { text: prefix + '```openchamber-tool\n{"name":"write","arguments":{"path":"x","content":"y"}}\n```' }
111
+ }, { onBubbleDelta, signal: controller.signal })(
112
+ { name: 'p/m', id: 'm', provider: 'openchamber', api: 'openai-completions' },
113
+ { messages: [], tools: [{ name: 'write' }] },
114
+ )
115
+ expect((await stream.result()).stopReason).toBe('aborted')
116
+ expect(onBubbleDelta.mock.calls).toEqual([[0, '我先确认设置。', true]])
117
+ })
118
+
119
+ it('recovers an incomplete streamed message from the final response without duplicate delivery', async () => {
120
+ const onBubbleDelta = vi.fn()
121
+ const prefix = '```openchamber-message\n{"text":"先确认一下。"}\n```\n'
122
+ const stream = createContactStreamFn(async ({ onTextDelta }) => {
123
+ onTextDelta(prefix.slice(0, 30))
124
+ return { text: prefix + '结果已确认。' }
125
+ }, { onBubbleDelta })(
126
+ { name: 'p/m', id: 'm', provider: 'openchamber', api: 'openai-completions' }, { messages: [] },
127
+ )
128
+ expect((await stream.result()).stopReason).toBe('stop')
129
+ expect(onBubbleDelta.mock.calls).toEqual([[0, '先确认一下。', true], [1, '结果已确认。', true]])
130
+ })
131
+
132
+ it('preserves delivered public messages across a protocol correction without replaying rejected prose', async () => {
133
+ const onBubbleDelta = vi.fn()
134
+ const completion = vi.fn()
135
+ .mockResolvedValueOnce({ text: '```openchamber-message\n{"text":"我先检查配置。"}\n```\nI need to plan privately.' })
136
+ .mockResolvedValueOnce({ text: '```openchamber-final\n{"status":"blocked","text":"请提供配置文件位置。"}\n```' })
137
+ const stream = createContactStreamFn(completion, { onBubbleDelta })(
138
+ { name: 'p/m', id: 'm', provider: 'openchamber', api: 'openai-completions' },
139
+ { messages: [], tools: [{ name: 'read' }] },
140
+ )
141
+ const result = await stream.result()
142
+ expect(projectStreamedContactTurnBubbles([result])).toEqual(['我先检查配置。', '请提供配置文件位置。'])
143
+ expect(onBubbleDelta.mock.calls).toEqual([[0, '我先检查配置。', true], [1, '请提供配置文件位置。', true]])
144
+ expect(completion.mock.calls[1][0].body.messages).toContainEqual({ role: 'assistant', content: '我先检查配置。' })
145
+ expect(JSON.stringify(completion.mock.calls[1][0].body)).not.toContain('I need to plan privately.')
146
+ })
147
+
148
+ it.each(['{"text":42}', '{"text":""}', '{"text":"unterminated'])('rejects malformed public messages without exposing their records: %s', async (json) => {
149
+ const onBubbleDelta = vi.fn()
150
+ const text = '```openchamber-message\n' + json + '\n```'
151
+ const stream = createContactStreamFn(async ({ onTextDelta }) => {
152
+ onTextDelta(text)
153
+ return { text }
154
+ }, { onBubbleDelta })(
155
+ { name: 'p/m', id: 'm', provider: 'openchamber', api: 'openai-completions' }, { messages: [] },
156
+ )
157
+ expect((await stream.result()).stopReason).toBe('error')
158
+ expect(onBubbleDelta).not.toHaveBeenCalled()
159
+ })
160
+
161
+ it('keeps a completed reset committed when its model summary fails, with no canned reply', async () => {
162
+ const clearContactMemory = vi.fn(async () => ({ reset: true, memoryCleared: true }))
163
+ const onBubbleDelta = vi.fn()
164
+ const createChatCompletion = vi.fn()
165
+ .mockResolvedValueOnce({ text: '```openchamber-tool\n{"name":"new_conversation","arguments":{}}\n```' })
166
+ .mockRejectedValueOnce(new Error('model unavailable'))
167
+ await expect(runContactTurn({
168
+ assistant: { providerID: 'p', modelID: 'm', variant: 'high' }, history: [], userText: '清除记忆',
169
+ tools: createContactTools({ clearContactMemory }), createChatCompletion, onBubbleDelta,
170
+ })).rejects.toThrow('model unavailable')
171
+ expect(clearContactMemory).toHaveBeenCalledTimes(1)
172
+ expect(createChatCompletion).toHaveBeenCalledTimes(2)
173
+ expect(createChatCompletion.mock.calls.map(([input]) => input.body.variant)).toEqual(['high', 'high'])
174
+ expect(onBubbleDelta).not.toHaveBeenCalled()
175
+ })
176
+
25
177
  it('runs managed contact lookups from the user home with the coordination policy', async () => {
26
178
  const home = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-contact-home-'))
27
179
  const homeSpy = vi.spyOn(os, 'homedir').mockReturnValue(home)
@@ -207,7 +359,7 @@ describe('createContactStreamFn', () => {
207
359
  assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
208
360
  history: [],
209
361
  userText: '监听这个会话',
210
- createChatCompletion: vi.fn(),
362
+ createChatCompletion: vi.fn(async () => ({ text: '有结果会告诉你。' })),
211
363
  tools: [{
212
364
  name: 'watch_session',
213
365
  execute: vi.fn(async () => ({
@@ -488,14 +640,14 @@ describe('runContactTurn', () => {
488
640
  const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-contact-batch-'))
489
641
  const assignWork = vi.fn(async () => ({ sessionID: 'ses_once', directory: workspace, title: 'Work', status: 'busy' }))
490
642
  const assign = '```openchamber-tool\n' + JSON.stringify({ name: 'assign_session', arguments: { projectPath: workspace, prompt: 'Work' } }) + '\n```'
491
- const replies = [assign + '\n```openchamber-tool\n{"name":"write","arguments":{"path":"unexpected.txt","content":"bad"}}\n```', assign]
643
+ const replies = [assign + '\n```openchamber-tool\n{"name":"write","arguments":{"path":"unexpected.txt","content":"bad"}}\n```', assign, '任务已经交给工作会话。']
492
644
  const createChatCompletion = vi.fn(async () => ({ text: replies.shift() || '' }))
493
645
  try {
494
646
  const result = await runContactTurn({
495
647
  assistant: { providerID: 'p', modelID: 'm', effectiveWorkspacePath: workspace },
496
648
  history: [], userText: '建个会话', skillHomeDir: path.join(workspace, 'home'), tools: createContactTools({ assignWork }), createChatCompletion,
497
649
  })
498
- expect(createChatCompletion).toHaveBeenCalledTimes(2)
650
+ expect(createChatCompletion).toHaveBeenCalledTimes(3)
499
651
  expect(assignWork).toHaveBeenCalledTimes(1)
500
652
  expect(result.cards[0].sessionID).toBe('ses_once')
501
653
  expect(fs.existsSync(path.join(workspace, 'unexpected.txt'))).toBe(false)
@@ -520,6 +672,7 @@ describe('runContactTurn', () => {
520
672
  ]
521
673
  let call = 0
522
674
  const createChatCompletion = vi.fn(async ({ body }) => {
675
+ if (call === 3) return { text: replies[call++] }
523
676
  expect(body.messages[0].content).toContain('assign_session')
524
677
  expect(body.messages[0].content).toContain('docs')
525
678
  expect(body.messages[0].content).toContain('arguments schema:')
@@ -539,8 +692,8 @@ describe('runContactTurn', () => {
539
692
  expect(result.tools.map((tool) => tool.name)).toEqual([...PI_CODING_TOOL_NAMES, ...tools.map((tool) => tool.name)])
540
693
  expect(assignWork).toHaveBeenCalledTimes(1)
541
694
  expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_mixed' })])
542
- // read text (miss) → missed-fence retry assign; assign terminates (no post-assign LLM).
543
- expect(createChatCompletion).toHaveBeenCalledTimes(3)
695
+ // Assignment terminates mutations; a tools-disabled completion summarizes the result.
696
+ expect(createChatCompletion).toHaveBeenCalledTimes(4)
544
697
  // Tool call/result association: after read, the next completion sees a named tool result.
545
698
  const secondMessages = createChatCompletion.mock.calls[1][0].body.messages
546
699
  const toolResultTurn = secondMessages.find((message) => (
@@ -561,8 +714,9 @@ describe('runContactTurn', () => {
561
714
  }))
562
715
  const tools = createContactTools({ assignWork })
563
716
  let completions = 0
564
- const createChatCompletion = vi.fn(async () => {
717
+ const createChatCompletion = vi.fn(async ({ body }) => {
565
718
  completions += 1
719
+ if (body.messages[0].content.startsWith('Reply briefly')) return { text: '任务已交给工作会话。' }
566
720
  // Safety: a non-terminating loop must not mint 37 sessions in tests.
567
721
  if (completions > 8) {
568
722
  throw new Error('harness safety stop: too many completions without terminate')
@@ -590,9 +744,9 @@ describe('runContactTurn', () => {
590
744
  expect(result.cards).toHaveLength(1)
591
745
  // Card is the user-facing confirm — English toolText must not leak into bubbles.
592
746
  expect(result.bubbles.join('\n')).not.toContain('Opened a coding session.')
593
- // First completion issues assign; terminate skips the auto follow-up LLM.
594
- expect(createChatCompletion).toHaveBeenCalledTimes(1)
595
- expect(completions).toBe(1)
747
+ // The result summary has no tools and cannot create another worker.
748
+ expect(createChatCompletion).toHaveBeenCalledTimes(2)
749
+ expect(completions).toBe(2)
596
750
  })
597
751
 
598
752
  it('real Agent loop: list_projects then assign keeps prereq + one session (terminate after assign)', async () => {
@@ -607,6 +761,7 @@ describe('runContactTurn', () => {
607
761
  let completions = 0
608
762
  const createChatCompletion = vi.fn(async ({ body }) => {
609
763
  completions += 1
764
+ if (body.messages[0].content.startsWith('Reply briefly')) return { text: '已找到项目并安排工作。' }
610
765
  if (completions > 6) {
611
766
  throw new Error('harness safety stop: too many completions')
612
767
  }
@@ -678,11 +833,11 @@ describe('runContactTurn', () => {
678
833
  const result = await runContactTurn({
679
834
  assistant: { providerID: 'p', modelID: 'm', defaultPrompt: '' },
680
835
  history: [], userText: '建会话', tools: createContactTools(),
681
- createChatCompletion: vi.fn(), AgentImpl,
836
+ createChatCompletion: vi.fn(async () => ({ text: '已安排工作。' })), AgentImpl,
682
837
  })
683
838
  expect(prompts).toHaveLength(2)
684
839
  // assign toolText is not a user bubble. Turn-boundary keeps pre-retry assistant prose.
685
- expect(result.bubbles).toEqual(['Only document search is available.'])
840
+ expect(result.bubbles).toEqual(['Only document search is available.', '已安排工作。'])
686
841
  expect(result.bubbles.join('\n')).not.toContain('Opened session.')
687
842
  })
688
843
  it('runs pi-agent-core with thinking off and no tools by default', async () => {
@@ -888,13 +1043,13 @@ describe('runContactTurn', () => {
888
1043
  assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
889
1044
  history: [],
890
1045
  userText: 'assign login',
891
- createChatCompletion: vi.fn(),
1046
+ createChatCompletion: vi.fn(async () => ({ text: '任务已安排。' })),
892
1047
  tools: [assignTool, { name: 'bash', execute: vi.fn() }],
893
1048
  projects: [{ id: 'proj_yee', path: '/repo/sample-app', label: 'OpenChamber Yee' }],
894
1049
  AgentImpl,
895
1050
  })
896
1051
  // Keep assistant spoken/confirm text; do not paint English assign toolText.
897
- expect(result.bubbles).toEqual(['Opening that.'])
1052
+ expect(result.bubbles).toEqual(['Opening that.', '任务已安排。'])
898
1053
  expect(result.bubbles.join('\n')).not.toContain('opened')
899
1054
  expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_1', cardType: 'session' })])
900
1055
  expect(result.thinkingLevel).toBe('off')
@@ -934,7 +1089,7 @@ describe('runContactTurn', () => {
934
1089
  assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
935
1090
  history: [],
936
1091
  userText: 'assign login',
937
- createChatCompletion: vi.fn(),
1092
+ createChatCompletion: vi.fn(async () => ({ text: '已安排工作。' })),
938
1093
  tools: [{ name: 'assign_session', execute: vi.fn() }],
939
1094
  AgentImpl,
940
1095
  })
@@ -943,7 +1098,7 @@ describe('runContactTurn', () => {
943
1098
  expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_plan' })])
944
1099
  })
945
1100
 
946
- it('keeps a short spoken preamble then the tool confirm', async () => {
1101
+ it('keeps a short spoken preamble then the model summary', async () => {
947
1102
  function AgentImpl(options) {
948
1103
  this.state = { ...options.initialState, messages: [] }
949
1104
  this.prompt = async () => {
@@ -977,11 +1132,11 @@ describe('runContactTurn', () => {
977
1132
  assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
978
1133
  history: [],
979
1134
  userText: 'assign login',
980
- createChatCompletion: vi.fn(),
1135
+ createChatCompletion: vi.fn(async () => ({ text: '已安排工作。' })),
981
1136
  tools: [{ name: 'assign_session', execute: vi.fn() }],
982
1137
  AgentImpl,
983
1138
  })
984
- expect(result.bubbles).toEqual(['我去找一下'])
1139
+ expect(result.bubbles).toEqual(['我去找一下', '已安排工作。'])
985
1140
  expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_speak' })])
986
1141
  })
987
1142
 
@@ -1023,7 +1178,7 @@ describe('runContactTurn', () => {
1023
1178
  expect(result.bubbles.join('\n')).not.toContain('<path')
1024
1179
  })
1025
1180
 
1026
- it('keeps only the spoken preamble when read_session has no post-read reply', async () => {
1181
+ it('requests a model summary when read_session has no post-read reply', async () => {
1027
1182
  function AgentImpl(options) {
1028
1183
  this.state = { ...options.initialState, messages: [] }
1029
1184
  this.prompt = async () => {
@@ -1047,11 +1202,11 @@ describe('runContactTurn', () => {
1047
1202
  assistant: { providerID: 'openai', modelID: 'gpt-5.2', defaultPrompt: '' },
1048
1203
  history: [],
1049
1204
  userText: '@session:ses_quote 这个对话在说什么',
1050
- createChatCompletion: vi.fn(),
1205
+ createChatCompletion: vi.fn(async () => ({ text: '这个会话目前为空。' })),
1051
1206
  tools: [{ name: 'read_session', execute: vi.fn() }],
1052
1207
  AgentImpl,
1053
1208
  })
1054
- expect(result.bubbles).toEqual(['我去读一下这个会话。'])
1209
+ expect(result.bubbles).toEqual(['我去读一下这个会话。', '这个会话目前为空。'])
1055
1210
  expect(result.bubbles.join('\n')).not.toContain('Referenced conversation data')
1056
1211
  })
1057
1212
 
@@ -1120,7 +1275,7 @@ describe('runContactTurn', () => {
1120
1275
  assistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', defaultPrompt: '' },
1121
1276
  history: [],
1122
1277
  userText: '帮我新建一个助理,名叫 FlowNL,不要开编码 session',
1123
- createChatCompletion: vi.fn(),
1278
+ createChatCompletion: vi.fn(async () => ({ text: 'FlowNL 已经建好了。' })),
1124
1279
  tools,
1125
1280
  AgentImpl,
1126
1281
  })
@@ -1180,7 +1335,7 @@ describe('runContactTurn', () => {
1180
1335
  expect(result.cards).toEqual([])
1181
1336
  // Prefer existing assistant text over the English missed-tool fallback.
1182
1337
  expect(result.bubbles).toEqual(['好的,已创建。', '好的,已创建。'])
1183
- expect(result.bubbles).not.toEqual([MISSED_TOOL_FAILURE_BUBBLE])
1338
+ expect(result.text).not.toContain('I could not complete that. No tool ran')
1184
1339
  })
1185
1340
 
1186
1341
  it('retries a missed fence once and then executes message_assistant', async () => {
@@ -1225,7 +1380,7 @@ describe('runContactTurn', () => {
1225
1380
  assistant: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', defaultPrompt: '' },
1226
1381
  history: [],
1227
1382
  userText: '给 PeerQA 说一声 hello-from-assistant 写好了',
1228
- createChatCompletion: vi.fn(),
1383
+ createChatCompletion: vi.fn(async () => ({ text: '已经告诉 PeerQA 了。' })),
1229
1384
  tools,
1230
1385
  AgentImpl,
1231
1386
  })
@@ -1275,7 +1430,10 @@ describe('runContactTurn', () => {
1275
1430
  { role: 'assistant', content: 'got the image' },
1276
1431
  ],
1277
1432
  userText: '开新对话',
1278
- createChatCompletion: vi.fn(),
1433
+ createChatCompletion: vi.fn(async ({ body }) => {
1434
+ expect(JSON.stringify(body)).not.toMatch(/dot.png|note.txt|got the image/)
1435
+ return { text: '记忆已清除,我们可以开始新话题了。' }
1436
+ }),
1279
1437
  tools,
1280
1438
  AgentImpl,
1281
1439
  })
@@ -1284,18 +1442,22 @@ describe('runContactTurn', () => {
1284
1442
  expect(result.reset).toBe(true)
1285
1443
  expect(result.historyCleared).toBe(false)
1286
1444
  expect(result.cards).toEqual([])
1287
- expect(result.bubbles).toEqual([NEW_CONVERSATION_CONFIRM_BUBBLE])
1445
+ expect(result.bubbles).toEqual(['记忆已清除,我们可以开始新话题了。'])
1288
1446
  expect(result.bubbles.join('')).not.toContain('dot.png')
1289
1447
  expect(result.bubbles.join('')).not.toContain('note.txt')
1290
1448
  })
1291
1449
 
1292
- it('real Agent loop: clear_chat_history fence runs once, terminates, no follow-up completion', async () => {
1450
+ it('real Agent loop: clear_chat_history runs once and gets an isolated model summary', async () => {
1293
1451
  const clearContactMemory = vi.fn(async () => ({ reset: true, memoryCleared: true }))
1294
1452
  const resetContact = vi.fn(async () => ({ reset: true, historyCleared: true }))
1295
1453
  const tools = createContactTools({ clearContactMemory, resetContact })
1296
1454
  let completions = 0
1297
- const createChatCompletion = vi.fn(async () => {
1455
+ const createChatCompletion = vi.fn(async ({ body }) => {
1298
1456
  completions += 1
1457
+ if (completions === 2) {
1458
+ expect(JSON.stringify(body)).not.toContain('secret')
1459
+ return { text: '聊天记录已清空。' }
1460
+ }
1299
1461
  if (completions > 4) {
1300
1462
  throw new Error('harness safety stop: clear_chat_history must terminate without follow-up LLM')
1301
1463
  }
@@ -1325,11 +1487,11 @@ describe('runContactTurn', () => {
1325
1487
  expect(clearContactMemory).not.toHaveBeenCalled()
1326
1488
  expect(result.reset).toBe(true)
1327
1489
  expect(result.historyCleared).toBe(true)
1328
- expect(result.bubbles).toEqual([CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE])
1490
+ expect(result.bubbles).toEqual(['聊天记录已清空。'])
1329
1491
  expect(result.bubbles.join('')).not.toMatch(/not cleared|denied|new_conversation instead|secret/i)
1330
1492
  expect(result.cards).toEqual([])
1331
- expect(createChatCompletion).toHaveBeenCalledTimes(1)
1332
- expect(completions).toBe(1)
1493
+ expect(createChatCompletion).toHaveBeenCalledTimes(2)
1494
+ expect(completions).toBe(2)
1333
1495
  })
1334
1496
 
1335
1497
  it('real Agent loop: clear_chat_history after cross-turn confirm terminates once with confirm only', async () => {
@@ -1339,6 +1501,7 @@ describe('runContactTurn', () => {
1339
1501
  let completions = 0
1340
1502
  const createChatCompletion = vi.fn(async () => {
1341
1503
  completions += 1
1504
+ if (completions === 2) return { text: '聊天记录已清空。' }
1342
1505
  if (completions > 4) {
1343
1506
  throw new Error('harness safety stop: wipe must terminate once')
1344
1507
  }
@@ -1366,10 +1529,10 @@ describe('runContactTurn', () => {
1366
1529
  expect(clearContactMemory).not.toHaveBeenCalled()
1367
1530
  expect(result.reset).toBe(true)
1368
1531
  expect(result.historyCleared).toBe(true)
1369
- expect(result.bubbles).toEqual([CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE])
1532
+ expect(result.bubbles).toEqual(['聊天记录已清空。'])
1370
1533
  expect(result.bubbles.join('')).not.toMatch(/not cleared|denied|explicit wipe|new_conversation instead/i)
1371
- expect(createChatCompletion).toHaveBeenCalledTimes(1)
1372
- expect(completions).toBe(1)
1534
+ expect(createChatCompletion).toHaveBeenCalledTimes(2)
1535
+ expect(completions).toBe(2)
1373
1536
  })
1374
1537
 
1375
1538
  it('real Agent loop: new_conversation terminates once and does not call resetContact', async () => {
@@ -1379,6 +1542,7 @@ describe('runContactTurn', () => {
1379
1542
  let completions = 0
1380
1543
  const createChatCompletion = vi.fn(async () => {
1381
1544
  completions += 1
1545
+ if (completions === 2) return { text: '记忆已清除,聊天记录保留。' }
1382
1546
  if (completions > 4) {
1383
1547
  throw new Error('harness safety stop: new_conversation must terminate without follow-up LLM')
1384
1548
  }
@@ -1406,9 +1570,9 @@ describe('runContactTurn', () => {
1406
1570
  expect(resetContact).not.toHaveBeenCalled()
1407
1571
  expect(result.reset).toBe(true)
1408
1572
  expect(result.historyCleared).toBe(false)
1409
- expect(result.bubbles).toEqual([NEW_CONVERSATION_CONFIRM_BUBBLE])
1410
- expect(createChatCompletion).toHaveBeenCalledTimes(1)
1411
- expect(completions).toBe(1)
1573
+ expect(result.bubbles).toEqual(['记忆已清除,聊天记录保留。'])
1574
+ expect(createChatCompletion).toHaveBeenCalledTimes(2)
1575
+ expect(completions).toBe(2)
1412
1576
  })
1413
1577
  })
1414
1578
 
@@ -1543,11 +1707,11 @@ describe('runContactTurn bubble index continuity', () => {
1543
1707
  })
1544
1708
  const doneBubbles = bubbleDeltas.filter((item) => item.done).map((item) => item.delta)
1545
1709
  expect(doneBubbles).toEqual(['我先看一下', '马上改人设', '改好了'])
1546
- // Tool-only confirm appends after the published spoken/final prefix.
1710
+ // Only model-authored messages form the published prefix and final transcript.
1547
1711
  expect(result.bubbles.slice(0, 3)).toEqual(['我先看一下', '马上改人设', '改好了'])
1548
- expect(result.bubbles.length).toBeGreaterThan(3)
1712
+ expect(result.bubbles).toHaveLength(3)
1549
1713
  expect(result.bubbles.slice(0, doneBubbles.length)).toEqual(doneBubbles)
1550
- expect(result.bubbles.at(-1)).toMatch(/Default prompt/i)
1714
+ expect(result.bubbles.join('')).not.toMatch(/Default prompt/i)
1551
1715
  expect(result.bubbles.join('\n')).not.toContain('openchamber-tool')
1552
1716
  })
1553
1717
 
@@ -1582,7 +1746,7 @@ describe('runContactTurn bubble index continuity', () => {
1582
1746
  expect(result.bubbles).toEqual(expect.arrayContaining(['好的,我来创建。']))
1583
1747
  // Final array keeps the full turn projection (streamed prefix stable).
1584
1748
  expect(result.bubbles.slice(0, doneBubbles.length)).toEqual(doneBubbles)
1585
- expect(result.bubbles).not.toEqual([MISSED_TOOL_FAILURE_BUBBLE])
1749
+ expect(result.text).not.toContain('I could not complete that. No tool ran')
1586
1750
  // Card tool: English tool confirm stays out of bubbles.
1587
1751
  expect(result.bubbles.join('')).not.toContain('Created assistant')
1588
1752
  })
@@ -53,15 +53,10 @@ import {
53
53
  readContactAttachmentBytes,
54
54
  } from './contact-attachments.js';
55
55
  import {
56
- ASSIGNED_SESSION_FALLBACK_BUBBLE,
57
56
  boundSessionListLimit,
58
- CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
59
- confirmBubbleAfterContactReset,
60
- contactTurnClearedChatHistory,
61
57
  createContactTools,
62
58
  filterRegisteredProjects,
63
59
  matchesProjectQuery,
64
- NEW_CONVERSATION_CONFIRM_BUBBLE,
65
60
  normalizeConnectedModels,
66
61
  normalizeRegisteredProjects,
67
62
  sanitizeRegisteredProject,
@@ -2231,10 +2226,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2231
2226
  const assignedCards = [];
2232
2227
  const spokenByIndex = new Map();
2233
2228
  let contactResetThisTurn = false;
2234
- let contactHistoryClearedThisTurn = false;
2235
- const markContactReset = (historyCleared = false) => {
2229
+ const markContactReset = () => {
2236
2230
  contactResetThisTurn = true;
2237
- if (historyCleared) contactHistoryClearedThisTurn = true;
2238
2231
  spokenByIndex.clear();
2239
2232
  assignedCards.length = 0;
2240
2233
  };
@@ -2357,15 +2350,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2357
2350
  return;
2358
2351
  }
2359
2352
  const resetThisTurn = contactResetThisTurn || generated?.reset === true;
2360
- const historyClearedThisTurn = contactHistoryClearedThisTurn
2361
- || contactTurnClearedChatHistory(generated?.messages)
2362
- || generated?.historyCleared === true;
2363
- const preferredConfirm = historyClearedThisTurn
2364
- ? CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE
2365
- : NEW_CONVERSATION_CONFIRM_BUBBLE;
2366
- const bubbles = resetThisTurn
2367
- ? confirmBubbleAfterContactReset(generated?.bubbles, preferredConfirm)
2368
- : (Array.isArray(generated?.bubbles) ? generated.bubbles.filter((item) => typeof item === 'string' && item.trim()) : []);
2353
+ const bubbles = resetThisTurn && generated?.reset !== true ? []
2354
+ : (Array.isArray(generated?.bubbles) ? generated.bubbles.filter((item) => typeof item === 'string' && item.trim()).slice(0, resetThisTurn ? 1 : undefined) : []);
2369
2355
  const cards = resetThisTurn
2370
2356
  ? []
2371
2357
  : [
@@ -2403,7 +2389,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2403
2389
  // No user row for resume — only assistant bubbles/cards (userMessageID is id prefix).
2404
2390
  persistContactAssistantReply(assistantID, {
2405
2391
  userMessageID: messageID,
2406
- bubbles: bubbles.length > 0 ? bubbles : [ASSIGNED_SESSION_FALLBACK_BUBBLE],
2392
+ bubbles,
2407
2393
  cards,
2408
2394
  turnID,
2409
2395
  reset: resetThisTurn,
@@ -2420,7 +2406,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2420
2406
  name: assistantSnapshot.name,
2421
2407
  turnID,
2422
2408
  status: 'complete',
2423
- body: spoken.join('\n') || cards[0]?.title || '',
2409
+ body: spoken.at(-1) || cards[0]?.title || '',
2424
2410
  });
2425
2411
  resolveContactTurnSettlement(messageID, { status: 'complete' });
2426
2412
  } catch (error) {
@@ -2556,10 +2542,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2556
2542
  const assignedCards = [];
2557
2543
  const spokenByIndex = new Map();
2558
2544
  let contactResetThisTurn = false;
2559
- let contactHistoryClearedThisTurn = false;
2560
- const markContactReset = (historyCleared = false) => {
2545
+ const markContactReset = () => {
2561
2546
  contactResetThisTurn = true;
2562
- if (historyCleared) contactHistoryClearedThisTurn = true;
2563
2547
  spokenByIndex.clear();
2564
2548
  assignedCards.length = 0;
2565
2549
  };
@@ -2707,15 +2691,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2707
2691
  return;
2708
2692
  }
2709
2693
  const resetThisTurn = contactResetThisTurn || generated?.reset === true;
2710
- const historyClearedThisTurn = contactHistoryClearedThisTurn
2711
- || contactTurnClearedChatHistory(generated?.messages)
2712
- || generated?.historyCleared === true;
2713
- const preferredConfirm = historyClearedThisTurn
2714
- ? CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE
2715
- : NEW_CONVERSATION_CONFIRM_BUBBLE;
2716
- const bubbles = resetThisTurn
2717
- ? confirmBubbleAfterContactReset(generated?.bubbles, preferredConfirm)
2718
- : (Array.isArray(generated?.bubbles) ? generated.bubbles.filter((item) => typeof item === 'string' && item.trim()) : []);
2694
+ const bubbles = resetThisTurn && generated?.reset !== true ? []
2695
+ : (Array.isArray(generated?.bubbles) ? generated.bubbles.filter((item) => typeof item === 'string' && item.trim()).slice(0, resetThisTurn ? 1 : undefined) : []);
2719
2696
  const cards = resetThisTurn
2720
2697
  ? []
2721
2698
  : [
@@ -2766,7 +2743,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2766
2743
  }
2767
2744
  persistContactAssistantReply(row.assistant_id, {
2768
2745
  userMessageID: messageID,
2769
- bubbles: bubbles.length > 0 ? bubbles : [ASSIGNED_SESSION_FALLBACK_BUBBLE],
2746
+ bubbles,
2770
2747
  cards,
2771
2748
  turnID,
2772
2749
  reset: resetThisTurn,
@@ -2784,7 +2761,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2784
2761
  name: assistantSnapshot.name,
2785
2762
  turnID,
2786
2763
  status: 'complete',
2787
- body: spoken.join('\n') || cards[0]?.title || '',
2764
+ body: spoken.at(-1) || cards[0]?.title || '',
2788
2765
  });
2789
2766
  resolveContactTurnSettlement(messageID, { status: 'complete' });
2790
2767
  } catch (error) {