@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
@@ -6,12 +6,20 @@ import {
6
6
  CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
7
7
  CLEAR_CHAT_HISTORY_TOOL_NAME,
8
8
  CREATE_ASSISTANT_TOOL_NAME,
9
+ GET_ASSISTANT_SETTINGS_TOOL_NAME,
9
10
  LIST_PROJECTS_TOOL_NAME,
10
11
  LIST_SESSIONS_TOOL_NAME,
11
12
  MESSAGE_ASSISTANT_TOOL_NAME,
12
13
  NEW_CONVERSATION_CONFIRM_BUBBLE,
13
14
  NEW_CONVERSATION_TOOL_NAME,
14
15
  SCHEDULE_TASK_TOOL_NAME,
16
+ STOP_SESSION_TOOL_NAME,
17
+ STOPPED_SESSION_FALLBACK_BUBBLE,
18
+ UPDATE_DEFAULT_PROMPT_CONFIRM_BUBBLE,
19
+ UPDATE_DEFAULT_PROMPT_TOOL_NAME,
20
+ UPDATE_DEFAULT_PROMPT_UNCHANGED_BUBBLE,
21
+ WATCH_SESSION_TOOL_NAME,
22
+ WATCHED_SESSION_FALLBACK_BUBBLE,
15
23
  confirmBubbleAfterContactReset,
16
24
  contactTurnHasSuccessfulReset,
17
25
  createContactTools,
@@ -31,11 +39,45 @@ import {
31
39
  import { attachmentScopeKey } from './assign.js';
32
40
 
33
41
  describe('contact tool protocol', () => {
42
+ it('shows current-instance models with ordered favorites and recents using exact ids', () => {
43
+ const prompt = formatConnectedModelsPrompt([
44
+ { providerID: 'p', providerName: 'Connected Provider', modelID: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
45
+ { providerID: 'q', modelID: 'gpt-5.4', name: 'GPT 5.4' },
46
+ ], { preferences: {
47
+ favoriteModels: [{ providerID: 'q', modelID: 'gpt-5.4', variant: 'high' }, { providerID: 'gone', modelID: 'offline' }],
48
+ recentModels: [{ providerID: 'p', modelID: 'claude-sonnet-4-6' }, { providerID: 'q', modelID: 'gpt-5.4' }],
49
+ } })
50
+ expect(prompt).toContain('Connected Provider')
51
+ expect(prompt).toContain('Favorite models (saved order)')
52
+ expect(prompt).toContain('Recent models (most recent first)')
53
+ expect(prompt).toContain('variant="high"')
54
+ expect(prompt).not.toContain('offline')
55
+ const recent = prompt.split('Recent models (most recent first)')[1]
56
+ expect(recent.indexOf('claude-sonnet-4-6')).toBeLessThan(recent.indexOf('gpt-5.4'))
57
+ })
58
+
59
+ it('distinguishes unavailable model preferences from successfully empty lists', () => {
60
+ expect(formatConnectedModelsPrompt([], { preferences: null })).toContain('preferences unavailable')
61
+ expect(formatConnectedModelsPrompt([], { preferences: { favoriteModels: [], recentModels: [] } })).toContain('Favorite models (saved order): none')
62
+ expect(formatConnectedModelsPrompt([], { catalogAvailable: false })).toContain('catalog unavailable')
63
+ })
64
+
65
+ it('parses watch_session and stop_session fences', () => {
66
+ const watchText = '```openchamber-tool\n{"name":"watch_session","arguments":{"sessionID":"ses_w"}}\n```';
67
+ expect(parseContactToolCalls(watchText, [WATCH_SESSION_TOOL_NAME, STOP_SESSION_TOOL_NAME]).toolCalls).toEqual([
68
+ { name: WATCH_SESSION_TOOL_NAME, arguments: { sessionID: 'ses_w' } },
69
+ ]);
70
+ const stopText = '好的 {"name":"stop_session","arguments":{"sessionID":"ses_s"}}';
71
+ expect(parseContactToolCalls(stopText, [WATCH_SESSION_TOOL_NAME, STOP_SESSION_TOOL_NAME]).toolCalls).toEqual([
72
+ { name: STOP_SESSION_TOOL_NAME, arguments: { sessionID: 'ses_s' } },
73
+ ]);
74
+ });
75
+
34
76
  it('parses an assign_session fence and strips it from chat text', () => {
35
77
  const text = 'On it.\n\n```openchamber-tool\n{"name":"assign_session","arguments":{"prompt":"Fix login","projectPath":"/repo"}}\n```';
36
78
  const parsed = parseContactToolCalls(text, [ASSIGN_SESSION_TOOL_NAME]);
37
79
  expect(parsed.chatText).toBe('On it.');
38
- expect(parsed.toolCall).toEqual({
80
+ expect(parsed.toolCalls[0]).toEqual({
39
81
  name: ASSIGN_SESSION_TOOL_NAME,
40
82
  arguments: { prompt: 'Fix login', projectPath: '/repo' },
41
83
  });
@@ -48,7 +90,7 @@ describe('contact tool protocol', () => {
48
90
  [CREATE_ASSISTANT_TOOL_NAME, SCHEDULE_TASK_TOOL_NAME, ASSIGN_SESSION_TOOL_NAME],
49
91
  );
50
92
  expect(create.chatText).toBe('好。');
51
- expect(create.toolCall).toEqual({
93
+ expect(create.toolCalls[0]).toEqual({
52
94
  name: CREATE_ASSISTANT_TOOL_NAME,
53
95
  arguments: { name: 'FlowQA', model: 'opencode-go/deepseek-v4-flash' },
54
96
  });
@@ -60,11 +102,11 @@ describe('contact tool protocol', () => {
60
102
  '```openchamber-tool\n{"name":"message_assistant","arguments":{"to":"PeerQA","text":"hello-from-assistant 写好了"}}\n```',
61
103
  [MESSAGE_ASSISTANT_TOOL_NAME],
62
104
  );
63
- expect(peer.toolCall).toEqual({
105
+ expect(peer.toolCalls[0]).toEqual({
64
106
  name: MESSAGE_ASSISTANT_TOOL_NAME,
65
107
  arguments: { to: 'PeerQA', text: 'hello-from-assistant 写好了' },
66
108
  });
67
- expect(schedule.toolCall).toEqual({
109
+ expect(schedule.toolCalls[0]).toEqual({
68
110
  name: SCHEDULE_TASK_TOOL_NAME,
69
111
  arguments: { name: 'Daily ping', prompt: 'ping', time: '18:00', timezone: 'Asia/Shanghai' },
70
112
  });
@@ -75,7 +117,7 @@ describe('contact tool protocol', () => {
75
117
  '好的,我来直接创建这个助理,不开编码会话。\n{"name":"create_assistant","arguments":{"name":"FlowNL","model":"opencode-go/deepseek-v4-flash"}}',
76
118
  [CREATE_ASSISTANT_TOOL_NAME, ASSIGN_SESSION_TOOL_NAME],
77
119
  );
78
- expect(parsed.toolCall).toEqual({
120
+ expect(parsed.toolCalls[0]).toEqual({
79
121
  name: CREATE_ASSISTANT_TOOL_NAME,
80
122
  arguments: { name: 'FlowNL', model: 'opencode-go/deepseek-v4-flash' },
81
123
  });
@@ -83,15 +125,107 @@ describe('contact tool protocol', () => {
83
125
  expect(parsed.chatText).not.toContain('create_assistant');
84
126
  });
85
127
 
128
+ it('accepts the existing single-call fence without a newline', () => {
129
+ expect(parseContactToolCalls('```openchamber-tool {"name":"read","arguments":{"path":"sample.md"}}```', ['read']).toolCalls)
130
+ .toEqual([{ name: 'read', arguments: { path: 'sample.md' } }]);
131
+ });
132
+
133
+ it('does not execute tool-shaped JSON inside other protocol or example fences', () => {
134
+ const text = '```openchamber-final\n{"status":"complete","text":"done","example":{"name":"bash","arguments":{"command":"pwd"}}}\n```';
135
+ expect(parseContactToolCalls(text, ['bash'])).toEqual({ chatText: text, toolCalls: [] });
136
+ });
137
+
138
+ it.each(['json\n', '\n', ''])('preserves complete tool payloads in generic fences: %s', (header) => {
139
+ const call = { name: 'read', arguments: { path: 'sample.md' } };
140
+ const result = parseContactToolCalls(`Before \`\`\`${header}${JSON.stringify(call)}\`\`\` After`, ['read']);
141
+ expect(result.toolCalls).toEqual([call]);
142
+ expect(result.chatText).toBe('Before After');
143
+ });
144
+
145
+ it('does not run nested or partial tool examples in generic fences', () => {
146
+ const text = '```json\n{"example":{"name":"bash","arguments":{"command":"pwd"}}}\n```';
147
+ expect(parseContactToolCalls(text, ['bash'])).toEqual({ chatText: text, toolCalls: [] });
148
+ const prose = '```\nExample: {"name":"bash","arguments":{"command":"pwd"}}\n```';
149
+ expect(parseContactToolCalls(prose, ['bash'])).toEqual({ chatText: prose, toolCalls: [] });
150
+ });
151
+
152
+ it('retains every tool call in order across fenced and embedded JSON', () => {
153
+ const calls = [
154
+ { name: 'read', arguments: { path: 'sample.md' } },
155
+ { name: 'bash', arguments: { command: 'date +%F' } },
156
+ { name: 'write', arguments: { path: 'result.md', content: 'done' } },
157
+ ];
158
+ const text = `Before ${JSON.stringify(calls[0])}\n\n\`\`\`openchamber-tool\n${JSON.stringify(calls[1])}\n\`\`\`\nAfter ${JSON.stringify(calls[2])}`;
159
+ const result = parseContactToolCalls(text, ['read', 'bash', 'write']);
160
+ expect(result.toolCalls).toEqual(calls);
161
+ expect(result.chatText).toContain('Before');
162
+ expect(result.chatText).toContain('After');
163
+ expect(result.chatText).not.toContain('arguments');
164
+ expect(result.protocolError).toBeUndefined();
165
+ });
166
+
167
+ it('keeps markdown fences and escaped quotes inside JSON string arguments', () => {
168
+ const calls = [
169
+ { name: 'write', arguments: { path: 'note.md', content: '```json\n{"quoted": "value"}\n```' } },
170
+ { name: 'read', arguments: { path: 'note.md' } },
171
+ ];
172
+ const text = calls.map((call) => `\`\`\`openchamber-tool\n${JSON.stringify(call)}\n\`\`\``).join('\n');
173
+ expect(parseContactToolCalls(text, ['read', 'write'])).toEqual({ chatText: '', toolCalls: calls });
174
+ });
175
+
176
+ it('preserves both calls from the observed read-sample and bash-date response', () => {
177
+ const calls = [
178
+ { name: 'read', arguments: { path: 'notes/sample.md' } },
179
+ { name: 'bash', arguments: { command: 'date +%F' } },
180
+ ];
181
+ const text = calls.map((call) => `\`\`\`openchamber-tool\n${JSON.stringify(call)}\n\`\`\``).join('\n\n');
182
+ expect(parseContactToolCalls(text, ['read', 'bash'])).toEqual({ chatText: '', toolCalls: calls });
183
+ });
184
+
185
+ it.each([
186
+ '{bad json}',
187
+ '{"name":"unknown","arguments":{}}',
188
+ '{"name":"read","arguments":[]}',
189
+ '',
190
+ ])('rejects the complete batch when an explicit tool fence is invalid: %s', (invalid) => {
191
+ const valid = '\`\`\`openchamber-tool\n{"name":"read","arguments":{"path":"sample.md"}}\n\`\`\`';
192
+ const result = parseContactToolCalls(`${valid}\n\`\`\`openchamber-tool\n${invalid}\n\`\`\``, ['read']);
193
+ expect(result.toolCalls).toEqual([]);
194
+ expect(result.protocolError).toEqual(expect.any(String));
195
+ });
196
+
197
+ it('rejects a truncated explicit tool fence without executing an earlier call', () => {
198
+ const result = parseContactToolCalls('{"name":"read","arguments":{"path":"sample.md"}}\n\`\`\`openchamber-tool\n{"name":"read"', ['read']);
199
+ expect(result.toolCalls).toEqual([]);
200
+ expect(result.protocolError).toEqual(expect.any(String));
201
+ });
202
+
203
+ it.each(['', '{"name":"read","arguments":{"path":"sample.md"}}\n'])('rejects an unclosed outer JSON object without executing a nested call or valid prefix', (prefix) => {
204
+ const result = parseContactToolCalls(prefix + '{"example": {"name":"bash","arguments":{"command":"pwd"}}', ['read', 'bash']);
205
+ expect(result.toolCalls).toEqual([]);
206
+ expect(result.protocolError).toEqual(expect.any(String));
207
+ });
208
+
209
+ it('does not discover nested JSON arguments or strings as additional tool calls', () => {
210
+ const nested = { name: 'bash', arguments: { command: 'pwd' } };
211
+ const call = { name: 'write', arguments: { path: 'result.json', content: JSON.stringify(nested), metadata: nested } };
212
+ expect(parseContactToolCalls(JSON.stringify(call), ['write', 'bash']).toolCalls).toEqual([call]);
213
+ expect(parseContactToolCalls(JSON.stringify({ example: nested }), ['bash']).toolCalls).toEqual([]);
214
+ });
215
+
86
216
  it('detects 建助理 without treating 不要开编码 session as assign_session', () => {
87
217
  const tools = [
88
218
  NEW_CONVERSATION_TOOL_NAME,
89
219
  CLEAR_CHAT_HISTORY_TOOL_NAME,
90
220
  LIST_PROJECTS_TOOL_NAME,
91
221
  LIST_SESSIONS_TOOL_NAME,
222
+ GET_ASSISTANT_SETTINGS_TOOL_NAME,
223
+ UPDATE_DEFAULT_PROMPT_TOOL_NAME,
92
224
  CREATE_ASSISTANT_TOOL_NAME,
93
225
  SCHEDULE_TASK_TOOL_NAME,
94
226
  MESSAGE_ASSISTANT_TOOL_NAME,
227
+ WATCH_SESSION_TOOL_NAME,
228
+ STOP_SESSION_TOOL_NAME,
95
229
  ASSIGN_SESSION_TOOL_NAME,
96
230
  ];
97
231
  expect(detectRequestedContactTools('帮我新建一个助理,名叫 FlowNL,不要开编码 session', tools)).toEqual([
@@ -100,6 +234,14 @@ describe('contact tool protocol', () => {
100
234
  expect(detectRequestedContactTools('每天 18:00 排一个 ping 定时任务', tools)).toEqual([SCHEDULE_TASK_TOOL_NAME]);
101
235
  expect(detectRequestedContactTools('建会话写一个文件', tools)).toEqual([ASSIGN_SESSION_TOOL_NAME]);
102
236
  expect(detectRequestedContactTools('对,你直接派给那个项目组,再建个会话去修这个问题', tools)).toEqual([ASSIGN_SESSION_TOOL_NAME]);
237
+ expect(detectRequestedContactTools('继续这个会话修 login', tools)).toEqual([ASSIGN_SESSION_TOOL_NAME]);
238
+ expect(detectRequestedContactTools('监听这个会话', tools)).toEqual([WATCH_SESSION_TOOL_NAME]);
239
+ expect(detectRequestedContactTools('watch this session', tools)).toEqual([WATCH_SESSION_TOOL_NAME]);
240
+ expect(detectRequestedContactTools('停止这个会话', tools)).toEqual([STOP_SESSION_TOOL_NAME]);
241
+ expect(detectRequestedContactTools('stop that session', tools)).toEqual([STOP_SESSION_TOOL_NAME]);
242
+ // Plain @session reference is context only — not auto watch/assign/stop.
243
+ expect(detectRequestedContactTools('看看 @session:ses_abc 里上次说了啥', tools)).toEqual([]);
244
+ expect(detectRequestedContactTools('@session:ses_abc', tools)).toEqual([]);
103
245
  expect(detectRequestedContactTools('写一个文件', tools)).toEqual([]);
104
246
  expect(detectRequestedContactTools('pwd', tools)).toEqual([]);
105
247
  expect(detectRequestedContactTools('给 PeerQA 说一声 hello-from-assistant 写好了', tools)).toEqual([
@@ -118,6 +260,11 @@ describe('contact tool protocol', () => {
118
260
  expect(detectRequestedContactTools('找项目 openchamber yee', tools)).toEqual([LIST_PROJECTS_TOOL_NAME]);
119
261
  expect(detectRequestedContactTools('看看现有对话', tools)).toEqual([LIST_SESSIONS_TOOL_NAME]);
120
262
  expect(detectRequestedContactTools('list sessions in that project', tools)).toEqual([LIST_SESSIONS_TOOL_NAME]);
263
+ expect(detectRequestedContactTools('看看我的默认提示词', tools)).toEqual([GET_ASSISTANT_SETTINGS_TOOL_NAME]);
264
+ expect(detectRequestedContactTools('看看 OpenCode 配置助手的默认提示词', tools)).toEqual([GET_ASSISTANT_SETTINGS_TOOL_NAME]);
265
+ expect(detectRequestedContactTools('把默认提示词改成简洁中文', tools)).toEqual([UPDATE_DEFAULT_PROMPT_TOOL_NAME]);
266
+ expect(detectRequestedContactTools('改 OpenCode 配置助手的默认提示词', tools)).toEqual([UPDATE_DEFAULT_PROMPT_TOOL_NAME]);
267
+ expect(detectRequestedContactTools('update default prompt to be terse', tools)).toEqual([UPDATE_DEFAULT_PROMPT_TOOL_NAME]);
121
268
  expect(detectRequestedContactTools('开新对话', tools)).not.toContain(ASSIGN_SESSION_TOOL_NAME);
122
269
  expect(detectRequestedContactTools('开新对话', tools)).not.toContain(CLEAR_CHAT_HISTORY_TOOL_NAME);
123
270
  expect(detectRequestedContactTools('不要开编码 session', tools)).toEqual([]);
@@ -130,14 +277,14 @@ describe('contact tool protocol', () => {
130
277
 
131
278
  it('parses bash fences when the pi coding tools are allowed', () => {
132
279
  const text = '```openchamber-tool\n{"name":"bash","arguments":{"command":"pwd"}}\n```';
133
- expect(parseContactToolCalls(text, ['bash', ASSIGN_SESSION_TOOL_NAME]).toolCall).toEqual({
280
+ expect(parseContactToolCalls(text, ['bash', ASSIGN_SESSION_TOOL_NAME]).toolCalls[0]).toEqual({
134
281
  name: 'bash',
135
282
  arguments: { command: 'pwd' },
136
283
  });
137
284
  expect(parseContactToolCalls(
138
285
  '```openchamber-tool\n{"name":"glob","arguments":{"pattern":"*"}}\n```',
139
286
  ['glob', 'bash'],
140
- ).toolCall).toBeNull();
287
+ ).toolCalls).toEqual([]);
141
288
  });
142
289
 
143
290
  it('tells DeepSeek to call tools from natural language, not slash commands', () => {
@@ -146,20 +293,31 @@ describe('contact tool protocol', () => {
146
293
  expect(prompt).toContain('clear_chat_history');
147
294
  expect(prompt).toContain('list_projects');
148
295
  expect(prompt).toContain('list_sessions');
296
+ expect(prompt).toContain('get_assistant_settings');
297
+ expect(prompt).toContain('update_default_prompt');
149
298
  expect(prompt).toContain('create_assistant');
150
299
  expect(prompt).toContain('schedule_task');
151
300
  expect(prompt).toContain('message_assistant');
152
301
  expect(prompt).toContain('assign_session');
302
+ expect(prompt).toContain('watch_session');
303
+ expect(prompt).toContain('stop_session');
153
304
  expect(prompt).toContain('开新对话');
154
305
  expect(prompt).toContain('清除记忆');
155
306
  expect(prompt).toContain('清空聊天记录');
156
307
  expect(prompt).toContain('找项目');
157
308
  expect(prompt).toContain('现有对话');
309
+ expect(prompt).toContain('默认提示词');
310
+ expect(prompt).toContain('助手设定');
158
311
  expect(prompt).toContain('建助理');
159
312
  expect(prompt).toContain('排定时任务');
160
313
  expect(prompt).toContain('说一声');
314
+ expect(prompt).toContain('监听会话');
315
+ expect(prompt).toContain('停止会话');
316
+ expect(prompt).toContain('plain @session:id reference alone is context');
161
317
  expect(prompt).toContain('session/new');
162
318
  expect(prompt).toContain('LLM memory only');
319
+ expect(prompt).toContain('Persists');
320
+ expect(prompt).toContain('later turns');
163
321
  expect(prompt).not.toContain('/card');
164
322
  expect(prompt).not.toContain('/dm');
165
323
  expect(prompt).toContain('A reply without the tool call does nothing');
@@ -273,10 +431,18 @@ describe('createContactTools', () => {
273
431
  CLEAR_CHAT_HISTORY_TOOL_NAME,
274
432
  LIST_PROJECTS_TOOL_NAME,
275
433
  LIST_SESSIONS_TOOL_NAME,
434
+ 'read_session',
435
+ GET_ASSISTANT_SETTINGS_TOOL_NAME,
436
+ UPDATE_DEFAULT_PROMPT_TOOL_NAME,
276
437
  CREATE_ASSISTANT_TOOL_NAME,
277
438
  SCHEDULE_TASK_TOOL_NAME,
278
439
  MESSAGE_ASSISTANT_TOOL_NAME,
279
440
  ASSIGN_SESSION_TOOL_NAME,
441
+ WATCH_SESSION_TOOL_NAME,
442
+ STOP_SESSION_TOOL_NAME,
443
+ 'steer_session',
444
+ 'archive_session',
445
+ 'delete_session',
280
446
  ]);
281
447
  expect(tools.some((tool) => ['bash', 'edit', 'read', 'write'].includes(tool.name))).toBe(false);
282
448
 
@@ -355,6 +521,91 @@ describe('createContactTools', () => {
355
521
  expect(onCard).toHaveBeenCalledTimes(3);
356
522
  });
357
523
 
524
+ it.each(['assign_session', 'watch_session'])('%s forwards AbortSignal and drops a late card after cancellation', async (name) => {
525
+ const controller = new AbortController();
526
+ const onCard = vi.fn();
527
+ const action = vi.fn(async input => {
528
+ expect(input.signal).toBe(controller.signal);
529
+ controller.abort();
530
+ return { sessionID: 'ses_late', directory: '/project', title: 'Late', status: 'busy' };
531
+ });
532
+ const tools = createContactTools({ assignWork: action, watchSession: action, onCard });
533
+ const tool = tools.find(t => t.name === name);
534
+ const result = await tool.execute('call', { sessionID: 'ses_late', prompt: 'work' }, controller.signal);
535
+ expect(result.details.error).toBeDefined();
536
+ expect(onCard).not.toHaveBeenCalled();
537
+ await tool.execute('call2', { sessionID: 'ses_late', prompt: 'work' }, controller.signal);
538
+ expect(action).toHaveBeenCalledTimes(1);
539
+ });
540
+
541
+ it('recognizes explicit session mutation requests and forwards cancellation signals', async () => {
542
+ const action = vi.fn(async () => ({ admitted: true }));
543
+ const tools = createContactTools({ steerSession: action, archiveSession: action, deleteSession: action });
544
+ expect(detectRequestedContactTools('取消这个 session', tools.map(t => t.name))).toEqual(['stop_session']);
545
+ expect(detectRequestedContactTools('归档这个会话', tools.map(t => t.name))).toEqual(['archive_session']);
546
+ expect(detectRequestedContactTools('删除这个 session', tools.map(t => t.name))).toEqual(['delete_session']);
547
+ expect(detectRequestedContactTools('给这个会话插话:先运行测试', tools.map(t => t.name))).toEqual(['steer_session']);
548
+ const controller = new AbortController();
549
+ const result = await tools.find(t => t.name === 'steer_session').execute('call', { sessionID: 'ses_exact', text: '先运行测试' }, controller.signal);
550
+ expect(action).toHaveBeenCalledWith({ sessionID: 'ses_exact', text: '先运行测试', signal: controller.signal });
551
+ expect(result.terminate).toBe(true);
552
+ expect(result.isError).not.toBe(true);
553
+ const invalid = await tools.find(t => t.name === 'steer_session').execute('call2', { sessionID: 'ses_exact', text: ' ' });
554
+ expect(invalid.details.error).toBe('validation_error');
555
+ expect(action).toHaveBeenCalledTimes(1);
556
+ });
557
+
558
+ it('watch_session emits a baseline session card and stop_session returns true abort results', async () => {
559
+ const onCard = vi.fn();
560
+ const watchSession = vi.fn(async () => ({
561
+ sessionID: 'ses_watch',
562
+ directory: '/repo',
563
+ title: 'Watched',
564
+ status: 'complete',
565
+ watched: true,
566
+ reused: true,
567
+ }));
568
+ const stopSession = vi.fn(async () => ({
569
+ sessionID: 'ses_watch',
570
+ directory: '/repo',
571
+ title: 'Watched',
572
+ aborted: true,
573
+ }));
574
+ const tools = createContactTools({ watchSession, stopSession, onCard });
575
+ const watched = await tools.find((tool) => tool.name === WATCH_SESSION_TOOL_NAME).execute('call_w', {
576
+ sessionID: 'ses_watch',
577
+ });
578
+ expect(watchSession).toHaveBeenCalledWith({ sessionID: 'ses_watch' });
579
+ expect(watched.terminate).toBe(true);
580
+ expect(watched.content[0].text).toBe(WATCHED_SESSION_FALLBACK_BUBBLE);
581
+ expect(watched.details.card).toMatchObject({
582
+ type: 'card',
583
+ cardType: 'session',
584
+ sessionID: 'ses_watch',
585
+ status: 'complete',
586
+ });
587
+ expect(onCard).toHaveBeenCalledTimes(1);
588
+
589
+ const stopped = await tools.find((tool) => tool.name === STOP_SESSION_TOOL_NAME).execute('call_s', {
590
+ sessionID: 'ses_watch',
591
+ });
592
+ expect(stopSession).toHaveBeenCalledWith({ sessionID: 'ses_watch' });
593
+ expect(stopped.terminate).toBe(true);
594
+ expect(stopped.content[0].text).toBe(STOPPED_SESSION_FALLBACK_BUBBLE);
595
+ expect(stopped.details.stopped).toMatchObject({ aborted: true, sessionID: 'ses_watch' });
596
+
597
+ const failedStop = createContactTools({
598
+ stopSession: async () => {
599
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'abort refused');
600
+ },
601
+ });
602
+ const failed = await failedStop.find((tool) => tool.name === STOP_SESSION_TOOL_NAME).execute('call_f', {
603
+ sessionID: 'ses_watch',
604
+ });
605
+ expect(failed.details.error).toBe('upstream_error');
606
+ expect(failed.content[0].text).toContain('abort refused');
607
+ });
608
+
358
609
  it('forwards server turn file parts on assign and keeps model selection in the tool args', async () => {
359
610
  const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' };
360
611
  const assignWork = vi.fn(async (params) => ({
@@ -535,6 +786,138 @@ describe('createContactTools', () => {
535
786
  expect(failed.content[0].text).toContain('Session index is unavailable');
536
787
  expect(failed.details.sessions).toBeUndefined();
537
788
  });
789
+
790
+ it('reads assistant settings via live callback and formats empty defaultPrompt', async () => {
791
+ const readAssistantSettings = vi.fn(async () => ({
792
+ id: 'asst_host',
793
+ name: 'DeepSeekQA',
794
+ defaultPrompt: '',
795
+ providerID: 'p',
796
+ modelID: 'm',
797
+ agent: null,
798
+ variant: null,
799
+ mode: 'continuous',
800
+ workspacePath: null,
801
+ enabled: true,
802
+ }));
803
+ const tools = createContactTools({ readAssistantSettings });
804
+ const result = await tools.find((tool) => tool.name === GET_ASSISTANT_SETTINGS_TOOL_NAME).execute('call_get', {});
805
+ expect(readAssistantSettings).toHaveBeenCalledTimes(1);
806
+ expect(result.details.settings).toMatchObject({
807
+ id: 'asst_host',
808
+ name: 'DeepSeekQA',
809
+ defaultPrompt: '',
810
+ providerID: 'p',
811
+ modelID: 'm',
812
+ enabled: true,
813
+ });
814
+ expect(result.content[0].text).toContain('defaultPrompt: (empty)');
815
+ expect(result.terminate).toBe(false);
816
+ });
817
+
818
+ it('updates default prompt, no-ops when unchanged, and reports callback failures', async () => {
819
+ const updateAssistantSettings = vi.fn(async ({ defaultPrompt }) => {
820
+ if (defaultPrompt === 'same') {
821
+ return { updated: false, unchanged: true, defaultPrompt: 'same' };
822
+ }
823
+ return { updated: true, defaultPrompt };
824
+ });
825
+ const tools = createContactTools({ updateAssistantSettings });
826
+ const update = tools.find((tool) => tool.name === UPDATE_DEFAULT_PROMPT_TOOL_NAME);
827
+
828
+ const saved = await update.execute('call_set', { prompt: 'Be terse.' });
829
+ expect(updateAssistantSettings).toHaveBeenCalledWith({ defaultPrompt: 'Be terse.' });
830
+ expect(saved.details).toEqual({ updated: true, defaultPrompt: 'Be terse.' });
831
+ expect(saved.content[0].text).toBe(UPDATE_DEFAULT_PROMPT_CONFIRM_BUBBLE);
832
+ expect(saved.terminate).toBe(false);
833
+
834
+ const cleared = await update.execute('call_clear', { prompt: '' });
835
+ expect(updateAssistantSettings).toHaveBeenCalledWith({ defaultPrompt: '' });
836
+ expect(cleared.details).toEqual({ updated: true, defaultPrompt: '' });
837
+ expect(cleared.terminate).toBe(false);
838
+
839
+ const unchanged = await update.execute('call_same', { prompt: 'same' });
840
+ expect(unchanged.details).toEqual({ updated: false, unchanged: true, defaultPrompt: 'same' });
841
+ expect(unchanged.content[0].text).toBe(UPDATE_DEFAULT_PROMPT_UNCHANGED_BUBBLE);
842
+ expect(unchanged.terminate).toBe(false);
843
+
844
+ const missing = await update.execute('call_missing', {});
845
+ expect(missing.details.error).toBe('validation_error');
846
+ expect(missing.terminate).toBe(true);
847
+
848
+ updateAssistantSettings.mockRejectedValueOnce(Object.assign(new Error('revision_conflict'), { code: 'revision_conflict' }));
849
+ const conflicted = await update.execute('call_conflict', { prompt: 'X' });
850
+ expect(conflicted.details.error).toBe('revision_conflict');
851
+ expect(conflicted.terminate).toBe(true);
852
+
853
+ updateAssistantSettings.mockRejectedValueOnce(Object.assign(new Error('sqlite busy'), { code: 'upstream_error' }));
854
+ const storageFailed = await update.execute('call_storage', { prompt: 'Y' });
855
+ expect(storageFailed.details.error).toBe('upstream_error');
856
+ expect(storageFailed.terminate).toBe(true);
857
+ });
858
+
859
+ it('reads and updates another assistant defaultPrompt by name without changing self', async () => {
860
+ const peer = {
861
+ id: 'asst_peer',
862
+ name: 'OpenCode 配置助手',
863
+ defaultPrompt: 'old peer',
864
+ providerID: 'p',
865
+ modelID: 'm',
866
+ agent: null,
867
+ variant: null,
868
+ mode: 'continuous',
869
+ workspacePath: null,
870
+ enabled: true,
871
+ };
872
+ const host = {
873
+ id: 'asst_host',
874
+ name: 'Host',
875
+ defaultPrompt: 'host prompt',
876
+ providerID: 'p',
877
+ modelID: 'm',
878
+ agent: null,
879
+ variant: null,
880
+ mode: 'continuous',
881
+ workspacePath: null,
882
+ enabled: true,
883
+ };
884
+ const readAssistantSettings = vi.fn(async (input) => (
885
+ input?.assistantID === 'asst_peer' ? peer : host
886
+ ));
887
+ const updateAssistantSettings = vi.fn(async (patch) => ({
888
+ updated: true,
889
+ defaultPrompt: patch.defaultPrompt,
890
+ }));
891
+ const listAssistants = vi.fn(async () => [host, peer]);
892
+ const tools = createContactTools({
893
+ readAssistantSettings,
894
+ updateAssistantSettings,
895
+ listAssistants,
896
+ currentAssistant: host,
897
+ });
898
+
899
+ const got = await tools.find((tool) => tool.name === GET_ASSISTANT_SETTINGS_TOOL_NAME)
900
+ .execute('call_peer_get', { to: 'OpenCode 配置助手' });
901
+ expect(readAssistantSettings).toHaveBeenCalledWith({ assistantID: 'asst_peer' });
902
+ expect(got.details.settings).toMatchObject({ id: 'asst_peer', name: 'OpenCode 配置助手', defaultPrompt: 'old peer' });
903
+
904
+ const saved = await tools.find((tool) => tool.name === UPDATE_DEFAULT_PROMPT_TOOL_NAME)
905
+ .execute('call_peer_set', { to: 'OpenCode 配置助手', prompt: 'new peer persona' });
906
+ expect(updateAssistantSettings).toHaveBeenCalledWith({
907
+ defaultPrompt: 'new peer persona',
908
+ assistantID: 'asst_peer',
909
+ });
910
+ expect(saved.details).toMatchObject({
911
+ updated: true,
912
+ defaultPrompt: 'new peer persona',
913
+ assistantID: 'asst_peer',
914
+ name: 'OpenCode 配置助手',
915
+ });
916
+
917
+ const missing = await tools.find((tool) => tool.name === UPDATE_DEFAULT_PROMPT_TOOL_NAME)
918
+ .execute('call_missing_peer', { to: '没有这个助手', prompt: 'x' });
919
+ expect(missing.details.error).toBe('not_found');
920
+ });
538
921
  });
539
922
 
540
923
  describe('confirmBubbleAfterContactReset', () => {
@@ -28,10 +28,41 @@ export const assistantContractFixtures = Object.freeze({
28
28
  text: 'Fixture reply',
29
29
  fallbackKind: null,
30
30
  }),
31
+ /** Countable assistant/peer replies after the shared read watermark. */
32
+ unreadCount: 0,
33
+ /** Persisted shared read watermark (multi-client). */
34
+ readWatermark: Object.freeze({
35
+ generation: 0,
36
+ ordinal: 2,
37
+ messageID: 'msg_contact_fixture',
38
+ }),
39
+ /** Highest safe mark-read tip (transcript head + generation). */
40
+ readTip: Object.freeze({
41
+ generation: 0,
42
+ ordinal: 2,
43
+ messageID: 'msg_contact_fixture',
44
+ }),
31
45
  createdAt: 1234,
32
46
  updatedAt: 1234,
33
47
  tombstoneAt: null,
34
48
  }),
49
+ /** Frozen POST /assistants/:id/contact/read response. */
50
+ contactReadResponse: Object.freeze({
51
+ assistantID: 'assistant_fixture',
52
+ changed: true,
53
+ unreadCount: 0,
54
+ readWatermark: Object.freeze({
55
+ generation: 0,
56
+ ordinal: 2,
57
+ messageID: 'msg_contact_fixture',
58
+ }),
59
+ readTip: Object.freeze({
60
+ generation: 0,
61
+ ordinal: 2,
62
+ messageID: 'msg_contact_fixture',
63
+ }),
64
+ revision: 4,
65
+ }),
35
66
  sessionBinding: Object.freeze({ sessionID: 'ses_fixture', directory: '/workspace', sessionGeneration: 4 }),
36
67
  compactResponse: Object.freeze({ binding: Object.freeze({ sessionID: 'ses_fixture', directory: '/workspace', sessionGeneration: 4 }), summarized: true }),
37
68
  messageAdmission: Object.freeze({ binding: Object.freeze({ sessionID: 'ses_fixture', directory: '/workspace', sessionGeneration: 4 }), messageID: 'msg_fixture', admitted: true, revision: 4 }),