新增: 待发箱/唤醒原语/预算随窗口 + 治理门禁与场景护栏
- 待发箱地基: V51 ai_outbox 表 + 手写 OutboxRepo(claim CAS/崩溃残留复位/瞬时失败保留) + outbox.rs 调度器(KV 默认关/Notify 唤醒/会话忙跳过不丢)
- Wake 异步唤醒原语: 三条件(delay/file-appear/process-exit)+纯判定+注入走正典 spawn_agentic_session,默认关
- 上下文预算随模型窗口: model_context.rs 三级优先(池显式>预设表>回退 128K);上界取 1M 而非 128K——max_tokens 仅裁剪预算,卡 128K 等于不治根因
- 文件租约补纯函数与单测(未另造双轨:LEASE 早随 cb260ab 落地) / 跨会话召回补纯函数单测(主体早已落地)
- 子代理: 完整卡已消化 + 子会话压缩层 + P3 批量
- 隧道/中继: 指令离线不丢 + Hello proto 版本 + 心跳与 runbook
- df-mcp: 写工具后落 KV 变更标记(供 GUI 轮询兜底,补独立 stdio 进程无感知)
- 迁移链顶点改单一真值常量 LATEST_SCHEMA_VERSION(修 2 处因 V51 失效的断言)
- 治理: i18n 特殊字符提交门禁(含反引号覆盖 + 10 自测) / 根目录卫生门禁 / 看板归档器 / 单一 lockfile(bun) / swap-install 落 temp/ 且兼容无 Get-FileHash
- 场景护栏: 流式正文可见性 e2e(playwright 免真机事件回放) + 列表续写/外置句柄 node 测试
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { test, expect } from 'playwright/test'
|
||||
|
||||
// 冷启动首测含 vite 逐模块转换,可能远超默认 60s;放宽到 120s 防偶发超时(其余测试复用热服务很快)。
|
||||
test.setTimeout(120_000)
|
||||
|
||||
//! 「流式正文可见性」场景回放护栏。
|
||||
//!
|
||||
//! 背景:这类 bug 反复回归(bcb0b82 修「流式正文被轮次折叠吞掉」,随后又迭「结果/过程分离」),
|
||||
//! 都出现在「含工具调用的多轮回复」这一场景,且都在实现/单测层测不到 —— 必须按真实事件序列
|
||||
//! 回放整条链路(事件 → store → renderItems 分桶 → DOM)。故走 playwright 免真机路径
|
||||
//! (src/mocks/tauri-shim.ts 垫片 + 事件注入),不动任何真实数据。
|
||||
//!
|
||||
//! 目标契约(三条):
|
||||
//! (a) 多轮含工具调用回复完成后,有正文的轮次内容不得丢失 —— 过程轮叙述收进轨迹卡,
|
||||
//! 展开轨迹卡即可回看全文(不再「流式可见、收尾消失」);
|
||||
//! (b) 流式期间当前轮正文必须可见(气泡里能读到正在流入的文本);
|
||||
//! (c) 不得重复渲染同一段文本(轨迹卡展开态与最终气泡不重叠)。
|
||||
//!
|
||||
//! 事件注入路径参考 tests/e2e/live-group.spec.ts:垫片把 listen 回调登记进
|
||||
//! __TAURI_INTERNALS__.callbacks,Playwright 逐个投递 AiChatEvent 驱动 handleEvent。
|
||||
//! 发送动作走真实 UI(sendMessage → doSend),故 streaming 态、事件监听注册等运行态与线上一致。
|
||||
//!
|
||||
//! 会话上下文说明:浏览器垫片下 settings 无持久化,启动不自动选中会话(activeConversationId=null),
|
||||
//! 此时 AiTextDelta 会被 handleEvent 的「无活跃会话」守卫丢弃。故先注入一条带 conversation_id 的
|
||||
//! AiConvStateChanged 建立活跃会话并触发历史(conv-mock-1)加载,再发消息。
|
||||
|
||||
const CID = 'conv-mock-1'
|
||||
const R1 = '过程轮叙述·护栏A1' // 过程轮(纯工具轮)的正文叙述
|
||||
const FINAL = '最终答复·护栏Z9' // 末轮最终答复
|
||||
|
||||
/** 经垫片回调注入一条 AiChatEvent(投递给所有已注册的 handler) */
|
||||
async function inject(page: import('playwright/test').Page, event: Record<string, unknown>) {
|
||||
await page.evaluate((ev) => {
|
||||
const internals = (window as unknown as {
|
||||
__TAURI_INTERNALS__: { callbacks: Map<number, (data: unknown) => void> }
|
||||
}).__TAURI_INTERNALS__
|
||||
// payload 结构对齐 Tauri Event<T>: { event, id, payload }
|
||||
for (const cb of internals.callbacks.values()) {
|
||||
cb({ event: 'ai-chat-event', id: 0, payload: ev })
|
||||
}
|
||||
}, event)
|
||||
}
|
||||
|
||||
/** 打开 AI 聊天页,建立活跃会话并等待其历史加载完成。 */
|
||||
async function openReady(page: import('playwright/test').Page) {
|
||||
await page.goto('/#ai-home')
|
||||
await expect(page.locator('.ai-messages')).toBeVisible({ timeout: 20_000 })
|
||||
await inject(page, { type: 'AiConvStateChanged', conversation_id: CID, conv_state: 'idle' })
|
||||
// conv-mock-1 历史第 1 轮含工具调用 → 折叠为 1 张轨迹卡;出现即代表历史已加载
|
||||
await expect(page.locator('.ai-trace-card').first()).toBeVisible({ timeout: 20_000 })
|
||||
}
|
||||
|
||||
/** 走真实发送路径发一条用户消息(同步 push 用户气泡并置 streaming)。 */
|
||||
async function sendUser(page: import('playwright/test').Page, text: string) {
|
||||
const input = page.locator('.ai-input')
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(text)
|
||||
await input.press('Enter')
|
||||
await expect(page.locator('.ai-messages')).toContainText(text, { timeout: 5000 })
|
||||
await inject(page, { type: 'AiConvStateChanged', conversation_id: CID, conv_state: 'generating' })
|
||||
}
|
||||
|
||||
/** 整轮收尾(AiCompleted 回填最终文本 + 复位 idle)。 */
|
||||
async function completeReply(page: import('playwright/test').Page) {
|
||||
await inject(page, {
|
||||
type: 'AiCompleted', conversation_id: CID,
|
||||
total_tokens: 10, prompt_tokens: 6, completion_tokens: 4,
|
||||
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 6, reasoning_tokens: 0,
|
||||
})
|
||||
await inject(page, { type: 'AiConvStateChanged', conversation_id: CID, conv_state: 'idle' })
|
||||
}
|
||||
|
||||
/** 统计某段文本在页面可见文本里出现的次数(重复渲染判据)。 */
|
||||
async function countVisible(page: import('playwright/test').Page, text: string): Promise<number> {
|
||||
return page.evaluate((t) => document.body.innerText.split(t).length - 1, text)
|
||||
}
|
||||
|
||||
test('契约a+c:多轮含工具调用完成后,过程轮正文不丢(轨迹卡展开可见)且无重复', async ({ page }) => {
|
||||
await openReady(page)
|
||||
await sendUser(page, '请汇总进行中的任务(护栏场景)')
|
||||
|
||||
// 轮 1:正文叙述 + 一次工具调用
|
||||
await inject(page, { type: 'AiAgentRound', conversation_id: CID, round: 1 })
|
||||
await inject(page, { type: 'AiTextDelta', conversation_id: CID, delta: `${R1}:先调取任务数据核对口径。` })
|
||||
await inject(page, { type: 'AiToolCallStarted', conversation_id: CID, id: 'tc-guard-1', name: 'list_tasks', args: { status: 'in_progress' } })
|
||||
await inject(page, { type: 'AiToolCallCompleted', conversation_id: CID, id: 'tc-guard-1', result: '[{"id":"t1","status":"in_progress"}]' })
|
||||
|
||||
// 轮 2:最终答复
|
||||
await inject(page, { type: 'AiAgentRound', conversation_id: CID, round: 2 })
|
||||
await inject(page, { type: 'AiTextDelta', conversation_id: CID, delta: `${FINAL}:进行中 3 项,均属 DevFlow 项目。` })
|
||||
await completeReply(page)
|
||||
|
||||
// 契约 a(前置):最终答复落在气泡里(结果/过程分离:气泡只放最终结果)
|
||||
await expect(page.locator('.ai-msg-bubble--answer', { hasText: FINAL })).toHaveCount(1, { timeout: 5000 })
|
||||
|
||||
// 契约 a(核心):过程轮叙述不得丢失 —— 收进轨迹卡,展开即可回看全文 + 工具卡
|
||||
const traces = page.locator('.ai-trace-card')
|
||||
const n = await traces.count()
|
||||
expect(n, `轨迹卡数应 ≥2(历史 1 + 本轮 1),实际 ${n}`).toBeGreaterThanOrEqual(2)
|
||||
const ours = traces.last()
|
||||
await ours.locator('.ai-trace-head').click()
|
||||
await expect(ours.locator('.ai-trace-body')).toContainText(R1)
|
||||
await expect(ours.locator('.ai-trace-body .ai-tool-card')).toHaveCount(1)
|
||||
|
||||
// 契约 c:无重复渲染 —— 每段文本在可见文本里各出现一次
|
||||
expect(await countVisible(page, R1), `${R1} 重复渲染`).toBe(1)
|
||||
expect(await countVisible(page, FINAL), `${FINAL} 重复渲染`).toBe(1)
|
||||
})
|
||||
|
||||
// 契约 b(流式期间当前轮正文可见)。
|
||||
//
|
||||
// 历史回归:模板 <template v-for="item in renderItems" v-memo="[...]"> 的依赖数组曾漏流式文本源
|
||||
// (decoratedStreamingBlocks / streamingBlocks),而流式 delta 只改 currentText、不改任何消息 content
|
||||
// → 桶签名不变 → item 引用稳定 → v-memo 命中缓存跳过 patch → 流式气泡冻结在首个空帧,正文直到某次
|
||||
// 其它 dep 变化(工具事件/收尾)才一次性出现。修复=对渲染流式块的 item 定向纳入 decoratedStreamingBlocks。
|
||||
// 断言点刻意放在「仅一次 delta 之后」——此刻除流式文本源外无任何 dep 变化。
|
||||
test('契约b:流式期间当前轮正文即刻可见', async ({ page }) => {
|
||||
await openReady(page)
|
||||
await sendUser(page, '解释一下这个函数(护栏场景)')
|
||||
await inject(page, { type: 'AiAgentRound', conversation_id: CID, round: 1 })
|
||||
await inject(page, { type: 'AiTextDelta', conversation_id: CID, delta: `${R1}流式正文应当立即出现。` })
|
||||
await expect(page.locator('.ai-messages')).toContainText(R1, { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('单轮回复(无过程消息)零回归:正文进气泡且不新增轨迹卡', async ({ page }) => {
|
||||
await openReady(page)
|
||||
await sendUser(page, '解释一下这个函数(护栏场景二)')
|
||||
await inject(page, { type: 'AiAgentRound', conversation_id: CID, round: 1 })
|
||||
await inject(page, { type: 'AiTextDelta', conversation_id: CID, delta: `${FINAL}单轮:这是一段没有工具调用的回答。` })
|
||||
await completeReply(page)
|
||||
|
||||
await expect(page.locator('.ai-msg-bubble--answer', { hasText: FINAL })).toHaveCount(1, { timeout: 5000 })
|
||||
// 无过程消息 → 不凭空生成轨迹卡(仍只有历史那 1 张)
|
||||
await expect(page.locator('.ai-trace-card')).toHaveCount(1)
|
||||
expect(await countVisible(page, FINAL), `${FINAL} 重复渲染`).toBe(1)
|
||||
})
|
||||
Reference in New Issue
Block a user