@openchambery/web 1.19.12 → 1.19.13-beta.2
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/{AgentAvatar-CX0a1rKE.js → AgentAvatar-mbvX7YlW.js} +1 -1
- package/dist/assets/AssistantView-D5c557Oe.js +2 -0
- package/dist/assets/DiagramView-CjNUrHsU.js +1 -0
- package/dist/assets/FadeInOnReveal-moBbL8Ll.js +1 -0
- package/dist/assets/{Icon-BYM02QyU.js → Icon-CkzzhaLr.js} +1 -1
- package/dist/assets/MarkdownRenderer-BHfbAwVO.js +2 -0
- package/dist/assets/MarkdownRendererImpl-CkBoUibc.js +7 -0
- package/dist/assets/{MarkstreamRendererImpl-BFdJzxia.js → MarkstreamRendererImpl-BrXUOI9g.js} +1 -1
- package/dist/assets/MobileDetailNavigation-aY9-HwBN.js +1 -0
- package/dist/assets/MobileShareBridge-CRt_8DmM.js +4 -0
- package/dist/assets/MobileSurface-CIhtF-CU.js +2 -0
- package/dist/assets/{MultiRunWindow-C3p3lj3U.js → MultiRunWindow-DDq6C8t7.js} +1 -1
- package/dist/assets/OnboardingScreen-j0iCyH7f.js +1 -0
- package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-CDC5B3RH.js} +1 -1
- package/dist/assets/SettingsView-DFWWMvAj.js +25 -0
- package/dist/assets/{SettingsWindow-8IDuPZA5.js → SettingsWindow-CnYfBplB.js} +1 -1
- package/dist/assets/TerminalView-D5CQFVVm.js +8 -0
- package/dist/assets/ToolOutputDialog-VMI7TQFT.js +10 -0
- package/dist/assets/{appThemeRegistry-B87OLIgc.js → appThemeRegistry-UIrP0-5h.js} +1 -1
- package/dist/assets/assistant-attachment-cache-C0aRnFkj.js +1 -0
- package/dist/assets/{button-C0cth9tl.js → button-xx1UOBA5.js} +1 -1
- package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-CUasgPiF.js} +1 -1
- package/dist/assets/{collapsible-DFT9cE80.js → collapsible-CACHBRYZ.js} +1 -1
- package/dist/assets/{context-menu-CAp74NfV.js → context-menu-CYbm4wBZ.js} +1 -1
- package/dist/assets/desktopHosts-DILvrAok.js +1 -0
- package/dist/assets/desktopRelayRestore-t8asCr3m.js +1 -0
- package/dist/assets/{diagram-BryzMuta.js → diagram-u0BC9uuY.js} +1 -1
- package/dist/assets/{dialog-CgbV5ljg.js → dialog-BwQ3rY_x.js} +1 -1
- package/dist/assets/dualLimitLru-D8Sphgua.js +1 -0
- package/dist/assets/{es-BHqnSjoZ.js → es-CRjOwvYM.js} +1 -1
- package/dist/assets/fileQueries-Bgly-E8f.js +1 -0
- package/dist/assets/{fr-BfrlJY9i.js → fr-D9taEN9L.js} +1 -1
- package/dist/assets/{gitApi-tTtHQVA3.js → gitApi-Bu4W3bgD.js} +1 -1
- package/dist/assets/imageSource-BlAJJUEI.js +1 -0
- package/dist/assets/{input-DFLpqjGR.js → input-CI730njs.js} +1 -1
- package/dist/assets/insertionBoundaries-B1AqxsBD.js +4 -0
- package/dist/assets/{ja-DP-7qt-x.js → ja-CYQ5aoWp.js} +1 -1
- package/dist/assets/{ko-E2B5vGQP.js → ko-BwASV0Q0.js} +1 -1
- package/dist/assets/main-CqcB6tKc.js +169 -0
- package/dist/assets/main-DG-k0j8A.js +2 -0
- package/dist/assets/markdown-worker-CptoICmq.js +2 -0
- package/dist/assets/markdownHeightCache-DEjGKLv8.js +3 -0
- package/dist/assets/markdownTheme-DOfGg-A7.js +1 -0
- package/dist/assets/miniChat-BQtYQrsh.js +2 -0
- package/dist/assets/mobile-8iyqtXkr.js +2 -0
- package/dist/assets/{multirun-B0V3hdnD.js → multirun-CG_FCb_Z.js} +1 -1
- package/dist/assets/persistence-YbQKzHUQ.js +3 -0
- package/dist/assets/{pl-r8X5gRM5.js → pl-DUI1vPil.js} +1 -1
- package/dist/assets/platform-DzsGqlGW.js +2 -0
- package/dist/assets/projectMeta-DMpMDWE1.js +3 -0
- package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-CZQFJllP.js} +1 -1
- package/dist/assets/relay-image-stream-DM7atqJs.js +2 -0
- package/dist/assets/renderElectronMiniChatApp-DBzRdJl8.js +2 -0
- package/dist/assets/renderMobileApp-CIseqCyn.js +28 -0
- package/dist/assets/runtime-auth-D66yq6ie.js +1 -0
- package/dist/assets/runtime-fetch-zIalUnbb.js +1 -0
- package/dist/assets/runtime-switch-LvWDU-SL.js +1 -0
- package/dist/assets/runtimeConfig-D8nJYB-e.js +2 -0
- package/dist/assets/runtimeEndpointReset-CSub_ecV.js +147 -0
- package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DQDZ5HW-.js} +1 -1
- package/dist/assets/{sessionLookup-B6P_aCun.js → sessionLookup-BiBD-4wC.js} +1 -1
- package/dist/assets/sortable-tabs-strip-Dom6Sthu.js +1 -0
- package/dist/assets/src-AA-5nXsF.css +2 -0
- package/dist/assets/{store-BDFdnk6l.js → store-CSnH_QXM.js} +2 -2
- package/dist/assets/terminalApi-noa4Cdbh.js +1 -0
- package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BfNtUdJE.js} +1 -1
- package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-DPxumadF.js} +1 -1
- package/dist/assets/{uk-Di0vl9V-.js → uk-C1FYZlmA.js} +1 -1
- package/dist/assets/{url-Pk6tQ1EZ.js → url-CVhsI9kR.js} +1 -1
- package/dist/assets/{useAppFontEffects-Dmes1chr.js → useAppFontEffects-qHt5uaFN.js} +32 -32
- package/dist/assets/useAssistantUIStore-CLPCNoj_.js +1 -0
- package/dist/assets/useDesktopWindowControlsLayout-BipyIXiK.js +1 -0
- package/dist/assets/{useEffectiveDirectory-CU2ChP0J.js → useEffectiveDirectory-DSNCCbCt.js} +1 -1
- package/dist/assets/useFontPreferences-CB-cGo3I.js +1 -0
- package/dist/assets/useMobileNavigationStore-C1NJGAik.js +413 -0
- package/dist/assets/useSessionAutoCleanup-Dcn6Unvd.js +2 -0
- package/dist/assets/useUIStore-CxOoCv7B.js +1 -0
- package/dist/assets/useWorkerHighlightedLines-CzpT4M7V.js +7 -0
- package/dist/assets/{utils-BiK3tBGM.js → utils-BHTVlVov.js} +1 -1
- package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-C96qP1ip.js} +1 -1
- package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-BsRk1Rbb.js} +1 -1
- package/dist/index.html +12 -9
- package/dist/mini-chat.html +12 -9
- package/dist/mobile.html +12 -9
- package/package.json +1 -1
- package/server/index.js +19 -0
- package/server/lib/assistant-delivery-parts.js +19 -1
- package/server/lib/assistants/DOCUMENTATION.md +56 -11
- package/server/lib/assistants/assign.js +455 -30
- package/server/lib/assistants/assign.test.js +351 -0
- package/server/lib/assistants/cards.js +16 -2
- package/server/lib/assistants/cards.test.js +16 -1
- package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
- package/server/lib/assistants/contact-attachments.http.test.js +436 -0
- package/server/lib/assistants/contact-attachments.js +814 -0
- package/server/lib/assistants/contact-attachments.p1.test.js +307 -0
- package/server/lib/assistants/contact-attachments.test.js +249 -0
- package/server/lib/assistants/contact-store.js +552 -19
- package/server/lib/assistants/contact-store.test.js +415 -1
- package/server/lib/assistants/contact-tools.js +114 -14
- package/server/lib/assistants/contact-tools.test.js +135 -6
- package/server/lib/assistants/contracts.js +42 -2
- package/server/lib/assistants/harness.js +4 -1
- package/server/lib/assistants/harness.test.js +124 -0
- package/server/lib/assistants/routes.js +46 -1
- package/server/lib/assistants/service.js +569 -84
- package/server/lib/assistants/service.test.js +936 -37
- package/server/lib/fs/prompt-attachment-store.js +663 -0
- package/server/lib/fs/prompt-attachment-store.test.js +263 -0
- package/server/lib/fs/routes.js +28 -72
- package/server/lib/fs/routes.test.js +15 -30
- package/server/lib/llm/catalog.js +91 -16
- package/server/lib/llm/catalog.test.js +152 -11
- package/server/lib/notifications/APNS.md +20 -4
- package/server/lib/notifications/DOCUMENTATION.md +5 -0
- package/server/lib/notifications/apns-runtime.js +136 -18
- package/server/lib/notifications/apns-runtime.test.js +128 -0
- package/server/lib/notifications/apns-titles.js +21 -1
- package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
- package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
- package/server/lib/notifications/runtime.js +93 -2
- package/server/lib/notifications/runtime.test.js +167 -4
- package/server/lib/opencode/core-routes.js +2 -1
- package/server/lib/opencode/settings-helpers.js +12 -0
- package/server/lib/opencode/settings-runtime.js +16 -0
- package/server/lib/opencode/settings-runtime.test.js +21 -0
- package/server/lib/scheduled-tasks/runtime.js +21 -0
- package/server/lib/scheduled-tasks/runtime.test.js +24 -0
- package/dist/assets/AssistantView-DxIghtun.js +0 -2
- package/dist/assets/DiagramView-BeUgp_Y0.js +0 -1
- package/dist/assets/FadeInOnReveal-CTlNE4Hg.js +0 -1
- package/dist/assets/MarkdownRenderer-C4MGa_kQ.js +0 -2
- package/dist/assets/MarkdownRendererImpl-DB4Q3kGa.js +0 -7
- package/dist/assets/MobileDetailNavigation-B_RwWOwv.js +0 -1
- package/dist/assets/MobileShareBridge-UiOLx0-2.js +0 -4
- package/dist/assets/MobileSurface-CNChj_dX.js +0 -2
- package/dist/assets/OnboardingScreen-DS2nugLX.js +0 -1
- package/dist/assets/SettingsView-BXfKsz0J.js +0 -25
- package/dist/assets/TerminalView-BiKCueth.js +0 -8
- package/dist/assets/ToolOutputDialog-BC2F_Fdl.js +0 -10
- package/dist/assets/desktopHosts-Dc2PS66M.js +0 -1
- package/dist/assets/desktopRelayRestore-T9y0orqa.js +0 -1
- package/dist/assets/fileQueries-BY-9OWzp.js +0 -1
- package/dist/assets/insertionBoundaries-CCQpZ5zn.js +0 -4
- package/dist/assets/main-C_PUoT3E.js +0 -169
- package/dist/assets/main-Dkg31fRY.js +0 -2
- package/dist/assets/markdown-worker-CTsrtpPh.js +0 -2
- package/dist/assets/markdownHeightCache-CYDlisxr.js +0 -3
- package/dist/assets/markdownTheme-DyJ4Gfcj.js +0 -2
- package/dist/assets/miniChat-C9QMj8T8.js +0 -2
- package/dist/assets/mobile-Z_X7CbzB.js +0 -2
- package/dist/assets/persistence-DdUac14I.js +0 -3
- package/dist/assets/platform-B4KKLnZ5.js +0 -2
- package/dist/assets/projectMeta-DRW9MQ4z.js +0 -3
- package/dist/assets/renderElectronMiniChatApp-Dn1Su1dd.js +0 -2
- package/dist/assets/renderMobileApp-BG1JJt1Q.js +0 -28
- package/dist/assets/runtimeConfig-C6matIEB.js +0 -2
- package/dist/assets/runtimeEndpointReset-kgwmUJDb.js +0 -147
- package/dist/assets/sortable-tabs-strip-C8_9kHqu.js +0 -1
- package/dist/assets/src-B-P5qIBc.css +0 -2
- package/dist/assets/terminalApi-947l7Ra3.js +0 -1
- package/dist/assets/useAssistantUIStore-me6s63b2.js +0 -1
- package/dist/assets/useDesktopWindowControlsLayout-DuZ6URPR.js +0 -1
- package/dist/assets/useFontPreferences-BCOBlaAd.js +0 -1
- package/dist/assets/useMobileNavigationStore-C8_J-qTp.js +0 -413
- package/dist/assets/useSessionAutoCleanup-FOQeu1_T.js +0 -1
- package/dist/assets/useUIStore-CuaA8-Hp.js +0 -1
- package/dist/assets/useWorkerHighlightedLines-GBqKnki1.js +0 -7
|
@@ -12,10 +12,53 @@ import {
|
|
|
12
12
|
|
|
13
13
|
const require = createRequire(import.meta.url);
|
|
14
14
|
const root = () => fs.mkdtempSync(path.join(os.tmpdir(), 'assistants-'));
|
|
15
|
+
/** Minimal valid 1×1 PNG (strict data-URL / image magic fixtures). */
|
|
16
|
+
const FIXTURE_PNG = Buffer.from([
|
|
17
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
18
|
+
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
19
|
+
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
|
20
|
+
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
|
|
21
|
+
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
|
|
22
|
+
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
|
|
23
|
+
0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xfe,
|
|
24
|
+
0xd4, 0xef, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
|
|
25
|
+
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
|
26
|
+
]);
|
|
27
|
+
const FIXTURE_PNG_DATA_URL = `data:image/png;base64,${FIXTURE_PNG.toString('base64')}`;
|
|
28
|
+
/** Valid text/plain data URL body "x". */
|
|
29
|
+
const FIXTURE_TEXT_DATA_URL = 'data:text/plain;base64,eA==';
|
|
15
30
|
// Behavioral tests enable the global switch after boot; pass enabled:false to assert the fresh-install default.
|
|
16
31
|
const setup = (directory = root(), client = {}, options = {}) => {
|
|
17
32
|
const { enabled = true, ...serviceOptions } = options;
|
|
18
|
-
const
|
|
33
|
+
const {
|
|
34
|
+
provider = undefined,
|
|
35
|
+
config = undefined,
|
|
36
|
+
session: sessionOverrides = {},
|
|
37
|
+
...sessionClient
|
|
38
|
+
} = client && typeof client === 'object' ? client : {};
|
|
39
|
+
const service = createAssistantsService({
|
|
40
|
+
dbPath: path.join(directory, 'assistants.sqlite'),
|
|
41
|
+
dataDir: directory,
|
|
42
|
+
getAllowedRoots: () => [directory],
|
|
43
|
+
buildOpenCodeUrl: () => 'http://127.0.0.1:1',
|
|
44
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
45
|
+
clientFactory: () => ({
|
|
46
|
+
session: {
|
|
47
|
+
create: async () => ({ data: { id: crypto.randomUUID() } }),
|
|
48
|
+
get: async () => ({ data: { id: 'present' } }),
|
|
49
|
+
update: async () => ({ data: { id: 'archived' } }),
|
|
50
|
+
promptAsync: async () => ({ data: { info: { id: 'msg_1' } } }),
|
|
51
|
+
summarize: async () => ({ data: true }),
|
|
52
|
+
delete: async () => ({ data: true }),
|
|
53
|
+
...sessionClient,
|
|
54
|
+
...sessionOverrides,
|
|
55
|
+
},
|
|
56
|
+
...(provider ? { provider } : {}),
|
|
57
|
+
...(config ? { config } : {}),
|
|
58
|
+
}),
|
|
59
|
+
runContactTurn: serviceOptions.runContactTurn ?? (async ({ userText }) => ({ text: `reply:${userText}`, bubbles: [`reply:${userText}`] })),
|
|
60
|
+
...serviceOptions,
|
|
61
|
+
});
|
|
19
62
|
if (enabled) {
|
|
20
63
|
const snapshot = service.snapshot();
|
|
21
64
|
if (!snapshot.enabled) service.setEnabled({ enabled: true, expectedRevision: snapshot.revision });
|
|
@@ -83,7 +126,7 @@ describe('assistants service', () => {
|
|
|
83
126
|
const assistant = service.createAssistant(assistantInput);
|
|
84
127
|
const current = await service.ensure(assistant.id);
|
|
85
128
|
const sent = await service.send(assistant.id, { ...current, messageID: 'client_1', parts: [{ type: 'text', text: 'hello' }] });
|
|
86
|
-
expect(sent).toEqual({ binding: current, messageID: 'client_1', admitted: true });
|
|
129
|
+
expect(sent).toEqual({ binding: current, messageID: 'client_1', admitted: true, revision: expect.any(Number) });
|
|
87
130
|
// Admission resolves before the async turn persists assistant bubbles.
|
|
88
131
|
expect(service.contactMessages(assistant.id, { limit: 50 }).messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([
|
|
89
132
|
{ role: 'user', text: 'hello' },
|
|
@@ -101,6 +144,80 @@ describe('assistants service', () => {
|
|
|
101
144
|
service.close();
|
|
102
145
|
});
|
|
103
146
|
|
|
147
|
+
it('exposes latestMessagePreview on create/update/snapshot from contact transcript', async () => {
|
|
148
|
+
const directory = root();
|
|
149
|
+
const service = setup(directory, {}, {
|
|
150
|
+
runContactTurn: async ({ userText }) => ({ text: `ok ${userText}`, bubbles: [`ok ${userText}`] }),
|
|
151
|
+
});
|
|
152
|
+
const created = service.createAssistant(assistantInput);
|
|
153
|
+
expect(created.latestMessagePreview).toBeNull();
|
|
154
|
+
expect(service.snapshot().assistants[0].latestMessagePreview).toBeNull();
|
|
155
|
+
|
|
156
|
+
await settleSend(service, created.id, { messageID: 'prev_u1', parts: [{ type: 'text', text: 'hello preview' }] });
|
|
157
|
+
const afterUser = service.snapshot().assistants[0].latestMessagePreview;
|
|
158
|
+
// After turn settle the assistant reply is newest.
|
|
159
|
+
expect(afterUser).toMatchObject({
|
|
160
|
+
role: 'assistant',
|
|
161
|
+
text: 'ok hello preview',
|
|
162
|
+
fallbackKind: null,
|
|
163
|
+
});
|
|
164
|
+
expect(typeof afterUser.messageID).toBe('string');
|
|
165
|
+
expect(Number.isFinite(afterUser.ordinal)).toBe(true);
|
|
166
|
+
|
|
167
|
+
const updated = await service.updateAssistant(created.id, {
|
|
168
|
+
expectedRevision: service.snapshot().assistants[0].revision,
|
|
169
|
+
name: 'Renamed',
|
|
170
|
+
});
|
|
171
|
+
expect(updated.latestMessagePreview).toMatchObject({ text: 'ok hello preview', role: 'assistant' });
|
|
172
|
+
|
|
173
|
+
// Clear-memory keeps transcript → preview stays on last bubble.
|
|
174
|
+
const memServiceClose = service;
|
|
175
|
+
// use existing service with clear tool path
|
|
176
|
+
memServiceClose.close();
|
|
177
|
+
const service2 = setup(directory, {}, {
|
|
178
|
+
runContactTurn: async ({ userText, tools }) => {
|
|
179
|
+
if (userText === '开新对话') {
|
|
180
|
+
await tools.find((item) => item.name === 'new_conversation').execute('mem', {});
|
|
181
|
+
return { text: NEW_CONVERSATION_CONFIRM_BUBBLE, bubbles: [NEW_CONVERSATION_CONFIRM_BUBBLE], reset: true };
|
|
182
|
+
}
|
|
183
|
+
if (userText === '清空聊天记录') {
|
|
184
|
+
const tool = tools.find((item) => item.name === 'clear_chat_history');
|
|
185
|
+
const result = await tool.execute('wipe', {});
|
|
186
|
+
return {
|
|
187
|
+
text: result.content[0].text,
|
|
188
|
+
bubbles: [result.content[0].text],
|
|
189
|
+
reset: true,
|
|
190
|
+
historyCleared: true,
|
|
191
|
+
messages: [{ role: 'toolResult', toolName: 'clear_chat_history', details: result.details }],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return { text: `ok ${userText}`, bubbles: [`ok ${userText}`] };
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
const id = service2.snapshot().assistants[0].id;
|
|
198
|
+
expect(service2.snapshot().assistants[0].latestMessagePreview?.text).toBe('ok hello preview');
|
|
199
|
+
await settleSend(service2, id, { messageID: 'prev_mem', parts: [{ type: 'text', text: '开新对话' }] });
|
|
200
|
+
expect(service2.snapshot().assistants[0].latestMessagePreview?.text).toBe(NEW_CONVERSATION_CONFIRM_BUBBLE);
|
|
201
|
+
// Transcript still has older rows after clear-memory.
|
|
202
|
+
expect(service2.contactMessages(id, { limit: 50 }).messages.some((m) => m.text === 'hello preview')).toBe(true);
|
|
203
|
+
|
|
204
|
+
await settleSend(service2, id, { messageID: 'prev_wipe', parts: [{ type: 'text', text: '清空聊天记录' }] });
|
|
205
|
+
expect(service2.snapshot().assistants[0].latestMessagePreview).toMatchObject({
|
|
206
|
+
text: CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
|
|
207
|
+
role: 'assistant',
|
|
208
|
+
});
|
|
209
|
+
expect(service2.contactMessages(id, { limit: 50 }).messages.some((m) => m.text === 'hello preview')).toBe(false);
|
|
210
|
+
|
|
211
|
+
service2.close();
|
|
212
|
+
const cold = setup(directory, {}, {
|
|
213
|
+
runContactTurn: async ({ userText }) => ({ text: `ok ${userText}`, bubbles: [`ok ${userText}`] }),
|
|
214
|
+
});
|
|
215
|
+
expect(cold.snapshot().assistants[0].latestMessagePreview).toMatchObject({
|
|
216
|
+
text: CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
|
|
217
|
+
});
|
|
218
|
+
cold.close();
|
|
219
|
+
});
|
|
220
|
+
|
|
104
221
|
it('new_conversation clears LLM memory while keeping the full transcript', async () => {
|
|
105
222
|
const directory = root();
|
|
106
223
|
let creates = 0;
|
|
@@ -169,8 +286,8 @@ describe('assistants service', () => {
|
|
|
169
286
|
messageID: 'attach_1',
|
|
170
287
|
parts: [
|
|
171
288
|
{ type: 'text', text: 'look at these' },
|
|
172
|
-
{ type: 'file', mime: 'image/png', url:
|
|
173
|
-
{ type: 'file', mime: 'text/plain', url:
|
|
289
|
+
{ type: 'file', mime: 'image/png', url: FIXTURE_PNG_DATA_URL, filename: 'dot.png' },
|
|
290
|
+
{ type: 'file', mime: 'text/plain', url: FIXTURE_TEXT_DATA_URL, filename: 'note.txt' },
|
|
174
291
|
],
|
|
175
292
|
});
|
|
176
293
|
await settleSend(service, assistant.id, { messageID: 'reset_leftover', parts: [{ type: 'text', text: '开新对话' }] });
|
|
@@ -222,11 +339,13 @@ describe('assistants service', () => {
|
|
|
222
339
|
assistantID: assistant.id,
|
|
223
340
|
reset: true,
|
|
224
341
|
historyCleared: true,
|
|
342
|
+
generation: 1,
|
|
225
343
|
});
|
|
226
344
|
expect(creates).toBe(1);
|
|
227
345
|
expect(service.contactMessages(assistant.id, { limit: 50 })).toMatchObject({
|
|
228
346
|
messages: [],
|
|
229
347
|
complete: true,
|
|
348
|
+
generation: 1,
|
|
230
349
|
});
|
|
231
350
|
await settleSend(service, assistant.id, { messageID: 'again_1', parts: [{ type: 'text', text: 'hello again' }] });
|
|
232
351
|
await settleSend(service, assistant.id, { messageID: 'wipe_1', parts: [{ type: 'text', text: '清空聊天记录' }] });
|
|
@@ -249,22 +368,26 @@ describe('assistants service', () => {
|
|
|
249
368
|
service.close();
|
|
250
369
|
});
|
|
251
370
|
|
|
252
|
-
it('
|
|
371
|
+
it('clear_chat_history executes when the model calls it without a user-text auth gate', async () => {
|
|
253
372
|
const directory = root();
|
|
254
373
|
const wipeAttempts = [];
|
|
255
374
|
const service = setup(directory, {}, {
|
|
256
375
|
runContactTurn: async ({ userText, tools }) => {
|
|
257
|
-
if (userText === '
|
|
376
|
+
if (userText === '请处理一下') {
|
|
258
377
|
const tool = tools.find((item) => item.name === 'clear_chat_history');
|
|
259
|
-
const result = await tool.execute(
|
|
378
|
+
const result = await tool.execute('model_chose_wipe', {});
|
|
260
379
|
wipeAttempts.push({
|
|
261
|
-
userText,
|
|
262
380
|
error: result.details?.error || null,
|
|
381
|
+
denied: result.details?.denied === true,
|
|
263
382
|
historyCleared: result.details?.historyCleared === true,
|
|
383
|
+
terminate: result.terminate,
|
|
384
|
+
text: result.content?.[0]?.text,
|
|
264
385
|
});
|
|
265
386
|
return {
|
|
266
|
-
text:
|
|
267
|
-
bubbles: [
|
|
387
|
+
text: result.content[0].text,
|
|
388
|
+
bubbles: [result.content[0].text],
|
|
389
|
+
reset: true,
|
|
390
|
+
historyCleared: true,
|
|
268
391
|
messages: [{
|
|
269
392
|
role: 'toolResult',
|
|
270
393
|
toolName: 'clear_chat_history',
|
|
@@ -277,17 +400,187 @@ describe('assistants service', () => {
|
|
|
277
400
|
});
|
|
278
401
|
const assistant = service.createAssistant(assistantInput);
|
|
279
402
|
await settleSend(service, assistant.id, { messageID: 'seed_keep', parts: [{ type: 'text', text: 'keep-me' }] });
|
|
280
|
-
await settleSend(service, assistant.id, { messageID: '
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
403
|
+
await settleSend(service, assistant.id, { messageID: 'model_wipe', parts: [{ type: 'text', text: '请处理一下' }] });
|
|
404
|
+
expect(wipeAttempts).toEqual([{
|
|
405
|
+
error: null,
|
|
406
|
+
denied: false,
|
|
407
|
+
historyCleared: true,
|
|
408
|
+
terminate: true,
|
|
409
|
+
text: CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
|
|
410
|
+
}]);
|
|
286
411
|
const texts = service.contactMessages(assistant.id, { limit: 50 }).messages.map((message) => message.text);
|
|
287
|
-
expect(texts).toContain('keep-me');
|
|
288
|
-
expect(texts).
|
|
289
|
-
expect(texts).
|
|
290
|
-
|
|
412
|
+
expect(texts).not.toContain('keep-me');
|
|
413
|
+
expect(texts).toEqual(['请处理一下', CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE]);
|
|
414
|
+
expect(texts.some((text) => /not cleared|denied|new_conversation instead/i.test(text))).toBe(false);
|
|
415
|
+
service.close();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('tool wipe keeps later-admitted queued B and replays B only once', async () => {
|
|
419
|
+
const directory = root();
|
|
420
|
+
const turnRuns = [];
|
|
421
|
+
let aReachedTurn;
|
|
422
|
+
const aReachedGate = new Promise((resolve) => { aReachedTurn = resolve; });
|
|
423
|
+
let bAdmitted;
|
|
424
|
+
const bAdmittedGate = new Promise((resolve) => { bAdmitted = resolve; });
|
|
425
|
+
const service = setup(directory, {}, {
|
|
426
|
+
runContactTurn: async ({ userText, tools }) => {
|
|
427
|
+
turnRuns.push(userText);
|
|
428
|
+
if (userText === '清空聊天记录') {
|
|
429
|
+
aReachedTurn();
|
|
430
|
+
await bAdmittedGate;
|
|
431
|
+
const tool = tools.find((item) => item.name === 'clear_chat_history');
|
|
432
|
+
const result = await tool.execute('wipe_queue_a', {});
|
|
433
|
+
return {
|
|
434
|
+
text: result.content[0].text,
|
|
435
|
+
bubbles: [result.content[0].text],
|
|
436
|
+
reset: true,
|
|
437
|
+
historyCleared: true,
|
|
438
|
+
messages: [{
|
|
439
|
+
role: 'toolResult',
|
|
440
|
+
toolName: 'clear_chat_history',
|
|
441
|
+
details: result.details,
|
|
442
|
+
}],
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
const assistant = service.createAssistant(assistantInput);
|
|
449
|
+
await settleSend(service, assistant.id, { messageID: 'seed_queue', parts: [{ type: 'text', text: 'seed before wipe' }] });
|
|
450
|
+
// A pause -> B admission -> A wipe -> B complete -> replay B once.
|
|
451
|
+
const aSend = service.send(assistant.id, { messageID: 'wipe_a', parts: [{ type: 'text', text: '清空聊天记录' }] });
|
|
452
|
+
await aReachedGate;
|
|
453
|
+
const bSend = service.send(assistant.id, { messageID: 'queued_b', parts: [{ type: 'text', text: 'queued B keep me' }] });
|
|
454
|
+
await bSend;
|
|
455
|
+
bAdmitted();
|
|
456
|
+
await aSend;
|
|
457
|
+
await service.whenContactTurnSettled('wipe_a');
|
|
458
|
+
await service.whenContactTurnSettled('queued_b');
|
|
459
|
+
// Same messageID + payload replays admission only — no second lane run.
|
|
460
|
+
const replay = await service.send(assistant.id, { messageID: 'queued_b', parts: [{ type: 'text', text: 'queued B keep me' }] });
|
|
461
|
+
expect(replay).toMatchObject({ admitted: true, replayed: true, messageID: 'queued_b' });
|
|
462
|
+
const page = service.contactMessages(assistant.id, { limit: 50 });
|
|
463
|
+
const texts = page.messages.map((message) => message.text);
|
|
464
|
+
expect(texts).not.toContain('seed before wipe');
|
|
465
|
+
expect(texts).toContain('queued B keep me');
|
|
466
|
+
expect(texts).toContain('reply:queued B keep me');
|
|
467
|
+
expect(texts).toContain(CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE);
|
|
468
|
+
// Wipe user kept in place (no re-admit reorder before B).
|
|
469
|
+
const wipeIdx = page.messages.findIndex((message) => message.messageID === 'wipe_a');
|
|
470
|
+
const bIdx = page.messages.findIndex((message) => message.messageID === 'queued_b');
|
|
471
|
+
expect(wipeIdx).toBeGreaterThanOrEqual(0);
|
|
472
|
+
expect(bIdx).toBeGreaterThan(wipeIdx);
|
|
473
|
+
expect(turnRuns.filter((text) => text === 'queued B keep me')).toHaveLength(1);
|
|
474
|
+
expect(page.messages.filter((message) => message.messageID === 'queued_b')).toHaveLength(1);
|
|
475
|
+
service.close();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('tool wipe drops late prior-lane assistant/cards after reverse admit order and keeps C once', async () => {
|
|
479
|
+
const directory = root();
|
|
480
|
+
const turnRuns = [];
|
|
481
|
+
const cHistories = [];
|
|
482
|
+
let aReachedTurn;
|
|
483
|
+
const aReachedGate = new Promise((resolve) => { aReachedTurn = resolve; });
|
|
484
|
+
let lateWritten;
|
|
485
|
+
const lateWrittenGate = new Promise((resolve) => { lateWritten = resolve; });
|
|
486
|
+
let wipeMayRun;
|
|
487
|
+
const wipeMayRunGate = new Promise((resolve) => { wipeMayRun = resolve; });
|
|
488
|
+
const service = setup(directory, {}, {
|
|
489
|
+
runContactTurn: async ({ history, userText, tools }) => {
|
|
490
|
+
turnRuns.push(userText);
|
|
491
|
+
if (userText === 'ordinary A still running') {
|
|
492
|
+
aReachedTurn();
|
|
493
|
+
await lateWrittenGate;
|
|
494
|
+
// A completes after wipe already ran — its returned bubbles must not
|
|
495
|
+
// resurrect deleted late residue (wipe already cleared transcript).
|
|
496
|
+
return { text: 'late-A-final-bubble-should-not-matter', bubbles: ['late-A-final-bubble-should-not-matter'] };
|
|
497
|
+
}
|
|
498
|
+
if (userText === '清空聊天记录') {
|
|
499
|
+
await wipeMayRunGate;
|
|
500
|
+
const tool = tools.find((item) => item.name === 'clear_chat_history');
|
|
501
|
+
const result = await tool.execute('wipe_reverse', {});
|
|
502
|
+
return {
|
|
503
|
+
text: result.content[0].text,
|
|
504
|
+
bubbles: [result.content[0].text],
|
|
505
|
+
reset: true,
|
|
506
|
+
historyCleared: true,
|
|
507
|
+
messages: [{
|
|
508
|
+
role: 'toolResult',
|
|
509
|
+
toolName: 'clear_chat_history',
|
|
510
|
+
details: result.details,
|
|
511
|
+
}],
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
if (userText === 'queued C keep me') {
|
|
515
|
+
cHistories.push(history.map((item) => item.content));
|
|
516
|
+
}
|
|
517
|
+
return { text: `reply:${userText}`, bubbles: [`reply:${userText}`] };
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
const assistant = service.createAssistant(assistantInput);
|
|
521
|
+
await settleSend(service, assistant.id, { messageID: 'seed_rev', parts: [{ type: 'text', text: 'seed secret reverse' }] });
|
|
522
|
+
// A pause -> wipe B admit -> C admit -> late A reply/card/watch -> B wipe -> C.
|
|
523
|
+
const aSend = service.send(assistant.id, { messageID: 'ord_a', parts: [{ type: 'text', text: 'ordinary A still running' }] });
|
|
524
|
+
await aReachedGate;
|
|
525
|
+
const bSend = service.send(assistant.id, { messageID: 'wipe_b', parts: [{ type: 'text', text: '清空聊天记录' }] });
|
|
526
|
+
await bSend;
|
|
527
|
+
const cSend = service.send(assistant.id, { messageID: 'queued_c', parts: [{ type: 'text', text: 'queued C keep me' }] });
|
|
528
|
+
await cSend;
|
|
529
|
+
// Late residue from A while still paused (ordinal after wipe+C users).
|
|
530
|
+
service.appendContactCard(assistant.id, {
|
|
531
|
+
cardType: 'session',
|
|
532
|
+
sessionID: 'ses_late_a',
|
|
533
|
+
directory: directory,
|
|
534
|
+
title: 'late A card residue',
|
|
535
|
+
status: 'busy',
|
|
536
|
+
messageID: 'late_a_card',
|
|
537
|
+
});
|
|
538
|
+
const Database = require('better-sqlite3');
|
|
539
|
+
const db = new Database(path.join(directory, 'assistants.sqlite'));
|
|
540
|
+
const ordinal = Number(db.prepare(
|
|
541
|
+
'SELECT COALESCE(MAX(ordinal), 0) + 1 AS next FROM assistant_contact_message WHERE assistant_id=?',
|
|
542
|
+
).get(assistant.id).next);
|
|
543
|
+
db.prepare(
|
|
544
|
+
'INSERT INTO assistant_contact_message(message_id,assistant_id,role,turn_id,bubble_index,created_at,ordinal,status) VALUES (?,?,?,?,?,?,?,?)',
|
|
545
|
+
).run('late_a_reply', assistant.id, 'assistant', 'ord_a', 0, Date.now(), ordinal, 'complete');
|
|
546
|
+
db.prepare(
|
|
547
|
+
'INSERT INTO assistant_contact_part(message_id,part_id,ordinal,part_json) VALUES (?,?,?,?)',
|
|
548
|
+
).run('late_a_reply', 'p1', 1, JSON.stringify({ type: 'text', text: 'late-A-reply-residue' }));
|
|
549
|
+
db.close();
|
|
550
|
+
lateWritten();
|
|
551
|
+
wipeMayRun();
|
|
552
|
+
await aSend;
|
|
553
|
+
await service.whenContactTurnSettled('ord_a');
|
|
554
|
+
await service.whenContactTurnSettled('wipe_b');
|
|
555
|
+
await service.whenContactTurnSettled('queued_c');
|
|
556
|
+
const replay = await service.send(assistant.id, { messageID: 'queued_c', parts: [{ type: 'text', text: 'queued C keep me' }] });
|
|
557
|
+
expect(replay).toMatchObject({ admitted: true, replayed: true, messageID: 'queued_c' });
|
|
558
|
+
const page = service.contactMessages(assistant.id, { limit: 50 });
|
|
559
|
+
const texts = page.messages.map((message) => message.text);
|
|
560
|
+
expect(texts).not.toContain('seed secret reverse');
|
|
561
|
+
expect(texts).not.toContain('late-A-reply-residue');
|
|
562
|
+
expect(texts).not.toContain('late-A-final-bubble-should-not-matter');
|
|
563
|
+
expect(page.messages.some((message) => (message.cards || []).some((card) => card.sessionID === 'ses_late_a'))).toBe(false);
|
|
564
|
+
expect(texts).toContain('queued C keep me');
|
|
565
|
+
expect(texts).toContain('reply:queued C keep me');
|
|
566
|
+
expect(texts).toContain(CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE);
|
|
567
|
+
// Wipe user kept before C (no re-admit reorder).
|
|
568
|
+
const wipeIdx = page.messages.findIndex((message) => message.messageID === 'wipe_b');
|
|
569
|
+
const cIdx = page.messages.findIndex((message) => message.messageID === 'queued_c');
|
|
570
|
+
expect(wipeIdx).toBeGreaterThanOrEqual(0);
|
|
571
|
+
expect(cIdx).toBeGreaterThan(wipeIdx);
|
|
572
|
+
expect(turnRuns.filter((text) => text === 'queued C keep me')).toHaveLength(1);
|
|
573
|
+
expect(page.messages.filter((message) => message.messageID === 'queued_c')).toHaveLength(1);
|
|
574
|
+
// C LLM history must not see late A residue or pre-wipe seed.
|
|
575
|
+
const cHist = cHistories[0] || [];
|
|
576
|
+
expect(cHist.some((content) => String(content).includes('late-A'))).toBe(false);
|
|
577
|
+
expect(cHist.some((content) => String(content).includes('seed secret reverse'))).toBe(false);
|
|
578
|
+
const dbAfter = new Database(path.join(directory, 'assistants.sqlite'));
|
|
579
|
+
const watches = dbAfter.prepare(
|
|
580
|
+
'SELECT session_id FROM assistant_contact_watch WHERE assistant_id=?',
|
|
581
|
+
).all(assistant.id);
|
|
582
|
+
expect(watches).toEqual([]);
|
|
583
|
+
dbAfter.close();
|
|
291
584
|
service.close();
|
|
292
585
|
});
|
|
293
586
|
|
|
@@ -457,8 +750,8 @@ describe('assistants service', () => {
|
|
|
457
750
|
it('persists mixed text+image+file parts and forwards them to the contact harness', async () => {
|
|
458
751
|
const directory = root();
|
|
459
752
|
let harness;
|
|
460
|
-
const image = { type: 'file', mime: 'image/png', url:
|
|
461
|
-
const file = { type: 'file', mime: 'text/plain', url:
|
|
753
|
+
const image = { type: 'file', mime: 'image/png', url: FIXTURE_PNG_DATA_URL, filename: 'shot.png' };
|
|
754
|
+
const file = { type: 'file', mime: 'text/plain', url: FIXTURE_TEXT_DATA_URL, filename: 'notes.txt' };
|
|
462
755
|
const service = setup(directory, {}, {
|
|
463
756
|
runContactTurn: async (input) => {
|
|
464
757
|
harness = input;
|
|
@@ -471,11 +764,17 @@ describe('assistants service', () => {
|
|
|
471
764
|
parts: [{ type: 'text', text: 'look' }, image, file],
|
|
472
765
|
});
|
|
473
766
|
expect(harness.userText).toBe('look');
|
|
474
|
-
|
|
767
|
+
// Harness sees materialized data URLs; DB stores attachment descriptors (no raw url).
|
|
768
|
+
expect(harness.userParts[0]).toEqual({ type: 'text', text: 'look' });
|
|
769
|
+
expect(harness.userParts[1]).toMatchObject({ type: 'file', mime: 'image/png', filename: 'shot.png', url: expect.stringMatching(/^data:image\/png;base64,/) });
|
|
770
|
+
expect(harness.userParts[2]).toMatchObject({ type: 'file', mime: 'text/plain', filename: 'notes.txt', url: expect.stringMatching(/^data:text\/plain;base64,/) });
|
|
475
771
|
expect(service.contactMessages(assistant.id, { limit: 50 }).messages[0]).toMatchObject({
|
|
476
772
|
role: 'user',
|
|
477
|
-
|
|
478
|
-
|
|
773
|
+
parts: [
|
|
774
|
+
{ type: 'text', text: 'look' },
|
|
775
|
+
{ type: 'file', mime: 'image/png', filename: 'shot.png', attachmentID: expect.stringMatching(/^att_/) },
|
|
776
|
+
{ type: 'file', mime: 'text/plain', filename: 'notes.txt', attachmentID: expect.stringMatching(/^att_/) },
|
|
777
|
+
],
|
|
479
778
|
});
|
|
480
779
|
service.close();
|
|
481
780
|
const restarted = setup(directory, {}, {
|
|
@@ -483,14 +782,14 @@ describe('assistants service', () => {
|
|
|
483
782
|
});
|
|
484
783
|
expect(restarted.contactMessages(assistant.id, { limit: 50 }).messages[0].parts).toEqual([
|
|
485
784
|
{ type: 'text', text: 'look' },
|
|
486
|
-
image,
|
|
487
|
-
file,
|
|
785
|
+
expect.objectContaining({ type: 'file', mime: 'image/png', filename: 'shot.png', attachmentID: expect.stringMatching(/^att_/) }),
|
|
786
|
+
expect.objectContaining({ type: 'file', mime: 'text/plain', filename: 'notes.txt', attachmentID: expect.stringMatching(/^att_/) }),
|
|
488
787
|
]);
|
|
489
788
|
restarted.close();
|
|
490
789
|
});
|
|
491
790
|
|
|
492
791
|
it('admits a file-only contact send and stores the file part without a fake text row', async () => {
|
|
493
|
-
const image = { type: 'file', mime: 'image/png', url:
|
|
792
|
+
const image = { type: 'file', mime: 'image/png', url: FIXTURE_PNG_DATA_URL, filename: 'shot.png' };
|
|
494
793
|
let harness;
|
|
495
794
|
const service = setup(root(), {}, {
|
|
496
795
|
runContactTurn: async (input) => {
|
|
@@ -501,9 +800,14 @@ describe('assistants service', () => {
|
|
|
501
800
|
const assistant = service.createAssistant(assistantInput);
|
|
502
801
|
await settleSend(service, assistant.id, { messageID: 'file_only_1', parts: [image] });
|
|
503
802
|
expect(harness.userText).toBe('[attachment]');
|
|
504
|
-
expect(harness.userParts).toEqual([
|
|
803
|
+
expect(harness.userParts).toEqual([
|
|
804
|
+
expect.objectContaining({ type: 'file', mime: 'image/png', filename: 'shot.png', url: expect.stringMatching(/^data:image\/png;base64,/) }),
|
|
805
|
+
]);
|
|
505
806
|
const user = service.contactMessages(assistant.id).messages.find((message) => message.role === 'user');
|
|
506
|
-
expect(user.parts).toEqual([
|
|
807
|
+
expect(user.parts).toEqual([
|
|
808
|
+
expect.objectContaining({ type: 'file', mime: 'image/png', filename: 'shot.png', attachmentID: expect.stringMatching(/^att_/) }),
|
|
809
|
+
]);
|
|
810
|
+
expect(user.parts[0].url).toBeUndefined();
|
|
507
811
|
expect(user.text).toBe('');
|
|
508
812
|
service.close();
|
|
509
813
|
});
|
|
@@ -512,7 +816,7 @@ describe('assistants service', () => {
|
|
|
512
816
|
const service = setup(root(), { promptAsync: async () => ({ response: { status: 204 } }) }); const assistant = service.createAssistant(assistantInput); const current = await service.ensure(assistant.id);
|
|
513
817
|
expect(await service.compact(assistant.id, current)).toEqual({ binding: current, summarized: true });
|
|
514
818
|
expect(await settleSend(service, assistant.id, { ...current, messageID: 'client_204', parts: [{ type: 'text', text: 'hello' }] })).toMatchObject({ binding: current, messageID: 'client_204', admitted: true });
|
|
515
|
-
expect(Object.keys(assistantContractFixtures.assistant)).toContain('managedWorkspacePath'); expect(Object.keys(assistantContractFixtures.assistant)).not.toContain('skillRoots'); expect(Object.keys(assistantContractFixtures.compactResponse).sort()).toEqual(['binding', 'summarized']); expect(Object.keys(assistantContractFixtures.messageAdmission).sort()).toEqual(['admitted', 'binding', 'messageID']); service.close();
|
|
819
|
+
expect(Object.keys(assistantContractFixtures.assistant)).toContain('managedWorkspacePath'); expect(Object.keys(assistantContractFixtures.assistant)).not.toContain('skillRoots'); expect(Object.keys(assistantContractFixtures.compactResponse).sort()).toEqual(['binding', 'summarized']); expect(Object.keys(assistantContractFixtures.messageAdmission).sort()).toEqual(['admitted', 'binding', 'messageID', 'revision']); service.close();
|
|
516
820
|
});
|
|
517
821
|
|
|
518
822
|
it('admits 33-part direct messages and 129-part shares', async () => {
|
|
@@ -1203,6 +1507,314 @@ describe('assistants service', () => {
|
|
|
1203
1507
|
service.close();
|
|
1204
1508
|
});
|
|
1205
1509
|
|
|
1510
|
+
it.each([
|
|
1511
|
+
{ error: { status: 404 } },
|
|
1512
|
+
{ data: { info: { id: 'different_message' }, parts: [] } },
|
|
1513
|
+
{ data: { parts: [] } },
|
|
1514
|
+
])('preserves ambiguous admission until lookup identifies the exact message: %j', async (lookupResult) => {
|
|
1515
|
+
const directory = root();
|
|
1516
|
+
const project = path.join(directory, 'app');
|
|
1517
|
+
fs.mkdirSync(project, { recursive: true });
|
|
1518
|
+
const creates = [];
|
|
1519
|
+
const prompts = [];
|
|
1520
|
+
const deletes = [];
|
|
1521
|
+
const lookups = [];
|
|
1522
|
+
let promptCalls = 0;
|
|
1523
|
+
const service = setup(directory, {
|
|
1524
|
+
create: async (input) => {
|
|
1525
|
+
creates.push(input);
|
|
1526
|
+
return { data: { id: 'ses_lookup' } };
|
|
1527
|
+
},
|
|
1528
|
+
promptAsync: async (input) => {
|
|
1529
|
+
prompts.push(input);
|
|
1530
|
+
promptCalls += 1;
|
|
1531
|
+
if (promptCalls === 1) {
|
|
1532
|
+
const error = new TypeError('fetch failed');
|
|
1533
|
+
error.code = 'ECONNRESET';
|
|
1534
|
+
throw error;
|
|
1535
|
+
}
|
|
1536
|
+
return { response: { status: 204 } };
|
|
1537
|
+
},
|
|
1538
|
+
delete: async (input) => {
|
|
1539
|
+
deletes.push(input);
|
|
1540
|
+
return { data: true };
|
|
1541
|
+
},
|
|
1542
|
+
message: async (input) => {
|
|
1543
|
+
lookups.push(input);
|
|
1544
|
+
// Each response leaves the requested message unconfirmed.
|
|
1545
|
+
return lookupResult;
|
|
1546
|
+
},
|
|
1547
|
+
}, {
|
|
1548
|
+
runContactTurn: async ({ tools }) => {
|
|
1549
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
1550
|
+
const first = await assign.execute('call_1', {
|
|
1551
|
+
prompt: 'Fix',
|
|
1552
|
+
projectPath: project,
|
|
1553
|
+
variant: 'fast',
|
|
1554
|
+
});
|
|
1555
|
+
expect(first.details).toMatchObject({
|
|
1556
|
+
error: 'prompt_ambiguous',
|
|
1557
|
+
ambiguous: true,
|
|
1558
|
+
sessionID: 'ses_lookup',
|
|
1559
|
+
});
|
|
1560
|
+
expect(typeof first.details.messageID).toBe('string');
|
|
1561
|
+
expect(deletes).toEqual([]);
|
|
1562
|
+
const second = await assign.execute('call_2', {
|
|
1563
|
+
prompt: 'Fix',
|
|
1564
|
+
projectPath: project,
|
|
1565
|
+
variant: 'fast',
|
|
1566
|
+
});
|
|
1567
|
+
expect(second.details.assigned.sessionID).toBe('ses_lookup');
|
|
1568
|
+
return {
|
|
1569
|
+
text: second.content[0].text,
|
|
1570
|
+
bubbles: [second.content[0].text],
|
|
1571
|
+
cards: second.details.card ? [second.details.card] : [],
|
|
1572
|
+
};
|
|
1573
|
+
},
|
|
1574
|
+
});
|
|
1575
|
+
const assistant = service.createAssistant({ ...assistantInput, variant: 'fast' });
|
|
1576
|
+
await settleSend(service, assistant.id, {
|
|
1577
|
+
messageID: 'client_assign_lookup',
|
|
1578
|
+
parts: [{ type: 'text', text: 'Fix' }],
|
|
1579
|
+
});
|
|
1580
|
+
expect(creates).toHaveLength(1);
|
|
1581
|
+
expect(prompts[0]).toMatchObject({
|
|
1582
|
+
sessionID: 'ses_lookup',
|
|
1583
|
+
variant: 'fast',
|
|
1584
|
+
});
|
|
1585
|
+
// Retry reuses the same worker session (no second create).
|
|
1586
|
+
expect(prompts.length).toBeGreaterThanOrEqual(2);
|
|
1587
|
+
expect(prompts.every((item) => item.sessionID === 'ses_lookup')).toBe(true);
|
|
1588
|
+
expect(lookups.length).toBeGreaterThanOrEqual(1);
|
|
1589
|
+
expect(deletes).toEqual([]);
|
|
1590
|
+
service.close();
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
it('assign forwards current-turn images and explicit worker model into promptAsync without changing the contact', async () => {
|
|
1594
|
+
const directory = root();
|
|
1595
|
+
const project = path.join(directory, 'app');
|
|
1596
|
+
fs.mkdirSync(project, { recursive: true });
|
|
1597
|
+
const image = { type: 'file', mime: 'image/png', url: FIXTURE_PNG_DATA_URL, filename: 'card.png' };
|
|
1598
|
+
const creates = [];
|
|
1599
|
+
const prompts = [];
|
|
1600
|
+
const deletes = [];
|
|
1601
|
+
const service = setup(directory, {
|
|
1602
|
+
create: async (input) => {
|
|
1603
|
+
creates.push(input);
|
|
1604
|
+
return { data: { id: `ses_${creates.length}` } };
|
|
1605
|
+
},
|
|
1606
|
+
promptAsync: async (input) => {
|
|
1607
|
+
prompts.push(input);
|
|
1608
|
+
return { response: { status: 204 } };
|
|
1609
|
+
},
|
|
1610
|
+
delete: async (input) => {
|
|
1611
|
+
deletes.push(input);
|
|
1612
|
+
return { data: true };
|
|
1613
|
+
},
|
|
1614
|
+
provider: {
|
|
1615
|
+
list: async () => ({ data: { connected: ['xai', 'opencode-go'] } }),
|
|
1616
|
+
},
|
|
1617
|
+
config: {
|
|
1618
|
+
providers: async () => ({
|
|
1619
|
+
data: {
|
|
1620
|
+
providers: [
|
|
1621
|
+
{
|
|
1622
|
+
id: 'xai',
|
|
1623
|
+
name: 'xAI',
|
|
1624
|
+
models: {
|
|
1625
|
+
'grok-4.6': {
|
|
1626
|
+
id: 'grok-4.6',
|
|
1627
|
+
name: 'Grok 4.6',
|
|
1628
|
+
modalities: { input: ['text', 'image'] },
|
|
1629
|
+
},
|
|
1630
|
+
},
|
|
1631
|
+
},
|
|
1632
|
+
{
|
|
1633
|
+
id: 'opencode-go',
|
|
1634
|
+
name: 'OpenCode Go',
|
|
1635
|
+
models: {
|
|
1636
|
+
'deepseek-v4-flash': { id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
|
|
1637
|
+
},
|
|
1638
|
+
},
|
|
1639
|
+
],
|
|
1640
|
+
},
|
|
1641
|
+
}),
|
|
1642
|
+
},
|
|
1643
|
+
}, {
|
|
1644
|
+
runContactTurn: async ({ tools, userText, connectedModels }) => {
|
|
1645
|
+
expect(connectedModels.some((entry) => entry.providerID === 'xai' && entry.modelID === 'grok-4.6' && entry.acceptsImages)).toBe(true);
|
|
1646
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
1647
|
+
const result = await assign.execute('call_1', {
|
|
1648
|
+
prompt: userText,
|
|
1649
|
+
projectPath: project,
|
|
1650
|
+
title: 'Card width',
|
|
1651
|
+
model: 'xai/grok-4.6',
|
|
1652
|
+
});
|
|
1653
|
+
return {
|
|
1654
|
+
text: result.content[0].text,
|
|
1655
|
+
bubbles: [result.content[0].text],
|
|
1656
|
+
cards: result.details.card ? [result.details.card] : [],
|
|
1657
|
+
tools,
|
|
1658
|
+
};
|
|
1659
|
+
},
|
|
1660
|
+
});
|
|
1661
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1662
|
+
expect(assistant.providerID).toBe('p');
|
|
1663
|
+
expect(assistant.modelID).toBe('m');
|
|
1664
|
+
const sent = await settleSend(service, assistant.id, {
|
|
1665
|
+
messageID: 'client_assign_image_model',
|
|
1666
|
+
parts: [{ type: 'text', text: '修移动端卡片宽度' }, image],
|
|
1667
|
+
});
|
|
1668
|
+
expect(sent.settled.status).toBe('complete');
|
|
1669
|
+
expect(creates).toHaveLength(1);
|
|
1670
|
+
expect(prompts).toEqual([expect.objectContaining({
|
|
1671
|
+
sessionID: 'ses_1',
|
|
1672
|
+
model: { providerID: 'xai', modelID: 'grok-4.6' },
|
|
1673
|
+
parts: [
|
|
1674
|
+
{ type: 'text', text: '修移动端卡片宽度' },
|
|
1675
|
+
expect.objectContaining({
|
|
1676
|
+
type: 'file',
|
|
1677
|
+
mime: 'image/png',
|
|
1678
|
+
filename: 'card.png',
|
|
1679
|
+
url: expect.stringMatching(/^data:image\/png;base64,/),
|
|
1680
|
+
}),
|
|
1681
|
+
],
|
|
1682
|
+
})]);
|
|
1683
|
+
expect(deletes).toEqual([]);
|
|
1684
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
1685
|
+
id: assistant.id,
|
|
1686
|
+
providerID: 'p',
|
|
1687
|
+
modelID: 'm',
|
|
1688
|
+
});
|
|
1689
|
+
service.close();
|
|
1690
|
+
});
|
|
1691
|
+
|
|
1692
|
+
it('invalid explicit worker model fails closed with no session create', async () => {
|
|
1693
|
+
const directory = root();
|
|
1694
|
+
const project = path.join(directory, 'app');
|
|
1695
|
+
fs.mkdirSync(project, { recursive: true });
|
|
1696
|
+
const creates = [];
|
|
1697
|
+
const service = setup(directory, {
|
|
1698
|
+
create: async (input) => {
|
|
1699
|
+
creates.push(input);
|
|
1700
|
+
return { data: { id: `ses_${creates.length}` } };
|
|
1701
|
+
},
|
|
1702
|
+
promptAsync: async () => ({ response: { status: 204 } }),
|
|
1703
|
+
provider: {
|
|
1704
|
+
list: async () => ({ data: { connected: ['xai'] } }),
|
|
1705
|
+
},
|
|
1706
|
+
config: {
|
|
1707
|
+
providers: async () => ({
|
|
1708
|
+
data: {
|
|
1709
|
+
providers: [{
|
|
1710
|
+
id: 'xai',
|
|
1711
|
+
name: 'xAI',
|
|
1712
|
+
models: { 'grok-4.6': { id: 'grok-4.6', name: 'Grok 4.6', modalities: { input: ['text', 'image'] } } },
|
|
1713
|
+
}],
|
|
1714
|
+
},
|
|
1715
|
+
}),
|
|
1716
|
+
},
|
|
1717
|
+
}, {
|
|
1718
|
+
runContactTurn: async ({ tools }) => {
|
|
1719
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
1720
|
+
const result = await assign.execute('call_1', {
|
|
1721
|
+
prompt: 'Fix width',
|
|
1722
|
+
projectPath: project,
|
|
1723
|
+
model: 'missing/not-real',
|
|
1724
|
+
});
|
|
1725
|
+
expect(result.details.error).toBe('model_not_found');
|
|
1726
|
+
return {
|
|
1727
|
+
text: result.content[0].text,
|
|
1728
|
+
bubbles: [result.content[0].text],
|
|
1729
|
+
cards: [],
|
|
1730
|
+
tools,
|
|
1731
|
+
};
|
|
1732
|
+
},
|
|
1733
|
+
});
|
|
1734
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1735
|
+
await settleSend(service, assistant.id, {
|
|
1736
|
+
messageID: 'client_assign_bad_model',
|
|
1737
|
+
parts: [{ type: 'text', text: '用 missing 模型建会话' }],
|
|
1738
|
+
});
|
|
1739
|
+
expect(creates).toEqual([]);
|
|
1740
|
+
expect(service.snapshot().assistants[0]).toMatchObject({ providerID: 'p', modelID: 'm' });
|
|
1741
|
+
service.close();
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
it('cleans up the worker session when create succeeds but promptAsync fails definitively (4xx)', async () => {
|
|
1745
|
+
const directory = root();
|
|
1746
|
+
const project = path.join(directory, 'app');
|
|
1747
|
+
fs.mkdirSync(project, { recursive: true });
|
|
1748
|
+
const creates = [];
|
|
1749
|
+
const deletes = [];
|
|
1750
|
+
const service = setup(directory, {
|
|
1751
|
+
create: async (input) => {
|
|
1752
|
+
creates.push(input);
|
|
1753
|
+
return { data: { id: 'ses_fail_prompt' } };
|
|
1754
|
+
},
|
|
1755
|
+
promptAsync: async () => ({ error: { status: 400 }, response: { status: 400 } }),
|
|
1756
|
+
delete: async (input) => {
|
|
1757
|
+
deletes.push(input);
|
|
1758
|
+
return { data: true };
|
|
1759
|
+
},
|
|
1760
|
+
}, {
|
|
1761
|
+
runContactTurn: async ({ tools, userText }) => {
|
|
1762
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
1763
|
+
const result = await assign.execute('call_1', { prompt: userText, projectPath: project });
|
|
1764
|
+
expect(result.details.error).toBe('upstream_error');
|
|
1765
|
+
return {
|
|
1766
|
+
text: result.content[0].text,
|
|
1767
|
+
bubbles: [result.content[0].text],
|
|
1768
|
+
cards: [],
|
|
1769
|
+
tools,
|
|
1770
|
+
};
|
|
1771
|
+
},
|
|
1772
|
+
});
|
|
1773
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1774
|
+
await settleSend(service, assistant.id, {
|
|
1775
|
+
messageID: 'client_assign_prompt_fail',
|
|
1776
|
+
parts: [{ type: 'text', text: 'Fix after create' }],
|
|
1777
|
+
});
|
|
1778
|
+
expect(creates).toHaveLength(1);
|
|
1779
|
+
expect(deletes).toEqual([expect.objectContaining({ sessionID: 'ses_fail_prompt' })]);
|
|
1780
|
+
service.close();
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
it('keeps default-model assign behavior when no explicit worker model is requested', async () => {
|
|
1784
|
+
const directory = root();
|
|
1785
|
+
const project = path.join(directory, 'app');
|
|
1786
|
+
fs.mkdirSync(project, { recursive: true });
|
|
1787
|
+
const prompts = [];
|
|
1788
|
+
const service = setup(directory, {
|
|
1789
|
+
create: async () => ({ data: { id: 'ses_default' } }),
|
|
1790
|
+
promptAsync: async (input) => {
|
|
1791
|
+
prompts.push(input);
|
|
1792
|
+
return { response: { status: 204 } };
|
|
1793
|
+
},
|
|
1794
|
+
}, {
|
|
1795
|
+
runContactTurn: async ({ tools, userText }) => {
|
|
1796
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
1797
|
+
const result = await assign.execute('call_1', { prompt: userText, projectPath: project });
|
|
1798
|
+
return {
|
|
1799
|
+
text: result.content[0].text,
|
|
1800
|
+
bubbles: [result.content[0].text],
|
|
1801
|
+
cards: result.details.card ? [result.details.card] : [],
|
|
1802
|
+
tools,
|
|
1803
|
+
};
|
|
1804
|
+
},
|
|
1805
|
+
});
|
|
1806
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1807
|
+
await settleSend(service, assistant.id, {
|
|
1808
|
+
messageID: 'client_assign_default_model',
|
|
1809
|
+
parts: [{ type: 'text', text: '建会话修一下' }],
|
|
1810
|
+
});
|
|
1811
|
+
expect(prompts).toEqual([expect.objectContaining({
|
|
1812
|
+
model: { providerID: 'p', modelID: 'm' },
|
|
1813
|
+
parts: [{ type: 'text', text: '建会话修一下' }],
|
|
1814
|
+
})]);
|
|
1815
|
+
service.close();
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1206
1818
|
it('creates another assistant from create_assistant and persists the assistant card', async () => {
|
|
1207
1819
|
const directory = root();
|
|
1208
1820
|
const service = setup(directory, {}, {
|
|
@@ -1576,7 +2188,9 @@ describe('assistants service', () => {
|
|
|
1576
2188
|
});
|
|
1577
2189
|
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
1578
2190
|
assignedSessionIDs: ['ses_work'],
|
|
1579
|
-
|
|
2191
|
+
// assigned busy keeps the session card in flight; list green dot is contact-turn only
|
|
2192
|
+
working: false,
|
|
2193
|
+
activeContactTurn: null,
|
|
1580
2194
|
});
|
|
1581
2195
|
expect(service.processEvent({ type: 'session.idle', properties: { sessionID: 'ses_work' } })).toBe(true);
|
|
1582
2196
|
const page = service.contactMessages(assistant.id);
|
|
@@ -1609,7 +2223,8 @@ describe('assistants service', () => {
|
|
|
1609
2223
|
});
|
|
1610
2224
|
expect(first.snapshot().assistants[0]).toMatchObject({
|
|
1611
2225
|
assignedSessionIDs: ['ses_missed'],
|
|
1612
|
-
working:
|
|
2226
|
+
working: false,
|
|
2227
|
+
activeContactTurn: null,
|
|
1613
2228
|
});
|
|
1614
2229
|
first.close();
|
|
1615
2230
|
|
|
@@ -1647,7 +2262,8 @@ describe('assistants service', () => {
|
|
|
1647
2262
|
title: 'Follow-up',
|
|
1648
2263
|
status: 'busy',
|
|
1649
2264
|
});
|
|
1650
|
-
expect(service.snapshot().assistants[0].working).toBe(
|
|
2265
|
+
expect(service.snapshot().assistants[0].working).toBe(false);
|
|
2266
|
+
expect(service.snapshot().assistants[0].assignedSessionIDs).toEqual(['ses_timer']);
|
|
1651
2267
|
await tick();
|
|
1652
2268
|
page = service.contactMessages(assistant.id);
|
|
1653
2269
|
expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card' && part.sessionID === 'ses_timer'))?.parts[0].status).toBe('complete');
|
|
@@ -1751,7 +2367,7 @@ describe('assistants service', () => {
|
|
|
1751
2367
|
});
|
|
1752
2368
|
expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(1);
|
|
1753
2369
|
expect(service.snapshot().assistants[0].assignedSessionIDs.sort()).toEqual(['ses_busy', 'ses_down']);
|
|
1754
|
-
expect(service.snapshot().assistants[0].working).toBe(
|
|
2370
|
+
expect(service.snapshot().assistants[0].working).toBe(false);
|
|
1755
2371
|
service.close();
|
|
1756
2372
|
});
|
|
1757
2373
|
|
|
@@ -1997,11 +2613,24 @@ describe('assistants service', () => {
|
|
|
1997
2613
|
binding: expect.objectContaining({ sessionID: null, sessionGeneration: 0 }),
|
|
1998
2614
|
messageID: 'turn_stream_1',
|
|
1999
2615
|
admitted: true,
|
|
2616
|
+
revision: expect.any(Number),
|
|
2000
2617
|
});
|
|
2001
2618
|
expect(sent).not.toHaveProperty('settled');
|
|
2619
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
2620
|
+
working: true,
|
|
2621
|
+
activeContactTurn: expect.objectContaining({
|
|
2622
|
+
turnID: 'turn_stream_1',
|
|
2623
|
+
messageID: 'turn_stream_1',
|
|
2624
|
+
status: expect.stringMatching(/^(queued|running)$/),
|
|
2625
|
+
}),
|
|
2626
|
+
});
|
|
2002
2627
|
const settled = await service.whenContactTurnSettled('turn_stream_1');
|
|
2003
2628
|
expect(settled).toMatchObject({ status: 'complete' });
|
|
2004
2629
|
await new Promise((resolve) => setImmediate(resolve));
|
|
2630
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
2631
|
+
working: false,
|
|
2632
|
+
activeContactTurn: null,
|
|
2633
|
+
});
|
|
2005
2634
|
const types = events.map((event) => event.type);
|
|
2006
2635
|
expect(types[0]).toBe('openchamber:contact-turn-start');
|
|
2007
2636
|
expect(events[0].properties).toMatchObject({
|
|
@@ -2034,6 +2663,179 @@ describe('assistants service', () => {
|
|
|
2034
2663
|
service.close();
|
|
2035
2664
|
});
|
|
2036
2665
|
|
|
2666
|
+
it('replays same messageID+payload admission once and conflicts on payload mismatch', async () => {
|
|
2667
|
+
const directory = root();
|
|
2668
|
+
let release;
|
|
2669
|
+
const blocked = new Promise((resolve) => { release = resolve; });
|
|
2670
|
+
let runs = 0;
|
|
2671
|
+
const service = setup(directory, {}, {
|
|
2672
|
+
runContactTurn: async () => {
|
|
2673
|
+
runs += 1;
|
|
2674
|
+
await blocked;
|
|
2675
|
+
return { text: 'once', bubbles: ['once'] };
|
|
2676
|
+
},
|
|
2677
|
+
});
|
|
2678
|
+
const assistant = service.createAssistant(assistantInput);
|
|
2679
|
+
const first = await service.send(assistant.id, {
|
|
2680
|
+
messageID: 'idem_1',
|
|
2681
|
+
parts: [{ type: 'text', text: 'same' }],
|
|
2682
|
+
});
|
|
2683
|
+
expect(first).toMatchObject({ admitted: true, messageID: 'idem_1', revision: expect.any(Number) });
|
|
2684
|
+
const replay = await service.send(assistant.id, {
|
|
2685
|
+
messageID: 'idem_1',
|
|
2686
|
+
parts: [{ type: 'text', text: 'same' }],
|
|
2687
|
+
});
|
|
2688
|
+
expect(replay).toMatchObject({ admitted: true, messageID: 'idem_1', replayed: true });
|
|
2689
|
+
await expect(service.send(assistant.id, {
|
|
2690
|
+
messageID: 'idem_1',
|
|
2691
|
+
parts: [{ type: 'text', text: 'different' }],
|
|
2692
|
+
})).rejects.toMatchObject({ code: 'idempotency_conflict' });
|
|
2693
|
+
release();
|
|
2694
|
+
await service.whenContactTurnSettled('idem_1');
|
|
2695
|
+
expect(runs).toBe(1);
|
|
2696
|
+
const after = await service.send(assistant.id, {
|
|
2697
|
+
messageID: 'idem_1',
|
|
2698
|
+
parts: [{ type: 'text', text: 'same' }],
|
|
2699
|
+
});
|
|
2700
|
+
expect(after).toMatchObject({ admitted: true, replayed: true });
|
|
2701
|
+
expect(runs).toBe(1);
|
|
2702
|
+
service.close();
|
|
2703
|
+
});
|
|
2704
|
+
|
|
2705
|
+
it('persists a durable contact error bubble so query recovery works without SSE', async () => {
|
|
2706
|
+
const service = setup(root(), {}, {
|
|
2707
|
+
runContactTurn: async () => {
|
|
2708
|
+
const error = new Error('No connected model');
|
|
2709
|
+
error.code = 'no_provider';
|
|
2710
|
+
throw error;
|
|
2711
|
+
},
|
|
2712
|
+
});
|
|
2713
|
+
const assistant = service.createAssistant(assistantInput);
|
|
2714
|
+
await service.send(assistant.id, {
|
|
2715
|
+
messageID: 'fail_persist_1',
|
|
2716
|
+
parts: [{ type: 'text', text: 'hi' }],
|
|
2717
|
+
});
|
|
2718
|
+
await service.whenContactTurnSettled('fail_persist_1');
|
|
2719
|
+
const page = service.contactMessages(assistant.id);
|
|
2720
|
+
expect(page.messages.map((message) => ({ role: message.role, status: message.status, text: message.text }))).toEqual([
|
|
2721
|
+
{ role: 'user', status: 'complete', text: 'hi' },
|
|
2722
|
+
{ role: 'assistant', status: 'error', text: 'No connected model' },
|
|
2723
|
+
]);
|
|
2724
|
+
expect(service.snapshot().assistants[0]).toMatchObject({ working: false, activeContactTurn: null });
|
|
2725
|
+
service.close();
|
|
2726
|
+
});
|
|
2727
|
+
|
|
2728
|
+
it('bumps revision when queued contact turn becomes running', async () => {
|
|
2729
|
+
const directory = root();
|
|
2730
|
+
let release;
|
|
2731
|
+
const blocked = new Promise((resolve) => { release = resolve; });
|
|
2732
|
+
const tips = [];
|
|
2733
|
+
const service = setup(directory, {}, {
|
|
2734
|
+
onRevisionTip: (tip) => tips.push(tip),
|
|
2735
|
+
runContactTurn: async () => {
|
|
2736
|
+
await blocked;
|
|
2737
|
+
return { text: 'ok', bubbles: ['ok'] };
|
|
2738
|
+
},
|
|
2739
|
+
});
|
|
2740
|
+
const assistant = service.createAssistant(assistantInput);
|
|
2741
|
+
const before = service.snapshot().revision;
|
|
2742
|
+
await service.send(assistant.id, {
|
|
2743
|
+
messageID: 'run_bump_1',
|
|
2744
|
+
parts: [{ type: 'text', text: 'hi' }],
|
|
2745
|
+
});
|
|
2746
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
2747
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
2748
|
+
expect(service.snapshot().assistants[0].activeContactTurn?.status).toBe('running');
|
|
2749
|
+
expect(service.snapshot().revision).toBeGreaterThan(before);
|
|
2750
|
+
release();
|
|
2751
|
+
await service.whenContactTurnSettled('run_bump_1');
|
|
2752
|
+
expect(tips.length).toBeGreaterThan(0);
|
|
2753
|
+
service.close();
|
|
2754
|
+
});
|
|
2755
|
+
|
|
2756
|
+
it('keeps snapshot working authoritative until settle and clears it on server process restart', async () => {
|
|
2757
|
+
const directory = root();
|
|
2758
|
+
let releaseTurn;
|
|
2759
|
+
const blocked = new Promise((resolve) => { releaseTurn = resolve; });
|
|
2760
|
+
const service = setup(directory, {}, {
|
|
2761
|
+
runContactTurn: async () => {
|
|
2762
|
+
await blocked;
|
|
2763
|
+
return { text: 'done', bubbles: ['done'] };
|
|
2764
|
+
},
|
|
2765
|
+
});
|
|
2766
|
+
const assistant = service.createAssistant(assistantInput);
|
|
2767
|
+
await service.send(assistant.id, {
|
|
2768
|
+
messageID: 'turn_auth_1',
|
|
2769
|
+
parts: [{ type: 'text', text: 'hold' }],
|
|
2770
|
+
});
|
|
2771
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
2772
|
+
working: true,
|
|
2773
|
+
activeContactTurn: {
|
|
2774
|
+
turnID: 'turn_auth_1',
|
|
2775
|
+
messageID: 'turn_auth_1',
|
|
2776
|
+
status: expect.stringMatching(/^(queued|running)$/),
|
|
2777
|
+
admittedAt: expect.any(Number),
|
|
2778
|
+
},
|
|
2779
|
+
});
|
|
2780
|
+
// Same process: snapshot is the APP-restart recovery source.
|
|
2781
|
+
expect(service.snapshot().assistants[0].working).toBe(true);
|
|
2782
|
+
releaseTurn();
|
|
2783
|
+
await service.whenContactTurnSettled('turn_auth_1');
|
|
2784
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
2785
|
+
working: false,
|
|
2786
|
+
activeContactTurn: null,
|
|
2787
|
+
});
|
|
2788
|
+
service.close();
|
|
2789
|
+
|
|
2790
|
+
// New process (server restart): process-local activity is gone — never permanently green.
|
|
2791
|
+
const restarted = setup(directory, {}, {
|
|
2792
|
+
runContactTurn: async () => ({ text: 'again', bubbles: ['again'] }),
|
|
2793
|
+
});
|
|
2794
|
+
expect(restarted.snapshot().assistants[0]).toMatchObject({
|
|
2795
|
+
working: false,
|
|
2796
|
+
activeContactTurn: null,
|
|
2797
|
+
});
|
|
2798
|
+
restarted.close();
|
|
2799
|
+
});
|
|
2800
|
+
|
|
2801
|
+
it('isolates contact working per assistant and clears working on harness error', async () => {
|
|
2802
|
+
const directory = root();
|
|
2803
|
+
let releaseA;
|
|
2804
|
+
const blockedA = new Promise((resolve) => { releaseA = resolve; });
|
|
2805
|
+
const service = setup(directory, {}, {
|
|
2806
|
+
runContactTurn: async ({ userText }) => {
|
|
2807
|
+
if (userText === 'hold-a') {
|
|
2808
|
+
await blockedA;
|
|
2809
|
+
return { text: 'a-done', bubbles: ['a-done'] };
|
|
2810
|
+
}
|
|
2811
|
+
const error = new Error('No connected model');
|
|
2812
|
+
error.code = 'no_provider';
|
|
2813
|
+
throw error;
|
|
2814
|
+
},
|
|
2815
|
+
});
|
|
2816
|
+
const first = service.createAssistant({ ...assistantInput, name: 'A' });
|
|
2817
|
+
const second = service.createAssistant({ ...assistantInput, name: 'B' });
|
|
2818
|
+
await service.send(first.id, {
|
|
2819
|
+
messageID: 'turn_a',
|
|
2820
|
+
parts: [{ type: 'text', text: 'hold-a' }],
|
|
2821
|
+
});
|
|
2822
|
+
await service.send(second.id, {
|
|
2823
|
+
messageID: 'turn_b',
|
|
2824
|
+
parts: [{ type: 'text', text: 'fail-b' }],
|
|
2825
|
+
});
|
|
2826
|
+
const snap = () => Object.fromEntries(service.snapshot().assistants.map((item) => [item.id, item]));
|
|
2827
|
+
expect(snap()[first.id].working).toBe(true);
|
|
2828
|
+
expect(snap()[first.id].activeContactTurn?.turnID).toBe('turn_a');
|
|
2829
|
+
await service.whenContactTurnSettled('turn_b');
|
|
2830
|
+
expect(snap()[second.id].working).toBe(false);
|
|
2831
|
+
expect(snap()[second.id].activeContactTurn).toBeNull();
|
|
2832
|
+
expect(snap()[first.id].working).toBe(true);
|
|
2833
|
+
releaseA();
|
|
2834
|
+
await service.whenContactTurnSettled('turn_a');
|
|
2835
|
+
expect(snap()[first.id].working).toBe(false);
|
|
2836
|
+
service.close();
|
|
2837
|
+
});
|
|
2838
|
+
|
|
2037
2839
|
it('admits the user then broadcasts turn-end error when the harness fails', async () => {
|
|
2038
2840
|
const events = [];
|
|
2039
2841
|
const service = setup(root(), {}, {
|
|
@@ -2051,17 +2853,25 @@ describe('assistants service', () => {
|
|
|
2051
2853
|
});
|
|
2052
2854
|
expect(sent).toMatchObject({ admitted: true, messageID: 'client_no_provider' });
|
|
2053
2855
|
expect(service.contactMessages(assistant.id).messages.map((message) => message.role)).toEqual(['user']);
|
|
2856
|
+
expect(service.snapshot().assistants[0].working).toBe(true);
|
|
2054
2857
|
const settled = await service.whenContactTurnSettled('client_no_provider');
|
|
2055
2858
|
expect(settled).toMatchObject({ status: 'error', code: 'no_provider' });
|
|
2056
2859
|
await new Promise((resolve) => setImmediate(resolve));
|
|
2860
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
2861
|
+
working: false,
|
|
2862
|
+
activeContactTurn: null,
|
|
2863
|
+
});
|
|
2057
2864
|
expect(events.some((event) => event.type === 'openchamber:contact-turn-start')).toBe(true);
|
|
2058
2865
|
expect(events.some((event) => (
|
|
2059
2866
|
event.type === 'openchamber:contact-turn-end'
|
|
2060
2867
|
&& event.properties?.status === 'error'
|
|
2061
2868
|
&& event.properties?.turnID === 'client_no_provider'
|
|
2062
2869
|
))).toBe(true);
|
|
2063
|
-
// User row stays;
|
|
2064
|
-
expect(service.contactMessages(assistant.id).messages.map((message) => message.role)).toEqual([
|
|
2870
|
+
// User row stays; durable error bubble enables query recovery without SSE.
|
|
2871
|
+
expect(service.contactMessages(assistant.id).messages.map((message) => ({ role: message.role, status: message.status }))).toEqual([
|
|
2872
|
+
{ role: 'user', status: 'complete' },
|
|
2873
|
+
{ role: 'assistant', status: 'error' },
|
|
2874
|
+
]);
|
|
2065
2875
|
service.close();
|
|
2066
2876
|
});
|
|
2067
2877
|
|
|
@@ -2089,4 +2899,93 @@ describe('assistants service', () => {
|
|
|
2089
2899
|
db.close();
|
|
2090
2900
|
service.close();
|
|
2091
2901
|
});
|
|
2902
|
+
|
|
2903
|
+
it('pages contact messages 20/20/5 with generation+revision and exact messageID admission', async () => {
|
|
2904
|
+
const directory = root();
|
|
2905
|
+
const service = setup(directory, {}, {
|
|
2906
|
+
runContactTurn: async ({ userText }) => ({ text: `r:${userText}`, bubbles: [`r:${userText}`] }),
|
|
2907
|
+
});
|
|
2908
|
+
const assistant = service.createAssistant(assistantInput);
|
|
2909
|
+
const other = service.createAssistant({ ...assistantInput, name: 'B' });
|
|
2910
|
+
// 45 user admits → 45 user + 45 assistant = 90 rows; page three windows of 20/20/5 on users alone
|
|
2911
|
+
// Seed via direct store through many settled turns would be slow; insert via contactMessages path
|
|
2912
|
+
// by settling 22 turns (44 rows) then more via appendContactCard for density.
|
|
2913
|
+
for (let i = 0; i < 22; i += 1) {
|
|
2914
|
+
await settleSend(service, assistant.id, {
|
|
2915
|
+
messageID: `page_u_${i}`,
|
|
2916
|
+
parts: [{ type: 'text', text: `u-${i}` }],
|
|
2917
|
+
});
|
|
2918
|
+
}
|
|
2919
|
+
// 22 users + 22 assistants = 44; add one more card-only for 45th
|
|
2920
|
+
service.appendContactCard(assistant.id, {
|
|
2921
|
+
cardType: 'session',
|
|
2922
|
+
sessionID: 'ses_page_extra',
|
|
2923
|
+
directory,
|
|
2924
|
+
title: 'extra',
|
|
2925
|
+
status: 'complete',
|
|
2926
|
+
messageID: 'page_card_45',
|
|
2927
|
+
});
|
|
2928
|
+
|
|
2929
|
+
// Newest window first (chat tail), ascending within the page.
|
|
2930
|
+
const defaultPage = service.contactMessages(assistant.id);
|
|
2931
|
+
expect(defaultPage.messages).toHaveLength(20);
|
|
2932
|
+
expect(defaultPage.complete).toBe(false);
|
|
2933
|
+
expect(defaultPage.generation).toBe(0);
|
|
2934
|
+
expect(defaultPage.revision).toEqual(expect.any(Number));
|
|
2935
|
+
expect(defaultPage.messages.at(-1).messageID).toBe('page_card_45');
|
|
2936
|
+
|
|
2937
|
+
const second = service.contactMessages(assistant.id, { before: defaultPage.nextCursor, limit: 20 });
|
|
2938
|
+
expect(second.messages).toHaveLength(20);
|
|
2939
|
+
expect(second.complete).toBe(false);
|
|
2940
|
+
|
|
2941
|
+
const third = service.contactMessages(assistant.id, { before: second.nextCursor, limit: 20 });
|
|
2942
|
+
expect(third.messages.length).toBeGreaterThanOrEqual(5);
|
|
2943
|
+
expect(third.complete).toBe(true);
|
|
2944
|
+
expect(third.nextCursor).toBeNull();
|
|
2945
|
+
expect(third.messages[0].text).toBe('u-0');
|
|
2946
|
+
|
|
2947
|
+
// Concurrent append does not break a prior cursor keyset for older pages.
|
|
2948
|
+
await settleSend(service, assistant.id, {
|
|
2949
|
+
messageID: 'page_concurrent',
|
|
2950
|
+
parts: [{ type: 'text', text: 'concurrent-append' }],
|
|
2951
|
+
});
|
|
2952
|
+
const stillSecond = service.contactMessages(assistant.id, { before: defaultPage.nextCursor, limit: 20 });
|
|
2953
|
+
expect(stillSecond.messages[0].messageID).toBe(second.messages[0].messageID);
|
|
2954
|
+
|
|
2955
|
+
// Exact admission: deep/old message still found; missing / cross-assistant empty.
|
|
2956
|
+
const deepID = third.messages[0].messageID;
|
|
2957
|
+
const exact = service.contactMessages(assistant.id, { messageID: deepID });
|
|
2958
|
+
expect(exact).toMatchObject({ nextCursor: null, complete: true, generation: 0 });
|
|
2959
|
+
expect(exact.messages).toHaveLength(1);
|
|
2960
|
+
expect(exact.messages[0].messageID).toBe(deepID);
|
|
2961
|
+
expect(service.contactMessages(assistant.id, { messageID: 'nope' }).messages).toEqual([]);
|
|
2962
|
+
expect(service.contactMessages(other.id, { messageID: deepID }).messages).toEqual([]);
|
|
2963
|
+
|
|
2964
|
+
// clear-memory keeps generation + cursor validity
|
|
2965
|
+
const beforeGen = service.contactMessages(assistant.id).generation;
|
|
2966
|
+
service.clearContactMemory(assistant.id);
|
|
2967
|
+
expect(service.contactMessages(assistant.id, { before: defaultPage.nextCursor, limit: 5 }).generation).toBe(beforeGen);
|
|
2968
|
+
|
|
2969
|
+
// reset bumps generation → old cursor conflicts
|
|
2970
|
+
const wiped = service.resetContact(assistant.id);
|
|
2971
|
+
expect(wiped.generation).toBe(1);
|
|
2972
|
+
expect(() => service.contactMessages(assistant.id, { before: defaultPage.nextCursor })).toThrowError(
|
|
2973
|
+
expect.objectContaining({ code: 'contact_generation_conflict' }),
|
|
2974
|
+
);
|
|
2975
|
+
expect(service.contactMessages(assistant.id)).toMatchObject({
|
|
2976
|
+
messages: [],
|
|
2977
|
+
complete: true,
|
|
2978
|
+
generation: 1,
|
|
2979
|
+
});
|
|
2980
|
+
|
|
2981
|
+
// invalid cursor
|
|
2982
|
+
expect(() => service.contactMessages(assistant.id, { before: '%%%' })).toThrowError(
|
|
2983
|
+
expect.objectContaining({ code: 'validation_error' }),
|
|
2984
|
+
);
|
|
2985
|
+
expect(() => service.contactMessages(assistant.id, { before: 'x', messageID: 'y' })).toThrowError(
|
|
2986
|
+
expect.objectContaining({ code: 'validation_error' }),
|
|
2987
|
+
);
|
|
2988
|
+
|
|
2989
|
+
service.close();
|
|
2990
|
+
});
|
|
2092
2991
|
});
|