diff --git a/web/src/components/agent/agent-conversation.css b/web/src/components/agent/agent-conversation.css index d3f6fe9..7464e53 100644 --- a/web/src/components/agent/agent-conversation.css +++ b/web/src/components/agent/agent-conversation.css @@ -476,6 +476,6 @@ .agent-attachment__actions button { display: flex; align-items: center; gap: 5px; color: #ddd6fe; } .agent-attachment__actions button:disabled { opacity: .5; } .agent-attachment__preview { display: block; max-height: 260px; max-width: 100%; margin-top: 8px; border-radius: 6px; } -.agent-attachment__image-frame { display: flex; align-items: center; justify-content: center; height: 260px; width: min(480px, 100%); margin-top: 8px; } +.agent-attachment__image-frame { display: flex; align-items: center; justify-content: center; height: 260px; width: 480px; max-width: 100%; margin-top: 8px; } .agent-attachment__image-frame .agent-attachment__preview { width: 100%; height: 100%; object-fit: contain; margin: 0; } .agent-attachment__error { display: block; width: 100%; color: #fda4af; font-size: 12px; margin-top: 5px; } diff --git a/web/tests/agent-attachments.browser.mjs b/web/tests/agent-attachments.browser.mjs index 8090a6a..fc35564 100644 --- a/web/tests/agent-attachments.browser.mjs +++ b/web/tests/agent-attachments.browser.mjs @@ -11,13 +11,15 @@ const browser=await chromium.launch({executablePath:process.env.CHROME_EXECUTABL ...(process.env.BROWSER_NO_SANDBOX === '1' ? {args:['--no-sandbox']} : {})}); const context=await browser.newContext({viewport:{width:1200,height:900},permissions:['clipboard-write']}); await context.addInitScript(()=>localStorage.setItem('sybil_admin_token','browser-fixture-token')); -const page=await context.newPage(), errors=[], uploads=[], sends=[], downloads=[]; +const page=await context.newPage(), errors=[], uploads=[], sends=[], downloads=[], trackingRequests=[]; page.on('pageerror',e=>errors.push(e.stack || e.message)); const cap={version:1,maxCount:8,maxTotalBytes:33554432,maxImageBytes:6291456,maxFileBytes:8388608,imageMimeTypes:['image/png','image/jpeg','image/gif','image/webp'],quotaBytes:1073741824,usedBytes:0,remainingBytes:1073741824,maxConcurrentUploads:2,maxConcurrentDecodes:1}; const idle={state:'idle',isThinking:false,queued:0,backgroundWorkers:0,tools:[],queuedMessages:[]}; -let failedUpload=true, failedSend=true, live, cursor=0;const history=[]; +let failedUpload=true, failedSend=true, live, cursor=0, inputAttachments=true;const history=[]; const image=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a2ioAAAAASUVORK5CYII=','base64'); const attachment=(name='notes.txt')=>({id:`att_12345678-1234-4234-8234-${name==='picture.png'?'123456789abd':'123456789abc'}`,filename:name,mimeType:name==='picture.png'?'image/png':'text/plain',kind:name==='picture.png'?'image':'text',sizeBytes:name==='picture.png'?image.length:4,sha256:createHash('sha256').update(name==='picture.png'?image:Buffer.from('data')).digest('hex'),workspacePath:`/data/workspace/uploads/test/${name}`}); +const assistantImage={...attachment('picture.png'),id:'att_12345678-1234-4234-8234-123456789abe',filename:'assistant.png'};delete assistantImage.workspacePath; +await page.route('https://never-fetch.invalid/**',async route=>{trackingRequests.push(route.request().url());await route.abort();}); await page.route('**/api/**',async route=>{ const req=route.request(),url=new URL(req.url()),p=url.pathname,m=req.method();let value; if(p.startsWith('/api/v1/agent'))assert.equal(req.headers().authorization,'Bearer browser-fixture-token'); @@ -26,7 +28,7 @@ await page.route('**/api/**',async route=>{ if(failedUpload){failedUpload=false;await route.fulfill({status:507,json:{message:'Storage quota reached',code:'attachment_quota_exceeded'}});return;} await route.fulfill({status:201,json:{attachment:attachment(body.includes('picture.png')?'picture.png':'notes.txt')}});return; } - if(p.endsWith('/content')){downloads.push(req.url());await route.fulfill({contentType:p.includes('789abd')?'image/png':'text/plain',body:p.includes('789abd')?image:'data'});return;} + if(p.endsWith('/content')){downloads.push(req.url());const isImage=p.includes('789abd')||p.includes('789abe');await route.fulfill({contentType:isImage?'image/png':'text/plain',body:isImage?image:'data'});return;} if(p.endsWith('/agent/messages')&&m==='POST'){ const body=req.postDataJSON();sends.push(body); if(failedSend){failedSend=false;await route.fulfill({status:503,json:{message:'Temporarily unavailable'}});return;} @@ -37,7 +39,7 @@ await page.route('**/api/**',async route=>{ } if(p.endsWith('/agent/messages'))value={conversationId:'1',messages:history,cursor:String(cursor),beforeCursor:null,hasMore:false}; else if(p.endsWith('/read-state'))value={cursor:String(cursor),updatedAt:null}; - else if(p.endsWith('/agent'))value={id:'1',title:'Sybil Agent',activity:idle,capabilities:{attachments:cap},protocol:'pi-room.v1',websocket:'/v1/agent/ws'}; + else if(p.endsWith('/agent'))value={id:'1',title:'Sybil Agent',activity:idle,capabilities:{...(inputAttachments?{attachments:cap}:{}),assistantImages:{version:1}},protocol:'pi-room.v1',websocket:'/v1/agent/ws'}; else if(p.includes('session'))value={mode:'token',authenticated:true}; else if(p.endsWith('/tools')||p.endsWith('/chat-tools'))value={tools:[]}; else if(p.endsWith('/models'))value={models:[],providers:{}}; @@ -75,7 +77,28 @@ try{ await page.getByText('Uploaded',{exact:true}).waitFor();await page.getByRole('button',{name:'Remove notes.txt'}).click(); await page.locator('.agent-room__composer-area').evaluate(el=>{const data=new DataTransfer();data.items.add(new File(['data'],'notes.txt',{type:'text/plain'}));el.dispatchEvent(new DragEvent('drop',{dataTransfer:data,bubbles:true,cancelable:true}));}); await page.getByText('Uploaded',{exact:true}).waitFor(); + await page.getByRole('button',{name:'Remove notes.txt'}).click(); + // Assistant image-only messages use authenticated bytes automatically, with no source path. + const assistant={id:'outbound:900',role:'assistant',createdAt:new Date().toISOString(),content:'',metadata:{kind:'message',status:'complete',entryId:++cursor,agentAttachments:[assistantImage]}};history.push(assistant); + live.send(JSON.stringify({type:'messages',conversationId:'1',messages:[assistant],cursor:String(cursor),beforeCursor:String(cursor),hasMore:false})); + const assistantBubble=page.locator('[data-agent-message-id="outbound:900"]'),inlineImage=assistantBubble.locator('.agent-attachment__preview'); + await inlineImage.waitFor();await inlineImage.evaluate(img=>img.decode()); + assert.ok((await inlineImage.getAttribute('src')).startsWith('blob:')); + assert.equal(await assistantBubble.getByRole('button',{name:'Preview',exact:true}).count(),0); + assert.equal(await assistantBubble.locator('.agent-attachment__path').count(),0); + const tracking={id:'outbound:901',role:'assistant',createdAt:new Date().toISOString(),content:'Remote images must not load.\n\n![tracking](https://never-fetch.invalid/markdown.png) ',metadata:{kind:'message',status:'complete',entryId:++cursor}};history.push(tracking); + live.send(JSON.stringify({type:'messages',conversationId:'1',messages:[tracking],cursor:String(cursor),beforeCursor:String(cursor),hasMore:false})); + await page.getByText('Remote images must not load.',{exact:true}).waitFor(); + assert.equal(await page.locator('[data-agent-message-id="outbound:901"] .md-content img').count(),0); + // An output-only capability must preserve history without enabling input controls. + inputAttachments=false;await page.reload({waitUntil:'networkidle'});await page.locator('[data-testid="agent-sidebar-item"]').click(); + await inlineImage.waitFor();await inlineImage.evaluate(img=>img.decode()); + assert.equal(await assistantBubble.count(),1);assert.equal(await page.locator('.agent-attachment__preview').count(),1); + assert.ok((await inlineImage.getAttribute('src')).startsWith('blob:')); + assert.equal(await page.getByRole('button',{name:'Attach files',exact:true}).count(),0); + assert.equal(await page.getByLabel('Choose attachments').count(),0); + assert.deepEqual(trackingRequests,[],'Neither live nor historical Markdown/HTML image markup fetches remote bytes'); await page.setViewportSize({width:390,height:844});if(process.env.ATTACHMENT_SCREENSHOT)await page.screenshot({path:process.env.ATTACHMENT_SCREENSHOT}); assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>window.innerWidth),false); - assert.deepEqual(errors,[]);console.log('PASS browser picker/retry/quota/remove, file-only send/reload, authenticated preview/download, copy, paste/drop, mobile layout'); + assert.deepEqual(errors,[]);console.log('PASS browser picker/retry/quota/remove, file-only send/reload, authenticated preview/download, copy, paste/drop, assistant image-only live/history, output-only capability, blocked Markdown/HTML images, mobile layout'); }finally{await context.close();await browser.close();}