From b583ad2cf176fd0e07f8291eecd553d3faf31ce0 Mon Sep 17 00:00:00 2001 From: Mark Sturman Date: Sun, 13 Sep 2026 14:19:04 -0500 Subject: [PATCH] Show a run's execution timeline, and follow it live A host that advertises agents.history records what a run did as it ran -- 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 -- and the console showed none of it. Every opened run now carries an Execution timeline below its results, one numbered entry per retained position, read the way the standalone SDK reads it: every field checked, positions contiguous, the cursor naming its page, the model binding matching the saved plan, and an entry that carries any field beyond metadata withheld with an alert rather than shown. Positions removed by retention are named. Load more reads the next page from the host's cursor. Follow live opens the text/event-stream route from the last cursor, sending it as the query and as Last-Event-ID; a page is accepted only when the frame's id names it; the host's end frame closes an observation window, and the console reconnects from the cursor while the run is active, stopping and saying so after three windows without a new event, when the run is no longer active, or when the collector records that observation finished. An error frame is a refusal with the host's fixed reason. Nothing here restarts a run or calls a model. history.ts holds the page and event parsers and the plain-words description of each entry; history-stream.ts reads frames; the API client gains historyStream with the same secure request shape as the conversation stream; the capability is agents.history. Nine unit tests cover the parsers, descriptions, refusals, retention disclosure, frame reading and resumption; the API client test checks the headers, cursor validation and non-SSE refusal; four browser journeys (desktop and mobile) show the timeline, follow it to the collector's end, withhold a leaking entry, and offer no timeline without the capability. 28 native journeys and 519 unit tests pass; typecheck and build are clean. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012aNJobunWMPFEa8mjsFQqY --- README.md | 16 +++ scripts/test-agents.cjs | 47 ++++++++- src/agents/AgentsPage.tsx | 6 +- src/agents/HistoryTimeline.tsx | 87 ++++++++++++++++ src/agents/RunPanel.tsx | 8 +- src/agents/agents.css | 6 ++ src/agents/history-stream.ts | 53 ++++++++++ src/agents/history.ts | 181 +++++++++++++++++++++++++++++++++ src/api.ts | 20 ++++ src/capabilities.ts | 6 +- tests/agent-history.test.ts | 122 ++++++++++++++++++++++ tests/api.test.ts | 29 ++++++ 12 files changed, 570 insertions(+), 11 deletions(-) create mode 100644 src/agents/HistoryTimeline.tsx create mode 100644 src/agents/history-stream.ts create mode 100644 src/agents/history.ts create mode 100644 tests/agent-history.test.ts diff --git a/README.md b/README.md index 3ddac00..f534a79 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/scripts/test-agents.cjs b/scripts/test-agents.cjs index f79d065..0cad5e0 100644 --- a/scripts/test-agents.cjs +++ b/scripts/test-agents.cjs @@ -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)=>{ @@ -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'){ @@ -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); @@ -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'); diff --git a/src/agents/AgentsPage.tsx b/src/agents/AgentsPage.tsx index e704c39..2001417 100644 --- a/src/agents/AgentsPage.tsx +++ b/src/agents/AgentsPage.tsx @@ -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:[]}); @@ -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('/v1/agents/catalog',options).then(parseCatalog),api.request('/v1/agent-plans?limit=20',options).then(value=>parsePlanPage(value,status.space as string))]); const maxParallel=caps.features['agents.parallel']?parseRunPolicy(await api.request('/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]); @@ -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?:

This server does not support handoff configuration.

:selection.plan&&isInteractivePlan(selection.plan.plan)&&!data.inputsAvailable?

This server does not support human input workflows.

:; - })()}{data.runsAvailable&&} {error&&

{error}

} + })()}{data.runsAvailable&&} {error&&

{error}

} } ; } diff --git a/src/agents/HistoryTimeline.tsx b/src/agents/HistoryTimeline.tsx new file mode 100644 index 0000000..d6d73c8 --- /dev/null +++ b/src/agents/HistoryTimeline.tsx @@ -0,0 +1,87 @@ +import {useEffect,useRef,useState} from 'react'; +import {ApiError,type ApiClient} from '../api'; +import {describeEntry,historyAddress,parseHistoryPage,type HistoryEntry,type HistoryPage} from './history'; +import {readHistoryFrames} from './history-stream'; +import type {RunRequest} from './runs'; +const secureRequest={cache:'no-store',redirect:'error',credentials:'omit',referrerPolicy:'no-referrer'} as const; +const PAGE=50,SHOWN=2000,RECONNECTS=3; +function message(error:unknown):string{ + if(error instanceof ApiError){ + if(error.status===403)return 'This key cannot read execution history.'; + if(error.status===404)return 'No history is available for this run in the connected space.'; + if(error.status===409)return 'The history cannot be read in the run’s current state.'; + if(error.status===422)return 'The history cursor was refused. Reload the timeline from the start.'; + } + return error instanceof Error?error.message:'The execution history is unavailable.'; +} +interface Held {entries:HistoryEntry[];cursor:string|null;omitted:[number,number][];available:boolean|null;trimmed:number} +function absorb(held:Held,page:HistoryPage):Held{ + const known=new Set(held.entries.map(entry=>entry.position)); + const fresh=page.items.filter(entry=>!known.has(entry.position)); + let entries=[...held.entries,...fresh],trimmed=held.trimmed; + if(entries.length>SHOWN){trimmed+=entries.length-SHOWN;entries=entries.slice(entries.length-SHOWN);} + return {entries,cursor:page.next_after,available:page.available,trimmed,omitted:page.omitted&&!held.omitted.some(gap=>gap[0]===page.omitted?.[0])?[...held.omitted,page.omitted]:held.omitted}; +} +export function HistoryTimeline({api,request,active}:{api:ApiClient;request:RunRequest;active:boolean}){ + const [held,setHeld]=useState({entries:[],cursor:null,omitted:[],available:null,trimmed:0}),[busy,setBusy]=useState(false),[following,setFollowing]=useState(false),[issue,setIssue]=useState(''),[note,setNote]=useState(''); + const stream=useRef(null),latest=useRef(held);latest.current=held; + const id=request.run_id; + const read=async(after:string|null,signal:AbortSignal):Promise=>{ + const page=await api.request(historyAddress(id)+'?limit='+PAGE+(after?'&after='+encodeURIComponent(after):''),{...secureRequest,signal:AbortSignal.any([signal,AbortSignal.timeout(15000)])}); + return parseHistoryPage(page,request,after,PAGE); + }; + useEffect(()=>{ + const controller=new AbortController();setHeld({entries:[],cursor:null,omitted:[],available:null,trimmed:0});setIssue('');setNote('');setBusy(true); + void read(null,controller.signal).then(page=>{if(!controller.signal.aborted)setHeld(absorb({entries:[],cursor:null,omitted:[],available:null,trimmed:0},page));}) + .catch(error=>{if(!controller.signal.aborted)setIssue(message(error));}).finally(()=>{if(!controller.signal.aborted)setBusy(false);}); + return()=>{controller.abort();stream.current?.abort();}; + },[api,request]); // eslint-disable-line react-hooks/exhaustive-deps + const more=async()=>{ + if(busy||following||!held.cursor)return; + const controller=new AbortController();setBusy(true);setIssue(''); + try{const page=await read(held.cursor,controller.signal);setHeld(current=>absorb(current,page));} + catch(error){setIssue(message(error));} + finally{setBusy(false);} + }; + const follow=async()=>{ + if(following||busy)return; + const controller=new AbortController();stream.current=controller;setFollowing(true);setIssue('');setNote(''); + let stalls=0; + try{ + while(!controller.signal.aborted){ + const before=latest.current.cursor;let moved=false; + const body=await api.historyStream(id,before,PAGE,controller.signal); + for await(const page of readHistoryFrames(body,{request,after:before,limit:PAGE})){ + if(controller.signal.aborted)return; + if(page.items.length)moved=true; + setHeld(current=>absorb(current,page)); + const last=page.items[page.items.length-1]; + if(last&&last.event.type==='collection'&&last.event.kind!=='collection_started'){setNote('Observation finished; the collector recorded no more events for this run.');return;} + } + // The server closed its observation window. Reconnect from the last + // cursor while the run is still going; a window that brought nothing + // three times in a row is left, and said so, rather than polled forever. + if(!active){setNote('The observation window ended and the run is not active. Use Follow live again to look for more.');return;} + stalls=moved?0:stalls+1; + if(stalls>=RECONNECTS){setNote(`No new events across ${RECONNECTS} observation windows; following stopped. Use Follow live again to continue.`);return;} + } + }catch(error){if(!controller.signal.aborted)setIssue(message(error));} + finally{if(stream.current===controller){stream.current=null;setFollowing(false);}} + }; + const stop=()=>{stream.current?.abort();stream.current=null;setFollowing(false);}; + return

Timeline

+

Execution metadata as it was recorded: turns, model and tool calls, their timing and outcomes. Prompts, tool arguments, answers and reasoning are never in this timeline.

+ {held.available===false&&

No retained observations are available for this run.

} + {held.omitted.map(gap=>

Positions {gap[0]}–{gap[1]} were removed by retention and cannot be shown.

)} + {held.trimmed>0&&

{held.trimmed} earlier entr{held.trimmed===1?'y':'ies'} trimmed from view; reopen the run to read from the start.

} + {held.entries.length>0&&
    {held.entries.map(entry=>{const said=describeEntry(entry);return
  1. #{entry.position} {said.title} · {entry.step_id}{entry.selection_id!==entry.step_id?` · ${entry.selection_id}`:''}
    {said.detail}
  2. ;})}
} + {held.available&&!held.entries.length&&!busy&&

No events have been recorded yet.

} +
+ {held.cursor&&!following&&} + {held.available&&(following?:)} + {following&&Following live…} +
+ {note&&

{note}

} + {issue&&

{issue}

} +
; +} diff --git a/src/agents/RunPanel.tsx b/src/agents/RunPanel.tsx index 97f9a3a..8155106 100644 --- a/src/agents/RunPanel.tsx +++ b/src/agents/RunPanel.tsx @@ -7,6 +7,7 @@ import {ApiError,type ApiClient} from '../api'; import {isHandoffPlan,isInputTask,isInteractivePlan,type SavedPlan} from './plans'; import {matchRun,matchSubmission,parseRunPage,parseRunRequest,parseRunResult,parseRunStatus,runAddress,validateStart,type RunSubmission,type RunRequest,type RunStatus,type RunResult} from './runs'; import {InputPanel} from './InputPanel'; +import {HistoryTimeline} from './HistoryTimeline'; import {matchInputResults,parseInputPage,type RunInput} from './inputs'; const secureRequest={cache:'no-store',redirect:'error',credentials:'omit',referrerPolicy:'no-referrer'} as const; function message(error:unknown):string{ @@ -18,7 +19,7 @@ function message(error:unknown):string{ } return error instanceof Error?error.message:'The run response is unavailable.'; } -function RunView({api,space,id,expected,onChanged,inputsAvailable,usageAvailable,approvalsAvailable,approvalAttempt}:{api:ApiClient;space:string;id:string;expected?:RunSubmission;approvalAttempt:ApprovalAttemptRef;onChanged:()=>void;inputsAvailable:boolean;usageAvailable:boolean;approvalsAvailable:boolean}){ +function RunView({api,space,id,expected,onChanged,inputsAvailable,usageAvailable,approvalsAvailable,historyAvailable,approvalAttempt}:{api:ApiClient;space:string;id:string;expected?:RunSubmission;approvalAttempt:ApprovalAttemptRef;onChanged:()=>void;inputsAvailable:boolean;usageAvailable:boolean;approvalsAvailable:boolean;historyAvailable:boolean}){ const [version,setVersion]=useState(0),[request,setRequest]=useState(null),[status,setStatus]=useState(null),[result,setResult]=useState(null),[issue,setIssue]=useState(''),[busy,setBusy]=useState(false),[cancelling,setCancelling]=useState(false); const [inputs,setInputs]=useState([]),[approvals,setApprovals]=useState[]>([]); const active=useRef(null); @@ -76,6 +77,7 @@ function RunView({api,space,id,expected,onChanged,inputsAvailable,usageAvailable {result?.outcome==='handoff_limit'&&

The handoff limit was reached. These are partial results; no final answer was produced.

} {result&&
{result.tasks.map(output=>output.kind==='human_input'?

{output.task_id} · Human input

Reply used by this workflow

{output.text}
:

{output.task_id} · {output.agent_id} · {output.model_id}{result.finalTask===output.task_id?' · Final answer':''}

{output.handoff_to!==undefined&&

{output.handoff_to===null?'Agent finished':`Handed off to ${output.handoff_to}`}

}

{output.source_status==='retained'?`${output.evidence_ids.length} retained evidence references`:'No retained evidence · Treat this as ungrounded model output'} · {output.model_calls} model calls · {output.tool_calls} tool calls

{result.reusedTasks?.includes(output.task_id)&&

Saved task result reused

}{output.usage!==undefined&&}
{output.text}
)}
} {issue&&

{issue}

} + {request&&historyAvailable&&} ; } function RunHistory({api,space,version,onSelect}:{api:ApiClient;space:string;version:number;onSelect:(id:string)=>void}){ @@ -92,7 +94,7 @@ function RunHistory({api,space,version,onSelect}:{api:ApiClient;space:string;ver useEffect(()=>{const controller=new AbortController();active.current=controller;setItems([]);setAfter(null);void load(controller,null);return()=>controller.abort();},[api,space,version]); return

Run history in {space}

    {items.map(run=>
  • )}
{!busy&&!items.length&&!issue&&

No runs saved yet.

}{busy&&

Loading runs…

}{after&&}{issue&&

{issue}

}
; } -export function RunPanel({api,space,plan,dirty,maxParallel,handoffsAvailable,inputsAvailable,usageAvailable,approvalsAvailable}:{api:ApiClient;space:string;plan:SavedPlan|null;dirty:boolean;maxParallel:number;handoffsAvailable:boolean;inputsAvailable:boolean;usageAvailable:boolean;approvalsAvailable:boolean}){ +export function RunPanel({api,space,plan,dirty,maxParallel,handoffsAvailable,inputsAvailable,usageAvailable,approvalsAvailable,historyAvailable=false}:{api:ApiClient;space:string;plan:SavedPlan|null;dirty:boolean;maxParallel:number;handoffsAvailable:boolean;inputsAvailable:boolean;usageAvailable:boolean;approvalsAvailable:boolean;historyAvailable?:boolean}){ const [runId,setRunId]=useState(()=>crypto.randomUUID()),[parallel,setParallel]=useState(1),[question,setQuestion]=useState(''),[busy,setBusy]=useState(false),[attempted,setAttempted]=useState(false),[issue,setIssue]=useState(''),[submitted,setSubmitted]=useState(null),[selected,setSelected]=useState<{id:string;version:number;expected?:RunSubmission}|null>(null),[history,setHistory]=useState(0),[lookup,setLookup]=useState(''); const active=useRef(null); const approvalAttempts=useRef({api,space,items:new Map()}); @@ -118,7 +120,7 @@ export function RunPanel({api,space,plan,dirty,maxParallel,handoffsAvailable,inp {attempted&&
A new run calls the selected models again.
} {issue&&

{issue}

}
{event.preventDefault();try{inspect(lookup);setIssue('');}catch(error){setIssue(message(error));}}}>
- {selected&&setHistory(n=>n+1)}/>} + {selected&&setHistory(n=>n+1)}/>} ; } diff --git a/src/agents/agents.css b/src/agents/agents.css index 211acd9..285c905 100644 --- a/src/agents/agents.css +++ b/src/agents/agents.css @@ -21,3 +21,9 @@ .agent-tools small,.agent-tools-empty {color:var(--muted);} .agent-approvals{margin-top:24px;max-width:100%;min-width:0}.agent-approvals>p{max-width:72ch;line-height:1.6}.agent-run-view .agent-approval{padding:20px;border:1px solid var(--border,#ddd);border-radius:12px;margin:16px 0}.agent-approval h5{font-size:1rem;margin:0}.agent-approval-stage{font-size:.8rem;padding:5px 9px;border-radius:6px;background:var(--surface-muted,#f4f4f4)}.agent-approval-facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:18px 0}.agent-approval dt{font-size:.8rem;color:var(--muted,#777)}.agent-approval dd{margin:4px 0 0;overflow-wrap:anywhere}.agent-arguments-label{font-size:.85rem;font-weight:600;margin-bottom:8px}.agent-arguments{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;max-height:24rem;overflow:auto;font: .85rem/1.6 ui-monospace,SFMono-Regular,monospace;padding:14px;border-radius:8px;background:var(--surface-muted,#f4f4f4);unicode-bidi:isolate}.agent-approval-identity{font-size:.8rem;line-height:1.5;margin:12px 0}.agent-runs .agent-approval-select{display:flex;align-items:center;gap:10px;margin-top:12px}.agent-runs .agent-approval-select input{width:18px;height:18px;padding:0;margin:0;flex-shrink:0}.agent-recovery{display:block;max-width:100%;overflow-wrap:anywhere;margin:12px 0}.agent-approval-attempt{font-size:.8rem;overflow-wrap:anywhere}.agent-approval-history li{margin:16px 0;overflow-wrap:anywhere}.agent-approval-history summary{cursor:pointer}.agent-approval-history p{line-height:1.5}@media(max-width:600px){.agent-approval-facts{grid-template-columns:1fr}.agent-run-view .agent-approval{padding:14px}.agent-arguments{padding:10px;font-size:.8rem}.agent-approvals .agent-actions button{width:100%}} + +.agent-timeline{margin-top:1rem;border-top:1px solid var(--line,#d8d3c8);padding-top:.75rem} +.agent-timeline-note{font-size:.9rem;opacity:.8} +.agent-timeline-entries{list-style:none;padding:0;margin:.5rem 0;display:grid;gap:.4rem} +.agent-timeline-entries li{padding:.35rem .5rem;border-left:3px solid var(--accent,#7a5c2e);background:var(--surface-2,rgba(0,0,0,.03))} +.agent-timeline-position{font-variant-numeric:tabular-nums;opacity:.7;margin-right:.25rem} diff --git a/src/agents/history-stream.ts b/src/agents/history-stream.ts new file mode 100644 index 0000000..973a3da --- /dev/null +++ b/src/agents/history-stream.ts @@ -0,0 +1,53 @@ +// Live delivery of the same verified pages the history route returns, as +// text/event-stream frames. Each `history` frame carries a page as data +// and its next_after as the SSE id, so a page is accepted only when the +// id names it; `end` ends the observation; `error` is a refusal with the +// server's fixed reason and no exception text. Nothing here resumes on +// its own: the caller decides, with the last cursor it was given. +import {parseHistoryPage,type HistoryPage} from './history.ts'; +import type {RunRequest} from './runs.ts'; + +const MAX_FRAME=1_048_576; + +export async function* readHistoryFrames(body:ReadableStream,{request,after,limit}:{request:RunRequest;after:string|null;limit:number}):AsyncGenerator{ + const reader=body.getReader(),decoder=new TextDecoder('utf-8',{fatal:true}); + let pending='',event='',id='',data:string[]=[],size=0,cursor=after; + try{ + while(true){ + const {value,done}=await reader.read(); + if(done){decoder.decode();throw Error('The run history stream ended before the server ended it.');} + const decoded=decoder.decode(value,{stream:true}); + let offset=0; + while(offsetMAX_FRAME)throw Error('The run history stream frame exceeds its limit.'); + if(newline<0)break; + offset=newline+1;size++; + const line=pending.endsWith('\r')?pending.slice(0,-1):pending;pending=''; + if(line===''){ + if(data.length){ + if(event==='end')return; + const wire:unknown=JSON.parse(data.join('\n')); + if(event==='error'){ + const reason=wire&&typeof wire==='object'&&!Array.isArray(wire)&&typeof (wire as Record).error==='string'?(wire as Record).error as string:'unknown'; + throw Error('The run history stream was refused: '+reason+'.'); + } + if(event!=='history')throw Error('The run history stream sent an event this console does not read.'); + const page=parseHistoryPage(wire,request,cursor,limit); + if(id!==(page.next_after??''))throw Error('The run history stream id does not name the page it carries.'); + cursor=page.next_after; + yield page; + } + event='';id='';data=[];size=0; + }else if(!line.startsWith(':')){ + const colon=line.indexOf(':'),field=colon<0?line:line.slice(0,colon); + let content=colon<0?'':line.slice(colon+1);if(content.startsWith(' '))content=content.slice(1); + if(field==='event')event=content; + else if(field==='id')id=content; + else if(field==='data')data.push(content); + } + } + } + }finally{reader.releaseLock();} +} diff --git a/src/agents/history.ts b/src/agents/history.ts new file mode 100644 index 0000000..54dee71 --- /dev/null +++ b/src/agents/history.ts @@ -0,0 +1,181 @@ +// The execution history of a run, read the way the standalone SDK reads +// it: every field checked, positions contiguous, the cursor naming its +// page, and the model binding matching the saved plan. Metadata only -- +// no prompt, tool argument, answer or reasoning is in these events, and +// an entry carrying any other field is refused rather than shown. +import {identifier,isHandoffPlan,isInputTask,record} from './plans.ts'; +import {runAddress,type RunRequest} from './runs.ts'; + +const MAX_POSITION=Number.MAX_SAFE_INTEGER; +const TERMINALS=['turn_completed','turn_paused','turn_failed','turn_cancelled'] as const; +const KINDS=['turn_started',...TERMINALS,'operation_started','operation_completed','operation_failed','operation_reused','tool_proposed','tool_result'] as const; +const ERRORS=['unknown_tool','invalid_arguments','timeout','store_error','retrieval_failed','output_bytes','evidence_unavailable','tool_budget','tool_output_budget','search_for_seed_first','search_for_chunk_first','unsupported_chunk_window','invalid_computation','ambiguous_quote','numeric_literal_required','direct_return','approval_denied'] as const; +const PROGRESS_FIELDS=['sequence','invocation_id','agent_id','model_id','binding','kind','occurred_at','elapsed_s','operation_id','operation_kind','duration_s','tool_index','tool_name','status','error','output_bytes','origin','reused','journal_reused','presentation_reused']; +const GAP_FIELDS=['invocation_id','first_sequence','last_sequence']; +const COLLECTION_FIELDS=['kind','collection_id','occurred_at','invocation_id','last_sequence','observed_events','lost_events','terminal_kind','error']; +const PAGE_FIELDS=['space','run_id','available','items','next_after','retained_from','omitted']; + +export interface ProgressEvent {type:'progress';sequence:number;invocation_id:string;agent_id:string;model_id:string;binding:string;kind:string;occurred_at:string;elapsed_s:number;operation_id:number|null;operation_kind:string|null;duration_s:number|null;tool_index:number|null;tool_name:string|null;status:string|null;error:string|null;output_bytes:number|null;origin:string|null;reused:boolean|null;journal_reused:boolean|null;presentation_reused:boolean|null} +export interface ProgressGap {type:'gap';invocation_id:string;first_sequence:number;last_sequence:number} +export interface CollectionEvent {type:'collection';kind:string;collection_id:string;occurred_at:string;invocation_id:string|null;last_sequence:number;observed_events:number;lost_events:number;terminal_kind:string|null;error:string|null} +export type HistoryEvent=ProgressEvent|ProgressGap|CollectionEvent; +export interface HistoryEntry {position:number;step_id:string;selection_id:string;event:HistoryEvent;collection_id:string|null;activation_id:string|null} +export interface HistoryPage {space:string;run_id:string;available:boolean;items:HistoryEntry[];next_after:string|null;retained_from:number|null;omitted:[number,number]|null} + +function invalid(what:string):never{throw Error('The run history could not be verified: '+what+'.');} +function text(value:unknown,max:number,what:string):string{if(typeof value!=='string'||!value.trim()||value.length>max)invalid(what);return value;} +function integer(value:unknown,min:number,max:number,what:string):number{if(typeof value!=='number'||!Number.isSafeInteger(value)||valuemax)invalid(what);return value;} +function boolean(value:unknown,what:string):boolean{if(typeof value!=='boolean')invalid(what);return value;} +function timestamp(value:unknown,what:string):string{const result=text(value,64,what);if(!Number.isFinite(Date.parse(result)))invalid(what);return result;} +function hex(value:unknown,length:number,what:string):string{const result=text(value,length,what);if(!new RegExp('^[0-9a-f]{'+length+'}$').test(result))invalid(what);return result;} +function choice(value:unknown,choices:readonly T[],what:string):T{if(typeof value!=='string'||!(choices as readonly string[]).includes(value))invalid(what);return value as T;} +function duration(value:unknown):number{if(typeof value!=='number'||!Number.isFinite(value)||value<0)invalid('event timing');return value;} +function items(value:unknown,max:number,what:string):unknown[]{if(!Array.isArray(value)||value.length>max)invalid(what);return value as unknown[];} +function exact(row:Record,fields:string[],what:string):void{const keys=Object.keys(row);if(keys.length!==fields.length||fields.some(field=>!Object.hasOwn(row,field)))invalid(what);} +function optional(value:unknown,read:(value:unknown)=>T):T|null{return value===null?null:read(value);} + +export function historyCursor(value:unknown):string{ + const result=text(value,114,'history cursor'); + if(!/^[0-9a-f]{32}\.[0-9a-f]{16}\.[0-9a-f]{64}$/.test(result))invalid('history cursor'); + integer(cursorPosition(result),0,MAX_POSITION,'history cursor'); + return result; +} +export function cursorPosition(cursor:string):number{return Number.parseInt(cursor.split('.')[1],16);} +export function historyAddress(id:string):string{return runAddress(id)+'/history';} + +function progressEvent(row:Record):ProgressEvent{ + exact(row,PROGRESS_FIELDS,'progress fields'); + const result:ProgressEvent={type:'progress',sequence:integer(row.sequence,1,MAX_POSITION,'event sequence'),invocation_id:hex(row.invocation_id,32,'invocation'),agent_id:identifier(row.agent_id),model_id:identifier(row.model_id),binding:hex(row.binding,64,'event binding'),kind:choice(row.kind,KINDS,'event kind'),occurred_at:timestamp(row.occurred_at,'event time'),elapsed_s:duration(row.elapsed_s), + operation_id:optional(row.operation_id,value=>integer(value,1,MAX_POSITION,'operation id')),operation_kind:optional(row.operation_kind,value=>choice(value,['model','memory','custom'],'operation kind')),duration_s:optional(row.duration_s,duration), + tool_index:optional(row.tool_index,value=>integer(value,1,MAX_POSITION,'tool index')),tool_name:optional(row.tool_name,value=>{const name=text(value,128,'event tool');if(!/^[A-Za-z0-9_-]{1,128}$/.test(name))invalid('event tool');return name;}), + status:optional(row.status,value=>choice(value,['prepared','empty','unavailable'],'tool status')),error:optional(row.error,value=>choice(value,ERRORS,'tool error')),output_bytes:optional(row.output_bytes,value=>integer(value,0,MAX_POSITION,'tool output')),origin:optional(row.origin,value=>choice(value,['host','model'],'tool origin')), + reused:optional(row.reused,value=>boolean(value,'tool reuse')),journal_reused:optional(row.journal_reused,value=>boolean(value,'tool reuse')),presentation_reused:optional(row.presentation_reused,value=>boolean(value,'tool reuse'))}; + const operations=[result.operation_id,result.operation_kind,result.duration_s],tool=[result.tool_index,result.tool_name,result.origin],outcome=[result.status,result.error,result.output_bytes,result.reused,result.journal_reused,result.presentation_reused]; + const some=(values:unknown[])=>values.some(value=>value!==null),every=(values:unknown[])=>values.every(value=>value!==null); + if(result.kind.startsWith('turn_')){if(some([...operations,...tool,...outcome]))invalid('turn metadata');} + else if(result.kind.startsWith('operation_')){ + const timed=result.kind==='operation_completed'||result.kind==='operation_failed'; + if(result.operation_id===null||result.operation_kind===null||result.origin!==null||timed!==(result.duration_s!==null)||some(outcome))invalid('operation metadata'); + if(result.operation_kind==='model'){if(result.tool_index!==null||result.tool_name!==null)invalid('model operation');} + else if(result.tool_index===null||result.tool_name===null)invalid('tool operation'); + }else if(some(operations)||!every(tool))invalid('tool metadata'); + else if(result.kind==='tool_proposed'){if(some(outcome))invalid('tool proposal');} + else if(result.status===null||result.output_bytes===null||result.journal_reused===null||result.presentation_reused===null||result.reused!==(result.journal_reused||result.presentation_reused))invalid('tool outcome'); + return result; +} +function gapEvent(row:Record):ProgressGap{ + exact(row,GAP_FIELDS,'gap fields'); + const first=integer(row.first_sequence,1,MAX_POSITION,'gap sequence'); + return {type:'gap',invocation_id:hex(row.invocation_id,32,'invocation'),first_sequence:first,last_sequence:integer(row.last_sequence,first,MAX_POSITION,'gap sequence')}; +} +function collectionEvent(row:Record):CollectionEvent{ + exact(row,COLLECTION_FIELDS,'collection fields'); + const result:CollectionEvent={type:'collection',kind:choice(row.kind,['collection_started','collection_finished','collection_failed'],'collection kind'),collection_id:hex(row.collection_id,32,'collection id'),occurred_at:timestamp(row.occurred_at,'collection time'),invocation_id:optional(row.invocation_id,value=>hex(value,32,'invocation')), + last_sequence:integer(row.last_sequence,0,MAX_POSITION,'collection counts'),observed_events:integer(row.observed_events,0,MAX_POSITION,'collection counts'),lost_events:integer(row.lost_events,0,MAX_POSITION,'collection counts'),terminal_kind:optional(row.terminal_kind,value=>choice(value,TERMINALS,'collection outcome')),error:optional(row.error,value=>choice(value,['history_unavailable','collection_interrupted'],'collection error'))}; + if(result.observed_events+result.lost_events!==result.last_sequence)invalid('collection counts'); + if(result.kind==='collection_started'){if(result.last_sequence||result.invocation_id!==null||result.terminal_kind!==null||result.error!==null)invalid('collection start');} + else if(result.kind==='collection_finished'){if(result.invocation_id===null||result.terminal_kind===null||!result.last_sequence||result.error!==null)invalid('collection completion');} + else if(result.error===null)invalid('collection failure'); + return result; +} +export function parseHistoryEvent(value:unknown):HistoryEvent{ + const row=record(value); + if(!Object.hasOwn(row,'kind'))return gapEvent(row); + if(typeof row.kind==='string'&&row.kind.startsWith('collection_'))return collectionEvent(row); + return progressEvent(row); +} + +function matchEntry(entry:HistoryEntry,request:RunRequest):void{ + const plan=request.plan;let agent_id:string,model_id:string; + if(isHandoffPlan(plan)){ + const steps=Array.from({length:plan.max_handoffs+1},(_,index)=>'hop-'+String(index+1).padStart(2,'0')); + if(!steps.includes(entry.step_id))invalid('history hop'); + const policies=new Map(plan.agents.map(agent=>[agent.agent_id,agent])); + let reachable=new Set([plan.root_agent]); + for(let hop=0;hoppolicies.get(name)?.can_handoff_to??[])); + const selected=policies.get(entry.selection_id); + if(!reachable.has(entry.selection_id)||!selected)invalid('history route'); + agent_id=selected.agent_id;model_id=selected.model_id; + }else{ + const task=plan.tasks.find(task=>task.task_id===entry.step_id); + if(!task||isInputTask(task)||entry.selection_id!==entry.step_id)invalid('history task'); + agent_id=task.agent_id;model_id=task.model_id; + } + const binding=request.bindings[entry.selection_id]; + if(binding===undefined)invalid('history selection'); + if(entry.event.type==='progress'&&(entry.event.agent_id!==agent_id||entry.event.model_id!==model_id||entry.event.binding!==binding))invalid('history model binding'); +} +export function parseHistoryEntry(value:unknown,request:RunRequest):HistoryEntry{ + const row=record(value),required=['position','step_id','selection_id','event']; + if(required.some(field=>!Object.hasOwn(row,field))||Object.keys(row).some(key=>!required.includes(key)&&key!=='collection_id'&&key!=='activation_id'))invalid('history entry fields'); + const entry:HistoryEntry={position:integer(row.position,1,MAX_POSITION,'history position'),step_id:identifier(row.step_id),selection_id:identifier(row.selection_id),event:parseHistoryEvent(row.event), + collection_id:row.collection_id===undefined?null:optional(row.collection_id,value=>hex(value,32,'collection id')),activation_id:row.activation_id===undefined?null:optional(row.activation_id,identifier)}; + if(entry.event.type==='collection'&&entry.collection_id!==entry.event.collection_id)invalid('collection identity'); + matchEntry(entry,request); + return entry; +} + +export function parseHistoryPage(value:unknown,request:RunRequest,after:string|null=null,limit=50):HistoryPage{ + integer(limit,1,100,'history limit'); + const previous=after===null?null:historyCursor(after); + const row=record(value);exact(row,PAGE_FIELDS,'history page fields'); + if(row.space!==request.space||row.run_id!==request.run_id)invalid('history identity'); + const available=boolean(row.available,'history availability'); + const entries=items(row.items,limit,'history items').map(item=>parseHistoryEntry(item,request)); + if(!available){ + if(entries.length||previous!==null||row.next_after!==null||row.retained_from!==null||row.omitted!==null)invalid('unavailable history'); + return {space:request.space,run_id:request.run_id,available:false,items:[],next_after:null,retained_from:null,omitted:null}; + } + const next_after=historyCursor(row.next_after),floor=integer(row.retained_from,1,MAX_POSITION,'retention floor'); + const wanted=previous===null?1:cursorPosition(previous)+1,start=Math.max(wanted,floor); + let omitted:[number,number]|null=null; + if(row.omitted!==null){const gap=items(row.omitted,2,'retention gap');if(gap.length!==2)invalid('retention gap');omitted=[integer(gap[0],1,MAX_POSITION,'retention gap'),integer(gap[1],1,MAX_POSITION,'retention gap')];} + const expectedGap=start>wanted?[wanted,start-1]:null; + if(JSON.stringify(omitted)!==JSON.stringify(expectedGap))invalid('retention continuity'); + if(entries.some((entry,index)=>entry.position!==start+index))invalid('history continuity'); + const expected=entries.length?entries[entries.length-1].position:start-1; + if(cursorPosition(next_after)!==expected||(!entries.length&&previous!==next_after))invalid('history cursor position'); + if(previous!==null&&next_after.split('.')[0]!==previous.split('.')[0])invalid('history generation'); + const observed=new Map(); + for(const entry of entries){ + if(entry.collection_id===null)continue; + const current:[string|null,string|null,string,string]=[entry.event.type==='collection'?entry.event.invocation_id:entry.event.invocation_id,entry.activation_id,entry.step_id,entry.selection_id]; + const earlier=observed.get(entry.collection_id); + if(earlier&&(earlier[1]!==current[1]||earlier[2]!==current[2]||earlier[3]!==current[3]||(earlier[0]!==null&¤t[0]!==null&&earlier[0]!==current[0])))invalid('collection continuity'); + observed.set(entry.collection_id,[current[0]??(earlier?earlier[0]:null),current[1],current[2],current[3]]); + } + return {space:request.space,run_id:request.run_id,available:true,items:entries,next_after,retained_from:floor,omitted}; +} + +// What an entry says, in words. Never the content of anything -- there is +// none in these events -- only what happened, to which step, and when. +export interface EntryDescription {title:string;detail:string} +const seconds=(value:number)=>`${Number(value.toFixed(3))} s`; +function operation(event:ProgressEvent,verb:string):string{return (event.operation_kind==='model'?'Model call ':'Tool call ')+verb+(event.tool_name?' · '+event.tool_name:'');} +export function describeEntry(entry:HistoryEntry):EntryDescription{ + const event=entry.event; + if(event.type==='gap')return {title:`Unobserved sequences ${event.first_sequence}–${event.last_sequence}`,detail:'Events the collector did not see; the count between them is unknown'}; + if(event.type==='collection'){ + const counts=`${event.observed_events} observed, ${event.lost_events} lost`; + if(event.kind==='collection_started')return {title:'Observation started',detail:'at '+event.occurred_at}; + if(event.kind==='collection_finished')return {title:'Observation finished',detail:`${event.terminal_kind} · ${counts} · at ${event.occurred_at}`}; + return {title:'Observation failed',detail:`${event.error} · ${counts} · at ${event.occurred_at}`}; + } + const when=`elapsed ${seconds(event.elapsed_s)} · at ${event.occurred_at}`; + switch(event.kind){ + case 'turn_started':return {title:'Turn started',detail:when}; + case 'turn_completed':return {title:'Turn completed',detail:when}; + case 'turn_paused':return {title:'Turn paused',detail:when}; + case 'turn_failed':return {title:'Turn failed',detail:when}; + case 'turn_cancelled':return {title:'Turn cancelled',detail:when}; + case 'operation_started':return {title:operation(event,'started'),detail:when}; + case 'operation_completed':return {title:operation(event,'finished'),detail:`took ${seconds(event.duration_s??0)} · ${when}`}; + case 'operation_failed':return {title:operation(event,'failed'),detail:`after ${seconds(event.duration_s??0)} · ${when}`}; + case 'operation_reused':return {title:operation(event,'reused'),detail:when}; + case 'tool_proposed':return {title:`Tool proposed · ${event.tool_name}`,detail:`by the ${event.origin} · ${when}`}; + default:{ + const reuse=event.journal_reused?'journal reused':event.presentation_reused?'presentation reused':'fresh'; + return {title:`Tool result · ${event.tool_name}`,detail:`${event.status}${event.error?' · '+event.error:''} · ${event.output_bytes} bytes · ${reuse} · ${when}`}; + } + } +} diff --git a/src/api.ts b/src/api.ts index 64edd24..de09b54 100644 --- a/src/api.ts +++ b/src/api.ts @@ -2,6 +2,7 @@ import {readDocumentOriginal,validateOriginalReference} from './memory/document- import {readVideoFrame,validateVideoFrameReference,type VideoFrameReference} from './memory/document-video-frame.ts'; import {exportAddress,readGraphExport,type GraphExportRequest,type GraphExportFile} from './memory/knowledge-export.ts'; import {attachVoiceSocket,type VoiceEvents} from './conversations/voice/channel.ts'; +import {historyAddress,historyCursor} from './agents/history.ts'; import {voiceSocketUrl} from './conversations/voice/wire.ts'; export interface ApiClient { @@ -14,6 +15,7 @@ export interface ApiClient { documentVideoFrame(episodeId:number,frame:VideoFrameReference,signal:AbortSignal):Promise; documentOriginal(original:ImageAttachment,signal?:AbortSignal):Promise; conversationStream(sid: string, requestId: string, after: number, signal: AbortSignal): Promise>; + historyStream(runId:string,after:string|null,limit:number,signal:AbortSignal):Promise>; voiceConnection(sid:string,format:{sampleRate:number;channels:number},events:VoiceEvents,signal:AbortSignal):ReturnType; } @@ -54,6 +56,24 @@ export function createApiClient(key: string, unauthorized: () => void, base = '' const url=voiceSocketUrl(sid,base||window.location.origin); return attachVoiceSocket(new WebSocket(url),key,sid,format,events,signal); }, + async historyStream(runId,after,limit,signal){ + if(after!==null)historyCursor(after); + if(!Number.isSafeInteger(limit)||limit<1||limit>100)throw Error('Invalid history page size'); + const path=historyAddress(runId)+'/stream?limit='+limit+(after?'&after='+encodeURIComponent(after):''); + const response=await fetch(base+path,{ + method:'GET',headers:{Authorization:'Bearer '+key,Accept:'text/event-stream',...(after?{'Last-Event-ID':after}:{})},signal, + cache:'no-store',redirect:'error',credentials:'omit',referrerPolicy:'no-referrer', + }); + if(!response.ok){ + await response.body?.cancel(); + if(response.status===401)unauthorized(); + throw new ApiError(response.status,`History stream request failed (${response.status})`); + } + if(response.headers.get('content-type')?.split(';')[0].trim()!=='text/event-stream'||!response.body){ + await response.body?.cancel();throw Error('Invalid history stream response'); + } + return response.body; + }, async conversationStream(sid, requestId, after, signal) { if (![sid,requestId].every(id=>/^[A-Za-z0-9._:-]{1,128}$/.test(id)&&id!=='.'&&id!=='..') ||!Number.isSafeInteger(after)||after<0) throw Error('Invalid conversation stream address'); diff --git a/src/capabilities.ts b/src/capabilities.ts index e098ed5..1cbd08e 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -1,7 +1,7 @@ export const FEATURE_KEYS = ['recall', 'facts.read', 'facts.review', 'facts.close', 'facts.exclude', 'facts.include', 'events.read', 'metrics.read', 'scopes.read', 'status.read'] as const; export type Feature = typeof FEATURE_KEYS[number]; -type OptionalFeature = 'agents.handoffs.output_requirements' | 'agents.output_requirements' | 'agents.output_schema' | 'agents.approvals' | 'agents.usage' | 'documents.video.understand' | 'documents.sync' | 'agents.inputs' | 'recall.parts' | 'agents.handoffs' | 'agents.parallel' | 'agents.runs' | 'agents.catalog' | 'agents.plans' | 'episodes.forget' | 'documents.ocr.tables' | 'documents.jobs' | 'documents.files' | 'graph.knowledge_walk' | 'documents.provenance' | 'recall.graph_boost' | 'graph.knowledge_seeds' | 'graph.knowledge_paging' | 'graph.timeline' | 'graph.sources' | 'graph.export' | 'graph.path' | 'graph.knowledge' | 'entities.read' | 'graph.report' | 'images.understand' | 'models.manage' | 'episodes.attachments' | 'episodes.list' | 'episodes.read' | 'facts.links' | 'integrity.read' | 'profile.read' | 'processing.distill' | 'processing.derive' | 'jobs.read' | 'recall.conditions'; +type OptionalFeature = 'agents.history' | 'agents.handoffs.output_requirements' | 'agents.output_requirements' | 'agents.output_schema' | 'agents.approvals' | 'agents.usage' | 'documents.video.understand' | 'documents.sync' | 'agents.inputs' | 'recall.parts' | 'agents.handoffs' | 'agents.parallel' | 'agents.runs' | 'agents.catalog' | 'agents.plans' | 'episodes.forget' | 'documents.ocr.tables' | 'documents.jobs' | 'documents.files' | 'graph.knowledge_walk' | 'documents.provenance' | 'recall.graph_boost' | 'graph.knowledge_seeds' | 'graph.knowledge_paging' | 'graph.timeline' | 'graph.sources' | 'graph.export' | 'graph.path' | 'graph.knowledge' | 'entities.read' | 'graph.report' | 'images.understand' | 'models.manage' | 'episodes.attachments' | 'episodes.list' | 'episodes.read' | 'facts.links' | 'integrity.read' | 'profile.read' | 'processing.distill' | 'processing.derive' | 'jobs.read' | 'recall.conditions'; export interface Capabilities { schema_version: 1; implementation: string; @@ -14,7 +14,7 @@ export function parseCapabilities(value: unknown): Capabilities { if (data.schema_version !== 1 || typeof data.implementation !== 'string' || !data.implementation.trim() || !data.features || typeof data.features !== 'object' || Array.isArray(data.features)) throw Error('Unsupported capability response'); const features = data.features as Record; - for(const key of ['agents.approvals','agents.handoffs.output_requirements','agents.output_requirements','agents.output_schema','agents.usage','documents.video.understand','documents.sync','documents.ocr.tables','agents.inputs','recall.parts','agents.handoffs','agents.parallel','agents.runs','agents.catalog','agents.plans','episodes.forget','documents.jobs','documents.files','documents.provenance','processing.distill','processing.derive','graph.knowledge','entities.read','graph.report','graph.path','graph.export','graph.sources','graph.timeline','graph.knowledge_paging','graph.knowledge_seeds','graph.knowledge_walk','recall.graph_boost']){ + for(const key of ['agents.history','agents.approvals','agents.handoffs.output_requirements','agents.output_requirements','agents.output_schema','agents.usage','documents.video.understand','documents.sync','documents.ocr.tables','agents.inputs','recall.parts','agents.handoffs','agents.parallel','agents.runs','agents.catalog','agents.plans','episodes.forget','documents.jobs','documents.files','documents.provenance','processing.distill','processing.derive','graph.knowledge','entities.read','graph.report','graph.path','graph.export','graph.sources','graph.timeline','graph.knowledge_paging','graph.knowledge_seeds','graph.knowledge_walk','recall.graph_boost']){ if(features[key]!==undefined&&typeof features[key]!=='boolean')throw Error('Invalid processing capability flag'); } if (FEATURE_KEYS.some(key => typeof features[key] !== 'boolean')) throw Error('Incomplete or invalid capability flags'); @@ -28,5 +28,5 @@ export function parseCapabilities(value: unknown): Capabilities { if(features['recall.conditions']!==undefined&&typeof features['recall.conditions']!=='boolean')throw Error('Invalid recall conditions capability'); if(features['images.understand']!==undefined&&typeof features['images.understand']!=='boolean')throw Error('Invalid image understanding capability'); if(features['models.manage']!==undefined&&typeof features['models.manage']!=='boolean')throw Error('Invalid model management capability'); - return {schema_version:1, implementation:data.implementation, features:{'agents.approvals':features['agents.approvals']===true,'agents.handoffs.output_requirements':features['agents.handoffs.output_requirements']===true,'agents.output_requirements':features['agents.output_requirements']===true,'agents.output_schema':features['agents.output_schema']===true,'agents.usage':features['agents.usage']===true,'documents.video.understand':features['documents.video.understand']===true,'documents.sync':features['documents.sync']===true,'documents.ocr.tables':features['documents.ocr.tables']===true,'agents.inputs':features['agents.inputs']===true,'recall.parts':features['recall.parts']===true,'agents.handoffs':features['agents.handoffs']===true,'agents.parallel':features['agents.parallel']===true,'agents.runs':features['agents.runs']===true,'agents.catalog':features['agents.catalog']===true,'agents.plans':features['agents.plans']===true,'episodes.forget':features['episodes.forget']===true,'documents.jobs':features['documents.jobs']===true,'documents.files':features['documents.files']===true,'graph.knowledge_walk':features['graph.knowledge_walk']===true,'documents.provenance':features['documents.provenance']===true,'recall.graph_boost':features['recall.graph_boost']===true,'graph.knowledge_seeds':features['graph.knowledge_seeds']===true,'graph.knowledge_paging':features['graph.knowledge_paging']===true,'graph.timeline':features['graph.timeline']===true,'graph.sources':features['graph.sources']===true,'graph.export':features['graph.export']===true,'graph.path':features['graph.path']===true,'graph.report':features['graph.report']===true,'graph.knowledge':features['graph.knowledge']===true,'entities.read':features['entities.read']===true,...Object.fromEntries(FEATURE_KEYS.map(key => [key, features[key]])), 'episodes.attachments':features['episodes.attachments'] === true, 'episodes.list':features['episodes.list'] === true, 'episodes.read':features['episodes.read'] === true, 'facts.links':features['facts.links'] === true, 'integrity.read':features['integrity.read'] === true, 'profile.read':features['profile.read'] === true, 'processing.distill':features['processing.distill']===true, 'processing.derive':features['processing.derive']===true, 'jobs.read':features['jobs.read']===true, 'recall.conditions':features['recall.conditions']===true, 'models.manage':features['models.manage']===true, 'images.understand':features['images.understand']===true} as Capabilities['features']}; + return {schema_version:1, implementation:data.implementation, features:{'agents.history':features['agents.history']===true,'agents.approvals':features['agents.approvals']===true,'agents.handoffs.output_requirements':features['agents.handoffs.output_requirements']===true,'agents.output_requirements':features['agents.output_requirements']===true,'agents.output_schema':features['agents.output_schema']===true,'agents.usage':features['agents.usage']===true,'documents.video.understand':features['documents.video.understand']===true,'documents.sync':features['documents.sync']===true,'documents.ocr.tables':features['documents.ocr.tables']===true,'agents.inputs':features['agents.inputs']===true,'recall.parts':features['recall.parts']===true,'agents.handoffs':features['agents.handoffs']===true,'agents.parallel':features['agents.parallel']===true,'agents.runs':features['agents.runs']===true,'agents.catalog':features['agents.catalog']===true,'agents.plans':features['agents.plans']===true,'episodes.forget':features['episodes.forget']===true,'documents.jobs':features['documents.jobs']===true,'documents.files':features['documents.files']===true,'graph.knowledge_walk':features['graph.knowledge_walk']===true,'documents.provenance':features['documents.provenance']===true,'recall.graph_boost':features['recall.graph_boost']===true,'graph.knowledge_seeds':features['graph.knowledge_seeds']===true,'graph.knowledge_paging':features['graph.knowledge_paging']===true,'graph.timeline':features['graph.timeline']===true,'graph.sources':features['graph.sources']===true,'graph.export':features['graph.export']===true,'graph.path':features['graph.path']===true,'graph.report':features['graph.report']===true,'graph.knowledge':features['graph.knowledge']===true,'entities.read':features['entities.read']===true,...Object.fromEntries(FEATURE_KEYS.map(key => [key, features[key]])), 'episodes.attachments':features['episodes.attachments'] === true, 'episodes.list':features['episodes.list'] === true, 'episodes.read':features['episodes.read'] === true, 'facts.links':features['facts.links'] === true, 'integrity.read':features['integrity.read'] === true, 'profile.read':features['profile.read'] === true, 'processing.distill':features['processing.distill']===true, 'processing.derive':features['processing.derive']===true, 'jobs.read':features['jobs.read']===true, 'recall.conditions':features['recall.conditions']===true, 'models.manage':features['models.manage']===true, 'images.understand':features['images.understand']===true} as Capabilities['features']}; } diff --git a/tests/agent-history.test.ts b/tests/agent-history.test.ts new file mode 100644 index 0000000..208bd47 --- /dev/null +++ b/tests/agent-history.test.ts @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {parseHistoryPage,describeEntry,historyAddress,historyCursor,type HistoryPage} from '../src/agents/history.ts'; +import {readHistoryFrames} from '../src/agents/history-stream.ts'; +import {parseRunRequest} from '../src/agents/runs.ts'; +import {parseCapabilities} from '../src/capabilities.ts'; +import {readFileSync} from 'node:fs'; +const fixtures=JSON.parse(readFileSync(new URL('./fixtures/http-capabilities.json',import.meta.url),'utf8')) as {python:{features:Record}}; + +const task={task_id:'find',agent_id:'research',model_id:'careful',prompt:'Find evidence',depends_on:[]}; +const snapshot={space:'alpha',run_id:'run-1',created_at:'2026-09-11T00:00:00Z',cancel_requested_at:null,question:'What happened?',scope:{},exclude_session_id:null,plan:{space:'alpha',revision:1,updated_at:'2026-09-11T00:00:00Z',plan:{workflow_id:'report',tasks:[task]},bindings:{find:'a'.repeat(64)}}}; +const request=parseRunRequest(snapshot,'alpha','run-1'); +const invocation='c'.repeat(32),collection='d'.repeat(32); +const cursor=(position:number,generation='0'.repeat(32))=>`${generation}.${position.toString(16).padStart(16,'0')}.${'b'.repeat(64)}`; +const progress=(sequence:number,kind:string,extra:Record={})=>({sequence,invocation_id:invocation,agent_id:'research',model_id:'careful',binding:'a'.repeat(64),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,...extra}); +const started={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}; +const finished={kind:'collection_finished',collection_id:collection,occurred_at:'2026-09-11T00:00:02Z',invocation_id:invocation,last_sequence:3,observed_events:2,lost_events:1,terminal_kind:'turn_completed',error:null}; +const gap={invocation_id:invocation,first_sequence:2,last_sequence:2}; +const entry=(position:number,event:unknown)=>({position,step_id:'find',selection_id:'find',event,collection_id:collection,activation_id:null}); +const events=[started,progress(1,'turn_started'),gap,progress(3,'turn_completed'),finished]; +const page=(from:number,items:unknown[],extra:Record={})=>({space:'alpha',run_id:'run-1',available:true,items,next_after:cursor(from+items.length-1),retained_from:1,omitted:null,...extra}); +const whole=page(1,events.map((event,index)=>entry(index+1,event))); + +test('a history page is read in order and each entry describes itself without private text',()=>{ + const parsed=parseHistoryPage(whole,request); + assert.deepEqual(parsed.items.map(item=>item.position),[1,2,3,4,5]); + assert.equal(parsed.next_after,cursor(5)); + assert.equal(parsed.retained_from,1); + assert.deepEqual(parsed.items.map(item=>describeEntry(item).title),['Observation started','Turn started','Unobserved sequences 2–2','Turn completed','Observation finished']); + const last=describeEntry(parsed.items[4]); + assert.match(last.detail,/turn_completed/); + assert.match(last.detail,/2 observed/); + assert.match(last.detail,/1 lost/); + assert.equal(historyAddress('run-1'),'/v1/agent-runs/run-1/history'); +}); + +test('operations and tool outcomes are described with their timing and never their content',()=>{ + const items=[entry(1,progress(1,'operation_started',{operation_id:1,operation_kind:'model'})),entry(2,progress(2,'operation_completed',{operation_id:1,operation_kind:'model',duration_s:0.5})), + entry(3,progress(3,'tool_result',{tool_index:1,tool_name:'search_memory',origin:'model',status:'prepared',output_bytes:120,reused:false,journal_reused:false,presentation_reused:false}))]; + const parsed=parseHistoryPage(page(1,items),request); + const described=parsed.items.map(describeEntry); + assert.equal(described[0].title,'Model call started'); + assert.equal(described[1].title,'Model call finished'); + assert.match(described[1].detail,/0\.5 s/); + assert.equal(described[2].title,'Tool result · search_memory'); + assert.match(described[2].detail,/120 bytes/); + assert.ok(!JSON.stringify(described).includes('text')); +}); + +test('a page that does not belong to this run or breaks continuity is refused',()=>{ + assert.throws(()=>parseHistoryPage({...whole,space:'bravo'},request)); + assert.throws(()=>parseHistoryPage({...whole,run_id:'run-2'},request)); + assert.throws(()=>parseHistoryPage(page(1,[entry(1,started),entry(3,progress(1,'turn_started'))]),request),/continuity|position/i); + assert.throws(()=>parseHistoryPage({...whole,next_after:cursor(9)},request)); + assert.throws(()=>parseHistoryPage({...whole,extra:1},request)); + assert.throws(()=>parseHistoryPage(page(1,[{...entry(1,started),text:'private'}]),request),/entry/i); + assert.throws(()=>parseHistoryPage(page(1,[entry(1,progress(1,'turn_started',{model_id:'fast'}))]),request),/binding/i); + assert.throws(()=>parseHistoryPage(page(1,[entry(1,progress(1,'turn_started',{binding:'e'.repeat(64)}))]),request),/binding/i); + assert.throws(()=>parseHistoryPage(page(1,[{...entry(1,started),step_id:'other',selection_id:'other'}]),request)); + assert.throws(()=>parseHistoryPage(page(1,[entry(1,{...started,collection_id:'e'.repeat(32)})]),request),/collection/i); + assert.throws(()=>parseHistoryPage({...whole,available:false},request)); + assert.throws(()=>parseHistoryPage({...whole,next_after:'not-a-cursor'},request)); +}); + +test('an unavailable history says so and carries nothing else',()=>{ + const parsed=parseHistoryPage({space:'alpha',run_id:'run-1',available:false,items:[],next_after:null,retained_from:null,omitted:null},request); + assert.equal(parsed.available,false); + assert.deepEqual(parsed.items,[]); + assert.equal(parsed.next_after,null); +}); + +test('retention is disclosed exactly: what the cursor asked for and what remains',()=>{ + const items=[entry(7,progress(7,'turn_started')),entry(8,progress(8,'turn_completed'))]; + const parsed=parseHistoryPage(page(7,items,{retained_from:7,omitted:[5,6]}),request,cursor(4)); + assert.deepEqual(parsed.omitted,[5,6]); + assert.throws(()=>parseHistoryPage(page(7,items,{retained_from:7,omitted:null}),request,cursor(4)),/retention/i); + assert.throws(()=>parseHistoryPage(page(7,items,{retained_from:7,omitted:[5,7]}),request,cursor(4)),/retention/i); + assert.throws(()=>parseHistoryPage(page(7,items,{retained_from:7,omitted:[5,6]}),request,cursor(4,'1'.repeat(32))),/generation/i); + const empty=parseHistoryPage({...page(9,[]),next_after:cursor(8),retained_from:1},request,cursor(8)); + assert.equal(empty.next_after,cursor(8)); + assert.throws(()=>historyCursor('x')); +}); + +function stream(...parts:string[]):ReadableStream{ + const encoder=new TextEncoder(); + return new ReadableStream({start(controller){for(const part of parts)controller.enqueue(encoder.encode(part));controller.close();}}); +} +async function collect(body:ReadableStream,after:string|null=null):Promise{ + const pages:HistoryPage[]=[]; + for await(const item of readHistoryFrames(body,{request,after,limit:50}))pages.push(item); + return pages; +} +const first=page(1,events.slice(0,3).map((event,index)=>entry(index+1,event))); +const second={...page(4,events.slice(3).map((event,index)=>entry(index+4,event))),retained_from:1}; +const frame=(event:string,id:string|null,data:unknown)=>`event: ${event}\n${id===null?'':'id: '+id+'\n'}data: ${JSON.stringify(data)}\n\n`; + +test('history frames are read page by page, keep-alives ignored, until the server ends the stream',async()=>{ + const pages=await collect(stream(': keep-alive\n\n',frame('history',cursor(3),first),': still here\n\n',frame('history',cursor(5),second),frame('end',null,{}))); + assert.deepEqual(pages.map(item=>item.items.map(entry=>entry.position)),[[1,2,3],[4,5]]); + assert.equal(pages[1].next_after,cursor(5)); +}); + +test('a frame whose id does not name its page, an error frame, and a stream cut mid-frame are all refused',async()=>{ + await assert.rejects(collect(stream(frame('history',cursor(2),first),frame('end',null,{}))),/id/i); + await assert.rejects(collect(stream(frame('history',cursor(3),first),frame('error',null,{error:'history_unavailable'}))),/history_unavailable/); + await assert.rejects(collect(stream(frame('history',cursor(3),first),'event: history\nid: '+cursor(5)+'\n')),/ended/i); + await assert.rejects(collect(stream(frame('history',cursor(3),{...first,space:'bravo'}),frame('end',null,{})))); + await assert.rejects(collect(stream(frame('other',null,{}),frame('end',null,{}))),/event/i); +}); + +test('resuming from a cursor requires continuity with it',async()=>{ + const pages=await collect(stream(frame('history',cursor(5),second),frame('end',null,{})),cursor(3)); + assert.equal(pages.length,1); + await assert.rejects(collect(stream(frame('history',cursor(5),second),frame('end',null,{})),cursor(2)),/continuity|position/i); +}); + +test('the history capability is read like the others',()=>{ + const features={...fixtures.python.features};delete (features as Record)['agents.history']; + assert.equal(parseCapabilities({...fixtures.python,features}).features['agents.history'],false); + assert.equal(parseCapabilities({...fixtures.python,features:{...features,'agents.history':true}}).features['agents.history'],true); + assert.throws(()=>parseCapabilities({...fixtures.python,features:{...features,'agents.history':'yes'}})); +}); diff --git a/tests/api.test.ts b/tests/api.test.ts index 03fbc5b..b018c3e 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -153,3 +153,32 @@ test('original-file download authenticates a fixed attachment path and refuses r try{assert.deepEqual(Buffer.from(await (await api.documentOriginal(original)).arrayBuffer()),bytes);redirect=true;await assert.rejects(api.documentOriginal(original));assert.equal(calls,2);await assert.rejects(api.documentOriginal({...original,attachment_id:'../elsewhere'}));assert.equal(calls,2);} finally{server.closeAllConnections();await new Promise(resolve=>server.close(()=>resolve()));} }); + +test('history streams send the cursor both ways and refuse non-SSE responses, redirects and bad cursors',async()=>{ + let status=200,type='text/event-stream; charset=utf-8',denied=false,calls=0,lastEventId:string|undefined; + const cursor='0'.repeat(32)+'.'+'0'.repeat(15)+'3.'+'b'.repeat(64); + const server=createServer((req,res)=>{ + calls++;assert.equal(req.method,'GET');assert.equal(req.headers.authorization,'Bearer allowed');assert.equal(req.headers.accept,'text/event-stream'); + lastEventId=req.headers['last-event-id'] as string|undefined; + res.writeHead(status,{'content-type':type,location:'/unexpected'});res.end(': keep-alive\n\n'); + }); + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); + const address=server.address();if(!address||typeof address==='string')throw Error('missing address'); + const client=createApiClient('allowed',()=>{denied=true;},`http://127.0.0.1:${address.port}`); + const controller=new AbortController(); + try{ + const stream=await client.historyStream('run-1',cursor,50,controller.signal); + assert.equal(await new Response(stream).text(),': keep-alive\n\n'); + assert.equal(lastEventId,cursor,'the cursor travels as Last-Event-ID as well as the query'); + await client.historyStream('run-1',null,25,controller.signal); + assert.equal(lastEventId,undefined,'no cursor, no Last-Event-ID'); + for(const bad of ['../secret','..','with/slash'])await assert.rejects(client.historyStream(bad,null,50,controller.signal)); + await assert.rejects(client.historyStream('run-1','not-a-cursor',50,controller.signal),/cursor/i); + await assert.rejects(client.historyStream('run-1',null,0,controller.signal)); + await assert.rejects(client.historyStream('run-1',null,101,controller.signal)); + assert.equal(calls,2); + type='application/json';await assert.rejects(client.historyStream('run-1',null,50,controller.signal),/stream/i); + type='text/event-stream';status=302;await assert.rejects(client.historyStream('run-1',null,50,controller.signal)); + status=401;await assert.rejects(client.historyStream('run-1',null,50,controller.signal),e=>e instanceof ApiError&&e.status===401);assert.equal(denied,true); + }finally{server.closeAllConnections();await new Promise(resolve=>server.close(()=>resolve()));} +});