新增: 待发箱/唤醒原语/预算随窗口 + 治理门禁与场景护栏
- 待发箱地基: 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)
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
// useListContinuation 纯逻辑层单测 —— 仓库无 vitest,走 node 内置测试跑(零第三方依赖):
|
||||
// node --test tests/useListContinuation.test.mjs
|
||||
// Node ≥22.6 原生剥离类型,可直接 import .ts(mjs 侧显式带 .ts 后缀)。
|
||||
// 覆盖:识别(有序/无序/缩进/非列表)/ 围栏守卫 / 续写补号 / 空项退出 / 末位退格退出 / 边界。
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
analyzeListContext,
|
||||
nextMarker,
|
||||
continueList,
|
||||
exitListOnBackspace,
|
||||
} from '../src/composables/ai/useListContinuation.ts'
|
||||
|
||||
test('analyzeListContext: 有序标记识别(含缩进/字段)', () => {
|
||||
const ctx = analyzeListContext(' 12. hello', 10)
|
||||
assert.equal(ctx.kind, 'ordered')
|
||||
assert.equal(ctx.indent, ' ')
|
||||
assert.equal(ctx.marker, '12. ')
|
||||
assert.equal(ctx.number, 12)
|
||||
assert.equal(ctx.content, 'hello')
|
||||
assert.equal(ctx.lineStart, 0)
|
||||
assert.equal(ctx.markerEnd, 6)
|
||||
})
|
||||
|
||||
test('analyzeListContext: 有序 `)` 标记', () => {
|
||||
const ctx = analyzeListContext('3) x', 4)
|
||||
assert.equal(ctx.kind, 'ordered')
|
||||
assert.equal(ctx.marker, '3) ')
|
||||
assert.equal(ctx.number, 3)
|
||||
})
|
||||
|
||||
test('analyzeListContext: 无序三种符号', () => {
|
||||
for (const b of ['-', '*', '+']) {
|
||||
const ctx = analyzeListContext(`${b} item`, 3)
|
||||
assert.equal(ctx.kind, 'unordered')
|
||||
assert.equal(ctx.bullet, b)
|
||||
assert.equal(ctx.content, 'item')
|
||||
}
|
||||
})
|
||||
|
||||
test('analyzeListContext: 非列表行返回 null', () => {
|
||||
assert.equal(analyzeListContext('1.no-space', 2), null) // 标记后无空白
|
||||
assert.equal(analyzeListContext('*bold*', 3), null) // 强调非列表
|
||||
assert.equal(analyzeListContext('-', 1), null) // 裸符号
|
||||
assert.equal(analyzeListContext('plain text', 5), null)
|
||||
const deep = ' 1. deep' // 4 空格缩进 > 3
|
||||
assert.equal(analyzeListContext(deep, deep.length), null)
|
||||
})
|
||||
|
||||
test('analyzeListContext: 按光标所在行判定,不误判他行', () => {
|
||||
const text = '1. list\nplain'
|
||||
assert.equal(analyzeListContext(text, 9), null) // 光标在第二非列表行
|
||||
assert.equal(analyzeListContext(text, 3)?.kind, 'ordered')
|
||||
})
|
||||
|
||||
test('围栏守卫: ``` 内不触发(未闭合)', () => {
|
||||
const text = '```\n- x'
|
||||
assert.equal(analyzeListContext(text, text.length), null)
|
||||
assert.equal(continueList(text, text.length), null)
|
||||
})
|
||||
|
||||
test('围栏守卫: 闭合围栏外的列表照常触发', () => {
|
||||
const text = '```\ncode\n```\n- x'
|
||||
const ctx = analyzeListContext(text, text.length)
|
||||
assert.equal(ctx?.kind, 'unordered')
|
||||
})
|
||||
|
||||
test('nextMarker: 有序 +1(保留分隔符)/ 无序同型', () => {
|
||||
assert.equal(nextMarker(analyzeListContext('1. a', 4)), '2. ')
|
||||
assert.equal(nextMarker(analyzeListContext('9) a', 4)), '10) ')
|
||||
assert.equal(nextMarker(analyzeListContext('- a', 3)), '- ')
|
||||
})
|
||||
|
||||
test('continueList: 非空有序项 → 换行补下一序号', () => {
|
||||
assert.deepEqual(continueList('1. foo', 6), { text: '1. foo\n2. ', caret: 10 })
|
||||
})
|
||||
|
||||
test('continueList: 保留缩进', () => {
|
||||
assert.deepEqual(continueList(' 1. a', 6), { text: ' 1. a\n 2. ', caret: 12 })
|
||||
})
|
||||
|
||||
test('continueList: 非空无序项 → 换行补同型符号', () => {
|
||||
assert.deepEqual(continueList('- foo', 5), { text: '- foo\n- ', caret: 8 })
|
||||
})
|
||||
|
||||
test('continueList: 空项 → 退出列表(删标记留空行)', () => {
|
||||
assert.deepEqual(continueList('1. ', 3), { text: '', caret: 0 })
|
||||
assert.deepEqual(continueList('a\n2. ', 5), { text: 'a\n', caret: 2 })
|
||||
})
|
||||
|
||||
test('continueList: 非列表语境返回 null(交默认换行)', () => {
|
||||
assert.equal(continueList('plain', 5), null)
|
||||
})
|
||||
|
||||
test('exitListOnBackspace: 末位退格退出空项', () => {
|
||||
assert.deepEqual(exitListOnBackspace('1. ', 3), { text: '', caret: 0 })
|
||||
assert.deepEqual(exitListOnBackspace('a\n- ', 4), { text: 'a\n', caret: 2 })
|
||||
})
|
||||
|
||||
test('exitListOnBackspace: 非空项/非行末不触发', () => {
|
||||
assert.equal(exitListOnBackspace('1. x', 4), null) // 有正文
|
||||
assert.equal(exitListOnBackspace('1. ', 1), null) // 光标不在行末
|
||||
assert.equal(exitListOnBackspace('plain', 5), null)
|
||||
})
|
||||
|
||||
test('退出后文本不再被识别为列表(标记已移除)', () => {
|
||||
const r = continueList('1. ', 3)
|
||||
assert.equal(analyzeListContext(r.text, r.caret), null)
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
// D2a 外置句柄解析单测 —— 仓库无 vitest,走 node 内置测试(零第三方依赖):
|
||||
// node --test tests/useOffloadedResult.test.mjs
|
||||
// Node ≥22.6 原生剥离类型,可直接 import .ts(显式带 .ts 后缀)。
|
||||
// 覆盖:完整句柄(对象/JSON 字符串)、截断句柄(审计 result_brief,JSON 非法)正则兜底、
|
||||
// 标记真值校验、非句柄/无 path 的否定用例。
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { parseOffloadHandle, hasOffloadMark } from '../src/composables/ai/useOffloadedResult.ts'
|
||||
|
||||
const PATH = 'C:\\Users\\me\\AppData\\tool-results\\2026-10\\abc.txt'
|
||||
|
||||
test('parseOffloadHandle: 完整句柄对象取全字段', () => {
|
||||
const h = parseOffloadHandle({ __offloaded__: true, path: PATH, size_bytes: 9000, tool: 'read_file', head: 'x', hint: '...' })
|
||||
assert.equal(h.path, PATH)
|
||||
assert.equal(h.sizeBytes, 9000)
|
||||
assert.equal(h.tool, 'read_file')
|
||||
})
|
||||
|
||||
test('parseOffloadHandle: 完整句柄 JSON 字符串', () => {
|
||||
const s = JSON.stringify({ __offloaded__: true, path: PATH, size_bytes: 12345, tool: 'grep' })
|
||||
const h = parseOffloadHandle(s)
|
||||
assert.equal(h.path, PATH)
|
||||
assert.equal(h.sizeBytes, 12345)
|
||||
})
|
||||
|
||||
test('parseOffloadHandle: 截断句柄(JSON 非法)正则兜底抽 path 并反转义', () => {
|
||||
// 模拟后端 truncate_chars_ellipsis 截断:JSON 不闭合、尾部省略号
|
||||
const brief = `{"__offloaded__":true,"path":"${PATH.replace(/\\/g, '\\\\')}","size_bytes":9000,"head":"hhh`
|
||||
const h = parseOffloadHandle(brief)
|
||||
assert.equal(h.path, PATH)
|
||||
// 截断态仅能拿到 path,size/tool 缺失不报错
|
||||
assert.equal(h.sizeBytes, undefined)
|
||||
})
|
||||
|
||||
test('parseOffloadHandle: 标记非 true / 非句柄 / 无 path 一律 null', () => {
|
||||
assert.equal(parseOffloadHandle({ __offloaded__: false, path: PATH }), null)
|
||||
assert.equal(parseOffloadHandle({ __offloaded__: true }), null)
|
||||
assert.equal(parseOffloadHandle('{"a":1}'), null)
|
||||
assert.equal(parseOffloadHandle('plain text'), null)
|
||||
assert.equal(parseOffloadHandle(null), null)
|
||||
assert.equal(parseOffloadHandle([1, 2]), null)
|
||||
})
|
||||
|
||||
test('hasOffloadMark: 字符串严格匹配标记,对象看布尔真值', () => {
|
||||
assert.equal(hasOffloadMark('x "__offloaded__":true y'), true)
|
||||
assert.equal(hasOffloadMark('just __offloaded__ word'), false)
|
||||
assert.equal(hasOffloadMark({ __offloaded__: true }), true)
|
||||
assert.equal(hasOffloadMark({ __offloaded__: false }), false)
|
||||
assert.equal(hasOffloadMark(null), false)
|
||||
})
|
||||
Reference in New Issue
Block a user