@openchambery/web 1.19.14 → 1.19.15-beta.1

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 (58) hide show
  1. package/dist/assets/{AssistantView-Dncbu_Z2.js → AssistantView-D5rEZ-Ss.js} +2 -2
  2. package/dist/assets/{DiagramView-CovEl1BY.js → DiagramView-DaB9Sb7S.js} +1 -1
  3. package/dist/assets/MarkdownRenderer-emLeoNjI.js +2 -0
  4. package/dist/assets/{MarkdownRendererImpl-CCfHY-86.js → MarkdownRendererImpl-BWCAPlqn.js} +1 -1
  5. package/dist/assets/{MarkstreamRendererImpl-y6QgIUZA.js → MarkstreamRendererImpl-BD_NFKUv.js} +1 -1
  6. package/dist/assets/MobileDetailNavigation-DqXVAIap.js +1 -0
  7. package/dist/assets/{MobileShareBridge-B4S7BaUj.js → MobileShareBridge-Bk2fdkJk.js} +1 -1
  8. package/dist/assets/MobileSurface-C4mpcOCh.js +2 -0
  9. package/dist/assets/{MultiRunWindow-BeHa-CG6.js → MultiRunWindow-rs49_K1E.js} +1 -1
  10. package/dist/assets/{SettingsView-Dn7odtQz.js → SettingsView-b8LUjoad.js} +2 -2
  11. package/dist/assets/{SettingsWindow-C0tWPS5f.js → SettingsWindow-D8JFlPdA.js} +1 -1
  12. package/dist/assets/{TerminalView-BnJgC7Qo.js → TerminalView-DCd-Godi.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-D9S-X8hM.js → ToolOutputDialog-B50L_nUP.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-DBrvaJnq.js → appThemeRegistry-BWtCaTMe.js} +1 -1
  15. package/dist/assets/{fileMentionTouchSelection-Dc004xOh.js → fileMentionTouchSelection-DKYkoypI.js} +1 -1
  16. package/dist/assets/{gitApi-Cm3JLQqh.js → gitApi-D4PyYgLZ.js} +1 -1
  17. package/dist/assets/{insertionBoundaries-CTWYFsbH.js → insertionBoundaries-Bnnx03Wj.js} +1 -1
  18. package/dist/assets/{main-CSqu3MB4.js → main-BRiQTNFX.js} +4 -4
  19. package/dist/assets/{main-DwMprEyr.js → main-CnY0LWNc.js} +2 -2
  20. package/dist/assets/{miniChat-C8bzY_Ye.js → miniChat-B0pw_mUs.js} +2 -2
  21. package/dist/assets/{mobile-Oy-SRKkr.js → mobile-BKHmxwXz.js} +2 -2
  22. package/dist/assets/{multirun-ClcuaiKU.js → multirun-BQKqVfFT.js} +1 -1
  23. package/dist/assets/{projectMeta-F93wE2Ye.js → projectMeta-BvG8sdtC.js} +1 -1
  24. package/dist/assets/{renderElectronMiniChatApp-rOOhMVlQ.js → renderElectronMiniChatApp-E5a0yy7E.js} +2 -2
  25. package/dist/assets/{renderMobileApp-BP_hGwHP.js → renderMobileApp-DT0L4Ah6.js} +4 -4
  26. package/dist/assets/{runtimeConfig-iJ2mTBVN.js → runtimeConfig-utWzrDwa.js} +1 -1
  27. package/dist/assets/{runtimeEndpointReset-ISnhAS3q.js → runtimeEndpointReset-DE6lwHyg.js} +1 -1
  28. package/dist/assets/sessionChangeSummary-BG7l2heP.js +2 -0
  29. package/dist/assets/{sessionLookup-DQQ9-DuP.js → sessionLookup-12zVOR0J.js} +1 -1
  30. package/dist/assets/src-CFUi4riW.css +2 -0
  31. package/dist/assets/{useAppFontEffects--IcU7jyv.js → useAppFontEffects-BhYuP4L2.js} +5 -5
  32. package/dist/assets/{useDesktopWindowControlsLayout-BqJ5R_T2.js → useDesktopWindowControlsLayout-DPNMRkrI.js} +1 -1
  33. package/dist/assets/{useEffectiveDirectory-ChWXV8xM.js → useEffectiveDirectory-Ji8AKgzY.js} +1 -1
  34. package/dist/assets/{useMobileNavigationStore-DQkgxuzg.js → useMobileNavigationStore-BYdpuwL7.js} +1 -1
  35. package/dist/assets/{useSessionAutoCleanup-DWWAvQQ0.js → useSessionAutoCleanup-CADZCvZL.js} +1 -1
  36. package/dist/assets/{useWorkerHighlightedLines-D2oQszsk.js → useWorkerHighlightedLines-CuG88Lsj.js} +1 -1
  37. package/dist/index.html +3 -3
  38. package/dist/mini-chat.html +3 -3
  39. package/dist/mobile.html +3 -3
  40. package/package.json +1 -1
  41. package/server/lib/assistants/DOCUMENTATION.md +33 -12
  42. package/server/lib/assistants/contact-store.js +25 -1
  43. package/server/lib/assistants/contact-store.test.js +39 -5
  44. package/server/lib/assistants/contact-tools.js +3 -3
  45. package/server/lib/assistants/harness.js +136 -101
  46. package/server/lib/assistants/harness.test.js +235 -8
  47. package/server/lib/assistants/pi-tools.js +2 -0
  48. package/server/lib/assistants/pi-tools.test.js +8 -0
  49. package/server/lib/assistants/service.js +313 -74
  50. package/server/lib/assistants/service.test.js +555 -18
  51. package/server/lib/llm/DOCUMENTATION.md +9 -4
  52. package/server/lib/llm/generate.js +45 -6
  53. package/server/lib/llm/generate.test.js +52 -0
  54. package/dist/assets/MarkdownRenderer-CZqok-Cf.js +0 -2
  55. package/dist/assets/MobileDetailNavigation-Ca-d5eZ9.js +0 -1
  56. package/dist/assets/MobileSurface-xL5Jby4U.js +0 -2
  57. package/dist/assets/sessionChangeSummary-BAzU2J1H.js +0 -2
  58. package/dist/assets/src-DYeppLfP.css +0 -2
@@ -9,6 +9,7 @@ import {
9
9
  CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
10
10
  NEW_CONVERSATION_CONFIRM_BUBBLE,
11
11
  } from './contact-tools.js';
12
+ import { runContactTurn as realRunContactTurn } from './harness.js';
12
13
 
13
14
  const require = createRequire(import.meta.url);
14
15
  const root = () => fs.mkdtempSync(path.join(os.tmpdir(), 'assistants-'));
@@ -151,16 +152,21 @@ describe('assistants service', () => {
151
152
 
152
153
  it('only an explicit new watch can rearm an interrupted worker', async () => {
153
154
  const directory = root()
154
- const runContactTurn = vi.fn(async () => ({ text: 'done', bubbles: ['done'] }))
155
+ const runContactTurn = vi.fn(async ({ userText }) => ({
156
+ text: userText.includes('cancelled') ? 'interrupted' : 'done',
157
+ bubbles: [userText.includes('cancelled') ? 'interrupted' : 'done'],
158
+ }))
155
159
  const service = setup(directory, {}, { runContactTurn, clock: () => 77 })
156
160
  const assistant = service.createAssistant(assistantInput)
157
161
  const card = { cardType: 'session', sessionID: 'ses_rearm', directory, status: 'busy' }
158
162
  service.appendContactCard(assistant.id, card)
159
163
  service.processEvent({ type: 'session.error', properties: { sessionID: 'ses_rearm', error: { name: 'MessageAbortedError' } } })
164
+ await service.whenContactTurnSettled(assignedSessionResumeMessageID(assistant.id, 'ses_rearm', 'cancelled', 77))
165
+ expect(runContactTurn).toHaveBeenCalledTimes(1)
160
166
  service.appendContactCard(assistant.id, card)
161
167
  service.processEvent({ type: 'session.idle', properties: { sessionID: 'ses_rearm' } })
162
168
  await service.whenContactTurnSettled(assignedSessionResumeMessageID(assistant.id, 'ses_rearm', 'complete', 77))
163
- expect(runContactTurn).toHaveBeenCalledTimes(1)
169
+ expect(runContactTurn).toHaveBeenCalledTimes(2)
164
170
  service.close()
165
171
  })
166
172
 
@@ -197,57 +203,89 @@ describe('assistants service', () => {
197
203
  service.close()
198
204
  })
199
205
 
200
- it.each(['session.error', 'message.updated'])('user interruption via %s cancels the watch without an automatic continuation', async (type) => {
206
+ it.each(['session.error', 'message.updated'])('user interruption via %s resumes contact with a user_interrupted reason', async (type) => {
201
207
  const directory = root()
202
- const runContactTurn = vi.fn(async () => ({ text: 'must not resume', bubbles: ['must not resume'] }))
203
- const service = setup(directory, {}, { runContactTurn })
208
+ const resumes = []
209
+ const runContactTurn = vi.fn(async ({ userText }) => {
210
+ resumes.push(userText)
211
+ return { text: '你自己打断了,我就先停这儿。', bubbles: ['你自己打断了,我就先停这儿。'] }
212
+ })
213
+ const service = setup(directory, {
214
+ messages: async () => ({
215
+ data: [{
216
+ info: { id: 'msg_abort', role: 'assistant', error: { name: 'MessageAbortedError' } },
217
+ parts: [{ type: 'text', text: 'Halfway through login fix.' }],
218
+ }],
219
+ }),
220
+ }, { runContactTurn, clock: () => 88 })
204
221
  const assistant = service.createAssistant(assistantInput)
205
222
  service.appendContactCard(assistant.id, { cardType: 'session', sessionID: 'ses_cancel', directory, status: 'busy' })
206
223
  const error = { name: 'MessageAbortedError', data: { message: 'Aborted by user' } }
207
224
  service.processEvent({ type, properties: type === 'message.updated'
208
225
  ? { info: { id: 'msg_abort', role: 'assistant', sessionID: 'ses_cancel', error } }
209
226
  : { sessionID: 'ses_cancel', error } })
227
+ await service.whenContactTurnSettled(assignedSessionResumeMessageID(assistant.id, 'ses_cancel', 'cancelled', 88))
228
+ expect(runContactTurn).toHaveBeenCalledTimes(1)
229
+ expect(resumes[0]).toContain('status: cancelled')
230
+ expect(resumes[0]).toContain('reason: user_interrupted')
231
+ expect(resumes[0]).toContain('manually interrupted')
232
+ expect(resumes[0]).toContain('Halfway through login fix.')
233
+ expect(service.contactMessages(assistant.id).messages.some((message) => message.text === '你自己打断了,我就先停这儿。')).toBe(true)
210
234
  service.processEvent({ type: 'session.idle', properties: { sessionID: 'ses_cancel' } })
211
235
  service.processEvent({ type: 'session.status', properties: { sessionID: 'ses_cancel', status: { type: 'busy' } } })
212
236
  service.processEvent({ type: 'session.error', properties: { sessionID: 'ses_cancel', error: { name: 'UnknownError' } } })
213
237
  await new Promise(setImmediate)
214
- expect(runContactTurn).not.toHaveBeenCalled()
238
+ expect(runContactTurn).toHaveBeenCalledTimes(1)
215
239
  expect(service.contactMessages(assistant.id).messages.flatMap(m => m.parts).find(p => p.type === 'card').status).toBe('cancelled')
216
240
  expect(service.snapshot().assistants[0].assignedSessionIDs).toEqual([])
217
241
  service.close()
218
- const restarted = setup(directory, {}, { runContactTurn })
242
+ const restarted = setup(directory, {}, { runContactTurn, clock: () => 99 })
219
243
  restarted.processEvent({ type: 'session.idle', properties: { sessionID: 'ses_cancel' } })
220
244
  await new Promise(setImmediate)
221
- expect(runContactTurn).not.toHaveBeenCalled()
245
+ expect(runContactTurn).toHaveBeenCalledTimes(1)
222
246
  restarted.close()
223
247
  })
224
248
 
225
- it('reconciliation recognizes an interrupted worker instead of resuming its error', async () => {
249
+ it('reconciliation recognizes an interrupted worker and resumes cancelled instead of error', async () => {
226
250
  const directory = root()
227
- const runContactTurn = vi.fn(async () => ({ text: 'unexpected', bubbles: ['unexpected'] }))
251
+ const resumes = []
252
+ const runContactTurn = vi.fn(async ({ userText }) => {
253
+ resumes.push(userText)
254
+ return { text: 'interrupted', bubbles: ['interrupted'] }
255
+ })
228
256
  const service = setup(directory, {
229
257
  get: async () => ({ data: { status: { type: 'idle' } } }),
230
- messages: async () => ({ data: [{ info: { role: 'assistant', error: { name: 'MessageAbortedError' }, time: { completed: 1 } } }] }),
231
- }, { runContactTurn })
258
+ messages: async () => ({ data: [{ info: { role: 'assistant', error: { name: 'MessageAbortedError' }, time: { completed: 1 } }, parts: [{ type: 'text', text: 'partial worker' }] }] }),
259
+ }, { runContactTurn, clock: () => 90 })
232
260
  const assistant = service.createAssistant(assistantInput)
233
261
  service.appendContactCard(assistant.id, { cardType: 'session', sessionID: 'ses_cancel', directory, status: 'busy' })
234
262
  await service.reconcile()
235
- await new Promise(setImmediate)
236
- expect(runContactTurn).not.toHaveBeenCalled()
263
+ await service.whenContactTurnSettled(assignedSessionResumeMessageID(assistant.id, 'ses_cancel', 'cancelled', 90))
264
+ expect(runContactTurn).toHaveBeenCalledTimes(1)
265
+ expect(resumes[0]).toContain('status: cancelled')
266
+ expect(resumes[0]).toContain('reason: user_interrupted')
267
+ expect(resumes[0]).not.toContain('status: error')
237
268
  expect(service.contactMessages(assistant.id).messages.flatMap(m => m.parts).find(p => p.type === 'card').status).toBe('cancelled')
238
269
  service.close()
239
270
  })
240
271
 
241
- it('an interruption revokes a queued continuation even when complete arrived first', async () => {
272
+ it('an interruption revokes a queued complete continuation and resumes cancelled instead', async () => {
242
273
  const directory = root()
243
- const runContactTurn = vi.fn(async () => ({ text: 'unexpected', bubbles: ['unexpected'] }))
274
+ const resumes = []
275
+ const runContactTurn = vi.fn(async ({ userText }) => {
276
+ resumes.push(userText)
277
+ return { text: 'interrupted', bubbles: ['interrupted'] }
278
+ })
244
279
  const service = setup(directory, {}, { runContactTurn, clock: () => 42 })
245
280
  const assistant = service.createAssistant(assistantInput)
246
281
  service.appendContactCard(assistant.id, { cardType: 'session', sessionID: 'ses_race', directory, status: 'busy' })
247
282
  service.processEvent({ type: 'session.idle', properties: { sessionID: 'ses_race' } })
248
283
  service.processEvent({ type: 'session.error', properties: { sessionID: 'ses_race', error: { name: 'MessageAbortedError' } } })
249
284
  await service.whenContactTurnSettled(assignedSessionResumeMessageID(assistant.id, 'ses_race', 'complete', 42))
250
- expect(runContactTurn).not.toHaveBeenCalled()
285
+ await service.whenContactTurnSettled(assignedSessionResumeMessageID(assistant.id, 'ses_race', 'cancelled', 42))
286
+ expect(runContactTurn).toHaveBeenCalledTimes(1)
287
+ expect(resumes[0]).toContain('status: cancelled')
288
+ expect(resumes[0]).toContain('reason: user_interrupted')
251
289
  expect(service.snapshot().assistants[0].working).toBe(false)
252
290
  service.close()
253
291
  })
@@ -1770,7 +1808,9 @@ describe('assistants service', () => {
1770
1808
  const page = service.contactMessages(assistant.id);
1771
1809
  expect(page.messages.map((message) => message.role)).toEqual(['user', 'assistant', 'assistant']);
1772
1810
  expect(page.messages.some((message) => message.role === 'peer')).toBe(false);
1773
- expect(page.messages.at(-1).parts[0]).toMatchObject({
1811
+ expect(page.messages.some((message) => message.text === 'Opened the login session.')).toBe(true);
1812
+ const cardPart = page.messages.flatMap((message) => message.parts).find((part) => part.type === 'card');
1813
+ expect(cardPart).toMatchObject({
1774
1814
  type: 'card',
1775
1815
  cardType: 'session',
1776
1816
  sessionID: 'ses_1',
@@ -3723,6 +3763,51 @@ describe('assistants service', () => {
3723
3763
  service.close();
3724
3764
  });
3725
3765
 
3766
+ it('persists completed spoken bubbles before the contact turn settles', async () => {
3767
+ const directory = root();
3768
+ let release;
3769
+ const blocked = new Promise((resolve) => { release = resolve; });
3770
+ const service = setup(directory, {}, {
3771
+ runContactTurn: async ({ onBubbleDelta }) => {
3772
+ onBubbleDelta(0, '我去找一下', true);
3773
+ await blocked;
3774
+ onBubbleDelta(1, '办好了', true);
3775
+ return { bubbles: ['我去找一下', '办好了'] };
3776
+ },
3777
+ });
3778
+ const assistant = service.createAssistant(assistantInput);
3779
+ await service.send(assistant.id, {
3780
+ messageID: 'mid_turn_1',
3781
+ parts: [{ type: 'text', text: 'hi' }],
3782
+ });
3783
+ let page = { messages: [] };
3784
+ for (let attempt = 0; attempt < 40; attempt += 1) {
3785
+ page = service.contactMessages(assistant.id);
3786
+ if (page.messages.some((message) => message.messageID === 'mid_turn_1:bubble:1')) break;
3787
+ await new Promise((resolve) => setTimeout(resolve, 10));
3788
+ }
3789
+ expect(page.messages.map((message) => ({ id: message.messageID, text: message.text }))).toEqual([
3790
+ { id: 'mid_turn_1', text: 'hi' },
3791
+ { id: 'mid_turn_1:bubble:1', text: '我去找一下' },
3792
+ ]);
3793
+ expect(service.snapshot().assistants[0]).toMatchObject({
3794
+ working: true,
3795
+ latestMessagePreview: expect.objectContaining({ text: '我去找一下' }),
3796
+ });
3797
+ release();
3798
+ await service.whenContactTurnSettled('mid_turn_1');
3799
+ page = service.contactMessages(assistant.id);
3800
+ expect(page.messages.map((message) => message.messageID)).toEqual([
3801
+ 'mid_turn_1',
3802
+ 'mid_turn_1:bubble:1',
3803
+ 'mid_turn_1:bubble:2',
3804
+ ]);
3805
+ const db = new (require('better-sqlite3'))(path.join(directory, 'assistants.sqlite'));
3806
+ expect(db.prepare('SELECT COUNT(*) AS count FROM assistant_contact_message WHERE message_id=?').get('mid_turn_1:bubble:1').count).toBe(1);
3807
+ db.close();
3808
+ service.close();
3809
+ });
3810
+
3726
3811
  it('replays same messageID+payload admission once and conflicts on payload mismatch', async () => {
3727
3812
  const directory = root();
3728
3813
  let release;
@@ -4197,4 +4282,456 @@ describe('read_session referenced conversation', () => {
4197
4282
  if (scenario === 'invalid-limit') expect(read).not.toHaveBeenCalled();
4198
4283
  service.close();
4199
4284
  });
4285
+
4286
+ it('real harness two read_session preambles: SSE done bubbles match SQLite 1:1 including duplicates', async () => {
4287
+ const directory = root();
4288
+ const events = [];
4289
+ const fence = (name, args) => '```openchamber-tool\n' + JSON.stringify({ name, arguments: args }) + '\n```';
4290
+ const replies = [
4291
+ `我去读一下\n\n${fence('read_session', { sessionID: 'ses_a' })}`,
4292
+ `我再读一下\n\n${fence('read_session', { sessionID: 'ses_b' })}`,
4293
+ '```openchamber-final\n{"status":"complete","text":"办好了"}\n```',
4294
+ ];
4295
+ const createChatCompletion = vi.fn(async () => ({
4296
+ completion: { choices: [{ message: { content: replies.shift() || '' } }] },
4297
+ }));
4298
+ const service = setup(directory, {
4299
+ get: async ({ sessionID }) => ({
4300
+ data: { id: sessionID, directory, title: sessionID },
4301
+ }),
4302
+ messages: async ({ sessionID }) => ({
4303
+ data: [{
4304
+ info: { id: `msg_${sessionID}`, sessionID, role: 'user' },
4305
+ parts: [{ type: 'text', text: `body:${sessionID}` }],
4306
+ }],
4307
+ }),
4308
+ }, {
4309
+ onContactTurnEvent: (event) => events.push(event),
4310
+ // Real Agent + createContactTools; only completion gateway is stubbed.
4311
+ runContactTurn: realRunContactTurn,
4312
+ createChatCompletion,
4313
+ });
4314
+ const assistant = service.createAssistant(assistantInput);
4315
+ await settleSend(service, assistant.id, {
4316
+ messageID: 'two_read_1',
4317
+ parts: [{ type: 'text', text: '读两个会话' }],
4318
+ });
4319
+ const doneDeltas = events
4320
+ .filter((event) => event.type === 'openchamber:contact-bubble-delta' && event.properties?.done)
4321
+ .map((event) => ({
4322
+ index: event.properties.bubbleIndex,
4323
+ text: event.properties.delta,
4324
+ }));
4325
+ expect(doneDeltas).toEqual([
4326
+ { index: 0, text: '我去读一下' },
4327
+ { index: 1, text: '我再读一下' },
4328
+ { index: 2, text: '办好了' },
4329
+ ]);
4330
+ const page = service.contactMessages(assistant.id);
4331
+ const assistantBubbles = page.messages
4332
+ .filter((message) => message.role === 'assistant' && message.messageID.startsWith('two_read_1:bubble:'))
4333
+ .map((message) => ({ id: message.messageID, text: message.text }));
4334
+ expect(assistantBubbles).toEqual([
4335
+ { id: 'two_read_1:bubble:1', text: '我去读一下' },
4336
+ { id: 'two_read_1:bubble:2', text: '我再读一下' },
4337
+ { id: 'two_read_1:bubble:3', text: '办好了' },
4338
+ ]);
4339
+ // SSE final array ↔ SQLite 1:1
4340
+ expect(assistantBubbles.map((row) => row.text)).toEqual(doneDeltas.map((row) => row.text));
4341
+ service.close();
4342
+ });
4343
+
4344
+ it('real harness duplicate preambles keep both lines in SSE and SQLite', async () => {
4345
+ const directory = root();
4346
+ const events = [];
4347
+ const fence = (name, args) => '```openchamber-tool\n' + JSON.stringify({ name, arguments: args }) + '\n```';
4348
+ const replies = [
4349
+ `我去读一下\n\n${fence('read_session', { sessionID: 'ses_dup_a' })}`,
4350
+ `我去读一下\n\n${fence('read_session', { sessionID: 'ses_dup_b' })}`,
4351
+ '```openchamber-final\n{"status":"complete","text":"办好了"}\n```',
4352
+ ];
4353
+ const service = setup(directory, {
4354
+ get: async ({ sessionID }) => ({ data: { id: sessionID, directory, title: sessionID } }),
4355
+ messages: async ({ sessionID }) => ({
4356
+ data: [{ info: { id: 'm', sessionID, role: 'assistant' }, parts: [{ type: 'text', text: 'x' }] }],
4357
+ }),
4358
+ }, {
4359
+ onContactTurnEvent: (event) => events.push(event),
4360
+ runContactTurn: realRunContactTurn,
4361
+ createChatCompletion: vi.fn(async () => ({
4362
+ completion: { choices: [{ message: { content: replies.shift() || '' } }] },
4363
+ })),
4364
+ });
4365
+ const assistant = service.createAssistant(assistantInput);
4366
+ await settleSend(service, assistant.id, {
4367
+ messageID: 'dup_preamble_1',
4368
+ parts: [{ type: 'text', text: '再读一遍' }],
4369
+ });
4370
+ const texts = events
4371
+ .filter((event) => event.type === 'openchamber:contact-bubble-delta' && event.properties?.done)
4372
+ .map((event) => event.properties.delta);
4373
+ expect(texts).toEqual(['我去读一下', '我去读一下', '办好了']);
4374
+ const sqlite = service.contactMessages(assistant.id).messages
4375
+ .filter((message) => message.messageID.startsWith('dup_preamble_1:bubble:'))
4376
+ .map((message) => message.text);
4377
+ expect(sqlite).toEqual(texts);
4378
+ service.close();
4379
+ });
4380
+
4381
+ it('real harness missed-tool retry keeps pre-retry spoken in SSE and SQLite', async () => {
4382
+ const directory = root();
4383
+ const events = [];
4384
+ const fence = (name, args) => '```openchamber-tool\n' + JSON.stringify({ name, arguments: args }) + '\n```';
4385
+ const replies = [
4386
+ '```openchamber-final\n{"status":"complete","text":"好的,我来创建。"}\n```',
4387
+ `这就建好\n\n${fence('create_assistant', { name: 'MissSvc', model: 'p/m' })}`,
4388
+ ];
4389
+ const service = setup(directory, {}, {
4390
+ onContactTurnEvent: (event) => events.push(event),
4391
+ runContactTurn: realRunContactTurn,
4392
+ createChatCompletion: vi.fn(async () => ({
4393
+ completion: { choices: [{ message: { content: replies.shift() || '' } }] },
4394
+ })),
4395
+ });
4396
+ const assistant = service.createAssistant(assistantInput);
4397
+ await settleSend(service, assistant.id, {
4398
+ messageID: 'miss_retry_1',
4399
+ parts: [{ type: 'text', text: '帮我新建一个助理,名叫 MissSvc,不要开编码 session' }],
4400
+ });
4401
+ const texts = events
4402
+ .filter((event) => event.type === 'openchamber:contact-bubble-delta' && event.properties?.done)
4403
+ .map((event) => event.properties.delta);
4404
+ expect(texts).toContain('好的,我来创建。');
4405
+ const sqlite = service.contactMessages(assistant.id).messages
4406
+ .filter((message) => message.messageID.startsWith('miss_retry_1:bubble:'))
4407
+ .map((message) => message.text);
4408
+ expect(sqlite).toEqual(texts);
4409
+ expect(sqlite.join('')).not.toContain('Created assistant');
4410
+ service.close();
4411
+ });
4412
+
4413
+ it('new_conversation preamble is replaced by unique reset confirm; history and generation kept', async () => {
4414
+ const directory = root();
4415
+ let releasePreamble;
4416
+ const preambleGate = new Promise((resolve) => { releasePreamble = resolve; });
4417
+ const service = setup(directory, {}, {
4418
+ runContactTurn: async ({ userText, tools, onBubbleDelta }) => {
4419
+ if (userText !== '开新对话') return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
4420
+ onBubbleDelta(0, '我先清一下记忆', true);
4421
+ await preambleGate;
4422
+ await tools.find((item) => item.name === 'new_conversation').execute('nc_preamble', {});
4423
+ onBubbleDelta(0, '迟到气泡应被屏蔽', true);
4424
+ return {
4425
+ text: NEW_CONVERSATION_CONFIRM_BUBBLE,
4426
+ bubbles: [NEW_CONVERSATION_CONFIRM_BUBBLE, 'leftover should drop'],
4427
+ reset: true,
4428
+ };
4429
+ },
4430
+ });
4431
+ const assistant = service.createAssistant(assistantInput);
4432
+ const genBefore = service.contactMessages(assistant.id).generation;
4433
+ await settleSend(service, assistant.id, {
4434
+ messageID: 'hist_keep',
4435
+ parts: [{ type: 'text', text: 'remember me' }],
4436
+ });
4437
+ const sent = await service.send(assistant.id, {
4438
+ messageID: 'reset_preamble',
4439
+ parts: [{ type: 'text', text: '开新对话' }],
4440
+ });
4441
+ let mid = { messages: [] };
4442
+ for (let attempt = 0; attempt < 40; attempt += 1) {
4443
+ mid = service.contactMessages(assistant.id);
4444
+ if (mid.messages.some((message) => message.messageID === 'reset_preamble:bubble:1')) break;
4445
+ await new Promise((resolve) => setTimeout(resolve, 10));
4446
+ }
4447
+ expect(mid.messages.some((message) => message.text === '我先清一下记忆')).toBe(true);
4448
+ releasePreamble();
4449
+ await service.whenContactTurnSettled(sent.messageID);
4450
+ const page = service.contactMessages(assistant.id, { limit: 50 });
4451
+ expect(page.generation).toBe(genBefore);
4452
+ expect(page.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([
4453
+ { role: 'user', text: 'remember me' },
4454
+ { role: 'assistant', text: 'reply:remember me' },
4455
+ { role: 'user', text: '开新对话' },
4456
+ { role: 'assistant', text: NEW_CONVERSATION_CONFIRM_BUBBLE },
4457
+ ]);
4458
+ expect(page.messages.filter((message) => message.messageID.startsWith('reset_preamble:bubble:'))).toHaveLength(1);
4459
+ expect(page.messages.some((message) => message.text.includes('迟到') || message.text.includes('leftover'))).toBe(false);
4460
+ service.close();
4461
+ });
4462
+
4463
+ it('stop_session defers SSE complete during abort and cancels without resume on success', async () => {
4464
+ const directory = root();
4465
+ const project = path.join(directory, 'app');
4466
+ fs.mkdirSync(project, { recursive: true });
4467
+ let releaseAbort;
4468
+ const abortGate = new Promise((resolve) => { releaseAbort = resolve; });
4469
+ const resumes = [];
4470
+ let abortCalls = 0;
4471
+ const service = setup(directory, {
4472
+ get: async (input) => ({
4473
+ data: { id: input.sessionID, directory: project, title: 'Running', status: { type: 'busy' } },
4474
+ }),
4475
+ abort: async () => {
4476
+ abortCalls += 1;
4477
+ await abortGate;
4478
+ return { data: true };
4479
+ },
4480
+ }, {
4481
+ runContactTurn: async ({ tools, userText }) => {
4482
+ if (typeof userText === 'string' && userText.includes('Internal assigned-session resume')) {
4483
+ resumes.push(userText);
4484
+ return { text: '续答', bubbles: ['续答'] };
4485
+ }
4486
+ if (userText === 'watch') {
4487
+ const watch = tools.find((tool) => tool.name === 'watch_session');
4488
+ const result = await watch.execute('call_w', { sessionID: 'ses_abort_race' });
4489
+ return { text: '盯着', bubbles: ['盯着'], cards: result.details.card ? [result.details.card] : [] };
4490
+ }
4491
+ if (userText === 'peer watch') {
4492
+ const watch = tools.find((tool) => tool.name === 'watch_session');
4493
+ const result = await watch.execute('call_peer_w', { sessionID: 'ses_abort_race' });
4494
+ return { text: 'peer盯', bubbles: ['peer盯'], cards: result.details.card ? [result.details.card] : [] };
4495
+ }
4496
+ const stop = tools.find((tool) => tool.name === 'stop_session');
4497
+ const stopPromise = stop.execute('call_s', { sessionID: 'ses_abort_race' });
4498
+ await new Promise((resolve) => setTimeout(resolve, 5));
4499
+ // SSE complete arrives while abort is in flight — must not schedule resume.
4500
+ expect(service.reportAssignedSessionSettle('ses_abort_race', 'complete')).toBe(false);
4501
+ releaseAbort();
4502
+ const result = await stopPromise;
4503
+ expect(result.details.error).toBeUndefined();
4504
+ expect(result.details.stopped?.aborted).toBe(true);
4505
+ return { text: '已停下', bubbles: ['已停下'] };
4506
+ },
4507
+ });
4508
+ const assistant = service.createAssistant(assistantInput);
4509
+ // Second assistant keeps resume_allowed=true while the stopper's send clears only its own.
4510
+ const peer = service.createAssistant({ name: 'PeerWatch', providerID: 'p', modelID: 'm' });
4511
+ await settleSend(service, assistant.id, {
4512
+ messageID: 'watch_abort_race',
4513
+ parts: [{ type: 'text', text: 'watch' }],
4514
+ });
4515
+ await settleSend(service, peer.id, {
4516
+ messageID: 'peer_watch_abort_race',
4517
+ parts: [{ type: 'text', text: 'peer watch' }],
4518
+ });
4519
+ expect(service.snapshot().assistants.find((row) => row.id === assistant.id).assignedSessionIDs).toEqual(['ses_abort_race']);
4520
+ expect(service.snapshot().assistants.find((row) => row.id === peer.id).assignedSessionIDs).toEqual(['ses_abort_race']);
4521
+ await settleSend(service, assistant.id, {
4522
+ messageID: 'stop_abort_race',
4523
+ parts: [{ type: 'text', text: '停止会话' }],
4524
+ });
4525
+ expect(abortCalls).toBe(1);
4526
+ // Strong assert: peer still had resumeAllowed=true, yet success path resume=false suppresses it.
4527
+ expect(resumes).toEqual([]);
4528
+ const page = service.contactMessages(assistant.id);
4529
+ const card = page.messages.flatMap((message) => message.parts || []).find((part) => part?.cardType === 'session');
4530
+ expect(card?.status).toBe('cancelled');
4531
+ const peerCard = service.contactMessages(peer.id).messages
4532
+ .flatMap((message) => message.parts || [])
4533
+ .find((part) => part?.sessionID === 'ses_abort_race');
4534
+ expect(peerCard?.status).toBe('cancelled');
4535
+ // Late complete after cancelled stays idempotent.
4536
+ expect(service.reportAssignedSessionSettle('ses_abort_race', 'complete')).toBe(false);
4537
+ service.close();
4538
+ });
4539
+
4540
+ it('stop_session abort failure replays observed terminal to resumeAllowed peer; independent worker unaffected', async () => {
4541
+ const directory = root();
4542
+ const project = path.join(directory, 'app');
4543
+ fs.mkdirSync(project, { recursive: true });
4544
+ let releaseAbort;
4545
+ const abortGate = new Promise((resolve) => { releaseAbort = resolve; });
4546
+ const resumes = [];
4547
+ const service = setup(directory, {
4548
+ get: async (input) => ({
4549
+ data: { id: input.sessionID, directory: project, title: input.sessionID, status: { type: 'busy' } },
4550
+ }),
4551
+ abort: async (input) => {
4552
+ if (input.sessionID === 'ses_fail_abort') {
4553
+ await abortGate;
4554
+ return { error: { message: 'abort refused' } };
4555
+ }
4556
+ return { data: true };
4557
+ },
4558
+ }, {
4559
+ runContactTurn: async ({ tools, userText }) => {
4560
+ if (typeof userText === 'string' && userText.includes('Internal assigned-session resume')) {
4561
+ resumes.push(userText);
4562
+ return { text: '续', bubbles: ['续'] };
4563
+ }
4564
+ if (userText === 'watch both') {
4565
+ const watch = tools.find((tool) => tool.name === 'watch_session');
4566
+ await watch.execute('w1', { sessionID: 'ses_fail_abort' });
4567
+ await watch.execute('w2', { sessionID: 'ses_other_worker' });
4568
+ return { text: '双盯', bubbles: ['双盯'], cards: [] };
4569
+ }
4570
+ if (userText === 'peer watch fail') {
4571
+ const watch = tools.find((tool) => tool.name === 'watch_session');
4572
+ await watch.execute('peer_w', { sessionID: 'ses_fail_abort' });
4573
+ return { text: 'peer盯', bubbles: ['peer盯'], cards: [] };
4574
+ }
4575
+ const stop = tools.find((tool) => tool.name === 'stop_session');
4576
+ const stopPromise = stop.execute('s1', { sessionID: 'ses_fail_abort' });
4577
+ await new Promise((resolve) => setTimeout(resolve, 5));
4578
+ // SSE complete while abort in-flight is deferred.
4579
+ expect(service.reportAssignedSessionSettle('ses_fail_abort', 'complete')).toBe(false);
4580
+ releaseAbort();
4581
+ const result = await stopPromise;
4582
+ expect(result.details.error).toBe('upstream_error');
4583
+ expect(result.content[0].text).toContain('abort refused');
4584
+ return { text: result.content[0].text, bubbles: [result.content[0].text] };
4585
+ },
4586
+ });
4587
+ const assistant = service.createAssistant(assistantInput);
4588
+ const peer = service.createAssistant({ name: 'PeerFail', providerID: 'p', modelID: 'm' });
4589
+ await settleSend(service, assistant.id, {
4590
+ messageID: 'watch_fail_abort',
4591
+ parts: [{ type: 'text', text: 'watch both' }],
4592
+ });
4593
+ await settleSend(service, peer.id, {
4594
+ messageID: 'peer_watch_fail_abort',
4595
+ parts: [{ type: 'text', text: 'peer watch fail' }],
4596
+ });
4597
+ expect(service.snapshot().assistants.find((row) => row.id === assistant.id).assignedSessionIDs.sort()).toEqual([
4598
+ 'ses_fail_abort',
4599
+ 'ses_other_worker',
4600
+ ].sort());
4601
+ await settleSend(service, assistant.id, {
4602
+ messageID: 'stop_fail_abort',
4603
+ parts: [{ type: 'text', text: '停止失败会话' }],
4604
+ });
4605
+ // Stopper send cleared its own resume_allowed; peer still resumeAllowed=true so
4606
+ // failure replay of stashed complete must schedule peer continuation.
4607
+ await new Promise((resolve) => setTimeout(resolve, 50));
4608
+ expect(resumes.some((text) => text.includes('ses_fail_abort'))).toBe(true);
4609
+ expect(service.snapshot().assistants.find((row) => row.id === assistant.id).assignedSessionIDs).toEqual(['ses_other_worker']);
4610
+ const page = service.contactMessages(assistant.id, { limit: 50 });
4611
+ const cards = page.messages.flatMap((message) => message.parts || []).filter((part) => part?.cardType === 'session');
4612
+ const failedCard = cards.find((part) => part.sessionID === 'ses_fail_abort');
4613
+ const otherCard = cards.find((part) => part.sessionID === 'ses_other_worker');
4614
+ expect(failedCard?.status).toBe('complete');
4615
+ expect(otherCard?.status).toBe('busy');
4616
+ const peerCard = service.contactMessages(peer.id).messages
4617
+ .flatMap((message) => message.parts || [])
4618
+ .find((part) => part?.sessionID === 'ses_fail_abort');
4619
+ expect(peerCard?.status).toBe('complete');
4620
+ service.close();
4621
+ });
4622
+
4623
+ it('stop_session silent abort success cancels without waiting for SSE', async () => {
4624
+ const directory = root();
4625
+ const project = path.join(directory, 'app');
4626
+ fs.mkdirSync(project, { recursive: true });
4627
+ const resumes = [];
4628
+ const service = setup(directory, {
4629
+ get: async (input) => ({
4630
+ data: { id: input.sessionID, directory: project, title: 'Silent', status: { type: 'busy' } },
4631
+ }),
4632
+ abort: async () => ({ data: true }),
4633
+ }, {
4634
+ runContactTurn: async ({ tools, userText }) => {
4635
+ if (typeof userText === 'string' && userText.includes('Internal assigned-session resume')) {
4636
+ resumes.push(userText);
4637
+ return { text: '续', bubbles: ['续'] };
4638
+ }
4639
+ if (userText === 'watch') {
4640
+ const watch = tools.find((tool) => tool.name === 'watch_session');
4641
+ const result = await watch.execute('w', { sessionID: 'ses_silent' });
4642
+ return { text: '盯', bubbles: ['盯'], cards: result.details.card ? [result.details.card] : [] };
4643
+ }
4644
+ if (userText === 'peer silent watch') {
4645
+ const watch = tools.find((tool) => tool.name === 'watch_session');
4646
+ const result = await watch.execute('peer_silent', { sessionID: 'ses_silent' });
4647
+ return { text: 'peer盯', bubbles: ['peer盯'], cards: result.details.card ? [result.details.card] : [] };
4648
+ }
4649
+ const stop = tools.find((tool) => tool.name === 'stop_session');
4650
+ const result = await stop.execute('s', { sessionID: 'ses_silent' });
4651
+ expect(result.details.stopped?.aborted).toBe(true);
4652
+ return { text: '停了', bubbles: ['停了'] };
4653
+ },
4654
+ });
4655
+ const assistant = service.createAssistant(assistantInput);
4656
+ const peer = service.createAssistant({ name: 'PeerSilent', providerID: 'p', modelID: 'm' });
4657
+ await settleSend(service, assistant.id, {
4658
+ messageID: 'watch_silent',
4659
+ parts: [{ type: 'text', text: 'watch' }],
4660
+ });
4661
+ await settleSend(service, peer.id, {
4662
+ messageID: 'peer_watch_silent',
4663
+ parts: [{ type: 'text', text: 'peer silent watch' }],
4664
+ });
4665
+ await settleSend(service, assistant.id, {
4666
+ messageID: 'stop_silent',
4667
+ parts: [{ type: 'text', text: '停止' }],
4668
+ });
4669
+ // Peer still resumeAllowed; success resume=false must suppress peer continuation too.
4670
+ expect(resumes).toEqual([]);
4671
+ expect(service.snapshot().assistants.find((row) => row.id === assistant.id).assignedSessionIDs).toEqual([]);
4672
+ expect(service.snapshot().assistants.find((row) => row.id === peer.id).assignedSessionIDs).toEqual([]);
4673
+ const card = service.contactMessages(assistant.id).messages
4674
+ .flatMap((message) => message.parts || [])
4675
+ .find((part) => part?.sessionID === 'ses_silent');
4676
+ expect(card?.status).toBe('cancelled');
4677
+ service.close();
4678
+ });
4679
+
4680
+ it('concurrent stop_session on the same worker shares one abort and cancels once', async () => {
4681
+ const directory = root();
4682
+ const project = path.join(directory, 'app');
4683
+ fs.mkdirSync(project, { recursive: true });
4684
+ let releaseAbort;
4685
+ const abortGate = new Promise((resolve) => { releaseAbort = resolve; });
4686
+ let abortCalls = 0;
4687
+ const resumes = [];
4688
+ const service = setup(directory, {
4689
+ get: async (input) => ({
4690
+ data: { id: input.sessionID, directory: project, title: 'Concurrent', status: { type: 'busy' } },
4691
+ }),
4692
+ abort: async () => {
4693
+ abortCalls += 1;
4694
+ await abortGate;
4695
+ return { data: true };
4696
+ },
4697
+ }, {
4698
+ runContactTurn: async ({ tools, userText }) => {
4699
+ if (typeof userText === 'string' && userText.includes('Internal assigned-session resume')) {
4700
+ resumes.push(userText);
4701
+ return { text: '续', bubbles: ['续'] };
4702
+ }
4703
+ if (userText === 'watch concurrent') {
4704
+ const watch = tools.find((tool) => tool.name === 'watch_session');
4705
+ await watch.execute('cw', { sessionID: 'ses_concurrent' });
4706
+ return { text: '盯', bubbles: ['盯'], cards: [] };
4707
+ }
4708
+ if (userText === 'peer concurrent watch') {
4709
+ const watch = tools.find((tool) => tool.name === 'watch_session');
4710
+ await watch.execute('pcw', { sessionID: 'ses_concurrent' });
4711
+ return { text: 'peer盯', bubbles: ['peer盯'], cards: [] };
4712
+ }
4713
+ const stop = tools.find((tool) => tool.name === 'stop_session');
4714
+ const result = await stop.execute(`s_${userText}`, { sessionID: 'ses_concurrent' });
4715
+ expect(result.details.stopped?.aborted).toBe(true);
4716
+ return { text: '停了', bubbles: ['停了'] };
4717
+ },
4718
+ });
4719
+ const a = service.createAssistant({ name: 'StopA', providerID: 'p', modelID: 'm' });
4720
+ const b = service.createAssistant({ name: 'StopB', providerID: 'p', modelID: 'm' });
4721
+ await settleSend(service, a.id, { messageID: 'watch_conc_a', parts: [{ type: 'text', text: 'watch concurrent' }] });
4722
+ await settleSend(service, b.id, { messageID: 'watch_conc_b', parts: [{ type: 'text', text: 'peer concurrent watch' }] });
4723
+ const stopA = service.send(a.id, { messageID: 'stop_conc_a', parts: [{ type: 'text', text: 'stop A' }] });
4724
+ const stopB = service.send(b.id, { messageID: 'stop_conc_b', parts: [{ type: 'text', text: 'stop B' }] });
4725
+ await new Promise((resolve) => setTimeout(resolve, 20));
4726
+ releaseAbort();
4727
+ await Promise.all([
4728
+ stopA.then((sent) => service.whenContactTurnSettled(sent.messageID)),
4729
+ stopB.then((sent) => service.whenContactTurnSettled(sent.messageID)),
4730
+ ]);
4731
+ expect(abortCalls).toBe(1);
4732
+ expect(resumes).toEqual([]);
4733
+ expect(service.snapshot().assistants.find((row) => row.id === a.id).assignedSessionIDs).toEqual([]);
4734
+ expect(service.snapshot().assistants.find((row) => row.id === b.id).assignedSessionIDs).toEqual([]);
4735
+ service.close();
4736
+ });
4200
4737
  });