@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
@@ -6,8 +6,16 @@ import {
6
6
  ASSIGN_CODES,
7
7
  PROJECT_REQUIRED_MESSAGE,
8
8
  assignSession,
9
+ attachmentScopeKey,
10
+ buildAssignParts,
11
+ hasAssignImageParts,
12
+ isAmbiguousPromptFailure,
9
13
  isManagedAssistantWorkspace,
14
+ requireProvidedString,
10
15
  resolveAssignDirectory,
16
+ resolveAssignWorkerModel,
17
+ resolveAssignWorkerVariant,
18
+ sanitizeAssignFileParts,
11
19
  } from './assign.js';
12
20
 
13
21
  const root = () => fs.mkdtempSync(path.join(os.tmpdir(), 'assign-'));
@@ -54,6 +62,157 @@ describe('resolveAssignDirectory', () => {
54
62
  });
55
63
  });
56
64
 
65
+ describe('requireProvidedString / resolveAssignWorkerModel', () => {
66
+ const catalog = {
67
+ models: [
68
+ { providerID: 'openai', modelID: 'gpt-4o', name: 'GPT-4o', acceptsImages: true },
69
+ { providerID: 'xai', modelID: 'grok-4.6', name: 'Grok 4.6', acceptsImages: true },
70
+ { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', name: 'deepseek-v4-flash', acceptsImages: false },
71
+ { providerID: 'other', modelID: 'deepseek-v4-flash', name: 'clone', acceptsImages: false },
72
+ ],
73
+ };
74
+
75
+ it('defaults to the assistant model without requiring catalog', () => {
76
+ expect(resolveAssignWorkerModel({
77
+ fallback: { providerID: 'p', modelID: 'm' },
78
+ })).toEqual({
79
+ providerID: 'p',
80
+ modelID: 'm',
81
+ source: 'assistant',
82
+ name: null,
83
+ acceptsImages: null,
84
+ });
85
+ });
86
+
87
+ it('resolves explicit provider/model against the connected catalog', () => {
88
+ expect(resolveAssignWorkerModel({
89
+ model: 'xai/grok-4.6',
90
+ fallback: { providerID: 'p', modelID: 'm' },
91
+ catalog,
92
+ })).toMatchObject({
93
+ providerID: 'xai',
94
+ modelID: 'grok-4.6',
95
+ source: 'explicit',
96
+ acceptsImages: true,
97
+ });
98
+ });
99
+
100
+ it('fails closed for missing or ambiguous model names', () => {
101
+ expect(() => resolveAssignWorkerModel({
102
+ model: 'missing-model',
103
+ fallback: { providerID: 'p', modelID: 'm' },
104
+ catalog,
105
+ })).toThrowError(expect.objectContaining({ code: ASSIGN_CODES.MODEL_NOT_FOUND }));
106
+ expect(() => resolveAssignWorkerModel({
107
+ modelID: 'deepseek-v4-flash',
108
+ fallback: { providerID: 'p', modelID: 'm' },
109
+ catalog,
110
+ })).toThrowError(expect.objectContaining({ code: ASSIGN_CODES.MODEL_AMBIGUOUS }));
111
+ expect(() => resolveAssignWorkerModel({
112
+ model: 'xai/grok-4.6',
113
+ fallback: { providerID: 'p', modelID: 'm' },
114
+ catalog: null,
115
+ })).toThrowError(expect.objectContaining({ code: ASSIGN_CODES.UPSTREAM }));
116
+ });
117
+
118
+ it('fails closed on illegal provided explicit model fields (no silent null fallback)', () => {
119
+ expect(() => requireProvidedString({ x: 1 }, { field: 'model', max: 512 })).toThrowError(
120
+ expect.objectContaining({ code: ASSIGN_CODES.VALIDATION }),
121
+ );
122
+ expect(() => requireProvidedString('x'.repeat(513), { field: 'model', max: 512 })).toThrowError(
123
+ expect.objectContaining({ code: ASSIGN_CODES.VALIDATION, message: expect.stringMatching(/exceeds 512/) }),
124
+ );
125
+ expect(() => requireProvidedString(' ', { field: 'modelID', max: 256 })).toThrowError(
126
+ expect.objectContaining({ code: ASSIGN_CODES.VALIDATION }),
127
+ );
128
+ expect(() => resolveAssignWorkerModel({
129
+ model: 'x'.repeat(513),
130
+ fallback: { providerID: 'p', modelID: 'm' },
131
+ catalog,
132
+ })).toThrowError(expect.objectContaining({ code: ASSIGN_CODES.VALIDATION }));
133
+ expect(() => resolveAssignWorkerModel({
134
+ model: { providerID: 'xai', modelID: 'grok-4.6' },
135
+ fallback: { providerID: 'p', modelID: 'm' },
136
+ catalog,
137
+ })).toThrowError(expect.objectContaining({ code: ASSIGN_CODES.VALIDATION }));
138
+ expect(() => resolveAssignWorkerModel({
139
+ providerID: 'xai',
140
+ modelID: 'grok-4.6',
141
+ model: 'openai/gpt-4o',
142
+ fallback: { providerID: 'p', modelID: 'm' },
143
+ catalog,
144
+ })).toThrowError(expect.objectContaining({
145
+ code: ASSIGN_CODES.VALIDATION,
146
+ message: expect.stringMatching(/conflicts/),
147
+ }));
148
+ });
149
+ });
150
+
151
+ describe('resolveAssignWorkerVariant', () => {
152
+ it('reuses assistant variant only for same/default model; drops it cross-model', () => {
153
+ expect(resolveAssignWorkerVariant({
154
+ source: 'assistant',
155
+ workerProviderID: 'p',
156
+ workerModelID: 'm',
157
+ assistant: { providerID: 'p', modelID: 'm', variant: 'fast' },
158
+ })).toBe('fast');
159
+ expect(resolveAssignWorkerVariant({
160
+ source: 'explicit',
161
+ workerProviderID: 'xai',
162
+ workerModelID: 'grok-4.6',
163
+ assistant: { providerID: 'p', modelID: 'm', variant: 'fast' },
164
+ })).toBeNull();
165
+ expect(resolveAssignWorkerVariant({
166
+ source: 'explicit',
167
+ workerProviderID: 'xai',
168
+ workerModelID: 'grok-4.6',
169
+ assistant: { providerID: 'p', modelID: 'm', variant: 'fast' },
170
+ variant: 'default',
171
+ })).toBe('default');
172
+ expect(() => resolveAssignWorkerVariant({
173
+ source: 'explicit',
174
+ workerProviderID: 'xai',
175
+ workerModelID: 'grok-4.6',
176
+ variant: 'x'.repeat(300),
177
+ })).toThrowError(expect.objectContaining({ code: ASSIGN_CODES.VALIDATION }));
178
+ });
179
+ });
180
+
181
+ describe('assign file parts', () => {
182
+ it('keeps attachment order and fingerprints content without exposing file data', () => {
183
+ const first = { type: 'file', mime: 'text/plain', filename: 'first.txt', url: 'data:text/plain;base64,Zmlyc3Q=' };
184
+ const second = { ...first, filename: 'second.txt', url: 'data:text/plain;base64,c2Vjb25k' };
185
+ const key = attachmentScopeKey([first, second]);
186
+ expect(key).toEqual(attachmentScopeKey([{ ...first }, { ...second }]));
187
+ expect(key).not.toEqual(attachmentScopeKey([second, first]));
188
+ expect(JSON.stringify(key)).not.toContain(first.url);
189
+ expect(JSON.stringify(key)).not.toContain(first.filename);
190
+ });
191
+ it('sanitizes file parts and builds prompt+file worker parts', () => {
192
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' };
193
+ const bad = { type: 'file', mime: 'image/png' };
194
+ expect(sanitizeAssignFileParts([image, bad, { type: 'text', text: 'x' }])).toEqual([image]);
195
+ expect(hasAssignImageParts([image])).toBe(true);
196
+ expect(buildAssignParts({ prompt: 'Fix width', fileParts: [image] })).toEqual([
197
+ { type: 'text', text: 'Fix width' },
198
+ image,
199
+ ]);
200
+ expect(attachmentScopeKey([image])).toEqual([
201
+ expect.stringMatching(/^[a-f0-9]{64}$/),
202
+ ]);
203
+ expect(JSON.stringify(attachmentScopeKey([image]))).not.toContain('base64');
204
+ });
205
+ });
206
+
207
+ describe('isAmbiguousPromptFailure', () => {
208
+ it('treats transport/5xx/no-status as ambiguous and 4xx as definite', () => {
209
+ expect(isAmbiguousPromptFailure(Object.assign(new Error('fetch failed'), { name: 'TypeError' }), null)).toBe(true);
210
+ expect(isAmbiguousPromptFailure(null, { error: { status: 500 }, response: { status: 500 } })).toBe(true);
211
+ expect(isAmbiguousPromptFailure(null, { error: { status: 400 }, response: { status: 400 } })).toBe(false);
212
+ expect(isAmbiguousPromptFailure(null, { response: { status: 204 } })).toBe(false);
213
+ });
214
+ });
215
+
57
216
  describe('assignSession', () => {
58
217
  it('creates a visible worker session and prompts it', async () => {
59
218
  const directory = root();
@@ -77,6 +236,7 @@ describe('assignSession', () => {
77
236
  title: 'Login',
78
237
  status: 'busy',
79
238
  reused: false,
239
+ model: { providerID: 'openai', modelID: 'gpt-5.2' },
80
240
  });
81
241
  expect(createSession).toHaveBeenCalledWith(expect.objectContaining({
82
242
  directory: fs.realpathSync(project),
@@ -96,11 +256,200 @@ describe('assignSession', () => {
96
256
  }));
97
257
  });
98
258
 
259
+ it('forwards current-turn images into promptAsync with an explicit vision model', async () => {
260
+ const directory = root();
261
+ fs.mkdirSync(directory, { recursive: true });
262
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'card.png' };
263
+ const createSession = vi.fn(async () => ({ data: { id: 'ses_img' } }));
264
+ const promptExisting = vi.fn(async () => ({ response: { status: 204 } }));
265
+ const assigned = await assignSession({
266
+ prompt: 'Fix mobile card width from this screenshot',
267
+ directory,
268
+ assistant: { id: 'asst_1', name: 'Ada', providerID: 'p', modelID: 'm', variant: 'fast' },
269
+ model: { providerID: 'xai', modelID: 'grok-4.6', acceptsImages: true },
270
+ fileParts: [image],
271
+ allowedRoots: [directory],
272
+ managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
273
+ createSession,
274
+ promptExisting,
275
+ });
276
+ expect(assigned.model).toEqual({ providerID: 'xai', modelID: 'grok-4.6' });
277
+ expect(promptExisting).toHaveBeenCalledWith(expect.objectContaining({
278
+ model: { providerID: 'xai', modelID: 'grok-4.6' },
279
+ parts: [
280
+ { type: 'text', text: 'Fix mobile card width from this screenshot' },
281
+ image,
282
+ ],
283
+ }));
284
+ // Cross-model must not reuse contact-only variant.
285
+ expect(promptExisting.mock.calls[0][0].variant).toBeUndefined();
286
+ });
287
+
288
+ it('reuses assistant variant only when the worker model matches the contact', async () => {
289
+ const directory = root();
290
+ fs.mkdirSync(directory, { recursive: true });
291
+ const promptExisting = vi.fn(async () => ({ response: { status: 204 } }));
292
+ await assignSession({
293
+ prompt: 'Fix',
294
+ directory,
295
+ assistant: { providerID: 'p', modelID: 'm', variant: 'fast' },
296
+ allowedRoots: [directory],
297
+ managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
298
+ createSession: async () => ({ data: { id: 'ses_var' } }),
299
+ promptExisting,
300
+ });
301
+ expect(promptExisting).toHaveBeenCalledWith(expect.objectContaining({ variant: 'fast' }));
302
+ });
303
+
304
+ it('rejects image attachments for non-vision worker models before create', async () => {
305
+ const directory = root();
306
+ fs.mkdirSync(directory, { recursive: true });
307
+ const createSession = vi.fn();
308
+ await expect(assignSession({
309
+ prompt: 'Look at this',
310
+ directory,
311
+ assistant: { providerID: 'p', modelID: 'm' },
312
+ model: { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', acceptsImages: false },
313
+ fileParts: [{ type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'x.png' }],
314
+ allowedRoots: [directory],
315
+ managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
316
+ createSession,
317
+ promptExisting: vi.fn(),
318
+ })).rejects.toMatchObject({ code: ASSIGN_CODES.IMAGE_NOT_SUPPORTED });
319
+ expect(createSession).not.toHaveBeenCalled();
320
+ });
321
+
322
+ it('deletes a newly created worker on definite prompt failure (4xx)', async () => {
323
+ const directory = root();
324
+ fs.mkdirSync(directory, { recursive: true });
325
+ const createSession = vi.fn(async () => ({ data: { id: 'ses_orphan' } }));
326
+ const promptExisting = vi.fn(async () => ({ error: { status: 400 }, response: { status: 400 } }));
327
+ const deleteSession = vi.fn(async () => ({ data: true }));
328
+ await expect(assignSession({
329
+ prompt: 'Fix',
330
+ directory,
331
+ assistant: { providerID: 'p', modelID: 'm' },
332
+ allowedRoots: [directory],
333
+ managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
334
+ createSession,
335
+ promptExisting,
336
+ deleteSession,
337
+ })).rejects.toMatchObject({ code: ASSIGN_CODES.UPSTREAM });
338
+ expect(deleteSession).toHaveBeenCalledWith({
339
+ sessionID: 'ses_orphan',
340
+ directory: fs.realpathSync(directory),
341
+ });
342
+ });
343
+
344
+ it('does not delete on ambiguous prompt network/5xx; marks prompt_ambiguous with sessionID', async () => {
345
+ const directory = root();
346
+ fs.mkdirSync(directory, { recursive: true });
347
+ const createSession = vi.fn(async () => ({ data: { id: 'ses_maybe' } }));
348
+ const promptExisting = vi.fn(async () => {
349
+ throw Object.assign(new Error('fetch failed'), { name: 'TypeError' });
350
+ });
351
+ const deleteSession = vi.fn(async () => ({ data: true }));
352
+ await expect(assignSession({
353
+ prompt: 'Fix',
354
+ directory,
355
+ messageID: 'msg_assign_fixed',
356
+ assistant: { providerID: 'p', modelID: 'm' },
357
+ allowedRoots: [directory],
358
+ managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
359
+ createSession,
360
+ promptExisting,
361
+ deleteSession,
362
+ })).rejects.toMatchObject({
363
+ code: ASSIGN_CODES.PROMPT_AMBIGUOUS,
364
+ ambiguous: true,
365
+ sessionID: 'ses_maybe',
366
+ messageID: 'msg_assign_fixed',
367
+ });
368
+ expect(deleteSession).not.toHaveBeenCalled();
369
+ });
370
+
371
+ it('confirms ambiguous prompt via lookupMessage instead of deleting or recreating', async () => {
372
+ const directory = root();
373
+ fs.mkdirSync(directory, { recursive: true });
374
+ const promptExisting = vi.fn(async () => ({ error: { status: 503 }, response: { status: 503 } }));
375
+ const deleteSession = vi.fn();
376
+ const lookupMessage = vi.fn(async () => ({ found: true, id: 'msg_assign_ok' }));
377
+ const assigned = await assignSession({
378
+ prompt: 'Fix',
379
+ directory,
380
+ messageID: 'msg_assign_ok',
381
+ assistant: { providerID: 'p', modelID: 'm' },
382
+ allowedRoots: [directory],
383
+ managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
384
+ createSession: async () => ({ data: { id: 'ses_confirmed' } }),
385
+ promptExisting,
386
+ deleteSession,
387
+ lookupMessage,
388
+ });
389
+ expect(assigned).toMatchObject({
390
+ sessionID: 'ses_confirmed',
391
+ messageID: 'msg_assign_ok',
392
+ promptStatus: 'confirmed',
393
+ });
394
+ expect(deleteSession).not.toHaveBeenCalled();
395
+ expect(lookupMessage).toHaveBeenCalledWith(expect.objectContaining({
396
+ sessionID: 'ses_confirmed',
397
+ messageID: 'msg_assign_ok',
398
+ }));
399
+ });
400
+
401
+ it.each([false, true])('confirms an ambiguous response for reused=%s through one admission path', async (reused) => {
402
+ const directory = root();
403
+ const deleteSession = vi.fn();
404
+ const createSession = vi.fn(async () => ({ data: { id: 'ses_common' } }));
405
+ const result = await assignSession({
406
+ prompt: 'Fix', directory, allowedRoots: [directory],
407
+ assistant: { providerID: 'p', modelID: 'm' },
408
+ ...(reused ? { sessionID: 'ses_common' } : {}),
409
+ createSession, deleteSession,
410
+ promptExisting: async () => ({ error: { status: 503 } }),
411
+ lookupMessage: async () => ({ found: true }),
412
+ });
413
+ expect(result).toMatchObject({ sessionID: 'ses_common', reused, promptStatus: 'confirmed' });
414
+ expect(createSession).toHaveBeenCalledTimes(reused ? 0 : 1);
415
+ expect(deleteSession).not.toHaveBeenCalled();
416
+ });
417
+
418
+ it.each([false, true])('cleans up only new workers after definite rejection, reused=%s', async (reused) => {
419
+ const directory = root();
420
+ const deleteSession = vi.fn();
421
+ await expect(assignSession({
422
+ prompt: 'Fix', directory, allowedRoots: [directory],
423
+ assistant: { providerID: 'p', modelID: 'm' },
424
+ ...(reused ? { sessionID: 'ses_common' } : {}),
425
+ createSession: async () => ({ data: { id: 'ses_common' } }),
426
+ deleteSession,
427
+ promptExisting: async () => ({ error: { status: 400 } }),
428
+ })).rejects.toMatchObject({ code: ASSIGN_CODES.UPSTREAM });
429
+ expect(deleteSession).toHaveBeenCalledTimes(reused ? 0 : 1);
430
+ });
431
+
432
+ it('bounds create/prompt with deadlines', async () => {
433
+ const directory = root();
434
+ fs.mkdirSync(directory, { recursive: true });
435
+ await expect(assignSession({
436
+ prompt: 'Fix',
437
+ directory,
438
+ assistant: { providerID: 'p', modelID: 'm' },
439
+ allowedRoots: [directory],
440
+ managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
441
+ createTimeoutMs: 20,
442
+ createSession: async () => new Promise(() => {}),
443
+ promptExisting: vi.fn(),
444
+ })).rejects.toMatchObject({ code: ASSIGN_CODES.UPSTREAM, message: expect.stringMatching(/timed out/) });
445
+ });
446
+
99
447
  it('reuses an existing session without creating another', async () => {
100
448
  const directory = root();
101
449
  fs.mkdirSync(directory, { recursive: true });
102
450
  const createSession = vi.fn();
103
451
  const promptExisting = vi.fn(async () => ({ response: { status: 204 } }));
452
+ const deleteSession = vi.fn();
104
453
  const assigned = await assignSession({
105
454
  prompt: 'Continue login',
106
455
  sessionID: 'ses_existing',
@@ -110,9 +459,11 @@ describe('assignSession', () => {
110
459
  managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
111
460
  createSession,
112
461
  promptExisting,
462
+ deleteSession,
113
463
  });
114
464
  expect(assigned).toMatchObject({ sessionID: 'ses_existing', reused: true, status: 'busy' });
115
465
  expect(createSession).not.toHaveBeenCalled();
466
+ expect(deleteSession).not.toHaveBeenCalled();
116
467
  expect(promptExisting).toHaveBeenCalledWith(expect.objectContaining({ sessionID: 'ses_existing' }));
117
468
  });
118
469
  });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Isolated acceptance for server-authoritative contact working without touching :3001.
3
+ * Exercises the same snapshot DTO shape HTTP clients pull after APP reload.
4
+ */
5
+ import fs from 'node:fs';
6
+ import http from 'node:http';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import express from 'express';
10
+ import { afterEach, describe, expect, it } from 'vitest';
11
+ import { createAssistantsService } from './service.js';
12
+
13
+ const root = () => fs.mkdtempSync(path.join(os.tmpdir(), 'contact-http-'));
14
+ const closers = [];
15
+
16
+ const listen = (app) => new Promise((resolve) => {
17
+ const server = http.createServer(app);
18
+ server.listen(0, '127.0.0.1', () => {
19
+ const { port } = server.address();
20
+ closers.push(() => new Promise((done) => server.close(() => done())));
21
+ resolve({ port, base: `http://127.0.0.1:${port}` });
22
+ });
23
+ });
24
+
25
+ const json = async (base, pathName, init = {}) => {
26
+ const response = await fetch(`${base}${pathName}`, {
27
+ ...init,
28
+ headers: { 'content-type': 'application/json', ...(init.headers || {}) },
29
+ });
30
+ const body = await response.json().catch(() => null);
31
+ return { status: response.status, body };
32
+ };
33
+
34
+ afterEach(async () => {
35
+ while (closers.length) {
36
+ await closers.pop()();
37
+ }
38
+ });
39
+
40
+ describe('contact active turn HTTP snapshot (isolated)', () => {
41
+ it('admission → running → settle is visible on snapshot GET; APP reload matches; missed-end idle poll converges', async () => {
42
+ const directory = root();
43
+ let release;
44
+ const blocked = new Promise((resolve) => { release = resolve; });
45
+ const service = createAssistantsService({
46
+ dbPath: path.join(directory, 'assistants.sqlite'),
47
+ dataDir: directory,
48
+ getAllowedRoots: () => [directory],
49
+ buildOpenCodeUrl: () => 'http://127.0.0.1:1',
50
+ getOpenCodeAuthHeaders: () => ({}),
51
+ clientFactory: () => ({
52
+ session: {
53
+ create: async () => ({ data: { id: 'ses_x' } }),
54
+ get: async () => ({ data: { id: 'ses_x' } }),
55
+ update: async () => ({ data: { id: 'ses_x' } }),
56
+ promptAsync: async () => ({ response: { status: 204 } }),
57
+ summarize: async () => ({ data: true }),
58
+ delete: async () => ({ data: true }),
59
+ },
60
+ }),
61
+ runContactTurn: async () => {
62
+ await blocked;
63
+ return { text: 'done', bubbles: ['done'] };
64
+ },
65
+ createChatCompletion: async () => ({ choices: [{ message: { content: 'x' } }] }),
66
+ });
67
+ closers.push(async () => service.close());
68
+ service.setEnabled({ enabled: true, expectedRevision: service.snapshot().revision });
69
+ const assistant = service.createAssistant({
70
+ name: 'HTTP Bot',
71
+ providerID: 'p',
72
+ modelID: 'm',
73
+ defaultPrompt: '',
74
+ mode: 'continuous',
75
+ });
76
+
77
+ const app = express();
78
+ app.use(express.json({ limit: '2mb' }));
79
+ app.get('/api/openchamber/assistants/snapshot', (_req, res) => res.json(service.snapshot()));
80
+ app.post('/api/openchamber/assistants/:id/messages', async (req, res) => {
81
+ try {
82
+ const body = await service.send(req.params.id, req.body);
83
+ res.status(202).json(body);
84
+ } catch (error) {
85
+ res.status(400).json({ error: error.code || 'error', message: error.message });
86
+ }
87
+ });
88
+ const { base } = await listen(app);
89
+
90
+ // Fresh client: idle
91
+ let snap = await json(base, '/api/openchamber/assistants/snapshot');
92
+ expect(snap.status).toBe(200);
93
+ expect(snap.body.assistants[0]).toMatchObject({
94
+ id: assistant.id,
95
+ working: false,
96
+ activeContactTurn: null,
97
+ });
98
+
99
+ const admitted = await json(base, `/api/openchamber/assistants/${encodeURIComponent(assistant.id)}/messages`, {
100
+ method: 'POST',
101
+ body: JSON.stringify({ messageID: 'http_turn_1', parts: [{ type: 'text', text: 'hi' }] }),
102
+ });
103
+ expect(admitted.status).toBe(202);
104
+ expect(admitted.body).toMatchObject({ admitted: true, messageID: 'http_turn_1', revision: expect.any(Number) });
105
+
106
+ // Live client observes working after admit
107
+ snap = await json(base, '/api/openchamber/assistants/snapshot');
108
+ expect(snap.body.assistants[0].working).toBe(true);
109
+ expect(snap.body.assistants[0].activeContactTurn).toMatchObject({
110
+ turnID: 'http_turn_1',
111
+ messageID: 'http_turn_1',
112
+ status: expect.stringMatching(/^(queued|running)$/),
113
+ });
114
+
115
+ // APP reload / new client: same authoritative snapshot
116
+ const reloaded = await json(base, '/api/openchamber/assistants/snapshot');
117
+ expect(reloaded.body.assistants[0].working).toBe(true);
118
+ expect(reloaded.body.assistants[0].activeContactTurn.turnID).toBe('http_turn_1');
119
+
120
+ // Missed contact-turn-end: poll until idle
121
+ release();
122
+ await service.whenContactTurnSettled('http_turn_1');
123
+ let idle = null;
124
+ for (let attempt = 0; attempt < 20; attempt += 1) {
125
+ idle = await json(base, '/api/openchamber/assistants/snapshot');
126
+ if (!idle.body.assistants[0].working) break;
127
+ await new Promise((r) => setTimeout(r, 25));
128
+ }
129
+ expect(idle.body.assistants[0]).toMatchObject({
130
+ working: false,
131
+ activeContactTurn: null,
132
+ });
133
+ });
134
+ });
@@ -108,6 +108,32 @@ export function nextContactOrdinal(db, assistantID) {
108
108
  ).get(assistantID).next);
109
109
  }
110
110
 
111
+ /** Load one contact row by message id (null when missing). */
112
+ export function getContactMessage(db, messageID) {
113
+ if (typeof messageID !== 'string' || !messageID) return null;
114
+ const row = db.prepare('SELECT * FROM assistant_contact_message WHERE message_id=?').get(messageID);
115
+ if (!row) return null;
116
+ return hydrateMessage(db, row);
117
+ }
118
+
119
+ /** Stable fingerprint for idempotent contact admission replay. */
120
+ export function contactPartsFingerprint(parts) {
121
+ const list = Array.isArray(parts) ? parts : [];
122
+ return json(list.map((part) => {
123
+ if (!part || typeof part !== 'object') return null;
124
+ if (part.type === 'text') return { type: 'text', text: typeof part.text === 'string' ? part.text : '' };
125
+ if (part.type === 'file') {
126
+ return {
127
+ type: 'file',
128
+ mime: typeof part.mime === 'string' ? part.mime : '',
129
+ url: typeof part.url === 'string' ? part.url : '',
130
+ ...(typeof part.filename === 'string' && part.filename ? { filename: part.filename } : {}),
131
+ };
132
+ }
133
+ return part;
134
+ }));
135
+ }
136
+
111
137
  export function insertContactMessage(db, {
112
138
  messageID,
113
139
  assistantID,
@@ -338,6 +364,44 @@ export function trimContactHistoryForLlm(messages, {
338
364
  * rows (queued turns) must not be injected into this turn; assistant rows written
339
365
  * after that ordinal by a prior completed lane turn still enter the window.
340
366
  */
367
+ /**
368
+ * In-process contact-turn activity (not SQLite). Process memory only: APP
369
+ * restart while the server keeps running can rehydrate from snapshot; a server
370
+ * process restart clears every active turn so the green dot cannot stick forever.
371
+ * Status ladder: admission → queued → running → settled (removed).
372
+ */
373
+ export const CONTACT_ACTIVE_TURN_STATUSES = Object.freeze(['queued', 'running']);
374
+
375
+ export function createActiveContactTurn({ turnID, messageID, status = 'queued', admittedAt }) {
376
+ if (typeof turnID !== 'string' || !turnID.trim()) return null;
377
+ if (typeof messageID !== 'string' || !messageID.trim()) return null;
378
+ if (!CONTACT_ACTIVE_TURN_STATUSES.includes(status)) return null;
379
+ const at = Number(admittedAt);
380
+ if (!Number.isFinite(at)) return null;
381
+ return {
382
+ turnID: turnID.trim(),
383
+ messageID: messageID.trim(),
384
+ status,
385
+ admittedAt: Math.trunc(at),
386
+ };
387
+ }
388
+
389
+ /** Earliest admitted active turn; running preferred when admittedAt ties. */
390
+ export function projectActiveContactTurn(activeTurns) {
391
+ const list = Array.isArray(activeTurns)
392
+ ? activeTurns
393
+ : (activeTurns instanceof Map ? [...activeTurns.values()] : []);
394
+ const valid = list
395
+ .map((item) => createActiveContactTurn(item))
396
+ .filter(Boolean)
397
+ .sort((left, right) => {
398
+ if (left.admittedAt !== right.admittedAt) return left.admittedAt - right.admittedAt;
399
+ if (left.status === right.status) return left.turnID.localeCompare(right.turnID);
400
+ return left.status === 'running' ? -1 : 1;
401
+ });
402
+ return valid[0] ?? null;
403
+ }
404
+
341
405
  export function contactHistoryForLlm(db, assistantID, { excludeMessageIDs = [], beforeOrdinal = null } = {}) {
342
406
  const afterOrdinal = getContactContextBoundary(db, assistantID);
343
407
  const exclude = new Set(
@@ -11,12 +11,14 @@ import {
11
11
  CONTACT_LLM_MAX_TURNS,
12
12
  clearContactMemory,
13
13
  contactHistoryForLlm,
14
+ createActiveContactTurn,
14
15
  deleteContactMessages,
15
16
  ensureContactSchema,
16
17
  getContactContextBoundary,
17
18
  insertContactMessage,
18
19
  listContactMessages,
19
20
  nextContactOrdinal,
21
+ projectActiveContactTurn,
20
22
  trimContactHistoryForLlm,
21
23
  } from './contact-store.js';
22
24
 
@@ -231,6 +233,16 @@ describe('contact LLM history trim', () => {
231
233
  reopened.close();
232
234
  });
233
235
 
236
+ it('projects the earliest active contact turn for snapshot recovery', () => {
237
+ expect(createActiveContactTurn({ turnID: '', messageID: 'm', status: 'queued', admittedAt: 1 })).toBeNull();
238
+ expect(createActiveContactTurn({ turnID: 't', messageID: 'm', status: 'settled', admittedAt: 1 })).toBeNull();
239
+ const queued = createActiveContactTurn({ turnID: 't1', messageID: 'm1', status: 'queued', admittedAt: 20 });
240
+ const running = createActiveContactTurn({ turnID: 't0', messageID: 'm0', status: 'running', admittedAt: 10 });
241
+ expect(projectActiveContactTurn([queued, running])).toEqual(running);
242
+ expect(projectActiveContactTurn(new Map([['t1', queued]]))).toEqual(queued);
243
+ expect(projectActiveContactTurn([])).toBeNull();
244
+ });
245
+
234
246
  it('keeps the previous boundary when clearContactMemory write fails inside a transaction', () => {
235
247
  const db = openDb();
236
248
  const assistantID = 'asst_fail';