fix: close alignment review races and preserve local conversation state
This commit is contained in:
1 parent
c7231be2c5
commit
37ddb2ce24
14 files changed
+203
-45
No files matched your search
@@ -37,3 +37,10 @@ console.log('alignment-conversation: local groups, original expanded order, ques
|
||||
assert.equal(isLoadingTipPaused({hovered:false,focused:true}),true)
|
||||
assert.equal(isLoadingTipPaused({hovered:true,focused:false}),true)
|
||||
assert.equal(isLoadingTipPaused({hovered:false,focused:false}),false)
|
||||
|
||||
const expandedGrouped=renderToStaticMarkup(<CompletedFold parts={message.parts!} turnFinished turnDurationMs={1000} expandFinishedEvents
|
||||
renderGroup={(group,children)=><section data-local-group={group.startIndex}>{children}</section>}
|
||||
renderUnit={(p,i)=><i data-index={i}>{p.type}</i>}/> )
|
||||
assert.deepEqual([...expandedGrouped.matchAll(/data-index="(\d+)"/g)].map(m=>Number(m[1])),[0,1,2,3,4,5])
|
||||
assert.deepEqual([...expandedGrouped.matchAll(/data-local-group="(\d+)"/g)].map(m=>Number(m[1])),[1,4],
|
||||
'opening completion must preserve independently controlled local summaries')
|
||||
@@ -14,9 +14,10 @@ let calls=0
|
||||
const setup = (kernel:ClaudeKernel, choose='allow-always') => {
|
||||
const controller = new AbortController()
|
||||
const raw=kernel as any
|
||||
const events:any[]=[]
|
||||
raw.instance={applyFlagSettings:async()=>{},close:()=>{}}
|
||||
raw.active={settled:false,request:{runId:'r',signal:controller.signal,emit:()=>{},interactions:{approval:async (...args:any[])=>{calls++;assert.equal(args[5].toolName,'Bash');const decision={kind:'approval',optionId:choose};await args[6]?.(decision);return decision}}}}
|
||||
return { raw, controller, invoke:(extra:object={})=>raw.canUseTool('Bash',{command:'echo safe'}, {signal:controller.signal,toolUseID:'t',suggestions,...extra}) }
|
||||
raw.active={settled:false,request:{runId:'r',signal:controller.signal,emit:(event:any)=>events.push(event),interactions:{approval:async (...args:any[])=>{calls++;assert.equal(args[5].toolName,'Bash');const decision={kind:'approval',optionId:choose};await args[6]?.(decision);return decision}}}}
|
||||
return { raw, controller, events, invoke:(extra:object={})=>raw.canUseTool('Bash',{command:'echo safe'}, {signal:controller.signal,toolUseID:'t',suggestions,...extra}) }
|
||||
}
|
||||
try {
|
||||
const a=setup(create('a')); const result=await a.invoke()
|
||||
@@ -25,11 +26,23 @@ try {
|
||||
assert.equal(sessionGrantRules('a',temp).length,2);assert.equal(sessionGrantRules('b',temp).length,0)
|
||||
const once=setup(create('once'),'allow-once'); assert.equal((await once.invoke()).updatedPermissions,undefined);assert.equal(sessionGrantRules('once',temp).length,0)
|
||||
const denied=setup(create('denied'),'reject-once');assert.equal((await denied.invoke()).behavior,'deny')
|
||||
assert.deepEqual(denied.events.map(event=>event.progress.phase),['waiting-approval','thinking'],'rejected approval retires waiting status before next model event')
|
||||
for(const mode of ['ask','plan'] as const) {const old=calls;assert.equal((await setup(create(mode,mode,'full-access')).invoke()).behavior,'deny');assert.equal(calls,old)}
|
||||
const full=setup(create('full','craft','full-access'));const old=calls;assert.equal((await full.invoke()).behavior,'allow');assert.equal(calls,old)
|
||||
await full.invoke({matchedAskRule:{source:'projectSettings',toolName:'Bash',ruleContent:'echo safe:*'}});assert.equal(calls,old+1)
|
||||
const failed=setup(create('failed'));failed.raw.instance.applyFlagSettings=async()=>{throw new Error('apply failed')};assert.equal((await failed.invoke()).behavior,'deny');assert.equal(sessionGrantRules('failed',temp).length,0)
|
||||
const stale=setup(create('stale'));stale.raw.active.request.interactions.approval=async()=>{stale.controller.abort();return {kind:'approval',optionId:'allow-always'}}
|
||||
assert.equal((await stale.invoke()).behavior,'deny');assert.equal(sessionGrantRules('stale',temp).length,0)
|
||||
const skipped=setup(create('skipped-question'))
|
||||
skipped.raw.active.request.interactions.question=async()=>({kind:'question',skipped:true,answer:{questions:[]}})
|
||||
const askOptions={signal:skipped.controller.signal,toolUseID:'question-tool'}
|
||||
const questionInput={questions:[{question:'Choose?',options:[{label:'Yes',description:'Answer yes'}],multiSelect:false}]}
|
||||
assert.equal((await skipped.raw.canUseTool('AskUserQuestion',questionInput,askOptions)).behavior,'deny')
|
||||
assert.deepEqual(skipped.events.map(event=>event.progress.phase),['waiting-answer','thinking'],'skipped question retires waiting status')
|
||||
const cancelledQuestion=setup(create('cancelled-question'))
|
||||
cancelledQuestion.raw.active.request.interactions.question=async()=>{cancelledQuestion.controller.abort();return {kind:'question',skipped:true,answer:{questions:[]}}}
|
||||
assert.equal((await cancelledQuestion.raw.canUseTool('AskUserQuestion',questionInput,{...askOptions,signal:cancelledQuestion.controller.signal})).behavior,'deny')
|
||||
assert.deepEqual(cancelledQuestion.events.map(event=>event.progress.phase),['waiting-answer'],'cancelled request cannot emit stale thinking phase')
|
||||
assert.deepEqual(stale.events.map(event=>event.progress.phase),['waiting-approval'],'cancelled approval cannot emit stale tool/thinking phase')
|
||||
console.log('Claude host approval policy unit checks passed (no SDK process/application/matching verification)')
|
||||
} finally {clearSessionGrants();await rm(temp,{recursive:true,force:true})}
|
||||
@@ -10,6 +10,9 @@ const React=require('react') as typeof import('react')
|
||||
const {act}=React
|
||||
const {createRoot}=require('react-dom/client') as typeof import('react-dom/client')
|
||||
const {QuestionFloating}=require('../src/renderer/src/workbuddy/conversation/QuestionFloating') as typeof import('../src/renderer/src/workbuddy/conversation/QuestionFloating')
|
||||
const {projectQuestionAnswers}=require('../src/renderer/src/workbuddy/conversation/question-projection') as typeof import('../src/renderer/src/workbuddy/conversation/question-projection')
|
||||
const {renderToStaticMarkup}=require('react-dom/server') as typeof import('react-dom/server')
|
||||
const MessageBubble=require('../src/renderer/src/components/MessageBubble').default as typeof import('../src/renderer/src/components/MessageBubble').default
|
||||
const {CompletedFold,LiveEventDisclosure}=require('../src/renderer/src/workbuddy/conversation/CompletedFold') as typeof import('../src/renderer/src/workbuddy/conversation/CompletedFold')
|
||||
const root=createRoot(document.getElementById('root')!)
|
||||
const click=(el:Element)=>el.dispatchEvent(new MouseEvent('click',{bubbles:true}))
|
||||
@@ -54,6 +57,26 @@ async function main(){
|
||||
await act(async()=>root.render(<QuestionFloating key="replace" config={{...config,toolCallId:'new'}}/>))
|
||||
await act(async()=>new Promise(r=>setTimeout(r,140)))
|
||||
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')==='First','request replacement cancels old delayed advance')
|
||||
// Manual pagination must retire a previously selected option's delayed navigation.
|
||||
const three={toolCallId:'three',questions:[{id:'a',question:'Page A',options:['A']},{id:'b',question:'Page B',options:['B']},{id:'c',question:'Page C',options:['C']}]}
|
||||
await act(async()=>root.render(<QuestionFloating key="manual-page" config={three}/>))
|
||||
await act(async()=>click(document.querySelector('.question-floating__option')!))
|
||||
await act(async()=>click(document.querySelectorAll('.question-floating__page-button')[1]))
|
||||
await act(async()=>new Promise(r=>setTimeout(r,140)))
|
||||
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')==='Page B','manual next retires delayed advance instead of skipping a page')
|
||||
await act(async()=>root.render(<QuestionFloating key="manual-skip" config={three}/>))
|
||||
await act(async()=>click(document.querySelector('.question-floating__option')!))
|
||||
await act(async()=>click(document.querySelector('.question-floating__skip')!))
|
||||
await act(async()=>new Promise(r=>setTimeout(r,140)))
|
||||
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')==='Page B','skip retires delayed advance instead of skipping two pages')
|
||||
const legacyAnswer={questions:[{id:'legacy-question',question:'Legacy unique question',answers:['yes']}]}
|
||||
const legacy: import('../src/shared/types').ChatMessage={id:'legacy',role:'assistant',createdAt:1,content:'Final prose',status:'done',tools:[{id:'legacy-tool',name:'AskUserQuestion',args:{},argsRaw:'{}',status:'ok',metadata:{questionAnswer:legacyAnswer}}],interactions:[{id:'legacy-interaction',runId:'old',kind:'question',toolCallId:'legacy-tool',title:'Question',questions:[],status:'answered',answer:legacyAnswer}]}
|
||||
const projected=projectQuestionAnswers(legacy)
|
||||
check(projected.positionedAnswers.size===0 && projected.unpositioned.length===1,'fallback tool order is not treated as recorded question position')
|
||||
check(projected.unpositionedToolIds.has('legacy-tool'),'legacy duplicate tool rendering is suppressed')
|
||||
const legacyMarkup=renderToStaticMarkup(<MessageBubble message={legacy}/>)
|
||||
check(legacyMarkup.includes('历史问答(原始位置不可用)') && (legacyMarkup.match(/Legacy unique question/g)||[]).length===1,'legacy answer displayed exactly once with unknown-position label')
|
||||
check(projectQuestionAnswers({...legacy,interactions:[]}).unpositioned.length===1,'metadata-only legacy answer preserves unknown-position compatibility path')
|
||||
const parts=[{type:'reasoning',id:'r',text:'thought'},{type:'tool',toolId:'t'},{type:'text',text:'A'},{type:'tool',toolId:'later'}] as const
|
||||
await act(async()=>root.render(<CompletedFold parts={parts} turnFinished turnDurationMs={1000} renderUnit={(_p,i)=><i data-order={i}/>}/>))
|
||||
check(document.querySelector('.cr-collapse__header')?.getAttribute('aria-expanded')==='false','completion initially collapsed')
|
||||
|
||||
@@ -141,7 +141,7 @@ async function main() {
|
||||
// Automatic invalid-model repair is unrelated to a failed permission choice.
|
||||
useChatStore.setState(state => ({ sessions: state.sessions.map(item => item.id === 'a' ? { ...item, modelId:'deleted' } : item) }))
|
||||
await assert.rejects(useChatStore.getState().send('automatic model fallback must not clear permission failure'), /cannot save downgrade/)
|
||||
assert.equal(useChatStore.getState().sessions.find(item => item.id === 'a')?.modelId, model.id)
|
||||
assert.equal(useChatStore.getState().sessions.find(item => item.id === 'a')?.modelId, 'deleted', 'failed settings block automatic repair as well as submission')
|
||||
await useChatStore.getState().selectAgentMode('plan')
|
||||
await assert.rejects(useChatStore.getState().send('mode save must not clear permission failure'), /cannot save downgrade/)
|
||||
await useChatStore.getState().selectAgentMode('craft')
|
||||
@@ -184,6 +184,35 @@ async function main() {
|
||||
assert.equal(useChatStore.getState().currentSessionId, null)
|
||||
assert.equal(useChatStore.getState().draftContext.permissionMode, 'default-sandbox', 'pending creation cannot overwrite newer draft preference')
|
||||
|
||||
for (const initialModel of [undefined, 'deleted']) {
|
||||
for (const queued of [false, true]) {
|
||||
setup()
|
||||
const owner = `pending-model-${initialModel}-${queued}`
|
||||
const chosen = { ...model, id: 'chosen', name: 'Chosen' }
|
||||
useSettingsStore.setState({ models: [model, chosen], selectedModelId: model.id })
|
||||
useChatStore.setState({ currentSessionId: owner, sessions: [{ ...session(owner), modelId: initialModel }],
|
||||
...(queued ? { runs: { existing: { runId: 'existing', assistantId: 'assistant', sessionId: owner } } } : {}) })
|
||||
const choice = deferred<SessionMeta>()
|
||||
const writes: Array<{ id: string; modelId?: string }> = []
|
||||
window.ycode.session.updateContext = async (id, patch) => {
|
||||
writes.push({ id, modelId: patch.modelId })
|
||||
if (writes.length === 1) return choice.promise
|
||||
return { ...session(id), ...patch }
|
||||
}
|
||||
if (queued) window.ycode.chat.queue = { enqueue: async (payload: SendPayload) => { sent = payload; return {} } } as any
|
||||
const choosing = useChatStore.getState().selectModel(chosen.id)
|
||||
const nextSend = useChatStore.getState().send('explicit pending choice wins')
|
||||
assert.equal(sent, undefined)
|
||||
// Switching views while the save is pending must not repair the new owner.
|
||||
useChatStore.setState({ currentSessionId: 'b' })
|
||||
choice.resolve({ ...session(owner), modelId: chosen.id })
|
||||
await choosing; await nextSend
|
||||
assert.deepEqual(writes, [{ id: owner, modelId: chosen.id }], 'fallback cannot overwrite a pending explicit choice')
|
||||
assert.equal(sent?.sessionId, owner)
|
||||
assert.equal(useChatStore.getState().sessions.find(item => item.id === owner)?.modelId, chosen.id)
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(renderToStaticMarkup(<ModeChip mode="craft" onClear={() => {}} />), '')
|
||||
assert.match(renderToStaticMarkup(<ModeChip mode="plan" onClear={() => {}} />), /退出计划模式/)
|
||||
assert.doesNotMatch(renderToStaticMarkup(<ModeChip mode="ask" disabled onClear={() => {}} />), /<button/)
|
||||
|
||||
@@ -67,6 +67,18 @@ async function main() {
|
||||
await act(async () => click(other))
|
||||
check(underlyingClicks === 1, 'only the dismissal click is consumed')
|
||||
await render()
|
||||
const otherChild = document.createElement('span')
|
||||
otherChild.textContent = 'nested task title'
|
||||
other.append(otherChild)
|
||||
await act(async () => {
|
||||
otherChild.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true, button: 0 }))
|
||||
// Pointer-up on the row produces a click on the common ancestor, not the title.
|
||||
click(other)
|
||||
})
|
||||
check(underlyingClicks === 1, 'retargeted outside click cannot select underlying task')
|
||||
check(!document.querySelector('.task-popconfirm'), 'retargeted dismissal still closes')
|
||||
otherChild.remove()
|
||||
await render()
|
||||
anchor.focus()
|
||||
await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })))
|
||||
check(document.activeElement === anchor, 'Escape restores anchor focus')
|
||||
|
||||
Reference in new issue
Block a user