@openchambery/web 1.19.12 → 1.19.13-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/dist/assets/{AgentAvatar-CX0a1rKE.js → AgentAvatar-mbvX7YlW.js} +1 -1
  2. package/dist/assets/AssistantView-D5c557Oe.js +2 -0
  3. package/dist/assets/DiagramView-CjNUrHsU.js +1 -0
  4. package/dist/assets/FadeInOnReveal-moBbL8Ll.js +1 -0
  5. package/dist/assets/{Icon-BYM02QyU.js → Icon-CkzzhaLr.js} +1 -1
  6. package/dist/assets/MarkdownRenderer-BHfbAwVO.js +2 -0
  7. package/dist/assets/MarkdownRendererImpl-CkBoUibc.js +7 -0
  8. package/dist/assets/{MarkstreamRendererImpl-BFdJzxia.js → MarkstreamRendererImpl-BrXUOI9g.js} +1 -1
  9. package/dist/assets/MobileDetailNavigation-aY9-HwBN.js +1 -0
  10. package/dist/assets/MobileShareBridge-CRt_8DmM.js +4 -0
  11. package/dist/assets/MobileSurface-CIhtF-CU.js +2 -0
  12. package/dist/assets/{MultiRunWindow-C3p3lj3U.js → MultiRunWindow-DDq6C8t7.js} +1 -1
  13. package/dist/assets/OnboardingScreen-j0iCyH7f.js +1 -0
  14. package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-CDC5B3RH.js} +1 -1
  15. package/dist/assets/SettingsView-DFWWMvAj.js +25 -0
  16. package/dist/assets/{SettingsWindow-8IDuPZA5.js → SettingsWindow-CnYfBplB.js} +1 -1
  17. package/dist/assets/TerminalView-D5CQFVVm.js +8 -0
  18. package/dist/assets/ToolOutputDialog-VMI7TQFT.js +10 -0
  19. package/dist/assets/{appThemeRegistry-B87OLIgc.js → appThemeRegistry-UIrP0-5h.js} +1 -1
  20. package/dist/assets/assistant-attachment-cache-C0aRnFkj.js +1 -0
  21. package/dist/assets/{button-C0cth9tl.js → button-xx1UOBA5.js} +1 -1
  22. package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-CUasgPiF.js} +1 -1
  23. package/dist/assets/{collapsible-DFT9cE80.js → collapsible-CACHBRYZ.js} +1 -1
  24. package/dist/assets/{context-menu-CAp74NfV.js → context-menu-CYbm4wBZ.js} +1 -1
  25. package/dist/assets/desktopHosts-DILvrAok.js +1 -0
  26. package/dist/assets/desktopRelayRestore-t8asCr3m.js +1 -0
  27. package/dist/assets/{diagram-BryzMuta.js → diagram-u0BC9uuY.js} +1 -1
  28. package/dist/assets/{dialog-CgbV5ljg.js → dialog-BwQ3rY_x.js} +1 -1
  29. package/dist/assets/dualLimitLru-D8Sphgua.js +1 -0
  30. package/dist/assets/{es-BHqnSjoZ.js → es-CRjOwvYM.js} +1 -1
  31. package/dist/assets/fileQueries-Bgly-E8f.js +1 -0
  32. package/dist/assets/{fr-BfrlJY9i.js → fr-D9taEN9L.js} +1 -1
  33. package/dist/assets/{gitApi-tTtHQVA3.js → gitApi-Bu4W3bgD.js} +1 -1
  34. package/dist/assets/imageSource-BlAJJUEI.js +1 -0
  35. package/dist/assets/{input-DFLpqjGR.js → input-CI730njs.js} +1 -1
  36. package/dist/assets/insertionBoundaries-B1AqxsBD.js +4 -0
  37. package/dist/assets/{ja-DP-7qt-x.js → ja-CYQ5aoWp.js} +1 -1
  38. package/dist/assets/{ko-E2B5vGQP.js → ko-BwASV0Q0.js} +1 -1
  39. package/dist/assets/main-CqcB6tKc.js +169 -0
  40. package/dist/assets/main-DG-k0j8A.js +2 -0
  41. package/dist/assets/markdown-worker-CptoICmq.js +2 -0
  42. package/dist/assets/markdownHeightCache-DEjGKLv8.js +3 -0
  43. package/dist/assets/markdownTheme-DOfGg-A7.js +1 -0
  44. package/dist/assets/miniChat-BQtYQrsh.js +2 -0
  45. package/dist/assets/mobile-8iyqtXkr.js +2 -0
  46. package/dist/assets/{multirun-B0V3hdnD.js → multirun-CG_FCb_Z.js} +1 -1
  47. package/dist/assets/persistence-YbQKzHUQ.js +3 -0
  48. package/dist/assets/{pl-r8X5gRM5.js → pl-DUI1vPil.js} +1 -1
  49. package/dist/assets/platform-DzsGqlGW.js +2 -0
  50. package/dist/assets/projectMeta-DMpMDWE1.js +3 -0
  51. package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-CZQFJllP.js} +1 -1
  52. package/dist/assets/relay-image-stream-DM7atqJs.js +2 -0
  53. package/dist/assets/renderElectronMiniChatApp-DBzRdJl8.js +2 -0
  54. package/dist/assets/renderMobileApp-CIseqCyn.js +28 -0
  55. package/dist/assets/runtime-auth-D66yq6ie.js +1 -0
  56. package/dist/assets/runtime-fetch-zIalUnbb.js +1 -0
  57. package/dist/assets/runtime-switch-LvWDU-SL.js +1 -0
  58. package/dist/assets/runtimeConfig-D8nJYB-e.js +2 -0
  59. package/dist/assets/runtimeEndpointReset-CSub_ecV.js +147 -0
  60. package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DQDZ5HW-.js} +1 -1
  61. package/dist/assets/{sessionLookup-B6P_aCun.js → sessionLookup-BiBD-4wC.js} +1 -1
  62. package/dist/assets/sortable-tabs-strip-Dom6Sthu.js +1 -0
  63. package/dist/assets/src-AA-5nXsF.css +2 -0
  64. package/dist/assets/{store-BDFdnk6l.js → store-CSnH_QXM.js} +2 -2
  65. package/dist/assets/terminalApi-noa4Cdbh.js +1 -0
  66. package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BfNtUdJE.js} +1 -1
  67. package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-DPxumadF.js} +1 -1
  68. package/dist/assets/{uk-Di0vl9V-.js → uk-C1FYZlmA.js} +1 -1
  69. package/dist/assets/{url-Pk6tQ1EZ.js → url-CVhsI9kR.js} +1 -1
  70. package/dist/assets/{useAppFontEffects-Dmes1chr.js → useAppFontEffects-qHt5uaFN.js} +32 -32
  71. package/dist/assets/useAssistantUIStore-CLPCNoj_.js +1 -0
  72. package/dist/assets/useDesktopWindowControlsLayout-BipyIXiK.js +1 -0
  73. package/dist/assets/{useEffectiveDirectory-CU2ChP0J.js → useEffectiveDirectory-DSNCCbCt.js} +1 -1
  74. package/dist/assets/useFontPreferences-CB-cGo3I.js +1 -0
  75. package/dist/assets/useMobileNavigationStore-C1NJGAik.js +413 -0
  76. package/dist/assets/useSessionAutoCleanup-Dcn6Unvd.js +2 -0
  77. package/dist/assets/useUIStore-CxOoCv7B.js +1 -0
  78. package/dist/assets/useWorkerHighlightedLines-CzpT4M7V.js +7 -0
  79. package/dist/assets/{utils-BiK3tBGM.js → utils-BHTVlVov.js} +1 -1
  80. package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-C96qP1ip.js} +1 -1
  81. package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-BsRk1Rbb.js} +1 -1
  82. package/dist/index.html +12 -9
  83. package/dist/mini-chat.html +12 -9
  84. package/dist/mobile.html +12 -9
  85. package/package.json +1 -1
  86. package/server/index.js +19 -0
  87. package/server/lib/assistant-delivery-parts.js +19 -1
  88. package/server/lib/assistants/DOCUMENTATION.md +56 -11
  89. package/server/lib/assistants/assign.js +455 -30
  90. package/server/lib/assistants/assign.test.js +351 -0
  91. package/server/lib/assistants/cards.js +16 -2
  92. package/server/lib/assistants/cards.test.js +16 -1
  93. package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
  94. package/server/lib/assistants/contact-attachments.http.test.js +436 -0
  95. package/server/lib/assistants/contact-attachments.js +814 -0
  96. package/server/lib/assistants/contact-attachments.p1.test.js +307 -0
  97. package/server/lib/assistants/contact-attachments.test.js +249 -0
  98. package/server/lib/assistants/contact-store.js +552 -19
  99. package/server/lib/assistants/contact-store.test.js +415 -1
  100. package/server/lib/assistants/contact-tools.js +114 -14
  101. package/server/lib/assistants/contact-tools.test.js +135 -6
  102. package/server/lib/assistants/contracts.js +42 -2
  103. package/server/lib/assistants/harness.js +4 -1
  104. package/server/lib/assistants/harness.test.js +124 -0
  105. package/server/lib/assistants/routes.js +46 -1
  106. package/server/lib/assistants/service.js +569 -84
  107. package/server/lib/assistants/service.test.js +936 -37
  108. package/server/lib/fs/prompt-attachment-store.js +663 -0
  109. package/server/lib/fs/prompt-attachment-store.test.js +263 -0
  110. package/server/lib/fs/routes.js +28 -72
  111. package/server/lib/fs/routes.test.js +15 -30
  112. package/server/lib/llm/catalog.js +91 -16
  113. package/server/lib/llm/catalog.test.js +152 -11
  114. package/server/lib/notifications/APNS.md +20 -4
  115. package/server/lib/notifications/DOCUMENTATION.md +5 -0
  116. package/server/lib/notifications/apns-runtime.js +136 -18
  117. package/server/lib/notifications/apns-runtime.test.js +128 -0
  118. package/server/lib/notifications/apns-titles.js +21 -1
  119. package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
  120. package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
  121. package/server/lib/notifications/runtime.js +93 -2
  122. package/server/lib/notifications/runtime.test.js +167 -4
  123. package/server/lib/opencode/core-routes.js +2 -1
  124. package/server/lib/opencode/settings-helpers.js +12 -0
  125. package/server/lib/opencode/settings-runtime.js +16 -0
  126. package/server/lib/opencode/settings-runtime.test.js +21 -0
  127. package/server/lib/scheduled-tasks/runtime.js +21 -0
  128. package/server/lib/scheduled-tasks/runtime.test.js +24 -0
  129. package/dist/assets/AssistantView-DxIghtun.js +0 -2
  130. package/dist/assets/DiagramView-BeUgp_Y0.js +0 -1
  131. package/dist/assets/FadeInOnReveal-CTlNE4Hg.js +0 -1
  132. package/dist/assets/MarkdownRenderer-C4MGa_kQ.js +0 -2
  133. package/dist/assets/MarkdownRendererImpl-DB4Q3kGa.js +0 -7
  134. package/dist/assets/MobileDetailNavigation-B_RwWOwv.js +0 -1
  135. package/dist/assets/MobileShareBridge-UiOLx0-2.js +0 -4
  136. package/dist/assets/MobileSurface-CNChj_dX.js +0 -2
  137. package/dist/assets/OnboardingScreen-DS2nugLX.js +0 -1
  138. package/dist/assets/SettingsView-BXfKsz0J.js +0 -25
  139. package/dist/assets/TerminalView-BiKCueth.js +0 -8
  140. package/dist/assets/ToolOutputDialog-BC2F_Fdl.js +0 -10
  141. package/dist/assets/desktopHosts-Dc2PS66M.js +0 -1
  142. package/dist/assets/desktopRelayRestore-T9y0orqa.js +0 -1
  143. package/dist/assets/fileQueries-BY-9OWzp.js +0 -1
  144. package/dist/assets/insertionBoundaries-CCQpZ5zn.js +0 -4
  145. package/dist/assets/main-C_PUoT3E.js +0 -169
  146. package/dist/assets/main-Dkg31fRY.js +0 -2
  147. package/dist/assets/markdown-worker-CTsrtpPh.js +0 -2
  148. package/dist/assets/markdownHeightCache-CYDlisxr.js +0 -3
  149. package/dist/assets/markdownTheme-DyJ4Gfcj.js +0 -2
  150. package/dist/assets/miniChat-C9QMj8T8.js +0 -2
  151. package/dist/assets/mobile-Z_X7CbzB.js +0 -2
  152. package/dist/assets/persistence-DdUac14I.js +0 -3
  153. package/dist/assets/platform-B4KKLnZ5.js +0 -2
  154. package/dist/assets/projectMeta-DRW9MQ4z.js +0 -3
  155. package/dist/assets/renderElectronMiniChatApp-Dn1Su1dd.js +0 -2
  156. package/dist/assets/renderMobileApp-BG1JJt1Q.js +0 -28
  157. package/dist/assets/runtimeConfig-C6matIEB.js +0 -2
  158. package/dist/assets/runtimeEndpointReset-kgwmUJDb.js +0 -147
  159. package/dist/assets/sortable-tabs-strip-C8_9kHqu.js +0 -1
  160. package/dist/assets/src-B-P5qIBc.css +0 -2
  161. package/dist/assets/terminalApi-947l7Ra3.js +0 -1
  162. package/dist/assets/useAssistantUIStore-me6s63b2.js +0 -1
  163. package/dist/assets/useDesktopWindowControlsLayout-DuZ6URPR.js +0 -1
  164. package/dist/assets/useFontPreferences-BCOBlaAd.js +0 -1
  165. package/dist/assets/useMobileNavigationStore-C8_J-qTp.js +0 -413
  166. package/dist/assets/useSessionAutoCleanup-FOQeu1_T.js +0 -1
  167. package/dist/assets/useUIStore-CuaA8-Hp.js +0 -1
  168. package/dist/assets/useWorkerHighlightedLines-GBqKnki1.js +0 -7
@@ -0,0 +1,436 @@
1
+ /**
2
+ * Real express + core-routes binary middleware + assistants routes.
3
+ * Isolated loopback / tempdir — no :3001, no user DB.
4
+ *
5
+ * Fixture layout (ephemeral):
6
+ * <tmpdir>/
7
+ * assistants-routes.sqlite
8
+ * prompt-attachments/ (content-addressed bytes via real fs store)
9
+ */
10
+ import fs from 'node:fs';
11
+ import fsp from 'node:fs/promises';
12
+ import http from 'node:http';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { createHash } from 'node:crypto';
16
+ import { createRequire } from 'node:module';
17
+ import express from 'express';
18
+ import { afterEach, describe, expect, it } from 'vitest';
19
+ import { registerCommonRequestMiddleware } from '../opencode/core-routes.js';
20
+ import { isSafeInlineImageMime } from './contact-attachments.js';
21
+ import { AssistantError, createAssistantsService } from './service.js';
22
+
23
+ const require = createRequire(import.meta.url);
24
+
25
+ const mkRoot = () => fs.mkdtempSync(path.join(os.tmpdir(), 'contact-att-http-'));
26
+ const closers = [];
27
+
28
+ const listen = (app) => new Promise((resolve) => {
29
+ const server = http.createServer(app);
30
+ server.listen(0, '127.0.0.1', () => {
31
+ const { port } = server.address();
32
+ closers.push(() => new Promise((done) => server.close(() => done())));
33
+ resolve({ port, base: `http://127.0.0.1:${port}` });
34
+ });
35
+ });
36
+
37
+ const realPng = Buffer.from([
38
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
39
+ 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
40
+ 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
41
+ 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
42
+ 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
43
+ 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
44
+ 0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xfe,
45
+ 0xd4, 0xef, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
46
+ 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
47
+ ]);
48
+
49
+ afterEach(async () => {
50
+ while (closers.length) await closers.pop()();
51
+ });
52
+
53
+ /** Production attachment route handlers + core-routes raw middleware + mocked contact harness. */
54
+ const buildApp = (directory, serviceOverrides = {}) => {
55
+ const service = createAssistantsService({
56
+ dbPath: path.join(directory, 'assistants-routes.sqlite'),
57
+ dataDir: directory,
58
+ getAllowedRoots: () => [directory],
59
+ buildOpenCodeUrl: () => 'http://127.0.0.1:1',
60
+ getOpenCodeAuthHeaders: () => ({}),
61
+ clientFactory: () => ({
62
+ session: {
63
+ create: async () => ({ data: { id: 'ses_http' } }),
64
+ get: async () => ({ data: { id: 'ses_http' } }),
65
+ update: async () => ({ data: { id: 'ses_http' } }),
66
+ promptAsync: async () => ({ response: { status: 204 } }),
67
+ summarize: async () => ({ data: true }),
68
+ delete: async () => ({ data: true }),
69
+ },
70
+ provider: { list: async () => ({ data: { connected: [] } }) },
71
+ config: { providers: async () => ({ data: { providers: [] } }) },
72
+ }),
73
+ runContactTurn: async ({ userText }) => ({
74
+ text: `ok:${userText}`,
75
+ bubbles: [`ok:${userText}`],
76
+ }),
77
+ createChatCompletion: async () => ({ choices: [{ message: { content: 'x' } }] }),
78
+ ...serviceOverrides,
79
+ });
80
+ closers.push(async () => service.close());
81
+ service.setEnabled({ enabled: true, expectedRevision: service.snapshot().revision });
82
+
83
+ const app = express();
84
+ // Real core-routes binary exemption (must run before body parsers / attachment routes).
85
+ registerCommonRequestMiddleware(app, { express });
86
+ app.use((req, _res, next) => next());
87
+
88
+ const respond = (res, work, success = 200) => Promise.resolve()
89
+ .then(work)
90
+ .then((body) => res.status(success).json(body))
91
+ .catch((error) => {
92
+ const code = error instanceof AssistantError ? error.code : error?.code || 'internal_error';
93
+ const status = code === 'not_found' ? 404
94
+ : ['revision_conflict', 'idempotency_conflict', 'contact_generation_conflict'].includes(code) ? 409
95
+ : code === 'assistant_disabled' ? 403
96
+ : code === 'PROMPT_ATTACHMENT_TOO_LARGE' ? 413
97
+ : code === 'upstream_error' ? 502
98
+ : 400;
99
+ res.status(status).json({
100
+ ok: false,
101
+ error: code,
102
+ message: typeof error?.message === 'string' && error.message.trim() ? error.message : code,
103
+ });
104
+ });
105
+
106
+ // Same PUT/GET contract as packages/web/server/lib/assistants/routes.js
107
+ app.put('/api/openchamber/assistants/:assistantID/contact/attachments/:uploadID', (req, res) => {
108
+ const controller = new AbortController();
109
+ req.once?.('aborted', () => controller.abort());
110
+ respond(res, () => service.putAssistantContactAttachment(req.params.assistantID, req.params.uploadID, {
111
+ stream: req,
112
+ headers: req.headers,
113
+ signal: controller.signal,
114
+ }), 201);
115
+ });
116
+ app.get('/api/openchamber/assistants/:assistantID/contact/attachments/:attachmentID', (req, res) => {
117
+ Promise.resolve()
118
+ .then(() => service.getAssistantContactAttachment(req.params.assistantID, req.params.attachmentID))
119
+ .then((file) => {
120
+ res.status(200);
121
+ res.setHeader('Content-Type', file.mime || 'application/octet-stream');
122
+ res.setHeader('Content-Length', String(file.size));
123
+ res.setHeader('ETag', file.etag || `"${file.sha256}"`);
124
+ res.setHeader('X-Content-Type-Options', 'nosniff');
125
+ res.setHeader('Cache-Control', 'private, no-store');
126
+ const safeInline = isSafeInlineImageMime(file.mime);
127
+ const name = file.filename ? encodeURIComponent(file.filename) : 'attachment';
128
+ res.setHeader(
129
+ 'Content-Disposition',
130
+ `${safeInline ? 'inline' : 'attachment'}; filename*=UTF-8''${name}`,
131
+ );
132
+ res.end(file.buffer);
133
+ })
134
+ .catch((error) => {
135
+ const code = error instanceof AssistantError ? error.code : error?.code || 'internal_error';
136
+ const status = code === 'not_found' ? 404 : 400;
137
+ res.status(status).json({ ok: false, error: code, message: error?.message || code });
138
+ });
139
+ });
140
+
141
+ return { app, service, directory };
142
+ };
143
+
144
+ describe('contact attachments real HTTP (core-routes + assistants routes)', () => {
145
+ it('PNG + application/json real PUT 201 → GET bytes/headers → send descriptor → page refs', async () => {
146
+ const directory = mkRoot();
147
+ closers.push(async () => fsp.rm(directory, { recursive: true, force: true }).catch(() => {}));
148
+ const { app, service } = buildApp(directory);
149
+ const { base } = await listen(app);
150
+
151
+ const assistant = service.createAssistant({
152
+ name: 'HTTP Att2',
153
+ providerID: 'p',
154
+ modelID: 'm',
155
+ });
156
+
157
+ const digest = createHash('sha256').update(realPng).digest('hex');
158
+ const putRes = await fetch(
159
+ `${base}/api/openchamber/assistants/${encodeURIComponent(assistant.id)}/contact/attachments/up_png1`,
160
+ {
161
+ method: 'PUT',
162
+ headers: {
163
+ 'Content-Type': 'image/png',
164
+ 'X-Content-SHA256': digest,
165
+ 'X-Content-Size': String(realPng.length),
166
+ 'X-Attachment-Filename': encodeURIComponent('shot.png'),
167
+ },
168
+ body: realPng,
169
+ },
170
+ );
171
+ const descriptor = await putRes.json();
172
+ expect(putRes.status, JSON.stringify(descriptor)).toBe(201);
173
+ expect(descriptor).toMatchObject({
174
+ type: 'file',
175
+ attachmentID: expect.stringMatching(/^att_/),
176
+ sha256: digest,
177
+ size: realPng.length,
178
+ mime: 'image/png',
179
+ filename: 'shot.png',
180
+ });
181
+ expect(JSON.stringify(descriptor)).not.toContain('prompt-attachments');
182
+ expect(descriptor.url).toBeUndefined();
183
+
184
+ // application/json Content-Type must keep raw bytes (not express.json-parsed).
185
+ const jsonBytes = Buffer.from(JSON.stringify({ nested: true, blob: 'x'.repeat(64) }));
186
+ const jsonDigest = createHash('sha256').update(jsonBytes).digest('hex');
187
+ const putJson = await fetch(
188
+ `${base}/api/openchamber/assistants/${encodeURIComponent(assistant.id)}/contact/attachments/up_json_raw`,
189
+ {
190
+ method: 'PUT',
191
+ headers: {
192
+ 'Content-Type': 'application/json',
193
+ 'X-Content-SHA256': jsonDigest,
194
+ 'X-Content-Size': String(jsonBytes.length),
195
+ 'X-Attachment-Filename': encodeURIComponent('payload.json'),
196
+ },
197
+ body: jsonBytes,
198
+ },
199
+ );
200
+ const jsonDesc = await putJson.json();
201
+ expect(putJson.status, JSON.stringify(jsonDesc)).toBe(201);
202
+ expect(jsonDesc.sha256).toBe(jsonDigest);
203
+ expect(jsonDesc.size).toBe(jsonBytes.length);
204
+ expect(jsonDesc.mime).toBe('application/json');
205
+
206
+ const sent = await service.send(assistant.id, {
207
+ messageID: 'http_att_msg_1',
208
+ parts: [
209
+ { type: 'text', text: 'see image' },
210
+ {
211
+ type: 'file',
212
+ mime: descriptor.mime,
213
+ attachmentID: descriptor.attachmentID,
214
+ sha256: descriptor.sha256,
215
+ size: descriptor.size,
216
+ filename: descriptor.filename,
217
+ },
218
+ ],
219
+ });
220
+ expect(sent.admitted).toBe(true);
221
+ const settled = await service.whenContactTurnSettled(sent.messageID);
222
+ expect(settled, JSON.stringify(settled)).toMatchObject({ status: 'complete' });
223
+
224
+ const page = service.contactMessages(assistant.id, { limit: 20 });
225
+ const pageJson = JSON.stringify(page);
226
+ expect(pageJson).not.toMatch(/data:image/);
227
+ expect(pageJson).not.toMatch(/base64,[A-Za-z0-9+/]{20,}/);
228
+ const user = page.messages.find((m) => m.role === 'user');
229
+ expect(user.parts.some((p) => p.attachmentID === descriptor.attachmentID)).toBe(true);
230
+ expect(user.parts.find((p) => p.attachmentID)?.url).toBeUndefined();
231
+
232
+ const getPng = await fetch(
233
+ `${base}/api/openchamber/assistants/${encodeURIComponent(assistant.id)}/contact/attachments/${encodeURIComponent(descriptor.attachmentID)}`,
234
+ );
235
+ expect(getPng.status).toBe(200);
236
+ const gotPng = Buffer.from(await getPng.arrayBuffer());
237
+ expect(gotPng.equals(realPng)).toBe(true);
238
+ expect(getPng.headers.get('content-type')).toMatch(/image\/png/i);
239
+ expect(getPng.headers.get('content-length')).toBe(String(realPng.length));
240
+ expect(getPng.headers.get('etag')).toContain(digest);
241
+ expect(getPng.headers.get('x-content-type-options')).toBe('nosniff');
242
+ expect(getPng.headers.get('cache-control')).toMatch(/no-store/i);
243
+ expect(getPng.headers.get('content-disposition')).toMatch(/^inline/i);
244
+
245
+ const getJson = await fetch(
246
+ `${base}/api/openchamber/assistants/${encodeURIComponent(assistant.id)}/contact/attachments/${encodeURIComponent(jsonDesc.attachmentID)}`,
247
+ );
248
+ expect(getJson.status).toBe(200);
249
+ const gotJson = Buffer.from(await getJson.arrayBuffer());
250
+ expect(gotJson.equals(jsonBytes)).toBe(true);
251
+ expect(getJson.headers.get('content-disposition')).toMatch(/^attachment/i);
252
+ expect(getJson.headers.get('cache-control')).toMatch(/no-store/i);
253
+
254
+ expect(directory.startsWith(os.tmpdir()) || directory.includes('/T/')).toBe(true);
255
+ });
256
+ });
257
+
258
+ describe('contact attachment materialize failure terminal settle', () => {
259
+ it('deleting bytes after admit causes durable error, working=false, next turn ok', async () => {
260
+ const directory = mkRoot();
261
+ closers.push(async () => fsp.rm(directory, { recursive: true, force: true }).catch(() => {}));
262
+
263
+ let materializeEntered = false;
264
+
265
+ const service = createAssistantsService({
266
+ dbPath: path.join(directory, 'assistants.sqlite'),
267
+ dataDir: directory,
268
+ getAllowedRoots: () => [directory],
269
+ buildOpenCodeUrl: () => 'http://127.0.0.1:1',
270
+ getOpenCodeAuthHeaders: () => ({}),
271
+ clientFactory: () => ({
272
+ session: {
273
+ create: async () => ({ data: { id: 'ses_m' } }),
274
+ get: async () => ({ data: { id: 'ses_m' } }),
275
+ update: async () => ({ data: { id: 'ses_m' } }),
276
+ promptAsync: async () => ({ response: { status: 204 } }),
277
+ summarize: async () => ({ data: true }),
278
+ delete: async () => ({ data: true }),
279
+ },
280
+ }),
281
+ runContactTurn: async () => {
282
+ materializeEntered = true;
283
+ return { text: 'should-not', bubbles: ['should-not'] };
284
+ },
285
+ createChatCompletion: async () => ({ choices: [{ message: { content: 'x' } }] }),
286
+ });
287
+ closers.push(async () => service.close());
288
+ service.setEnabled({ enabled: true, expectedRevision: service.snapshot().revision });
289
+ const assistant = service.createAssistant({ name: 'Fail Mat', providerID: 'p', modelID: 'm' });
290
+
291
+ const digest = createHash('sha256').update(realPng).digest('hex');
292
+ const descriptor = await service.putAssistantContactAttachment(assistant.id, 'up_fail1', {
293
+ stream: { async *[Symbol.asyncIterator]() { yield realPng; } },
294
+ headers: {
295
+ 'content-type': 'image/png',
296
+ 'x-content-sha256': digest,
297
+ 'x-content-size': String(realPng.length),
298
+ 'x-attachment-filename': 'gone.png',
299
+ },
300
+ });
301
+
302
+ const storeRoot = path.join(directory, 'prompt-attachments');
303
+ const found = [];
304
+ const walk = (dir) => {
305
+ for (const name of fs.readdirSync(dir)) {
306
+ const full = path.join(dir, name);
307
+ if (fs.statSync(full).isDirectory()) walk(full);
308
+ else found.push(full);
309
+ }
310
+ };
311
+ if (fs.existsSync(storeRoot)) walk(storeRoot);
312
+ expect(found.length).toBeGreaterThan(0);
313
+ const filePath = found[0];
314
+ fs.unlinkSync(filePath);
315
+ expect(fs.existsSync(filePath)).toBe(false);
316
+
317
+ const sent = await service.send(assistant.id, {
318
+ messageID: 'msg_mat_fail',
319
+ parts: [
320
+ { type: 'text', text: 'with missing file' },
321
+ {
322
+ type: 'file',
323
+ mime: 'image/png',
324
+ attachmentID: descriptor.attachmentID,
325
+ sha256: descriptor.sha256,
326
+ size: descriptor.size,
327
+ filename: 'gone.png',
328
+ },
329
+ ],
330
+ });
331
+ expect(sent.admitted).toBe(true);
332
+
333
+ const settled = await service.whenContactTurnSettled('msg_mat_fail');
334
+ expect(settled.status).toBe('error');
335
+ expect(materializeEntered).toBe(false);
336
+
337
+ const snap = service.snapshot().assistants.find((a) => a.id === assistant.id);
338
+ expect(snap.working).toBe(false);
339
+ expect(snap.activeContactTurn).toBeNull();
340
+
341
+ const page = service.contactMessages(assistant.id, { limit: 20 });
342
+ expect(page.messages.some((m) => m.status === 'error' || (m.role === 'assistant' && m.text))).toBe(true);
343
+ const pageJson = JSON.stringify(page);
344
+ expect(pageJson).not.toMatch(/data:image\/png;base64,[A-Za-z0-9+/]{40,}/);
345
+
346
+ const next = await service.send(assistant.id, {
347
+ messageID: 'msg_mat_next',
348
+ parts: [{ type: 'text', text: 'plain next' }],
349
+ });
350
+ expect(next.admitted).toBe(true);
351
+ const nextSettled = await service.whenContactTurnSettled('msg_mat_next');
352
+ expect(nextSettled.status).toBe('complete');
353
+ expect(service.snapshot().assistants.find((a) => a.id === assistant.id).working).toBe(false);
354
+ });
355
+ });
356
+
357
+ describe('migration write failure + close cancel', () => {
358
+ it('disk write failure inject leaves original part_json; sibling migrates', async () => {
359
+ const { migrateContactDataUrlParts } = await import('./contact-attachments.js');
360
+ const { ensureContactSchema, insertContactMessage, nextContactOrdinal } = await import('./contact-store.js');
361
+ const { ensureContactAttachmentSchema } = await import('./contact-attachments.js');
362
+ const { storePromptAttachmentBytes } = await import('../fs/prompt-attachment-store.js');
363
+ const directory = mkRoot();
364
+ closers.push(async () => fsp.rm(directory, { recursive: true, force: true }).catch(() => {}));
365
+ const Database = require('better-sqlite3');
366
+ const db = new Database(path.join(directory, 'm.sqlite'));
367
+ ensureContactSchema(db);
368
+ ensureContactAttachmentSchema(db);
369
+
370
+ const goodUrl = `data:image/png;base64,${realPng.toString('base64')}`;
371
+ insertContactMessage(db, {
372
+ messageID: 'msg_disk_a',
373
+ assistantID: 'asst_m',
374
+ role: 'user',
375
+ turnID: 'msg_disk_a',
376
+ bubbleIndex: 0,
377
+ createdAt: Date.now(),
378
+ ordinal: nextContactOrdinal(db, 'asst_m'),
379
+ status: 'complete',
380
+ parts: [{ type: 'file', mime: 'image/png', url: goodUrl, filename: 'a.png' }],
381
+ });
382
+ insertContactMessage(db, {
383
+ messageID: 'msg_disk_b',
384
+ assistantID: 'asst_m',
385
+ role: 'user',
386
+ turnID: 'msg_disk_b',
387
+ bubbleIndex: 0,
388
+ createdAt: Date.now(),
389
+ ordinal: nextContactOrdinal(db, 'asst_m'),
390
+ status: 'complete',
391
+ parts: [{ type: 'file', mime: 'image/png', url: goodUrl, filename: 'b.png' }],
392
+ });
393
+ const beforeA = db.prepare('SELECT part_json FROM assistant_contact_part WHERE message_id=?').get('msg_disk_a').part_json;
394
+ let calls = 0;
395
+ const result = await migrateContactDataUrlParts({
396
+ db,
397
+ dataDir: directory,
398
+ assistantID: 'asst_m',
399
+ storeBytes: async (args) => {
400
+ calls += 1;
401
+ if (calls === 1) {
402
+ const err = new Error('EIO simulated');
403
+ err.code = 'EIO';
404
+ throw err;
405
+ }
406
+ return storePromptAttachmentBytes(args);
407
+ },
408
+ });
409
+ expect(result.failed).toBeGreaterThanOrEqual(1);
410
+ expect(result.failures.some((f) => f.error === 'EIO')).toBe(true);
411
+ const afterA = db.prepare('SELECT part_json FROM assistant_contact_part WHERE message_id=?').get('msg_disk_a').part_json;
412
+ expect(afterA).toBe(beforeA);
413
+ expect(result.migrated).toBeGreaterThanOrEqual(1);
414
+ const afterB = db.prepare('SELECT part_json FROM assistant_contact_part WHERE message_id=?').get('msg_disk_b').part_json;
415
+ expect(afterB).toContain('attachmentID');
416
+ expect(afterB).not.toContain('data:image/png;base64,');
417
+ db.close();
418
+ });
419
+
420
+ it('service.close aborts in-flight migration controller', async () => {
421
+ const directory = mkRoot();
422
+ closers.push(async () => fsp.rm(directory, { recursive: true, force: true }).catch(() => {}));
423
+ const service = createAssistantsService({
424
+ dbPath: path.join(directory, 'assistants.sqlite'),
425
+ dataDir: directory,
426
+ getAllowedRoots: () => [directory],
427
+ buildOpenCodeUrl: () => 'http://127.0.0.1:1',
428
+ getOpenCodeAuthHeaders: () => ({}),
429
+ clientFactory: () => ({ session: { create: async () => ({ data: { id: 's' } }), get: async () => ({ data: { id: 's' } }), update: async () => ({ data: {} }), promptAsync: async () => ({ response: { status: 204 } }), summarize: async () => ({ data: true }), delete: async () => ({ data: true }) } }),
430
+ runContactTurn: async () => ({ text: 'x', bubbles: ['x'] }),
431
+ createChatCompletion: async () => ({ choices: [{ message: { content: 'x' } }] }),
432
+ });
433
+ expect(() => service.close()).not.toThrow();
434
+ expect(() => service.close()).not.toThrow();
435
+ });
436
+ });