// Isolated real renderer with declared IPC snapshots; no model service or user data is used. const fs = require('node:fs/promises') const path = require('node:path') if (!process.versions.electron) { ;(async () => { const directory = await fs.mkdtemp(path.resolve('.tmp/subagent-display-')) const data = path.join(directory, 'data') const home = path.join(directory, 'home') const workspace = path.join(directory, 'workspace') await Promise.all([data, home, workspace].map(folder => fs.mkdir(folder))) const now = Date.now() const sessionId = 'subagent-mock-preview' const user = { id: 'mock-user', role: 'user', content: '并行检查一下当前项目的对话界面和子智能体调度。', createdAt: now - 46000, status: 'done' } const session = { id: sessionId, title: '子智能体展示 · Mock', cwd: workspace, modelId: 'mock-model', createdAt: now - 46000, updatedAt: now, messages: [user] } const other = { id: 'subagent-other-preview', title: '其他会话', cwd: workspace, modelId: 'mock-model', createdAt: now - 60000, updatedAt: now - 50000, messages: [{ id: 'other-assistant', role: 'assistant', content: '这是另一个会话的历史记录。', status: 'done', createdAt: now - 50000 }] } await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions: [session, other] })) await fs.writeFile(path.join(data, 'config.json'), JSON.stringify({ baseUrl: 'http://127.0.0.1:9/v1', model: 'gpt-6.1-sol', workspaceRoot: workspace, agentSettings: { teamManagement: true } })) await fs.writeFile(path.join(data, 'models.json'), JSON.stringify({ version: 1, selectedModelId: 'mock-model', models: [{ id: 'mock-model', name: 'GPT-6.1 Sol', model: 'gpt-6.1-sol', providerId: 'custom', protocol: 'openai', baseUrl: 'http://127.0.0.1:9/v1', supportsToolCall: true, supportsReasoning: true, contextWindowTokens: 500000, maxOutputTokens: 128000, tokenBudgetVersion: 2 }] })) await fs.writeFile(path.join(directory, 'fixture.json'), JSON.stringify({ now, session, user })) const env = { ...process.env, YCODE_SUBAGENT_PREVIEW: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home, YCODE_DISABLE_SYSTEM_INTEGRATION: '1', 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; process.stdout.write(bytes) }) child.stderr.on('data', bytes => { output += bytes }) const timer = setTimeout(() => child.kill(), 60000) 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(-5000)) process.exitCode = code ?? 1 }) })().catch(error => { console.error(error); process.exitCode = 1 }) } else { const { app, BrowserWindow, ipcMain } = require('electron') const { pathToFileURL } = require('node:url') const assert = require('node:assert/strict') const directory = process.env.YCODE_SUBAGENT_PREVIEW app.setPath('userData', path.join(directory, 'data')) app.setPath('sessionData', path.join(directory, 'data')) app.disableHardwareAcceleration() BrowserWindow.prototype.show = function () {} const pause = ms => new Promise(done => setTimeout(done, ms)) const errors = [] const checks = [] const check = (label, condition) => { assert.ok(condition, label); checks.push(label); console.log('DISPLAY_CHECK ' + label) } const until = async (operation, label) => { for (let index = 0; index < 300; index++) { const value = await operation() if (value) return value await pause(50) } throw Error('Timeout: ' + label) } app.whenReady().then(async () => { const { now, session, user } = JSON.parse(await fs.readFile(path.join(directory, 'fixture.json'), 'utf8')) const common = { access: 'read-only', files: [], modelName: 'GPT-6.1 Sol', createdAt: now - 46000, tools: [] } const agents = [ { ...common, id: 'mock-agent-ui', name: '对话界面核对', task: '检查对话区域的字体、间距和工具事件展示,整理差异及对应文件。', status: 'running', startedAt: now - 33000, events: [ { id: 'mock-reasoning', kind: 'reasoning', text: '先检查消息渲染组件与样式,再核对事件折叠和模型信息的布局。' }, { id: 'mock-read', kind: 'tool', name: 'read_file', status: 'ok', text: 'src/renderer/src/components/MessageBubble.tsx\n已读取消息渲染、事件折叠和子智能体卡片入口。' }, { id: 'mock-read-style', kind: 'tool', name: 'read_file', status: 'running', text: '正在读取 src/renderer/src/components/subagent-card.css …' } ], usage: { inputTokens: 8600, outputTokens: 740, totalTokens: 9340 } }, { ...common, id: 'mock-agent-scheduler', name: '调度逻辑检查', task: '核对 5 个并发名额、排队和取消的行为。', status: 'completed', startedAt: now - 44000, finishedAt: now - 12000, events: [{ id: 'mock-check', kind: 'text', text: '已核对全局并发限制和任务清理逻辑。' }], result: '已核对:最多同时运行 5 个子智能体,超出上限进入队列;停止成员后释放名额。', usage: { inputTokens: 6200, outputTokens: 920, totalTokens: 7120 } }, { ...common, id: 'mock-agent-summary', name: '验收清单整理', task: '等待空闲名额后整理本轮检查结果。', status: 'queued', events: [] } ] agents[0].tools = [ { id: 'mock-read', name: 'read_file', status: 'ok', args: { path: 'src/renderer/src/components/MessageBubble.tsx' }, argsRaw: '{"path":"src/renderer/src/components/MessageBubble.tsx"}', output: '消息渲染与事件折叠入口。' }, { id: 'mock-read-style', name: 'read_file', status: 'running', args: { path: 'src/renderer/src/workbuddy/subagents/source.css' }, argsRaw: '{"path":"src/renderer/src/workbuddy/subagents/source.css"}' } ] const assistant = { id: 'mock-assistant', role: 'assistant', content: '我已拆分检查任务,正在并行核对。可以展开输入框上方的成员入口查看各自进度。', createdAt: now - 45000, turnStartedAt: now - 46000, status: 'streaming', requestId: 'mock-run', tools: [], parts: [{ type: 'text', text: '我已拆分检查任务,正在并行核对。可以展开输入框上方的成员入口查看各自进度。' }, ...agents.map(agent => ({ type: 'subagent', agent }))] } const snapshot = { runId: 'mock-run', assistantMessageId: assistant.id, sessionId: session.id, sequence: 1, messages: [user, assistant] } delete process.env.YCODE_DISABLE_GPU delete process.env.YCODE_DISABLE_GPU_SANDBOX await import(pathToFileURL(path.resolve('out/main/index.mjs')).href) const main = await until(() => BrowserWindow.getAllWindows().find(window => window.getTitle() === 'YCode' && !window.webContents.isLoading()), 'main') main.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) errors.push(event.message) }) ipcMain.removeHandler('chat:active-run') let hydrationDelay = 0 ipcMain.handle('chat:active-run', async (_event, id) => { if (id === session.id && hydrationDelay) await pause(hydrationDelay); return id === session.id ? snapshot : null }) await main.loadFile(path.resolve('out/renderer/index.html'), { query: { taskId: session.id } }) const read = code => main.webContents.executeJavaScript(code) main.setContentSize(1550, 970) await until(() => read('document.querySelector(".conversation-team-collaboration__trigger")?.textContent.includes("3 位团队成员")'), 'mock input-area members') check('one collapsed member entry lives above the input with no reply duplicates', await read('document.querySelectorAll(".composer__area .conversation-team-collaboration__trigger").length===1&&!document.querySelector(".chat-scroll .conversation-team-collaboration")&&!document.querySelector(".conversation-team-collaboration__member")&&!document.querySelector(".yc-subagent")')) await read('document.fonts.ready.then(() => true)') await pause(400) const placement = await read('(() => { const area=document.querySelector("[data-testid=composer-task-activity]"), input=document.querySelector(".composer .wb-composer"); const a=area.getBoundingClientRect(), b=input.getBoundingClientRect(); return {above:a.bottom<=b.top+1,left:Math.abs(a.left-b.left),width:Math.abs(a.width-b.width),outsideTimeline:!area.closest(".chat-scroll"),owner:area.dataset.owner} })()') check('member entry shares input width and stays outside the transcript scroller', placement.above && placement.left < 1 && placement.width < 1 && placement.outsideTimeline && placement.owner === session.id) await fs.writeFile(path.join(directory, 'subagents-collapsed.png'), (await main.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })).toPNG()) await read('document.querySelector(".conversation-team-collaboration__trigger").click();true') await until(() => read('document.querySelectorAll(".conversation-team-collaboration__member").length===3'), 'three members') check('member list reflects running, queued and completed state without changing task identity', await read('Array.from(document.querySelectorAll(".conversation-team-collaboration__member")).map(item=>item.dataset.status).join(",")==="running,queued,completed"&&document.querySelector(".conversation-team-collaboration__member[data-status=queued]").textContent.includes("任务三")')) await read('document.querySelector(".conversation-team-collaboration__member[data-status=running]").click();true') await until(() => read('!!document.querySelector(".conversation-member-detail")'), 'member detail') check('selection opens in the right detail slot with the matching member identity', await read('document.querySelector("[data-view-id=detail-panel] .conversation-member-detail")?.dataset.subagentId==="mock-agent-ui"&&document.querySelector(".conversation-team-collaboration__member[aria-current=true]")?.dataset.subagentId==="mock-agent-ui"')) const themeMetrics = await read('({header:getComputedStyle(document.querySelector(".conversation-member-detail__header")).minHeight,space10:getComputedStyle(document.querySelector(".conversation-member-detail")).getPropertyValue("--wb-spacing-10"),space2:getComputedStyle(document.querySelector(".conversation-member-detail")).getPropertyValue("--wb-spacing-2"),background:getComputedStyle(document.querySelector(".conversation-member-detail")).getPropertyValue("--wb-bg-primary")})') console.log('THEME_METRICS ' + JSON.stringify(themeMetrics)) check('detail inherits source theme spacing and surface tokens', Math.abs(parseFloat(themeMetrics.header) - parseFloat(themeMetrics.space10) - parseFloat(themeMetrics.space2)) < .1 && themeMetrics.background.trim().length > 0) await read('document.querySelector(".conversation-member-detail__assignment-toggle").click();true') check('assignment can expand independently from the child message timeline', await read('document.querySelector(".conversation-member-detail__assignment-detail").textContent.includes("检查对话区域")')) await read('document.querySelector(".conversation-member-detail .cr-live-event-feed .cr-collapse__header")?.click();true') await pause(700) const bounds = await read('Array.from(document.querySelectorAll(".conversation-team-collaboration__member")).map(item => ({ status: item.dataset.status, text: item.innerText, y: item.getBoundingClientRect().y, bottom: item.getBoundingClientRect().bottom }))') assert.equal(await read('document.querySelector(".conversation-team-collaboration__trigger").getAttribute("aria-expanded")'), 'true') await fs.writeFile(path.join(directory, 'subagents-expanded.png'), (await main.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })).toPNG()) await read('localStorage.setItem("ycode:theme","dark");window.dispatchEvent(new StorageEvent("storage",{key:"ycode:theme",newValue:"dark"}));true') await until(() => read('document.querySelector(".conversation-member-detail")?.dataset.crTheme==="dark"'), 'dark member detail') await pause(450) check('child thinking block uses the dark conversation styles', await read('getComputedStyle(document.querySelector(".conversation-member-detail .cr-reasoning")).backgroundColor!=="rgb(255, 255, 255)"')) await fs.writeFile(path.join(directory, 'subagents-dark.png'), (await main.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })).toPNG()) const updated = { ...agents[0], events: [...agents[0].events, { id: 'live-update', kind: 'text', text: '已完成消息组件核对,正在检查状态同步。' }] } main.webContents.send('chat:event', { runId: snapshot.runId, sessionId: session.id, sequence: 2, type: 'part', part: { type: 'subagent', agent: updated } }) await until(() => read('document.querySelector(".conversation-member-detail").textContent.includes("已完成消息组件核对")'), 'live child text update') assert.equal(await read('document.querySelectorAll(".conversation-team-collaboration__member").length'), 3) check('new child text streams into the selected detail without duplicate members', true) check('member detail follows the dark theme', await read('getComputedStyle(document.querySelector(".conversation-member-detail")).backgroundColor===getComputedStyle(document.querySelector(".chat-pane")).backgroundColor')) main.setContentSize(760, 850) await pause(700) const overflow = await read('document.documentElement.scrollWidth > window.innerWidth') assert.equal(overflow, false, 'narrow member view has no page overflow') check('narrow view uses the existing right drawer with no page overflow', await read('!!document.querySelector(".app-shell__detail-slot--drawer .conversation-member-detail")') && !overflow) await fs.writeFile(path.join(directory, 'subagents-narrow.png'), (await main.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })).toPNG()) main.setContentSize(1550, 970) await pause(350) await read('document.querySelector("[data-session-id=subagent-other-preview]").click();true') await until(() => read('document.body.textContent.includes("这是另一个会话的历史记录")&&!document.querySelector(".conversation-member-detail")'), 'other conversation') check('switching conversation never leaks the selected child or its input entry', await read('!document.querySelector("[data-testid=composer-task-activity]")')) hydrationDelay = 500 await read('document.querySelector("[data-session-id=subagent-mock-preview]").click();true') await until(() => read('document.querySelector(".conversation-member-detail")?.dataset.subagentId==="mock-agent-ui"'), 'restore selected member') await pause(700) check('selected member survives conversation rehydration', await read('document.querySelector(".conversation-member-detail")?.dataset.subagentId==="mock-agent-ui"')) // Terminal replay and a later branch snapshot must both update an already-open detail. main.webContents.send('chat:event', { runId: snapshot.runId, sessionId: session.id, sequence: 3, type: 'cancelled' }) await until(() => read('document.querySelector(".conversation-member-detail")?.dataset.status==="cancelled"'), 'cancel replay') check('parent cancellation settles the open child and removes the stop action', await read('!document.querySelector(".yc-member-stop")')) const nextAssistant = { id: 'next-assistant', role: 'assistant', content: '新一轮开始,尚未调度成员。', parts: [], tools: [], status: 'streaming', requestId: 'next-run', createdAt: now } main.webContents.send('chat:run-started', { ...snapshot, runId: 'next-run', assistantMessageId: nextAssistant.id, sequence: 1, messages: [user, { ...assistant, status: 'done' }, { ...user, id: 'next-user', createdAt: now }, nextAssistant] }) await until(() => read('document.body.textContent.includes("新一轮开始")&&!document.querySelector("[data-testid=composer-task-activity]")'), 'new turn clears previous team') check('a new turn never displays the previous reply team in the input area', true) main.webContents.send('chat:event', { runId: 'next-run', sessionId: session.id, sequence: 2, type: 'part', part: { type: 'subagent', agent: { ...agents[0], id: 'next-child', name: '新一轮成员' } } }) await until(() => read('document.querySelector(".conversation-team-collaboration__trigger")?.textContent.includes("1 位团队成员")'), 'new team appears') check('new team starts collapsed and binds to its own assistant', await read('document.querySelector(".conversation-team-collaboration").dataset.assistantId==="next-assistant"&&document.querySelector(".conversation-team-collaboration__trigger").getAttribute("aria-expanded")==="false"')) const branch = { ...snapshot, runId: 'mock-branch-run', sequence: 4, messages: [user, { ...assistant, parts: [{ type: 'text', text: '已回退到不含子任务的分支。' }], content: '已回退到不含子任务的分支。', status: 'done' }] } main.webContents.send('chat:run-started', branch) await until(() => read('document.body.textContent.includes("已回退到不含子任务的分支")&&!document.querySelector(".conversation-member-detail")'), 'branch cleanup') check('a branch without the child closes stale detail and removes the input entry', await read('!document.querySelector("[data-testid=composer-task-activity]")')) check('real renderer reports no errors', errors.length === 0) await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, mock: true, rendererErrors: errors, placement, cards: bounds, screenshot: 'subagents-expanded.png' }, null, 2)) console.log('SUBAGENT_PREVIEW ' + path.join(directory, 'subagents-expanded.png')) app.exit(0) }).catch(async error => { await fs.writeFile(path.join(directory, 'error.txt'), error.stack || String(error)) console.error(error) app.exit(1) }) }