fix: close alignment review races and preserve local conversation state

This commit is contained in:
Codex committed 2026-10-21 04:55:11 +09:00
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')
+15 -2
View File
@@ -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/)
+12
View File
@@ -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')