@openchambery/web 1.19.11 → 1.19.13-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/{AgentAvatar-CX0a1rKE.js → AgentAvatar-CiQIIM0d.js} +1 -1
- package/dist/assets/AssistantView-YmeyxehG.js +2 -0
- package/dist/assets/{DiagramView-BQO1cBLs.js → DiagramView-BpZb-G9P.js} +1 -1
- package/dist/assets/{FadeInOnReveal-CTlNE4Hg.js → FadeInOnReveal-nf6-ZCrs.js} +1 -1
- package/dist/assets/{Icon-BYM02QyU.js → Icon-CeKHYyKZ.js} +1 -1
- package/dist/assets/MarkdownRenderer-wpeGdciL.js +2 -0
- package/dist/assets/{MarkdownRendererImpl--takeMXN.js → MarkdownRendererImpl-Peu0GVSQ.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-DGSqWcPN.js → MarkstreamRendererImpl-B-odd2Gz.js} +1 -1
- package/dist/assets/MobileDetailNavigation-CtYke_1C.js +1 -0
- package/dist/assets/{MobileShareBridge-D-BUjACe.js → MobileShareBridge-DIfOl8EZ.js} +3 -3
- package/dist/assets/MobileSurface-_KoX75Ff.js +2 -0
- package/dist/assets/{MultiRunWindow-BNR9ZWmD.js → MultiRunWindow-BT5Dwgzu.js} +1 -1
- package/dist/assets/{OnboardingScreen-DS2nugLX.js → OnboardingScreen-BPusUgFG.js} +1 -1
- package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-DUghqFGg.js} +1 -1
- package/dist/assets/{SettingsView-DGerg-C2.js → SettingsView-BwQpbIZl.js} +11 -11
- package/dist/assets/{SettingsWindow-CSpd5JbJ.js → SettingsWindow-DfEGfyr8.js} +1 -1
- package/dist/assets/{TerminalView-DS-oUxJr.js → TerminalView-8402vpcO.js} +1 -1
- package/dist/assets/{ToolOutputDialog-Q1k9-IaY.js → ToolOutputDialog-B5RdkhsG.js} +1 -1
- package/dist/assets/{appThemeRegistry-CG7D-3-q.js → appThemeRegistry-CIZ-shMD.js} +1 -1
- package/dist/assets/{button-C0cth9tl.js → button-DFPOdZ0g.js} +1 -1
- package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-C05anBwU.js} +1 -1
- package/dist/assets/{collapsible-DFT9cE80.js → collapsible-oGbEvbe1.js} +1 -1
- package/dist/assets/{context-menu-CAp74NfV.js → context-menu-DGz4_2hs.js} +1 -1
- package/dist/assets/{desktopHosts-Dc2PS66M.js → desktopHosts-BVJXQq1S.js} +1 -1
- package/dist/assets/{desktopRelayRestore-T9y0orqa.js → desktopRelayRestore-RDfuj1M7.js} +1 -1
- package/dist/assets/{diagram-BryzMuta.js → diagram-Csk5GpDC.js} +1 -1
- package/dist/assets/{dialog-CgbV5ljg.js → dialog-rrqB-H6q.js} +1 -1
- package/dist/assets/{es-BHqnSjoZ.js → es-FE0sJ9EC.js} +1 -1
- package/dist/assets/{fileQueries-BY-9OWzp.js → fileQueries-BcdWOPlD.js} +1 -1
- package/dist/assets/{fr-BfrlJY9i.js → fr-DNDCSfGt.js} +1 -1
- package/dist/assets/{gitApi-Cr-joUEw.js → gitApi-_P0FsoXe.js} +1 -1
- package/dist/assets/{input-DFLpqjGR.js → input-BO3r20op.js} +1 -1
- package/dist/assets/{insertionBoundaries-X0xbikAA.js → insertionBoundaries-CWoioTdE.js} +1 -1
- package/dist/assets/{ja-DP-7qt-x.js → ja-BDwrk0cM.js} +1 -1
- package/dist/assets/{ko-E2B5vGQP.js → ko-BeeU2o4I.js} +1 -1
- package/dist/assets/{main-D0L7fIg3.js → main-CLj_WS1o.js} +2 -2
- package/dist/assets/{main-BI7fNQoi.js → main-CnSZ89pD.js} +4 -4
- package/dist/assets/{markdown-worker-CTsrtpPh.js → markdown-worker-CZWE1F1M.js} +1 -1
- package/dist/assets/{markdownHeightCache-CYDlisxr.js → markdownHeightCache-Cdd3hkMl.js} +1 -1
- package/dist/assets/{markdownTheme-DyJ4Gfcj.js → markdownTheme-DIB8WVmz.js} +1 -1
- package/dist/assets/miniChat-B9c3y2pD.js +2 -0
- package/dist/assets/{mobile-OcCu_0mG.js → mobile-DvbXUfNS.js} +2 -2
- package/dist/assets/{multirun-B0G_BEIN.js → multirun-CxhTsmRq.js} +1 -1
- package/dist/assets/{persistence-DdUac14I.js → persistence-CVTWFaqe.js} +2 -2
- package/dist/assets/{pl-r8X5gRM5.js → pl-BYuzpcI5.js} +1 -1
- package/dist/assets/{platform-B4KKLnZ5.js → platform-BY_Gtsqr.js} +1 -1
- package/dist/assets/{projectMeta-BNOzn1QS.js → projectMeta-Dl5iPCB6.js} +1 -1
- package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-Bn7cwEzv.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-BREc3uhX.js → renderElectronMiniChatApp-CkJ51qCh.js} +2 -2
- package/dist/assets/renderMobileApp-DcjQPhWw.js +28 -0
- package/dist/assets/{runtimeConfig-P8nVMYzE.js → runtimeConfig-Bsk2kWsW.js} +1 -1
- package/dist/assets/runtimeEndpointReset-BBLFgl85.js +147 -0
- package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DDVT7O5J.js} +1 -1
- package/dist/assets/{sessionLookup-C_9-9ggp.js → sessionLookup-C8VZn0gJ.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-C8_9kHqu.js → sortable-tabs-strip-s7UUxsa6.js} +1 -1
- package/dist/assets/src-BpHDMgud.css +2 -0
- package/dist/assets/{store-BDFdnk6l.js → store-n94dSb3M.js} +2 -2
- package/dist/assets/{terminalApi-947l7Ra3.js → terminalApi-C54_rKEK.js} +1 -1
- package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BjX-KhMN.js} +1 -1
- package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-CQGqGAG_.js} +1 -1
- package/dist/assets/{uk-Di0vl9V-.js → uk-CwpbHEa3.js} +1 -1
- package/dist/assets/{url-Pk6tQ1EZ.js → url-BMjqccCo.js} +1 -1
- package/dist/assets/{useAppFontEffects-CEmOsrr6.js → useAppFontEffects-BIFw36SA.js} +5 -5
- package/dist/assets/{useAssistantUIStore-me6s63b2.js → useAssistantUIStore-Cg9bsT4b.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-CxaK-zXh.js → useDesktopWindowControlsLayout-DoygEcN4.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-BIwv5r5l.js → useEffectiveDirectory-DJSYYkVp.js} +1 -1
- package/dist/assets/{useFontPreferences-BCOBlaAd.js → useFontPreferences-B5_apbBC.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-DHm5kZPx.js → useMobileNavigationStore-DuTu0Mzh.js} +5 -5
- package/dist/assets/{useSessionAutoCleanup-CPLP9KWH.js → useSessionAutoCleanup-C-L9teva.js} +1 -1
- package/dist/assets/{useUIStore-CuaA8-Hp.js → useUIStore-DwpQTjLO.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-CZ4ZTWEH.js → useWorkerHighlightedLines-DxTfgdEi.js} +1 -1
- package/dist/assets/{utils-BiK3tBGM.js → utils-81nUI_xp.js} +1 -1
- package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-BpzFZ-LU.js} +1 -1
- package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-Bopgc5Eo.js} +1 -1
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +1 -1
- package/server/index.js +19 -0
- package/server/lib/assistants/DOCUMENTATION.md +21 -6
- package/server/lib/assistants/assign.js +455 -30
- package/server/lib/assistants/assign.test.js +351 -0
- package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
- package/server/lib/assistants/contact-store.js +64 -0
- package/server/lib/assistants/contact-store.test.js +12 -0
- package/server/lib/assistants/contact-tools.js +216 -25
- package/server/lib/assistants/contact-tools.test.js +167 -0
- package/server/lib/assistants/contracts.js +2 -2
- package/server/lib/assistants/harness.js +20 -3
- package/server/lib/assistants/harness.test.js +120 -1
- package/server/lib/assistants/pi-tools.js +50 -3
- package/server/lib/assistants/pi-tools.test.js +25 -0
- package/server/lib/assistants/service.js +320 -27
- package/server/lib/assistants/service.test.js +581 -9
- package/server/lib/llm/DOCUMENTATION.md +31 -18
- package/server/lib/llm/catalog.js +91 -16
- package/server/lib/llm/catalog.test.js +152 -11
- package/server/lib/llm/generate.js +43 -7
- package/server/lib/llm/generate.test.js +98 -1
- package/server/lib/llm/temp-directory.js +34 -13
- package/server/lib/llm/temp-directory.test.js +57 -0
- 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/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-BwrcqJzu.js +0 -2
- package/dist/assets/MarkdownRenderer-C49bfcHx.js +0 -2
- package/dist/assets/MobileDetailNavigation-LZU2vFAs.js +0 -1
- package/dist/assets/MobileSurface-OAJjAQCZ.js +0 -2
- package/dist/assets/miniChat-BJ9AclZX.js +0 -2
- package/dist/assets/renderMobileApp-C4igZ42L.js +0 -28
- package/dist/assets/runtimeEndpointReset-6lVRspis.js +0 -147
- package/dist/assets/src-B-P5qIBc.css +0 -2
|
@@ -15,7 +15,35 @@ const root = () => fs.mkdtempSync(path.join(os.tmpdir(), 'assistants-'));
|
|
|
15
15
|
// Behavioral tests enable the global switch after boot; pass enabled:false to assert the fresh-install default.
|
|
16
16
|
const setup = (directory = root(), client = {}, options = {}) => {
|
|
17
17
|
const { enabled = true, ...serviceOptions } = options;
|
|
18
|
-
const
|
|
18
|
+
const {
|
|
19
|
+
provider = undefined,
|
|
20
|
+
config = undefined,
|
|
21
|
+
session: sessionOverrides = {},
|
|
22
|
+
...sessionClient
|
|
23
|
+
} = client && typeof client === 'object' ? client : {};
|
|
24
|
+
const service = createAssistantsService({
|
|
25
|
+
dbPath: path.join(directory, 'assistants.sqlite'),
|
|
26
|
+
dataDir: directory,
|
|
27
|
+
getAllowedRoots: () => [directory],
|
|
28
|
+
buildOpenCodeUrl: () => 'http://127.0.0.1:1',
|
|
29
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
30
|
+
clientFactory: () => ({
|
|
31
|
+
session: {
|
|
32
|
+
create: async () => ({ data: { id: crypto.randomUUID() } }),
|
|
33
|
+
get: async () => ({ data: { id: 'present' } }),
|
|
34
|
+
update: async () => ({ data: { id: 'archived' } }),
|
|
35
|
+
promptAsync: async () => ({ data: { info: { id: 'msg_1' } } }),
|
|
36
|
+
summarize: async () => ({ data: true }),
|
|
37
|
+
delete: async () => ({ data: true }),
|
|
38
|
+
...sessionClient,
|
|
39
|
+
...sessionOverrides,
|
|
40
|
+
},
|
|
41
|
+
...(provider ? { provider } : {}),
|
|
42
|
+
...(config ? { config } : {}),
|
|
43
|
+
}),
|
|
44
|
+
runContactTurn: serviceOptions.runContactTurn ?? (async ({ userText }) => ({ text: `reply:${userText}`, bubbles: [`reply:${userText}`] })),
|
|
45
|
+
...serviceOptions,
|
|
46
|
+
});
|
|
19
47
|
if (enabled) {
|
|
20
48
|
const snapshot = service.snapshot();
|
|
21
49
|
if (!snapshot.enabled) service.setEnabled({ enabled: true, expectedRevision: snapshot.revision });
|
|
@@ -83,7 +111,7 @@ describe('assistants service', () => {
|
|
|
83
111
|
const assistant = service.createAssistant(assistantInput);
|
|
84
112
|
const current = await service.ensure(assistant.id);
|
|
85
113
|
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 });
|
|
114
|
+
expect(sent).toEqual({ binding: current, messageID: 'client_1', admitted: true, revision: expect.any(Number) });
|
|
87
115
|
// Admission resolves before the async turn persists assistant bubbles.
|
|
88
116
|
expect(service.contactMessages(assistant.id, { limit: 50 }).messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([
|
|
89
117
|
{ role: 'user', text: 'hello' },
|
|
@@ -512,7 +540,7 @@ describe('assistants service', () => {
|
|
|
512
540
|
const service = setup(root(), { promptAsync: async () => ({ response: { status: 204 } }) }); const assistant = service.createAssistant(assistantInput); const current = await service.ensure(assistant.id);
|
|
513
541
|
expect(await service.compact(assistant.id, current)).toEqual({ binding: current, summarized: true });
|
|
514
542
|
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();
|
|
543
|
+
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
544
|
});
|
|
517
545
|
|
|
518
546
|
it('admits 33-part direct messages and 129-part shares', async () => {
|
|
@@ -1160,6 +1188,352 @@ describe('assistants service', () => {
|
|
|
1160
1188
|
service.close();
|
|
1161
1189
|
});
|
|
1162
1190
|
|
|
1191
|
+
it('same contact-turn assign gate: model re-send creates one worker and one card', async () => {
|
|
1192
|
+
const directory = root();
|
|
1193
|
+
const project = path.join(directory, 'app');
|
|
1194
|
+
fs.mkdirSync(project, { recursive: true });
|
|
1195
|
+
const creates = [];
|
|
1196
|
+
const service = setup(directory, {
|
|
1197
|
+
create: async (input) => {
|
|
1198
|
+
creates.push(input);
|
|
1199
|
+
return { data: { id: `ses_${creates.length}` } };
|
|
1200
|
+
},
|
|
1201
|
+
promptAsync: async () => ({ response: { status: 204 } }),
|
|
1202
|
+
}, {
|
|
1203
|
+
runContactTurn: async ({ tools, userText }) => {
|
|
1204
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
1205
|
+
const args = { prompt: userText, projectPath: project, title: 'Once' };
|
|
1206
|
+
const first = await assign.execute('call_1', args);
|
|
1207
|
+
const second = await assign.execute('call_2', args);
|
|
1208
|
+
const thirdDifferent = await assign.execute('call_3', { ...args, prompt: `${userText} again` });
|
|
1209
|
+
expect(first.terminate).toBe(true);
|
|
1210
|
+
expect(second.terminate).toBe(true);
|
|
1211
|
+
expect(second.details.assigned.sessionID).toBe(first.details.assigned.sessionID);
|
|
1212
|
+
expect(thirdDifferent.details.error).toBe('validation_error');
|
|
1213
|
+
return {
|
|
1214
|
+
text: first.content[0].text,
|
|
1215
|
+
bubbles: [first.content[0].text],
|
|
1216
|
+
cards: first.details.card ? [first.details.card, second.details.card].filter(Boolean) : [],
|
|
1217
|
+
tools,
|
|
1218
|
+
};
|
|
1219
|
+
},
|
|
1220
|
+
});
|
|
1221
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1222
|
+
await settleSend(service, assistant.id, {
|
|
1223
|
+
messageID: 'client_assign_once',
|
|
1224
|
+
parts: [{ type: 'text', text: 'Fix once' }],
|
|
1225
|
+
});
|
|
1226
|
+
expect(creates).toHaveLength(1);
|
|
1227
|
+
const page = service.contactMessages(assistant.id);
|
|
1228
|
+
const cards = page.messages.flatMap((message) => (message.parts || []).filter((part) => part.type === 'card'));
|
|
1229
|
+
expect(cards).toHaveLength(1);
|
|
1230
|
+
expect(cards[0]).toMatchObject({ cardType: 'session', sessionID: 'ses_1' });
|
|
1231
|
+
service.close();
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
it.each([
|
|
1235
|
+
{ error: { status: 404 } },
|
|
1236
|
+
{ data: { info: { id: 'different_message' }, parts: [] } },
|
|
1237
|
+
{ data: { parts: [] } },
|
|
1238
|
+
])('preserves ambiguous admission until lookup identifies the exact message: %j', async (lookupResult) => {
|
|
1239
|
+
const directory = root();
|
|
1240
|
+
const project = path.join(directory, 'app');
|
|
1241
|
+
fs.mkdirSync(project, { recursive: true });
|
|
1242
|
+
const creates = [];
|
|
1243
|
+
const prompts = [];
|
|
1244
|
+
const deletes = [];
|
|
1245
|
+
const lookups = [];
|
|
1246
|
+
let promptCalls = 0;
|
|
1247
|
+
const service = setup(directory, {
|
|
1248
|
+
create: async (input) => {
|
|
1249
|
+
creates.push(input);
|
|
1250
|
+
return { data: { id: 'ses_lookup' } };
|
|
1251
|
+
},
|
|
1252
|
+
promptAsync: async (input) => {
|
|
1253
|
+
prompts.push(input);
|
|
1254
|
+
promptCalls += 1;
|
|
1255
|
+
if (promptCalls === 1) {
|
|
1256
|
+
const error = new TypeError('fetch failed');
|
|
1257
|
+
error.code = 'ECONNRESET';
|
|
1258
|
+
throw error;
|
|
1259
|
+
}
|
|
1260
|
+
return { response: { status: 204 } };
|
|
1261
|
+
},
|
|
1262
|
+
delete: async (input) => {
|
|
1263
|
+
deletes.push(input);
|
|
1264
|
+
return { data: true };
|
|
1265
|
+
},
|
|
1266
|
+
message: async (input) => {
|
|
1267
|
+
lookups.push(input);
|
|
1268
|
+
// Each response leaves the requested message unconfirmed.
|
|
1269
|
+
return lookupResult;
|
|
1270
|
+
},
|
|
1271
|
+
}, {
|
|
1272
|
+
runContactTurn: async ({ tools }) => {
|
|
1273
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
1274
|
+
const first = await assign.execute('call_1', {
|
|
1275
|
+
prompt: 'Fix',
|
|
1276
|
+
projectPath: project,
|
|
1277
|
+
variant: 'fast',
|
|
1278
|
+
});
|
|
1279
|
+
expect(first.details).toMatchObject({
|
|
1280
|
+
error: 'prompt_ambiguous',
|
|
1281
|
+
ambiguous: true,
|
|
1282
|
+
sessionID: 'ses_lookup',
|
|
1283
|
+
});
|
|
1284
|
+
expect(typeof first.details.messageID).toBe('string');
|
|
1285
|
+
expect(deletes).toEqual([]);
|
|
1286
|
+
const second = await assign.execute('call_2', {
|
|
1287
|
+
prompt: 'Fix',
|
|
1288
|
+
projectPath: project,
|
|
1289
|
+
variant: 'fast',
|
|
1290
|
+
});
|
|
1291
|
+
expect(second.details.assigned.sessionID).toBe('ses_lookup');
|
|
1292
|
+
return {
|
|
1293
|
+
text: second.content[0].text,
|
|
1294
|
+
bubbles: [second.content[0].text],
|
|
1295
|
+
cards: second.details.card ? [second.details.card] : [],
|
|
1296
|
+
};
|
|
1297
|
+
},
|
|
1298
|
+
});
|
|
1299
|
+
const assistant = service.createAssistant({ ...assistantInput, variant: 'fast' });
|
|
1300
|
+
await settleSend(service, assistant.id, {
|
|
1301
|
+
messageID: 'client_assign_lookup',
|
|
1302
|
+
parts: [{ type: 'text', text: 'Fix' }],
|
|
1303
|
+
});
|
|
1304
|
+
expect(creates).toHaveLength(1);
|
|
1305
|
+
expect(prompts[0]).toMatchObject({
|
|
1306
|
+
sessionID: 'ses_lookup',
|
|
1307
|
+
variant: 'fast',
|
|
1308
|
+
});
|
|
1309
|
+
// Retry reuses the same worker session (no second create).
|
|
1310
|
+
expect(prompts.length).toBeGreaterThanOrEqual(2);
|
|
1311
|
+
expect(prompts.every((item) => item.sessionID === 'ses_lookup')).toBe(true);
|
|
1312
|
+
expect(lookups.length).toBeGreaterThanOrEqual(1);
|
|
1313
|
+
expect(deletes).toEqual([]);
|
|
1314
|
+
service.close();
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
it('assign forwards current-turn images and explicit worker model into promptAsync without changing the contact', async () => {
|
|
1318
|
+
const directory = root();
|
|
1319
|
+
const project = path.join(directory, 'app');
|
|
1320
|
+
fs.mkdirSync(project, { recursive: true });
|
|
1321
|
+
const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'card.png' };
|
|
1322
|
+
const creates = [];
|
|
1323
|
+
const prompts = [];
|
|
1324
|
+
const deletes = [];
|
|
1325
|
+
const service = setup(directory, {
|
|
1326
|
+
create: async (input) => {
|
|
1327
|
+
creates.push(input);
|
|
1328
|
+
return { data: { id: `ses_${creates.length}` } };
|
|
1329
|
+
},
|
|
1330
|
+
promptAsync: async (input) => {
|
|
1331
|
+
prompts.push(input);
|
|
1332
|
+
return { response: { status: 204 } };
|
|
1333
|
+
},
|
|
1334
|
+
delete: async (input) => {
|
|
1335
|
+
deletes.push(input);
|
|
1336
|
+
return { data: true };
|
|
1337
|
+
},
|
|
1338
|
+
provider: {
|
|
1339
|
+
list: async () => ({ data: { connected: ['xai', 'opencode-go'] } }),
|
|
1340
|
+
},
|
|
1341
|
+
config: {
|
|
1342
|
+
providers: async () => ({
|
|
1343
|
+
data: {
|
|
1344
|
+
providers: [
|
|
1345
|
+
{
|
|
1346
|
+
id: 'xai',
|
|
1347
|
+
name: 'xAI',
|
|
1348
|
+
models: {
|
|
1349
|
+
'grok-4.6': {
|
|
1350
|
+
id: 'grok-4.6',
|
|
1351
|
+
name: 'Grok 4.6',
|
|
1352
|
+
modalities: { input: ['text', 'image'] },
|
|
1353
|
+
},
|
|
1354
|
+
},
|
|
1355
|
+
},
|
|
1356
|
+
{
|
|
1357
|
+
id: 'opencode-go',
|
|
1358
|
+
name: 'OpenCode Go',
|
|
1359
|
+
models: {
|
|
1360
|
+
'deepseek-v4-flash': { id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
|
|
1361
|
+
},
|
|
1362
|
+
},
|
|
1363
|
+
],
|
|
1364
|
+
},
|
|
1365
|
+
}),
|
|
1366
|
+
},
|
|
1367
|
+
}, {
|
|
1368
|
+
runContactTurn: async ({ tools, userText, connectedModels }) => {
|
|
1369
|
+
expect(connectedModels.some((entry) => entry.providerID === 'xai' && entry.modelID === 'grok-4.6' && entry.acceptsImages)).toBe(true);
|
|
1370
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
1371
|
+
const result = await assign.execute('call_1', {
|
|
1372
|
+
prompt: userText,
|
|
1373
|
+
projectPath: project,
|
|
1374
|
+
title: 'Card width',
|
|
1375
|
+
model: 'xai/grok-4.6',
|
|
1376
|
+
});
|
|
1377
|
+
return {
|
|
1378
|
+
text: result.content[0].text,
|
|
1379
|
+
bubbles: [result.content[0].text],
|
|
1380
|
+
cards: result.details.card ? [result.details.card] : [],
|
|
1381
|
+
tools,
|
|
1382
|
+
};
|
|
1383
|
+
},
|
|
1384
|
+
});
|
|
1385
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1386
|
+
expect(assistant.providerID).toBe('p');
|
|
1387
|
+
expect(assistant.modelID).toBe('m');
|
|
1388
|
+
const sent = await settleSend(service, assistant.id, {
|
|
1389
|
+
messageID: 'client_assign_image_model',
|
|
1390
|
+
parts: [{ type: 'text', text: '修移动端卡片宽度' }, image],
|
|
1391
|
+
});
|
|
1392
|
+
expect(sent.settled.status).toBe('complete');
|
|
1393
|
+
expect(creates).toHaveLength(1);
|
|
1394
|
+
expect(prompts).toEqual([expect.objectContaining({
|
|
1395
|
+
sessionID: 'ses_1',
|
|
1396
|
+
model: { providerID: 'xai', modelID: 'grok-4.6' },
|
|
1397
|
+
parts: [
|
|
1398
|
+
{ type: 'text', text: '修移动端卡片宽度' },
|
|
1399
|
+
image,
|
|
1400
|
+
],
|
|
1401
|
+
})]);
|
|
1402
|
+
expect(deletes).toEqual([]);
|
|
1403
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
1404
|
+
id: assistant.id,
|
|
1405
|
+
providerID: 'p',
|
|
1406
|
+
modelID: 'm',
|
|
1407
|
+
});
|
|
1408
|
+
service.close();
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
it('invalid explicit worker model fails closed with no session create', async () => {
|
|
1412
|
+
const directory = root();
|
|
1413
|
+
const project = path.join(directory, 'app');
|
|
1414
|
+
fs.mkdirSync(project, { recursive: true });
|
|
1415
|
+
const creates = [];
|
|
1416
|
+
const service = setup(directory, {
|
|
1417
|
+
create: async (input) => {
|
|
1418
|
+
creates.push(input);
|
|
1419
|
+
return { data: { id: `ses_${creates.length}` } };
|
|
1420
|
+
},
|
|
1421
|
+
promptAsync: async () => ({ response: { status: 204 } }),
|
|
1422
|
+
provider: {
|
|
1423
|
+
list: async () => ({ data: { connected: ['xai'] } }),
|
|
1424
|
+
},
|
|
1425
|
+
config: {
|
|
1426
|
+
providers: async () => ({
|
|
1427
|
+
data: {
|
|
1428
|
+
providers: [{
|
|
1429
|
+
id: 'xai',
|
|
1430
|
+
name: 'xAI',
|
|
1431
|
+
models: { 'grok-4.6': { id: 'grok-4.6', name: 'Grok 4.6', modalities: { input: ['text', 'image'] } } },
|
|
1432
|
+
}],
|
|
1433
|
+
},
|
|
1434
|
+
}),
|
|
1435
|
+
},
|
|
1436
|
+
}, {
|
|
1437
|
+
runContactTurn: async ({ tools }) => {
|
|
1438
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
1439
|
+
const result = await assign.execute('call_1', {
|
|
1440
|
+
prompt: 'Fix width',
|
|
1441
|
+
projectPath: project,
|
|
1442
|
+
model: 'missing/not-real',
|
|
1443
|
+
});
|
|
1444
|
+
expect(result.details.error).toBe('model_not_found');
|
|
1445
|
+
return {
|
|
1446
|
+
text: result.content[0].text,
|
|
1447
|
+
bubbles: [result.content[0].text],
|
|
1448
|
+
cards: [],
|
|
1449
|
+
tools,
|
|
1450
|
+
};
|
|
1451
|
+
},
|
|
1452
|
+
});
|
|
1453
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1454
|
+
await settleSend(service, assistant.id, {
|
|
1455
|
+
messageID: 'client_assign_bad_model',
|
|
1456
|
+
parts: [{ type: 'text', text: '用 missing 模型建会话' }],
|
|
1457
|
+
});
|
|
1458
|
+
expect(creates).toEqual([]);
|
|
1459
|
+
expect(service.snapshot().assistants[0]).toMatchObject({ providerID: 'p', modelID: 'm' });
|
|
1460
|
+
service.close();
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
it('cleans up the worker session when create succeeds but promptAsync fails definitively (4xx)', async () => {
|
|
1464
|
+
const directory = root();
|
|
1465
|
+
const project = path.join(directory, 'app');
|
|
1466
|
+
fs.mkdirSync(project, { recursive: true });
|
|
1467
|
+
const creates = [];
|
|
1468
|
+
const deletes = [];
|
|
1469
|
+
const service = setup(directory, {
|
|
1470
|
+
create: async (input) => {
|
|
1471
|
+
creates.push(input);
|
|
1472
|
+
return { data: { id: 'ses_fail_prompt' } };
|
|
1473
|
+
},
|
|
1474
|
+
promptAsync: async () => ({ error: { status: 400 }, response: { status: 400 } }),
|
|
1475
|
+
delete: async (input) => {
|
|
1476
|
+
deletes.push(input);
|
|
1477
|
+
return { data: true };
|
|
1478
|
+
},
|
|
1479
|
+
}, {
|
|
1480
|
+
runContactTurn: async ({ tools, userText }) => {
|
|
1481
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
1482
|
+
const result = await assign.execute('call_1', { prompt: userText, projectPath: project });
|
|
1483
|
+
expect(result.details.error).toBe('upstream_error');
|
|
1484
|
+
return {
|
|
1485
|
+
text: result.content[0].text,
|
|
1486
|
+
bubbles: [result.content[0].text],
|
|
1487
|
+
cards: [],
|
|
1488
|
+
tools,
|
|
1489
|
+
};
|
|
1490
|
+
},
|
|
1491
|
+
});
|
|
1492
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1493
|
+
await settleSend(service, assistant.id, {
|
|
1494
|
+
messageID: 'client_assign_prompt_fail',
|
|
1495
|
+
parts: [{ type: 'text', text: 'Fix after create' }],
|
|
1496
|
+
});
|
|
1497
|
+
expect(creates).toHaveLength(1);
|
|
1498
|
+
expect(deletes).toEqual([expect.objectContaining({ sessionID: 'ses_fail_prompt' })]);
|
|
1499
|
+
service.close();
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
it('keeps default-model assign behavior when no explicit worker model is requested', async () => {
|
|
1503
|
+
const directory = root();
|
|
1504
|
+
const project = path.join(directory, 'app');
|
|
1505
|
+
fs.mkdirSync(project, { recursive: true });
|
|
1506
|
+
const prompts = [];
|
|
1507
|
+
const service = setup(directory, {
|
|
1508
|
+
create: async () => ({ data: { id: 'ses_default' } }),
|
|
1509
|
+
promptAsync: async (input) => {
|
|
1510
|
+
prompts.push(input);
|
|
1511
|
+
return { response: { status: 204 } };
|
|
1512
|
+
},
|
|
1513
|
+
}, {
|
|
1514
|
+
runContactTurn: async ({ tools, userText }) => {
|
|
1515
|
+
const assign = tools.find((tool) => tool.name === 'assign_session');
|
|
1516
|
+
const result = await assign.execute('call_1', { prompt: userText, projectPath: project });
|
|
1517
|
+
return {
|
|
1518
|
+
text: result.content[0].text,
|
|
1519
|
+
bubbles: [result.content[0].text],
|
|
1520
|
+
cards: result.details.card ? [result.details.card] : [],
|
|
1521
|
+
tools,
|
|
1522
|
+
};
|
|
1523
|
+
},
|
|
1524
|
+
});
|
|
1525
|
+
const assistant = service.createAssistant(assistantInput);
|
|
1526
|
+
await settleSend(service, assistant.id, {
|
|
1527
|
+
messageID: 'client_assign_default_model',
|
|
1528
|
+
parts: [{ type: 'text', text: '建会话修一下' }],
|
|
1529
|
+
});
|
|
1530
|
+
expect(prompts).toEqual([expect.objectContaining({
|
|
1531
|
+
model: { providerID: 'p', modelID: 'm' },
|
|
1532
|
+
parts: [{ type: 'text', text: '建会话修一下' }],
|
|
1533
|
+
})]);
|
|
1534
|
+
service.close();
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1163
1537
|
it('creates another assistant from create_assistant and persists the assistant card', async () => {
|
|
1164
1538
|
const directory = root();
|
|
1165
1539
|
const service = setup(directory, {}, {
|
|
@@ -1533,7 +1907,9 @@ describe('assistants service', () => {
|
|
|
1533
1907
|
});
|
|
1534
1908
|
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
1535
1909
|
assignedSessionIDs: ['ses_work'],
|
|
1536
|
-
|
|
1910
|
+
// assigned busy keeps the session card in flight; list green dot is contact-turn only
|
|
1911
|
+
working: false,
|
|
1912
|
+
activeContactTurn: null,
|
|
1537
1913
|
});
|
|
1538
1914
|
expect(service.processEvent({ type: 'session.idle', properties: { sessionID: 'ses_work' } })).toBe(true);
|
|
1539
1915
|
const page = service.contactMessages(assistant.id);
|
|
@@ -1566,7 +1942,8 @@ describe('assistants service', () => {
|
|
|
1566
1942
|
});
|
|
1567
1943
|
expect(first.snapshot().assistants[0]).toMatchObject({
|
|
1568
1944
|
assignedSessionIDs: ['ses_missed'],
|
|
1569
|
-
working:
|
|
1945
|
+
working: false,
|
|
1946
|
+
activeContactTurn: null,
|
|
1570
1947
|
});
|
|
1571
1948
|
first.close();
|
|
1572
1949
|
|
|
@@ -1604,7 +1981,8 @@ describe('assistants service', () => {
|
|
|
1604
1981
|
title: 'Follow-up',
|
|
1605
1982
|
status: 'busy',
|
|
1606
1983
|
});
|
|
1607
|
-
expect(service.snapshot().assistants[0].working).toBe(
|
|
1984
|
+
expect(service.snapshot().assistants[0].working).toBe(false);
|
|
1985
|
+
expect(service.snapshot().assistants[0].assignedSessionIDs).toEqual(['ses_timer']);
|
|
1608
1986
|
await tick();
|
|
1609
1987
|
page = service.contactMessages(assistant.id);
|
|
1610
1988
|
expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card' && part.sessionID === 'ses_timer'))?.parts[0].status).toBe('complete');
|
|
@@ -1708,7 +2086,7 @@ describe('assistants service', () => {
|
|
|
1708
2086
|
});
|
|
1709
2087
|
expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(1);
|
|
1710
2088
|
expect(service.snapshot().assistants[0].assignedSessionIDs.sort()).toEqual(['ses_busy', 'ses_down']);
|
|
1711
|
-
expect(service.snapshot().assistants[0].working).toBe(
|
|
2089
|
+
expect(service.snapshot().assistants[0].working).toBe(false);
|
|
1712
2090
|
service.close();
|
|
1713
2091
|
});
|
|
1714
2092
|
|
|
@@ -1954,11 +2332,24 @@ describe('assistants service', () => {
|
|
|
1954
2332
|
binding: expect.objectContaining({ sessionID: null, sessionGeneration: 0 }),
|
|
1955
2333
|
messageID: 'turn_stream_1',
|
|
1956
2334
|
admitted: true,
|
|
2335
|
+
revision: expect.any(Number),
|
|
1957
2336
|
});
|
|
1958
2337
|
expect(sent).not.toHaveProperty('settled');
|
|
2338
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
2339
|
+
working: true,
|
|
2340
|
+
activeContactTurn: expect.objectContaining({
|
|
2341
|
+
turnID: 'turn_stream_1',
|
|
2342
|
+
messageID: 'turn_stream_1',
|
|
2343
|
+
status: expect.stringMatching(/^(queued|running)$/),
|
|
2344
|
+
}),
|
|
2345
|
+
});
|
|
1959
2346
|
const settled = await service.whenContactTurnSettled('turn_stream_1');
|
|
1960
2347
|
expect(settled).toMatchObject({ status: 'complete' });
|
|
1961
2348
|
await new Promise((resolve) => setImmediate(resolve));
|
|
2349
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
2350
|
+
working: false,
|
|
2351
|
+
activeContactTurn: null,
|
|
2352
|
+
});
|
|
1962
2353
|
const types = events.map((event) => event.type);
|
|
1963
2354
|
expect(types[0]).toBe('openchamber:contact-turn-start');
|
|
1964
2355
|
expect(events[0].properties).toMatchObject({
|
|
@@ -1991,6 +2382,179 @@ describe('assistants service', () => {
|
|
|
1991
2382
|
service.close();
|
|
1992
2383
|
});
|
|
1993
2384
|
|
|
2385
|
+
it('replays same messageID+payload admission once and conflicts on payload mismatch', async () => {
|
|
2386
|
+
const directory = root();
|
|
2387
|
+
let release;
|
|
2388
|
+
const blocked = new Promise((resolve) => { release = resolve; });
|
|
2389
|
+
let runs = 0;
|
|
2390
|
+
const service = setup(directory, {}, {
|
|
2391
|
+
runContactTurn: async () => {
|
|
2392
|
+
runs += 1;
|
|
2393
|
+
await blocked;
|
|
2394
|
+
return { text: 'once', bubbles: ['once'] };
|
|
2395
|
+
},
|
|
2396
|
+
});
|
|
2397
|
+
const assistant = service.createAssistant(assistantInput);
|
|
2398
|
+
const first = await service.send(assistant.id, {
|
|
2399
|
+
messageID: 'idem_1',
|
|
2400
|
+
parts: [{ type: 'text', text: 'same' }],
|
|
2401
|
+
});
|
|
2402
|
+
expect(first).toMatchObject({ admitted: true, messageID: 'idem_1', revision: expect.any(Number) });
|
|
2403
|
+
const replay = await service.send(assistant.id, {
|
|
2404
|
+
messageID: 'idem_1',
|
|
2405
|
+
parts: [{ type: 'text', text: 'same' }],
|
|
2406
|
+
});
|
|
2407
|
+
expect(replay).toMatchObject({ admitted: true, messageID: 'idem_1', replayed: true });
|
|
2408
|
+
await expect(service.send(assistant.id, {
|
|
2409
|
+
messageID: 'idem_1',
|
|
2410
|
+
parts: [{ type: 'text', text: 'different' }],
|
|
2411
|
+
})).rejects.toMatchObject({ code: 'idempotency_conflict' });
|
|
2412
|
+
release();
|
|
2413
|
+
await service.whenContactTurnSettled('idem_1');
|
|
2414
|
+
expect(runs).toBe(1);
|
|
2415
|
+
const after = await service.send(assistant.id, {
|
|
2416
|
+
messageID: 'idem_1',
|
|
2417
|
+
parts: [{ type: 'text', text: 'same' }],
|
|
2418
|
+
});
|
|
2419
|
+
expect(after).toMatchObject({ admitted: true, replayed: true });
|
|
2420
|
+
expect(runs).toBe(1);
|
|
2421
|
+
service.close();
|
|
2422
|
+
});
|
|
2423
|
+
|
|
2424
|
+
it('persists a durable contact error bubble so query recovery works without SSE', async () => {
|
|
2425
|
+
const service = setup(root(), {}, {
|
|
2426
|
+
runContactTurn: async () => {
|
|
2427
|
+
const error = new Error('No connected model');
|
|
2428
|
+
error.code = 'no_provider';
|
|
2429
|
+
throw error;
|
|
2430
|
+
},
|
|
2431
|
+
});
|
|
2432
|
+
const assistant = service.createAssistant(assistantInput);
|
|
2433
|
+
await service.send(assistant.id, {
|
|
2434
|
+
messageID: 'fail_persist_1',
|
|
2435
|
+
parts: [{ type: 'text', text: 'hi' }],
|
|
2436
|
+
});
|
|
2437
|
+
await service.whenContactTurnSettled('fail_persist_1');
|
|
2438
|
+
const page = service.contactMessages(assistant.id);
|
|
2439
|
+
expect(page.messages.map((message) => ({ role: message.role, status: message.status, text: message.text }))).toEqual([
|
|
2440
|
+
{ role: 'user', status: 'complete', text: 'hi' },
|
|
2441
|
+
{ role: 'assistant', status: 'error', text: 'No connected model' },
|
|
2442
|
+
]);
|
|
2443
|
+
expect(service.snapshot().assistants[0]).toMatchObject({ working: false, activeContactTurn: null });
|
|
2444
|
+
service.close();
|
|
2445
|
+
});
|
|
2446
|
+
|
|
2447
|
+
it('bumps revision when queued contact turn becomes running', async () => {
|
|
2448
|
+
const directory = root();
|
|
2449
|
+
let release;
|
|
2450
|
+
const blocked = new Promise((resolve) => { release = resolve; });
|
|
2451
|
+
const tips = [];
|
|
2452
|
+
const service = setup(directory, {}, {
|
|
2453
|
+
onRevisionTip: (tip) => tips.push(tip),
|
|
2454
|
+
runContactTurn: async () => {
|
|
2455
|
+
await blocked;
|
|
2456
|
+
return { text: 'ok', bubbles: ['ok'] };
|
|
2457
|
+
},
|
|
2458
|
+
});
|
|
2459
|
+
const assistant = service.createAssistant(assistantInput);
|
|
2460
|
+
const before = service.snapshot().revision;
|
|
2461
|
+
await service.send(assistant.id, {
|
|
2462
|
+
messageID: 'run_bump_1',
|
|
2463
|
+
parts: [{ type: 'text', text: 'hi' }],
|
|
2464
|
+
});
|
|
2465
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
2466
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
2467
|
+
expect(service.snapshot().assistants[0].activeContactTurn?.status).toBe('running');
|
|
2468
|
+
expect(service.snapshot().revision).toBeGreaterThan(before);
|
|
2469
|
+
release();
|
|
2470
|
+
await service.whenContactTurnSettled('run_bump_1');
|
|
2471
|
+
expect(tips.length).toBeGreaterThan(0);
|
|
2472
|
+
service.close();
|
|
2473
|
+
});
|
|
2474
|
+
|
|
2475
|
+
it('keeps snapshot working authoritative until settle and clears it on server process restart', async () => {
|
|
2476
|
+
const directory = root();
|
|
2477
|
+
let releaseTurn;
|
|
2478
|
+
const blocked = new Promise((resolve) => { releaseTurn = resolve; });
|
|
2479
|
+
const service = setup(directory, {}, {
|
|
2480
|
+
runContactTurn: async () => {
|
|
2481
|
+
await blocked;
|
|
2482
|
+
return { text: 'done', bubbles: ['done'] };
|
|
2483
|
+
},
|
|
2484
|
+
});
|
|
2485
|
+
const assistant = service.createAssistant(assistantInput);
|
|
2486
|
+
await service.send(assistant.id, {
|
|
2487
|
+
messageID: 'turn_auth_1',
|
|
2488
|
+
parts: [{ type: 'text', text: 'hold' }],
|
|
2489
|
+
});
|
|
2490
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
2491
|
+
working: true,
|
|
2492
|
+
activeContactTurn: {
|
|
2493
|
+
turnID: 'turn_auth_1',
|
|
2494
|
+
messageID: 'turn_auth_1',
|
|
2495
|
+
status: expect.stringMatching(/^(queued|running)$/),
|
|
2496
|
+
admittedAt: expect.any(Number),
|
|
2497
|
+
},
|
|
2498
|
+
});
|
|
2499
|
+
// Same process: snapshot is the APP-restart recovery source.
|
|
2500
|
+
expect(service.snapshot().assistants[0].working).toBe(true);
|
|
2501
|
+
releaseTurn();
|
|
2502
|
+
await service.whenContactTurnSettled('turn_auth_1');
|
|
2503
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
2504
|
+
working: false,
|
|
2505
|
+
activeContactTurn: null,
|
|
2506
|
+
});
|
|
2507
|
+
service.close();
|
|
2508
|
+
|
|
2509
|
+
// New process (server restart): process-local activity is gone — never permanently green.
|
|
2510
|
+
const restarted = setup(directory, {}, {
|
|
2511
|
+
runContactTurn: async () => ({ text: 'again', bubbles: ['again'] }),
|
|
2512
|
+
});
|
|
2513
|
+
expect(restarted.snapshot().assistants[0]).toMatchObject({
|
|
2514
|
+
working: false,
|
|
2515
|
+
activeContactTurn: null,
|
|
2516
|
+
});
|
|
2517
|
+
restarted.close();
|
|
2518
|
+
});
|
|
2519
|
+
|
|
2520
|
+
it('isolates contact working per assistant and clears working on harness error', async () => {
|
|
2521
|
+
const directory = root();
|
|
2522
|
+
let releaseA;
|
|
2523
|
+
const blockedA = new Promise((resolve) => { releaseA = resolve; });
|
|
2524
|
+
const service = setup(directory, {}, {
|
|
2525
|
+
runContactTurn: async ({ userText }) => {
|
|
2526
|
+
if (userText === 'hold-a') {
|
|
2527
|
+
await blockedA;
|
|
2528
|
+
return { text: 'a-done', bubbles: ['a-done'] };
|
|
2529
|
+
}
|
|
2530
|
+
const error = new Error('No connected model');
|
|
2531
|
+
error.code = 'no_provider';
|
|
2532
|
+
throw error;
|
|
2533
|
+
},
|
|
2534
|
+
});
|
|
2535
|
+
const first = service.createAssistant({ ...assistantInput, name: 'A' });
|
|
2536
|
+
const second = service.createAssistant({ ...assistantInput, name: 'B' });
|
|
2537
|
+
await service.send(first.id, {
|
|
2538
|
+
messageID: 'turn_a',
|
|
2539
|
+
parts: [{ type: 'text', text: 'hold-a' }],
|
|
2540
|
+
});
|
|
2541
|
+
await service.send(second.id, {
|
|
2542
|
+
messageID: 'turn_b',
|
|
2543
|
+
parts: [{ type: 'text', text: 'fail-b' }],
|
|
2544
|
+
});
|
|
2545
|
+
const snap = () => Object.fromEntries(service.snapshot().assistants.map((item) => [item.id, item]));
|
|
2546
|
+
expect(snap()[first.id].working).toBe(true);
|
|
2547
|
+
expect(snap()[first.id].activeContactTurn?.turnID).toBe('turn_a');
|
|
2548
|
+
await service.whenContactTurnSettled('turn_b');
|
|
2549
|
+
expect(snap()[second.id].working).toBe(false);
|
|
2550
|
+
expect(snap()[second.id].activeContactTurn).toBeNull();
|
|
2551
|
+
expect(snap()[first.id].working).toBe(true);
|
|
2552
|
+
releaseA();
|
|
2553
|
+
await service.whenContactTurnSettled('turn_a');
|
|
2554
|
+
expect(snap()[first.id].working).toBe(false);
|
|
2555
|
+
service.close();
|
|
2556
|
+
});
|
|
2557
|
+
|
|
1994
2558
|
it('admits the user then broadcasts turn-end error when the harness fails', async () => {
|
|
1995
2559
|
const events = [];
|
|
1996
2560
|
const service = setup(root(), {}, {
|
|
@@ -2008,17 +2572,25 @@ describe('assistants service', () => {
|
|
|
2008
2572
|
});
|
|
2009
2573
|
expect(sent).toMatchObject({ admitted: true, messageID: 'client_no_provider' });
|
|
2010
2574
|
expect(service.contactMessages(assistant.id).messages.map((message) => message.role)).toEqual(['user']);
|
|
2575
|
+
expect(service.snapshot().assistants[0].working).toBe(true);
|
|
2011
2576
|
const settled = await service.whenContactTurnSettled('client_no_provider');
|
|
2012
2577
|
expect(settled).toMatchObject({ status: 'error', code: 'no_provider' });
|
|
2013
2578
|
await new Promise((resolve) => setImmediate(resolve));
|
|
2579
|
+
expect(service.snapshot().assistants[0]).toMatchObject({
|
|
2580
|
+
working: false,
|
|
2581
|
+
activeContactTurn: null,
|
|
2582
|
+
});
|
|
2014
2583
|
expect(events.some((event) => event.type === 'openchamber:contact-turn-start')).toBe(true);
|
|
2015
2584
|
expect(events.some((event) => (
|
|
2016
2585
|
event.type === 'openchamber:contact-turn-end'
|
|
2017
2586
|
&& event.properties?.status === 'error'
|
|
2018
2587
|
&& event.properties?.turnID === 'client_no_provider'
|
|
2019
2588
|
))).toBe(true);
|
|
2020
|
-
// User row stays;
|
|
2021
|
-
expect(service.contactMessages(assistant.id).messages.map((message) => message.role)).toEqual([
|
|
2589
|
+
// User row stays; durable error bubble enables query recovery without SSE.
|
|
2590
|
+
expect(service.contactMessages(assistant.id).messages.map((message) => ({ role: message.role, status: message.status }))).toEqual([
|
|
2591
|
+
{ role: 'user', status: 'complete' },
|
|
2592
|
+
{ role: 'assistant', status: 'error' },
|
|
2593
|
+
]);
|
|
2022
2594
|
service.close();
|
|
2023
2595
|
});
|
|
2024
2596
|
|