mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 11:10:13 +01:00
116 lines
7.4 KiB
TypeScript
116 lines
7.4 KiB
TypeScript
import {afterEach, expect, test} from 'bun:test';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import {createHash} from 'node:crypto';
|
|
import {readPlanCountTranscript, type NativePublicToolEvent} from './helpers/plan-count-transcript';
|
|
import {auditAutoplanMethodReads} from './helpers/autoplan-method-read-audit';
|
|
import {autoplanPhaseCompletions} from './helpers/autoplan-phase-observer';
|
|
|
|
const dirs:string[]=[];
|
|
afterEach(()=>{for(const d of dirs.splice(0))fs.rmSync(d,{recursive:true,force:true});});
|
|
const uuid=(n:number)=>`00000000-0000-4000-8000-${String(n).padStart(12,'0')}`;
|
|
const sid='session-owned',cwd='/fixture/repo',archive='/fixture/state/project/ceo-plans';
|
|
const time='2026-09-11T01:23:54.673Z';
|
|
const record=(n:number,parent:number|null,where=cwd,role='assistant',content:any[]=[])=>({
|
|
sessionId:sid,cwd:where,isSidechain:false,uuid:uuid(n),parentUuid:parent===null?null:uuid(parent),
|
|
timestamp:time,message:{role,content},
|
|
});
|
|
const use={type:'tool_use',id:'read-owned',name:'Read',input:{file_path:'/fixture/state/methodology.md',offset:1,limit:2}};
|
|
const file={filePath:'/fixture/state/methodology.md',startLine:1,numLines:2,totalLines:2,content:'Review every phase.\nPreserve ownership.'};
|
|
function rows():any[]{return [
|
|
record(1,null,cwd,'user',[{type:'text',text:'Run the review.'}]),
|
|
{...record(2,1),message:undefined,type:'attachment'},
|
|
record(3,2,archive,'assistant',[use]),
|
|
{...record(4,3,archive,'user',[{type:'tool_result',tool_use_id:'read-owned',content:'Public Read output.'}]),toolUseResult:{file}},
|
|
record(5,4,archive,'assistant',[{type:'text',text:'Phase 1 complete.'}]),
|
|
record(6,5,cwd,'assistant',[{type:'tool_use',id:'dispatch',name:'Agent',input:{prompt:'You are the independent DESIGN reviewer for this phase.\nRead the bound methodology.'}}]),
|
|
];}
|
|
function read(records:any[],options:{partial?:boolean,name?:string}={}){
|
|
const config=fs.mkdtempSync(path.join(os.tmpdir(),'plan-cwd-'));dirs.push(config);
|
|
const project=path.join(config,'projects','owned');fs.mkdirSync(project,{recursive:true});
|
|
const journal=path.join(project,(options.name??sid)+'.jsonl');
|
|
const bytes=records.map(r=>JSON.stringify(r)).join('\n')+(options.partial?'':'\n');fs.writeFileSync(journal,bytes);
|
|
const events:NativePublicToolEvent[]=[];const transcript=readPlanCountTranscript(config,cwd,e=>events.push(e));
|
|
expect(fs.readFileSync(journal,'utf8')).toBe(bytes);
|
|
return {events,transcript};
|
|
}
|
|
const methodology={phase:'design',path:file.filePath,content:file.content,lines:2,bytes:Buffer.byteLength(file.content),sha256:createHash('sha256').update(file.content).digest('hex')};
|
|
const designAudit=(events:NativePublicToolEvent[])=>auditAutoplanMethodReads(events,()=>methodology).find(a=>a.phase==='design');
|
|
|
|
test('same native ancestry survives cwd changes and retains exact methodology delivery',()=>{
|
|
const {events,transcript}=read(rows());
|
|
expect(events.map(e=>e.toolUseId)).toEqual(['read-owned','read-owned','dispatch']);
|
|
expect(designAudit(events)?.passed).toBe(true);
|
|
expect(autoplanPhaseCompletions(transcript,0)).toEqual([{phase:1,ts:Date.parse(time)}]);
|
|
});
|
|
test('the directory name is not an allowlist and returning to the original cwd keeps the chain',()=>{
|
|
const r=rows();for(const x of r)if(x.cwd===archive)x.cwd='/different/owned/work-directory';
|
|
r.push(record(7,6,'/another/directory','assistant',[{type:'text',text:'Phase 2 complete.'}]));
|
|
expect(autoplanPhaseCompletions(read(r).transcript,0).map(x=>x.phase)).toEqual([1,2]);
|
|
});
|
|
|
|
for(const [name,change] of Object.entries({
|
|
'missing origin':(r:any[])=>r.shift(),
|
|
'foreign origin':(r:any[])=>r[0].cwd='/another/fixture',
|
|
'assistant origin':(r:any[])=>r[0].message.role='assistant',
|
|
'non-root origin':(r:any[])=>r[0].parentUuid=uuid(99),
|
|
'missing root UUID':(r:any[])=>delete r[0].uuid,
|
|
'invalid root UUID':(r:any[])=>r[0].uuid='root',
|
|
'sidechain origin':(r:any[])=>r[0].isSidechain=true,
|
|
'agent origin':(r:any[])=>r[0].agentId='child',
|
|
'invalid origin time':(r:any[])=>r[0].timestamp='unknown',
|
|
'disconnected branch':(r:any[])=>r[2].parentUuid=uuid(99),
|
|
'foreign branch session':(r:any[])=>r[2].sessionId='another-session',
|
|
'sidechain branch':(r:any[])=>r[2].isSidechain=true,
|
|
'agent branch':(r:any[])=>r[2].agentId='child',
|
|
'relative branch cwd':(r:any[])=>r[2].cwd='relative-directory',
|
|
'missing branch cwd':(r:any[])=>delete r[2].cwd,
|
|
'invalid branch time':(r:any[])=>r[2].timestamp='unknown',
|
|
'missing branch UUID':(r:any[])=>delete r[2].uuid,
|
|
'duplicate root UUID':(r:any[])=>r[2].uuid=r[0].uuid,
|
|
'missing attachment link':(r:any[])=>r.splice(1,1),
|
|
}))test(`${name} cannot supply changed-cwd evidence`,()=>{
|
|
const r=rows();change(r);const {events,transcript}=read(r);
|
|
expect(events.some(e=>e.toolUseId==='read-owned')).toBe(false);
|
|
expect(autoplanPhaseCompletions(transcript,0)).toEqual([]);
|
|
expect(designAudit(events)?.passed).toBe(false);
|
|
});
|
|
|
|
test('a later matching root cannot rebind a session that began in another fixture',()=>{
|
|
const r=rows();r.unshift(record(99,null,'/another/fixture','user'));
|
|
expect(read(r).events.some(e=>e.toolUseId==='read-owned')).toBe(false);
|
|
});
|
|
test('legacy records without native ancestry retain exact-cwd scoping',()=>{
|
|
const r=rows();for(const x of r){delete x.uuid;delete x.parentUuid;}
|
|
const x=read(r);expect(x.events.map(e=>e.toolUseId)).toEqual(['dispatch']);
|
|
expect(autoplanPhaseCompletions(x.transcript,0)).toEqual([]);
|
|
});
|
|
test('filename and session identity remain bound',()=>{
|
|
expect(read(rows(),{name:'foreign'}).transcript.status).toBe('missing');
|
|
});
|
|
test('an incomplete final record cannot supply a changed-cwd completion',()=>{
|
|
const r=rows().slice(0,5);expect(autoplanPhaseCompletions(read(r,{partial:true}).transcript,0)).toEqual([]);
|
|
});
|
|
test('a changed-cwd question packet still counts once and preserves unanswered tabs',()=>{
|
|
const questions=['First decision?','Second decision?'].map(question=>({header:'Decision',question,options:[{label:'A'},{label:'B'}]}));
|
|
const r=rows();r.push(record(7,6,archive,'assistant',[{type:'tool_use',id:'ask',name:'AskUserQuestion',input:{questions}}]),
|
|
{...record(8,7,archive,'user',[{type:'tool_result',tool_use_id:'ask',content:'Answered first tab.'}]),toolUseResult:{answers:{'First decision?':'A'}}});
|
|
const t=read(r).transcript;expect(t.calls).toHaveLength(1);expect(t.calls[0].answered).toBe(true);
|
|
expect(t.calls[0].unansweredQuestionIndices).toEqual([1]);expect(t.calls[0].questions).toEqual(questions);
|
|
});
|
|
test('a changed-cwd failed plan approval remains failed',()=>{
|
|
const r=rows();r.push(record(7,6,archive,'assistant',[{type:'tool_use',id:'exit',name:'ExitPlanMode',input:{}}]),
|
|
record(8,7,archive,'user',[{type:'tool_result',tool_use_id:'exit',content:'Not approved.',is_error:true}]));
|
|
const t=read(r).transcript;expect(t.planReadyRequests).toHaveLength(1);expect(t.planReadyRequests![0].failed).toBe(true);expect(t.calls).toEqual([]);
|
|
});
|
|
for(const [name,change] of Object.entries({
|
|
'foreign file path':(r:any[])=>r[3].toolUseResult={file:{...file,filePath:'/other/methodology.md'}},
|
|
'incorrect content':(r:any[])=>r[3].toolUseResult={file:{...file,content:'Omitted required instructions.'}},
|
|
'error result':(r:any[])=>r[3].message.content[0].is_error=true,
|
|
'wrong result id':(r:any[])=>r[3].message.content[0].tool_use_id='another-tool',
|
|
'future result':(r:any[])=>r[3].timestamp='2026-09-11T02:00:00.000Z',
|
|
}))test(`cwd continuation does not bypass ${name}`,()=>{
|
|
const r=rows();change(r);expect(designAudit(read(r).events)?.passed).toBe(false);
|
|
});
|