Files
gstack/test/plan-count-session-cwd.test.ts

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);
});