@getopencrawl/cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import {readResearchAnswerContract} from './research-answer-contract.mjs';
3
3
  import {readResearchAnswerView} from './research-answer-view.mjs';
4
4
  import fs from 'node:fs/promises';
5
5
  import {createHash} from 'node:crypto';
6
- import {allowedOptions,decimal,identifier,withInterrupt} from './jobs.mjs';
6
+ import {allowedOptions,decimal,identifier,settle,transient,withInterrupt} from './jobs.mjs';
7
7
  import {displayLine,displayText} from './display.mjs';
8
8
 
9
9
  const ROOT='/api/research/agents',ACTIVE=new Set(['queued','running']);
@@ -27,7 +27,7 @@ function confirmedReadFailure(error,request){
27
27
  const message={
28
28
  401:'Saved research could not be read. Check the selected CLI account, server and workspace and its existing credential.',
29
29
  403:'This account cannot read the requested research. Check the selected account and workspace and ask its administrator to confirm access.',
30
- 404:request.command==='receipt'?'The original admission receipt is unavailable to this account. Check the selected workspace and exact operation. A missing receipt does not authorize a new admission.':'The saved research is unavailable to this account. Check the selected workspace and exact agent, run or source identifiers. Do not substitute a current webpage for retained evidence.',
30
+ 404:request.command==='receipt'?'The original admission receipt is unavailable to this account. Check the selected workspace and exact operation. A missing receipt does not authorize a new admission.':'The saved research is unavailable to this account. Check the selected workspace and exact agent, run or source identifiers. Do not substitute a current webpage for retained evidence. Watches, jobs and research built by the OpenCrawl agent belong to their project: use that project\'s key (project.api_key from the build), not the organization key.',
31
31
  409:'The saved research could not be read in its current state. Read the same agent, schedule or feedback and reconcile its current revision before any write.'
32
32
  }[error.status];
33
33
  return message?{code:`http_${error.status}`,message}:null;
@@ -127,7 +127,7 @@ export function agentRequest(rest,options){
127
127
  return {...base,id,route,method:'PATCH',body:{expected_revision:revision,status:command==='pause'?'paused':'active'}};
128
128
  }
129
129
  async function readDescriptor(file){const handle=await fs.open(file,'r');try{const size=(await handle.stat()).size;if(!integer(size,1,65536))throw new Error('Research brief JSON must be between 1 byte and 64 KiB.');const bytes=Buffer.alloc(size+1),read=await handle.read(bytes,0,size+1,0);if(read.bytesRead!==size)throw new Error('Research brief changed while reading.');let value;try{value=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes.subarray(0,size)))}catch{throw new Error('Invalid research brief JSON. Supply valid UTF-8 JSON without extra fields.');}return validateAgentDescriptor(value)}finally{await handle.close()}}
130
- export async function waitAgentRun(client,initial,{timeout,signal,now=()=>performance.now(),sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms))}={}){let run=validateAgentRun(initial);const started=now();let deadline=started+(timeout??([2,3].includes(readResearchCompletion(run).completion?.version??0)?540:300))*1000;while(ACTIVE.has(run.status)){if(signal?.aborted)return {run,wait:{outcome:'interrupted'}};if(now()>=deadline)return {run,wait:{outcome:'timeout'}};try{run=validateAgentRun(await client.request(`${ROOT}/${encodeURIComponent(run.agent_id)}/runs/${encodeURIComponent(run.id)}`,{signal,timeout:Math.max(1,Math.min(30000,Math.ceil(deadline-now()))),maxResponseBytes:262144}),run.agent_id,run.id)}catch(error){const outcome=signal?.aborted?'interrupted':now()>=deadline?'timeout':'unavailable',failure=outcome==='unavailable'?confirmedReadFailure(error,{command:'agent-run'}):null;return {run,wait:{outcome,...(failure?{error:failure}:{})}}}if(timeout===undefined&&[2,3].includes(readResearchCompletion(run).completion?.version??0))deadline=started+540000;if(ACTIVE.has(run.status)){const next=Math.min(deadline,now()+2000);while(!signal?.aborted&&now()<next)await sleep(Math.min(100,next-now()));}}return {run,wait:{outcome:run.status}};}
130
+ export async function waitAgentRun(client,initial,{timeout,signal,now=()=>performance.now(),sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms))}={}){let run=validateAgentRun(initial),failures=0;const started=now();let deadline=started+(timeout??([2,3].includes(readResearchCompletion(run).completion?.version??0)?540:300))*1000;while(ACTIVE.has(run.status)){if(signal?.aborted)return {run,wait:{outcome:'interrupted'}};if(now()>=deadline)return {run,wait:{outcome:'timeout'}};try{run=validateAgentRun(await client.request(`${ROOT}/${encodeURIComponent(run.agent_id)}/runs/${encodeURIComponent(run.id)}`,{signal,timeout:Math.max(1,Math.min(30000,Math.ceil(deadline-now()))),maxResponseBytes:262144}),run.agent_id,run.id);failures=0}catch(error){if(!signal?.aborted&&now()<deadline&&transient(error)){await settle(++failures,{signal,now,sleep,deadline});continue;}const outcome=signal?.aborted?'interrupted':now()>=deadline?'timeout':'unavailable',failure=outcome==='unavailable'?confirmedReadFailure(error,{command:'agent-run'}):null;return {run,wait:{outcome,...(failure?{error:failure}:{})}}}if(timeout===undefined&&[2,3].includes(readResearchCompletion(run).completion?.version??0))deadline=started+540000;if(ACTIVE.has(run.status)){const next=Math.min(deadline,now()+2000);while(!signal?.aborted&&now()<next)await sleep(Math.min(100,next-now()));}}return {run,wait:{outcome:run.status}};}
131
131
  function renderRun(run,stdout){stdout(`Run: ${displayLine(run.id)}\nAgent: ${displayLine(run.agent_id)}\nStatus: ${displayLine(run.status)}\n`);const answer=readResearchAnswerView(run);if(answer.state==='valid'&&answer.sections.some(s=>s.assertions.length)){stdout('\nConcise answer\n');for(const section of answer.sections){stdout('\n'+displayText(section.component.text)+'\n');for(const assertion of section.assertions)stdout(displayText(assertion.text)+'\n');if(section.notice)stdout(displayText(section.notice)+'\n');const evidenceIds=section.assertions.length?[...new Set(section.assertions.flatMap(a=>a.fact_refs.flatMap(r=>run.findings.find(f=>f.id===r.finding_id)?.citations.map(c=>c.id)??[])))]:section.component.evidence_ids;if(evidenceIds.length)stdout('Evidence: '+evidenceIds.map(displayLine).join(', ')+'\n');}stdout('\nExact findings and qualifications\n');}for(const finding of run.findings){stdout(`\n${displayLine(finding.id)}\nObserved statement: ${displayText(finding.claim)}\n`);if(finding.relevance!=='Selected for this research brief.')stdout(`Relevance (inference): ${displayText(finding.relevance)}\n`);for(const qualifier of finding.qualifiers)stdout('Qualification: '+displayText(qualifier)+'\n');for(const cite of finding.citations)stdout(`[${displayLine(cite.id)}] ${displayLine(cite.title)}\n${displayLine(cite.url)}\nObserved: ${displayLine(cite.observed_at)}; source positions ${cite.start}–${cite.end}\n${displayText(cite.quote)}\n`);}for(const gap of [...run.gaps,...readResearchAnswerContract(run).notices.map(notice=>notice.text),...readResearchCompletion(run).notices.map(notice=>notice.text)])stdout('Gap: '+displayText(gap)+'\n');const presentation=readResearchPresentation(run);if(presentation&&!['reviewed','skipped_no_eligible'].includes(presentation.status))stdout('Summary: A concise presentation was not completed for every part. The saved findings and gaps remain available.\n');if(run.error)stdout('Run error: '+displayLine(run.error.message)+'\n');}
132
132
  export async function agentCommand(rest,options,client,{stdout=value=>process.stdout.write(value),stderr=value=>process.stderr.write(value)}={}){
133
133
  const request=agentRequest(rest,options);if(request.file)request.body=await readDescriptor(request.file);
@@ -148,7 +148,7 @@ export async function agentCommand(rest,options,client,{stdout=value=>process.st
148
148
  else if(request.command==='repeats')validateAgentRepeats(value,request.id,request.runId);
149
149
  else if(request.command==='agent-run')validateAgentRun(value,request.id,request.runId);
150
150
  else {validateResearchAgent(value,request.id);if(['pause','resume'].includes(request.command)&&(value.status!==request.body.status||![request.body.expected_revision,request.body.expected_revision+1].includes(value.revision)))invalid();}
151
- }catch(error){const status=Number.isInteger(error?.status)?error.status:undefined,message=confirmedReadFailure(error,request)?.message??(request.command==='feedback'?'Feedback was not confirmed. Read the same finding feedback and reconcile its revision before another write.':request.command==='source'?'Saved source context could not be read. Do not substitute a current webpage for this saved observation.':request.command==='schedule'?'Schedule request was not confirmed. Read the same schedule and reconcile its setting and revision before another write.':'Research request was not confirmed. Read the same agent/run or recover the original admission key; never start a duplicate blindly.');const result={error:{code:status?`http_${status}`:'request_unconfirmed',message,...(request.key?{request_key:request.key}:{})}};if(options.json)stdout(JSON.stringify(result,null,2)+'\n');else stderr(message+(request.key?'\nOriginal request key: '+request.key:'')+'\n');return 1;}
151
+ }catch(error){const status=Number.isInteger(error?.status)?error.status:undefined,message=confirmedReadFailure(error,request)?.message??(request.command==='feedback'?'Feedback was not confirmed. Read the same finding feedback and reconcile its revision before another write.':request.command==='source'?'Saved source context could not be read. Do not substitute a current webpage for this saved observation.':request.command==='schedule'?'Schedule request was not confirmed. Read the same schedule and reconcile its setting and revision before another write.'+(status===409&&error.detail?' Server: '+error.detail:''):'Research request was not confirmed. Read the same agent/run or recover the original admission key; never start a duplicate blindly.');const result={error:{code:status?`http_${status}`:'request_unconfirmed',message,...(request.key?{request_key:request.key}:{})}};if(options.json)stdout(JSON.stringify(result,null,2)+'\n');else stderr(message+(request.key?'\nOriginal request key: '+request.key:'')+'\n');return 1;}
152
152
  if(request.command==='feedback'){if(options.json)stdout(JSON.stringify(value,null,2)+'\n');else stdout(`Feedback: ${displayLine(value.feedback.state)}\nRevision: ${value.feedback.revision}\nRating: ${displayLine(value.feedback.rating??'none')}\n${value.feedback.note===null?'':displayText(value.feedback.note)+'\n'}Customer feedback is not independent factual proof.\n`);return 0;}
153
153
  if(request.command==='source'){if(options.json)stdout(JSON.stringify(value,null,2)+'\n');else stdout(`Saved source context: ${displayLine(value.source.id)}\n${displayLine(value.source.title)}\n${displayLine(value.source.url)}\nObserved: ${displayLine(value.source.observed_at)}\nThis is the admitted evidence window, which may be part of a page.\n\n${displayText(value.source.text)}\n`);return 0;}
154
154
  if(request.command==='runs'){if(options.json)stdout(JSON.stringify(value,null,2)+'\n');else{for(const run of value.items)stdout(`${displayLine(run.id)}\t${displayLine(run.status)}\t${displayLine(run.created_at)}\n`);stderr(`Next cursor: ${displayLine(value.next_cursor)}\nMore admitted runs: ${value.has_more?'yes':'no'}\nRetain pending run IDs and read each exact run until terminal.\n`);}return 0;}
@@ -0,0 +1,231 @@
1
+ import assert from 'node:assert/strict';
2
+ import {test} from 'node:test';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import http from 'node:http';
7
+ import {spawn} from 'node:child_process';
8
+ import {fileURLToPath} from 'node:url';
9
+ import {researchCommand} from './research.mjs';
10
+ import {agentRequest,validateAgentDescriptor,validateAgentRun,waitAgentRun} from './research-agent.mjs';
11
+
12
+ const stamp='2026-10-24T08:00:00.000Z',id='ragent_'+'a'.repeat(32),runId='job_'+'b'.repeat(32),key='agent-request-key-0001';
13
+ const input={customer:{name:'Atlas',website:'https://atlas.example/',context:''},goal:'Find workflow changes.',collection_ids:[],schedule_minutes:0};
14
+ const run=(patch={})=>({id:runId,agent_id:id,agent_revision:1,status:'queued',phase:'queued',created_at:stamp,started_at:null,finished_at:null,outcome:null,findings:[],gaps:[],usage:{},error:null,...patch});
15
+ const agent=(patch={})=>({id,...input,revision:1,status:'active',created_at:stamp,updated_at:stamp,latest_run:run(),...patch});
16
+ const receipt={agent:agent(),run:run(),replayed:false};
17
+ const citation={id:'W1',url:'https://source.example/release',title:'Release',quote:'é🌲 claim',start:3,end:11,body_sha256:'a'.repeat(64),observed_at:stamp,published_at:'October 2026',source_kind:'web_search',collection_id:null,document_id:null,version_id:null};
18
+ const completed=()=>run({status:'completed',phase:'finished',started_at:stamp,finished_at:stamp,outcome:'findings',findings:[{id:'finding_one',claim:'The vendor announces a workflow.',relevance:'This may be relevant to Atlas.',qualifiers:['Enterprise only.'],citations:[citation]}]});
19
+ async function execute(rest,options,value){let stdout='',stderr='';const calls=[];const code=await researchCommand(rest,options,{request:async(route,args)=>{calls.push({route,args});if(value instanceof Error)throw value;return typeof value==='function'?value(calls.length):value}},{stdout:x=>stdout+=x,stderr:x=>stderr+=x});return {code,stdout,stderr,calls};}
20
+
21
+ test('execution status exit code is independent of findings and preserves legacy or new coverage diagnostics',async()=>{
22
+ for(const [status,usage,code] of [['partial',{},2],['completed',{evidence_limits:{version:1,reasons:[]}},0],['partial',{evidence_limits:{version:1,reasons:['finding_omitted']}},2]]){
23
+ const value={...completed(),status,outcome:'no_supported_findings',findings:[],gaps:['This bounded run is not an exhaustive survey.'],usage};
24
+ const json=await execute(['agent-run',id,runId],{json:true},value),display=await execute(['agent-run',id,runId],{},value);
25
+ assert.equal(json.code,code);assert.deepEqual(JSON.parse(json.stdout),value);assert.equal(json.calls.length,1);
26
+ assert.equal(display.code,code);assert.ok(display.stdout.includes(value.gaps[0]));assert.equal(display.calls.length,1);
27
+ }
28
+ });
29
+
30
+ test('five findings preserve text order, own sources and full JSON; six and oversized packets emit no findings',async()=>{
31
+ const value=completed();value.findings=Array.from({length:5},(_,index)=>({...structuredClone(value.findings[0]),id:`finding_${index}`,claim:`Workflow ${index+1} has a documented export.`,qualifiers:[`Export ${index+1} requires Enterprise approval.`],citations:[{...citation,id:`W${index+1}`,url:`https://source.example/workflow/${index+1}`,quote:`Export ${index+1} is documented.`,start:0,end:23}]}));
32
+ const json=await execute(['agent-run',id,runId],{json:true},value),display=await execute(['agent-run',id,runId],{},value);
33
+ assert.equal(json.code,0);assert.deepEqual(JSON.parse(json.stdout),value);assert.equal(json.calls.length,1);assert.equal(display.code,0);assert.equal(display.calls.length,1);
34
+ let previous=-1;for(const finding of value.findings){const position=display.stdout.indexOf(finding.claim);assert.ok(position>previous);previous=position;for(const text of [...finding.qualifiers,finding.citations[0].url,finding.citations[0].quote])assert.ok(display.stdout.includes(text))}
35
+ const six={...value,findings:[...value.findings,{...value.findings[0],id:'finding_six'}]},oversized=structuredClone(value);
36
+ for(const finding of oversized.findings)finding.citations=Array.from({length:3},()=>({...citation,quote:'🌲'.repeat(1200),start:0,end:1200}));
37
+ assert.ok(Buffer.byteLength(JSON.stringify({findings:oversized.findings,gaps:oversized.gaps}))>32768);
38
+ for(const invalid of [six,oversized]){const rejected=await execute(['agent-run',id,runId],{json:true},invalid);assert.equal(rejected.code,1);assert.equal(rejected.calls.length,1);assert.ok(!rejected.stdout.includes(value.findings[0].claim))}
39
+ });
40
+
41
+ test('saved run JSON and display retain all five full qualifiers without truncation',async()=>{
42
+ const value=completed();value.findings[0].qualifiers=Array.from({length:5},(_,i)=>`${i+1}:`+'x'.repeat(298));
43
+ const json=await execute(['agent-run',id,runId],{json:true},value);
44
+ assert.equal(json.code,0);assert.deepEqual(JSON.parse(json.stdout),value);assert.equal(json.calls.length,1);
45
+ const display=await execute(['agent-run',id,runId],{},value);
46
+ assert.equal(display.code,0);for(const qualifier of value.findings[0].qualifiers)assert(display.stdout.includes(qualifier));
47
+ for(const qualifiers of [[...value.findings[0].qualifiers,'Sixth condition.'],[...value.findings[0].qualifiers.slice(0,4),'x'.repeat(301)]]){
48
+ const invalid=structuredClone(value);invalid.findings[0].qualifiers=qualifiers;
49
+ assert.throws(()=>validateAgentRun(invalid,id,runId));
50
+ }
51
+ });
52
+
53
+ test('failure diagnostic reads preserve JSON and failed exit without a retry',async()=>{
54
+ for(const usage of [{},{failure:{version:1,phase:'review',check:'response_contract'}}]){
55
+ const value=run({status:'failed',phase:'finished',started_at:stamp,finished_at:stamp,usage,error:{code:'invalid_output',message:'The research output could not be validated.'}});
56
+ const result=await execute(['agent-run',id,runId],{json:true},value);
57
+ assert.equal(result.code,1);assert.deepEqual(JSON.parse(result.stdout),value);assert.equal(result.calls.length,1);
58
+ assert.throws(()=>validateAgentRun({...value,usage:{padding:'x'.repeat(16385)}},id,runId));
59
+ }
60
+ });
61
+
62
+ test('history reads one page and prints persistent checkpoint including empty tail',async()=>{
63
+ const page={items:[run()],next_cursor:'next',has_more:true};
64
+ const result=await execute(['agent','runs',id],{limit:'1',cursor:'prior',json:true},page);
65
+ assert.equal(result.code,0);assert.deepEqual(JSON.parse(result.stdout),page);assert.equal(result.calls.length,1);
66
+ assert.equal(result.calls[0].route,`/api/research/agents/${id}/runs?limit=1&cursor=prior`);assert.equal(result.calls[0].args.method,undefined);assert.equal(result.calls[0].args.maxResponseBytes,1048576);
67
+ const empty=await execute(['agent','runs',id],{cursor:'next'},{items:[],next_cursor:'next',has_more:false});
68
+ assert.equal(empty.code,0);assert.match(empty.stderr,/Next cursor: next/);assert.match(empty.stderr,/More admitted runs: no/);
69
+ for(const options of [{limit:'101'},{cursor:'é'},{cursor:'bad\n'},{wait:true},{'request-key':key}])assert.throws(()=>agentRequest(['agent','runs',id],options));
70
+ });
71
+ test('history refuses malformed page without emitting partial source text or guessed cursor',async()=>{
72
+ const good={items:[completed()],next_cursor:'next',has_more:false};
73
+ for(const value of [{...good,extra:true},{...good,next_cursor:null},{...good,has_more:1},{...good,next_cursor:'prior'},
74
+ {...good,items:[],has_more:true},{...good,items:[]},{...good,items:[run(),run()]},{...good,items:[run({agent_id:'wrong'})]},
75
+ {...good,items:[run({status:['queued']})]}]){
76
+ const result=await execute(['agent','runs',id],{cursor:'prior',json:true},value);assert.equal(result.code,1);assert(!result.stdout.includes('Enterprise only.'));assert(!result.stdout.includes('next_cursor'));
77
+ }
78
+ const error=new Error('PRIVATE_SOURCE');error.status=404;const denied=await execute(['agent','runs',id],{cursor:'prior',json:true},error);
79
+ assert.equal(denied.code,1);assert.equal(denied.calls.length,1);assert(!denied.stdout.includes('PRIVATE_SOURCE'));assert(!denied.stdout.includes('next_cursor'));
80
+ });
81
+ test('history enforces whole-envelope byte bound before rendering',async()=>{
82
+ const items=Array.from({length:70},(_,i)=>run({id:'job_'+String(i).padStart(32,'0'),usage:{text:'🌲'.repeat(4000)}}));
83
+ const result=await execute(['agent','runs',id],{limit:'100',json:true},{items,next_cursor:'next',has_more:false});assert.equal(result.code,1);assert(!result.stdout.includes('🌲'));
84
+ });
85
+
86
+ test('agent request parser binds exact routes, offsets and operation keys',()=>{
87
+ assert.deepEqual(agentRequest(['agent-run',id,runId],{json:true}).route,`/api/research/agents/${id}/runs/${runId}`);
88
+ const rerun=agentRequest(['agent','run',id],{revision:'1','request-key':key});assert.equal(rerun.method,'POST');assert.deepEqual(rerun.body,{expected_revision:1});
89
+ assert.equal(agentRequest(['agent','receipt'],{operation:'run:'+id,'request-key':key}).method,undefined);
90
+ for(const args of [[['agent','run',id],{'request-key':key}],[['agent-run',id,runId],{timeout:'2'}],[['agent','create'],{file:'brief.json','request-key':key,bypass:true}],[['agent','show',id,'extra'],{}]])assert.throws(()=>agentRequest(...args));
91
+ });
92
+ test('brief validation normalizes website and rejects oversized or broadened descriptors',()=>{
93
+ assert.equal(validateAgentDescriptor({...input,customer:{name:'Atlas',website:'HTTPS://ATLAS.EXAMPLE:443/#section'}}).customer.website,input.customer.website);
94
+ for(const patch of [{schedule_minutes:60},{schedule_minutes:true},{collection_ids:['x','x']},{goal:'\ud800'},{extra:'PRIVATE'},{customer:{name:'Atlas',website:'https://user:secret@x.example/'}}])assert.throws(()=>validateAgentDescriptor({...input,...patch}));
95
+ });
96
+ test('create sends exactly one reviewed descriptor, retains recovery key and no source API',async()=>{
97
+ const dir=await fs.mkdtemp(path.join(os.tmpdir(),'opencrawl-agent-')),file=path.join(dir,'brief.json');
98
+ try{await fs.writeFile(file,JSON.stringify(input));const value=await execute(['agent','create'],{file,'request-key':key,json:true},receipt);assert.equal(value.code,0);assert.equal(value.calls.length,1);assert.equal(value.calls[0].route,'/api/research/agents');assert.deepEqual(value.calls[0].args.body,input);assert.equal(value.calls[0].args.idempotencyKey,key);assert.equal(JSON.parse(value.stdout).run.status,'queued');}
99
+ finally{await fs.rm(dir,{recursive:true,force:true})}
100
+ });
101
+ test('malformed brief never sends and never echoes private bytes',async()=>{
102
+ const dir=await fs.mkdtemp(path.join(os.tmpdir(),'opencrawl-agent-')),file=path.join(dir,'brief.json');
103
+ try{await fs.writeFile(file,'{"PRIVATE_SYNTHETIC_SECRET');await assert.rejects(execute(['agent','create'],{file,'request-key':key},receipt),error=>!error.message.includes('PRIVATE_SYNTHETIC_SECRET'));}
104
+ finally{await fs.rm(dir,{recursive:true,force:true})}
105
+ });
106
+ test('unknown run admission never retries and prints only fixed error plus original key',async()=>{
107
+ const value=await execute(['agent','run',id],{revision:'1','request-key':key,json:true},new Error('PRIVATE_SYNTHETIC_SECRET'));
108
+ assert.equal(value.code,1);assert.equal(value.calls.length,1);assert.equal(JSON.parse(value.stdout).error.request_key,key);assert.ok(!value.stdout.includes('PRIVATE_SYNTHETIC_SECRET'));
109
+ });
110
+ test('plain findings preserve exact quote, qualifier, inference and source range',async()=>{
111
+ const value=await execute(['agent-run',id,runId],{},completed());assert.equal(value.code,0);assert.equal(value.calls.length,1);
112
+ for(const expected of ['Observed statement:','Relevance (inference):','Qualification: Enterprise only.','é🌲 claim','source positions 3–11','https://source.example/release'])assert.ok(value.stdout.includes(expected));
113
+ });
114
+ test('neutral relevance is hidden only in text while saved JSON and historical values remain intact',async()=>{
115
+ const value=completed();value.findings[0].relevance='Selected for this research brief.';
116
+ value.usage={model_stages:{version:2,synthesis_calls:1,review_calls:1,draft_findings:1,review_decisions:[{ordinal:1,selected_quotes_support_all_claims_and_qualifiers:true,source_context_preserves_restrictions_and_date:true,addresses_requested_goal:true}]}};
117
+ const display=await execute(['agent-run',id,runId],{},value);assert.equal(display.code,0);assert.equal(display.calls.length,1);
118
+ assert(!display.stdout.includes('Relevance (inference):'));assert(!display.stdout.includes(value.findings[0].relevance));
119
+ for(const expected of [value.findings[0].claim,'Qualification: Enterprise only.',citation.quote,citation.url,'source positions 3–11'])assert(display.stdout.includes(expected));
120
+ const json=await execute(['agent-run',id,runId],{json:true},value);assert.equal(json.code,0);assert.equal(json.calls.length,1);assert.deepEqual(JSON.parse(json.stdout),value);
121
+ for(const relevance of ['This may be relevant to Atlas.','Selected for this research brief. ']){
122
+ value.findings[0].relevance=relevance;const historical=await execute(['agent-run',id,runId],{},value);assert.equal(historical.code,0);assert(historical.stdout.includes('Relevance (inference): '+relevance));
123
+ }
124
+ });
125
+ test('invalid scope/Unicode/citation kinds/internal fields never emit source text',async()=>{
126
+ const cases=[completed(),completed(),completed(),completed(),completed()];cases[0].id='wrong';cases[1]._source_fences=[];cases[2].findings[0].citations=[{...citation,end:12}];cases[3].findings[0].citations=[{...citation,source_kind:'indexed'}];cases[4].findings[0].claim='\ud800';
127
+ for(const value of cases){const result=await execute(['agent-run',id,runId],{json:true},value);assert.equal(result.code,1);assert.ok(!result.stdout.includes('é🌲 claim'));}
128
+ });
129
+ test('same source may support two exact quotes and hash remains supplied evidence metadata',()=>{
130
+ const value=completed();value.findings[0].citations.push({...citation,quote:'claim',start:6,end:11,body_sha256:'f'.repeat(64)});assert.deepEqual(validateAgentRun(value),value);
131
+ });
132
+ test('read terminal failed, unknown, partial and needs-input remain distinct exit states',async()=>{
133
+ for(const [status,code] of [['partial',2],['failed',1],['unknown',1],['cancelled',1],['needs_input',1]]){const value=run({status,phase:'finished',finished_at:stamp,outcome:status==='needs_input'?'needs_input':null});const result=await execute(['agent-run',id,runId],{json:true},value);assert.equal(result.code,code);assert.equal(JSON.parse(result.stdout).status,status);}
134
+ });
135
+ test('wait uses only exact GET and interrupted wait never cancels or creates work',async()=>{
136
+ let clock=0;const calls=[];const result=await waitAgentRun({request:async(route,args)=>{calls.push({route,args});return calls.length===1?run({status:'running',phase:'researching'}):completed()}},run(),{now:()=>clock,sleep:async ms=>{clock+=ms}});
137
+ assert.equal(result.wait.outcome,'completed');assert.ok(calls.every(c=>c.route===`/api/research/agents/${id}/runs/${runId}`&&!c.args.method));
138
+ const controller=new AbortController();controller.abort();const stopped=await waitAgentRun({request:async()=>assert.fail()},run(),{signal:controller.signal});assert.equal(stopped.wait.outcome,'interrupted');
139
+ });
140
+ test('list reads one page only and historical receipt stays queued',async()=>{
141
+ const page=await execute(['agent','list'],{limit:'1',json:true},{items:[agent()],next_cursor:'next'});assert.equal(page.calls.length,1);assert.equal(JSON.parse(page.stdout).next_cursor,'next');
142
+ const recovered=await execute(['agent','receipt'],{operation:'create','request-key':key,json:true},{...receipt,replayed:true});assert.equal(recovered.calls[0].args.method,undefined);assert.equal(JSON.parse(recovered.stdout).run.status,'queued');
143
+ });
144
+ test('invalid admitted response preserves recovery key and impossible dates never render',async()=>{
145
+ const result=await execute(['agent','run',id],{revision:'1','request-key':key,json:true},{agent:'PRIVATE_SYNTHETIC_SECRET'});
146
+ assert.equal(result.calls.length,1);assert.equal(result.code,1);assert.equal(JSON.parse(result.stdout).error.request_key,key);assert.ok(!result.stdout.includes('PRIVATE_SYNTHETIC_SECRET'));
147
+ assert.throws(()=>validateAgentRun(completedWithDate()));
148
+ function completedWithDate(){return {...completed(),created_at:'2026-02-30T08:00:00Z'}}
149
+ });
150
+ test('status writes reject a changed requested status or unrelated revision',async()=>{
151
+ for(const patch of [{revision:8,status:'paused'},{revision:2,status:'active'}]){const result=await execute(['agent','pause',id],{revision:'1',json:true},agent(patch));assert.equal(result.calls.length,1);assert.equal(result.code,1);}
152
+ });
153
+
154
+ test('actual CLI performs one create and exact keyed GET recovery through the real transport',async()=>{
155
+ const folder=await fs.mkdtemp(path.join(os.tmpdir(),'opencrawl-agent-cli-')),calls=[];
156
+ const server=http.createServer(async(req,res)=>{let body='';for await(const part of req)body+=part;calls.push({path:req.url,method:req.method,key:req.headers['idempotency-key'],authorization:req.headers.authorization,body:body?JSON.parse(body):null});res.setHeader('Content-Type','application/json');res.end(JSON.stringify(req.url.includes('/runs?')?{items:[completed()],next_cursor:'history-next',has_more:false}:req.url.endsWith('/schedule')?(req.method==='PATCH'?daily():schedule()):req.url.endsWith('/repeats')?repeats():req.url.includes('/runs/')?completed():{...receipt,replayed:req.method==='GET'}));});
157
+ await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
158
+ const script=fileURLToPath(new URL('../bin/opencrawl.mjs',import.meta.url)),url='http://127.0.0.1:'+server.address().port;
159
+ const env={PATH:process.env.PATH,HOME:folder,OPENCRAWL_CONFIG_DIR:folder,OPENCRAWL_URL:url,OPENCRAWL_API_KEY:'synthetic-only-key'};
160
+ const invoke=args=>new Promise((resolve,reject)=>{const child=spawn(process.execPath,[script,...args],{cwd:folder,env,stdio:['ignore','pipe','pipe']});let stdout='',stderr='';const timer=setTimeout(()=>child.kill('SIGKILL'),10000);child.stdout.on('data',x=>stdout+=x);child.stderr.on('data',x=>stderr+=x);child.on('error',reject);child.on('close',code=>{clearTimeout(timer);resolve({code,stdout,stderr})});});
161
+ try{
162
+ const file=path.join(folder,'brief.json');await fs.writeFile(file,JSON.stringify(input));
163
+ const created=await invoke(['research','agent','create','--file',file,'--request-key',key,'--json']);assert.equal(created.code,0,created.stdout+created.stderr);assert.equal(JSON.parse(created.stdout).run.id,runId);
164
+ const recovered=await invoke(['research','agent','receipt','--operation','create','--request-key',key,'--json']);assert.equal(recovered.code,0,recovered.stdout+recovered.stderr);assert.equal(JSON.parse(recovered.stdout).replayed,true);
165
+ const saved=await invoke(['research','agent-run',id,runId,'--json']);assert.equal(saved.code,0,saved.stderr);assert.equal(JSON.parse(saved.stdout).status,'completed');
166
+ const scheduleRead=await invoke(['research','agent','schedule',id,'--json']);assert.equal(scheduleRead.code,0,scheduleRead.stdout+scheduleRead.stderr);assert.deepEqual(JSON.parse(scheduleRead.stdout),schedule());
167
+ const scheduled=await invoke(['research','agent','schedule',id,'daily','--revision','0','--json']);assert.equal(scheduled.code,0,scheduled.stdout+scheduled.stderr);assert.deepEqual(JSON.parse(scheduled.stdout),daily());
168
+ const repeated=await invoke(['research','agent','repeats',id,runId,'--json']);assert.equal(repeated.code,0,repeated.stdout+repeated.stderr);assert.deepEqual(JSON.parse(repeated.stdout),repeats());
169
+ const history=await invoke(['research','agent','runs',id,'--limit','1','--cursor','history-prior','--json']);assert.equal(history.code,0,history.stdout+history.stderr);assert.deepEqual(JSON.parse(history.stdout),{items:[completed()],next_cursor:'history-next',has_more:false});
170
+ assert.deepEqual(calls.map(c=>[c.method,c.path]),[['POST','/api/research/agents'],['GET','/api/research/agents/receipts/by-key?operation=create'],['GET',`/api/research/agents/${id}/runs/${runId}`],['GET',`/api/research/agents/${id}/schedule`],['PATCH',`/api/research/agents/${id}/schedule`],['GET',`/api/research/agents/${id}/runs/${runId}/repeats`],['GET',`/api/research/agents/${id}/runs?limit=1&cursor=history-prior`]]);
171
+ assert.deepEqual(calls[4].body,{expected_revision:0,enabled:true});assert.equal(calls[4].key,undefined);
172
+ assert.deepEqual(calls[0].body,input);assert.ok(calls.every(c=>c.authorization==='Bearer synthetic-only-key'));assert.equal(calls[0].key,key);assert.equal(calls[1].key,key);assert.equal(calls[2].key,undefined);
173
+ const invalid=await invoke(['research','agent','run',id,'--revision','1','--request-key',key,'--request-key','duplicate']);assert.equal(invalid.code,1);assert.equal(calls.length,7);
174
+ }finally{server.closeAllConnections();await new Promise(resolve=>server.close(resolve));await fs.rm(folder,{recursive:true,force:true});}
175
+ });
176
+ const page=patch=>({requested_url:'https://source.example/old-release',final_url:citation.url,body_sha256:'f'.repeat(64),start:100,end:116,total_chars:500,extractor:'public-static-v3',...patch});
177
+ test('page evidence JSON preserves exact window/quote metadata without another fetch',async()=>{
178
+ const value=completed();value.findings[0].citations=[{...citation,source_kind:'web_page',page:page()}];
179
+ const result=await execute(['agent-run',id,runId],{json:true},value);assert.equal(result.code,0);assert.equal(result.calls.length,1);assert.deepEqual(JSON.parse(result.stdout),value);
180
+ const c=JSON.parse(result.stdout).findings[0].citations[0];assert.equal(c.page.start+c.start,103);assert.notEqual(c.body_sha256,c.page.body_sha256);
181
+ });
182
+ test('invalid page metadata or wrong source kind never renders the quote',async()=>{
183
+ const bad=[null,{},page({extra:true}),page({requested_url:'https://u:p@source.example/'}),page({final_url:'https://other.example/'}),page({body_sha256:'F'.repeat(64)}),page({start:-1}),page({start:true}),page({start:1.5}),page({end:100}),page({end:110}),page({end:501}),page({total_chars:131073}),page({total_chars:true}),page({start:0,end:16001,total_chars:16001}),page({extractor:'unknown'})];
184
+ for(const metadata of bad){const value=completed();value.findings[0].citations=[{...citation,source_kind:'web_page',page:metadata}];const result=await execute(['agent-run',id,runId],{json:true},value);assert.equal(result.code,1);assert(!result.stdout.includes(citation.quote));}
185
+ for(const extra of [{source_kind:'web_page'},{source_kind:'web_page',page:page(),version_id:'rver_foreign'},{source_kind:'web_search',page:null},{source_kind:'indexed',collection_id:'rcol_one',document_id:'doc_one',version_id:'rver_one',page:page()}]){const value=completed();value.findings[0].citations=[{...citation,...extra}];assert.throws(()=>validateAgentRun(value));}
186
+ });
187
+ test('legacy source kinds stay readable without page metadata',()=>{
188
+ for(const c of [citation,{...citation,source_kind:'indexed',collection_id:'rcol_one',document_id:'doc_one',version_id:'rver_one'}]){const value=completed();value.findings[0].citations=[c];assert.deepEqual(validateAgentRun(value),value);}
189
+ });
190
+
191
+ const schedule=(patch={})=>({agent_id:id,revision:0,enabled:false,schedule_minutes:0,next_scheduled_at:null,blocked_reason:null,blocked_run_id:null,deferred_reason:null,...patch});
192
+ const daily=(patch={})=>schedule({revision:1,enabled:true,schedule_minutes:1440,next_scheduled_at:stamp,...patch});
193
+ const repeats=(patch={})=>({agent_id:id,run_id:runId,status:'available',items:[{finding_id:'finding_current',classification:'first_seen',first_seen_run_id:runId,first_seen_finding_id:'finding_current'}],...patch});
194
+ test('schedule command is explicit, revision-bound and separate from agent pause or run',()=>{
195
+ assert.deepEqual(agentRequest(['agent','schedule',id],{}),{command:'schedule',id,route:`/api/research/agents/${id}/schedule`});
196
+ for(const [action,enabled] of [['daily',true],['pause',false],['resume',true]]){const request=agentRequest(['agent','schedule',id,action],{revision:'0',json:true});assert.equal(request.method,'PATCH');assert.deepEqual(request.body,{expected_revision:0,enabled});}
197
+ for(const [args,options] of [[['agent','schedule',id,'daily'],{}],[['agent','schedule',id,'show'],{revision:'0'}],[['agent','schedule',id,'resume'],{revision:'-1'}],[['agent','schedule',id,'daily'],{revision:'1','request-key':key}],[['agent','schedule',id,'hourly'],{}],[['agent','repeats',id,runId,'extra'],{}]])assert.throws(()=>agentRequest(args,options));
198
+ });
199
+ test('daily and blocked schedules render honestly; mixed agent lists and historical reads work',async()=>{
200
+ const updated=await execute(['agent','schedule',id,'daily'],{revision:'0',json:true},daily());assert.equal(updated.code,0);assert.deepEqual(updated.calls[0].args.body,{expected_revision:0,enabled:true});assert.equal(updated.calls.length,1);
201
+ const blocked=await execute(['agent','schedule',id],{},daily({next_scheduled_at:null,blocked_reason:'unknown',blocked_run_id:runId}));assert.equal(blocked.code,0);assert.match(blocked.stdout,/Daily research: enabled/);assert.match(blocked.stdout,/Blocked: unknown/);
202
+ const deferred=await execute(['agent','schedule',id],{},daily({deferred_reason:'daily_budget'}));assert.match(deferred.stdout,/Deferred: daily_budget/);
203
+ const mixed=await execute(['agent','list'],{json:true},{items:[agent(),agent({id:'ragent_daily',schedule_minutes:1440,latest_run:null})],next_cursor:null});assert.equal(mixed.code,0);
204
+ assert.equal((await execute(['agent','receipt'],{operation:'create','request-key':key,json:true},{...receipt,replayed:true})).code,0);
205
+ assert.throws(()=>validateAgentDescriptor({...input,schedule_minutes:1440}));
206
+ });
207
+ test('schedule failures never retry, substitute a revision or expose invalid response',async()=>{
208
+ for(const value of [new Error('PRIVATE_SYNTHETIC_SECRET'),daily({agent_id:'other'}),daily({revision:9}),daily({schedule_minutes:0}),daily({next_scheduled_at:null}),daily({blocked_reason:'unexpected'}),daily({extra:'PRIVATE_SYNTHETIC_SECRET'}),daily({deferred_reason:'invented'}),schedule({deferred_reason:'daily_budget'})]){const result=await execute(['agent','schedule',id,'daily'],{revision:'0',json:true},value);assert.equal(result.code,1);assert.equal(result.calls.length,1);assert(!result.stdout.includes('PRIVATE_SYNTHETIC_SECRET'));}
209
+ });
210
+ test('repeat command reads once, distinguishes not assessed and never removes saved findings',async()=>{
211
+ const value=repeats();const result=await execute(['agent','repeats',id,runId],{json:true},value);assert.equal(result.code,0);assert.deepEqual(JSON.parse(result.stdout),value);assert.equal(result.calls.length,1);assert.equal(result.calls[0].route,`/api/research/agents/${id}/runs/${runId}/repeats`);assert.equal(result.calls[0].args.method,undefined);
212
+ const unassessed=await execute(['agent','repeats',id,runId],{},repeats({status:'not_assessed',items:[]}));assert.match(unassessed.stdout,/not_assessed/);assert.match(unassessed.stdout,/not a judgment of business novelty/);
213
+ const saved=await execute(['agent-run',id,runId],{json:true},completed());assert.deepEqual(JSON.parse(saved.stdout),completed());
214
+ });
215
+ test('repeat response rejects foreign, duplicate and contradictory first-seen references',async()=>{
216
+ for(const value of [repeats({status:['available']}),repeats({items:[{...repeats().items[0],classification:['exact_repeat'],first_seen_run_id:'foreign'}]}),repeats({run_id:'foreign'}),repeats({status:'not_assessed'}),repeats({items:[...repeats().items,...repeats().items]}),repeats({items:[{...repeats().items[0],classification:'exact_repeat'}]}),repeats({items:[{...repeats().items[0],classification:'exact_repeat',first_seen_finding_id:'finding_other'}]}),repeats({items:[{...repeats().items[0],first_seen_run_id:'foreign'}]}),repeats({extra:true})]){const result=await execute(['agent','repeats',id,runId],{json:true},value);assert.equal(result.code,1);assert.equal(result.calls.length,1);}
217
+ });
218
+
219
+ test('source command reads an exact saved window without starting research',async()=>{
220
+ const {createHash}=await import('node:crypto');const text='Before. é🌲 claim. Requires approval.\r\n';
221
+ const value={agent_id:id,run_id:runId,source:{id:'W1',url:'https://source.example/release',title:'Release',text,body_sha256:createHash('sha256').update(text).digest('hex'),observed_at:stamp,published_at:null,source_kind:'indexed',collection_id:'rcoll_one',document_id:'doc_one',version_id:'rv_one'}};
222
+ const result=await execute(['agent','source',id,runId,'W1'],{json:true},value);
223
+ assert.equal(result.code,0);assert.deepEqual(JSON.parse(result.stdout),value);assert.equal(result.calls.length,1);assert.equal(result.calls[0].route,`/api/research/agents/${id}/runs/${runId}/sources/W1`);assert.equal(result.calls[0].args.body,undefined);
224
+ const display=await execute(['agent','source',id,runId,'W1'],{},value);assert.equal(display.code,0);assert.ok(display.stdout.includes('Requires approval.'));assert.ok(display.stdout.includes('admitted evidence window'));
225
+ for(const mutate of [v=>v.run_id='other',v=>v.source.id='other',v=>v.source.text+='changed',v=>v.source.body_sha256='0'.repeat(64),v=>v.source.version_id=null,v=>v.source.source_kind='web_search']){
226
+ const bad=structuredClone(value);mutate(bad);const denied=await execute(['agent','source',id,runId,'W1'],{json:true},bad);assert.equal(denied.code,1);assert.ok(!denied.stdout.includes('Requires approval.'));assert.equal(denied.calls.length,1);
227
+ }
228
+ });
229
+ test('source command rejects ambiguous or extra arguments before transport',()=>{
230
+ for(const [rest,options] of [[['agent','source',id,runId],{}],[['agent','source',id,runId,'W1','extra'],{}],[['agent','source',id,runId,'W1'],{wait:true}]])assert.throws(()=>agentRequest(rest,options));
231
+ });
@@ -0,0 +1,14 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {agentCommand} from './research-agent.mjs';
4
+ import {makeRun,saturated,hostile,unknown} from '../../sdk/tests/fixtures/answer-contract-cases.mjs';
5
+ const execute=async(run,json=false)=>{let stdout='',stderr='',calls=0;const code=await agentCommand(['agent-run',run.agent_id,run.id],{json},{request:async()=>{calls++;return run}},{stdout:t=>stdout+=t,stderr:t=>stderr+=t});return {stdout,stderr,calls,code}};
6
+ test('canonical BC03 gap appears once; five saturated notes plus one typed notice retain exact JSON/status',async()=>{
7
+ for(const r of [makeRun(),saturated()]){const before=JSON.stringify(r),human=await execute(r),json=await execute(r,true);assert.equal(human.code,0);assert.equal(human.calls,1);assert.equal(json.calls,1);assert.deepEqual(JSON.parse(json.stdout),r);assert.equal(JSON.stringify(r),before);assert.equal(human.stdout.split('does not establish an answer or its contradiction').length-1,1);for(const gap of r.gaps)assert(human.stdout.includes(gap));for(const f of r.findings){assert(human.stdout.includes(f.claim));for(const q of f.qualifiers)assert(human.stdout.includes(q))}}
8
+ });
9
+ test('malformed assessment remains an optional warning with preserved literal output and no retry',async()=>{
10
+ const r=makeRun();r.usage.answer_contract={version:99};r.status='partial';const out=await execute(r);assert.equal(out.code,2);assert.equal(out.calls,1);assert.match(out.stdout,/assessment could not be validated/);assert(out.stdout.includes(r.findings[0].claim));
11
+ });
12
+ test('terminal and bidi controls in assessment are escaped only in human output',async()=>{
13
+ const r=hostile(),out=await execute(r),json=await execute(r,true);assert.equal(out.code,0);assert(!out.stdout.includes('\u001b'));assert(!out.stdout.includes('\u202e'));assert.match(out.stdout,/\\u001b/);assert.match(out.stdout,/\\u202e/);assert.deepEqual(JSON.parse(json.stdout),r);assert(JSON.parse(json.stdout).usage.answer_contract.components.at(-1).text.includes(unknown(r).text));
14
+ });
@@ -0,0 +1,27 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {agentCommand} from './research-agent.mjs';
4
+ import {makeRun,omittedRun,extraSupportedRun} from '../../sdk/tests/fixtures/answer-view-cases.mjs';
5
+ const execute=async(run,json=false)=>{let stdout='',stderr='',calls=0;const code=await agentCommand(['agent-run',run.agent_id,run.id],{json},{request:async()=>{calls++;return run}},{stdout:t=>stdout+=t,stderr:t=>stderr+=t});return {stdout,stderr,calls,code}};
6
+ test('concise prose precedes unchanged exact facts, conditions, citations and every saved gap',async()=>{
7
+ const r=makeRun();r.status='partial';r.gaps=['Saved gap one.','Saved gap two.'];const before=JSON.stringify(r),human=await execute(r),json=await execute(r,true);
8
+ assert.equal(human.code,2);assert.equal(human.calls,1);assert(human.stdout.indexOf('Concise answer')<human.stdout.indexOf('Observed statement:'));
9
+ assert(human.stdout.includes(r.usage.answer_view.sections[0].assertions[0].text));assert(human.stdout.includes('Evidence: P1'));assert(human.stdout.includes('Review the exact evidence'));
10
+ for(const f of r.findings){assert(human.stdout.includes(f.claim));for(const q of f.qualifiers)assert(human.stdout.includes(q));for(const c of f.citations)assert(human.stdout.includes(c.quote))}for(const g of r.gaps)assert(human.stdout.includes(g));
11
+ assert.equal(json.calls,1);assert.equal(json.code,2);assert.equal(json.stdout,JSON.stringify(r,null,2)+'\n');assert.equal(JSON.stringify(r),before);
12
+ });
13
+ test('absent and invalid concise metadata leave legacy human output exactly equal',async()=>{
14
+ const absent=makeRun();delete absent.usage.answer_view;const invalid=structuredClone(absent);invalid.usage.answer_view={version:99};
15
+ assert.deepEqual(await execute(invalid),await execute(absent));assert.equal((await execute(invalid,true)).stdout,JSON.stringify(invalid,null,2)+'\n');
16
+ });
17
+ test('concise evidence labels include the sources of additional retained supporting fields',async()=>{
18
+ const r=extraSupportedRun(),out=await execute(r);assert.equal(out.calls,1);assert(out.stdout.includes('Evidence: P1, P2'));
19
+ assert.equal((await execute(r,true)).stdout,JSON.stringify(r,null,2)+'\n');
20
+ });
21
+ test('source-scoped unknown stays separate and optional prose does not become a complete badge',async()=>{
22
+ const r=omittedRun('source_scoped_unknown_assessment','source_scoped_unknown'),out=await execute(r);
23
+ assert(out.stdout.includes('Not established in the saved sources for this part.'));assert(!out.stdout.includes('All questions answered'));assert.equal(out.calls,1);
24
+ });
25
+ test('unsupported terminal-control prose falls back, while raw JSON remains exact',async()=>{
26
+ const r=makeRun();r.usage.answer_view.sections[0].assertions[0].text+='\u001b';const out=await execute(r),raw=await execute(r,true);assert(!out.stdout.includes('Concise answer'));assert(!out.stdout.includes('\u001b'));assert.equal(raw.stdout,JSON.stringify(r,null,2)+'\n');
27
+ });
@@ -0,0 +1,110 @@
1
+ import assert from 'node:assert/strict';
2
+ import {test} from 'node:test';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import {readResearchCompletion} from './research-completion.mjs';
7
+ import {agentCommand,agentRequest,validateAgentDescriptor} from './research-agent.mjs';
8
+
9
+ const fixture=JSON.parse(await fs.readFile(new URL('./fixtures/research-completion-v1.json',import.meta.url),'utf8'));
10
+ const actual=JSON.parse(await fs.readFile(new URL('./fixtures/research-completion-hosted.json',import.meta.url),'utf8'));
11
+ const empty={completion:null,notices:[]},metadata=()=>structuredClone(fixture.completion);
12
+ const read=value=>readResearchCompletion({usage:{completion:value}});
13
+ const key='completion-original-key-0001',id=actual.runs.admitted.agent_id;
14
+ const brief={customer:{name:'Northlight',website:'https://northlight.example/',context:''},goal:'Find documented workflow changes.',collection_ids:[],schedule_minutes:0};
15
+ const queued=actual.runs.admitted;
16
+ const receipt={agent:{id,revision:1,status:'active',...brief,created_at:queued.created_at,updated_at:queued.created_at,latest_run:queued},run:queued,replayed:false};
17
+ async function execute(rest,options,result){
18
+ let stdout='',stderr='';const calls=[];
19
+ const code=await agentCommand(rest,options,{request:async(route,args)=>{calls.push({route,args});if(result instanceof Error)throw result;return result;}},{stdout:x=>stdout+=x,stderr:x=>stderr+=x});
20
+ return {code,stdout,stderr,calls};
21
+ }
22
+
23
+ test('fixed notices preserve order and detach the optional view from saved metadata',()=>{
24
+ const input=metadata(),before=structuredClone(input),view=read(input);
25
+ assert.deepEqual(view.completion,before);
26
+ assert.deepEqual(view.notices,Object.entries(fixture.notice_text).map(([code,text])=>({code,text})));
27
+ view.completion.notices.pop();view.completion.limits.pages=1;
28
+ assert.deepEqual(input,before);
29
+ });
30
+
31
+ test('absent and queued metadata are quiet and cannot imply completion',()=>{
32
+ for(const run of [{},{usage:{}},{usage:null}])assert.deepEqual(readResearchCompletion(run),empty);
33
+ const admitted=actual.runs.admitted.usage.completion;
34
+ assert.deepEqual(read(admitted),{completion:admitted,notices:[]});
35
+ for(const patch of [{validated_passes:1},{notices:['bounded_sources']},{timing_ms:metadata().timing_ms},{completed_usage_lower_bound:metadata().completed_usage_lower_bound}])assert.deepEqual(read({...admitted,...patch}),empty);
36
+ });
37
+
38
+ test('malformed, raw or private optional fields never become human notices',()=>{
39
+ const mutations=[v=>{v.version=2},v=>{v.policy='other'},v=>{v.policy_sha256='a'.repeat(64)},v=>{v.whole_request_completeness='complete'},v=>{v.reason='all_answered'},v=>{v.limits.pages=10},v=>{v.limits.extra=1},v=>{delete v.limits.page_bytes},v=>{v.notices=['PRIVATE_RAW\u001b[31m']},v=>{v.notices=['bounded_sources','bounded_sources']},v=>{v.notices=['toString']},v=>{v.notices=['__proto__']},v=>{v.raw='PRIVATE_RAW'},v=>{delete v.validated_passes},v=>{v.timing_ms=null},v=>{v.completed_usage_lower_bound=[]}];
40
+ for(const mutate of mutations){const value=metadata();mutate(value);assert.deepEqual(read(value),empty);}
41
+ });
42
+
43
+ test('integer limits and finite timing phases reject impossible optional measurements',()=>{
44
+ for(const invalid of [-1,NaN,Infinity,true,'1',0.5])for(const field of ['validated_passes','timing_ms.total','timing_ms.phases.0.elapsed_ms','completed_usage_lower_bound.model_calls','completed_usage_lower_bound.web_searches','completed_usage_lower_bound.input_tokens','completed_usage_lower_bound.output_tokens']){
45
+ const value=metadata(),parts=field.split('.');let target=value;for(const part of parts.slice(0,-1))target=target[part];target[parts.at(-1)]=invalid;assert.deepEqual(read(value),empty,field);
46
+ }
47
+ const mutations=[v=>{v.validated_passes=3},v=>{v.timing_ms.total=360001},v=>{v.timing_ms.phases[0].elapsed_ms=210001},v=>{v.timing_ms.phases.push(v.timing_ms.phases[0])},v=>{v.timing_ms.phases[0].phase='raw'},v=>{v.timing_ms.phases[0].body='private'},v=>{v.completed_usage_lower_bound.model_calls=6},v=>{v.completed_usage_lower_bound.web_searches=6},v=>{v.completed_usage_lower_bound.input_tokens=1000000001}];
48
+ for(const mutate of mutations){const value=metadata();mutate(value);assert.deepEqual(read(value),empty);}
49
+ });
50
+
51
+ test('actual API projected admitted repaired unknown and unassessed runs preserve JSON and status on one read',async()=>{
52
+ assert.deepEqual(Object.keys(actual.runs),['admitted','repaired','unknown','unassessed']);
53
+ for(const [name,run] of Object.entries(actual.runs)){
54
+ const before=JSON.stringify(run),options=['agent-run',run.agent_id,run.id];
55
+ const json=await execute(options,{json:true},run),human=await execute(options,{},run);
56
+ assert.deepEqual(JSON.parse(json.stdout),run);assert.equal(json.code,human.code);assert.equal(json.calls.length,1);assert.equal(human.calls.length,1);
57
+ assert.equal(json.calls[0].args.method,undefined);assert.equal(JSON.stringify(run),before);
58
+ assert.deepEqual(readResearchCompletion(run).completion,run.usage.completion);
59
+ for(const code of run.usage.completion.notices)assert.ok(human.stdout.includes(fixture.notice_text[code]),name);
60
+ if(name==='unknown'){assert.equal(json.code,1);assert.equal(run.findings.length,0);assert.equal(readResearchCompletion(run).completion.validated_passes,1);assert.equal(readResearchCompletion(run).notices.length,0);}
61
+ }
62
+ });
63
+
64
+ test('all five saved gaps survive, followed by fixed optional notices without changing ordinary findings',async()=>{
65
+ const run=structuredClone(actual.runs.repaired);run.gaps=Array.from({length:5},(_,i)=>`Saved gap ${i+1}.`);run.usage.completion=metadata();
66
+ const result=await execute(['agent-run',run.agent_id,run.id],{},run);assert.equal(result.calls.length,1);
67
+ let previous=-1;for(const text of [...run.gaps,...Object.values(fixture.notice_text)]){const next=result.stdout.indexOf('Gap: '+text);assert.ok(next>previous,text);previous=next;}
68
+ for(const finding of run.findings){assert.ok(result.stdout.includes(finding.claim));for(const citation of finding.citations)assert.ok(result.stdout.includes(citation.quote));}
69
+ });
70
+
71
+ test('invalid optional metadata is ignored in human output and preserved verbatim in JSON without extra requests',async()=>{
72
+ const run=structuredClone(actual.runs.repaired);run.usage.completion={...metadata(),notices:['PRIVATE_RAW']};
73
+ const human=await execute(['agent-run',run.agent_id,run.id],{},run),json=await execute(['agent-run',run.agent_id,run.id],{json:true},run);
74
+ assert.equal(human.code,json.code);assert.equal(human.calls.length,1);assert.equal(json.calls.length,1);assert.ok(!human.stdout.includes('PRIVATE_RAW'));
75
+ assert.deepEqual(JSON.parse(json.stdout),run);for(const finding of run.findings)assert.ok(human.stdout.includes(finding.claim));
76
+ });
77
+
78
+ test('ordinary descriptor and manual-run body remain byte-identical when policy is absent',()=>{
79
+ assert.equal(JSON.stringify(validateAgentDescriptor(brief)),JSON.stringify(brief));
80
+ const request=agentRequest(['agent','run',id],{revision:'1','request-key':key});assert.equal(JSON.stringify(request.body),'\u007b"expected_revision":1}');assert.equal(request.key,key);
81
+ });
82
+
83
+ test('v1 remains explicit at create JSON and manual-run option',()=>{
84
+ assert.deepEqual(validateAgentDescriptor({...brief,research_policy:'completion_v1'}),{...brief,research_policy:'completion_v1'});
85
+ const request=agentRequest(['agent','run',id],{revision:'1','request-key':key,'research-policy':'completion_v1'});assert.deepEqual(request.body,{expected_revision:1,research_policy:'completion_v1'});assert.equal(request.key,key);
86
+ for(const value of [undefined,null,true,1,'ordinary','completion_v4',{},['completion_v1']]){
87
+ assert.throws(()=>validateAgentDescriptor({...brief,research_policy:value}),/research_policy/);
88
+ assert.throws(()=>agentRequest(['agent','run',id],{revision:'1','request-key':key,'research-policy':value}),/research-policy/);
89
+ }
90
+ for(const rest of [['agent','show',id],['agent-run',id,queued.id],['agent','schedule',id,'daily'],['agent','create']])assert.throws(()=>agentRequest(rest,{'research-policy':'completion_v1'}),/Unsupported/);
91
+ });
92
+
93
+ test('create forwards exactly one explicit policy with original key; unavailable policy never falls back',async()=>{
94
+ const directory=await fs.mkdtemp(path.join(os.tmpdir(),'opencrawl-completion-')),file=path.join(directory,'brief.json');
95
+ try{
96
+ const body={...brief,research_policy:'completion_v1'};await fs.writeFile(file,JSON.stringify(body));
97
+ const result=await execute(['agent','create'],{file,'request-key':key,json:true},receipt);
98
+ assert.equal(result.code,0);assert.equal(result.calls.length,1);assert.deepEqual(result.calls[0].args.body,body);assert.equal(result.calls[0].args.idempotencyKey,key);
99
+ const error=new Error('PRIVATE_SERVER_REASON');error.status=409;
100
+ const denied=await execute(['agent','create'],{file,'request-key':key,json:true},error);
101
+ assert.equal(denied.code,1);assert.equal(denied.calls.length,1);assert.equal(JSON.parse(denied.stdout).error.request_key,key);assert.ok(!denied.stdout.includes('PRIVATE_SERVER_REASON'));
102
+ }finally{await fs.rm(directory,{recursive:true,force:true});}
103
+ });
104
+
105
+ test('manual admission unknown keeps original policy/key and uses only GET for explicit receipt recovery',async()=>{
106
+ const result=await execute(['agent','run',id],{revision:'1','request-key':key,'research-policy':'completion_v1',json:true},new Error('PRIVATE_LOST_ACK'));
107
+ assert.equal(result.code,1);assert.equal(result.calls.length,1);assert.deepEqual(result.calls[0].args.body,{expected_revision:1,research_policy:'completion_v1'});assert.equal(JSON.parse(result.stdout).error.request_key,key);assert.ok(!result.stdout.includes('PRIVATE_LOST_ACK'));
108
+ const recovered=await execute(['agent','receipt'],{operation:'run:'+id,'request-key':key,json:true},{...receipt,replayed:true});
109
+ assert.equal(recovered.code,0);assert.equal(recovered.calls.length,1);assert.equal(recovered.calls[0].args.method,undefined);assert.equal(recovered.calls[0].args.idempotencyKey,key);assert.equal(recovered.calls[0].args.body,undefined);
110
+ });
@@ -0,0 +1,57 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import http from 'node:http';
7
+ import {spawn} from 'node:child_process';
8
+ import {fileURLToPath} from 'node:url';
9
+ import {researchCommand,researchRequest} from './research.mjs';
10
+ import {loggedInConfig} from './client.mjs';
11
+ const stamp='2026-10-24T05:00:00Z',url='https://example.com/product';
12
+ const side=(patch={})=>({version_id:'rver_after',version_sha256:'a'.repeat(64),body_sha256:'b'.repeat(64),title:'Public product',url,final_url:url,extraction_version:'static-v3',version_observed_at:stamp,text:'é🌲 value',start_char:0,end_char:8,full_length:20,next_start_char:8,omitted_before:false,omitted_after:true,window_origin:'page_start',changed_start_char:null,changed_end_char:null,...patch});
13
+ const detail=(patch={})=>({id:'rev_one',collection_id:'rcol_one',url,kind:'baseline',observed_at:stamp,previous_observed_at:null,gap:false,comparison:'single_observation',budget:16000,text_bytes:12,before:null,after:side(),...patch});
14
+ async function execute(value=detail(),options={},rest=['event','rcol_one','rev_one']){let stdout='',stderr='';const calls=[];const code=await researchCommand(rest,options,{request:async(route,params)=>{calls.push({route,params});if(value instanceof Error)throw value;return value}},{stdout:x=>stdout+=x,stderr:x=>stderr+=x});return {code,stdout,stderr,calls};}
15
+ test('event command requires exactly two safe IDs and strict bounded numeric options',()=>{
16
+ assert.equal(researchRequest(['event','rcol_one','rev_one'],{}).route,'/api/research/collections/rcol_one/events/rev_one?budget=16000');
17
+ const request=researchRequest(['event','rcol_one','rev_one'],{budget:'256','before-start':'0','after-start':'25'});assert.equal(request.beforeStart,0);assert.equal(request.afterStart,25);assert.match(request.route,/before_start=0&after_start=25$/);
18
+ for(const rest of [['event'],['event','rcol_one'],['event','rcol_one','rev_one','extra'],['event','rcol_one','../bad']])assert.throws(()=>researchRequest(rest,{}));
19
+ for(const options of [{budget:'0'},{budget:'32001'},{budget:'1e3'},{budget:true},{'after-start':'-1'},{'after-start':'01'},{'before-start':'2000001'},{wait:true},{cursor:'x'},{'request-key':'key'},{refresh:true}])assert.throws(()=>researchRequest(['event','rcol_one','rev_one'],options));
20
+ });
21
+ test('event JSON is exact, plain observations label omissions and never fetch another page',async()=>{
22
+ const json=await execute(detail(),{json:true});assert.deepEqual(JSON.parse(json.stdout),detail());assert.equal(json.calls.length,1);assert.equal(json.calls[0].params.method,undefined);assert.equal(json.calls[0].params.body,undefined);
23
+ const human=await execute();assert.match(human.stdout,/Event: rev_one/);assert.match(human.stdout,/Codepoints: \[0, 8\) of 20/);assert.match(human.stdout,/é🌲 value/);assert.match(human.stderr,/--after-start 8/);assert.match(human.stderr,/not authored modifications/);assert.equal(human.calls.length,1);
24
+ });
25
+ test('bad event evidence emits no source text, including JSON mode',async()=>{
26
+ for(const value of [detail({id:'foreign'}),detail({collection_id:'foreign'}),detail({budget:256}),detail({text_bytes:11}),detail({comparison:'equal_body'}),detail({after:side({end_char:9})}),detail({after:side({body_sha256:'bad'})}),detail({after:side({next_start_char:9})}),detail({after:side({omitted_after:false})}),detail({after:side({text:'\ud800',end_char:1})}),detail({text_bytes:20000,after:side({text:'🌲'.repeat(5000),end_char:5000,full_length:5000,next_start_char:null,omitted_after:false})})]){
27
+ let output='';await assert.rejects(researchCommand(['event','rcol_one','rev_one'],{json:true},{request:async()=>value},{stdout:s=>output+=s,stderr:()=>{}}),/invalid or out-of-scope/);assert.equal(output,'');
28
+ }
29
+ });
30
+ test('explicit offsets include zero/end and absent sides fail closed',async()=>{
31
+ const end=detail({text_bytes:0,after:side({text:'',start_char:20,end_char:20,next_start_char:null,omitted_before:true,omitted_after:false,window_origin:'explicit_offset'})});
32
+ assert.equal((await execute(end,{'after-start':'20'})).code,0);
33
+ await assert.rejects(execute(detail(),{'before-start':'0'}));await assert.rejects(execute(detail(),{'after-start':'1'}));
34
+ const empty=detail({kind:'unavailable',before:null,after:null,text_bytes:0,comparison:'no_observation'});assert.match((await execute(empty)).stdout,/no retained observation/);
35
+ });
36
+ test('event terminal output escapes controls while JSON retains exact source strings',async()=>{
37
+ const value=detail({gap:true,text_bytes:12,after:side({text:'é🌲 valu\u001b',title:'Title\u001b[2J'})});
38
+ const human=await execute(value);assert.ok(!human.stdout.includes('\u001b'));assert.match(human.stdout,/\\u001b/);assert.match(human.stderr,/exact change time is unknown/);
39
+ assert.equal(JSON.parse((await execute(value,{json:true})).stdout).after.text,value.after.text);
40
+ });
41
+ test('event failures do not disclose upstream detail or start fallback operations',async()=>{
42
+ const result=await execute(Object.assign(new Error('PRIVATE_SOURCE_SECRET'),{status:403}),{json:true});assert.equal(result.code,1);assert.equal(result.calls.length,1);assert.ok(!result.stdout.includes('PRIVATE_SOURCE_SECRET'));
43
+ });
44
+ const script=fileURLToPath(new URL('../bin/opencrawl.mjs',import.meta.url));
45
+ function run(args,env,cwd){return new Promise((resolve,reject)=>{const child=spawn(process.execPath,[script,...args],{env,cwd,stdio:['ignore','pipe','pipe']});let stdout='',stderr='';const timer=setTimeout(()=>child.kill('SIGKILL'),10000);child.stdout.on('data',x=>stdout+=x);child.stderr.on('data',x=>stderr+=x);child.on('error',reject);child.on('close',code=>{clearTimeout(timer);resolve({code,stdout,stderr})})});}
46
+ test('actual CLI event command retains selected account/origin and never edits credentials',async()=>{
47
+ const dir=await fs.mkdtemp(path.join(os.tmpdir(),'opencrawl-event-')),calls=[];
48
+ const server=http.createServer((req,res)=>{calls.push({url:req.url,method:req.method,authorization:req.headers.authorization});res.setHeader('content-type','application/json');res.end(JSON.stringify(detail()));});
49
+ await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));const origin='http://127.0.0.1:'+server.address().port;
50
+ const env={...process.env};for(const name of Object.keys(env))if(name.startsWith('OPENCRAWL_'))delete env[name];env.OPENCRAWL_CONFIG_DIR=dir;
51
+ const config=loggedInConfig({},origin,'synthetic-event-key',{user:{id:'usr_'+'c'.repeat(32)},workspace:{id:'ws_'+'d'.repeat(32)}});await fs.writeFile(path.join(dir,'config.json'),JSON.stringify(config),{mode:0o600});
52
+ try{
53
+ const args=['research','event','rcol_one','rev_one','--account',config.activeProfile,'--json'];const result=await run(args,env,dir);assert.equal(result.code,0,result.stderr);assert.deepEqual(JSON.parse(result.stdout),detail());assert.deepEqual(calls,[{url:'/api/research/collections/rcol_one/events/rev_one?budget=16000',method:'GET',authorization:'Bearer synthetic-event-key'}]);
54
+ for(const extra of [['--server','https://foreign.example'],['--after-start','-1'],['--budget','256','--budget','32000']])assert.equal((await run([...args,...extra],env,dir)).code,1);
55
+ assert.equal(calls.length,1);assert.deepEqual(JSON.parse(await fs.readFile(path.join(dir,'config.json'),'utf8')),config);
56
+ }finally{server.closeAllConnections();await new Promise(resolve=>server.close(resolve));await fs.rm(dir,{recursive:true,force:true});}
57
+ });
@@ -0,0 +1,56 @@
1
+ import assert from 'node:assert/strict';
2
+ import {test} from 'node:test';
3
+ import fs from 'node:fs/promises';
4
+ import {readResearchCompletion,readResearchPresentation} from './research-completion.mjs';
5
+ import {agentCommand,agentRequest,validateAgentDescriptor,validateAgentRun,waitAgentRun} from './research-agent.mjs';
6
+ const f=JSON.parse(await fs.readFile(new URL('./fixtures/research-completion-v2.json',import.meta.url),'utf8'));
7
+ const old=JSON.parse(await fs.readFile(new URL('./fixtures/research-completion-hosted.json',import.meta.url),'utf8'));
8
+ const initial=()=>({...structuredClone(old.runs.admitted),status:'running',phase:'presenting',usage:{completion:structuredClone(f.admitted)}});
9
+ const brief={customer:{name:'Northlight',website:'https://northlight.example/',context:''},goal:'Find documented workflow changes.',collection_ids:[],schedule_minutes:0};
10
+
11
+ test('v2 create/run selection is explicit and omitted request remains byte-identical',()=>{
12
+ assert.equal(JSON.stringify(validateAgentDescriptor(brief)),JSON.stringify(brief));
13
+ assert.deepEqual(validateAgentDescriptor({...brief,research_policy:'completion_v2'}),{...brief,research_policy:'completion_v2'});
14
+ const args=['agent','run',initial().agent_id],options={revision:'1','request-key':'original-key-0000001'};
15
+ assert.deepEqual(agentRequest(args,options).body,{expected_revision:1});
16
+ assert.deepEqual(agentRequest(args,{...options,'research-policy':'completion_v2'}).body,{expected_revision:1,research_policy:'completion_v2'});
17
+ for(const policy of ['completion_v4',null,false])assert.throws(()=>agentRequest(args,{...options,'research-policy':policy}));
18
+ });
19
+ test('CLI v2 readers preserve finite receipt and ignore malformed presentation',()=>{
20
+ assert.deepEqual(readResearchCompletion(initial()).completion,f.admitted);
21
+ assert.deepEqual(readResearchPresentation({usage:{presentation:f.presentation}}),f.presentation);
22
+ for(const presentation of [{...f.presentation,raw:'PRIVATE'},{...f.presentation,calls_completed:2},{...f.presentation,known_output_tokens:12001}])assert.equal(readResearchPresentation({usage:{presentation}}),null);
23
+ assert.deepEqual(validateAgentRun({...initial(),usage:{presentation:{status:'PRIVATE'}}}).phase,'presenting');
24
+ });
25
+ test('CLI default v2 wait passes300 seconds then expires once at540 from original start',async()=>{
26
+ let clock=5000,calls=0;const original=initial();
27
+ const result=await waitAgentRun({request:async(_route,options)=>{assert.ok(options.timeout<=30000);calls++;clock+=Math.min(25000,options.timeout);return initial()}},original,{now:()=>clock,sleep:async ms=>clock+=ms});
28
+ assert.equal(result.wait.outcome,'timeout');assert.equal(calls,20);assert.equal(clock,545000); // original start plus540 seconds, never renewed
29
+ });
30
+ test('CLI explicit and malformed-policy waits keep old timeout and never replay writes',async()=>{
31
+ for(const [usage,timeout,expected] of [[{},undefined,12],[{completion:{policy:'completion_v2'}},undefined,12],[{completion:f.admitted},300,12],[{completion:f.admitted},1,1]]){
32
+ let clock=0,calls=0;const run={...initial(),usage};
33
+ const result=await waitAgentRun({request:async(_route,options)=>{assert.equal(options.method,undefined);calls++;clock+=Math.min(25000,options.timeout);return run}},run,{...(timeout===undefined?{}:{timeout}),now:()=>clock,sleep:async ms=>clock+=ms});
34
+ assert.equal(result.wait.outcome,'timeout');assert.equal(calls,expected);
35
+ }
36
+ });
37
+ test('human fallback is one fixed notice; raw JSON, all gaps, findings and quotes remain intact',async()=>{
38
+ const run=structuredClone(old.runs.repaired);run.usage.presentation=structuredClone(f.presentation);run.gaps=Array.from({length:5},(_,i)=>`Original gap ${i+1}.`);
39
+ const invoke=async json=>{let stdout='',stderr='',calls=0;const code=await agentCommand(['agent-run',run.agent_id,run.id],{json},{request:async()=>{calls++;return run}},{stdout:x=>stdout+=x,stderr:x=>stderr+=x});return {stdout,stderr,calls,code}};
40
+ const json=await invoke(true);assert.deepEqual(JSON.parse(json.stdout),run);assert.equal(json.calls,1);
41
+ const human=await invoke(false);assert.equal((human.stdout.match(/Summary:/g)||[]).length,1);
42
+ for(const text of [...run.gaps,...run.findings.flatMap(v=>[v.claim,...v.qualifiers,...v.citations.map(c=>c.quote)])])assert.ok(human.stdout.includes(text));
43
+ run.usage.presentation.status='skipped_no_eligible';assert.equal((await invoke(false)).stdout.includes('Summary:'),false);
44
+ run.usage.presentation.raw='PRIVATE';assert.equal((await invoke(false)).stdout.includes('Summary:'),false);
45
+ });
46
+
47
+ test('actual admitted presenting reviewed and fallback public states are unchanged through CLI JSON',async()=>{
48
+ const actual=JSON.parse(await fs.readFile(new URL('./fixtures/research-presentation-hosted.json',import.meta.url),'utf8'));
49
+ assert.equal(actual.presenting.phase,'synthesizing');
50
+ for(const run of Object.values(actual)){
51
+ let stdout='',calls=0;const before=JSON.stringify(run);
52
+ const code=await agentCommand(['agent-run',run.agent_id,run.id],{json:true},{request:async()=>{calls++;return run}},{stdout:x=>stdout+=x,stderr:()=>{}});
53
+ assert.equal(code,0);assert.deepEqual(JSON.parse(stdout),run);assert.equal(calls,1);assert.equal(JSON.stringify(run),before);
54
+ assert.deepEqual(readResearchCompletion(run).completion,run.usage.completion);assert.deepEqual(readResearchPresentation(run),run.usage.presentation??null);
55
+ }
56
+ });