import assert from 'node:assert/strict' import { sessionGrantProposal, sessionGrantRules, rememberSessionGrant, clearSessionGrants } from '../src/main/agent/claude/session-grants' import { applyAssistantEvent } from '../src/shared/message-parts' import { createInteractionBroker, respondToInteraction } from '../src/main/interaction-broker' import type { ChatEvent, ChatMessage } from '../src/shared/types' const proposal = sessionGrantProposal([{type:'addRules',behavior:'allow',destination:'projectSettings',rules:[{toolName:'Bash',ruleContent:'echo safe:*'},{toolName:'Read',ruleContent:'/tmp/owned/**'}]}])! assert.equal(proposal.updates[0].destination, 'session') assert.deepEqual(proposal.rules, ['Bash(echo safe:*)','Read(/tmp/owned/**)']) assert.equal(sessionGrantProposal([{type:'setMode',mode:'bypassPermissions',destination:'session'}]), undefined) assert.equal(sessionGrantProposal([{type:'addRules',behavior:'allow',destination:'session',rules:[{toolName:'Bash'}]}]), undefined) assert.equal(sessionGrantProposal([...proposal.updates,{type:'addDirectories',directories:['/'],destination:'session'}]), undefined) rememberSessionGrant('a', proposal); rememberSessionGrant('a',proposal) assert.equal(sessionGrantRules('a').length, 2) assert.equal(sessionGrantRules('b').length, 0) const copy = sessionGrantRules('a'); copy.push('Write(*)'); assert.equal(sessionGrantRules('a').length, 2) rememberSessionGrant('a',proposal,'/workspace/a'); assert.equal(sessionGrantRules('a','/workspace/b').length,0) clearSessionGrants('a'); assert.equal(sessionGrantRules('a').length,0) const msg: ChatMessage = {id:'m',role:'assistant',content:'',createdAt:1,requestId:'r',status:'streaming'} const event: ChatEvent = {runId:'r',type:'progress',progress:{source:'agent',phase:'tool',toolName:'Read'}} const live = applyAssistantEvent(msg,event) assert.equal(live.runtimeProgress?.toolName,'Read') assert.equal(applyAssistantEvent(msg,{...event,runId:'other'}),msg) for(const terminal of [{runId:'r',type:'done',content:'',tools:[]},{runId:'r',type:'cancelled'},{runId:'r',type:'error',message:'fail'}] as ChatEvent[]) { const done = applyAssistantEvent(live,{...terminal,turnFinishedAt:100,turnDurationMs:99}) assert.equal(done.runtimeProgress,undefined); assert.equal(done.turnFinishedAt,100) assert.equal(applyAssistantEvent(done,event),done) } const events: ChatEvent[]=[] const controller = new AbortController() const broker = createInteractionBroker('r',controller.signal,event=>events.push(event)) const response = broker.approval('tool','Run', [{optionId:'once',name:'Once',kind:'allow_once'}],undefined,undefined,{requestKind:'permission',toolName:'Bash',allowScope:'once'}) const pending = events.find(event=>event.type==='interaction')! assert.equal(pending.type,'interaction') if(pending.type!=='interaction') throw new Error('missing') assert.equal(respondToInteraction('other',pending.interaction.id,{kind:'approval',optionId:'once'},event=>events.push(event)),false) assert.throws(()=>respondToInteraction('r',pending.interaction.id,{kind:'approval',optionId:'bad'},event=>events.push(event))) assert.equal(respondToInteraction('r',pending.interaction.id,{kind:'approval',optionId:'once'},event=>events.push(event)),true) assert.equal(respondToInteraction('r',pending.interaction.id,{kind:'approval',optionId:'once'},event=>events.push(event)),false) await response const cancelled = broker.approval('tool2','Run', [{optionId:'once',name:'Once',kind:'allow_once'}]) const last = events.at(-1)! controller.abort() await assert.rejects(cancelled,/取消/) if(last.type==='interaction') assert.equal(respondToInteraction('r',last.interaction.id,{kind:'approval',optionId:'once'},event=>events.push(event)),false) const retryController=new AbortController() const retryEvents:ChatEvent[]=[] const retryBroker=createInteractionBroker('retry',retryController.signal,event=>retryEvents.push(event)) let failApply=true const waiting=retryBroker.approval('tool','Run',[{optionId:'session',name:'Session',kind:'allow_always'}],undefined,undefined,undefined,()=>{if(failApply)throw new Error('apply rejected');return Promise.resolve()}) const retryEvent=retryEvents[0];if(retryEvent.type!=='interaction')throw new Error('missing') await assert.rejects(Promise.resolve(respondToInteraction('retry',retryEvent.interaction.id,{kind:'approval',optionId:'session'},event=>retryEvents.push(event))),/apply rejected/) assert.equal(retryEvents.some(event=>event.type==='interaction'&&event.interaction.status==='answered'),false) assert.equal(retryEvents.at(-1)?.type==='interaction'&&(retryEvents.at(-1) as any).interaction.status,'error') failApply=false assert.equal(await respondToInteraction('retry',retryEvent.interaction.id,{kind:'approval',optionId:'session'},event=>retryEvents.push(event)),true) await waiting const abortApply=new AbortController();const abortEvents:ChatEvent[]=[] const abortBroker=createInteractionBroker('abort-apply',abortApply.signal,event=>abortEvents.push(event)) let unblock!:()=>void const abortedWait=abortBroker.approval('tool','Run',[{optionId:'session',name:'Session',kind:'allow_always'}],undefined,undefined,undefined,()=>new Promise(resolve=>{unblock=resolve})) const catchAbort=assert.rejects(abortedWait,/取消/) const abortEvent=abortEvents[0];if(abortEvent.type!=='interaction')throw new Error('missing') const applying=respondToInteraction('abort-apply',abortEvent.interaction.id,{kind:'approval',optionId:'session'},event=>abortEvents.push(event)) assert.equal(respondToInteraction('abort-apply',abortEvent.interaction.id,{kind:'approval',optionId:'session'},event=>abortEvents.push(event)),false) await Promise.resolve() // Let the guarded async application begin before cancelling it. abortApply.abort();unblock();assert.equal(await applying,false);await catchAbort assert.equal(abortEvents.some(event=>event.type==='interaction'&&event.interaction.status==='answered'),false) console.log('Runtime progress, session-grant scope and approval lifecycle regressions passed')