@openchambery/web 1.19.13 → 1.19.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-m1jNq4bX.js} +1 -1
  2. package/dist/assets/AssistantView-Dncbu_Z2.js +2 -0
  3. package/dist/assets/{DiagramView-BdI4PT2H.js → DiagramView-CovEl1BY.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
  5. package/dist/assets/Icon-D1BcR8Tz.js +1 -0
  6. package/dist/assets/{MarkdownRenderer-CLQ4Z8Z0.js → MarkdownRenderer-CZqok-Cf.js} +2 -2
  7. package/dist/assets/{MarkdownRendererImpl-2ewbRZE2.js → MarkdownRendererImpl-CCfHY-86.js} +3 -3
  8. package/dist/assets/{MarkstreamRendererImpl-DXewO7S_.js → MarkstreamRendererImpl-y6QgIUZA.js} +2 -2
  9. package/dist/assets/MobileDetailNavigation-Ca-d5eZ9.js +1 -0
  10. package/dist/assets/MobileShareBridge-B4S7BaUj.js +4 -0
  11. package/dist/assets/MobileSurface-xL5Jby4U.js +2 -0
  12. package/dist/assets/{MultiRunWindow-C8ukIdLA.js → MultiRunWindow-BeHa-CG6.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-bIWeMLVx.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
  15. package/dist/assets/SettingsView-Dn7odtQz.js +25 -0
  16. package/dist/assets/{SettingsWindow-CSePZ_V8.js → SettingsWindow-C0tWPS5f.js} +1 -1
  17. package/dist/assets/{TerminalView-BmSIrZ81.js → TerminalView-BnJgC7Qo.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-2BYaWLo1.js → ToolOutputDialog-D9S-X8hM.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-Dk4gBaOu.js → appThemeRegistry-DBrvaJnq.js} +1 -1
  20. package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
  21. package/dist/assets/{clipboard-CUasgPiF.js → clipboard-GUUNlWIv.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-DhMGbF_B.js} +1 -1
  28. package/dist/assets/{es-CRjOwvYM.js → es-BQb6I8gl.js} +2 -2
  29. package/dist/assets/fileMentionTouchSelection-Dc004xOh.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-BjGouC_l.js} +2 -2
  32. package/dist/assets/{gitApi-DtPf-h-O.js → gitApi-Cm3JLQqh.js} +3 -3
  33. package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
  34. package/dist/assets/insertionBoundaries-CTWYFsbH.js +1 -0
  35. package/dist/assets/{ja-CYQ5aoWp.js → ja-Cw9zliYY.js} +2 -2
  36. package/dist/assets/{ko-BwASV0Q0.js → ko-Dyz0Dk9b.js} +2 -2
  37. package/dist/assets/main-CSqu3MB4.js +169 -0
  38. package/dist/assets/{main-Ci3JmtpW.js → main-DwMprEyr.js} +2 -2
  39. package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
  40. package/dist/assets/miniChat-C8bzY_Ye.js +2 -0
  41. package/dist/assets/mobile-Oy-SRKkr.js +2 -0
  42. package/dist/assets/{multirun-CmaTeZON.js → multirun-ClcuaiKU.js} +1 -1
  43. package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
  44. package/dist/assets/{pl-DUI1vPil.js → pl-C7EY-dTH.js} +1 -1
  45. package/dist/assets/platform-DQ5_LNKO.js +2 -0
  46. package/dist/assets/{projectMeta-cw9nrwpr.js → projectMeta-F93wE2Ye.js} +2 -2
  47. package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-B1uHpjFW.js} +2 -2
  48. package/dist/assets/renderElectronMiniChatApp-rOOhMVlQ.js +2 -0
  49. package/dist/assets/renderMobileApp-BP_hGwHP.js +28 -0
  50. package/dist/assets/{runtimeConfig-DI8l0VyZ.js → runtimeConfig-iJ2mTBVN.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-ISnhAS3q.js +147 -0
  52. package/dist/assets/sessionChangeSummary-BAzU2J1H.js +2 -0
  53. package/dist/assets/sessionLookup-DQQ9-DuP.js +1 -0
  54. package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-dYW6SH62.js} +1 -1
  55. package/dist/assets/src-DYeppLfP.css +2 -0
  56. package/dist/assets/{store-CSnH_QXM.js → store-ClnJdJSj.js} +3 -3
  57. package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
  58. package/dist/assets/{textarea-BfNtUdJE.js → textarea-B9CmN13Z.js} +1 -1
  59. package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
  60. package/dist/assets/{uk-C1FYZlmA.js → uk-DgG8v2md.js} +2 -2
  61. package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
  62. package/dist/assets/{useAppFontEffects-BDFUND4v.js → useAppFontEffects--IcU7jyv.js} +43 -44
  63. package/dist/assets/useDesktopWindowControlsLayout-BqJ5R_T2.js +1 -0
  64. package/dist/assets/{useEffectiveDirectory-e9HjIqGz.js → useEffectiveDirectory-ChWXV8xM.js} +1 -1
  65. package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
  66. package/dist/assets/useMobileNavigationStore-DQkgxuzg.js +413 -0
  67. package/dist/assets/useSessionAutoCleanup-DWWAvQQ0.js +2 -0
  68. package/dist/assets/{useWorkerHighlightedLines-DTGE0CDs.js → useWorkerHighlightedLines-D2oQszsk.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-C6OFKjeT.js} +2 -2
  72. package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-BVP99aMr.js} +2 -2
  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 +93 -16
  79. package/server/lib/assistants/assign.js +117 -12
  80. package/server/lib/assistants/assign.test.js +79 -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 +303 -52
  85. package/server/lib/assistants/contact-tools.test.js +239 -7
  86. package/server/lib/assistants/contracts.js +31 -0
  87. package/server/lib/assistants/harness.js +178 -50
  88. package/server/lib/assistants/harness.test.js +428 -3
  89. package/server/lib/assistants/routes.js +3 -0
  90. package/server/lib/assistants/service.js +586 -43
  91. package/server/lib/assistants/service.test.js +696 -7
  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-CKmIS1cS.js +0 -2
  99. package/dist/assets/Icon-CkzzhaLr.js +0 -1
  100. package/dist/assets/MobileDetailNavigation-BGaAyuBH.js +0 -1
  101. package/dist/assets/MobileShareBridge-BAZoyeIP.js +0 -4
  102. package/dist/assets/MobileSurface-BVb3xNWt.js +0 -2
  103. package/dist/assets/SettingsView-CkQ61pbr.js +0 -25
  104. package/dist/assets/insertionBoundaries-BMhzPFft.js +0 -4
  105. package/dist/assets/main-BjeOhdcG.js +0 -169
  106. package/dist/assets/miniChat-BYrjYEXJ.js +0 -2
  107. package/dist/assets/mobile-BVMNnPK2.js +0 -2
  108. package/dist/assets/platform-DzsGqlGW.js +0 -2
  109. package/dist/assets/renderElectronMiniChatApp-COwia8Cs.js +0 -2
  110. package/dist/assets/renderMobileApp-CXwwpY1t.js +0 -28
  111. package/dist/assets/runtimeEndpointReset-BkAQXmXJ.js +0 -147
  112. package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
  113. package/dist/assets/sessionLookup-Imc995rW.js +0 -1
  114. package/dist/assets/src-AA-5nXsF.css +0 -2
  115. package/dist/assets/useDesktopWindowControlsLayout-C5hZD1co.js +0 -1
  116. package/dist/assets/useMobileNavigationStore-CYbDxRTM.js +0 -413
  117. package/dist/assets/useSessionAutoCleanup-BGy9_AhN.js +0 -2
@@ -13,9 +13,13 @@ import {
13
13
  NEW_CONVERSATION_CONFIRM_BUBBLE,
14
14
  NEW_CONVERSATION_TOOL_NAME,
15
15
  SCHEDULE_TASK_TOOL_NAME,
16
+ STOP_SESSION_TOOL_NAME,
17
+ STOPPED_SESSION_FALLBACK_BUBBLE,
16
18
  UPDATE_DEFAULT_PROMPT_CONFIRM_BUBBLE,
17
19
  UPDATE_DEFAULT_PROMPT_TOOL_NAME,
18
20
  UPDATE_DEFAULT_PROMPT_UNCHANGED_BUBBLE,
21
+ WATCH_SESSION_TOOL_NAME,
22
+ WATCHED_SESSION_FALLBACK_BUBBLE,
19
23
  confirmBubbleAfterContactReset,
20
24
  contactTurnHasSuccessfulReset,
21
25
  createContactTools,
@@ -35,11 +39,45 @@ import {
35
39
  import { attachmentScopeKey } from './assign.js';
36
40
 
37
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
+
38
76
  it('parses an assign_session fence and strips it from chat text', () => {
39
77
  const text = 'On it.\n\n```openchamber-tool\n{"name":"assign_session","arguments":{"prompt":"Fix login","projectPath":"/repo"}}\n```';
40
78
  const parsed = parseContactToolCalls(text, [ASSIGN_SESSION_TOOL_NAME]);
41
79
  expect(parsed.chatText).toBe('On it.');
42
- expect(parsed.toolCall).toEqual({
80
+ expect(parsed.toolCalls[0]).toEqual({
43
81
  name: ASSIGN_SESSION_TOOL_NAME,
44
82
  arguments: { prompt: 'Fix login', projectPath: '/repo' },
45
83
  });
@@ -52,7 +90,7 @@ describe('contact tool protocol', () => {
52
90
  [CREATE_ASSISTANT_TOOL_NAME, SCHEDULE_TASK_TOOL_NAME, ASSIGN_SESSION_TOOL_NAME],
53
91
  );
54
92
  expect(create.chatText).toBe('好。');
55
- expect(create.toolCall).toEqual({
93
+ expect(create.toolCalls[0]).toEqual({
56
94
  name: CREATE_ASSISTANT_TOOL_NAME,
57
95
  arguments: { name: 'FlowQA', model: 'opencode-go/deepseek-v4-flash' },
58
96
  });
@@ -64,11 +102,11 @@ describe('contact tool protocol', () => {
64
102
  '```openchamber-tool\n{"name":"message_assistant","arguments":{"to":"PeerQA","text":"hello-from-assistant 写好了"}}\n```',
65
103
  [MESSAGE_ASSISTANT_TOOL_NAME],
66
104
  );
67
- expect(peer.toolCall).toEqual({
105
+ expect(peer.toolCalls[0]).toEqual({
68
106
  name: MESSAGE_ASSISTANT_TOOL_NAME,
69
107
  arguments: { to: 'PeerQA', text: 'hello-from-assistant 写好了' },
70
108
  });
71
- expect(schedule.toolCall).toEqual({
109
+ expect(schedule.toolCalls[0]).toEqual({
72
110
  name: SCHEDULE_TASK_TOOL_NAME,
73
111
  arguments: { name: 'Daily ping', prompt: 'ping', time: '18:00', timezone: 'Asia/Shanghai' },
74
112
  });
@@ -79,7 +117,7 @@ describe('contact tool protocol', () => {
79
117
  '好的,我来直接创建这个助理,不开编码会话。\n{"name":"create_assistant","arguments":{"name":"FlowNL","model":"opencode-go/deepseek-v4-flash"}}',
80
118
  [CREATE_ASSISTANT_TOOL_NAME, ASSIGN_SESSION_TOOL_NAME],
81
119
  );
82
- expect(parsed.toolCall).toEqual({
120
+ expect(parsed.toolCalls[0]).toEqual({
83
121
  name: CREATE_ASSISTANT_TOOL_NAME,
84
122
  arguments: { name: 'FlowNL', model: 'opencode-go/deepseek-v4-flash' },
85
123
  });
@@ -87,6 +125,94 @@ describe('contact tool protocol', () => {
87
125
  expect(parsed.chatText).not.toContain('create_assistant');
88
126
  });
89
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
+
90
216
  it('detects 建助理 without treating 不要开编码 session as assign_session', () => {
91
217
  const tools = [
92
218
  NEW_CONVERSATION_TOOL_NAME,
@@ -98,6 +224,8 @@ describe('contact tool protocol', () => {
98
224
  CREATE_ASSISTANT_TOOL_NAME,
99
225
  SCHEDULE_TASK_TOOL_NAME,
100
226
  MESSAGE_ASSISTANT_TOOL_NAME,
227
+ WATCH_SESSION_TOOL_NAME,
228
+ STOP_SESSION_TOOL_NAME,
101
229
  ASSIGN_SESSION_TOOL_NAME,
102
230
  ];
103
231
  expect(detectRequestedContactTools('帮我新建一个助理,名叫 FlowNL,不要开编码 session', tools)).toEqual([
@@ -106,6 +234,14 @@ describe('contact tool protocol', () => {
106
234
  expect(detectRequestedContactTools('每天 18:00 排一个 ping 定时任务', tools)).toEqual([SCHEDULE_TASK_TOOL_NAME]);
107
235
  expect(detectRequestedContactTools('建会话写一个文件', tools)).toEqual([ASSIGN_SESSION_TOOL_NAME]);
108
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([]);
109
245
  expect(detectRequestedContactTools('写一个文件', tools)).toEqual([]);
110
246
  expect(detectRequestedContactTools('pwd', tools)).toEqual([]);
111
247
  expect(detectRequestedContactTools('给 PeerQA 说一声 hello-from-assistant 写好了', tools)).toEqual([
@@ -141,14 +277,14 @@ describe('contact tool protocol', () => {
141
277
 
142
278
  it('parses bash fences when the pi coding tools are allowed', () => {
143
279
  const text = '```openchamber-tool\n{"name":"bash","arguments":{"command":"pwd"}}\n```';
144
- expect(parseContactToolCalls(text, ['bash', ASSIGN_SESSION_TOOL_NAME]).toolCall).toEqual({
280
+ expect(parseContactToolCalls(text, ['bash', ASSIGN_SESSION_TOOL_NAME]).toolCalls[0]).toEqual({
145
281
  name: 'bash',
146
282
  arguments: { command: 'pwd' },
147
283
  });
148
284
  expect(parseContactToolCalls(
149
285
  '```openchamber-tool\n{"name":"glob","arguments":{"pattern":"*"}}\n```',
150
286
  ['glob', 'bash'],
151
- ).toolCall).toBeNull();
287
+ ).toolCalls).toEqual([]);
152
288
  });
153
289
 
154
290
  it('tells DeepSeek to call tools from natural language, not slash commands', () => {
@@ -163,6 +299,8 @@ describe('contact tool protocol', () => {
163
299
  expect(prompt).toContain('schedule_task');
164
300
  expect(prompt).toContain('message_assistant');
165
301
  expect(prompt).toContain('assign_session');
302
+ expect(prompt).toContain('watch_session');
303
+ expect(prompt).toContain('stop_session');
166
304
  expect(prompt).toContain('开新对话');
167
305
  expect(prompt).toContain('清除记忆');
168
306
  expect(prompt).toContain('清空聊天记录');
@@ -173,6 +311,9 @@ describe('contact tool protocol', () => {
173
311
  expect(prompt).toContain('建助理');
174
312
  expect(prompt).toContain('排定时任务');
175
313
  expect(prompt).toContain('说一声');
314
+ expect(prompt).toContain('监听会话');
315
+ expect(prompt).toContain('停止会话');
316
+ expect(prompt).toContain('plain @session:id reference alone is context');
176
317
  expect(prompt).toContain('session/new');
177
318
  expect(prompt).toContain('LLM memory only');
178
319
  expect(prompt).toContain('Persists');
@@ -290,12 +431,18 @@ describe('createContactTools', () => {
290
431
  CLEAR_CHAT_HISTORY_TOOL_NAME,
291
432
  LIST_PROJECTS_TOOL_NAME,
292
433
  LIST_SESSIONS_TOOL_NAME,
434
+ 'read_session',
293
435
  GET_ASSISTANT_SETTINGS_TOOL_NAME,
294
436
  UPDATE_DEFAULT_PROMPT_TOOL_NAME,
295
437
  CREATE_ASSISTANT_TOOL_NAME,
296
438
  SCHEDULE_TASK_TOOL_NAME,
297
439
  MESSAGE_ASSISTANT_TOOL_NAME,
298
440
  ASSIGN_SESSION_TOOL_NAME,
441
+ WATCH_SESSION_TOOL_NAME,
442
+ STOP_SESSION_TOOL_NAME,
443
+ 'steer_session',
444
+ 'archive_session',
445
+ 'delete_session',
299
446
  ]);
300
447
  expect(tools.some((tool) => ['bash', 'edit', 'read', 'write'].includes(tool.name))).toBe(false);
301
448
 
@@ -374,6 +521,91 @@ describe('createContactTools', () => {
374
521
  expect(onCard).toHaveBeenCalledTimes(3);
375
522
  });
376
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
+
377
609
  it('forwards server turn file parts on assign and keeps model selection in the tool args', async () => {
378
610
  const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' };
379
611
  const assignWork = vi.fn(async (params) => ({
@@ -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 }),