mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:41:57 +00:00
00f8dd78a9
图上一个节点连出多条边就是 fan-out,LangGraph 在同一个 superstep 里并发执行
——这一直是真并发。但在时间线上它们只是穿插出现的几行,"这三路是同时跑的、
因此省下了 3.6 秒"一个字都没说。上一轮给协作团队做了泳道,这条同样的信息
在 fan-out 这边还是缺的。
现在按时间跨度把同时跑的节点收进一个分组,标题写明几路、meta 写明省了多少:
⑂ 3 路并行 合计 6.6s,实际 3.0s
├ 查销售库 2.2s
├ 查库存库 3.0s
└ 查客户库 1.5s
**判据是"同一批出发",不是"跨度重叠"。** 第一版按包含关系排除嵌套,结果漏掉了
先跑完的那一路:三路同时开始但先后结束时,长的那条在时间上确实"包含"短的。
实测 leg0 [0.05,2.21] / leg1 [0.05,3.01] / leg2 [0.05,1.51],被漏掉的正是
最快的 leg2——而漏掉最快那路会让"省下多少"算少。改成看开始时刻是否落在同一批
(250ms 窗口),fan-out 的几路实测相差不到 1ms,子工作流那种真嵌套则是父节点
明显更早开始,自然分得开。
组内要求两两都同批,而不是链式传递:否则 A|B 重叠、B|C 重叠但 A|C 不重叠的
三个节点也会被串成一组,而它们并不是同时在跑。瞬时节点(<30ms)不参与——
它们谁跟谁都"重叠",是噪音不是信息。
**误报比漏报更糟**:它会让人以为省了时间。所以断言里专门守了反向——db、human、
loop_approve 三份顺序执行的样本一个都不能被判成并行。
fixtures 里新增的 fanout 是真跑出来的,不是手写的:三路分别 sleep 1.8 / 2.6 /
1.1 秒,事件原样收进来,所以 6.6s / 3.0s 这两个数和后端量到的是同一份。
验证:check-decode 新增 6 条(含三份顺序样本的反向断言);check-stream 新增
10 条,宽窄两栏都验了不溢出;后端 276 passed 不受影响;tsc 干净。
272 lines
14 KiB
JavaScript
272 lines
14 KiB
JavaScript
// AssistantStream 的渲染回归检查。
|
|
//
|
|
// 解码器的检查(check-decode.mjs)守的是"翻译对不对",这里守的是"画出来
|
|
// 对不对"——两者能各自通过而合起来是坏的:Step 里字段都对,组件却把它渲染成
|
|
// 一屏转义 JSON,或者在 360px 窄栏里把页面撑得横向滚动。
|
|
//
|
|
// 用真浏览器跑真组件,数据还是 fixtures.json 里那批真实事件。
|
|
// 跑之前前端得起着:./scripts/dev.sh
|
|
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
|
|
|
|
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
|
|
// 不用 playwright install:它既下不动也会动到已有缓存。系统 Chrome 就够了。
|
|
const CHROME = process.env.CHROME_PATH
|
|
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
|
const CASES = ['db', 'think', 'human', 'issue', 'failed', 'loop_approve', 'supervisor']
|
|
|
|
let failed = 0
|
|
const check = (name, cond, detail = '') => {
|
|
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
|
|
if (!cond) failed++
|
|
}
|
|
|
|
const browser = await chromium.launch({ executablePath: CHROME })
|
|
|
|
for (const kind of CASES) {
|
|
console.log(`\n=== ${kind} ===`)
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
|
|
const errors = []
|
|
page.on('pageerror', (e) => errors.push(e.message))
|
|
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()) })
|
|
await page.goto(`${WEB}/preview.html?case=${kind}`, { waitUntil: 'networkidle' })
|
|
await page.waitForTimeout(300)
|
|
|
|
const text = await page.locator('body').innerText()
|
|
// 404 是 favicon,和组件无关
|
|
const real = errors.filter((e) => !e.includes('404'))
|
|
check('没有运行时报错', real.length === 0, real.join(' | '))
|
|
check('有内容渲染出来', text.length > 40, `${text.length} 字`)
|
|
check('没有把转义 JSON 直接吐出来', !text.includes('\\"columns\\"'))
|
|
check('没有 0ms 噪音', !/\b0ms\b/.test(text))
|
|
check('没有裸事件名', !/\b(node|run|llm|tool)\.(started|finished|start|end)\b/.test(text))
|
|
|
|
// 360px 窄栏是画布右栏的真实宽度。这里溢出,右栏就会横向滚动
|
|
const overflow = await page.evaluate(() => {
|
|
const el = document.documentElement
|
|
return el.scrollWidth - el.clientWidth
|
|
})
|
|
check('页面不横向溢出', overflow <= 0, `${overflow}px`)
|
|
|
|
await page.close()
|
|
}
|
|
|
|
console.log('\n=== 展开交互 ===')
|
|
{
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
|
|
await page.goto(`${WEB}/preview.html?case=db`, { waitUntil: 'networkidle' })
|
|
await page.waitForTimeout(300)
|
|
const before = await page.locator('body').innerText()
|
|
check('收起时不显示 SQL', !before.includes('SELECT'))
|
|
await page.locator('button', { hasText: '在 bi 上查询数据' }).first().click()
|
|
await page.waitForTimeout(250)
|
|
const after = await page.locator('body').innerText()
|
|
check('展开后能看到 SQL 原文', after.includes('SELECT') && after.includes('ANALYTICS'))
|
|
check('展开后能看到结果表', after.includes('factory_code'))
|
|
await page.close()
|
|
}
|
|
|
|
console.log('\n=== Markdown 渲染 ===')
|
|
{
|
|
// 用库里导出的**真实输出**,不是编的样本:模型写什么才是要渲染的,
|
|
// 按 CommonMark 规范挑测例只会测到用不上的角落
|
|
const page = await browser.newPage({ viewport: { width: 1100, height: 900 } })
|
|
const errors = []
|
|
page.on('pageerror', (e) => errors.push(e.message))
|
|
await page.goto(`${WEB}/preview.html?md=1`, { waitUntil: 'networkidle' })
|
|
await page.waitForTimeout(600)
|
|
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
|
|
|
const text = await page.locator('body').innerText()
|
|
// 「1+1 等于 **2**」原样带着星号显示——这就是改之前的样子
|
|
const stray = [...text.matchAll(/\*\*[^*\n]{1,40}\*\*|(?<!\w)`[^`\n]{1,40}`/g)].map((m) => m[0])
|
|
check('没有残留的 markdown 标记', stray.length === 0, stray.slice(0, 4).join(' '))
|
|
|
|
const el = await page.evaluate(() => ({
|
|
strong: document.querySelectorAll('strong').length,
|
|
code: document.querySelectorAll('code').length,
|
|
li: document.querySelectorAll('li').length,
|
|
table: document.querySelectorAll('table').length,
|
|
quote: document.querySelectorAll('blockquote').length,
|
|
// 模型很爱写 **`table_name`**。粗体在扫描顺序上先命中,强调内部不再
|
|
// 解析的话,那对反引号会原样显示出来
|
|
codeInStrong: document.querySelectorAll('strong code').length,
|
|
// 模型输出不可信,链接必须挡住 javascript:
|
|
badHref: [...document.querySelectorAll('a')]
|
|
.filter((a) => !/^(https?:|mailto:)/i.test(a.getAttribute('href') || '')).length,
|
|
// 只数 markdown 渲染出来的,不数整页——dev server 自己就注入 HMR 脚本
|
|
rawHtml: [...document.querySelectorAll('main, .space-y-2')]
|
|
.reduce((n, el) => n + el.querySelectorAll('script,iframe,object,embed,img').length, 0),
|
|
goodHref: [...document.querySelectorAll('a')]
|
|
.filter((a) => /^https?:/i.test(a.getAttribute('href') || '')).length,
|
|
pwned: window.__pwned === 1,
|
|
}))
|
|
check('粗体渲染出来了', el.strong > 0, `${el.strong} 处`)
|
|
check('行内代码渲染出来了', el.code > 0, `${el.code} 处`)
|
|
check('列表渲染出来了', el.li > 0, `${el.li} 条`)
|
|
check('表格渲染出来了', el.table > 0, `${el.table} 张`)
|
|
check('粗体里的代码也解析', el.codeInStrong > 0, `${el.codeInStrong} 处`)
|
|
// 样本里混了一段构造的恶意输入(真实输出不会自带攻击,但模型输出是不可信
|
|
// 内容,渲染层要么天生免疫,要么就是个 XSS 口子)
|
|
check('javascript: 链接没被渲染成可点的', el.badHref === 0, `${el.badHref} 个`)
|
|
check('正常链接照常可点', el.goodHref > 0, `${el.goodHref} 个`)
|
|
check('原始 HTML 没有变成真标签', el.rawHtml === 0, `${el.rawHtml} 个`)
|
|
check('注入的脚本没有执行', el.pwned === false)
|
|
check('原始 HTML 当文本显示出来了', text.includes('<script>'))
|
|
await page.close()
|
|
}
|
|
|
|
console.log('\n=== 长报告的折叠 ===')
|
|
{
|
|
// 折叠以前是按字符硬切的,切点落进表格中间:前面几行渲染成表格、最后半行
|
|
// 留成原始的 `| 1 | ThearchyHelios | …`。用户看到的是一份被咬掉一口的报告。
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
|
|
await page.goto(`${WEB}/preview.html?long=1`, { waitUntil: 'networkidle' })
|
|
await page.waitForTimeout(300)
|
|
|
|
const folded = await page.locator('body').innerText()
|
|
check('折叠时说破了"后面还有"', folded.includes('后面还有'))
|
|
check('给得出展开入口', folded.includes('展开全部'))
|
|
// 关键一条:折叠后不能留下没渲染成表格的原始 markdown 行
|
|
const rawRow = folded.split('\n').find((l) => /^\s*\|.*\|/.test(l))
|
|
check('没有半截的原始表格行', !rawRow, rawRow ? `残留:${rawRow.slice(0, 48)}` : '')
|
|
check('开头的正文还在', folded.includes('总结'))
|
|
|
|
await page.locator('button', { hasText: '展开全部' }).first().click()
|
|
await page.waitForTimeout(250)
|
|
const full = await page.locator('body').innerText()
|
|
check('展开后拿得到结尾', full.includes('身兼管理员与商家双重身份'))
|
|
check('展开后表格渲染完整', full.includes('user_39'))
|
|
await page.close()
|
|
}
|
|
|
|
console.log('\n=== 没查库的那一轮 ===')
|
|
{
|
|
// 「涉及数据必须真查」是这条路径上最硬的约定,放开直接回答之后,用户得能
|
|
// 一眼分清哪些结论背后真的动了库
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
|
|
await page.goto(`${WEB}/preview.html?noquery=1`, { waitUntil: 'networkidle' })
|
|
await page.waitForTimeout(300)
|
|
const body = await page.locator('body').innerText()
|
|
check('说破了这一条没有查库', body.includes('没有查库'))
|
|
check('声明排在结论前面',
|
|
body.indexOf('没有查库') < body.indexOf('role.level'),
|
|
`声明@${body.indexOf('没有查库')} 结论@${body.indexOf('role.level')}`)
|
|
check('答案本身照常渲染', body.includes('口径'))
|
|
await page.close()
|
|
}
|
|
|
|
console.log('\n=== 复核说明 ===')
|
|
{
|
|
// 「跑完了」和「答得对」是两回事。复核说明必须排在成果**上方**——排在下面
|
|
// 等于让人读完整个结论、信了,才知道它是在什么条件下得出的
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
|
|
await page.goto(`${WEB}/preview.html?review=1`, { waitUntil: 'networkidle' })
|
|
await page.waitForTimeout(300)
|
|
const body = await page.locator('body').innerText()
|
|
|
|
check('说破了取数没跑通', body.includes('取数没跑通'))
|
|
check('说明排在结论前面',
|
|
body.indexOf('取数没跑通') < body.indexOf('大约 5 个'),
|
|
`说明@${body.indexOf('取数没跑通')} 结论@${body.indexOf('大约 5 个')}`)
|
|
check('答案本身照常渲染', body.includes('大约 5 个'))
|
|
check('异常清单默认收起', !body.includes('no such table'))
|
|
check('数得出有几处异常', body.includes('检测到 2 处异常'))
|
|
|
|
// broken 用报警色,degraded 用普通边框。混成一个样子,用户就学会了一概忽略
|
|
const colors = await page.evaluate(() => {
|
|
const err = getComputedStyle(document.documentElement).getPropertyValue('--err').trim()
|
|
const hit = [...document.querySelectorAll('div[style*="border-color"]')]
|
|
.map((el) => getComputedStyle(el).borderTopColor)
|
|
return { err, hit }
|
|
})
|
|
check('两档复核长得不一样', new Set(colors.hit).size > 1, colors.hit.join(' / '))
|
|
|
|
// 改写是有损的,原件得留着能对照
|
|
check('改写前的原文可以展开', body.includes('看改写前的原文'))
|
|
check('原文默认不占地方', !body.includes('平台管理员可以修改他人权限。\n'))
|
|
|
|
const overflow = await page.evaluate(() =>
|
|
document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
|
check('页面不横向溢出', overflow <= 0, `${overflow}px`)
|
|
await page.close()
|
|
}
|
|
|
|
console.log('\n=== 并行分支 ===')
|
|
{
|
|
// fan-out 一直是真并发(实测 3 个 sleep 2 秒的节点墙钟 2.6 秒),但时间线上
|
|
// 原来只是穿插出现的几行,省下的时间一个字都没说
|
|
for (const [w, dense, label] of [[1000, '0', '宽栏'], [380, '1', '窄栏']]) {
|
|
const page = await browser.newPage({ viewport: { width: w, height: 700 } })
|
|
const errors = []
|
|
page.on('pageerror', (e) => errors.push(e.message))
|
|
await page.goto(`${WEB}/preview.html?fanout=1&dense=${dense}`, { waitUntil: 'networkidle' })
|
|
await page.waitForTimeout(400)
|
|
const body = await page.locator('body').innerText()
|
|
check(`${label}:没有运行时报错`, errors.length === 0, errors.join(' | '))
|
|
check(`${label}:说破了这是三路并行`, body.includes('3 路并行'))
|
|
check(`${label}:省下多少写出来了`, /合计 6\.\ds,实际 3\.\ds/.test(body),
|
|
body.slice(body.indexOf('3 路并行'), body.indexOf('3 路并行') + 40).replace(/\n/g, ' '))
|
|
check(`${label}:三路都还看得见`,
|
|
['查销售库', '查库存库', '查客户库'].every((n) => body.includes(n)))
|
|
const overflow = await page.evaluate(() =>
|
|
document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
|
check(`${label}:不横向溢出`, overflow <= 0, `${overflow}px`)
|
|
await page.close()
|
|
}
|
|
}
|
|
|
|
console.log('\n=== 协作团队的泳道 ===')
|
|
{
|
|
// 这个节点以前在界面上是一条扁平的步骤序列:看得出"谁回了什么",
|
|
// 看不出"谁和谁是同时干的"——而一轮同时派几个人正是它相对单 agent 的
|
|
// 全部优势,不画出来等于没有
|
|
for (const [w, dense, label] of [[1000, '0', '宽栏'], [380, '1', '窄栏']]) {
|
|
const page = await browser.newPage({ viewport: { width: w, height: 700 } })
|
|
const errors = []
|
|
page.on('pageerror', (e) => errors.push(e.message))
|
|
await page.goto(`${WEB}/preview.html?team=1&dense=${dense}`, { waitUntil: 'networkidle' })
|
|
await page.waitForTimeout(400)
|
|
const body = await page.locator('body').innerText()
|
|
|
|
check(`${label}:没有运行时报错`, errors.length === 0, errors.join(' | '))
|
|
check(`${label}:三个人各占一行`,
|
|
['researcher', 'analyst', 'writer'].every((n) => body.includes(n)))
|
|
check(`${label}:说清楚并行省了多少`, /2 轮并行,省下 11\.8s/.test(body),
|
|
body.slice(0, 90).replace(/\n/g, ' '))
|
|
const overflow = await page.evaluate(() =>
|
|
document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
|
check(`${label}:不横向溢出`, overflow <= 0, `${overflow}px`)
|
|
|
|
if (dense === '0') {
|
|
// 条形宽度按耗时归一化,一眼看得出谁是这一轮的瓶颈。
|
|
// 都画一样长的话,"并行"就只剩一句口号
|
|
const widths = await page.evaluate(() =>
|
|
[...document.querySelectorAll('button[title*="第 1 轮"]')]
|
|
.map((b) => b.getBoundingClientRect().width))
|
|
check('同一轮里慢的那个条更长', widths.length === 2 && widths[0] > widths[1] * 1.2,
|
|
widths.map((x) => Math.round(x)).join(' vs '))
|
|
|
|
await page.locator('button[title*="第 1 轮"]').first().click()
|
|
await page.waitForTimeout(300)
|
|
const opened = await page.locator('body').innerText()
|
|
check('点开看得到这个人的任务和回复',
|
|
opened.includes('查 Milvus 与 Qdrant') && opened.includes('内置 jieba'))
|
|
}
|
|
await page.close()
|
|
}
|
|
}
|
|
|
|
console.log('\n=== 空态 ===')
|
|
{
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
|
|
await page.goto(`${WEB}/preview.html?case=__none__`, { waitUntil: 'networkidle' })
|
|
await page.waitForTimeout(250)
|
|
const text = await page.locator('body').innerText()
|
|
check('没有内容时给的是空态而不是白屏', text.includes('问点什么'))
|
|
await page.close()
|
|
}
|
|
|
|
await browser.close()
|
|
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 助手流渲染全部通过')
|
|
process.exit(failed ? 1 : 0)
|