@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.
Files changed (123) 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-BQO1cBLs.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--takeMXN.js → MarkdownRendererImpl-Peu0GVSQ.js} +1 -1
  8. package/dist/assets/{MarkstreamRendererImpl-DGSqWcPN.js → MarkstreamRendererImpl-B-odd2Gz.js} +1 -1
  9. package/dist/assets/MobileDetailNavigation-CtYke_1C.js +1 -0
  10. package/dist/assets/{MobileShareBridge-D-BUjACe.js → MobileShareBridge-DIfOl8EZ.js} +3 -3
  11. package/dist/assets/MobileSurface-_KoX75Ff.js +2 -0
  12. package/dist/assets/{MultiRunWindow-BNR9ZWmD.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-DGerg-C2.js → SettingsView-BwQpbIZl.js} +11 -11
  16. package/dist/assets/{SettingsWindow-CSpd5JbJ.js → SettingsWindow-DfEGfyr8.js} +1 -1
  17. package/dist/assets/{TerminalView-DS-oUxJr.js → TerminalView-8402vpcO.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-Q1k9-IaY.js → ToolOutputDialog-B5RdkhsG.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-CG7D-3-q.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-Cr-joUEw.js → gitApi-_P0FsoXe.js} +1 -1
  32. package/dist/assets/{input-DFLpqjGR.js → input-BO3r20op.js} +1 -1
  33. package/dist/assets/{insertionBoundaries-X0xbikAA.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-D0L7fIg3.js → main-CLj_WS1o.js} +2 -2
  37. package/dist/assets/{main-BI7fNQoi.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-OcCu_0mG.js → mobile-DvbXUfNS.js} +2 -2
  43. package/dist/assets/{multirun-B0G_BEIN.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-BNOzn1QS.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-BREc3uhX.js → renderElectronMiniChatApp-CkJ51qCh.js} +2 -2
  50. package/dist/assets/renderMobileApp-DcjQPhWw.js +28 -0
  51. package/dist/assets/{runtimeConfig-P8nVMYzE.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-C_9-9ggp.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-CEmOsrr6.js → useAppFontEffects-BIFw36SA.js} +5 -5
  64. package/dist/assets/{useAssistantUIStore-me6s63b2.js → useAssistantUIStore-Cg9bsT4b.js} +1 -1
  65. package/dist/assets/{useDesktopWindowControlsLayout-CxaK-zXh.js → useDesktopWindowControlsLayout-DoygEcN4.js} +1 -1
  66. package/dist/assets/{useEffectiveDirectory-BIwv5r5l.js → useEffectiveDirectory-DJSYYkVp.js} +1 -1
  67. package/dist/assets/{useFontPreferences-BCOBlaAd.js → useFontPreferences-B5_apbBC.js} +1 -1
  68. package/dist/assets/{useMobileNavigationStore-DHm5kZPx.js → useMobileNavigationStore-DuTu0Mzh.js} +5 -5
  69. package/dist/assets/{useSessionAutoCleanup-CPLP9KWH.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-CZ4ZTWEH.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 +21 -6
  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 +216 -25
  87. package/server/lib/assistants/contact-tools.test.js +167 -0
  88. package/server/lib/assistants/contracts.js +2 -2
  89. package/server/lib/assistants/harness.js +20 -3
  90. package/server/lib/assistants/harness.test.js +120 -1
  91. package/server/lib/assistants/pi-tools.js +50 -3
  92. package/server/lib/assistants/pi-tools.test.js +25 -0
  93. package/server/lib/assistants/service.js +320 -27
  94. package/server/lib/assistants/service.test.js +581 -9
  95. package/server/lib/llm/DOCUMENTATION.md +31 -18
  96. package/server/lib/llm/catalog.js +91 -16
  97. package/server/lib/llm/catalog.test.js +152 -11
  98. package/server/lib/llm/generate.js +43 -7
  99. package/server/lib/llm/generate.test.js +98 -1
  100. package/server/lib/llm/temp-directory.js +34 -13
  101. package/server/lib/llm/temp-directory.test.js +57 -0
  102. package/server/lib/notifications/APNS.md +20 -4
  103. package/server/lib/notifications/DOCUMENTATION.md +5 -0
  104. package/server/lib/notifications/apns-runtime.js +136 -18
  105. package/server/lib/notifications/apns-runtime.test.js +128 -0
  106. package/server/lib/notifications/apns-titles.js +21 -1
  107. package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
  108. package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
  109. package/server/lib/notifications/runtime.js +93 -2
  110. package/server/lib/notifications/runtime.test.js +167 -4
  111. package/server/lib/opencode/settings-helpers.js +12 -0
  112. package/server/lib/opencode/settings-runtime.js +16 -0
  113. package/server/lib/opencode/settings-runtime.test.js +21 -0
  114. package/server/lib/scheduled-tasks/runtime.js +21 -0
  115. package/server/lib/scheduled-tasks/runtime.test.js +24 -0
  116. package/dist/assets/AssistantView-BwrcqJzu.js +0 -2
  117. package/dist/assets/MarkdownRenderer-C49bfcHx.js +0 -2
  118. package/dist/assets/MobileDetailNavigation-LZU2vFAs.js +0 -1
  119. package/dist/assets/MobileSurface-OAJjAQCZ.js +0 -2
  120. package/dist/assets/miniChat-BJ9AclZX.js +0 -2
  121. package/dist/assets/renderMobileApp-C4igZ42L.js +0 -28
  122. package/dist/assets/runtimeEndpointReset-6lVRspis.js +0 -147
  123. 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 () => {
@@ -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
- working: true,
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: true,
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(true);
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(true);
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; no assistant bubble on failure.
2021
- 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
+ ]);
2022
2594
  service.close();
2023
2595
  });
2024
2596