fix: reject invalid history parents on Windows and give questions exclusive composer

This commit is contained in:
Codex committed 2026-10-21 18:39:29 +09:00
1 parent a4e77cacbe
commit 23537aaf28
9 files changed
+218 -18

No files matched your search

+10 -1
View File
@@ -26,7 +26,7 @@ for (const kind of ['question', 'approval', 'plan', 'mcp'] as const) {
running: true
})
assert.equal(result.interaction?.kind, kind)
assert.equal(result.input.visible, kind !== 'approval', 'only actual approval replaces normal input')
assert.equal(result.input.visible, kind !== 'approval' && kind !== 'question', 'active question and approval replace normal input')
assert.equal(result.input.blockedByInteraction, true)
assert.equal(result.input.destination, 'blocked')
assert.equal(result.input.preservesDraft, true, 'takeover never clears the draft')
@@ -75,3 +75,12 @@ const secondLayout = composeComposer({ queueCount: 1, queueRevision: 2 }).layout
assert.notEqual(firstLayout, secondLayout, 'layout key changes when queue height may change')
console.log('input-composition-regression: precedence, takeover, queue preservation, task placement, toolbar and escape rules passed')
for (const kind of ['question', 'approval'] as const) {
for (const status of ['pending', 'responding', 'error', 'answered', 'rejected', 'cancelled'] as const) {
const active = ['pending', 'responding', 'error'].includes(status)
const state = composeComposer({ interaction: { id: kind, kind, status } })
assert.equal(state.input.visible, !active, `${kind}/${status} input exclusivity`)
assert.equal(state.input.mounted, true, 'draft editor always remains mounted')
}
}
+59 -2
View File
@@ -1,5 +1,7 @@
import assert from 'node:assert/strict'
import { mkdtemp, readFile, stat, writeFile, rm } from 'node:fs/promises'
import fs, { mkdir, mkdtemp, readFile, realpath, stat, symlink, writeFile, rm } from 'node:fs/promises'
import { syncBuiltinESMExports } from 'node:module'
import { mock } from 'node:test'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { createConversionContext } from '../src/main/model-bridge/history'
@@ -158,7 +160,7 @@ try {
} finally { await Promise.all([lease.close(), lease.close()]) }
await assert.rejects(fetch(lease.baseUrl)); checks++
}
const dir = await mkdtemp(join(tmpdir(), 'ycode-bridge-'))
const dir = await mkdtemp(join(await realpath(tmpdir()), 'ycode-bridge-'))
try {
for (const reasoningMode of ['hidden', 'reasoning']) {
const file = join(dir, reasoningMode + '.json')
@@ -207,6 +209,61 @@ try {
await writeFile(join(dir, 'blocked'), 'not a directory')
const blocked = await createConversionContext(join(dir, 'blocked', 'history.json')).catch(() => null)
check(blocked === null, 'Invalid history path fails closed')
await assert.rejects(createConversionContext(join(dir, 'blocked', 'missing', 'history.json')), /history/); checks++
await assert.rejects(createConversionContext(dir), /history/); checks++
const missingFile = join(dir, 'new', 'nested', 'history.json')
const fresh = await createConversionContext(missingFile)
check(fresh.responsesHistory.size === 0, 'Genuinely missing parents allow a new history')
await fresh.persist!()
check((await createConversionContext(missingFile)).responsesHistory.size === 0, 'New nested history persists and reloads')
// Simulate Windows' leaf ENOENT below a regular-file parent on every platform.
const blockedFile = join(dir, 'blocked', 'history.json'), originalLstat = fs.lstat
mock.method(fs, 'lstat', async (...args: Parameters<typeof fs.lstat>) => {
if (String(args[0]) === blockedFile) throw Object.assign(new Error('missing'), { code: 'ENOENT' })
return originalLstat(...args)
})
syncBuiltinESMExports()
try { await assert.rejects(createConversionContext(blockedFile), /history/); checks++ }
finally { mock.restoreAll(); syncBuiltinESMExports() }
// Missing during read is not a new conversation; permission errors are never absence.
for (const [method, code, target] of [['readFile', 'ENOENT', snapshotFile], ['lstat', 'EACCES', snapshotFile], ['lstat', 'EACCES', dir]] as const) {
const original = fs[method]
mock.method(fs, method, async (...args: any[]) => {
if (String(args[0]) === target) throw Object.assign(new Error('unavailable'), { code })
return (original as Function)(...args)
})
syncBuiltinESMExports()
try { await assert.rejects(createConversionContext(snapshotFile), /history/); checks++ }
finally { mock.restoreAll(); syncBuiltinESMExports() }
}
const linkedDir = join(dir, 'linked'), targetDir = join(dir, 'target')
await mkdir(targetDir)
await symlink(targetDir, linkedDir, process.platform === 'win32' ? 'junction' : 'dir')
const linked = await createConversionContext(join(linkedDir, 'history.json'))
await linked.persist!()
check((await createConversionContext(join(targetDir, 'history.json'))).responsesHistory.size === 0, 'Trusted parent directory links remain supported')
const changedFile = join(dir, 'changed', 'history.json'), changed = await createConversionContext(changedFile)
await writeFile(join(dir, 'changed'), 'not a directory')
await assert.rejects(changed.persist!(), /history/); checks++
const danglingDir = join(dir, 'dangling')
await symlink(join(dir, 'absent-target'), danglingDir, process.platform === 'win32' ? 'junction' : 'dir')
await assert.rejects(createConversionContext(join(danglingDir, 'history.json')), /history/); checks++
if (process.platform !== 'win32') {
const linkedFile = join(dir, 'linked.json')
await symlink(snapshotFile, linkedFile)
await assert.rejects(createConversionContext(linkedFile), /history/); checks++
}
const concurrentFile = join(dir, 'concurrent.json')
const [left, right] = await Promise.all([createConversionContext(concurrentFile), createConversionContext(concurrentFile)])
left.responsesHistory.set(`${'a'.repeat(64)}:${'b'.repeat(64)}`, [])
right.responsesHistory.set(`${'c'.repeat(64)}:${'d'.repeat(64)}`, [])
await Promise.all([left.persist!(), right.persist!()])
check((await createConversionContext(concurrentFile)).responsesHistory.size === 2, 'Concurrent atomic history saves preserve both snapshots')
check(!(await fs.readdir(dir)).some(name => name.endsWith('.tmp')), 'Atomic history saves leave no temporary files')
} finally { await rm(dir, { recursive: true, force: true }) }
} finally { upstream.closeAllConnections(); await new Promise<void>(resolve => upstream.close(() => resolve())) }
console.log(`Protocol bridge offline regression: ${checks} checks passed (no SDK/CLI or external API used)`)
@@ -0,0 +1,89 @@
/** jsdom behavior only; no layout, browser pixels, SDK process or model calls. */
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { readFileSync } from 'node:fs'
const externalRequire=createRequire(process.cwd()+'/package.json')
const {JSDOM}=externalRequire(process.env.YCODE_TEST_DOM_MODULE||'jsdom')
const dom=new JSDOM('<html><body><main id="root"></main></body></html>',{url:'http://localhost/',pretendToBeVisual:true})
for(const key of ['window','document','Node','Element','HTMLElement','HTMLInputElement','HTMLTextAreaElement','MouseEvent','KeyboardEvent','Document','Text','DOMParser','MutationObserver','Range','ShadowRoot','File','FileReader','Event','getComputedStyle']) Object.defineProperty(globalThis,key,{value:dom.window[key],configurable:true})
Object.defineProperty(globalThis,'navigator',{value:dom.window.navigator,configurable:true})
Object.assign(globalThis,{IS_REACT_ACT_ENVIRONMENT:true,requestAnimationFrame:dom.window.requestAnimationFrame.bind(dom.window),cancelAnimationFrame:dom.window.cancelAnimationFrame.bind(dom.window)})
const composerStyle=document.createElement('style')
composerStyle.textContent=['composer.css','composer-source.css'].map(file=>readFileSync(`src/renderer/src/workbuddy/composer/${file}`,'utf8')).join('\n')
document.head.append(composerStyle)
const React=require('react') as typeof import('react')
const {act}=React
const {createRoot}=require('react-dom/client') as typeof import('react-dom/client')
const {ComposerControls}=require('../src/renderer/src/workbuddy/composer/ComposerControls') as typeof import('../src/renderer/src/workbuddy/composer/ComposerControls')
const {composeComposer}=require('../src/renderer/src/workbuddy/composer/input-composition') as typeof import('../src/renderer/src/workbuddy/composer/input-composition')
const {QuestionFloating}=require('../src/renderer/src/workbuddy/conversation/QuestionFloating') as typeof import('../src/renderer/src/workbuddy/conversation/QuestionFloating')
const {findActiveComposerInteraction}=require('../src/renderer/src/workbuddy/composer/active-interaction') as typeof import('../src/renderer/src/workbuddy/composer/active-interaction')
const root=createRoot(document.getElementById('root')!)
let checks=0
const check=(value:unknown,message:string)=>{assert.ok(value,message);checks++}
const click=(selector:string)=>document.querySelector(selector)!.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}))
const question: import('../src/shared/conversation').ChatInteraction = {id:'question',kind:'question',runId:'run-a',toolCallId:'ask-a',status:'pending',title:'Release?',questions:[{id:'q',question:'Release?',options:['Fix first','Release now']}]}
let session='session-a', running:{runId:string;sessionId:string}|null={runId:'run-a',sessionId:'session-a'}, unavailable=false
let status=question.status, sends=0,stops=0,answers=0,skips=0,changes=0
let resolve!:()=>void,reject!:(error:Error)=>void
const draft='Existing unsent draft'
const attachments=[{id:'attachment',name:'draft.txt',mimeType:'text/plain',size:1}]
const messages=()=>[{id:'assistant',role:'assistant' as const,content:'',createdAt:1,status:'streaming' as const,interactions:[{...question,status}]}]
const render=()=>act(async()=>{
const active=findActiveComposerInteraction(messages(),session,running,unavailable)
const composition=composeComposer({interaction:active,queueCount:2,running:!!running})
const surface=active?.kind==='question'?<QuestionFloating config={{questions:active.questions,toolCallId:active.toolCallId}} responding={status==='responding'}
onSelect={()=>{answers++;return new Promise<void>((yes,no)=>{resolve=yes;reject=no})}} onSkip={async()=>{skips++;status='cancelled'}}/>:undefined
root.render(<ComposerControls key={session} sessionId={session} value={draft} onChange={()=>{changes++}} onSend={()=>{sends++}} onStop={()=>{stops++}}
attachments={attachments} resources={['/fixture/keep.ts']} approvalKey={active?.id}
approvalSlot={!composition.input.visible?surface:undefined} floatingSlot={composition.input.visible?surface:undefined}
queueSlot={composition.queue.visible?<div data-queue="true">queued</div>:undefined}/>)
})
async function main(){
status='answered';await render()
const stack=document.querySelector<HTMLElement>('.conversation-input-area__input-stack')!
const editor=stack.querySelector<HTMLElement>('[contenteditable]')!
await act(async()=>editor.focus())
status='pending';await render()
check(stack===document.querySelector('.conversation-input-area__input-stack'),'question preserves mounted draft stack')
check(stack.hidden&&stack.hasAttribute('inert')&&stack.getAttribute('aria-hidden')==='true','pending question hides/inerts/accessibility-hides ordinary composer')
check(getComputedStyle(stack).display==='none','normal composer removed from layout')
check(!stack.contains(document.activeElement),'focus leaves hidden editor')
check(document.querySelectorAll('[role="dialog"]').length===1&&!!document.querySelector('.question-floating__option'),'only question surface shown')
check(!document.querySelector('.wb-composer__toolbar, [data-queue], button[aria-label="停止"],button[aria-label="发送"]'),'ordinary controls and queue absent; no extra stop')
const changesBefore=changes
await act(async()=>{editor.focus();editor.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,cancelable:true}))})
check(!stack.contains(document.activeElement)&&sends===0&&changes===changesBefore,'hidden editor cannot focus, edit or send')
await act(async()=>click('.question-floating__option'))
await act(async()=>click('.question-floating__advance'))
check(answers===1,'question submits through its own callback')
status='responding';await render()
check(stack.hidden&&document.querySelector('[role="dialog"]')?.getAttribute('aria-busy')==='true','responding question retains exclusivity')
await act(async()=>reject(Error('retry fixture')))
status='error';await render()
check(stack.hidden&&document.querySelector('[role="alert"]')?.textContent?.includes('retry fixture'),'failed answer remains retryable without exposing composer')
await act(async()=>click('.question-floating__advance'));await act(async()=>resolve())
status='answered';await render()
check(!stack.hidden&&!stack.hasAttribute('inert'),'answered restores composer')
check(stack.querySelector('[contenteditable]')===editor&&stack.textContent?.includes(draft)&&stack.textContent.includes('draft.txt')&&stack.textContent.includes('keep.ts'),'same editor draft attachments resources survive')
check(!!document.querySelector('[data-queue]')&&!!document.querySelector('.wb-composer__toolbar'),'queue toolbar restored')
status='pending';await render();await act(async()=>click('.question-floating__dismiss'));await render()
check(skips===1&&!stack.hidden,'dismiss/skip restores normal input after terminal state')
status='pending';running=null;await render()
check(!stack.hidden&&!document.querySelector('[role="dialog"]'),'historical pending question without live run cannot take over')
running={runId:'other-run',sessionId:session};await render()
check(!stack.hidden,'old-run question does not take over current run')
running={runId:'run-a',sessionId:'other-session'};await render()
check(!stack.hidden,'other-session question does not take over')
running={runId:'run-a',sessionId:session};unavailable=true;await render()
check(!stack.hidden,'hidden/loading/error pane has no actionable question')
unavailable=false;status='cancelled';await render()
check(!stack.hidden,'cancelled history question leaves normal composer')
check(sends===0&&stops===0,'question transitions never invoke normal send or stop')
// Approval behavior remains unchanged, including selection without a live run.
const approval={...question,kind:'approval' as const,requestId:'approval',options:[]}
check(findActiveComposerInteraction([{...messages()[0],interactions:[approval]}],session,null)?.kind==='approval','existing approval selection unchanged')
await act(async()=>root.unmount());dom.window.close()
console.log(`Question composer DOM: ${checks} checks passed (not pixel/SDK verification)`);process.exit(0)
}
main().catch(error=>{console.error(error);process.exit(1)})
+3 -3
View File
@@ -42,7 +42,7 @@ for (const file of ['scripts/build-runtime-regressions.mjs', '.tmp/claude-approv
// Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model.
if (domModule) {
for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'completed-fold-native-identity-regression', 'permission-intercept-dom-regression', 'reasoning-follow-dom-regression']) {
for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'question-composer-dom-regression', 'completed-fold-nested-regression', 'completed-fold-native-identity-regression', 'permission-intercept-dom-regression', 'reasoning-follow-dom-regression']) {
const outfile = `.tmp/alignment-tests/${file}.cjs`
await build({ entryPoints: [`scripts/${file}.tsx`], outfile, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' })
const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 })
@@ -56,5 +56,5 @@ if (domModule) {
console.log(JSON.stringify({ result: 'PASS', phase: 'alignment-offline', suites: tests.length + 3 }))
console.log(JSON.stringify(domModule
? { result: 'PASS', phase: 'alignment-dom', suites: 8 }
: { result: 'SKIP', phase: 'alignment-dom', suites: 8, reason: 'YCODE_TEST_DOM_MODULE is not set; DOM coverage was not run. Use --require-dom to forbid this skip.' }))
? { result: 'PASS', phase: 'alignment-dom', suites: 9 }
: { result: 'SKIP', phase: 'alignment-dom', suites: 9, reason: 'YCODE_TEST_DOM_MODULE is not set; DOM coverage was not run. Use --require-dom to forbid this skip.' }))