@openchambery/web 1.19.10-beta.1 → 1.19.10

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 (61) hide show
  1. package/dist/assets/{AssistantView-7SZQ12Js.js → AssistantView-BsSP5fIS.js} +1 -1
  2. package/dist/assets/{DiagramView-D5WUE1De.js → DiagramView-Dx6Bt2V6.js} +1 -1
  3. package/dist/assets/MarkdownRenderer-BkAWQ2GV.js +2 -0
  4. package/dist/assets/{MarkdownRendererImpl-uH_cfmYV.js → MarkdownRendererImpl-Ds8Ssyq-.js} +1 -1
  5. package/dist/assets/{MarkstreamRendererImpl-BAEStQuf.js → MarkstreamRendererImpl-De3BKIuP.js} +1 -1
  6. package/dist/assets/{MobileDetailNavigation-DfZmPtql.js → MobileDetailNavigation-BXKxAdE_.js} +1 -1
  7. package/dist/assets/{MobileShareBridge-CSJXojSq.js → MobileShareBridge-ziYucHOE.js} +1 -1
  8. package/dist/assets/MobileSurface-CVKw8mGv.js +2 -0
  9. package/dist/assets/{MultiRunWindow-DM_LKkMR.js → MultiRunWindow-CNEIMi0b.js} +1 -1
  10. package/dist/assets/{OnboardingScreen-C3-wzeSX.js → OnboardingScreen-DS2nugLX.js} +1 -1
  11. package/dist/assets/{SettingsView-DLzL3jV2.js → SettingsView-gH4Hr-Ci.js} +2 -2
  12. package/dist/assets/{SettingsWindow-CtF8i9jw.js → SettingsWindow-DywJoMmg.js} +1 -1
  13. package/dist/assets/{TerminalView-D_XjSm4c.js → TerminalView-DMCgudnq.js} +1 -1
  14. package/dist/assets/{ToolOutputDialog-FB6tTD3j.js → ToolOutputDialog-DS0sIkYs.js} +1 -1
  15. package/dist/assets/{appThemeRegistry-QBlzmiVt.js → appThemeRegistry-CPmXzUpv.js} +1 -1
  16. package/dist/assets/desktopHosts-Dc2PS66M.js +1 -0
  17. package/dist/assets/desktopRelayRestore-T9y0orqa.js +1 -0
  18. package/dist/assets/{gitApi-Dhf22VRf.js → gitApi-B7Z4cSkO.js} +1 -1
  19. package/dist/assets/{insertionBoundaries-DPXJs_6g.js → insertionBoundaries-BqKrCN3T.js} +1 -1
  20. package/dist/assets/{main-rP7OpJ90.js → main-BK_cIfNI.js} +22 -22
  21. package/dist/assets/{main-DDkeV7EJ.js → main-gw6pD8eo.js} +2 -2
  22. package/dist/assets/{miniChat-DbpkRapr.js → miniChat-BuKi9XeD.js} +2 -2
  23. package/dist/assets/{mobile-BHzD2N9R.js → mobile-C24ys3se.js} +2 -2
  24. package/dist/assets/{multirun-Bj0sq61V.js → multirun-CekxHJb2.js} +1 -1
  25. package/dist/assets/{projectMeta-CWZHL9ES.js → projectMeta-4yuBfnoI.js} +1 -1
  26. package/dist/assets/{renderElectronMiniChatApp-Cvq7gvEF.js → renderElectronMiniChatApp-DPannb7B.js} +2 -2
  27. package/dist/assets/{renderMobileApp-Bw-DYhhE.js → renderMobileApp-B_rMJh_N.js} +4 -4
  28. package/dist/assets/{runtimeConfig-DI2F7QqO.js → runtimeConfig-Qm8Qi_ur.js} +1 -1
  29. package/dist/assets/{runtimeEndpointReset-BaZvZoAh.js → runtimeEndpointReset-abc-B3J7.js} +1 -1
  30. package/dist/assets/{sessionLookup-C0X2jjwR.js → sessionLookup-cWAHYgXp.js} +1 -1
  31. package/dist/assets/{useAppFontEffects-BzioX7oh.js → useAppFontEffects-BC7Olwie.js} +5 -5
  32. package/dist/assets/{useDesktopWindowControlsLayout-sxoPqjgO.js → useDesktopWindowControlsLayout-D06g5Qdz.js} +1 -1
  33. package/dist/assets/{useEffectiveDirectory-D36udiNq.js → useEffectiveDirectory-SoAZb6s1.js} +1 -1
  34. package/dist/assets/{useMobileNavigationStore-DYJ6I8FV.js → useMobileNavigationStore-Btd4QQdS.js} +1 -1
  35. package/dist/assets/useSessionAutoCleanup-CLoSRLcC.js +1 -0
  36. package/dist/assets/{useWorkerHighlightedLines-bL-mmXJM.js → useWorkerHighlightedLines-Cu4ICr-m.js} +1 -1
  37. package/dist/index.html +4 -4
  38. package/dist/mini-chat.html +4 -4
  39. package/dist/mobile.html +4 -4
  40. package/package.json +1 -1
  41. package/server/lib/assistants/DOCUMENTATION.md +21 -10
  42. package/server/lib/assistants/contact-store.js +90 -5
  43. package/server/lib/assistants/contact-store.test.js +127 -2
  44. package/server/lib/assistants/contact-tools.js +107 -24
  45. package/server/lib/assistants/contact-tools.test.js +45 -3
  46. package/server/lib/assistants/harness.js +15 -5
  47. package/server/lib/assistants/harness.test.js +65 -3
  48. package/server/lib/assistants/service.js +76 -10
  49. package/server/lib/assistants/service.test.js +270 -11
  50. package/server/lib/llm/DOCUMENTATION.md +6 -0
  51. package/server/lib/llm/generate.js +4 -1
  52. package/server/lib/llm/generate.test.js +6 -1
  53. package/server/lib/opencode/DOCUMENTATION.md +1 -0
  54. package/server/lib/opencode/openchamber-routes.js +3 -0
  55. package/server/lib/opencode/openchamber-routes.test.js +43 -0
  56. package/server/lib/relay/DOCUMENTATION.md +1 -0
  57. package/dist/assets/MarkdownRenderer-CqYd9loP.js +0 -2
  58. package/dist/assets/MobileSurface-CmFXo_Zx.js +0 -2
  59. package/dist/assets/desktopHosts-CWxex1c3.js +0 -1
  60. package/dist/assets/desktopRelayRestore-9EbBYQ9s.js +0 -1
  61. package/dist/assets/useSessionAutoCleanup-7L5-suil.js +0 -1
@@ -5,6 +5,10 @@ import { describe, expect, it, vi } from 'vitest';
5
5
  import { createRequire } from 'node:module';
6
6
  import { createAssistantsService } from './service.js';
7
7
  import { assistantContractFixtures } from './contracts.js';
8
+ import {
9
+ CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
10
+ NEW_CONVERSATION_CONFIRM_BUBBLE,
11
+ } from './contact-tools.js';
8
12
 
9
13
  const require = createRequire(import.meta.url);
10
14
  const root = () => fs.mkdtempSync(path.join(os.tmpdir(), 'assistants-'));
@@ -97,7 +101,7 @@ describe('assistants service', () => {
97
101
  service.close();
98
102
  });
99
103
 
100
- it('new_conversation clears contact history without calling OpenCode session/new', async () => {
104
+ it('new_conversation clears LLM memory while keeping the full transcript', async () => {
101
105
  const directory = root();
102
106
  let creates = 0;
103
107
  let lastHistory = null;
@@ -109,7 +113,7 @@ describe('assistants service', () => {
109
113
  if (userText === '开新对话') {
110
114
  const tool = tools.find((item) => item.name === 'new_conversation');
111
115
  const result = await tool.execute('reset_1', {});
112
- return { text: result.content[0].text, bubbles: [result.content[0].text] };
116
+ return { text: result.content[0].text, bubbles: [result.content[0].text], reset: true };
113
117
  }
114
118
  return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
115
119
  },
@@ -125,13 +129,16 @@ describe('assistants service', () => {
125
129
  expect(creates).toBe(1);
126
130
  const page = service.contactMessages(assistant.id, { limit: 50 });
127
131
  expect(page.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([
132
+ { role: 'user', text: 'remember this secret' },
133
+ { role: 'assistant', text: 'reply:remember this secret' },
134
+ { role: 'user', text: 'and this too' },
135
+ { role: 'assistant', text: 'reply:and this too' },
128
136
  { role: 'user', text: '开新对话' },
129
- { role: 'assistant', text: 'Started a new conversation. Previous contact messages are cleared.' },
137
+ { role: 'assistant', text: NEW_CONVERSATION_CONFIRM_BUBBLE },
130
138
  ]);
131
139
  await settleSend(service, assistant.id, { messageID: 'fresh_1', parts: [{ type: 'text', text: 'what did I say before?' }] });
132
140
  expect(lastHistory.map((item) => item.content)).toEqual([
133
- '开新对话',
134
- 'Started a new conversation. Previous contact messages are cleared.',
141
+ NEW_CONVERSATION_CONFIRM_BUBBLE,
135
142
  ]);
136
143
  expect(lastHistory.some((item) => item.content.includes('remember this secret'))).toBe(false);
137
144
  service.close();
@@ -144,13 +151,14 @@ describe('assistants service', () => {
144
151
  if (userText === '开新对话') {
145
152
  await tools.find((item) => item.name === 'new_conversation').execute('reset_leftover', {});
146
153
  return {
147
- text: 'Started a new conversation. Previous contact messages are cleared.\n\nI still see your dot.png and note.txt.',
154
+ text: `${NEW_CONVERSATION_CONFIRM_BUBBLE}\n\nI still see your dot.png and note.txt.`,
148
155
  bubbles: [
149
- 'Started a new conversation. Previous contact messages are cleared.',
156
+ NEW_CONVERSATION_CONFIRM_BUBBLE,
150
157
  'I still see your dot.png and note.txt.',
151
158
  'Those attachments are still in context.',
152
159
  ],
153
160
  cards: [{ type: 'card', cardType: 'session', sessionID: 'ses_stale', directory: '/repo', title: 'Old', status: 'busy' }],
161
+ reset: true,
154
162
  };
155
163
  }
156
164
  return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
@@ -168,30 +176,281 @@ describe('assistants service', () => {
168
176
  await settleSend(service, assistant.id, { messageID: 'reset_leftover', parts: [{ type: 'text', text: '开新对话' }] });
169
177
  const page = service.contactMessages(assistant.id, { limit: 50 });
170
178
  expect(page.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([
179
+ { role: 'user', text: 'look at these' },
180
+ { role: 'assistant', text: 'reply:look at these' },
171
181
  { role: 'user', text: '开新对话' },
172
- { role: 'assistant', text: 'Started a new conversation. Previous contact messages are cleared.' },
182
+ { role: 'assistant', text: NEW_CONVERSATION_CONFIRM_BUBBLE },
173
183
  ]);
174
184
  expect(page.messages.some((message) => message.text.includes('dot.png') || message.text.includes('note.txt'))).toBe(false);
175
- expect(page.messages.some((message) => message.cards?.length > 0)).toBe(false);
185
+ expect(page.messages.filter((message) => message.cards?.length > 0)).toHaveLength(0);
176
186
  service.close();
177
187
  });
178
188
 
179
- it('resetContact empties the transcript for a fresh UI refetch without createNew', async () => {
189
+ it('clear_chat_history and resetContact delete the transcript without createNew', async () => {
180
190
  const directory = root();
181
191
  let creates = 0;
182
192
  const service = setup(directory, {
183
193
  create: async () => ({ data: { id: `ses_${++creates}` } }),
194
+ }, {
195
+ runContactTurn: async ({ userText, tools }) => {
196
+ if (userText === '清空聊天记录' || userText === '清除聊天记录') {
197
+ const tool = tools.find((item) => item.name === 'clear_chat_history');
198
+ const result = await tool.execute('wipe_1', {});
199
+ if (result.details?.error) {
200
+ return { text: result.content[0].text, bubbles: [result.content[0].text] };
201
+ }
202
+ return {
203
+ text: result.content[0].text,
204
+ bubbles: [result.content[0].text],
205
+ reset: true,
206
+ historyCleared: true,
207
+ messages: [{
208
+ role: 'toolResult',
209
+ toolName: 'clear_chat_history',
210
+ details: result.details,
211
+ }],
212
+ };
213
+ }
214
+ return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
215
+ },
184
216
  });
185
217
  const assistant = service.createAssistant(assistantInput);
186
218
  await service.ensure(assistant.id);
187
219
  await settleSend(service, assistant.id, { messageID: 'keep_1', parts: [{ type: 'text', text: 'hello' }] });
188
220
  expect(service.contactMessages(assistant.id, { limit: 50 }).messages.length).toBeGreaterThan(0);
189
- expect(service.resetContact(assistant.id)).toEqual({ assistantID: assistant.id, reset: true });
221
+ expect(service.resetContact(assistant.id)).toEqual({
222
+ assistantID: assistant.id,
223
+ reset: true,
224
+ historyCleared: true,
225
+ });
190
226
  expect(creates).toBe(1);
191
227
  expect(service.contactMessages(assistant.id, { limit: 50 })).toMatchObject({
192
228
  messages: [],
193
229
  complete: true,
194
230
  });
231
+ await settleSend(service, assistant.id, { messageID: 'again_1', parts: [{ type: 'text', text: 'hello again' }] });
232
+ await settleSend(service, assistant.id, { messageID: 'wipe_1', parts: [{ type: 'text', text: '清空聊天记录' }] });
233
+ expect(service.contactMessages(assistant.id, { limit: 50 }).messages.map((message) => ({
234
+ role: message.role,
235
+ text: message.text,
236
+ }))).toEqual([
237
+ { role: 'user', text: '清空聊天记录' },
238
+ { role: 'assistant', text: CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE },
239
+ ]);
240
+ await settleSend(service, assistant.id, { messageID: 'again_2', parts: [{ type: 'text', text: 'hello third' }] });
241
+ await settleSend(service, assistant.id, { messageID: 'wipe_2', parts: [{ type: 'text', text: '清除聊天记录' }] });
242
+ expect(service.contactMessages(assistant.id, { limit: 50 }).messages.map((message) => ({
243
+ role: message.role,
244
+ text: message.text,
245
+ }))).toEqual([
246
+ { role: 'user', text: '清除聊天记录' },
247
+ { role: 'assistant', text: CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE },
248
+ ]);
249
+ service.close();
250
+ });
251
+
252
+ it('rejects malicious clear_chat_history without explicit wipe intent and keeps transcript', async () => {
253
+ const directory = root();
254
+ const wipeAttempts = [];
255
+ const service = setup(directory, {}, {
256
+ runContactTurn: async ({ userText, tools }) => {
257
+ if (userText === '清除记忆' || userText === '不要清除聊天记录') {
258
+ const tool = tools.find((item) => item.name === 'clear_chat_history');
259
+ const result = await tool.execute(`malicious_${userText}`, {});
260
+ wipeAttempts.push({
261
+ userText,
262
+ error: result.details?.error || null,
263
+ historyCleared: result.details?.historyCleared === true,
264
+ });
265
+ return {
266
+ text: `blocked:${userText}`,
267
+ bubbles: [`blocked:${userText}`],
268
+ messages: [{
269
+ role: 'toolResult',
270
+ toolName: 'clear_chat_history',
271
+ details: result.details,
272
+ }],
273
+ };
274
+ }
275
+ return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
276
+ },
277
+ });
278
+ const assistant = service.createAssistant(assistantInput);
279
+ await settleSend(service, assistant.id, { messageID: 'seed_keep', parts: [{ type: 'text', text: 'keep-me' }] });
280
+ await settleSend(service, assistant.id, { messageID: 'bad_memory', parts: [{ type: 'text', text: '清除记忆' }] });
281
+ await settleSend(service, assistant.id, { messageID: 'bad_negation', parts: [{ type: 'text', text: '不要清除聊天记录' }] });
282
+ expect(wipeAttempts).toEqual([
283
+ { userText: '清除记忆', error: 'validation_error', historyCleared: false },
284
+ { userText: '不要清除聊天记录', error: 'validation_error', historyCleared: false },
285
+ ]);
286
+ const texts = service.contactMessages(assistant.id, { limit: 50 }).messages.map((message) => message.text);
287
+ expect(texts).toContain('keep-me');
288
+ expect(texts).toContain('清除记忆');
289
+ expect(texts).toContain('不要清除聊天记录');
290
+ expect(texts).not.toContain(CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE);
291
+ service.close();
292
+ });
293
+
294
+ it('loads LLM history at turn start so queued turns respect a prior clear-memory', async () => {
295
+ const directory = root();
296
+ const histories = [];
297
+ let releaseClear;
298
+ const clearGate = new Promise((resolve) => { releaseClear = resolve; });
299
+ const service = setup(directory, {}, {
300
+ runContactTurn: async ({ history, userText, tools }) => {
301
+ histories.push({ userText, history: history.map((item) => item.content) });
302
+ if (userText === '开新对话') {
303
+ await tools.find((item) => item.name === 'new_conversation').execute('queued_clear', {});
304
+ releaseClear();
305
+ return { text: 'cleared', bubbles: ['cleared'], reset: true };
306
+ }
307
+ if (userText === 'queued after clear') {
308
+ await clearGate;
309
+ }
310
+ return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
311
+ },
312
+ });
313
+ const assistant = service.createAssistant(assistantInput);
314
+ await settleSend(service, assistant.id, { messageID: 'seed_1', parts: [{ type: 'text', text: 'seed secret' }] });
315
+ const clearSend = service.send(assistant.id, { messageID: 'clear_q', parts: [{ type: 'text', text: '开新对话' }] });
316
+ const queuedSend = service.send(assistant.id, { messageID: 'queued_q', parts: [{ type: 'text', text: 'queued after clear' }] });
317
+ await Promise.all([clearSend, queuedSend]);
318
+ await service.whenContactTurnSettled('clear_q');
319
+ await service.whenContactTurnSettled('queued_q');
320
+ const queuedHistory = histories.find((entry) => entry.userText === 'queued after clear')?.history || [];
321
+ expect(queuedHistory.some((content) => content.includes('seed secret'))).toBe(false);
322
+ expect(service.contactMessages(assistant.id, { limit: 50 }).messages.some((message) => message.text === 'seed secret')).toBe(true);
323
+ service.close();
324
+ });
325
+
326
+ it('clear-memory watermark does not permanently exclude later-admitted queued users', async () => {
327
+ const directory = root();
328
+ const histories = [];
329
+ let releaseClear;
330
+ const clearGate = new Promise((resolve) => { releaseClear = resolve; });
331
+ const service = setup(directory, {}, {
332
+ runContactTurn: async ({ history, userText, tools }) => {
333
+ histories.push({ userText, history: history.map((item) => item.content) });
334
+ if (userText === '开新对话') {
335
+ await tools.find((item) => item.name === 'new_conversation').execute('waterline_clear', {});
336
+ releaseClear();
337
+ return { text: NEW_CONVERSATION_CONFIRM_BUBBLE, bubbles: [NEW_CONVERSATION_CONFIRM_BUBBLE], reset: true };
338
+ }
339
+ if (userText === 'queued keep me') {
340
+ await clearGate;
341
+ }
342
+ return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
343
+ },
344
+ });
345
+ const assistant = service.createAssistant(assistantInput);
346
+ await settleSend(service, assistant.id, { messageID: 'seed_w', parts: [{ type: 'text', text: 'seed secret' }] });
347
+ const clearSend = service.send(assistant.id, { messageID: 'clear_w', parts: [{ type: 'text', text: '开新对话' }] });
348
+ const queuedSend = service.send(assistant.id, { messageID: 'queued_w', parts: [{ type: 'text', text: 'queued keep me' }] });
349
+ await Promise.all([clearSend, queuedSend]);
350
+ await service.whenContactTurnSettled('clear_w');
351
+ await service.whenContactTurnSettled('queued_w');
352
+ await settleSend(service, assistant.id, { messageID: 'follow_w', parts: [{ type: 'text', text: 'follow after queue' }] });
353
+ const followHistory = histories.find((entry) => entry.userText === 'follow after queue')?.history || [];
354
+ expect(followHistory.some((content) => content.includes('seed secret'))).toBe(false);
355
+ expect(followHistory.some((content) => content.includes('queued keep me'))).toBe(true);
356
+ expect(followHistory.some((content) => content.includes('reply:queued keep me'))).toBe(true);
357
+ service.close();
358
+ });
359
+
360
+ it('does not inject later-admitted queued user messages into the current turn history', async () => {
361
+ const directory = root();
362
+ const histories = [];
363
+ let releaseFirst;
364
+ const firstGate = new Promise((resolve) => { releaseFirst = resolve; });
365
+ const service = setup(directory, {}, {
366
+ runContactTurn: async ({ history, userText }) => {
367
+ histories.push({ userText, history: history.map((item) => item.content) });
368
+ if (userText === 'first turn') {
369
+ await firstGate;
370
+ }
371
+ return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
372
+ },
373
+ });
374
+ const assistant = service.createAssistant(assistantInput);
375
+ await settleSend(service, assistant.id, { messageID: 'seed_hist', parts: [{ type: 'text', text: 'seed prior' }] });
376
+ const firstSend = service.send(assistant.id, { messageID: 'first_q', parts: [{ type: 'text', text: 'first turn' }] });
377
+ const queuedSend = service.send(assistant.id, { messageID: 'queued_later', parts: [{ type: 'text', text: 'queued later secret' }] });
378
+ await Promise.all([firstSend, queuedSend]);
379
+ // Let the first lane turn observe history while the queued user row is already admitted.
380
+ releaseFirst();
381
+ await service.whenContactTurnSettled('first_q');
382
+ await service.whenContactTurnSettled('queued_later');
383
+ const firstHistory = histories.find((entry) => entry.userText === 'first turn')?.history || [];
384
+ expect(firstHistory.some((content) => content.includes('seed prior'))).toBe(true);
385
+ expect(firstHistory.some((content) => content.includes('queued later secret'))).toBe(false);
386
+ const queuedHistory = histories.find((entry) => entry.userText === 'queued later secret')?.history || [];
387
+ expect(queuedHistory.some((content) => content.includes('first turn'))).toBe(true);
388
+ expect(queuedHistory.some((content) => content.includes('reply:first turn'))).toBe(true);
389
+ service.close();
390
+ });
391
+
392
+ it('persists clear-memory across restart and isolates assistants', async () => {
393
+ const directory = root();
394
+ const service = setup(directory, {}, {
395
+ runContactTurn: async ({ userText, tools }) => {
396
+ if (userText === '开新对话') {
397
+ await tools.find((item) => item.name === 'new_conversation').execute('persist_clear', {});
398
+ return { text: 'cleared', bubbles: ['cleared'], reset: true };
399
+ }
400
+ return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
401
+ },
402
+ });
403
+ const first = service.createAssistant(assistantInput);
404
+ const second = service.createAssistant({ ...assistantInput, name: 'B' });
405
+ await settleSend(service, first.id, { messageID: 'a1', parts: [{ type: 'text', text: 'alpha secret' }] });
406
+ await settleSend(service, second.id, { messageID: 'b1', parts: [{ type: 'text', text: 'beta secret' }] });
407
+ await settleSend(service, first.id, { messageID: 'a_clear', parts: [{ type: 'text', text: '开新对话' }] });
408
+ expect(service.clearContactMemory(second.id)).toMatchObject({
409
+ assistantID: second.id,
410
+ memoryCleared: true,
411
+ reset: true,
412
+ });
413
+ service.close();
414
+ const histories = [];
415
+ const restarted = setup(directory, {}, {
416
+ runContactTurn: async ({ history, userText }) => {
417
+ histories.push({ assistantHint: userText, history: history.map((item) => item.content) });
418
+ return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
419
+ },
420
+ });
421
+ const assistants = restarted.snapshot().assistants;
422
+ const a = assistants.find((item) => item.name === 'A');
423
+ const b = assistants.find((item) => item.name === 'B');
424
+ await settleSend(restarted, a.id, { messageID: 'a_next', parts: [{ type: 'text', text: 'after restart a' }] });
425
+ await settleSend(restarted, b.id, { messageID: 'b_next', parts: [{ type: 'text', text: 'after restart b' }] });
426
+ const aHistory = histories.find((entry) => entry.assistantHint === 'after restart a')?.history || [];
427
+ const bHistory = histories.find((entry) => entry.assistantHint === 'after restart b')?.history || [];
428
+ expect(aHistory.some((content) => content.includes('alpha secret'))).toBe(false);
429
+ expect(bHistory.some((content) => content.includes('beta secret'))).toBe(false);
430
+ expect(restarted.contactMessages(a.id, { limit: 50 }).messages.some((message) => message.text === 'alpha secret')).toBe(true);
431
+ expect(restarted.contactMessages(b.id, { limit: 50 }).messages.some((message) => message.text === 'beta secret')).toBe(true);
432
+ restarted.close();
433
+ });
434
+
435
+ it('exposes clearContactMemory without wiping GET transcript rows', async () => {
436
+ const directory = root();
437
+ let lastHistory = null;
438
+ const service = setup(directory, {}, {
439
+ runContactTurn: async ({ history, userText }) => {
440
+ lastHistory = history;
441
+ return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
442
+ },
443
+ });
444
+ const assistant = service.createAssistant(assistantInput);
445
+ await settleSend(service, assistant.id, { messageID: 'api_1', parts: [{ type: 'text', text: 'visible forever' }] });
446
+ expect(service.clearContactMemory(assistant.id)).toMatchObject({
447
+ assistantID: assistant.id,
448
+ reset: true,
449
+ memoryCleared: true,
450
+ });
451
+ expect(service.contactMessages(assistant.id, { limit: 50 }).messages.some((message) => message.text === 'visible forever')).toBe(true);
452
+ await settleSend(service, assistant.id, { messageID: 'api_2', parts: [{ type: 'text', text: 'next' }] });
453
+ expect(lastHistory.some((item) => item.content.includes('visible forever'))).toBe(false);
195
454
  service.close();
196
455
  });
197
456
 
@@ -83,6 +83,12 @@ an empty success. The 502 body includes `{ error, message }`.
83
83
 
84
84
  ## Ownership
85
85
 
86
+ The hidden generator agent explicitly describes application-owned tool calls:
87
+ it emits `openchamber-tool` JSON for the contact harness to execute and receives
88
+ the results on subsequent requests. Native OpenCode tool permissions remain
89
+ denied. Capability statements and execution errors must follow the supplied
90
+ application tool catalog and actual results.
91
+
86
92
  The Assistant contact harness owns system prompt, OpenChamber transcript,
87
93
  bubble splitting, and OpenChamber API tools (`assign_session`). Those tools
88
94
  deliver through contact **cards**, not this completions payload. The gateway
@@ -15,7 +15,10 @@ permissions:
15
15
  effect: deny
16
16
  ---
17
17
 
18
- You are a text generator. Reply with only the requested text. Do not use tools.
18
+ You generate responses for an application-owned assistant. Follow the supplied system instructions and response format.
19
+ The application executes its registered tools, including openchamber-tool JSON fences in your response, and supplies their results on the next request.
20
+ Emit the requested application tool call when an action requires one. Your native tool permissions describe this generator process; the application's supplied tool catalog describes the assistant's capabilities.
21
+ Report execution and failures from supplied tool results only.
19
22
  `;
20
23
 
21
24
  const isMissing = (result) =>
@@ -72,7 +72,12 @@ describe('generateOpenCodeText', () => {
72
72
  modelID: 'gpt-5-nano',
73
73
  messages: [{ role: 'user', content: 'hi' }],
74
74
  clientFactory: createOpencodeClient,
75
- ensureTempDirectory: async () => '/tmp/openchamber-llm',
75
+ ensureTempDirectory: async ({ agentMarkdown }) => {
76
+ expect(agentMarkdown).toContain('openchamber-tool JSON fences')
77
+ expect(agentMarkdown).toContain('effect: deny')
78
+ expect(agentMarkdown).toContain('Report execution and failures from supplied tool results only.')
79
+ return '/tmp/openchamber-llm'
80
+ },
76
81
  detect: async () => ({ available: false, mode: 'throwaway-session' }),
77
82
  })
78
83
 
@@ -352,6 +352,7 @@ When adding or changing Host HTTP APIs that mobile/desktop clients reach over Pr
352
352
  - `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints:
353
353
  - `GET /api/openchamber/update-check`
354
354
  - `POST /api/openchamber/update-install`
355
+ - Standalone web/CLI package installer. Electron-hosted servers return `403` before package-manager work; their owning desktop application manages updates. The desktop instance menu offers this action only for SSH-managed hosts, excluding Relay/imported connections.
355
356
  - `GET /api/zen/models`
356
357
  - session-index routes (see `session-index/DOCUMENTATION.md`)
357
358
  - transcript-cache routes under `/api/openchamber/transcript-cache` (see `transcript-cache/DOCUMENTATION.md`)
@@ -60,6 +60,9 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
60
60
  });
61
61
 
62
62
  app.post('/api/openchamber/update-install', async (_req, res) => {
63
+ if (process.versions?.electron) {
64
+ return res.status(403).json({ error: 'Update this instance from its desktop application.' });
65
+ }
63
66
  try {
64
67
  const { spawn: spawnChild } = await import('child_process');
65
68
  const {
@@ -0,0 +1,43 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import express from 'express';
3
+ import request from 'supertest';
4
+ import { registerOpenChamberRoutes } from './openchamber-routes.js';
5
+ import { checkForUpdates } from '../package-manager.js';
6
+
7
+ vi.mock('../session-index/routes.js', () => ({ registerSessionIndexRoutes: vi.fn() }));
8
+ vi.mock('../transcript-cache/routes.js', () => ({ registerTranscriptCacheRoutes: vi.fn() }));
9
+ vi.mock('../config-sync/routes.js', () => ({ registerConfigSyncRoutes: vi.fn() }));
10
+ vi.mock('../package-manager.js', () => ({
11
+ checkForUpdates: vi.fn(),
12
+ getUpdateCommand: vi.fn(),
13
+ detectPackageManagerDetails: vi.fn(),
14
+ }));
15
+
16
+ describe('remote update installation runtime boundary', () => {
17
+ beforeEach(() => {
18
+ vi.clearAllMocks();
19
+ vi.mocked(checkForUpdates).mockResolvedValue({ available: false });
20
+ });
21
+
22
+ const createApp = (versions) => {
23
+ const app = express();
24
+ registerOpenChamberRoutes(app, { express, process: { versions, env: {} } });
25
+ return app;
26
+ };
27
+
28
+ it('rejects the web installer in an Electron-hosted server before any update work', async () => {
29
+ const response = await request(createApp({ electron: '40.0.0' }))
30
+ .post('/api/openchamber/update-install');
31
+ expect(response.status).toBe(403);
32
+ expect(response.body.error).toMatch(/desktop application/i);
33
+ expect(checkForUpdates).not.toHaveBeenCalled();
34
+ });
35
+
36
+ it('preserves the standalone web server update path', async () => {
37
+ const response = await request(createApp({ node: '24.0.0' }))
38
+ .post('/api/openchamber/update-install');
39
+ expect(response.status).toBe(400);
40
+ expect(response.body.error).toBe('No update available');
41
+ expect(checkForUpdates).toHaveBeenCalledOnce();
42
+ });
43
+ });
@@ -61,6 +61,7 @@ See also `packages/web/server/lib/opencode/DOCUMENTATION.md` (“Catalog / OpenC
61
61
  - The tunnel is **transport only**. The OpenChamber server still authenticates every tunneled request exactly as it authenticates a direct remote client. The relay path grants reachability, not authorization.
62
62
  - A paired **desktop** client is a device-fleet operator: through the tunnel it can create pairing links, list every paired device, and revoke other devices (parity with the SSH attach flow, where the connecting desktop authenticates as the operator). Mobile (and any other) paired clients stay scoped to their own record. Re-pointing the Host relay endpoint set from a remote bearer remains forbidden — a paired desktop may name any endpoint the host already has, but adding/removing endpoints (including `relayUrl` in pairing requests for a new domain) is an owner-UI / local-desktop-shell action.
63
63
  - Clients carry their normal credential. HTTP and SSE requests authenticate with the client's bearer token (a header). **WebSocket upgrades cannot send headers**, so they authenticate with a short-lived URL-scoped token minted beforehand and passed as a query parameter. This asymmetry is important when adding new WebSocket features (see the skill).
64
+ - Desktop switch preflight calls `/auth/session` with the saved bearer over a pinned tunnel before adopting it. `/health` and cached reachability badges provide transport information only. Failed or timed-out probes close their temporary tunnel and preserve the active runtime; successful switch probes transfer ownership of the authenticated tunnel to the runtime.
64
65
  - The host authenticates itself to the relay with a signed handshake using its long-lived signing key.
65
66
  - Enabling the relay is explicit opt-in and disabled by default; disabling it severs all relay reachability immediately.
66
67
 
@@ -1,2 +0,0 @@
1
- const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/MarkstreamRendererImpl-BAEStQuf.js","assets/rolldown-runtime-C0FnF6B9.js","assets/vendor-base-ui-CnHfUmDN.js","assets/vendor-react-DLPElpoQ.js","assets/vendor-markstream-react-DX55XLGO.js","assets/vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js","assets/vendor-markstream-core-Cm34iPLL.js","assets/vendor-floating-ui-dom-BHhLtF5l.js","assets/vendor-floating-ui-core-Co1I1lFV.js","assets/vendor-reactuses-core-DDoDDyM9.js","assets/vendor-lodash-es-DfLbG5Af.js","assets/vendor-js-cookie-hSOiFTtX.js","assets/useMobileNavigationStore-DYJ6I8FV.js","assets/useUIStore-CuaA8-Hp.js","assets/vendor-zustand-B4dlGEM2.js","assets/persistence-DdUac14I.js","assets/vendor-pathe-vBmvQE8f.js","assets/vendor-tanstack-query-core-BWz0ose6.js","assets/platform-B4KKLnZ5.js","assets/vendor-opencode-sdk-C07HQypa.js","assets/vendor-tanstack-react-query-D7pys_c9.js","assets/vendor-sonner-DVzioF3d.js","assets/clipboard-DJ4nQEuY.js","assets/store-BDFdnk6l.js","assets/useAssistantUIStore-me6s63b2.js","assets/vendor-fuse.js-DB5wDJbI.js","assets/utils-BiK3tBGM.js","assets/vendor-class-variance-authority-BLUaYARZ.js","assets/vendor-tailwind-merge-BicNWLTg.js","assets/useThemeSystem-DMzWVJaW.js","assets/useRuntimeAPIs-DSLXJybW.js","assets/url-Pk6tQ1EZ.js","assets/useEffectiveDirectory-D36udiNq.js","assets/markdownHeightCache-CYDlisxr.js","assets/MarkdownRendererImpl-uH_cfmYV.js","assets/vendor-katex--B5dHUyk.js","assets/markdownTheme-DyJ4Gfcj.js","assets/vendor-pierre-diffs-DX7XTPG3.js","assets/vendor-hast-util-to-html-B0p1k5Xa.js","assets/vendor-character-entities-legacy-Wut6vMuj.js","assets/vendor-character-entities-html4-DlN_yAFd.js","assets/vendor-ccount-CyGsA0u7.js","assets/vendor-comma-separated-tokens-B9ouxNNS.js","assets/vendor-oniguruma-to-es-CrHzjjSU.js","assets/vendor-oniguruma-parser-D2DIvhSP.js","assets/vendor-diff-Dq7CZNim.js","assets/vendor-lru_map-DpDied8H.js","assets/markdown-worker-CTsrtpPh.js","assets/FadeInOnReveal-CTlNE4Hg.js","assets/vendor-morphdom-BbKiNDAu.js","assets/vendor-beautiful-mermaid-DykqIK5D.js","assets/vendor-dompurify-CeJNOzik.js","assets/vendor-marked-WVekxjAN.js","assets/vendor-remend-C3cXc-tf.js","assets/MarkstreamRendererImpl-DKhVy3nF.css"])))=>i.map(i=>d[i]);
2
- import{o as e}from"./rolldown-runtime-C0FnF6B9.js";import{a as t}from"./vendor-zustand-B4dlGEM2.js";import{nt as n,rt as r}from"./vendor-base-ui-CnHfUmDN.js";import{o as i}from"./vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js";import{n as a}from"./vendor-react-DLPElpoQ.js";import{t as o}from"./utils-BiK3tBGM.js";import{a as s,n as c,t as l}from"./markdownHeightCache-CYDlisxr.js";var u=a(),d=e(r(),1),f=`openchamber:chunk-import-reload`,p=250,m=3e4,h=[/Importing a module script failed/i,/Failed to fetch dynamically imported module/i,/error loading dynamically imported module/i,/Loading chunk \S+ failed/i,/ChunkLoadError/i];function g(e){if(e instanceof Error)return`${e.name}\n${e.message}\n${e.stack??``}`;if(typeof e==`string`)return e;try{return JSON.stringify(e)}catch{return String(e)}}function _(e){let t=g(e);return h.some(e=>e.test(t))}function v(e){return`1.19.10-beta.1:${g(e).slice(0,500)}`}function y(e){if(typeof window>`u`)return;let t=Date.now(),n=v(e),r=null;try{let e=window.sessionStorage.getItem(f);if(e)try{r=JSON.parse(e)}catch{r=null}}catch{return}let i=typeof r?.timestamp==`number`?r.timestamp:0;if(!(r?.signature===n&&t-i<m)){try{window.sessionStorage.setItem(f,JSON.stringify({signature:n,timestamp:t}))}catch{return}console.error(`[refresh-debug] chunk-import-reload`,{error:g(e).slice(0,1e3)}),window.setTimeout(()=>{window.location.reload()},0)}}async function b(e,t={}){let n=t.retries??1,r;for(let t=0;t<=n;t+=1)try{return await e()}catch(e){if(r=e,!_(e)||t>=n)break;await new Promise(e=>setTimeout(e,p*(t+1)))}throw _(r)&&y(r),r}var x=e=>(0,d.lazy)(()=>b(e)),S=(e,t)=>{try{let n=t??(typeof localStorage>`u`?null:localStorage);return n?n.getItem(e)===`1`:!1}catch{return!1}},C=(e,t)=>{try{let n=t??(typeof localStorage>`u`?null:localStorage);return!n||n.getItem(e)!==`0`}catch{return!0}},w=(e,t)=>{try{typeof localStorage<`u`&&localStorage.setItem(e,t?`1`:`0`)}catch{}},T=`oc:legend-timeline`,E=e=>S(T,e),D=`oc:markstream-react`,O=e=>C(D,e),k=t(e=>({legendTimelineEnabled:E(),setLegendTimelineEnabled:t=>{w(T,t),e({legendTimelineEnabled:t})},markstreamReactEnabled:O(),setMarkstreamReactEnabled:t=>{w(D,t),e({markstreamReactEnabled:t})}})),A=null,j=()=>(A??=i(()=>import(`./MarkstreamRendererImpl-BAEStQuf.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54])).catch(e=>{throw A=null,e}),A),M=()=>{j().catch(()=>void 0)},N=null,P=()=>(N??=i(()=>import(`./MarkdownRendererImpl-uH_cfmYV.js`).then(e=>e.n),__vite__mapDeps([34,1,13,14,2,15,16,3,17,18,19,5,35,9,10,11,22,23,12,20,21,24,25,26,27,28,29,36,37,38,39,40,41,42,43,44,45,46,30,31,47,33,32,48,49,50,51,52,53])).catch(e=>{throw N=null,e}),N),F=()=>{P().catch(()=>void 0),O()&&M()},I=class extends d.Component{state={hasError:!1};static getDerivedStateFromError(){return{hasError:!0}}componentDidCatch(e){console.warn(`[markstream-react] falling back to the current Markdown renderer`,e)}render(){return this.state.hasError?this.props.fallback:this.props.children}},L=d.createContext(!0),R=()=>d.useContext(L),z=n(),B=x(()=>P().then(e=>({default:e.MarkdownRenderer}))),V=x(()=>j().then(e=>({default:e.MarkstreamRenderer}))),H=x(()=>P().then(e=>({default:e.SimpleMarkdownRenderer}))),U=e=>e===`tool`?`markdown-content markdown-tool`:e===`reasoning`?`markdown-content markdown-reasoning`:`markdown-content leading-relaxed`,W=e=>{let t=(0,u.c)(15),n=k(q),r=typeof e.content==`string`?e.content:``,i=typeof e.variant==`string`?e.variant:`assistant`,a;t[0]!==r||t[1]!==n||t[2]!==e.reserveMeasuredHeight||t[3]!==i?(a=e.reserveMeasuredHeight?c(l(r,n?`markstream:${i}`:i)):void 0,t[0]=r,t[1]=n,t[2]=e.reserveMeasuredHeight,t[3]=i,t[4]=a):a=t[4];let d=a,f;t[5]!==e.className||t[6]!==e.variant?(f=o(`relative break-words w-full min-w-0`,U(e.variant),typeof e.className==`string`?e.className:void 0),t[5]=e.className,t[6]=e.variant,t[7]=f):f=t[7];let p;t[8]!==r||t[9]!==e.animated||t[10]!==d?(p=(0,z.jsx)(s,{animated:e.animated,content:r,reservedHeight:d}),t[8]=r,t[9]=e.animated,t[10]=d,t[11]=p):p=t[11];let m;return t[12]!==f||t[13]!==p?(m=(0,z.jsx)(`div`,{className:f,"aria-busy":`true`,"data-markdown-hydration":`deferred`,"data-markdown-ready":`false`,children:p}),t[12]=f,t[13]=p,t[14]=m):m=t[14],m},G=e=>{let t=(0,u.c)(9),n=R(),r=k(J);if(!n&&e.isStreaming!==!0){let n;return t[0]===e?n=t[1]:(n=(0,z.jsx)(W,{...e,animated:!1,reserveMeasuredHeight:!0}),t[0]=e,t[1]=n),n}let i;t[2]===e?i=t[3]:(i=(0,z.jsx)(d.Suspense,{fallback:(0,z.jsx)(W,{...e,reserveMeasuredHeight:!0}),children:(0,z.jsx)(B,{...e})}),t[2]=e,t[3]=i);let a=i;if(!r)return a;let o;t[4]===e?o=t[5]:(o=(0,z.jsx)(d.Suspense,{fallback:(0,z.jsx)(W,{...e,reserveMeasuredHeight:!0}),children:(0,z.jsx)(V,{...e})}),t[4]=e,t[5]=o);let s;return t[6]!==a||t[7]!==o?(s=(0,z.jsx)(I,{fallback:a,children:o}),t[6]=a,t[7]=o,t[8]=s):s=t[8],s},K=e=>{let t=(0,u.c)(4);if(!R()){let n;return t[0]===e?n=t[1]:(n=(0,z.jsx)(W,{...e,animated:!1}),t[0]=e,t[1]=n),n}let n;return t[2]===e?n=t[3]:(n=(0,z.jsx)(d.Suspense,{fallback:(0,z.jsx)(W,{...e}),children:(0,z.jsx)(H,{...e})}),t[2]=e,t[3]=n),n};function q(e){return e.markstreamReactEnabled}function J(e){return e.markstreamReactEnabled}export{k as a,F as i,K as n,x as o,L as r,G as t};