Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,22 @@ The Python host must configure `AgentCatalog` and `AgentPlanStore` in `create_ap
Hosts with run capabilities also support starting, cancelling and inspecting
saved workflow runs in the browser. Native execution uses `AgentWorkflow`.

When the host advertises `agents.history`, every opened run shows an
**Execution timeline** below its results: the metadata the host recorded as
the run executed -- observation start and end, turns, model and tool calls
with their timing, tool outcomes by status and size, and the sequences the
collector did not see -- one numbered entry per retained position. Prompts,
tool arguments, answers and reasoning are never in these events, and an
entry carrying any other field is withheld with an alert rather than shown.
Positions removed by retention are named. **Load more** reads the next page
from the cursor the host returned; **Follow live** opens the host's
`text/event-stream` route from the last cursor, accepts a page only when its
SSE id names it, reconnects from that cursor while the run is active when the
host closes an observation window, and stops -- saying so -- after three
windows without a new event, when the run is no longer active, or when the
collector records that observation finished. Nothing in the timeline restarts
a run or calls a model.

Run `node --test scripts/test-agents.cjs` with the same Playwright environment
variables used above. It verifies packaged desktop/mobile editing, persistence,
forged save receipts, conflict/draft handling, delayed paging and capability gates.
Expand Down
47 changes: 45 additions & 2 deletions scripts/test-agents.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const engines=require(process.env.SCONE_PLAYWRIGHT_MODULE||'playwright');
const contract=require('../tests/fixtures/http-capabilities.json');let browser;
before(async()=>{browser=await engines[process.env.SCONE_BROWSER_ENGINE||'chromium'].launch({headless:true,executablePath:process.env.SCONE_BROWSER_PATH});});
after(async()=>{await browser?.close();});
async function fixture(t,{mobile=false,supported=true,paged=false,runs=false,maxParallel=1,handoffs=false}={}){
async function fixture(t,{mobile=false,supported=true,paged=false,runs=false,maxParallel=1,handoffs=false,history=false}={}){
const html=fs.readFileSync(path.resolve(__dirname,'../dist/console.html'),'utf8').replaceAll('__SCONE_TOKEN__','agent-fixture');
const state={handoffCapability:handoffs,saved:null,forged:false,hold:null,pageStarted:false,conflict:false,runs:new Map(),starts:0,cancels:0,complete:true,loseStart:false,forgeResult:false,substitute:false,forgeTarget:false,forgeFinal:false};
const server=http.createServer(async(req,res)=>{
Expand All @@ -16,7 +16,7 @@ async function fixture(t,{mobile=false,supported=true,paged=false,runs=false,max
assert.equal(req.headers.authorization,'Bearer agent-fixture');res.setHeader('content-type','application/json');
const send=value=>res.end(JSON.stringify(value));
if(url.pathname==='/v1/status')return send({space:'alpha',episodes:0});
if(url.pathname==='/v1/capabilities')return send({...contract.python,features:{...contract.python.features,'agents.handoffs':state.handoffCapability,'agents.catalog':supported,'agents.plans':supported,'agents.runs':runs,'agents.parallel':runs&&maxParallel>1}});
if(url.pathname==='/v1/capabilities')return send({...contract.python,features:{...contract.python.features,'agents.handoffs':state.handoffCapability,'agents.catalog':supported,'agents.plans':supported,'agents.runs':runs,'agents.parallel':runs&&maxParallel>1,'agents.history':history}});
if(url.pathname==='/v1/agents/run-policy')return send({space:'alpha',max_parallel_tasks:maxParallel,max_active_runs:4});
if(url.pathname==='/v1/agents/catalog')return send({agents:(handoffs?['research','write']:['research']).map(agent_id=>({agent_id,default_model:'fast',models:[{model_id:'fast',label:'Fast local',revision:'1'},{model_id:'careful',label:'Careful local',revision:'1'}]}))});
if(url.pathname==='/v1/agent-plans'){
Expand All @@ -35,6 +35,24 @@ async function fixture(t,{mobile=false,supported=true,paged=false,runs=false,max
if(runs&&url.pathname.startsWith('/v1/agent-runs/')){
const parts=url.pathname.split('/'),run=state.runs.get(decodeURIComponent(parts[3]));if(!run){res.writeHead(404);return send({error:'missing'});}
if(parts[4]==='request')return send(run.original);
if(parts[4]==='history'){
// Execution metadata for the run: three recorded positions, and a
// fourth that only the live stream delivers. Never any text.
const task=run.original.plan.plan.tasks[0],binding=run.original.plan.bindings[task.task_id];
const cursor=n=>'0'.repeat(32)+'.'+n.toString(16).padStart(16,'0')+'.'+'b'.repeat(64),invocation='c'.repeat(32),collection='d'.repeat(32);
const progress=(sequence,kind)=>({sequence,invocation_id:invocation,agent_id:task.agent_id,model_id:task.model_id,binding,kind,occurred_at:'2026-09-11T00:00:01Z',elapsed_s:0.25,operation_id:null,operation_kind:null,duration_s:null,tool_index:null,tool_name:null,status:null,error:null,output_bytes:null,origin:null,reused:null,journal_reused:null,presentation_reused:null});
const events=[{kind:'collection_started',collection_id:collection,occurred_at:'2026-09-11T00:00:00Z',invocation_id:null,last_sequence:0,observed_events:0,lost_events:0,terminal_kind:null,error:null},progress(1,'turn_started'),progress(2,'turn_completed'),
{kind:'collection_finished',collection_id:collection,occurred_at:'2026-09-11T00:00:02Z',invocation_id:invocation,last_sequence:2,observed_events:2,lost_events:0,terminal_kind:'turn_completed',error:null}];
const entry=(position)=>({position,step_id:task.task_id,selection_id:task.task_id,event:events[position-1],collection_id:collection,activation_id:null,...(state.leak?{text:'a private prompt'}:{})});
const after=url.searchParams.get('after'),from=after?parseInt(after.split('.')[1],16)+1:1;
const pageOf=(upto)=>{const items=[];for(let position=from;position<=upto;position++)items.push(entry(position));return {space:'alpha',run_id:run.status.run_id,available:true,items,next_after:cursor(items.length?upto:from-1),retained_from:1,omitted:null};};
if(parts[5]==='stream'){
state.streams=(state.streams??0)+1;state.streamAfter=after;
res.writeHead(200,{'content-type':'text/event-stream','cache-control':'no-store'});
const page=pageOf(4);res.write(': keep-alive\n\n');res.write('event: history\nid: '+page.next_after+'\ndata: '+JSON.stringify(page)+'\n\n');return res.end('event: end\ndata: {}\n\n');
}
return send(pageOf(3));
}
if(parts[4]==='cancel'){state.cancels++;run.status={...run.status,status:'cancelled',active_local:false,outcome_unknown:true,error_class:'CancelledError'};return send(run.status);}
if(parts[4]==='result'&&run.original.plan.plan.agents){
const plan=run.original.plan.plan,first=plan.agents.find(agent=>agent.agent_id===plan.root_agent),target=first.can_handoff_to[0]??null,last=plan.agents.find(agent=>agent.agent_id===target);
Expand Down Expand Up @@ -142,6 +160,31 @@ test('history refreshes after a polled run finishes',async t=>{
await page.getByLabel('Verified run results').waitFor();await page.getByLabel('Run history').getByText('completed · Revision 1',{exact:true}).waitFor();assert.equal(state.starts,1);
});

for(const mobile of [false,true])test(`the run timeline shows recorded execution metadata and follows the live stream to its end, mobile=${mobile}`,async t=>{
const {page,state}=await fixture(t,{runs:true,history:true,mobile});await fill(page);await save(page);await page.getByText('Saved revision 1.',{exact:true}).waitFor();
await page.getByLabel('Question',{exact:true}).fill('Show me the timeline');await page.getByRole('button',{name:'Start run',exact:true}).click();
await page.getByLabel('Verified run results').waitFor();
const timeline=page.getByRole('region',{name:'Execution timeline',exact:true});await timeline.waitFor();
await timeline.getByText('Observation started',{exact:true}).waitFor();await timeline.getByText('Turn completed',{exact:true}).waitFor();
assert.equal(await timeline.locator('li').count(),3);assert.equal(await timeline.getByText('Observation finished',{exact:true}).count(),0);
await timeline.getByRole('button',{name:'Follow live',exact:true}).click();
await timeline.getByText('Observation finished',{exact:true}).waitFor();await timeline.getByText(/collector recorded no more events/).waitFor();
assert.equal(await timeline.locator('li').count(),4);assert.equal(state.streams,1);assert.match(state.streamAfter,/^0{32}\.0{15}3\./,'the stream resumed from the last recorded position');
assert.equal(await page.getByText('a private prompt').count(),0);assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
});
test('a history entry carrying anything beyond metadata is withheld, not shown',async t=>{
const {page,state}=await fixture(t,{runs:true,history:true});state.leak=true;await fill(page);await save(page);await page.getByText('Saved revision 1.',{exact:true}).waitFor();
await page.getByLabel('Question',{exact:true}).fill('Show me the timeline');await page.getByRole('button',{name:'Start run',exact:true}).click();
const timeline=page.getByRole('region',{name:'Execution timeline',exact:true});await timeline.waitFor();
await timeline.getByRole('alert').waitFor();assert.match(await timeline.getByRole('alert').innerText(),/could not be verified/);
assert.equal(await timeline.locator('li').count(),0);assert.equal(await page.getByText('a private prompt').count(),0);
});
test('without the history capability no timeline is offered',async t=>{
const {page}=await fixture(t,{runs:true});await fill(page);await save(page);await page.getByText('Saved revision 1.',{exact:true}).waitFor();
await page.getByLabel('Question',{exact:true}).fill('No timeline here');await page.getByRole('button',{name:'Start run',exact:true}).click();
await page.getByLabel('Verified run results').waitFor();assert.equal(await page.getByRole('region',{name:'Execution timeline',exact:true}).count(),0);
});

async function fillHandoff(page){
await page.getByRole('button',{name:'New handoff workflow',exact:true}).click();
await page.getByLabel('Workflow identifier',{exact:true}).fill('report');
Expand Down
6 changes: 3 additions & 3 deletions src/agents/AgentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {AgentTools} from './AgentTools';

const secureRequest={cache:'no-store',redirect:'error',credentials:'omit',referrerPolicy:'no-referrer'} as const;

type Ready={approvalsAvailable:boolean;handoffRequirementsAvailable:boolean;requirementsAvailable:boolean;schemaAvailable:boolean;usageAvailable:boolean;inputsAvailable:boolean;handoffsAvailable:boolean;runsAvailable:boolean;maxParallel:number;space:string;catalog:AgentChoice[];items:SavedPlan[];next_after:string|null};
type Ready={historyAvailable:boolean;approvalsAvailable:boolean;handoffRequirementsAvailable:boolean;requirementsAvailable:boolean;schemaAvailable:boolean;usageAvailable:boolean;inputsAvailable:boolean;handoffsAvailable:boolean;runsAvailable:boolean;maxParallel:number;space:string;catalog:AgentChoice[];items:SavedPlan[];next_after:string|null};
function Editor({api,space,catalog,initial,onSave,onDirty,inputsAvailable,requirementsAvailable,schemaAvailable}:{api:ApiClient;space:string;catalog:AgentChoice[];initial:SavedPlan|null;onSave:(plan:SavedPlan)=>void;onDirty:()=>void;inputsAvailable:boolean;requirementsAvailable:boolean;schemaAvailable:boolean}){
const first=catalog[0];
const newTask=(id:string):AgentTask=>({task_id:id,agent_id:first?.agent_id??'',model_id:first?.default_model??'',prompt:'',depends_on:[]});
Expand Down Expand Up @@ -80,7 +80,7 @@ export function AgentsPage({api,enabled}:{api:ApiClient;enabled:boolean}){
if(!caps.features['agents.catalog']||!caps.features['agents.plans'])throw Error('Agent workflow configuration is not enabled on this server.');
const [catalog,page]=await Promise.all([api.request<unknown>('/v1/agents/catalog',options).then(parseCatalog),api.request<unknown>('/v1/agent-plans?limit=20',options).then(value=>parsePlanPage(value,status.space as string))]);
const maxParallel=caps.features['agents.parallel']?parseRunPolicy(await api.request<unknown>('/v1/agents/run-policy',options),status.space):1;
if(!controller.signal.aborted)setLoaded({api,data:{approvalsAvailable:caps.features['agents.approvals'],handoffRequirementsAvailable:caps.features['agents.handoffs.output_requirements'],requirementsAvailable:caps.features['agents.output_requirements'],schemaAvailable:caps.features['agents.output_schema'],usageAvailable:caps.features['agents.usage'],inputsAvailable:caps.features['agents.inputs'],handoffsAvailable:caps.features['agents.handoffs'],maxParallel,runsAvailable:caps.features['agents.runs'],space:status.space,catalog,...page}});
if(!controller.signal.aborted)setLoaded({api,data:{historyAvailable:caps.features['agents.history'],approvalsAvailable:caps.features['agents.approvals'],handoffRequirementsAvailable:caps.features['agents.handoffs.output_requirements'],requirementsAvailable:caps.features['agents.output_requirements'],schemaAvailable:caps.features['agents.output_schema'],usageAvailable:caps.features['agents.usage'],inputsAvailable:caps.features['agents.inputs'],handoffsAvailable:caps.features['agents.handoffs'],maxParallel,runsAvailable:caps.features['agents.runs'],space:status.space,catalog,...page}});
})().catch(error=>{if(!controller.signal.aborted)setError(error instanceof Error?error.message:'Agent configuration could not be loaded.');}).finally(()=>{if(!controller.signal.aborted)setLoading(false);});
return()=>controller.abort();
},[api,enabled,refresh]);
Expand All @@ -101,7 +101,7 @@ export function AgentsPage({api,enabled}:{api:ApiClient;enabled:boolean}){
{(()=>{
const props={api,handoffRequirementsAvailable:data.handoffRequirementsAvailable,requirementsAvailable:data.requirementsAvailable,schemaAvailable:data.schemaAvailable,inputsAvailable:data.inputsAvailable,space:data.space,catalog:data.catalog,initial:selection.plan,onDirty:()=>setDirty(true),onSave:(saved:SavedPlan)=>{setDirty(false);setSelection(current=>({...current,plan:saved}));setLoaded(current=>current?.api===api?{api,data:{...current.data,items:[saved,...current.data.items.filter(item=>item.plan.workflow_id!==saved.plan.workflow_id)]}}:current);}};
return (selection.plan?isHandoffPlan(selection.plan.plan):selection.handoff)?data.handoffsAvailable?<HandoffEditor key={selection.id} {...props}/>:<p role="alert">This server does not support handoff configuration.</p>:selection.plan&&isInteractivePlan(selection.plan.plan)&&!data.inputsAvailable?<p role="alert">This server does not support human input workflows.</p>:<Editor key={selection.id} {...props}/>;
})()}</div>{data.runsAvailable&&<RunPanel key={data.space} api={api} space={data.space} plan={selection.plan} dirty={dirty} maxParallel={data.maxParallel} handoffsAvailable={data.handoffsAvailable} inputsAvailable={data.inputsAvailable} usageAvailable={data.usageAvailable} approvalsAvailable={data.approvalsAvailable}/>} {error&&<p role="alert">{error}</p>}
})()}</div>{data.runsAvailable&&<RunPanel key={data.space} api={api} space={data.space} historyAvailable={data.historyAvailable} plan={selection.plan} dirty={dirty} maxParallel={data.maxParallel} handoffsAvailable={data.handoffsAvailable} inputsAvailable={data.inputsAvailable} usageAvailable={data.usageAvailable} approvalsAvailable={data.approvalsAvailable}/>} {error&&<p role="alert">{error}</p>}
</>}
</main>;
}
Loading
Loading