feat: align conversation state, composer and scoped approvals

This commit is contained in:
dot committed 2026-10-21 03:46:53 +09:00
1 parent 771ca540f7
commit 308b98df23
61 files changed
+1701 -292

No files matched your search

@@ -0,0 +1,69 @@
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<void>(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')