fix(conversation): align TodoWrite task projection and disclosure lifecycle

This commit is contained in:
Codex committed 2026-10-21 19:38:14 +09:00
1 parent 8d10743fb2
commit d423d59b08
9 files changed
+335 -66

No files matched your search

+97
View File
@@ -0,0 +1,97 @@
/** B05 real React TodoWrite + CompletedFold DOM tests. Synthetic data only, no native UI claim. */
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import type { ConversationToolCall } from '../src/renderer/src/workbuddy/conversation/types'
const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom')
const dom = new JSDOM('<html><body><main id="root"></main></body></html>', { pretendToBeVisual:true, url:'http://localhost/' })
for (const key of ['window','document','Node','Element','HTMLElement','MouseEvent','KeyboardEvent','Event','CustomEvent']) 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 { act } = require('react') as typeof import('react')
const { createRoot } = require('react-dom/client') as typeof import('react-dom/client')
const { CompletedFold } = require('../src/renderer/src/workbuddy/conversation/CompletedFold') as typeof import('../src/renderer/src/workbuddy/conversation/CompletedFold')
const { ToolCallRenderer } = require('../src/renderer/src/workbuddy/conversation/ToolCallRenderer') as typeof import('../src/renderer/src/workbuddy/conversation/ToolCallRenderer')
const { ThinkingBlock } = require('../src/renderer/src/workbuddy/conversation/SpecializedBlocks') as typeof import('../src/renderer/src/workbuddy/conversation/SpecializedBlocks')
const root = createRoot(document.getElementById('root')!)
let checks = 0
function equal(actual:unknown, expected:unknown, label:string) { assert.deepEqual(actual,expected,label);checks++ }
const tasks = (count=7, active=4) => Array.from({length:count},(_,i)=>({id:String(i+1),content:`Task ${i+1}`,activeForm:`Working ${i+1}`,status:i===active?'in_progress':i<active?'completed':'pending'}))
const makeTool = (id:string,last:boolean,args:Record<string,unknown>={todos:tasks()}): ConversationToolCall => ({id,name:'TodoWrite',status:'executed',isLastContent:last,args,result:'Todos have been modified successfully...',output:'Todos have been modified successfully...'})
const calls: Array<{toolCallId:string;toolName:string;taskId:string|number}> = []
interface Options { session?:string; finished?:boolean; last?:boolean; remount?:number; second?:boolean; callback?:boolean; active?:number; count?:number }
function Fixture({session='session-a',finished=true,last=true,remount=0,second=false,callback=false,active=4,count=7}: Options) {
const parts = [{type:'tool',toolId:'todo-1'},{type:'reasoning',id:'thought',text:'Saved thought'},{type:'tool',toolId:'ordinary'},{type:'tool',toolId:'question'},...(second?[{type:'tool',toolId:'todo-2'}]:[]),{type:'text',text:'Answer'}]
return <CompletedFold foldId={`${session}:message`} parts={parts} turnFinished={finished}
classifyToolCall={part=>part.toolId==='question'?{role:'inline-question'}:undefined}
renderUnit={part=>part.type==='reasoning'?<ThinkingBlock text="Saved thought" isFinished />:part.type==='tool'?<div data-fixture-tool={part.toolId}>
<ToolCallRenderer key={`${part.toolId}:${remount}`} sessionId={session}
tool={part.toolId==='todo-1'?makeTool('todo-1',last,{todos:tasks(count,active)}):part.toolId==='todo-2'?makeTool('todo-2',true,{todos:tasks(6,1)}):part.toolId==='question'?{id:'question',name:'AskUserQuestion',status:'executed',metaData:{questionAnswer:{questions:[{question:'Choose?',answers:['Yes']}]}}}:{id:'ordinary',name:'UnknownOrdinaryTool',status:'executed',args:{value:1},result:'Ordinary result'}}
onTodoTaskClick={callback?request=>calls.push(request):undefined} />
</div>:<p>Answer</p>} />
}
const find=(selector:string)=>{const el=document.querySelector(selector);assert.ok(el,selector);return el}
const todo='[data-fixture-tool="todo-1"] .cr-tool-head__main'
const secondTodo='[data-fixture-tool="todo-2"] .cr-tool-head__main'
const ordinary='[data-fixture-tool="ordinary"] .cr-tool-head__main'
const question='[data-fixture-tool="question"] .cr-tool-head__main'
const thought='.cr-reasoning > .cr-collapse__header'
const outer='.cr-collapse--completed > .cr-collapse__header'
const expanded=(selector:string)=>find(selector).getAttribute('aria-expanded')
const rows=()=>Array.from(document.querySelectorAll('[data-fixture-tool="todo-1"] .cr-tool-plan-task__row')).map(el=>el.getAttribute('data-task-id'))
async function render(options:Options={}) { await act(async()=>root.render(<Fixture {...options} />)) }
async function click(selector:string) { await act(async()=>find(selector).dispatchEvent(new MouseEvent('click',{bubbles:true}))) }
async function key(selector:string,value:string) { await act(async()=>find(selector).dispatchEvent(new KeyboardEvent('keydown',{key:value,bubbles:true,cancelable:true}))) }
async function main() {
await render()
equal(expanded(outer),'false','finished outer starts closed')
await click(outer)
equal(expanded(todo),'true','last Todo initially open')
equal(rows(),['3','4','5','6','7'],'real component displays five centered rows')
equal(document.querySelector('[data-fixture-tool="todo-1"]')!.textContent!.includes('Todos have been modified successfully'),false,'SDK success hidden')
for(const selector of [ordinary,question,thought]) {equal(expanded(selector),'false',`${selector} starts independent`); await click(selector)}
await click(todo); equal(expanded(todo),'false','manual Todo close')
await render(); equal(expanded(todo),'false','same isLastContent preserves manual choice')
await click(outer); await click(outer)
equal(expanded(todo),'false','outer collapse/reopen preserves manual close')
for(const selector of [ordinary,question,thought]) equal(expanded(selector),'true','Todo state does not alter other disclosure')
await render({remount:1}); equal(expanded(todo),'false','same-value inner remount preserves manual choice')
await render({last:false,remount:1}); equal(expanded(todo),'false','true to false closes')
await render({last:true,remount:1}); equal(expanded(todo),'true','false to true opens')
await render({last:false,remount:2}); equal(expanded(todo),'false','real true-false edge concurrent with remount closes')
await click(todo); equal(expanded(todo),'true','manual open while not last')
await render({last:false,remount:3}); equal(expanded(todo),'true','same false remount does not overwrite manual open')
await click(todo); equal(expanded(todo),'false','manual close before next real edge')
await render({last:true,remount:4}); equal(expanded(todo),'true','false-true remount opens despite retained manual close')
await render({session:'session-b',last:false}); equal(expanded(outer),'false','session identity resets completed outer')
await click(outer); equal(expanded(todo),'false','same tool id in other session does not inherit')
for(const selector of [ordinary,question,thought]) equal(expanded(selector),'false','other disclosures isolated per session')
await render({session:'live-session',finished:false,last:true})
equal(expanded(todo),'true','live Todo open')
await render({session:'live-session',finished:true,last:false})
equal(expanded(todo),'false','live-to-completed reparent plus actual edge closes')
await click(outer); await click(todo)
await render({session:'live-session',finished:true,last:false,second:true})
equal(expanded(todo),'true','older manually opened snapshot stays open after later call')
equal(expanded(secondTodo),'true','later call has own disclosure')
equal(document.querySelectorAll('.cr-tool-head--todo-write').length,2,'distinct historical calls are not merged')
equal(document.querySelectorAll('[data-fixture-tool="todo-2"] .cr-tool-plan-task__row').length,5,'second snapshot independently projected')
await click(secondTodo); equal(expanded(todo),'true','closing later Todo cannot close older snapshot')
await render({session:'live-session',finished:true,last:false,second:true,active:6})
equal(rows(),['3','4','5','6','7'],'same call update refreshes window without data merge')
assert.match(find('[data-fixture-tool="todo-1"]').textContent!,/Working 7/); checks++
await render({session:'callbacks',callback:true})
await click(outer)
const row='[data-fixture-tool="todo-1"] [data-task-id="3"]'
equal(find(row).getAttribute('role'),'button','host callback exposes button semantics')
equal(find(row).getAttribute('tabindex'),'0','host callback exposes Tab target')
await click(row); await key(row,'Enter'); await key(row,' ')
equal(calls,[0,1,2].map(()=>({toolCallId:'todo-1',toolName:'TodoWrite',taskId:'3'})),'click and keyboard preserve exact event identity')
equal(expanded(todo),'true','row activation does not toggle parent')
await render({session:'callbacks',callback:false})
equal(find(row).getAttribute('role'),null,'no fake role without actual host target')
equal(find(row).getAttribute('tabindex'),null,'no fake keyboard navigation without target')
await click(row); await key(row,'Enter'); equal(calls.length,3,'callback removed disables interaction')
await act(async()=>root.unmount());dom.window.close()
console.log(`todo-write-dom: ${checks} actual TodoWrite/CompletedFold checks passed; no Electron/pixel claim`)
}
main().then(()=>process.exit(0)).catch(error=>{console.error(error);dom.window.close();process.exit(1)})