@openchambery/web 1.19.13-beta.1 → 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 (146) hide show
  1. package/dist/assets/{AgentAvatar-CiQIIM0d.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-CeKHYyKZ.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-B-odd2Gz.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-BT5Dwgzu.js → MultiRunWindow-DDq6C8t7.js} +1 -1
  13. package/dist/assets/OnboardingScreen-j0iCyH7f.js +1 -0
  14. package/dist/assets/{OverlayScrollbar-DUghqFGg.js → OverlayScrollbar-CDC5B3RH.js} +1 -1
  15. package/dist/assets/SettingsView-DFWWMvAj.js +25 -0
  16. package/dist/assets/{SettingsWindow-DfEGfyr8.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-CIZ-shMD.js → appThemeRegistry-UIrP0-5h.js} +1 -1
  20. package/dist/assets/assistant-attachment-cache-C0aRnFkj.js +1 -0
  21. package/dist/assets/{button-DFPOdZ0g.js → button-xx1UOBA5.js} +1 -1
  22. package/dist/assets/{clipboard-C05anBwU.js → clipboard-CUasgPiF.js} +1 -1
  23. package/dist/assets/{collapsible-oGbEvbe1.js → collapsible-CACHBRYZ.js} +1 -1
  24. package/dist/assets/{context-menu-DGz4_2hs.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-Csk5GpDC.js → diagram-u0BC9uuY.js} +1 -1
  28. package/dist/assets/{dialog-rrqB-H6q.js → dialog-BwQ3rY_x.js} +1 -1
  29. package/dist/assets/dualLimitLru-D8Sphgua.js +1 -0
  30. package/dist/assets/{es-FE0sJ9EC.js → es-CRjOwvYM.js} +1 -1
  31. package/dist/assets/fileQueries-Bgly-E8f.js +1 -0
  32. package/dist/assets/{fr-DNDCSfGt.js → fr-D9taEN9L.js} +1 -1
  33. package/dist/assets/{gitApi-_P0FsoXe.js → gitApi-Bu4W3bgD.js} +1 -1
  34. package/dist/assets/imageSource-BlAJJUEI.js +1 -0
  35. package/dist/assets/{input-BO3r20op.js → input-CI730njs.js} +1 -1
  36. package/dist/assets/insertionBoundaries-B1AqxsBD.js +4 -0
  37. package/dist/assets/{ja-BDwrk0cM.js → ja-CYQ5aoWp.js} +1 -1
  38. package/dist/assets/{ko-BeeU2o4I.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-CxhTsmRq.js → multirun-CG_FCb_Z.js} +1 -1
  47. package/dist/assets/persistence-YbQKzHUQ.js +3 -0
  48. package/dist/assets/{pl-BYuzpcI5.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-Bn7cwEzv.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-BBLFgl85.js → runtimeEndpointReset-CSub_ecV.js} +6 -6
  60. package/dist/assets/{sessionChangeSummary-DDVT7O5J.js → sessionChangeSummary-DQDZ5HW-.js} +1 -1
  61. package/dist/assets/{sessionLookup-C8VZn0gJ.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-n94dSb3M.js → store-CSnH_QXM.js} +2 -2
  65. package/dist/assets/terminalApi-noa4Cdbh.js +1 -0
  66. package/dist/assets/{textarea-BjX-KhMN.js → textarea-BfNtUdJE.js} +1 -1
  67. package/dist/assets/{tooltip-CQGqGAG_.js → tooltip-DPxumadF.js} +1 -1
  68. package/dist/assets/{uk-CwpbHEa3.js → uk-C1FYZlmA.js} +1 -1
  69. package/dist/assets/{url-BMjqccCo.js → url-CVhsI9kR.js} +1 -1
  70. package/dist/assets/{useAppFontEffects-BIFw36SA.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-DJSYYkVp.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-81nUI_xp.js → utils-BHTVlVov.js} +1 -1
  80. package/dist/assets/{zh-CN-BpzFZ-LU.js → zh-CN-C96qP1ip.js} +1 -1
  81. package/dist/assets/{zh-TW-Bopgc5Eo.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/lib/assistant-delivery-parts.js +19 -1
  87. package/server/lib/assistants/DOCUMENTATION.md +39 -7
  88. package/server/lib/assistants/cards.js +16 -2
  89. package/server/lib/assistants/cards.test.js +16 -1
  90. package/server/lib/assistants/contact-attachments.http.test.js +436 -0
  91. package/server/lib/assistants/contact-attachments.js +814 -0
  92. package/server/lib/assistants/contact-attachments.p1.test.js +307 -0
  93. package/server/lib/assistants/contact-attachments.test.js +249 -0
  94. package/server/lib/assistants/contact-store.js +494 -25
  95. package/server/lib/assistants/contact-store.test.js +403 -1
  96. package/server/lib/assistants/contact-tools.js +6 -5
  97. package/server/lib/assistants/contact-tools.test.js +24 -6
  98. package/server/lib/assistants/contracts.js +41 -1
  99. package/server/lib/assistants/harness.test.js +124 -0
  100. package/server/lib/assistants/routes.js +46 -1
  101. package/server/lib/assistants/service.js +258 -66
  102. package/server/lib/assistants/service.test.js +400 -30
  103. package/server/lib/fs/prompt-attachment-store.js +663 -0
  104. package/server/lib/fs/prompt-attachment-store.test.js +263 -0
  105. package/server/lib/fs/routes.js +28 -72
  106. package/server/lib/fs/routes.test.js +15 -30
  107. package/server/lib/opencode/core-routes.js +2 -1
  108. package/dist/assets/AssistantView-YmeyxehG.js +0 -2
  109. package/dist/assets/DiagramView-BpZb-G9P.js +0 -1
  110. package/dist/assets/FadeInOnReveal-nf6-ZCrs.js +0 -1
  111. package/dist/assets/MarkdownRenderer-wpeGdciL.js +0 -2
  112. package/dist/assets/MarkdownRendererImpl-Peu0GVSQ.js +0 -7
  113. package/dist/assets/MobileDetailNavigation-CtYke_1C.js +0 -1
  114. package/dist/assets/MobileShareBridge-DIfOl8EZ.js +0 -4
  115. package/dist/assets/MobileSurface-_KoX75Ff.js +0 -2
  116. package/dist/assets/OnboardingScreen-BPusUgFG.js +0 -1
  117. package/dist/assets/SettingsView-BwQpbIZl.js +0 -25
  118. package/dist/assets/TerminalView-8402vpcO.js +0 -8
  119. package/dist/assets/ToolOutputDialog-B5RdkhsG.js +0 -10
  120. package/dist/assets/desktopHosts-BVJXQq1S.js +0 -1
  121. package/dist/assets/desktopRelayRestore-RDfuj1M7.js +0 -1
  122. package/dist/assets/fileQueries-BcdWOPlD.js +0 -1
  123. package/dist/assets/insertionBoundaries-CWoioTdE.js +0 -4
  124. package/dist/assets/main-CLj_WS1o.js +0 -2
  125. package/dist/assets/main-CnSZ89pD.js +0 -169
  126. package/dist/assets/markdown-worker-CZWE1F1M.js +0 -2
  127. package/dist/assets/markdownHeightCache-Cdd3hkMl.js +0 -3
  128. package/dist/assets/markdownTheme-DIB8WVmz.js +0 -2
  129. package/dist/assets/miniChat-B9c3y2pD.js +0 -2
  130. package/dist/assets/mobile-DvbXUfNS.js +0 -2
  131. package/dist/assets/persistence-CVTWFaqe.js +0 -3
  132. package/dist/assets/platform-BY_Gtsqr.js +0 -2
  133. package/dist/assets/projectMeta-Dl5iPCB6.js +0 -3
  134. package/dist/assets/renderElectronMiniChatApp-CkJ51qCh.js +0 -2
  135. package/dist/assets/renderMobileApp-DcjQPhWw.js +0 -28
  136. package/dist/assets/runtimeConfig-Bsk2kWsW.js +0 -2
  137. package/dist/assets/sortable-tabs-strip-s7UUxsa6.js +0 -1
  138. package/dist/assets/src-BpHDMgud.css +0 -2
  139. package/dist/assets/terminalApi-C54_rKEK.js +0 -1
  140. package/dist/assets/useAssistantUIStore-Cg9bsT4b.js +0 -1
  141. package/dist/assets/useDesktopWindowControlsLayout-DoygEcN4.js +0 -1
  142. package/dist/assets/useFontPreferences-B5_apbBC.js +0 -1
  143. package/dist/assets/useMobileNavigationStore-DuTu0Mzh.js +0 -413
  144. package/dist/assets/useSessionAutoCleanup-C-L9teva.js +0 -1
  145. package/dist/assets/useUIStore-DwpQTjLO.js +0 -1
  146. package/dist/assets/useWorkerHighlightedLines-DxTfgdEi.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
+ });