// TODO 08 验收:新任务发送后的立即切页与占位。 // 真实隔离 YCode(真实 preload/IPC/渲染进程)+ 真实 OMP + 受控本地 OpenAI 兼容上游。 // // 原版依据(REA ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb, // WorkBuddy 5.8.2,见 docs/对话启动提示文案-2026-10-16.md):启动阶段文案取自 // chat.prepare.,渲染在流式状态栏主文案位置。YCode 侧使用 acquiring_runtime // =「正在启动对话运行环境…」作为主进程回合就绪前的占位文案。 // 原版「发送后」逐帧等待态没有 REA 运行时证据:REA visual probe 的 text 动作只接受 // INPUT、key 动作排除可编辑内容上的 Enter/Space(见 .tmp/todo08-wb-new-task/notes.md), // 因此本脚本只验收可证行为:发送即切页 + 用户消息先出现 + 准备阶段占位先于真实回合 + // 真实消息替换不重复 + 发送失败恢复草稿且不残留乐观消息。 const fs = require('node:fs/promises') const path = require('node:path') const assert = require('node:assert/strict') if (!process.versions.electron) { ;(async () => { const directory = await fs.mkdtemp(path.resolve('.tmp/new-task-switch-')) const home = path.join(directory, 'home') await fs.mkdir(home, { recursive: true }) const env = { ...process.env, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_QA_DIRECTORY: directory, YCODE_QA_REAL_OMP: process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe'), YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' } delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL const child = require('node:child_process').spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) let output = '' child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('NEW_TASK_SWITCH_')) process.stdout.write(bytes) }) child.stderr.on('data', bytes => { output += bytes }) const timer = setTimeout(() => child.kill(), 240000) child.once('exit', async code => { clearTimeout(timer) await fs.writeFile(path.join(directory, 'process.log'), output) console.log(JSON.stringify({ directory, exitCode: code })) if (code) console.error(output.slice(-8000)) process.exitCode = code ? 1 : 0 }) })().catch(error => { console.error(error); process.exitCode = 1 }) } else { const { app, BrowserWindow, ipcMain } = require('electron') const { createServer } = require('node:http') const { pathToFileURL } = require('node:url') const directory = process.env.YCODE_QA_DIRECTORY app.disableHardwareAcceleration() process.env.YCODE_OMP_PATH = process.env.YCODE_QA_REAL_OMP app.setPath('home', path.join(directory, 'home')) const checks = [] const snapshots = {} const rendererErrors = [] const preStartFailureMarker = 'QA_PRE_START_FAILURE_20261017' let controlledFailureCalls = 0 let controlledFailureSessionId let main, server, phase = 'boot' const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) const read = (source, win = main) => Promise.race([win.webContents.executeJavaScript(source), new Promise((_, reject) => setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 20000))]) async function wait(source, label) { for (let index = 0; index < 400; index++) { const value = await read(source); if (value) return value; await pause(40) } throw Error('Timeout: ' + label) } async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) } function check(label, actual, expected) { const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected) checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected }) } async function fill(value) { await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null); if(!input)throw new Error('Missing composer input'); Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,${JSON.stringify(value)}); input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`) } async function dropDraftFixtures(attachmentName, attachmentText, referencePath) { await read(`(()=>{const composer=document.querySelector('.wb-composer'); if(!composer)throw new Error('Missing composer'); const transfer=new DataTransfer(); transfer.items.add(new File([${JSON.stringify(attachmentText)}],${JSON.stringify(attachmentName)},{type:'text/plain'})); transfer.setData('text/uri-list',${JSON.stringify(referencePath)}); composer.dispatchEvent(new DragEvent('drop',{bubbles:true,cancelable:true,dataTransfer:transfer})); return true})()`) } const within = limit => value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value < limit ;(async () => { // 受控上游:首个响应刻意延迟,保证「真实回合」到达前有一段可观测的占位窗口。 server = createServer(async (req, res) => { let raw = '' for await (const part of req) raw += part const body = JSON.parse(raw || '{}') // 「停止验收」场景需要一个更长的回合,方便在运行中切走/切回再停止。 await pause(JSON.stringify(body).includes('停止验收') ? 15000 : 600) const content = `LOCAL_REPLY_${body.model}` if (!body.stream) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content } }] })); return } res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }) const chunk = delta => `data: ${JSON.stringify({ id: 'local-test', object: 'chat.completion.chunk', created: 1, model: body.model, choices: [{ index: 0, delta, finish_reason: null }] })}\n\n` res.write(chunk({ role: 'assistant', content })) res.write(`data: ${JSON.stringify({ id: 'local-test', object: 'chat.completion.chunk', created: 1, model: body.model, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 } })}\n\ndata: [DONE]\n\n`) res.end() }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const baseUrl = `http://127.0.0.1:${server.address().port}/v1` await fs.mkdir(process.env.YCODE_USER_DATA_DIR, { recursive: true }) app.setPath('userData', process.env.YCODE_USER_DATA_DIR) app.setPath('sessionData', process.env.YCODE_USER_DATA_DIR) // Register every production handler normally, but let this scenario reject one // chat:send after the real renderer has already created its session. The // wrapper runs before production startup, so no turn or persisted message exists. const originalIpcHandle = ipcMain.handle ipcMain.handle = function handleWithControlledChatFailure(channel, listener) { if (channel !== 'chat:send') return originalIpcHandle.call(ipcMain, channel, listener) return originalIpcHandle.call(ipcMain, channel, async (event, payload) => { if (controlledFailureCalls === 0 && typeof payload?.text === 'string' && payload.text.includes(preStartFailureMarker)) { controlledFailureCalls += 1 controlledFailureSessionId = payload.sessionId throw new Error('QA controlled chat:send failure before turn startup') } return listener(event, payload) }) } try { await import(pathToFileURL(path.resolve('out/main/index.mjs')).href) // Production registers IPC handlers inside app.whenReady(), after module evaluation. await app.whenReady() } finally { ipcMain.handle = originalIpcHandle } await app.whenReady() for (let index = 0; index < 120; index++) { main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode') if (main && !main.webContents.isLoading()) break await pause(50) } assert.ok(main) main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) }) await wait('!!window.ycode && !!document.querySelector(".chat-pane")', 'main renderer') await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-one', name: 'Local One', providerId: 'custom', model: 'model-one', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true, maxInputTokens: 64000, maxOutputTokens: 4096 })})`) await read('window.ycode.models.select("local-one")') await step('home-surface') await wait(`!!document.querySelector('.wb-home-composer') && !!document.querySelector('.wb-composer__input')`, 'home composer') const sessionCountBefore = (await read('window.ycode.session.list()')).length check('no session exists before the first send', sessionCountBefore, 0) await step('send-new-task') const message = 'QA 新任务发送验收' await read(`(()=>{const marks={};const text=${JSON.stringify(message)}; const state=()=>({conversation:!!document.querySelector('.cr-message-list'), user:[...document.querySelectorAll('.cr-self-bubble')].some(node=>node.textContent.includes(text)), prepare:document.body.textContent.includes('正在启动对话运行环境…'), duration:!!document.querySelector('.cr-streaming-duration'), reply:document.body.textContent.includes('LOCAL_REPLY_model-one')}); const tick=()=>{const now=performance.now();const value=state(); if(!marks.conversation&&value.conversation)marks.conversation=now; if(!marks.userMessage&&value.user)marks.userMessage=now; if(!marks.prepareText&&value.prepare)marks.prepareText=now; if(marks.prepareText&&!marks.prepareCleared&&!value.prepare)marks.prepareCleared=now; if(!marks.duration&&value.duration)marks.duration=now; if(!marks.reply&&value.reply)marks.reply=now;}; window.__qa={t0:0,marks};window.__qaTick=tick; const observer=new MutationObserver(tick);observer.observe(document.documentElement,{subtree:true,childList:true,characterData:true}); tick();return true})()`) await fill(message) await wait(`(()=>{const button=document.querySelector('button[aria-label="发送"]');return !!button&&!button.disabled})()`, 'send button enabled') await read(`(()=>{window.__qa.t0=performance.now();document.querySelector('button[aria-label="发送"]').click();return true})()`) await wait(`!!(window.__qa.marks.conversation&&window.__qa.marks.userMessage&&window.__qa.marks.prepareText)`, 'optimistic marks') await pause(150) snapshots.optimistic = await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null); return {t0:window.__qa.t0,marks:{...window.__qa.marks},inputValue:input?input.value:null, userBubbles:[...document.querySelectorAll('.cr-self-bubble')].map(node=>node.textContent.trim()), home:!!document.querySelector('.wb-home-composer'),conversation:!!document.querySelector('.cr-message-list'), prepare:document.body.textContent.includes('正在启动对话运行环境…'),duration:!!document.querySelector('.cr-streaming-duration')}})()`) const marks = snapshots.optimistic.marks const t0 = snapshots.optimistic.t0 check('send switches to the conversation view immediately', marks.conversation - t0, within(500)) check('user message is visible immediately after send', marks.userMessage - t0, within(500)) check('startup placeholder appears immediately after send', marks.prepareText - t0, within(500)) check('streaming duration is running during the wait', marks.duration - t0, within(500)) check('composer draft is cleared at send time', snapshots.optimistic.inputValue, '') check('home surface is left at send time', snapshots.optimistic.home, false) check('optimistic user message is on screen', snapshots.optimistic.userBubbles.some(text => text.includes(message)), true) check('startup placeholder text is the recovered acquiring_runtime string', snapshots.optimistic.prepare, true) await step('real-turn') const sessions = await read('window.ycode.session.list()') const sessionId = sessions[0] && sessions[0].id check('exactly one session was created for the send', sessions.length, 1) const done = await wait(`window.ycode.session.messages(${JSON.stringify(sessionId)}).then(list=>{const last=list.at(-1);return last&&last.status==='done'&&last})`, 'turn finished') check('local upstream answered the turn', done.content, 'LOCAL_REPLY_model-one') await wait(`document.body.textContent.includes('LOCAL_REPLY_model-one')`, 'reply rendered') await pause(250) snapshots.finished = await read(`(()=>({userBubbles:[...document.querySelectorAll('.cr-self-bubble')].map(node=>node.textContent.trim()), prepare:document.body.textContent.includes('正在启动对话运行环境…'),duration:!!document.querySelector('.cr-streaming-duration'), reply:document.body.textContent.includes('LOCAL_REPLY_model-one'),marks:{...window.__qa.marks}}))()`) const finalMarks = snapshots.finished.marks check('startup placeholder is cleared before the reply arrives', finalMarks.prepareCleared !== undefined && finalMarks.prepareCleared <= (finalMarks.reply ?? Number.POSITIVE_INFINITY), true) check('startup placeholder is gone after the turn', snapshots.finished.prepare, false) check('streaming placeholder is replaced by the finished footer', snapshots.finished.duration, false) check('exactly one user message after the turn (no optimistic duplicate)', snapshots.finished.userBubbles.length, 1) check('reply is rendered in the conversation', snapshots.finished.reply, true) check('real turn took longer than the optimistic render (placeholder was needed)', finalMarks.reply - finalMarks.userMessage, value => typeof value === 'number' && value > 300) snapshots.timings = { conversationMs: finalMarks.conversation - t0, userMessageMs: finalMarks.userMessage - t0, prepareTextMs: finalMarks.prepareText - t0, durationMs: finalMarks.duration - t0, prepareClearedMs: finalMarks.prepareCleared === undefined ? null : finalMarks.prepareCleared - t0, replyMs: finalMarks.reply === undefined ? null : finalMarks.reply - t0 } await step('create-success-send-failure') const originalSessionSnapshot = { sessions: await read('window.ycode.session.list()'), messages: await read(`window.ycode.session.messages(${JSON.stringify(sessionId)})`) } const startFailureWorkspace = await read('window.ycode.workspace.get()') await fs.mkdir(startFailureWorkspace.root, { recursive: true }) const startFailureReferencePath = path.join(startFailureWorkspace.root, 'qa-start-failure-reference.txt') await fs.writeFile(startFailureReferencePath, 'WORKSPACE_REFERENCE_MUST_SURVIVE_CREATE_SUCCESS_SEND_FAILURE\n') await read(`document.querySelector('[title="新建任务"]').click()`) await wait(`!!document.querySelector('.wb-home-composer')`, 'home surface for create-success send failure') const startFailureText = `${preStartFailureMarker} 正文草稿必须保留` const startFailureAttachmentName = 'qa-start-failure-attachment.txt' await dropDraftFixtures(startFailureAttachmentName, 'ATTACHMENT_MUST_SURVIVE', startFailureReferencePath) await wait(`(()=>{const names=[...document.querySelectorAll('.wb-composer__attachment-name')].map(node=>node.textContent); const refs=[...document.querySelectorAll('.wb-composer__resources .wb-composer__resource')].map(node=>node.textContent); return names.includes(${JSON.stringify(startFailureAttachmentName)})&&refs.some(text=>text.includes('qa-start-failure-reference.txt'))})()`, 'attachment and workspace reference staged') await fill(startFailureText) await wait(`[...document.querySelectorAll('.wb-composer__input')].some(node=>node.offsetParent!==null&&node.value===${JSON.stringify(startFailureText)})`, 'durable draft text entered') await read(`document.querySelector('button[aria-label="发送"]').click()`) await wait(`!!(document.querySelector('.wb-composer__error[role="alert"]')||document.querySelector('.msg__status--error'))`, 'controlled pre-start failure surfaced') await wait(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null); const names=[...document.querySelectorAll('.wb-composer__attachment-name')].map(node=>node.textContent); const refs=[...document.querySelectorAll('.wb-composer__resources .wb-composer__resource')].map(node=>node.textContent); return input?.value===${JSON.stringify(startFailureText)} &&names.includes(${JSON.stringify(startFailureAttachmentName)}) &&refs.some(text=>text.includes('qa-start-failure-reference.txt'))})()`, 'durable draft restored after controlled failure') await pause(250) const sessionsAfterControlledFailure = await read('window.ycode.session.list()') snapshots.createSuccessSendFailure = await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null); const picker=document.querySelector('.cr-workspace-picker'); const composerError=document.querySelector('.wb-composer__error');const statusError=document.querySelector('.msg__status--error'); return {inputValue:input?.value??null,home:!!document.querySelector('.wb-home-composer'), attachments:[...document.querySelectorAll('.wb-composer__attachment-name')].map(node=>node.textContent), resources:[...document.querySelectorAll('.wb-composer__resources .wb-composer__resource')].map(node=>node.textContent), workspaceEnabled:!!picker&&!picker.disabled&&picker.getAttribute('aria-disabled')!=='true', error:composerError?.textContent??statusError?.textContent??null}})()`) check('chat:send failure consumed exactly one controlled request', controlledFailureCalls, 1) check('controlled failure happened after session creation', typeof controlledFailureSessionId === 'string' && controlledFailureSessionId.length > 0, true) check('empty failed session was atomically deleted', sessionsAfterControlledFailure.some(item => item.id === controlledFailureSessionId), false) check('existing completed session list remains unchanged', sessionsAfterControlledFailure, originalSessionSnapshot.sessions) check('existing completed session messages remain unchanged', await read(`window.ycode.session.messages(${JSON.stringify(sessionId)})`), originalSessionSnapshot.messages) check('controlled failure returns to home', snapshots.createSuccessSendFailure.home, true) check('controlled failure preserves body draft', snapshots.createSuccessSendFailure.inputValue, startFailureText) check('controlled failure preserves attachment draft', snapshots.createSuccessSendFailure.attachments.includes(startFailureAttachmentName), true) check('controlled failure preserves workspace reference draft', snapshots.createSuccessSendFailure.resources.some(text => text.includes('qa-start-failure-reference.txt')), true) check('controlled failure returns with an enabled workspace picker', snapshots.createSuccessSendFailure.workspaceEnabled, true) check('controlled failure error is visible', snapshots.createSuccessSendFailure.error, value => typeof value === 'string' && value.includes('QA controlled chat:send failure')) await read(`(()=>{document.querySelector('.cr-workspace-picker')?.click();return true})()`) await wait(`!!document.querySelector('.source-workspace-picker-portal [role="menu"]')`, 'recovered workspace menu opens') const recoveredWorkspaceMenu = await read(`(()=>{const menu=document.querySelector('.source-workspace-picker-portal [role="menu"]'); return {options:menu?menu.querySelectorAll('[data-workspace-option]').length:0, labels:menu?[...menu.querySelectorAll('[data-workspace-option]')].map(node=>node.textContent.trim()):[]}})()`) check('recovered new-task workspace menu is actionable', recoveredWorkspaceMenu.options >= 2, true) await read(`(()=>{document.querySelector('.cr-workspace-picker')?.click();return true})()`) await wait(`!document.querySelector('.source-workspace-picker-portal [role="menu"]')`, 'workspace menu closes') await read(`document.querySelector('button[aria-label="发送"]').click()`) await wait(`window.ycode.session.list().then(list=>list.length===2)`, 'retry creates a real session') const sessionsAfterRetry = await read('window.ycode.session.list()') const retrySessionId = sessionsAfterRetry.find(item => item.id !== sessionId)?.id check('retry creates one replacement session', typeof retrySessionId === 'string' && !!retrySessionId, true) const retryDone = await wait(`window.ycode.session.messages(${JSON.stringify(retrySessionId)}).then(list=>{const last=list.at(-1);return last&&last.status==='done'&&last})`, 'retry turn finishes') const retryMessages = await read(`window.ycode.session.messages(${JSON.stringify(retrySessionId)})`) const retryUser = retryMessages.find(item => item.role === 'user' && item.content?.includes(preStartFailureMarker)) snapshots.createSuccessSendFailureRetry = { sessionId: retrySessionId, assistant: retryDone, user: retryUser } check('retry keeps the body draft', retryUser?.content, startFailureText) check('retry commits the preserved attachment', retryUser?.attachments?.some(item => item.name === startFailureAttachmentName), true) check('retry commits the preserved workspace reference', retryUser?.resources?.some(item => item.endsWith('qa-start-failure-reference.txt')), true) check('retry reply is rendered', retryDone.content, 'LOCAL_REPLY_model-one') check('existing completed session list still unchanged after retry', await read('window.ycode.session.list()').then(list => list.find(item => item.id === sessionId)), originalSessionSnapshot.sessions.find(item => item.id === sessionId)) await step('send-failure') // 清空模型注册表:发送会在 chat:send 的模型解析阶段被拒绝(早于任何回合/网络请求), // 这是「发送失败」路径;同时避免误连外部网关。 const removed = await read(`window.ycode.models.list()`) for (const model of removed.models) await read(`window.ycode.models.remove(${JSON.stringify(model.id)})`) const registryAfterRemoval = await read(`window.ycode.models.list()`) check('model registry is empty for the failure case', registryAfterRemoval.models.length, 0) await read(`document.querySelector('[title="新建任务"]').click()`) await wait(`!!document.querySelector('.wb-home-composer')`, 'home surface after new task') const failureMessage = 'QA 发送失败草稿恢复' await fill(failureMessage) await wait(`(()=>{const button=document.querySelector('button[aria-label="发送"]');return !!button&&!button.disabled})()`, 'send enabled again') await read(`document.querySelector('button[aria-label="发送"]').click()`) // 发送失败的错误可能落在发送框(发送框自身状态)或会话区状态行(store error); // 首条消息失败时布局会从对话页切回新建页,发送框重挂载,因此两处都接受。 await wait(`!!(document.querySelector('.wb-composer__error[role="alert"]')||document.querySelector('.msg__status--error'))`, 'send error surfaced') await pause(250) snapshots.failure = await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null); const composerError=document.querySelector('.wb-composer__error');const statusError=document.querySelector('.msg__status--error'); return {inputValue:input?input.value:null, userBubbles:document.querySelectorAll('.cr-self-bubble').length, composerError:composerError?composerError.textContent:null,statusError:statusError?statusError.textContent:null, prepare:document.body.textContent.includes('正在启动对话运行环境…'),home:!!document.querySelector('.wb-home-composer')}})()`) check('failed send restores the draft text', snapshots.failure.inputValue, failureMessage) check('failed send leaves no optimistic user message', snapshots.failure.userBubbles, 0) check('failed send surfaces an error', snapshots.failure.composerError ?? snapshots.failure.statusError, value => typeof value === 'string' && /模型/.test(value)) check('failed send leaves no startup placeholder', snapshots.failure.prepare, false) check('failed send returns to the home surface', snapshots.failure.home, true) await step('stop-and-switch') await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-one', name: 'Local One', providerId: 'custom', model: 'model-one', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true, maxInputTokens: 64000, maxOutputTokens: 4096 })})`) await read('window.ycode.models.select("local-one")') const idsBeforeStopScenario = (await read('window.ycode.session.list()')).map(session => session.id) await read(`document.querySelector('[title="新建任务"]').click()`) await wait(`!!document.querySelector('.wb-home-composer')`, 'home surface for the stop scenario') const stopMessage = 'QA 停止验收' await fill(stopMessage) await wait(`(()=>{const button=document.querySelector('button[aria-label="发送"]');return !!button&&!button.disabled})()`, 'send enabled for the stop scenario') await read(`document.querySelector('button[aria-label="发送"]').click()`) await wait(`document.body.textContent.includes('正在启动对话运行环境…')`, 'startup placeholder for the stop scenario') const stopSessionId = (await read('window.ycode.session.list()')).map(session => session.id) .find(id => !idsBeforeStopScenario.includes(id)) check('stop scenario created exactly one new session', typeof stopSessionId === 'string' && !!stopSessionId, true) await wait(`!!document.querySelector('button[aria-label="停止"]')`, 'stop button available while running') // 运行中切到新任务再切回:消息与运行状态不丢。 await read(`document.querySelector('[title="新建任务"]').click()`) await wait(`!!document.querySelector('.wb-home-composer') && !document.querySelector('.cr-self-bubble')`, 'switched to a new task mid-turn') await read(`(()=>{const row=[...document.querySelectorAll('[data-session-id]')].find(node=>node.getAttribute('data-session-id')===${JSON.stringify(stopSessionId)});if(row)row.click();return !!row})()`) await wait(`[...document.querySelectorAll('.cr-self-bubble')].some(node=>node.textContent.includes(${JSON.stringify(stopMessage)}))`, 'session restored after switching back') snapshots.switchedBack = await read(`(()=>({user:[...document.querySelectorAll('.cr-self-bubble')].some(node=>node.textContent.includes(${JSON.stringify(stopMessage)})), streaming:!!document.querySelector('.wb-cr-streaming-footer'),stop:!!document.querySelector('button[aria-label="停止"]')}))()`) check('switching tasks mid-turn keeps the message and the running turn', snapshots.switchedBack.user && snapshots.switchedBack.streaming && snapshots.switchedBack.stop, true) await step('workbench-escape-precedence') await read(`window.dispatchEvent(new CustomEvent('ycode:open-artifacts',{detail:{sessionId:${JSON.stringify(stopSessionId)}}}))`) await wait(`!!document.querySelector('.yc-workbench')`, 'right workbench opens during generation') await read(`(()=>{const button=document.querySelector('.yc-workbench button[aria-label="全屏"]'); if(!button)throw new Error('Missing fullscreen button');button.click();return true})()`) await wait(`document.querySelector('.yc-workbench')?.classList.contains('yc-workbench--fullscreen')===true`, 'right workbench enters fullscreen') const escapeFocusInsideWorkbench = await read(`(()=>{const button=document.querySelector('.yc-workbench button[aria-label="退出全屏"]'); button?.focus();return !!button&&document.activeElement===button&&!!document.activeElement.closest('.yc-workbench')})()`) check('fullscreen Escape starts with focus inside the workbench', escapeFocusInsideWorkbench, true) main.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }) main.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' }) await wait(`document.querySelector('.yc-workbench')?.classList.contains('yc-workbench--fullscreen')===false`, 'fullscreen Escape exits fullscreen') await pause(250) const statusAfterFullscreenEscape = await read(`window.ycode.session.messages(${JSON.stringify(stopSessionId)}).then(list=>list.at(-1)?.status)`) snapshots.fullscreenEscape = { fullscreen: await read(`document.querySelector('.yc-workbench')?.classList.contains('yc-workbench--fullscreen')===true`), assistantStatus: statusAfterFullscreenEscape, stopVisible: await read(`!!document.querySelector('button[aria-label="停止"]')`) } check('fullscreen Escape does not cancel the running turn', snapshots.fullscreenEscape.fullscreen === false && snapshots.fullscreenEscape.assistantStatus === 'streaming' && snapshots.fullscreenEscape.stopVisible === true, true) const normalEscapeFocusInsideWorkbench = await read(`(()=>{const button=document.querySelector('.yc-workbench button[aria-label="收起面板"]'); button?.focus();return !!button&&document.activeElement===button&&!!document.activeElement.closest('.yc-workbench')})()`) check('non-fullscreen Escape starts with focus inside the workbench', normalEscapeFocusInsideWorkbench, true) main.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }) main.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' }) await wait(`window.ycode.session.messages(${JSON.stringify(stopSessionId)}).then(list=>list.at(-1)?.status==='cancelled')`, 'turn cancelled') await pause(250) snapshots.stopped = await read(`(()=>({userBubbles:[...document.querySelectorAll('.cr-self-bubble')].map(node=>node.textContent.trim()), cancelled:!!document.querySelector('.cr-cancelled-indicator'),streaming:!!document.querySelector('.wb-cr-streaming-footer'), prepare:document.body.textContent.includes('正在启动对话运行环境…')}))()`) check('cancelled turn keeps exactly one user message', snapshots.stopped.userBubbles.length, 1) check('cancelled turn shows the cancelled indicator', snapshots.stopped.cancelled, true) check('cancelled turn leaves no streaming placeholder', snapshots.stopped.streaming, false) check('cancelled turn leaves no startup placeholder', snapshots.stopped.prepare, false) check('no renderer error logs', rendererErrors, []) const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', scope: 'Real isolated YCode App/preload/IPC/renderer with real OMP and a controlled local upstream; new-task send timing, startup placeholder, real-turn replacement and failure recovery measured in the live window.', sourceEvidence: ['ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb', 'docs/对话启动提示文案-2026-10-16.md', '.tmp/todo08-wb-new-task/notes.md'], snapshots, checks, rendererErrors } await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2)) console.log('NEW_TASK_SWITCH_' + report.result + ' ' + directory) for (const failure of checks.filter(item => !item.pass)) console.log('NEW_TASK_SWITCH_FAILURE ' + JSON.stringify(failure)) server.close() app.exit(report.result === 'PASS' ? 0 : 1) })().catch(async error => { await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, rendererErrors }, null, 2)) console.error(error) server?.close() app.exit(1) }) }