fix: reject invalid history parents on Windows and give questions exclusive composer
This commit is contained in:
1 parent
a4e77cacbe
commit
23537aaf28
9 files changed
+218
-18
No files matched your search
@@ -26,7 +26,7 @@ for (const kind of ['question', 'approval', 'plan', 'mcp'] as const) {
|
|||||||
running: true
|
running: true
|
||||||
})
|
})
|
||||||
assert.equal(result.interaction?.kind, kind)
|
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.blockedByInteraction, true)
|
||||||
assert.equal(result.input.destination, 'blocked')
|
assert.equal(result.input.destination, 'blocked')
|
||||||
assert.equal(result.input.preservesDraft, true, 'takeover never clears the draft')
|
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')
|
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')
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import assert from 'node:assert/strict'
|
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 { join } from 'node:path'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { createConversionContext } from '../src/main/model-bridge/history'
|
import { createConversionContext } from '../src/main/model-bridge/history'
|
||||||
@@ -158,7 +160,7 @@ try {
|
|||||||
} finally { await Promise.all([lease.close(), lease.close()]) }
|
} finally { await Promise.all([lease.close(), lease.close()]) }
|
||||||
await assert.rejects(fetch(lease.baseUrl)); checks++
|
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 {
|
try {
|
||||||
for (const reasoningMode of ['hidden', 'reasoning']) {
|
for (const reasoningMode of ['hidden', 'reasoning']) {
|
||||||
const file = join(dir, reasoningMode + '.json')
|
const file = join(dir, reasoningMode + '.json')
|
||||||
@@ -207,6 +209,61 @@ try {
|
|||||||
await writeFile(join(dir, 'blocked'), 'not a directory')
|
await writeFile(join(dir, 'blocked'), 'not a directory')
|
||||||
const blocked = await createConversionContext(join(dir, 'blocked', 'history.json')).catch(() => null)
|
const blocked = await createConversionContext(join(dir, 'blocked', 'history.json')).catch(() => null)
|
||||||
check(blocked === null, 'Invalid history path fails closed')
|
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 { await rm(dir, { recursive: true, force: true }) }
|
||||||
} finally { upstream.closeAllConnections(); await new Promise<void>(resolve => upstream.close(() => resolve())) }
|
} 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)`)
|
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)})
|
||||||
@@ -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.
|
// Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model.
|
||||||
if (domModule) {
|
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`
|
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' })
|
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 })
|
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({ result: 'PASS', phase: 'alignment-offline', suites: tests.length + 3 }))
|
||||||
console.log(JSON.stringify(domModule
|
console.log(JSON.stringify(domModule
|
||||||
? { result: 'PASS', phase: 'alignment-dom', suites: 8 }
|
? { result: 'PASS', phase: 'alignment-dom', suites: 9 }
|
||||||
: { 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: '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.' }))
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
import { lstat, mkdir, readFile, rename, stat as followStat, unlink, writeFile } from 'node:fs/promises'
|
||||||
import { dirname } from 'node:path'
|
import { dirname, resolve } from 'node:path'
|
||||||
import { randomBytes } from 'node:crypto'
|
import { randomBytes } from 'node:crypto'
|
||||||
import { BridgeError, record, type ConversionContext, type Json } from './protocol'
|
import { BridgeError, record, type ConversionContext, type Json } from './protocol'
|
||||||
|
|
||||||
@@ -12,9 +12,36 @@ function validItem(x: unknown): boolean {
|
|||||||
if (x.type === 'function_call') return typeof x.call_id === 'string' && typeof x.name === 'string' && typeof x.arguments === 'string'
|
if (x.type === 'function_call') return typeof x.call_id === 'string' && typeof x.name === 'string' && typeof x.arguments === 'string'
|
||||||
return x.type === 'message' && x.role === 'assistant' && Array.isArray(x.content) && x.content.every((s: unknown) => record(s) && s.type === 'output_text' && typeof s.text === 'string')
|
return x.type === 'message' && x.role === 'assistant' && Array.isArray(x.content) && x.content.every((s: unknown) => record(s) && s.type === 'output_text' && typeof s.text === 'string')
|
||||||
}
|
}
|
||||||
|
async function validateParents(file: string): Promise<void> {
|
||||||
|
const parents: string[] = []
|
||||||
|
for (let parent = dirname(resolve(file)); ; parent = dirname(parent)) {
|
||||||
|
parents.push(parent)
|
||||||
|
if (dirname(parent) === parent) break
|
||||||
|
}
|
||||||
|
// Check from the root: Windows may report ENOENT (not ENOTDIR) below a file.
|
||||||
|
// Only a missing component below verified directories is a new history path.
|
||||||
|
for (const parent of parents.reverse()) {
|
||||||
|
let stat
|
||||||
|
try { stat = await lstat(parent) }
|
||||||
|
catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
// The main process owns this path; OS/user-data directory links are valid.
|
||||||
|
// Follow those links only after lstat so dangling links are not "missing".
|
||||||
|
if (stat.isSymbolicLink()) stat = await followStat(parent)
|
||||||
|
if (!stat.isDirectory()) throw failure()
|
||||||
|
}
|
||||||
|
}
|
||||||
async function load(file: string): Promise<Map<string, Json[] | null>> {
|
async function load(file: string): Promise<Map<string, Json[] | null>> {
|
||||||
try {
|
try {
|
||||||
const stat = await lstat(file)
|
await validateParents(file)
|
||||||
|
let stat
|
||||||
|
try { stat = await lstat(file) }
|
||||||
|
catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Map()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_BYTES) throw failure()
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_BYTES) throw failure()
|
||||||
const value = JSON.parse(await readFile(file, 'utf8'))
|
const value = JSON.parse(await readFile(file, 'utf8'))
|
||||||
if (!record(value) || value.version !== 1 || !Array.isArray(value.entries) || value.entries.length > 1024) throw failure()
|
if (!record(value) || value.version !== 1 || !Array.isArray(value.entries) || value.entries.length > 1024) throw failure()
|
||||||
@@ -27,8 +54,7 @@ async function load(file: string): Promise<Map<string, Json[] | null>> {
|
|||||||
entries.set(entry[0], items)
|
entries.set(entry[0], items)
|
||||||
}
|
}
|
||||||
return entries
|
return entries
|
||||||
} catch (error) {
|
} catch {
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Map()
|
|
||||||
throw failure()
|
throw failure()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +78,7 @@ export async function createConversionContext(historyFile?: string): Promise<Con
|
|||||||
const data = JSON.stringify({ version: 1, entries: [...merged] })
|
const data = JSON.stringify({ version: 1, entries: [...merged] })
|
||||||
if (Buffer.byteLength(data) > MAX_BYTES) throw failure()
|
if (Buffer.byteLength(data) > MAX_BYTES) throw failure()
|
||||||
await mkdir(dirname(historyFile), { recursive: true, mode: 0o700 })
|
await mkdir(dirname(historyFile), { recursive: true, mode: 0o700 })
|
||||||
|
await validateParents(historyFile)
|
||||||
temp = `${historyFile}.${randomBytes(8).toString('hex')}.tmp`
|
temp = `${historyFile}.${randomBytes(8).toString('hex')}.tmp`
|
||||||
await writeFile(temp, data, { encoding: 'utf8', mode: 0o600, flag: 'wx' })
|
await writeFile(temp, data, { encoding: 'utf8', mode: 0o600, flag: 'wx' })
|
||||||
await rename(temp, historyFile); temp = undefined
|
await rename(temp, historyFile); temp = undefined
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { ResourceEntry } from '@shared/resources'
|
|||||||
import { ComposerControls, type ComposerSendPayload } from '../workbuddy/composer/ComposerControls'
|
import { ComposerControls, type ComposerSendPayload } from '../workbuddy/composer/ComposerControls'
|
||||||
import { ComposerBranchLabel, ContextUsageRing } from '../workbuddy/composer/context-info/ComposerContextInfo'
|
import { ComposerBranchLabel, ContextUsageRing } from '../workbuddy/composer/context-info/ComposerContextInfo'
|
||||||
import { composeComposer } from '../workbuddy/composer/input-composition'
|
import { composeComposer } from '../workbuddy/composer/input-composition'
|
||||||
|
import { findActiveComposerInteraction } from '../workbuddy/composer/active-interaction'
|
||||||
import { PromptQueuePanel, usePromptQueue } from '../workbuddy/composer/PromptQueuePanel'
|
import { PromptQueuePanel, usePromptQueue } from '../workbuddy/composer/PromptQueuePanel'
|
||||||
import { useCapabilityCatalog } from '../workbuddy/composer/useCapabilityCatalog'
|
import { useCapabilityCatalog } from '../workbuddy/composer/useCapabilityCatalog'
|
||||||
import { withCapabilitySelection } from '../workbuddy/composer/capability-selection'
|
import { withCapabilitySelection } from '../workbuddy/composer/capability-selection'
|
||||||
@@ -415,8 +416,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
|||||||
const availableReferenceFiles = referenceFiles.owner === referenceScope ? referenceFiles.items : []
|
const availableReferenceFiles = referenceFiles.owner === referenceScope ? referenceFiles.items : []
|
||||||
const addMenuError = (reason: unknown): void => useChatStore.setState({ error: reason instanceof Error ? reason.message : String(reason) })
|
const addMenuError = (reason: unknown): void => useChatStore.setState({ error: reason instanceof Error ? reason.message : String(reason) })
|
||||||
|
|
||||||
const activeInteraction = !hidden && !loading && !loadError && currentId ? messages.flatMap((message) => message.interactions ?? []).findLast((item) =>
|
const activeInteraction = findActiveComposerInteraction(messages, currentId, running, hidden || loading || !!loadError)
|
||||||
(item.kind === 'question' || item.kind === 'approval') && ['pending', 'responding', 'error'].includes(item.status)) : undefined
|
|
||||||
const respondToActive: typeof respond = (id, response) => {
|
const respondToActive: typeof respond = (id, response) => {
|
||||||
const state = useChatStore.getState()
|
const state = useChatStore.getState()
|
||||||
if (state.currentSessionId !== currentId || state.loading || state.loadError || hidden) return Promise.reject(new Error('审批所属会话已切换'))
|
if (state.currentSessionId !== currentId || state.loading || state.loadError || hidden) return Promise.reject(new Error('审批所属会话已切换'))
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export interface ComposerControlsProps {
|
|||||||
permissionSlot?: ReactNode
|
permissionSlot?: ReactNode
|
||||||
modeSlot?: ReactNode
|
modeSlot?: ReactNode
|
||||||
expertSlot?: ReactNode
|
expertSlot?: ReactNode
|
||||||
/** An active tool approval replaces, rather than floats above, normal editing. */
|
/** An active question or tool approval replaces, rather than floats above, normal editing. */
|
||||||
approvalSlot?: ReactNode
|
approvalSlot?: ReactNode
|
||||||
approvalKey?: string
|
approvalKey?: string
|
||||||
floatingSlot?: ReactNode
|
floatingSlot?: ReactNode
|
||||||
@@ -146,7 +146,7 @@ export function readReferenceUris(transfer: Pick<DataTransfer, 'getData'>): stri
|
|||||||
return transfer.getData('text/uri-list').split(/\r\n?|\n/).filter((value) => value.trim() && !value.startsWith('#'))
|
return transfer.getData('text/uri-list').split(/\r\n?|\n/).filter((value) => value.trim() && !value.startsWith('#'))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep each approval subtree scoped to its request identity. */
|
/** Keep each takeover subtree scoped to its request identity. */
|
||||||
function ApprovalComposerSurface({ children }: { children: ReactNode }): React.JSX.Element {
|
function ApprovalComposerSurface({ children }: { children: ReactNode }): React.JSX.Element {
|
||||||
return <div className="conversation-input-area__approval" data-composer-approval="true">
|
return <div className="conversation-input-area__approval" data-composer-approval="true">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ChatMessage } from '../../../../shared/types'
|
||||||
|
import type { ChatInteraction } from '../../../../shared/conversation'
|
||||||
|
|
||||||
|
/** Only a live question in the selected run may take over its composer.
|
||||||
|
* Historical question cards remain read-only; retain existing approval selection.
|
||||||
|
*/
|
||||||
|
export function findActiveComposerInteraction(
|
||||||
|
messages: readonly ChatMessage[],
|
||||||
|
sessionId: string | null,
|
||||||
|
running: { runId: string; sessionId?: string } | null,
|
||||||
|
unavailable = false
|
||||||
|
): ChatInteraction | undefined {
|
||||||
|
if (unavailable || !sessionId) return undefined
|
||||||
|
return messages.flatMap(message => message.interactions ?? []).findLast(item =>
|
||||||
|
['pending', 'responding', 'error'].includes(item.status) &&
|
||||||
|
(item.kind === 'approval' || (item.kind === 'question' &&
|
||||||
|
running?.sessionId === sessionId && running.runId === item.runId)))
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ export interface ComposerQueueComposition {
|
|||||||
export interface ComposerInputComposition {
|
export interface ComposerInputComposition {
|
||||||
/** The editor stays mounted so draft, attachments and capability selections survive takeover. */
|
/** The editor stays mounted so draft, attachments and capability selections survive takeover. */
|
||||||
mounted: true
|
mounted: true
|
||||||
/** Only real tool approval replaces the normal input surface. */
|
/** Active questions and tool approvals replace the normal input surface. */
|
||||||
visible: boolean
|
visible: boolean
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
blockedByInteraction: boolean
|
blockedByInteraction: boolean
|
||||||
@@ -137,7 +137,7 @@ function panelId(value: ComposerInteractionSnapshot | ComposerNoticeSnapshot | C
|
|||||||
* data.
|
* data.
|
||||||
* 4. Tasks move into the queue header when the queue is visible, otherwise
|
* 4. Tasks move into the queue header when the queue is visible, otherwise
|
||||||
* they stay in the unified stack above the editor.
|
* they stay in the unified stack above the editor.
|
||||||
* 5. Running state keeps its stop action semantics; approval takeover hides the normal toolbar and renders only the approval card.
|
* 5. Running state keeps its stop action semantics; question/approval takeover hides the normal toolbar and renders only the interaction card.
|
||||||
*/
|
*/
|
||||||
export function composeComposer(input: ComposerCompositionInput = {}): ComposerComposition {
|
export function composeComposer(input: ComposerCompositionInput = {}): ComposerComposition {
|
||||||
const interaction = isActiveComposerInteraction(input.interaction) ? input.interaction : null
|
const interaction = isActiveComposerInteraction(input.interaction) ? input.interaction : null
|
||||||
@@ -192,7 +192,7 @@ export function composeComposer(input: ComposerCompositionInput = {}): ComposerC
|
|||||||
queue,
|
queue,
|
||||||
input: {
|
input: {
|
||||||
mounted: true,
|
mounted: true,
|
||||||
visible: interaction?.kind !== 'approval',
|
visible: interaction?.kind !== 'approval' && interaction?.kind !== 'question',
|
||||||
disabled,
|
disabled,
|
||||||
blockedByInteraction: interactionTakeover,
|
blockedByInteraction: interactionTakeover,
|
||||||
destination: interactionTakeover ? 'blocked' : running ? 'queue' : 'send',
|
destination: interactionTakeover ? 'blocked' : running ? 'queue' : 'send',
|
||||||
|
|||||||
Reference in new issue
Block a user