@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.
- package/dist/assets/{AgentAvatar-CiQIIM0d.js → AgentAvatar-mbvX7YlW.js} +1 -1
- package/dist/assets/AssistantView-D5c557Oe.js +2 -0
- package/dist/assets/DiagramView-CjNUrHsU.js +1 -0
- package/dist/assets/FadeInOnReveal-moBbL8Ll.js +1 -0
- package/dist/assets/{Icon-CeKHYyKZ.js → Icon-CkzzhaLr.js} +1 -1
- package/dist/assets/MarkdownRenderer-BHfbAwVO.js +2 -0
- package/dist/assets/MarkdownRendererImpl-CkBoUibc.js +7 -0
- package/dist/assets/{MarkstreamRendererImpl-B-odd2Gz.js → MarkstreamRendererImpl-BrXUOI9g.js} +1 -1
- package/dist/assets/MobileDetailNavigation-aY9-HwBN.js +1 -0
- package/dist/assets/MobileShareBridge-CRt_8DmM.js +4 -0
- package/dist/assets/MobileSurface-CIhtF-CU.js +2 -0
- package/dist/assets/{MultiRunWindow-BT5Dwgzu.js → MultiRunWindow-DDq6C8t7.js} +1 -1
- package/dist/assets/OnboardingScreen-j0iCyH7f.js +1 -0
- package/dist/assets/{OverlayScrollbar-DUghqFGg.js → OverlayScrollbar-CDC5B3RH.js} +1 -1
- package/dist/assets/SettingsView-DFWWMvAj.js +25 -0
- package/dist/assets/{SettingsWindow-DfEGfyr8.js → SettingsWindow-CnYfBplB.js} +1 -1
- package/dist/assets/TerminalView-D5CQFVVm.js +8 -0
- package/dist/assets/ToolOutputDialog-VMI7TQFT.js +10 -0
- package/dist/assets/{appThemeRegistry-CIZ-shMD.js → appThemeRegistry-UIrP0-5h.js} +1 -1
- package/dist/assets/assistant-attachment-cache-C0aRnFkj.js +1 -0
- package/dist/assets/{button-DFPOdZ0g.js → button-xx1UOBA5.js} +1 -1
- package/dist/assets/{clipboard-C05anBwU.js → clipboard-CUasgPiF.js} +1 -1
- package/dist/assets/{collapsible-oGbEvbe1.js → collapsible-CACHBRYZ.js} +1 -1
- package/dist/assets/{context-menu-DGz4_2hs.js → context-menu-CYbm4wBZ.js} +1 -1
- package/dist/assets/desktopHosts-DILvrAok.js +1 -0
- package/dist/assets/desktopRelayRestore-t8asCr3m.js +1 -0
- package/dist/assets/{diagram-Csk5GpDC.js → diagram-u0BC9uuY.js} +1 -1
- package/dist/assets/{dialog-rrqB-H6q.js → dialog-BwQ3rY_x.js} +1 -1
- package/dist/assets/dualLimitLru-D8Sphgua.js +1 -0
- package/dist/assets/{es-FE0sJ9EC.js → es-CRjOwvYM.js} +1 -1
- package/dist/assets/fileQueries-Bgly-E8f.js +1 -0
- package/dist/assets/{fr-DNDCSfGt.js → fr-D9taEN9L.js} +1 -1
- package/dist/assets/{gitApi-_P0FsoXe.js → gitApi-Bu4W3bgD.js} +1 -1
- package/dist/assets/imageSource-BlAJJUEI.js +1 -0
- package/dist/assets/{input-BO3r20op.js → input-CI730njs.js} +1 -1
- package/dist/assets/insertionBoundaries-B1AqxsBD.js +4 -0
- package/dist/assets/{ja-BDwrk0cM.js → ja-CYQ5aoWp.js} +1 -1
- package/dist/assets/{ko-BeeU2o4I.js → ko-BwASV0Q0.js} +1 -1
- package/dist/assets/main-CqcB6tKc.js +169 -0
- package/dist/assets/main-DG-k0j8A.js +2 -0
- package/dist/assets/markdown-worker-CptoICmq.js +2 -0
- package/dist/assets/markdownHeightCache-DEjGKLv8.js +3 -0
- package/dist/assets/markdownTheme-DOfGg-A7.js +1 -0
- package/dist/assets/miniChat-BQtYQrsh.js +2 -0
- package/dist/assets/mobile-8iyqtXkr.js +2 -0
- package/dist/assets/{multirun-CxhTsmRq.js → multirun-CG_FCb_Z.js} +1 -1
- package/dist/assets/persistence-YbQKzHUQ.js +3 -0
- package/dist/assets/{pl-BYuzpcI5.js → pl-DUI1vPil.js} +1 -1
- package/dist/assets/platform-DzsGqlGW.js +2 -0
- package/dist/assets/projectMeta-DMpMDWE1.js +3 -0
- package/dist/assets/{pt-BR-Bn7cwEzv.js → pt-BR-CZQFJllP.js} +1 -1
- package/dist/assets/relay-image-stream-DM7atqJs.js +2 -0
- package/dist/assets/renderElectronMiniChatApp-DBzRdJl8.js +2 -0
- package/dist/assets/renderMobileApp-CIseqCyn.js +28 -0
- package/dist/assets/runtime-auth-D66yq6ie.js +1 -0
- package/dist/assets/runtime-fetch-zIalUnbb.js +1 -0
- package/dist/assets/runtime-switch-LvWDU-SL.js +1 -0
- package/dist/assets/runtimeConfig-D8nJYB-e.js +2 -0
- package/dist/assets/{runtimeEndpointReset-BBLFgl85.js → runtimeEndpointReset-CSub_ecV.js} +6 -6
- package/dist/assets/{sessionChangeSummary-DDVT7O5J.js → sessionChangeSummary-DQDZ5HW-.js} +1 -1
- package/dist/assets/{sessionLookup-C8VZn0gJ.js → sessionLookup-BiBD-4wC.js} +1 -1
- package/dist/assets/sortable-tabs-strip-Dom6Sthu.js +1 -0
- package/dist/assets/src-AA-5nXsF.css +2 -0
- package/dist/assets/{store-n94dSb3M.js → store-CSnH_QXM.js} +2 -2
- package/dist/assets/terminalApi-noa4Cdbh.js +1 -0
- package/dist/assets/{textarea-BjX-KhMN.js → textarea-BfNtUdJE.js} +1 -1
- package/dist/assets/{tooltip-CQGqGAG_.js → tooltip-DPxumadF.js} +1 -1
- package/dist/assets/{uk-CwpbHEa3.js → uk-C1FYZlmA.js} +1 -1
- package/dist/assets/{url-BMjqccCo.js → url-CVhsI9kR.js} +1 -1
- package/dist/assets/{useAppFontEffects-BIFw36SA.js → useAppFontEffects-qHt5uaFN.js} +32 -32
- package/dist/assets/useAssistantUIStore-CLPCNoj_.js +1 -0
- package/dist/assets/useDesktopWindowControlsLayout-BipyIXiK.js +1 -0
- package/dist/assets/{useEffectiveDirectory-DJSYYkVp.js → useEffectiveDirectory-DSNCCbCt.js} +1 -1
- package/dist/assets/useFontPreferences-CB-cGo3I.js +1 -0
- package/dist/assets/useMobileNavigationStore-C1NJGAik.js +413 -0
- package/dist/assets/useSessionAutoCleanup-Dcn6Unvd.js +2 -0
- package/dist/assets/useUIStore-CxOoCv7B.js +1 -0
- package/dist/assets/useWorkerHighlightedLines-CzpT4M7V.js +7 -0
- package/dist/assets/{utils-81nUI_xp.js → utils-BHTVlVov.js} +1 -1
- package/dist/assets/{zh-CN-BpzFZ-LU.js → zh-CN-C96qP1ip.js} +1 -1
- package/dist/assets/{zh-TW-Bopgc5Eo.js → zh-TW-BsRk1Rbb.js} +1 -1
- package/dist/index.html +12 -9
- package/dist/mini-chat.html +12 -9
- package/dist/mobile.html +12 -9
- package/package.json +1 -1
- package/server/lib/assistant-delivery-parts.js +19 -1
- package/server/lib/assistants/DOCUMENTATION.md +39 -7
- package/server/lib/assistants/cards.js +16 -2
- package/server/lib/assistants/cards.test.js +16 -1
- package/server/lib/assistants/contact-attachments.http.test.js +436 -0
- package/server/lib/assistants/contact-attachments.js +814 -0
- package/server/lib/assistants/contact-attachments.p1.test.js +307 -0
- package/server/lib/assistants/contact-attachments.test.js +249 -0
- package/server/lib/assistants/contact-store.js +494 -25
- package/server/lib/assistants/contact-store.test.js +403 -1
- package/server/lib/assistants/contact-tools.js +6 -5
- package/server/lib/assistants/contact-tools.test.js +24 -6
- package/server/lib/assistants/contracts.js +41 -1
- package/server/lib/assistants/harness.test.js +124 -0
- package/server/lib/assistants/routes.js +46 -1
- package/server/lib/assistants/service.js +258 -66
- package/server/lib/assistants/service.test.js +400 -30
- package/server/lib/fs/prompt-attachment-store.js +663 -0
- package/server/lib/fs/prompt-attachment-store.test.js +263 -0
- package/server/lib/fs/routes.js +28 -72
- package/server/lib/fs/routes.test.js +15 -30
- package/server/lib/opencode/core-routes.js +2 -1
- package/dist/assets/AssistantView-YmeyxehG.js +0 -2
- package/dist/assets/DiagramView-BpZb-G9P.js +0 -1
- package/dist/assets/FadeInOnReveal-nf6-ZCrs.js +0 -1
- package/dist/assets/MarkdownRenderer-wpeGdciL.js +0 -2
- package/dist/assets/MarkdownRendererImpl-Peu0GVSQ.js +0 -7
- package/dist/assets/MobileDetailNavigation-CtYke_1C.js +0 -1
- package/dist/assets/MobileShareBridge-DIfOl8EZ.js +0 -4
- package/dist/assets/MobileSurface-_KoX75Ff.js +0 -2
- package/dist/assets/OnboardingScreen-BPusUgFG.js +0 -1
- package/dist/assets/SettingsView-BwQpbIZl.js +0 -25
- package/dist/assets/TerminalView-8402vpcO.js +0 -8
- package/dist/assets/ToolOutputDialog-B5RdkhsG.js +0 -10
- package/dist/assets/desktopHosts-BVJXQq1S.js +0 -1
- package/dist/assets/desktopRelayRestore-RDfuj1M7.js +0 -1
- package/dist/assets/fileQueries-BcdWOPlD.js +0 -1
- package/dist/assets/insertionBoundaries-CWoioTdE.js +0 -4
- package/dist/assets/main-CLj_WS1o.js +0 -2
- package/dist/assets/main-CnSZ89pD.js +0 -169
- package/dist/assets/markdown-worker-CZWE1F1M.js +0 -2
- package/dist/assets/markdownHeightCache-Cdd3hkMl.js +0 -3
- package/dist/assets/markdownTheme-DIB8WVmz.js +0 -2
- package/dist/assets/miniChat-B9c3y2pD.js +0 -2
- package/dist/assets/mobile-DvbXUfNS.js +0 -2
- package/dist/assets/persistence-CVTWFaqe.js +0 -3
- package/dist/assets/platform-BY_Gtsqr.js +0 -2
- package/dist/assets/projectMeta-Dl5iPCB6.js +0 -3
- package/dist/assets/renderElectronMiniChatApp-CkJ51qCh.js +0 -2
- package/dist/assets/renderMobileApp-DcjQPhWw.js +0 -28
- package/dist/assets/runtimeConfig-Bsk2kWsW.js +0 -2
- package/dist/assets/sortable-tabs-strip-s7UUxsa6.js +0 -1
- package/dist/assets/src-BpHDMgud.css +0 -2
- package/dist/assets/terminalApi-C54_rKEK.js +0 -1
- package/dist/assets/useAssistantUIStore-Cg9bsT4b.js +0 -1
- package/dist/assets/useDesktopWindowControlsLayout-DoygEcN4.js +0 -1
- package/dist/assets/useFontPreferences-B5_apbBC.js +0 -1
- package/dist/assets/useMobileNavigationStore-DuTu0Mzh.js +0 -413
- package/dist/assets/useSessionAutoCleanup-C-L9teva.js +0 -1
- package/dist/assets/useUIStore-DwpQTjLO.js +0 -1
- 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
|
+
});
|