@openchambery/web 1.19.12 → 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.
Files changed (115) hide show
  1. package/dist/assets/{AgentAvatar-CX0a1rKE.js → AgentAvatar-CiQIIM0d.js} +1 -1
  2. package/dist/assets/AssistantView-YmeyxehG.js +2 -0
  3. package/dist/assets/{DiagramView-BeUgp_Y0.js → DiagramView-BpZb-G9P.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-CTlNE4Hg.js → FadeInOnReveal-nf6-ZCrs.js} +1 -1
  5. package/dist/assets/{Icon-BYM02QyU.js → Icon-CeKHYyKZ.js} +1 -1
  6. package/dist/assets/MarkdownRenderer-wpeGdciL.js +2 -0
  7. package/dist/assets/{MarkdownRendererImpl-DB4Q3kGa.js → MarkdownRendererImpl-Peu0GVSQ.js} +1 -1
  8. package/dist/assets/{MarkstreamRendererImpl-BFdJzxia.js → MarkstreamRendererImpl-B-odd2Gz.js} +1 -1
  9. package/dist/assets/MobileDetailNavigation-CtYke_1C.js +1 -0
  10. package/dist/assets/{MobileShareBridge-UiOLx0-2.js → MobileShareBridge-DIfOl8EZ.js} +3 -3
  11. package/dist/assets/MobileSurface-_KoX75Ff.js +2 -0
  12. package/dist/assets/{MultiRunWindow-C3p3lj3U.js → MultiRunWindow-BT5Dwgzu.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-DS2nugLX.js → OnboardingScreen-BPusUgFG.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-DUghqFGg.js} +1 -1
  15. package/dist/assets/{SettingsView-BXfKsz0J.js → SettingsView-BwQpbIZl.js} +11 -11
  16. package/dist/assets/{SettingsWindow-8IDuPZA5.js → SettingsWindow-DfEGfyr8.js} +1 -1
  17. package/dist/assets/{TerminalView-BiKCueth.js → TerminalView-8402vpcO.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-BC2F_Fdl.js → ToolOutputDialog-B5RdkhsG.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-B87OLIgc.js → appThemeRegistry-CIZ-shMD.js} +1 -1
  20. package/dist/assets/{button-C0cth9tl.js → button-DFPOdZ0g.js} +1 -1
  21. package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-C05anBwU.js} +1 -1
  22. package/dist/assets/{collapsible-DFT9cE80.js → collapsible-oGbEvbe1.js} +1 -1
  23. package/dist/assets/{context-menu-CAp74NfV.js → context-menu-DGz4_2hs.js} +1 -1
  24. package/dist/assets/{desktopHosts-Dc2PS66M.js → desktopHosts-BVJXQq1S.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-T9y0orqa.js → desktopRelayRestore-RDfuj1M7.js} +1 -1
  26. package/dist/assets/{diagram-BryzMuta.js → diagram-Csk5GpDC.js} +1 -1
  27. package/dist/assets/{dialog-CgbV5ljg.js → dialog-rrqB-H6q.js} +1 -1
  28. package/dist/assets/{es-BHqnSjoZ.js → es-FE0sJ9EC.js} +1 -1
  29. package/dist/assets/{fileQueries-BY-9OWzp.js → fileQueries-BcdWOPlD.js} +1 -1
  30. package/dist/assets/{fr-BfrlJY9i.js → fr-DNDCSfGt.js} +1 -1
  31. package/dist/assets/{gitApi-tTtHQVA3.js → gitApi-_P0FsoXe.js} +1 -1
  32. package/dist/assets/{input-DFLpqjGR.js → input-BO3r20op.js} +1 -1
  33. package/dist/assets/{insertionBoundaries-CCQpZ5zn.js → insertionBoundaries-CWoioTdE.js} +1 -1
  34. package/dist/assets/{ja-DP-7qt-x.js → ja-BDwrk0cM.js} +1 -1
  35. package/dist/assets/{ko-E2B5vGQP.js → ko-BeeU2o4I.js} +1 -1
  36. package/dist/assets/{main-Dkg31fRY.js → main-CLj_WS1o.js} +2 -2
  37. package/dist/assets/{main-C_PUoT3E.js → main-CnSZ89pD.js} +4 -4
  38. package/dist/assets/{markdown-worker-CTsrtpPh.js → markdown-worker-CZWE1F1M.js} +1 -1
  39. package/dist/assets/{markdownHeightCache-CYDlisxr.js → markdownHeightCache-Cdd3hkMl.js} +1 -1
  40. package/dist/assets/{markdownTheme-DyJ4Gfcj.js → markdownTheme-DIB8WVmz.js} +1 -1
  41. package/dist/assets/miniChat-B9c3y2pD.js +2 -0
  42. package/dist/assets/{mobile-Z_X7CbzB.js → mobile-DvbXUfNS.js} +2 -2
  43. package/dist/assets/{multirun-B0V3hdnD.js → multirun-CxhTsmRq.js} +1 -1
  44. package/dist/assets/{persistence-DdUac14I.js → persistence-CVTWFaqe.js} +2 -2
  45. package/dist/assets/{pl-r8X5gRM5.js → pl-BYuzpcI5.js} +1 -1
  46. package/dist/assets/{platform-B4KKLnZ5.js → platform-BY_Gtsqr.js} +1 -1
  47. package/dist/assets/{projectMeta-DRW9MQ4z.js → projectMeta-Dl5iPCB6.js} +1 -1
  48. package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-Bn7cwEzv.js} +1 -1
  49. package/dist/assets/{renderElectronMiniChatApp-Dn1Su1dd.js → renderElectronMiniChatApp-CkJ51qCh.js} +2 -2
  50. package/dist/assets/renderMobileApp-DcjQPhWw.js +28 -0
  51. package/dist/assets/{runtimeConfig-C6matIEB.js → runtimeConfig-Bsk2kWsW.js} +1 -1
  52. package/dist/assets/runtimeEndpointReset-BBLFgl85.js +147 -0
  53. package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DDVT7O5J.js} +1 -1
  54. package/dist/assets/{sessionLookup-B6P_aCun.js → sessionLookup-C8VZn0gJ.js} +1 -1
  55. package/dist/assets/{sortable-tabs-strip-C8_9kHqu.js → sortable-tabs-strip-s7UUxsa6.js} +1 -1
  56. package/dist/assets/src-BpHDMgud.css +2 -0
  57. package/dist/assets/{store-BDFdnk6l.js → store-n94dSb3M.js} +2 -2
  58. package/dist/assets/{terminalApi-947l7Ra3.js → terminalApi-C54_rKEK.js} +1 -1
  59. package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BjX-KhMN.js} +1 -1
  60. package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-CQGqGAG_.js} +1 -1
  61. package/dist/assets/{uk-Di0vl9V-.js → uk-CwpbHEa3.js} +1 -1
  62. package/dist/assets/{url-Pk6tQ1EZ.js → url-BMjqccCo.js} +1 -1
  63. package/dist/assets/{useAppFontEffects-Dmes1chr.js → useAppFontEffects-BIFw36SA.js} +5 -5
  64. package/dist/assets/{useAssistantUIStore-me6s63b2.js → useAssistantUIStore-Cg9bsT4b.js} +1 -1
  65. package/dist/assets/{useDesktopWindowControlsLayout-DuZ6URPR.js → useDesktopWindowControlsLayout-DoygEcN4.js} +1 -1
  66. package/dist/assets/{useEffectiveDirectory-CU2ChP0J.js → useEffectiveDirectory-DJSYYkVp.js} +1 -1
  67. package/dist/assets/{useFontPreferences-BCOBlaAd.js → useFontPreferences-B5_apbBC.js} +1 -1
  68. package/dist/assets/{useMobileNavigationStore-C8_J-qTp.js → useMobileNavigationStore-DuTu0Mzh.js} +5 -5
  69. package/dist/assets/{useSessionAutoCleanup-FOQeu1_T.js → useSessionAutoCleanup-C-L9teva.js} +1 -1
  70. package/dist/assets/{useUIStore-CuaA8-Hp.js → useUIStore-DwpQTjLO.js} +1 -1
  71. package/dist/assets/{useWorkerHighlightedLines-GBqKnki1.js → useWorkerHighlightedLines-DxTfgdEi.js} +1 -1
  72. package/dist/assets/{utils-BiK3tBGM.js → utils-81nUI_xp.js} +1 -1
  73. package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-BpzFZ-LU.js} +1 -1
  74. package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-Bopgc5Eo.js} +1 -1
  75. package/dist/index.html +9 -9
  76. package/dist/mini-chat.html +9 -9
  77. package/dist/mobile.html +9 -9
  78. package/package.json +1 -1
  79. package/server/index.js +19 -0
  80. package/server/lib/assistants/DOCUMENTATION.md +17 -4
  81. package/server/lib/assistants/assign.js +455 -30
  82. package/server/lib/assistants/assign.test.js +351 -0
  83. package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
  84. package/server/lib/assistants/contact-store.js +64 -0
  85. package/server/lib/assistants/contact-store.test.js +12 -0
  86. package/server/lib/assistants/contact-tools.js +108 -9
  87. package/server/lib/assistants/contact-tools.test.js +111 -0
  88. package/server/lib/assistants/contracts.js +2 -2
  89. package/server/lib/assistants/harness.js +4 -1
  90. package/server/lib/assistants/service.js +320 -27
  91. package/server/lib/assistants/service.test.js +538 -9
  92. package/server/lib/llm/catalog.js +91 -16
  93. package/server/lib/llm/catalog.test.js +152 -11
  94. package/server/lib/notifications/APNS.md +20 -4
  95. package/server/lib/notifications/DOCUMENTATION.md +5 -0
  96. package/server/lib/notifications/apns-runtime.js +136 -18
  97. package/server/lib/notifications/apns-runtime.test.js +128 -0
  98. package/server/lib/notifications/apns-titles.js +21 -1
  99. package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
  100. package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
  101. package/server/lib/notifications/runtime.js +93 -2
  102. package/server/lib/notifications/runtime.test.js +167 -4
  103. package/server/lib/opencode/settings-helpers.js +12 -0
  104. package/server/lib/opencode/settings-runtime.js +16 -0
  105. package/server/lib/opencode/settings-runtime.test.js +21 -0
  106. package/server/lib/scheduled-tasks/runtime.js +21 -0
  107. package/server/lib/scheduled-tasks/runtime.test.js +24 -0
  108. package/dist/assets/AssistantView-DxIghtun.js +0 -2
  109. package/dist/assets/MarkdownRenderer-C4MGa_kQ.js +0 -2
  110. package/dist/assets/MobileDetailNavigation-B_RwWOwv.js +0 -1
  111. package/dist/assets/MobileSurface-CNChj_dX.js +0 -2
  112. package/dist/assets/miniChat-C9QMj8T8.js +0 -2
  113. package/dist/assets/renderMobileApp-BG1JJt1Q.js +0 -28
  114. package/dist/assets/runtimeEndpointReset-kgwmUJDb.js +0 -147
  115. 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 service = createAssistantsService({ dbPath: path.join(directory, 'assistants.sqlite'), dataDir: directory, getAllowedRoots: () => [directory], buildOpenCodeUrl: () => 'http://127.0.0.1:1', getOpenCodeAuthHeaders: () => ({}), clientFactory: () => ({ session: { create: async () => ({ data: { id: crypto.randomUUID() } }), get: async () => ({ data: { id: 'present' } }), update: async () => ({ data: { id: 'archived' } }), promptAsync: async () => ({ data: { info: { id: 'msg_1' } } }), summarize: async () => ({ data: true }), ...client } }), runContactTurn: serviceOptions.runContactTurn ?? (async ({ userText }) => ({ text: `reply:${userText}`, bubbles: [`reply:${userText}`] })), ...serviceOptions });
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 () => {
@@ -1203,6 +1231,309 @@ describe('assistants service', () => {
1203
1231
  service.close();
1204
1232
  });
1205
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
+
1206
1537
  it('creates another assistant from create_assistant and persists the assistant card', async () => {
1207
1538
  const directory = root();
1208
1539
  const service = setup(directory, {}, {
@@ -1576,7 +1907,9 @@ describe('assistants service', () => {
1576
1907
  });
1577
1908
  expect(service.snapshot().assistants[0]).toMatchObject({
1578
1909
  assignedSessionIDs: ['ses_work'],
1579
- working: true,
1910
+ // assigned busy keeps the session card in flight; list green dot is contact-turn only
1911
+ working: false,
1912
+ activeContactTurn: null,
1580
1913
  });
1581
1914
  expect(service.processEvent({ type: 'session.idle', properties: { sessionID: 'ses_work' } })).toBe(true);
1582
1915
  const page = service.contactMessages(assistant.id);
@@ -1609,7 +1942,8 @@ describe('assistants service', () => {
1609
1942
  });
1610
1943
  expect(first.snapshot().assistants[0]).toMatchObject({
1611
1944
  assignedSessionIDs: ['ses_missed'],
1612
- working: true,
1945
+ working: false,
1946
+ activeContactTurn: null,
1613
1947
  });
1614
1948
  first.close();
1615
1949
 
@@ -1647,7 +1981,8 @@ describe('assistants service', () => {
1647
1981
  title: 'Follow-up',
1648
1982
  status: 'busy',
1649
1983
  });
1650
- expect(service.snapshot().assistants[0].working).toBe(true);
1984
+ expect(service.snapshot().assistants[0].working).toBe(false);
1985
+ expect(service.snapshot().assistants[0].assignedSessionIDs).toEqual(['ses_timer']);
1651
1986
  await tick();
1652
1987
  page = service.contactMessages(assistant.id);
1653
1988
  expect(page.messages.find((message) => message.parts.some((part) => part.type === 'card' && part.sessionID === 'ses_timer'))?.parts[0].status).toBe('complete');
@@ -1751,7 +2086,7 @@ describe('assistants service', () => {
1751
2086
  });
1752
2087
  expect(service.contactMessages(assistant.id).messages.filter((message) => message.text === 'oc.settle.complete')).toHaveLength(1);
1753
2088
  expect(service.snapshot().assistants[0].assignedSessionIDs.sort()).toEqual(['ses_busy', 'ses_down']);
1754
- expect(service.snapshot().assistants[0].working).toBe(true);
2089
+ expect(service.snapshot().assistants[0].working).toBe(false);
1755
2090
  service.close();
1756
2091
  });
1757
2092
 
@@ -1997,11 +2332,24 @@ describe('assistants service', () => {
1997
2332
  binding: expect.objectContaining({ sessionID: null, sessionGeneration: 0 }),
1998
2333
  messageID: 'turn_stream_1',
1999
2334
  admitted: true,
2335
+ revision: expect.any(Number),
2000
2336
  });
2001
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
+ });
2002
2346
  const settled = await service.whenContactTurnSettled('turn_stream_1');
2003
2347
  expect(settled).toMatchObject({ status: 'complete' });
2004
2348
  await new Promise((resolve) => setImmediate(resolve));
2349
+ expect(service.snapshot().assistants[0]).toMatchObject({
2350
+ working: false,
2351
+ activeContactTurn: null,
2352
+ });
2005
2353
  const types = events.map((event) => event.type);
2006
2354
  expect(types[0]).toBe('openchamber:contact-turn-start');
2007
2355
  expect(events[0].properties).toMatchObject({
@@ -2034,6 +2382,179 @@ describe('assistants service', () => {
2034
2382
  service.close();
2035
2383
  });
2036
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
+
2037
2558
  it('admits the user then broadcasts turn-end error when the harness fails', async () => {
2038
2559
  const events = [];
2039
2560
  const service = setup(root(), {}, {
@@ -2051,17 +2572,25 @@ describe('assistants service', () => {
2051
2572
  });
2052
2573
  expect(sent).toMatchObject({ admitted: true, messageID: 'client_no_provider' });
2053
2574
  expect(service.contactMessages(assistant.id).messages.map((message) => message.role)).toEqual(['user']);
2575
+ expect(service.snapshot().assistants[0].working).toBe(true);
2054
2576
  const settled = await service.whenContactTurnSettled('client_no_provider');
2055
2577
  expect(settled).toMatchObject({ status: 'error', code: 'no_provider' });
2056
2578
  await new Promise((resolve) => setImmediate(resolve));
2579
+ expect(service.snapshot().assistants[0]).toMatchObject({
2580
+ working: false,
2581
+ activeContactTurn: null,
2582
+ });
2057
2583
  expect(events.some((event) => event.type === 'openchamber:contact-turn-start')).toBe(true);
2058
2584
  expect(events.some((event) => (
2059
2585
  event.type === 'openchamber:contact-turn-end'
2060
2586
  && event.properties?.status === 'error'
2061
2587
  && event.properties?.turnID === 'client_no_provider'
2062
2588
  ))).toBe(true);
2063
- // User row stays; no assistant bubble on failure.
2064
- expect(service.contactMessages(assistant.id).messages.map((message) => message.role)).toEqual(['user']);
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
+ ]);
2065
2594
  service.close();
2066
2595
  });
2067
2596