Skip to content

Commit fa5ab73

Browse files
scopsycursoragent
andauthored
fix(api): normalize agent attachments through storage fixes NV-7456 (#10942)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent ca7b9eb commit fa5ab73

10 files changed

Lines changed: 1415 additions & 37 deletions

.coderabbit.yaml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,7 @@ reviews:
7373
- Keep the entire summary under 300 words.
7474
auto_review:
7575
enabled: true
76-
drafts: false
77-
ignore_title_keywords:
78-
- "WIP"
79-
- "DO NOT MERGE"
76+
drafts: true
8077
auto_pause_after_reviewed_commits: 5
8178
path_instructions:
8279
- path: "apps/api/**"

apps/api/src/app/agents/agents.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { EventsModule } from '../events/events.module';
1111
import { SharedModule } from '../shared/shared.module';
1212
import { AgentsController } from './agents.controller';
1313
import { AgentsWebhookController } from './agents-webhook.controller';
14+
import { AgentAttachmentStorage } from './services/agent-attachment-storage.service';
1415
import { AgentConfigResolver } from './services/agent-config-resolver.service';
1516
import { AgentConversationService } from './services/agent-conversation.service';
1617
import { AgentInboundHandler } from './services/agent-inbound-handler.service';
@@ -28,6 +29,7 @@ import { USE_CASES } from './usecases';
2829
ChannelEndpointRepository,
2930
ConversationRepository,
3031
ConversationActivityRepository,
32+
AgentAttachmentStorage,
3133
AgentConfigResolver,
3234
AgentSubscriberResolver,
3335
AgentConversationService,
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import type { StorageService } from '@novu/application-generic';
2+
import { expect } from 'chai';
3+
import type { Attachment } from 'chat';
4+
import sinon from 'sinon';
5+
import { AgentAttachmentStorage, READ_URL_TTL_SECONDS } from './agent-attachment-storage.service';
6+
7+
describe('AgentAttachmentStorage', () => {
8+
const mb = 1024 * 1024;
9+
const ctx = {
10+
organizationId: 'org1',
11+
environmentId: 'env1',
12+
conversationId: 'conv1',
13+
platformMessageId: 'msg1',
14+
};
15+
16+
function makeLogger() {
17+
return {
18+
warn: sinon.stub(),
19+
error: sinon.stub(),
20+
debug: sinon.stub(),
21+
info: sinon.stub(),
22+
setContext: sinon.stub(),
23+
};
24+
}
25+
26+
function makeStorageService() {
27+
return {
28+
uploadFile: sinon.stub().resolves({}),
29+
getReadSignedUrl: sinon.stub().resolves('https://signed/read'),
30+
fileExists: sinon.stub(),
31+
} as unknown as StorageService;
32+
}
33+
34+
it('should upload and return signed url for fetchData attachment', async () => {
35+
const uploadFile = sinon.stub().resolves({});
36+
const getReadSignedUrl = sinon.stub().resolves('https://signed/read');
37+
const storageService = {
38+
uploadFile,
39+
getReadSignedUrl,
40+
fileExists: sinon.stub(),
41+
} as unknown as StorageService;
42+
43+
const service = new AgentAttachmentStorage(storageService, makeLogger() as any);
44+
45+
const attachment: Attachment = {
46+
type: 'file',
47+
name: 'doc.pdf',
48+
mimeType: 'application/pdf',
49+
size: 10,
50+
fetchData: async () => Buffer.from('hello'),
51+
};
52+
53+
const result = await service.storeInbound([attachment], ctx);
54+
55+
expect(result).to.have.length(1);
56+
expect(result[0].url).to.equal('https://signed/read');
57+
expect(result[0].storageKey).to.include('org1/env1/agents/conv1/msg1/0-doc.pdf');
58+
expect(uploadFile.calledOnce).to.equal(true);
59+
expect(getReadSignedUrl.calledOnce).to.equal(true);
60+
expect(getReadSignedUrl.firstCall.args[1]).to.equal(READ_URL_TTL_SECONDS);
61+
});
62+
63+
it('should keep uploaded attachment metadata when signing fails', async () => {
64+
const uploadFile = sinon.stub().resolves({});
65+
const getReadSignedUrl = sinon.stub().rejects(new Error('signing unavailable'));
66+
const storageService = {
67+
uploadFile,
68+
getReadSignedUrl,
69+
fileExists: sinon.stub(),
70+
} as unknown as StorageService;
71+
const logger = makeLogger();
72+
73+
const service = new AgentAttachmentStorage(storageService, logger as any);
74+
75+
const attachment: Attachment = {
76+
type: 'file',
77+
name: 'doc.pdf',
78+
mimeType: 'application/pdf',
79+
size: 10,
80+
fetchData: async () => Buffer.from('hello'),
81+
};
82+
83+
const result = await service.storeInbound([attachment], ctx);
84+
85+
expect(result).to.have.length(1);
86+
expect(result[0]).to.include({
87+
type: 'file',
88+
name: 'doc.pdf',
89+
mimeType: 'application/pdf',
90+
size: 10,
91+
});
92+
expect(result[0].storageKey).to.include('org1/env1/agents/conv1/msg1/0-doc.pdf');
93+
expect(result[0].url).to.equal(undefined);
94+
expect(uploadFile.calledOnce).to.equal(true);
95+
expect(getReadSignedUrl.calledOnce).to.equal(true);
96+
expect(logger.warn.calledOnce).to.equal(true);
97+
});
98+
99+
it('should process at most 15 inbound attachments and preserve original indexes', async () => {
100+
const storageService = makeStorageService();
101+
const logger = makeLogger();
102+
const service = new AgentAttachmentStorage(storageService, logger as any);
103+
const fetchDataStubs = Array.from({ length: 16 }, () => sinon.stub().resolves(Buffer.from('x')));
104+
const attachments = fetchDataStubs.map((fetchData, index) => ({
105+
type: 'file',
106+
name: `file-${index}.txt`,
107+
mimeType: 'text/plain',
108+
size: 1,
109+
fetchData,
110+
})) as Attachment[];
111+
112+
const result = await service.storeInbound(attachments, ctx);
113+
114+
expect(result).to.have.length(15);
115+
expect(storageService.uploadFile.callCount).to.equal(15);
116+
expect(fetchDataStubs[15].called).to.equal(false);
117+
expect(result[14].storageKey).to.include('org1/env1/agents/conv1/msg1/14-file-14.txt');
118+
expect(logger.warn.calledWithMatch({ attachmentCount: 16, cap: 15 })).to.equal(true);
119+
});
120+
121+
it('should skip known-size attachments that would exceed the aggregate byte cap before fetch', async () => {
122+
const storageService = makeStorageService();
123+
const logger = makeLogger();
124+
const service = new AgentAttachmentStorage(storageService, logger as any);
125+
const fetchDataStubs = [
126+
sinon.stub().resolves(Buffer.from('a')),
127+
sinon.stub().resolves(Buffer.from('b')),
128+
sinon.stub().resolves(Buffer.from('c')),
129+
];
130+
const attachments = fetchDataStubs.map((fetchData, index) => ({
131+
type: 'file',
132+
name: `known-${index}.txt`,
133+
mimeType: 'text/plain',
134+
size: 20 * mb,
135+
fetchData,
136+
})) as Attachment[];
137+
138+
const result = await service.storeInbound(attachments, ctx);
139+
140+
expect(result).to.have.length(2);
141+
expect(storageService.uploadFile.callCount).to.equal(2);
142+
expect(fetchDataStubs[2].called).to.equal(false);
143+
expect(logger.warn.calledWithMatch({ size: 20 * mb, aggregateCap: 50 * mb })).to.equal(true);
144+
});
145+
146+
it('should skip fetchData attachments without size metadata before downloading', async () => {
147+
const storageService = makeStorageService();
148+
const logger = makeLogger();
149+
const service = new AgentAttachmentStorage(storageService, logger as any);
150+
const fetchData = sinon.stub().resolves(Buffer.from('x'));
151+
const attachments = [{ type: 'file', name: 'unknown.bin', fetchData }] as Attachment[];
152+
153+
const result = await service.storeInbound(attachments, ctx);
154+
155+
expect(result).to.have.length(0);
156+
expect(fetchData.called).to.equal(false);
157+
expect(storageService.uploadFile.called).to.equal(false);
158+
expect(logger.warn.called).to.equal(true);
159+
});
160+
161+
it('should skip blob attachments when trusted size metadata is missing', async () => {
162+
const storageService = makeStorageService();
163+
const logger = makeLogger();
164+
const service = new AgentAttachmentStorage(storageService, logger as any);
165+
const blob = new Blob([Buffer.from('x')]);
166+
const attachment = {
167+
type: 'file',
168+
name: 'blob.bin',
169+
data: blob,
170+
} as Attachment;
171+
172+
const result = await service.storeInbound([attachment], ctx);
173+
174+
expect(result).to.have.length(0);
175+
expect(storageService.uploadFile.called).to.equal(false);
176+
expect(logger.warn.called).to.equal(true);
177+
});
178+
179+
it('should skip attachments that exceed aggregate cap after fetch when size metadata is inaccurate', async () => {
180+
const storageService = makeStorageService();
181+
const logger = makeLogger();
182+
const service = new AgentAttachmentStorage(storageService, logger as any);
183+
const attachments = [
184+
{
185+
type: 'file',
186+
name: 'file-0.bin',
187+
size: 24 * mb,
188+
fetchData: async () => Buffer.alloc(24 * mb),
189+
},
190+
{
191+
type: 'file',
192+
name: 'file-1.bin',
193+
size: 25 * mb,
194+
fetchData: async () => Buffer.alloc(25 * mb),
195+
},
196+
{
197+
type: 'file',
198+
name: 'file-2.bin',
199+
size: 1,
200+
fetchData: async () => Buffer.alloc(2 * mb),
201+
},
202+
] as Attachment[];
203+
204+
const result = await service.storeInbound(attachments, ctx);
205+
206+
expect(result).to.have.length(2);
207+
expect(storageService.uploadFile.callCount).to.equal(2);
208+
expect(logger.warn.calledWithMatch({ byteLength: 2 * mb, aggregateCap: 50 * mb })).to.equal(true);
209+
});
210+
211+
it('should skip attachment over pre-fetch size limit', async () => {
212+
const storageService = {
213+
uploadFile: sinon.stub(),
214+
getReadSignedUrl: sinon.stub(),
215+
fileExists: sinon.stub(),
216+
} as unknown as StorageService;
217+
218+
const logger = makeLogger();
219+
const service = new AgentAttachmentStorage(storageService, logger as any);
220+
221+
const attachment: Attachment = {
222+
type: 'file',
223+
size: 26 * 1024 * 1024,
224+
fetchData: async () => Buffer.from('x'),
225+
};
226+
227+
const result = await service.storeInbound([attachment], ctx);
228+
229+
expect(result).to.have.length(0);
230+
expect(storageService.uploadFile.called).to.equal(false);
231+
expect(logger.warn.calledOnce).to.equal(true);
232+
});
233+
234+
it('should skip attachment over post-fetch size limit when size metadata is inaccurate', async () => {
235+
const storageService = {
236+
uploadFile: sinon.stub(),
237+
getReadSignedUrl: sinon.stub(),
238+
fileExists: sinon.stub(),
239+
} as unknown as StorageService;
240+
241+
const logger = makeLogger();
242+
const service = new AgentAttachmentStorage(storageService, logger as any);
243+
244+
const huge = Buffer.alloc(26 * 1024 * 1024);
245+
const attachment: Attachment = {
246+
type: 'file',
247+
size: 1,
248+
fetchData: async () => huge,
249+
};
250+
251+
const result = await service.storeInbound([attachment], ctx);
252+
253+
expect(result).to.have.length(0);
254+
expect(storageService.uploadFile.called).to.equal(false);
255+
});
256+
257+
it('should signRead when object exists', async () => {
258+
const storageService = {
259+
fileExists: sinon.stub().resolves(true),
260+
getReadSignedUrl: sinon.stub().resolves('https://read'),
261+
} as unknown as StorageService;
262+
263+
const service = new AgentAttachmentStorage(storageService, makeLogger() as any);
264+
const url = await service.signRead('org/env/agents/conv/msg/0-f.txt');
265+
266+
expect(url).to.equal('https://read');
267+
expect(storageService.fileExists.calledOnce).to.equal(true);
268+
});
269+
270+
it('should return null from signRead when object missing', async () => {
271+
const storageService = {
272+
fileExists: sinon.stub().resolves(false),
273+
getReadSignedUrl: sinon.stub(),
274+
} as unknown as StorageService;
275+
276+
const service = new AgentAttachmentStorage(storageService, makeLogger() as any);
277+
const url = await service.signRead('missing-key');
278+
279+
expect(url).to.equal(null);
280+
expect(storageService.getReadSignedUrl.called).to.equal(false);
281+
});
282+
});

0 commit comments

Comments
 (0)