@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.
- package/dist/assets/{AssistantView-Dncbu_Z2.js → AssistantView-D5rEZ-Ss.js} +2 -2
- package/dist/assets/{DiagramView-CovEl1BY.js → DiagramView-DaB9Sb7S.js} +1 -1
- package/dist/assets/MarkdownRenderer-emLeoNjI.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-CCfHY-86.js → MarkdownRendererImpl-BWCAPlqn.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-y6QgIUZA.js → MarkstreamRendererImpl-BD_NFKUv.js} +1 -1
- package/dist/assets/MobileDetailNavigation-DqXVAIap.js +1 -0
- package/dist/assets/{MobileShareBridge-B4S7BaUj.js → MobileShareBridge-Bk2fdkJk.js} +1 -1
- package/dist/assets/MobileSurface-C4mpcOCh.js +2 -0
- package/dist/assets/{MultiRunWindow-BeHa-CG6.js → MultiRunWindow-rs49_K1E.js} +1 -1
- package/dist/assets/{SettingsView-Dn7odtQz.js → SettingsView-b8LUjoad.js} +2 -2
- package/dist/assets/{SettingsWindow-C0tWPS5f.js → SettingsWindow-D8JFlPdA.js} +1 -1
- package/dist/assets/{TerminalView-BnJgC7Qo.js → TerminalView-DCd-Godi.js} +1 -1
- package/dist/assets/{ToolOutputDialog-D9S-X8hM.js → ToolOutputDialog-B50L_nUP.js} +1 -1
- package/dist/assets/{appThemeRegistry-DBrvaJnq.js → appThemeRegistry-BWtCaTMe.js} +1 -1
- package/dist/assets/{fileMentionTouchSelection-Dc004xOh.js → fileMentionTouchSelection-DKYkoypI.js} +1 -1
- package/dist/assets/{gitApi-Cm3JLQqh.js → gitApi-D4PyYgLZ.js} +1 -1
- package/dist/assets/{insertionBoundaries-CTWYFsbH.js → insertionBoundaries-Bnnx03Wj.js} +1 -1
- package/dist/assets/{main-CSqu3MB4.js → main-BRiQTNFX.js} +4 -4
- package/dist/assets/{main-DwMprEyr.js → main-CnY0LWNc.js} +2 -2
- package/dist/assets/{miniChat-C8bzY_Ye.js → miniChat-B0pw_mUs.js} +2 -2
- package/dist/assets/{mobile-Oy-SRKkr.js → mobile-BKHmxwXz.js} +2 -2
- package/dist/assets/{multirun-ClcuaiKU.js → multirun-BQKqVfFT.js} +1 -1
- package/dist/assets/{projectMeta-F93wE2Ye.js → projectMeta-BvG8sdtC.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-rOOhMVlQ.js → renderElectronMiniChatApp-E5a0yy7E.js} +2 -2
- package/dist/assets/{renderMobileApp-BP_hGwHP.js → renderMobileApp-DT0L4Ah6.js} +4 -4
- package/dist/assets/{runtimeConfig-iJ2mTBVN.js → runtimeConfig-utWzrDwa.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-ISnhAS3q.js → runtimeEndpointReset-DE6lwHyg.js} +1 -1
- package/dist/assets/sessionChangeSummary-BG7l2heP.js +2 -0
- package/dist/assets/{sessionLookup-DQQ9-DuP.js → sessionLookup-12zVOR0J.js} +1 -1
- package/dist/assets/src-CFUi4riW.css +2 -0
- package/dist/assets/{useAppFontEffects--IcU7jyv.js → useAppFontEffects-BhYuP4L2.js} +5 -5
- package/dist/assets/{useDesktopWindowControlsLayout-BqJ5R_T2.js → useDesktopWindowControlsLayout-DPNMRkrI.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-ChWXV8xM.js → useEffectiveDirectory-Ji8AKgzY.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-DQkgxuzg.js → useMobileNavigationStore-BYdpuwL7.js} +1 -1
- package/dist/assets/{useSessionAutoCleanup-DWWAvQQ0.js → useSessionAutoCleanup-CADZCvZL.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-D2oQszsk.js → useWorkerHighlightedLines-CuG88Lsj.js} +1 -1
- package/dist/index.html +3 -3
- package/dist/mini-chat.html +3 -3
- package/dist/mobile.html +3 -3
- package/package.json +1 -1
- package/server/lib/assistants/DOCUMENTATION.md +33 -12
- package/server/lib/assistants/contact-store.js +25 -1
- package/server/lib/assistants/contact-store.test.js +39 -5
- package/server/lib/assistants/contact-tools.js +3 -3
- package/server/lib/assistants/harness.js +136 -101
- package/server/lib/assistants/harness.test.js +235 -8
- package/server/lib/assistants/pi-tools.js +2 -0
- package/server/lib/assistants/pi-tools.test.js +8 -0
- package/server/lib/assistants/service.js +313 -74
- package/server/lib/assistants/service.test.js +555 -18
- package/server/lib/llm/DOCUMENTATION.md +9 -4
- package/server/lib/llm/generate.js +45 -6
- package/server/lib/llm/generate.test.js +52 -0
- package/dist/assets/MarkdownRenderer-CZqok-Cf.js +0 -2
- package/dist/assets/MobileDetailNavigation-Ca-d5eZ9.js +0 -1
- package/dist/assets/MobileSurface-xL5Jby4U.js +0 -2
- package/dist/assets/sessionChangeSummary-BAzU2J1H.js +0 -2
- 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 () => ({
|
|
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(
|
|
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
|
|
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
|
|
203
|
-
const
|
|
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).
|
|
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).
|
|
245
|
+
expect(runContactTurn).toHaveBeenCalledTimes(1)
|
|
222
246
|
restarted.close()
|
|
223
247
|
})
|
|
224
248
|
|
|
225
|
-
it('reconciliation recognizes an interrupted worker instead of
|
|
249
|
+
it('reconciliation recognizes an interrupted worker and resumes cancelled instead of error', async () => {
|
|
226
250
|
const directory = root()
|
|
227
|
-
const
|
|
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
|
|
236
|
-
expect(runContactTurn).
|
|
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
|
|
272
|
+
it('an interruption revokes a queued complete continuation and resumes cancelled instead', async () => {
|
|
242
273
|
const directory = root()
|
|
243
|
-
const
|
|
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
|
-
|
|
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.
|
|
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
|
});
|