diff --git a/bfs_planner_agent.py b/bfs_planner_agent.py index dc560d1..2840ffd 100644 --- a/bfs_planner_agent.py +++ b/bfs_planner_agent.py @@ -572,6 +572,22 @@ class Session: except Exception as exc: self.error_count += 1 self.observation = {"error": f"{type(exc).__name__}: {exc}"} + if ( + action + and action.get("tool") == "update_shot" + and "Unsupported shot changes" in str(exc) + and "mask" in (action.get("arguments", {}).get("changes") or {}) + ): + self.observation["recovery"] = { + "tool": "segment", + "instruction": ( + "Do not retry update_shot with mask. No changes were applied. " + "Inspect the source at the intended key frame, then call segment " + "with shot, text, key, points, keyframes and settings. " + "Use positive points on the target and negative points on protected people. " + "Submit any prompt or refs changes separately via update_shot." + ), + } self.event( "error", **self.observation, diff --git a/tests/test_agent_ui.mjs b/tests/test_agent_ui.mjs index 20b6778..985594b 100644 --- a/tests/test_agent_ui.mjs +++ b/tests/test_agent_ui.mjs @@ -161,3 +161,21 @@ test('polling retains activity that rolls out of the server event window',async( assert.equal(c.agent.a.historyEvents[0].summary,'older'); assert.equal(c.agent.a.events.at(-1).summary,'new'); }); + +test('copy exports unloaded history and tool failures without changing the visible filter', async () => { + const c=context();let copied=''; + Object.defineProperty(globalThis,'navigator',{configurable:true,value:{clipboard:{writeText:async text=>{copied=text;}}}}); + c.api.fetchApi=async url=>({ok:true,json:async()=>url.includes('/history/')?{before:0,events:[{time:1,kind:'action',tool:'segment',arguments:{shot:0}}]}:{id:'test',task:'Swap only one person',event_count:2,messages:[{time:1.5,text:'Preserve the other person',kind:'feedback'}],events:[{time:2,kind:'error',tool:'segment',error:'Example failure'}]}}); + await c.agent.copyTranscript('tools'); + assert.match(copied,/"shot": 0/);assert.match(copied,/Example failure/); + assert.equal(c.agent.a.logFilter,'all');assert.equal(c.agent.a.copying,false); + await c.agent.copyTranscript('conversation');assert.match(copied,/Preserve the other person/); + await c.agent.copyTranscript('errors');assert.match(copied,/Example failure/);assert.doesNotMatch(copied,/"shot": 0/); +}); + +test('copy reports failed history retrieval instead of copying an incomplete transcript', async () => { + const c=context();let writes=0; + Object.defineProperty(globalThis,'navigator',{configurable:true,value:{clipboard:{writeText:async()=>{writes++;}}}}); + c.api.fetchApi=async url=>({ok:!url.includes('/history/'),json:async()=>url.includes('/history/')?{error:'History unavailable'}:{event_count:2,events:[]}}); + await c.agent.copyTranscript('all');assert.equal(writes,0);assert.match(c.agent.a.error,/History unavailable/);assert.equal(c.agent.a.copying,false); +}); diff --git a/web/js/shotloop/view_agent.js b/web/js/shotloop/view_agent.js index 167cf88..c518e25 100644 --- a/web/js/shotloop/view_agent.js +++ b/web/js/shotloop/view_agent.js @@ -3,7 +3,7 @@ import { h } from './common.js'; import { AGENT_CSS } from './agent_styles.js'; export function createAgent(c, io, app) { - const a = reactive({ task:'', feedback:'', feedbackHistory:[], reviewCoverage:{}, messages:[], attachments:[], pendingMessages:[], contextUsage:null, contextSummary:null, tokenUsage:null, resources:null, resourceError:'', view:'chat', logFilter:'all', inputKind:'feedback', lightbox:null, followChat:true, actions:0, renders:0, created:0, historyEvents:[], historyBefore:0, eventCount:0, pendingRender:null, id:'', status:'idle', checklist:[], events:[], observation:{}, preview:null, previewHistory:[], vlmInfo:null, requiresNewTask:false, settings:{thinking:true,web:true,max_actions:40,max_renders:4,max_tokens:4096,review_fps:2,context_window:32768,project_root:''}, follow:false, error:'', working:false, combined:null }); + const a = reactive({ task:'', feedback:'', feedbackHistory:[], reviewCoverage:{}, messages:[], attachments:[], pendingMessages:[], contextUsage:null, contextSummary:null, tokenUsage:null, resources:null, resourceError:'', view:'chat', logFilter:'all', inputKind:'feedback', lightbox:null, followChat:true, actions:0, renders:0, created:0, copying:false, copyNotice:'', historyEvents:[], historyBefore:0, eventCount:0, pendingRender:null, id:'', status:'idle', checklist:[], events:[], observation:{}, preview:null, previewHistory:[], vlmInfo:null, requiresNewTask:false, settings:{thinking:true,web:true,max_actions:40,max_renders:4,max_tokens:4096,review_fps:2,context_window:32768,project_root:''}, follow:false, error:'', working:false, combined:null }); let timer = null, dead = false, polling = false, resourcesAt = 0, resourcePolling = false; let fileInput = null; const endpoint = '/bfs/shotloop/agent'; @@ -80,6 +80,49 @@ export function createAgent(c, io, app) { if(!a.id||!a.historyBefore)return; try{const r=await c.api.fetchApi(`${endpoint}/history/${encodeURIComponent(a.id)}?before=${a.historyBefore}&count=100`);const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||r.statusText);a.historyEvents=[...(j.events||[]),...a.historyEvents];a.historyBefore=j.before;}catch(e){a.error=String(e.message||e);} } + async function copyTranscript(mode='conversation'){ + if(a.copying)return; + a.copying=true;a.copyNotice='';a.error=''; + try{ + // Read a bounded snapshot of the entire persisted history, including unloaded pages. + const sessionId=a.id; + let snapshot={task:a.task,messages:a.messages,feedback:a.feedbackHistory,events:a.events,event_count:a.eventCount}; + if(sessionId){ + const r=await c.api.fetchApi(`${endpoint}/session/${encodeURIComponent(sessionId)}`); + snapshot=await r.json();if(!r.ok||snapshot.error)throw new Error(snapshot.error||r.statusText); + } + let events=[...(snapshot.events||[])]; + let before=Math.max(0,(snapshot.event_count||events.length)-events.length); + while(before>0){ + const r=await c.api.fetchApi(`${endpoint}/history/${encodeURIComponent(sessionId)}?before=${before}&count=100`); + const page=await r.json();if(!r.ok||page.error)throw new Error(page.error||r.statusText); + if(!Number.isInteger(page.before)||page.before<0||page.before>=before)throw new Error('Could not load the complete history.'); + events=[...(page.events||[]),...events];before=page.before; + } + const clean=(key,value)=>typeof value==='string'&&value.startsWith('data:image/')?'[image omitted]':value; + const stamp=time=>time?new Date(time*1000).toISOString():''; + const lines=[`BFS Agent · ${mode} · session ${sessionId||'not started'}`,`Task: ${snapshot.task||''}`]; + if(mode==='conversation'){ + const entries=(snapshot.messages||[]).map(m=>({time:m.time,text:`You (${m.kind||'feedback'}, ${m.status||'applied'}): ${m.text||''}`+(m.attachments?.length?'\nAttachments: '+m.attachments.map(f=>f.name||f.src).join(', '):'')})); + for(const e of events){ + if(['action','reply','error','context'].includes(e.kind))entries.push({time:e.time,text:JSON.stringify(e,clean,2)}); + else if(e.kind==='feedback'&&!(snapshot.messages||[]).some(m=>m.text===e.summary))entries.push({time:e.time,text:'You: '+e.summary}); + } + entries.sort((x,y)=>x.time-y.time);lines.push(...entries.map(e=>`[${stamp(e.time)}] ${e.text}`)); + }else{ + const selected=events.filter(e=>mode==='errors'?e.kind==='error':mode==='tools'?['action','observation'].includes(e.kind)||(e.kind==='error'&&e.tool):true); + lines.push(...selected.map(e=>`[${stamp(e.time)}] ${JSON.stringify(e,clean,2)}`)); + } + const text=lines.join('\n\n'); + try{await navigator.clipboard.writeText(text);}catch{ + const field=document.createElement('textarea');field.value=text;field.style.cssText='position:fixed;left:-10000px';document.body.appendChild(field); + const focus=document.activeElement; + try{field.select();if(!document.execCommand('copy'))throw new Error('Clipboard access failed. Allow clipboard access and try again.');} + finally{field.remove();focus?.focus();} + } + a.copyNotice=mode==='conversation'?'Conversation copied':`${mode[0].toUpperCase()+mode.slice(1)} log copied`; + }catch(e){a.error=String(e.message||e);}finally{a.copying=false;} + } async function pollResources(force=false){ if(dead||resourcePolling||(!force&&Date.now()-resourcesAt<5000))return; if(!force&&c.tab&&c.tab.value!=='agent')return; @@ -126,7 +169,7 @@ export function createAgent(c, io, app) { c.api.addEventListener('bfs-agent-event',event);c.api.addEventListener('bfs-agent-preview',preview); }); onBeforeUnmount(()=>{dead=true;clearInterval(timer);c.api.removeEventListener('bfs-agent-event',event);c.api.removeEventListener('bfs-agent-preview',preview);}); - return {a,start,control,poll,sendFeedback,pollResources,addFiles,paste,pickImages,setFileInput,newTask,loadHistory}; + return {a,start,control,poll,sendFeedback,pollResources,addFiles,paste,pickImages,setFileInput,newTask,loadHistory,copyTranscript}; } export function agentBar(c) { @@ -255,7 +298,7 @@ export function agentTab(c) { h('label',{class:'ag-row'},[h('input',{type:'checkbox',checked:a.follow,onChange:e=>{a.follow=e.target.checked;}}),'Follow tool tabs']), ]); const trace=h('div',{class:'ag-content'},[ - h('div',{class:'ag-log-filter'},['all','tools','errors'].map(filter=>h('button',{'aria-pressed':a.logFilter===filter,onClick:()=>{a.logFilter=filter;}},filter[0].toUpperCase()+filter.slice(1)))), + h('div',{class:'ag-log-filter'},['all','tools','errors'].map(filter=>h('button',{'aria-pressed':a.logFilter===filter,onClick:()=>{a.logFilter=filter;}},filter[0].toUpperCase()+filter.slice(1))).concat([h('button',{disabled:a.copying,onClick:()=>agent.copyTranscript(a.logFilter)},a.copying?'Copying…':'Copy log')])), h('div',{class:'ag-log'},events.filter(e=>a.logFilter==='errors'?e.kind==='error':a.logFilter==='tools'?e.kind==='action':!['decision','observation'].includes(e.kind)).slice().reverse().map(e=> e.kind==='action'?eventCard(e,events):h('div',{class:e.kind==='error'?'ag-error':'ag-note'},[h('small',`${timeLabel(e.time)} · ${e.kind}`),h('div',e.summary||e.error||JSON.stringify(e.review||{}))]))), a.historyBefore>0?h('button',{onClick:agent.loadHistory},'Load earlier activity'):null, @@ -320,10 +363,11 @@ export function agentTab(c) { ]), h('nav',{class:'ag-nav',role:'tablist','aria-label':'Agent views'},[ ...[['chat','Conversation'],['plan','Plan'],['renders','Renders'],['activity','Activity'],['settings','Settings']].map(([view,label])=>h('button',{class:'ag-tab',role:'tab','aria-selected':a.view===view,onClick:()=>{a.view=view;}},label)), - h('span',{class:'ag-nav-spacer'}),a.pendingMessages.length?h('span',{class:'ag-pending'},`${a.pendingMessages.length} queued`):null, + h('span',{class:'ag-nav-spacer'}),h('button',{class:'ag-tab',disabled:a.copying,onClick:()=>agent.copyTranscript('conversation')},a.copying?'Copying…':'Copy conversation'),a.pendingMessages.length?h('span',{class:'ag-pending'},`${a.pendingMessages.length} queued`):null, h('button',{class:'ag-tab',onClick:()=>agent.pollResources(true),title:a.resourceError||'Refresh resource snapshot'},a.resourceError?'↻ Unavailable':a.resources?.time?`↻ ${timeLabel(a.resources.time)}`:'↻ Resources'), ]), a.requiresNewTask?h('div',{class:'ag-error',style:'margin:12px 22px'},'Fix the VAE loader/connections, then start a new task. Resume uses the old workflow snapshot.'):null, + a.copyNotice?h('div',{class:'ag-note',role:'status',style:'margin:8px 22px'},a.copyNotice):null, a.error?h('div',{class:'ag-error',role:'alert',style:'margin:12px 22px'},a.error):null, a.view==='chat'?conversation:a.view==='settings'?settings:a.view==='activity'?trace:a.view==='renders'?evidence:plan, a.view==='chat'&&frames.length?h('details',{class:'ag-details',style:'margin:0 22px 14px'},[h('summary',a.observation.frames?'Latest result inspection':'Latest approximate preview'),h('div',{class:'ag-detail-content'},frameGrid(frames))]):null,