VS Code Agent Sessions E2E 场景解析:验证 Chat 消息在 Changes 视图中生成真实文件 Diff
本文围绕 02-chat-with-changes.scenario.md 这一 Agent Sessions 端到端(E2E)测试场景,讲解它如何验证"用户在 Chat 输入框提交一条消息后,Changes 视图真实呈现被修改文件、点击文件能打开真实 Diff 编辑器"的完整链路。读者可以据此理解 VS Code 仓库中会话类 E2E 测试的编写方式、compile-and-replay 架构以及"只 mock 外部后端、其余全部走真实实现"的测试哲学,并学会如何运行、重新生成与扩展这一场景。
场景定位:Agent Sessions 的六条自动化场景之一
在 src/vs/sessions/test/e2e/scenarios/ 目录下,共维护了六条人类可读的场景规格(*.scenario.md),它们按数字前缀排序并顺序执行:
01-chat-response.scenario.md02-chat-with-changes.scenario.md(本文主角)03-session-in-sidebar.scenario.md04-navigate-sessions.scenario.md05-full-workflow.scenario.md- 以及每个场景编译后生成的
generated/*.commands.json文件(如 02-chat-with-changes.commands.json)
这套测试采用 compile-and-replay(编译并回放) 架构:人类编写自然语言步骤,交给 Copilot CLI 编译成确定性的 playwright-cli 命令序列,回放阶段不再依赖任何 LLM。场景 02 聚焦的正是这套体系中最关键的能力验证——Chat 产出真实文件编辑、Changes 视图如实反映、Diff 编辑器真实打开。
被测行为:七个步骤验证一条完整用户路径
02-chat-with-changes.scenario.md 用 7 步串起"提问 → 得到含文件修改的响应 → 在 Changes 列表查看 → 打开 Diff → 关闭"的完整用户路径:
- 在 Chat 输入框中输入
build the project - 按 Enter 提交
- 验证 Chat 中出现了响应
- 验证 Changes 视图显示了被修改的文件
- 点击 Changes 列表中的
index.ts - 验证 Diff 编辑器打开了修改后的内容
- 按 Escape 关闭 Diff 编辑器
从仓库结构看,场景刻意选取了触发 mock Agent 文件编辑能力的关键词 build:它既能驱动真实 ChatEditingService 计算改动,又能在人工回放时稳定复现。
编译产物:自然语言步骤如何落地为可执行命令
npm run generate 会把这些自然语言步骤交给 Copilot CLI 翻译为语义化命令并写入 .commands.json。编译产物 02-chat-with-changes.commands.json 中每一步对应的命令为:
| 步骤 | 编译后的命令 | 说明 |
|---|---|---|
| 输入文本 | click textbox "Chat input" → type "build the project" |
聚焦输入框并键入关键字 |
| 提交 | click textbox "Chat input" → press Enter |
再次聚焦后回车 |
| 断言响应 | # ASSERT_VISIBLE: I'll help you build the project. Here are the changes: |
检查快照文本 |
| 断言文件列表 | # ASSERT_VISIBLE: index.ts / build.ts / package.json |
三个文件逐项断言 |
| 打开 Diff | click treeitem "index.ts" |
在树中点击文件 |
| 断言 Diff 内容 | # ASSERT_VISIBLE: import { build } from |
内容片段断言 |
| 关闭 Diff | press Escape |
恢复 UI 状态 |
值得注意,编译产物头部附有注释 Uses semantic selectors — regenerate with 'npm run generate' if UI labels change。也就是说命令里保存的是 角色 + 标签(如 textbox "Chat input"、treeitem "index.ts"),而不是易失效的引用编号,UI 文案变更后可重新生成。
为什么能产生"真实"Diff:最小化 Mock 架构
普通 E2E 要么依赖真实后端导致不稳定,要么把整条链路全部 mock 掉导致测了个寂寞。这套测试的独特之处在于:运行的是真实 Sessions workbench,只 mock 需要外部后端的最小集合。场景 02 之所以敢断言"真实文件 Diff",正是依赖这一架构设计(详见 src/vs/sessions/test/e2e/README.md)。
被替换为 mock 的只有四类需要外部依赖的服务:
| 服务 | Mock 行为 | 原因 |
|---|---|---|
IChatEntitlementService |
恒定返回 ChatEntitlement.Free |
CI 中没有真实 Copilot 账号 |
IDefaultAccountService |
返回伪造的已登录账号 | 隐藏"Sign In"按钮 |
IGitService |
立即 resolve | Web 测试中无真实 git 扩展,避免 10 秒屏障 |
Chat agents(copilotcli 等) |
基于关键字匹配的罐头响应 + textEdit 进度项 |
没有真实 LLM 后端 |
mock-fs:// FileSystemProvider |
直接在 workbench 注册 InMemoryFileSystemProvider |
必须在任何服务解析工作区文件前可用 |
| GitHub 认证 / PR 命令 | 恒登录态 / no-op 处理器 | 无真实 OAuth 与 GitHub API |
而下游链路全部走真实实现:ChatEditingService、ChatModel、ChangesViewPane、Diff 编辑器、上下文键(hasUndecidedChatEditingResourceContextKey、hasAppliedChatEditsContextKey)以及菜单动作(Create PR / Accept / Reject)等。mock Agent 是罐头数据进入系统的唯一入口,其下游全是真实代码路径。
预置内存文件系统:Diff 的"原始内容"来源
要让 ChatEditingService 算出真正的 before/after diff,mock 文件系统里必须预置与编辑目标一致的原文件。在 web.test.ts 中 MOCK_FS_FILES 预置了 4 个文件:
const MOCK_FS_FILES: Record<string, string> = {
'/mock-repo/src/index.ts': 'export function main() {\n\tconsole.log("Hello from mock repo");\n}\n',
'/mock-repo/src/utils.ts': 'export function add(a: number, b: number): number {\n\treturn a + b;\n}\n',
'/mock-repo/package.json': '{\n\t"name": "mock-repo",\n\t"version": "1.0.0"\n}\n',
'/mock-repo/README.md': '# Mock Repository\n\nThis is a mock repository for E2E testing.\n',
};
registerMockFileSystemProvider()(web.test.ts)直接把这些文件写入 mock-fs://mock-repo 这个 authority 下。为什么必须在 workbench 层注册而非 mock 扩展内?因为 SnippetsService、Agentic Prompt 文件定位器、MCP 等服务会在扩展宿主激活之前就去解析工作区内的文件,此时若 provider 尚未注册,会出现 ENOPRO: No file system provider 错误而静默失败。mock 扩展(extension.js)仍会通过扩展 API 注册一个 mock-fs provider 以满足扩展宿主操作,但 workbench 层的注册才是"权威来源"。
关键字驱动的罐头响应:场景数据的注入点
场景 02 中用户输入 build the project,会被 mock Agent 的关键字正则捕获。在 web.test.ts 的 getMockResponseWithEdits() 中:
if (/build|compile|create/i.test(message)) {
return {
text: 'I\'ll help you build the project. Here are the changes:',
fileEdits: [
{ uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/mock-repo/src/index.ts' }), // 修改已有文件
content: 'import { build } from "./build";\n\nexport function main() {\n\tconsole.log("Hello from mock repo");\n\tbuild();\n}\n' },
{ uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/mock-repo/src/build.ts' }), // 新建文件
content: 'export async function build() {\n\tconsole.log("Building...");\n\tconsole.log("Build complete!");\n}\n' },
{ uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/mock-repo/package.json' }), // 修改已有文件
content: '{\n\t"name": "mock-repo",\n\t"version": "1.0.0",\n\t"scripts": {\n\t\t"build": "node src/build.ts"\n\t}\n}\n' },
],
};
}
回复文本 I'll help you build the project. Here are the changes: 正是编译产物第 3 步断言出现的内容,三个 fileEdits 正好对应第 4 步断言的 index.ts、build.ts、package.json,而 index.ts 新内容首行 import { build } from "./build"; 则是第 6 步 Diff 内容断言 import { build } from 的来源。场景与数据驱动完全闭环。
mock Agent 通过 IChatAgentService.registerDynamicAgent() 注册 copilotcli、copilot-cloud-agent 两个动态 agent,其 invoke() 依次产出 markdownContent 进度项与文件编辑进度项(web.test.ts)。
编辑区间策略:已有文件 vs 新建文件
emitFileEdits()(web.test.ts)依据目标路径是否存在于 EXISTING_MOCK_FILES 决定 textEdit 的 range:
- 已有文件(如
index.ts、package.json):使用整文件替换区间{ startLineNumber: 1, startColumn: 1, endLineNumber: 99999, endColumn: 1 },让ChatEditingService把旧内容与新内容做真实 diff; - 新建文件(如
build.ts):使用"在开头插入"区间{ startLineNumber: 1, ... endLineNumber: 1 },在 Changes 视图中产生一条"file created"条目。
数据流:从输入框到 Diff 编辑器的调用链
场景 02 断言的一切都由下面这条真实管线驱动(见 README.md):
User types message → Chat Widget → ChatService
→ Mock Agent invoke() → progress([{ kind: 'textEdit', uri, edits }])
→ ChatModel.acceptResponseProgress()
→ ChatEditingService observes textEditGroup parts
→ Creates IModifiedFileEntry per file
→ Reads original content from mock-fs:// FileSystemProvider
→ Computes real diff (linesAdded, linesRemoved)
→ ChangesViewPane renders via observable chain
→ Click file → Opens real diff editor
也就是说,用户按下 Enter 后,键盘输入进入真实 ChatService;mock Agent 的罐头 textEdit 进入真实 ChatModel;再由真实 ChatEditingService(chatEditingServiceImpl.ts)观察进度、为每个文件创建带 before/after 内容的修改条目并计算真实增删行数;随后 Changes 视图(changesView.ts)通过 observable 链订阅 IChatEditingService 的状态渲染带 diff 统计的树;点击文件后打开的是真实 Diff 编辑器。这也是为什么该场景的定位是"真实 diff"而非"模拟展示"。
断言策略:断文件与内容,不断行号
README 明确给出一条重要工程建议:不要断言硬编码的行数(如 +23),而是断言文件名与内容片段。原因在于真实 diff 引擎计算的增删行数会随 mock 文件内容演化而变化,而文件名和关键内容片段才是稳定的接口契约。场景 02 正是这一策略的范本:
- 对响应只断言引导句的开头
I'll help you build the project. Here are the changes:; - 对文件树只断言三个文件名;
- 对 Diff 编辑器只断言新内容的特征行
import { build } from。
这种断言在回放期会被 test.cjs 以"轮询快照"的方式执行:# ASSERT_VISIBLE: 断言会以 500ms 间隔轮询、最长等待 10 秒(ASSERT_TIMEOUT_MS / ASSERT_POLL_MS,见 test.cjs),以容忍异步渲染。语义命令(如 click treeitem "index.ts")同样会在执行前先用实时无障碍树快照解析成当前页面的 ref,避免元素尚未渲染导致误失败(test.cjs)。
本地运行与重新生成
前置条件
- 仓库根目录已完成编译:
npm install && npm run compile(产物输出到out/); - 安装 e2e 目录依赖:
cd src/vs/sessions/test/e2e && npm install; - 仅在需要
generate阶段时安装 Copilot CLI 并确认可用:copilot --version。
回放阶段(快且确定性)
cd src/vs/sessions/test/e2e
npm test # 运行全部已编译场景
npm test -- 02-chat # 或按名称过滤:node test.cjs 02-chat-with-changes
test.cjs 会启动 Sessions web 服务器(随机端口 9100–9999)并以 --headed 打开浏览器,进入 /?skip-sessions-welcome 页面;每个场景之间会先按 Escape 并重新 goto,保证状态隔离;任一断言失败都会把失败现场截图保存为 out/failure-*.png。回放全程不调用任何 LLM,只是顺序执行命令与断言。
编译阶段(慢,含 LLM,改动后仅需一次)
cd src/vs/sessions/test/e2e
npm run generate # 编译全部场景
npm run generate -- 02-chat # 只重编译匹配的场景
generate.cjs 会为每个 .scenario.md 依次:启动服务器 → 打开页面 → 抓取无障碍树快照 → 把"快照 + 自然语言步骤"交给 Copilot CLI(模型参数为 claude-sonnet-4.6)→ 拿到语义命令(系统提示要求使用 click button "Send"、type "..."、press Enter、# ASSERT_VISIBLE: 等角色+标签形式,禁止使用 e43 这类引用编号)→ 执行命令推进 UI 状态进入下一步。编译产物 .commands.json 是提交到 git 的确定性测试计划。当新增 .scenario.md、UI 变化导致引用过期(测试开始失败)、或修改了既有步骤时,需要重新执行 generate。
编写与扩展这一场景的要点
- 步骤用纯自然语言撰写,文件按
NN-描述.scenario.md命名以控制顺序;场景之间靠 Escape 分隔。 - 一个步骤只做一个动作,便于失败信息定位。
- 按钮标签要与 UI 实际显示完全一致;标签中可能混入 PUA 区的 codicon 图标字符,应只用可读文本部分匹配。
- 给 mock Agent 添加新的文件编辑时,同步更新三处:新增/修改 extension.js 中的文件存储、web.test.ts 的
EXISTING_MOCK_FILES(两者路径必须一致),并在getMockResponseWithEdits()中注册对应关键字;所有路径须位于/mock-repo/之下。 - 触发编辑类响应的消息关键字可参考
getMockResponseWithEdits()已实现的分支:build/compile/create对应三条文件编辑(改index.ts、新建build.ts、改package.json),fix/bug对应utils.ts的校验修复,explain/describe则只返回解释文本而无编辑。
综上,02-chat-with-changes.scenario.md 不只是 7 行步骤,它是对 Agent Sessions"聊天产出真实文件修改"这一核心能力的端到端契约测试,与 mock 文件系统、关键字驱动 mock Agent、真实编辑服务管线共同构成了仓库中一套可读、可回放、可扩展的验证体系。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00