Files
Sybil-2/web/tests/agent-attachments.test.mjs
T

102 lines
10 KiB
JavaScript

import test from 'node:test';
import { createHash, randomUUID } from 'node:crypto';
import assert from 'node:assert/strict';
import { createAgentUploadQueue, agentAttachments, parseAgentAttachment } from '../src/lib/agent-attachments.ts';
import { AgentRoomClient } from '../src/lib/agent-room.ts';
import { createAgentOptimisticOutbox } from '../src/lib/agent-optimistic-outbox.ts';
import { agentTranscriptMessages } from '../src/lib/agent-room-presentation.ts';
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'notes.txt',mimeType:'text/plain',sizeBytes:4,sha256:createHash('sha256').update('data').digest('hex'),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt' };
const cap = {version:1,maxCount:8,maxTotalBytes:32*1024*1024,maxImageBytes:6*1024*1024,maxFileBytes:8*1024*1024,imageMimeTypes:['image/png','image/jpeg','image/gif','image/webp']};
const tick=()=>new Promise(r=>setTimeout(r,0));
const waitFor=async check=>{const until=Date.now()+5000;while(!check()){assert.ok(Date.now()<until,'async condition timed out');await tick();}};
test('composer gates unsupported servers, count/type/size and batches before upload; preserves retry ID and remove races',async()=>{
let capability, attempts=[], next=0, changed=0, finish;
const queue=createAgentUploadQueue({capability:()=>capability,newId:()=>`upload-${++next}`,changed:()=>changed++,upload:async(_file,id,signal)=>{attempts.push({id,signal});if(attempts.length===1)throw new Error('Quota full');return new Promise(resolve=>finish=resolve);}});
const input=new File(['data'],'notes.txt',{type:'text/plain'});
assert.throws(()=>queue.add([input]),/not available/);capability=cap;
assert.throws(()=>queue.add(Array(9).fill(input)),/8 files/);
assert.throws(()=>queue.add([new File([new Uint8Array(6*1024*1024+1)],'big.png',{type:'image/png'})]),/6 MiB/);
assert.throws(()=>queue.add(Array(5).fill(new File([new Uint8Array(7*1024*1024)],'large.bin'))),/32 MiB/);
queue.add([input]);assert.throws(()=>queue.ready(),/Wait/);await waitFor(()=>queue.snapshot()[0].status==='failed');
assert.equal(queue.snapshot()[0].status,'failed');assert.match(queue.snapshot()[0].error,/Quota/);
queue.retry('upload-1');await waitFor(()=>attempts.length===2);assert.deepEqual(attempts.map(a=>a.id),['upload-1','upload-1']);
queue.remove('upload-1');assert.equal(attempts.at(-1).signal.aborted,true);finish(file);await tick();assert.deepEqual(queue.ready(),[]);
assert.ok(changed>0);queue.dispose();
});
test('composer serializes transfers, keeps ready descriptors, and clears without orphaning pending state',async()=>{
const done=[];let count=0;
const queue=createAgentUploadQueue({capability:()=>cap,changed:()=>{},newId:()=>`id-${++count}`,upload:async()=>new Promise(r=>done.push(r))});
queue.add([new File(['data'],'notes.txt'),new File(['data'],'notes.txt')]);await waitFor(()=>done.length===1);assert.deepEqual(queue.snapshot().map(r=>r.status),['uploading','waiting']);
done[0](file);await waitFor(()=>done.length===2);assert.deepEqual(queue.snapshot().map(r=>r.status),['ready','uploading']);done[1]({...file,id:file.id.replace('12345678','12345679')});await waitFor(()=>queue.snapshot().every(row=>row.status==='ready'));assert.equal(queue.ready().length,2);queue.clear();assert.deepEqual(queue.ready(),[]);queue.dispose();
});
test('transport uses auth headers/FormData, ID-only send and checked receipt, authenticated download with no token URLs',async()=>{
const calls=[];let acknowledge=true;
const client=new AgentRoomClient({baseUrl:'/api',token:()=> 'browser-token',fetch:async(url,init)=>{
calls.push({url,init});assert.ok(!url.includes('token'));assert.equal(init.headers.get('Authorization'),'Bearer browser-token');
if(url.endsWith('/attachments')){assert.ok(init.body instanceof FormData);assert.equal(init.headers.get('Content-Type'),null);assert.equal(init.body.get('uploadId'),'uuid');return Response.json({attachment:file},{status:201});}
if(url.endsWith('/content'))return new Response('data',{headers:{'content-type':'text/plain'}});
const body=JSON.parse(init.body);assert.deepEqual(body,{content:'',requestId:'send-id',attachments:[{id:file.id}]});
return Response.json({id:1,status:'queued',messageId:'inbound:sybil:send-id',...(acknowledge?{attachmentIds:[file.id]}:{})});
}});
assert.deepEqual(await client.upload(new File(['data'],'notes.txt'),'uuid'),file);
assert.equal((await client.submit('','send-id',[file])).status,'queued');acknowledge=false;await assert.rejects(client.submit('','send-id',[file]),/invalid message receipt/);
assert.equal(await (await client.download(file)).text(),'data');assert.equal(calls.at(-1).init.cache,'no-store');assert.equal(calls.at(-1).init.redirect,'error');
});
test('file-only outbox survives reload and retry without sending paths, confirms matching attachment echoes and retains tool filtering',async()=>{
let saved, call, fail=true;
const options={storage:{read:()=>saved,write:rows=>saved=structuredClone(rows)},changed:()=>{},newId:()=> 'request',submit:async(content,requestId,attachments)=>{call={content,requestId,attachments};if(fail)throw new Error('lost response');return {status:'queued',messageId:'inbound:sybil:request'};}};
let outbox=createAgentOptimisticOutbox(options);await assert.rejects(outbox.send('',[file]));assert.deepEqual(saved[0].attachments,[file]);
outbox=createAgentOptimisticOutbox(options);assert.equal(outbox.snapshot().length,1);fail=false;await outbox.retry('inbound:sybil:request');assert.equal(call.content,'');assert.deepEqual(call.attachments,[file]);
const row=outbox.display([])[0];assert.equal(row.content,'Attached: notes.txt.');assert.deepEqual(agentAttachments(row.metadata),[file]);
outbox.reconcile({messages:[{...row,metadata:{kind:'message',status:'complete'}}]});assert.equal(outbox.snapshot()[0].confirmed,false);
const mismatch=outbox.display([{...row,metadata:{kind:'message',status:'complete'}}])[0];assert.deepEqual(agentAttachments(mismatch.metadata),[file]);assert.equal(mismatch.metadata.status,'queued');
outbox.reconcile({messages:[{...row,metadata:{...row.metadata,status:'complete'}}]});assert.equal(outbox.snapshot()[0].confirmed,true);
const raw=[row,{...row,id:'tool',role:'tool',metadata:{kind:'tool_call'}}];assert.equal(agentTranscriptMessages(raw).length,1);assert.equal(raw.length,2);
assert.throws(()=>parseAgentAttachment({...file,workspacePath:'https://bad'}));
});
test('attachment drafts preserve completed receipts and unresolved IDs/hash across disposal/reload, verified reselection resumes without a new ID',async()=>{
let saved=[], attempts=[], complete;
const storage={read:()=>saved,write:rows=>saved=structuredClone(rows)};
const options={capability:()=>cap,storage,changed:()=>{},upload:async(_file,id,signal)=>{attempts.push({id,signal});return new Promise(r=>complete=r);}};
let queue=createAgentUploadQueue(options);queue.add([new File(['data'],'notes.txt',{type:'text/plain'})]);
await waitFor(()=>Boolean(complete));const original=attempts[0].id;assert.equal(saved[0].sha256,file.sha256);
queue.dispose();assert.equal(attempts[0].signal.aborted,true);
queue=createAgentUploadQueue(options);assert.equal(queue.snapshot()[0].status,'reselect');assert.equal(queue.snapshot()[0].uploadId,original);assert.throws(()=>queue.ready(),/Wait/);
await assert.rejects(queue.reselect(original,new File(['evil'],'notes.txt',{type:'text/plain'})),/different contents/);assert.equal(attempts.length,1);
await queue.reselect(original,new File(['data'],'notes.txt',{type:'text/plain'}));await waitFor(()=>attempts.length===2);assert.equal(attempts[1].id,original);
complete(file);await tick();assert.equal(queue.ready()[0].id,file.id);queue.dispose();
const restored=createAgentUploadQueue(options);assert.deepEqual(restored.ready(),[file]);assert.equal(attempts.length,2,'completed upload is never repeated');restored.clear();assert.deepEqual(saved,[]);
});
test('mismatched complete echoes cannot hide local attachment errors or the retry action',async()=>{
const box=createAgentOptimisticOutbox({storage:{read:()=>null,write:()=>{}},changed:()=>{},newId:()=> 'mismatch',submit:async()=>{throw new Error('quota failure');}});
await assert.rejects(box.send('caption',[file]));const initial=box.display([])[0];
for(const attachments of [undefined,[{...file,id:file.id.replace('12345678','12345679')}]]){
const echo={...initial,metadata:{kind:'message',status:'complete',...(attachments?{agentAttachments:attachments}:{})}};
box.reconcile({messages:[echo]});const shown=box.display([echo])[0];assert.equal(shown.metadata.status,'failed');assert.equal(shown.metadata.error,'quota failure');assert.deepEqual(shown.metadata.agentAttachments,[file]);assert.equal(box.snapshot()[0].confirmed,false);
}
});
test('storage refusal prevents uploads and outbox handoff; no attachment draft is cleared before persistence',async()=>{
let uploads=0;const queue=createAgentUploadQueue({capability:()=>cap,storage:{read:()=>null,write:()=>{throw new Error('storage full');}},changed:()=>{},upload:async()=>{uploads++;return file;}});
assert.throws(()=>queue.add([new File(['data'],'notes.txt')]),/storage full/);await tick();assert.equal(uploads,0);
let cleared=false;const box=createAgentOptimisticOutbox({storage:{read:()=>null,write:()=>{throw new Error('storage full');}},changed:()=>{},submit:async()=>({status:'queued'})});
await assert.rejects(box.send('draft',[file],()=>{cleared=true;}));assert.equal(cleared,false);
});
test('a late canceled queue completion cannot overwrite a restored draft',async()=>{
let saved=[];const pending=[];
const options={capability:()=>cap,changed:()=>{},storage:{read:()=>saved,write:rows=>saved=structuredClone(rows)},upload:async()=>new Promise(resolve=>pending.push(resolve))};
const old=createAgentUploadQueue(options);old.add([new File(['data'],'notes.txt',{type:'text/plain'})]);await waitFor(()=>pending.length===1);
const oldId=old.snapshot()[0].uploadId;old.dispose();
const restored=createAgentUploadQueue(options);restored.remove(oldId);restored.add([new File(['data'],'notes.txt',{type:'text/plain'})]);await waitFor(()=>pending.length===2);
pending[1](file);await tick();const current=structuredClone(saved);assert.equal(current[0].status,'ready');assert.notEqual(current[0].uploadId,oldId);
pending[0](file);await tick();assert.deepEqual(saved,current);assert.deepEqual(restored.ready(),[file]);restored.dispose();
});