Remove incomplete terminal file-change tracking.

Terminal commands now only execute; apply/revert stays on dedicated file tools, and SQLite v11 drops leftover terminal_tool records.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yoan HuangandCursor committed 2026-09-17 23:59:40 +08:00
1 parent 33de80f1c3
commit fb8050ff0b
40 files changed
+182 -1597

No files matched your search

+1 -1
View File
@@ -297,7 +297,7 @@ append-only 伙伴消息转录;模型上下文、UI、resume 均由此投影
|--------------|------|
| `run_id` / `partner_id` | 归属 |
| `path` / `fileName` / `operation` | 文件身份与操作类型 |
| `capability` | `text_file` / `docx` / `ppt` / `terminal_exec` 等(`source` 才是 `terminal_tool`) |
| `capability` | `text_file` / `docx` / `ppt` / `excel` / `image` |
| `status` | `applied` / `failed` / `reverted` 等 |
| `changeCount` / `insertCount` / `deleteCount` | 改动统计 |
| `previewArtifactHash` 等 | diff 预览对象 |
+2 -2
View File
@@ -73,8 +73,8 @@ flowchart TB
| AI Runtime | 一次用户请求的 run 生命周期:事件流、审批、工具调用、最终答复 |
| Storage | `SqliteCore` + `LanceDbCore` + 各领域 Store |
| Retrieval | 文档索引、混合召回、结果融合 |
| Tool Runtime | 文件、终端、浏览器等本地能力;审批、命令安全、工作区变更追踪 |
| Workspace Change Tracking | terminal 仅当**有效 cwd**(会话起始路径 + 命令前导 `cd`/`Set-Location`)落在某个 `workspace_source.root_path` 内时才扫描该 cwd 子树;默认起始为主目录;工作地址外路径可读写但不纳入变更记录 |
| Tool Runtime | 文件、终端、浏览器等本地能力;审批、命令安全 |
| File Change Records | 只有受控文件工具写回才记修改、Diff 与回退;terminal 只执行,不扫描记账 |
工作地址是默认执行范围而非硬边界:模型默认在设置页现有文件地址内操作,系统级需求可访问地址外绝对路径。文件工具的 `sourcePath` / `outputPath` 必须为本机绝对路径。内置浏览器由 Electron `WebContentsView` 承载;Stagehand 在操作网页时按 Tab 挂到本机回环 CDP 桥(`webContents.debugger`),不在进程启动时打开 `--remote-debugging-port`。对象存储在 `userData/partners/{id}/objects/sha256`。
+2 -1
View File
@@ -52,9 +52,10 @@ SQLite 保存以下业务数据:
### 当前边界
- 当前代码支持的最终 schema version 是 `10`,由 `CURRENT_SCHEMA_VERSION` 定义。
- 当前代码支持的最终 schema version 是 `11`,由 `CURRENT_SCHEMA_VERSION` 定义。
- version `1–9` 已随正式版发布,永久保留且不可改写。已知历史例外只有 version 2 的旧名称 `workspace_change_sets`;version 9 继续负责修复通知字段。
- version `10` 是破坏性升级,不迁旧数据。`upgrade_from_v9` 从 v9 升级时 DROP 旧表再建最终结构:新增 `partner`;删除 `chat_session`、`chat_session_ui_state`(以及 `chat_conversation_memory`、`chat_session_resume_snapshot`、`ai_run_event`、`chat_session_skill_state`、`chat_message`);`chat_session_message_log` / `ai_run` / `ai_file_change` / `docx_apply` 改挂 `partner_id`;新增 `partner_ui_state`;`workbench_ui_state` 只留 `active_view_key`,删除 `draft_state_json`;`scheduled_task` 改挂 `partner_id`(无外键 CASCADE,专属伙伴删除后任务可保留并由服务重建),并含 `busy_cancel_notice_at`;`workspace_source` 去掉 name 且 `root_path UNIQUE`;`web_source` 去掉 `workspace_id` 且 `UNIQUE(normalized_url)`;`ai_run` 不再保存 `workspace_json`,并含 `failure_raw_message`(失败原文,与短标题 `error` / `failure_code` 分离)。不存在、也不新建 `chat_search_turn`。新增 `schema_migration_hooks`,供版本声明提交后副作用。v10 的 `afterCommit` 在 SQLite 事务提交后、LanceDB 连接前删除 `userData/lancedb`;hook 记 `pending/done`,失败可重试,`done` 后不重跑。空库仍执行 version `1–10`,跑完后结构与 v9 升级路径一致。
- version `11` 不改表结构,只删除 `ai_file_change` 中 `source` 为 `terminal_tool` / `workspace_scan` 或 `capability` 为 `terminal_exec` 的行。终端不再产生修改记录。
- 哪些 migration 已正式发布,以 `migrations/released-migrations.json` 为唯一边界。未进入清单的连续迁移可以在发布前合并或重写;进入清单后只能新增更高版本修复。
### 版本身份与执行规则
+8 -10
View File
@@ -49,8 +49,8 @@
- 馆长启动后默认安装 WPS 离线 JS 加载项并开启本机同步服务,无设置开关。已打开 WPS 文档的自动刷新见 `WB-WPS-001`。
- 用户可在右侧面板对真实本地 Markdown(所见即所得)与普通 UTF-8 文本(纯文本)做应用内手动编辑并保存,见 `WB-EDIT-001`;应用内手动编辑不生成修改记录、不进入回退体系。
- 当前通过 composer 附件进入对话上下文的文件不限扩展名,只要是可以选中的文件即可作为附件;`+` 选择和拖放文件保留源文件绝对路径,粘贴文件保存副本到当前伙伴运行时目录。附件不自动预解析,只把名称与路径交给 AI 后续自行处理。AI 工具侧仍有各自格式边界(如 `doc` 只读、`pptx` 可交给 PPT 工具读取和编辑、Excel 工具只写 `xlsx`),无法处理的文件会在 AI 工具层报错。
- 其他格式不保证直接写回和回退。AI 可以在审批与工作地址扫描边界内尝试处理,但不属于受支持文件能力范围。
- 终端执行只允许当前平台安全指令集直接运行;一条 terminal 命令被拆分后的每段都命中安全指令集时可直接执行,任一段未命中则必须先经用户手动批准。安全指令集只决定是否可直接执行,不决定执行模式。默认工作目录是用户主目录;仅当命令的**有效 cwd**(会话起始路径 + 前导 `cd`/`Set-Location`)落在某个工作地址内时,才扫描该 cwd 子树作为修改记录。
- 其他格式不保证直接写回和回退。AI 可以在审批边界内尝试处理,但不属于受支持文件能力范围。
- 终端执行只允许当前平台安全指令集直接运行;一条 terminal 命令被拆分后的每段都命中安全指令集时可直接执行,任一段未命中则必须先经用户手动批准。安全指令集只决定是否可直接执行,不决定执行模式。默认工作目录是用户主目录。终端改文件不进入修改记录,也不能从馆长回退。
- Composer 不再提供搜索模式。AI 仍可通过 `web_search` / `kb_search` 检索;`web_search` 不得自动展开右侧搜索列表(该列表面板已不存在)。设置页正在「更新索引」时,`kb_search` / `kb_list_documents` 失败并提示正在更新知识库;「检查更新」不拦截。
- 发送不再要求绑定工作空间目录;`@` 参考资料不能替代「已创建并选中伙伴」这一发送前提。
- 搜索、文件读取和执行结果必须服务后续回答、结论或文件产物,不能只停留在过程展示中。
@@ -64,7 +64,7 @@
- 受支持文件:当前已具备直接写回与回退能力的文件类型;当前包括 `docx`、`pptx`、`xlsx`、生成的 PNG、JPEG、WebP 图片和 UTF-8 普通文本文件。
- 目标文件:用户要求新建受支持文件时,AI 写工具直接创建的文件路径。
- 直接写回:AI 写工具将最终 docx、pptx、xlsx、生成图片或普通文本结果原子写入源文件或创建目标文件的流程;工具成功即代表正式路径已可读取,run 收尾不再提交文件。
- 修改记录:一次 AI run 中由受控文件工具直接写回,或由某条 terminal 命令前后差异识别出的创建、更新、删除或重命名记录;提供打开文件、适用格式的审核模式与回退本次修改入口。
- 修改记录:一次 AI run 中由受控文件工具直接写回产生的创建、更新、删除或重命名记录;提供打开文件、适用格式的审核模式与回退本次修改入口。终端造成的磁盘变化不记修改记录。
- 回退本次修改:基于写回前 checkpoint 恢复文件;新建文件回退时删除目标文件,若文件在 AI 写回后又被修改,需提示用户确认会丢失后续修改。
- 清空消息记录:删除该伙伴全部对话消息记录(及对应 `ai_run`),界面回到空对话;不删除伙伴本身,也不删除 `ai_file_change` 文件回退记录。
- 内部引用链接:assistant 回答中使用标准 Markdown 语法 `[显示名](绝对本地路径或 https URL)` 引用的本地文件或网页链接;点击可在右侧工作台预览文件或打开内置浏览器。本地文件做可达性检查,不可访问时展示弱化状态并禁止点击;`http(s)` 网页链接不做可达性检查,默认可点。详细校验与交互见 `WB-COMPOSER-003-AC8`。
@@ -535,7 +535,6 @@ Composer 只有智能模式,不再提供 AI / 搜索切换。
- `WB-PANEL-003-AC14` 当前修改记录同时具备修改后快照和差异内容时,面包屑旁展示“审核 / 预览”模式 Tag 及切换图标;`docx`、`pptx`、`xlsx` 和普通 UTF-8 文本文件均支持该切换。普通文本的预览模式打开 `record.path` 真实本地文件:若为可编辑 Markdown/UTF-8 文本则进入与普通打开一致的应用内编辑态;审核模式使用结构化文本 Diff;`xlsx` 的预览模式展示正式工作簿,审核模式展示带单元格边框标记的工作簿。修改记录默认进入审核模式;切入预览或回退后,差异数量、修改状态和回退按钮全部隐藏。
- `WB-PANEL-003-AC15` macOS 使用 Launch Services,Windows 使用 Shell Association Handler 查询当前文件的默认应用和其他关联应用;默认应用始终排在下拉首项并标记“默认”。Renderer 只接收不透明应用 ID、名称和图标,不能提交任意应用路径;同一应用重复注册时必须去重,查询失败时仍保留系统默认打开的降级能力。
- `WB-PANEL-003-AC16` 文件工具栏不单独展示“打开所在文件夹”按钮;该入口继续放在打开系统应用的下拉列表底部。工作区文件列表使用独立文件夹图标,收起时为透明按钮,展开后保持浅灰背景、边框和圆角,收起后选中样式立即消失。
- `WB-PANEL-003-AC17` 没有专用 Diff 的 terminal 命令记录不得统一显示“没有专用预览稿”:重命名展示修改前后文件名并说明内容未变化,内容修改说明暂不支持应用内差异,新建、删除、预览失败和已回退分别展示对应状态。
- `WB-PANEL-003-AC18` 右侧处于预览模式且展示真实本地文件时,必须监听当前激活文件的外部修改;路径不区分工作地址根目录、子目录或 `userData/partners/{id}/runtime/assets/`,范围覆盖所有当前支持应用内预览的文件类型,而非只限文本:PDF、DOCX、PPTX、XLSX/XLSM/XLTX/XLTM、PNG/JPG/JPEG/GIF/WebP/BMP/SVG、Markdown,以及严格 UTF-8 的 TXT、LOG、代码、JSON、HTML 等文件。仅当文件内容相对上次预览发生变化时才自动读取最新磁盘内容并提示“检测到当前文件被外部修改,已自动刷新”;仅打开外部应用、锁文件或 mtime/ctime 变化而正文不变时不得刷新或提示。切换文件、关闭 Tab 或切换到审核模式后停止旧监听,窗口重新获得焦点或恢复可见时补检查;AI Run 执行期间及结束后短暂保护期内的写入只刷新不提示。审核 / Diff 模式使用固定快照,不监听或刷新源文件;修改记录切换到预览模式时必须展示并监听 `record.path` 的真实当前文件,而不是修改时快照。
- `WB-PANEL-003-AC19` 任意受支持文件类型的 AI 修改记录处于 `applied` 状态时,通过系统默认应用或指定关联应用打开成功后,提示“如果外部应用仍显示旧内容,请关闭该文档后重新打开。”;该提示不按 TXT、DOCX、PPTX 或 XLSX 等扩展名区分。普通文件、失败记录、已回退记录和打开失败场景不得显示该提示。WPS 加载项已连接时仍可出现该提示,不作为失败;实际刷新由 `WB-APPLY-002-AC8` 负责。
- `WB-PANEL-003-AC20` DOCX、PPTX、XLSX、图片和普通文本专用工具已通过写入校验与前后 checksum 证明文件有效变化时,保存修改记录时立即保留该状态和快照;不得因目标位于 `userData/partners/{id}/runtime/assets` 而将成功记录改为失败。只有明确确认前后内容相同时才按无有效变化处理,不展示修改入口或固定错误条,仅临时提示“本次未检测到文件内容变化”。
@@ -591,26 +590,25 @@ Composer 只有智能模式,不再提供 AI / 搜索切换。
#### WB-TERMINAL-001 终端执行
- 系统行为:智能模式可通过终端执行命令;默认 cwd 为用户主目录。单次 `terminal_run` 可指定最长盯着命令结束的时间;到点后命令继续执行,本次调用不再盯。未指定时按原有默认盯时。长命令可在运行中让出控制权并由后续读取续接;命令遵守审批与安全策略。绝对路径可访问工作地址外文件,但只有命令的**有效 cwd**(会话起始路径 + 前导 `cd`/`Set-Location`)落在某个 `workspace_source.root_path` 内时,才扫描该 cwd 子树进入修改记录与回退。涉及数据计算、对账、统计等数值处理时,须通过终端真实执行计算,不得仅靠模型推算后直接写出结论文件。终端工具协议与并发/游标细节由 AI tool 回归覆盖。
- 系统行为:智能模式可通过终端执行命令;默认 cwd 为用户主目录。单次 `terminal_run` 可指定最长盯着命令结束的时间;到点后命令继续执行,本次调用不再盯。未指定时按原有默认盯时。长命令可在运行中让出控制权并由后续读取续接;命令遵守审批与安全策略。绝对路径可访问工作地址外文件。终端改文件不进入修改记录,也不能从馆长回退。涉及数据计算、对账、统计等数值处理时,须通过终端真实执行计算,不得仅靠模型推算后直接写出结论文件。终端工具协议与并发/游标细节由 AI tool 回归覆盖。
- 验收:
- `WB-TERMINAL-001-AC3` 删除伙伴、取消所属 run 或退出应用不会遗留进程。
- `WB-TERMINAL-001-AC4` terminal 命令继续遵守审批与其他安全策略;不能访问其他伙伴的终端。
- `WB-TERMINAL-001-AC7` AI 涉及数据计算、对账、统计、求和、对比等数值处理时,必须通过 terminal 执行真实计算;计算结果由终端命令产出,对应变化按 terminal 修改记录处理。
- `WB-TERMINAL-001-AC7` AI 涉及数据计算、对账、统计、求和、对比等数值处理时,必须通过 terminal 执行真实计算;计算结果由终端命令产出,结果文件不自动进入修改记录与回退。
- `WB-TERMINAL-001-AC8` `terminal_run` 的 `maxWaitSeconds` 只限制本次调用盯着命令结束的时间;到点后命令继续执行,不中断命令。
#### WB-APPLY-003 回退本次 AI 修改
- 系统行为:受控文件工具写回工作地址内文件时立即保存本次 run 的修改记录、有效变更校验和所需快照;terminal 每条命令执行前,仅当**有效 cwd**(会话起始路径 + 前导 `cd`/`Set-Location`)落在某个工作地址内时保存该 cwd 子树基线,命令最终结束或失败后计算该命令窗口内的新增、修改、删除和重命名。终端记录归入发起命令的 run。不同 terminal 命令并发重叠时允许同一变化在多个 run 中重复记录;系统不加锁、不推断唯一归属。AI 可以读写工作地址外绝对路径,但这些变更不进入当前修改记录与回退能力(含:有效 cwd 仍在主目录时用绝对路径写入工作地址)。AI 或用户在没有受控工具写回和 terminal 命令窗口时产生的外部变化不归入任何 AI Run。基线内容按 SHA-256 保存;文本 Diff 根据不可变的修改前后内容生成并缓存。回退使用修改前对象,不反向计算 Diff。
- 系统行为:受控文件工具写回工作地址内文件时立即保存本次 run 的修改记录、有效变更校验和所需快照。AI 或用户在没有受控工具写回时产生的外部变化(含终端改文件)不归入任何 AI Run。基线内容按 SHA-256 保存;文本 Diff 根据不可变的修改前后内容生成并缓存。回退使用修改前对象,不反向计算 Diff。
- 验收:
- `WB-APPLY-003-AC1` 未发生后续修改时,回退恢复写回前 checkpoint,记录状态变为 `reverted`。
- `WB-APPLY-003-AC2` 新建文件回退时删除 AI 新建目标文件。
- `WB-APPLY-003-AC3` 回退成功后,右侧修改的文件工作区应刷新记录状态;普通文本历史 diff 继续保留并标记“已回退”或“已删除”,避免丢失本次修改记录。
- `WB-APPLY-003-AC4` 终端通过 Shell、Python、Node 或其他程序造成的文件变化均由 Run 结束扫描捕获;终端修改的文本可生成普通行级 Diff,终端直接修改的 Docx 等二进制文件只提供修改前/修改后版本,不伪造语义审阅标记。
- `WB-APPLY-003-AC5` Docx 工具修改保存 before 和 Review Docx;Review Docx 继续由现有 Docx Renderer 渲染并保留段落、表格、图片、字体和页面结构。
- `WB-APPLY-003-AC6` PPT 修改记录使用 Diff Manifest v4,在审核模式提供固定的修改前后页面快照;旧 v3 记录明确提示格式过期,正式 PPT 不受影响。Excel 专用工具修改 `xlsx` 时在审核模式提供单元格边框标记,并可切换到预览模式。终端产生的二进制文件变化只提供修改前/修改后版本,不显示模式切换;终端产生的 UTF-8 文本变化按普通文本规则处理。
- `WB-APPLY-003-AC6` PPT 修改记录使用 Diff Manifest v4,在审核模式提供固定的修改前后页面快照;旧 v3 记录明确提示格式过期,正式 PPT 不受影响。Excel 专用工具修改 `xlsx` 时在审核模式提供单元格边框标记,并可切换到预览模式。
- `WB-APPLY-003-AC7` 图片生成记录直接预览最终图片并提供回退,不显示不存在的图片 Diff;更新图片回退后恢复首次生成前的文件,新建图片回退后删除目标文件。
- `WB-APPLY-003-AC8` Docx Review 只展示本次 Run 初始文档到最终净稿的净变化;同一原始节点连续修改只显示一次原始红色版本和一次最终绿色版本,中间稿不得出现,改回原内容不产生修改记录;本轮新增节点只显示最终仍存在的绿色版本,新增后删除不产生净变化。
- `WB-APPLY-003-AC9` 专用写工具的成功记录以工具原子写入结果、checkpoint 和前后 checksum 为权威依据;通用扫描匹配到同一路径时可补充快照,未匹配时不得否定已被校验的真实写入。
- `WB-APPLY-003-AC9` 专用写工具的成功记录以工具原子写入结果、checkpoint 和前后 checksum 为权威依据。
- `WB-APPLY-003-AC10` 回退 `.docx` / `.pptx` / `.xlsx` 后的 WPS 自动刷新见 `WB-WPS-001`。
#### WB-WPS-001 已打开的 WPS 文档随写回自动刷新
+6 -25
View File
@@ -96,7 +96,7 @@
- 目的:验证真实业务场景在真实生产调用下可以完成。
- case 设计必须使用真实用户风格输入,不得把内部固定工具顺序写进用户输入。
- 断言口径统一遵循 `doc/test.md` 的真实 AI / live 断言规则。
- 涉及数据计算 / 对账 / 统计的真实 AI 场景,brain 系统提示词已要求计算必须通过 terminal 工具真实执行;live case 硬断言校验完成态、关键产物与 terminal 计算是否真实发生。修改记录 `source=terminal_tool` 仅在有效 cwd 进入工作地址的可追踪写入路径上断言(见 `human-terminal-data-reconciliation-live`)。
- 涉及数据计算 / 对账 / 统计的真实 AI 场景,brain 系统提示词已要求计算必须通过 terminal 工具真实执行;live case 硬断言校验完成态、关键产物与 terminal 计算是否真实发生。终端改文件不进入修改记录。
- `human / live` 的完整证据写入测试运行产物;顶部“最新 Live 评估”仅由人工在复核 raw logs 与 artifact 后覆盖更新。
### 3.3 目录与命令
@@ -114,7 +114,6 @@ AI 回归测试目录:
| `tool/web-browsing/web-crawl-contract.test.ts` | `web-search-*`、`browser-use-*` |
| `tool/pdf/pdf-read-contract.test.ts` | `pdf-*-contract` |
| `tool/text-file/text-file-read-write-contract.test.ts` | `text-file-read-write-contract`、`text-file-atomic-guards-and-format-preservation` |
| `tool/terminal/terminal-inline-edit-artifact-contract.test.ts` | `terminal-inline-edit-artifact-contract`、`terminal-tracking-start-failure-cleanup` |
| `tool/image-generation/image-generate-contract.test.ts` | `image-generate-*-contract` |
| `tool/excel/excel-diff-row-structure-semantics.test.ts` | `excel-diff-row-structure-semantics`、`excel-diff-cumulative-net-semantics` |
| `tool/agent-control/read-image-multimodal-contract.test.ts` | `read-image-multimodal-contract`、`multimodal-image-budget-contract` |
@@ -141,8 +140,8 @@ AI unit / 辅助测试目录:
### 3.4 覆盖归属与去重规则
- `ai/tool` 负责生产工具 runtime 的完整输入输出、读写链、错误边界、原子性、修改记录与回退契约。
- `ChatRunService` 合同测试验证 run 结束不会扫描并归属未受控的工作地址变化;只有受控文件工具与 terminal 命令窗口能创建 run 级修改记录。terminal 仅当**有效 cwd**(会话起始路径 + 前导 `cd`/`Set-Location`)落在某个 `workspace_source.root_path` 内时才扫描该 cwd 子树。`ChatRunService` 不再校验工作空间绑定与搜索模式。
- sqlite migration unit 覆盖空库到 v10、v9→v10 破坏性升级后最终 schema 一致:存在 `partner` / `partner_ui_state`;不存在 `chat_session`、`chat_search_turn`、`chat_session_ui_state`;`chat_session_message_log` / `ai_run` / `ai_file_change` 挂 `partner_id`;`workbench_ui_state` 只留 `active_view_key`。UI store 为伙伴。
- `ChatRunService` 合同测试验证 run 结束不会扫描并归属未受控的工作地址变化;只有受控文件工具能创建 run 级修改记录。`ChatRunService` 不再校验工作空间绑定与搜索模式。
- sqlite migration unit 覆盖空库到 v11、v9→v10 破坏性升级后最终 schema 一致,以及 v11 删除终端修改行:存在 `partner` / `partner_ui_state`;不存在 `chat_session`、`chat_search_turn`、`chat_session_ui_state`;`chat_session_message_log` / `ai_run` / `ai_file_change` 挂 `partner_id`;`workbench_ui_state` 只留 `active_view_key`。UI store 为伙伴。
- `ai/human/mock-llm` 负责真实 human graph 下的工具学习、工具选择、串联执行、暂停恢复和结果交付;只允许固定 LLM 输出,不允许替换工具执行结果。
- `ai/human/live` 负责真实模型在自然语言输入下能否自主选择并正确使用能力;用户输入不得包含内部工具名或固定调用顺序。
- Workbench E2E 负责用户可见的附件、预览、Diff、跨 Sheet 交互与回退闭环,不重复验证工具内部算法。
@@ -472,24 +471,6 @@ DOCX tool 回归以 7 个公开工具、story 内容树、稳定 `nodeId`、segm
- Oracle:两次 hand 各产生一次非 internal 的审批要求与 reject 决策;不产生 `terminal_step_started` / `tool_call_result(terminal_run)` / `terminal_step_error`;受保护文件仍存在、目标目录未创建。
- Not:不验证 UI 提示;不验证 `terminal_run` 被直接调用时的行为(工具层不再独立请求审批)。
##### `terminal-inline-edit-artifact-contract`
- 测试类型:`backend`
- What:terminal 只通过真实命令前后差异生成修改记录和 artifact,不根据命令字符串猜测文件产物。
- Why:terminal 命令需要在不依赖整次 Run 收尾扫描的前提下,记录无法静态分析的真实文件变化。
- How:通过 terminal 修改已有文件并创建新文件,检查工具结果、文件结果、工具期修改记录和 `file_artifact` 事件。
- Oracle:命令真实生效;不在 `terminal_run` 内产生审批事件;terminal 工具结果不包含 `artifacts`;命令完成后产生带当前 Run ID 的修改记录和 `file_artifact`。
- Not:不根据命令文本推断文件路径;差异只来自命令前后工作区快照。
##### `terminal-tracking-start-failure-cleanup`
- 测试类型:`backend`
- What:终端命令已建立追踪基线但 PTY 启动失败时,追踪上下文会被立即丢弃。
- Why:启动失败不应留下无法完成的快照、修改记录或文件 artifact。
- How:真实构建 terminal 工具,替换终端会话启动为明确失败的实现,并记录开始、完成和丢弃追踪调用。
- Oracle:恰好开始并丢弃同一个命令追踪 ID;不调用完成追踪,不产生修改记录或 `file_artifact`。
- Not:不验证 PTY 本身的启动错误归因;该能力由终端会话管理测试覆盖。
##### `terminal-inline-interpreter-execution`
- 测试类型:`backend`
@@ -502,10 +483,10 @@ DOCX tool 回归以 7 个公开工具、story 内容树、稳定 `nodeId`、segm
##### `terminal-execution-contract`
- 测试类型:`backend`
- What:真实 PTY 终端从用户主目录执行命令、长命令让出控制权并可增量读取,命令级文件追踪与退出码正确;允许后台分离执行,放行 fd 合并重定向。
- What:真实 PTY 终端从用户主目录执行命令、长命令让出控制权并可增量读取,退出码正确;允许后台分离执行,放行 fd 合并重定向。
- Why:后台服务和长任务不能依赖单次 shell 超时;分离执行(`&` / `Start-Process`)由模型自行管理生命周期,框架只关闭 PTY 会话本身;`2>&1` 等 fd 合并重定向只是输出流合并,不能误判为危险重定向。
- How:通过 `terminal_run` 分别执行 `pwd`、单命令内 `cd + pwd`、写文件后长 sleep、持续输出命令、写文件后非零退出;通过 `terminal_read` 携带返回的 `readEndPosition` 增量读取持续输出命令,验证单次读取有字符上限、`hasMoreOutput` 指示未读完且可用返回位置连续读完。以 `sleep … & wait`(windows `Start-Process … -Wait`)验证分离执行可完成,以 `echo redirect-ok 2>&1` 验证 fd 合并重定向正常执行。`maxWaitSeconds` 由本 case 与 unit 覆盖:指定后本次调用按该秒数盯,到点命令仍在跑。
- Oracle:命令默认从用户主目录(`os.homedir()`)执行;长命令返回 `command_still_running` 且进程仍在;读取能取得真实增量输出;仅当**有效 cwd**(主目录起始 + 前导 `cd`/`Set-Location`(含 `-Path`/`-LiteralPath`、以及模型偶发的 `cd /d`)落入工作地址)时,写文件命令(完成与非零退出)才生成当前 run 的 `terminal_tool` 修改记录和 `file_artifact`;有效 cwd 仍在主目录时用绝对路径写入工作地址不记修改。Windows 会话内覆写的 `Set-Location` 必须接受 `-LiteralPath`/`-Path`,否则模型常用写法会失败并退化成绝对路径改文件(无 apply)。分离执行成功完成且输出含 `detached-ok`;`2>&1` 命令以 completed 和退出码 0 结束且输出包含 `redirect-ok`。`maxWaitSeconds` 到点后为 `command_still_running` 且进程仍在,对齐 `WB-TERMINAL-001-AC8`。
- Oracle:命令默认从用户主目录(`os.homedir()`)执行;长命令返回 `command_still_running` 且进程仍在;读取能取得真实增量输出。写文件命令只落磁盘,不产生修改记录或 `file_artifact`。Windows 会话内覆写的 `Set-Location` 必须接受 `-LiteralPath`/`-Path`。分离执行成功完成且输出含 `detached-ok`;`2>&1` 命令以 completed 和退出码 0 结束且输出包含 `redirect-ok`。`maxWaitSeconds` 到点后为 `command_still_running` 且进程仍在,对齐 `WB-TERMINAL-001-AC8`。
- Not:不验证 Workbench 浏览器联动,该能力由真实 e2e 覆盖;不在会话结束时强制清理模型拆出去的后台进程。
#### web-browsing 类别
@@ -987,7 +968,7 @@ DOCX tool 回归以 7 个公开工具、story 内容树、稳定 `nodeId`、segm
- What:真实模型将发票与回款数据对账,并为财务交付异常清单 CSV。
- Why:这是独立的真实终端智能性场景,不与 Excel 读写 case 混合。
- How:用户只描述对账和财务异常清单需求,不出现终端或命令概念。
- Oracle:输入文件不变;对账计算与 `reconciliation-exceptions.csv` 产出必须经过 terminal 脚本(`python/awk/join` 等真实计算命令);异常行和金额正确。修改记录 `source=terminal_tool` **仅当**存在有效 cwd 落在工作地址内、且非纯核验(`cat`/`md5`/`ls` 等)的 terminal 命令时才硬断言——家目录起用绝对路径写入、仅 cd 进去核验时不要求修改记录(对齐当前追踪边界)。
- Oracle:输入文件不变;对账计算与 `reconciliation-exceptions.csv` 产出必须经过 terminal 脚本(`python/awk/join` 等真实计算命令);异常行和金额正确。结果文件不要求进入修改记录。
- Not:不生成测试专用 JSON,不混入终端协议、runtime 下载或临时目录契约;不强制模型必须先 `cd` 再写。
##### `human-runtime-script-system-python-live`
-27
View File
@@ -559,15 +559,6 @@ pnpm test:update:e2e
- Oracle:AI 写入后编辑态显示最新内容且不误报外部修改;编辑态与审核模式共用一个文件 Tab;Run 终态后修改入口在未点击时已显示真实非零数量;切换伙伴再切回后未点击时数量仍为真实非零,不重置为 0,对齐 `WB-APPLY-005-AC4`;审核模式展示差异数量、修改状态和回退,预览模式全部隐藏;预览模式对 Markdown/UTF-8 文本进入应用内编辑态(与普通打开一致),对齐 `WB-EDIT-001-AC7`、`WB-PANEL-003-AC14`;已应用记录打开外部应用后显示重新打开提醒;文件夹入口打开的文件列表从工具栏底部贴右悬浮,不改变预览宽度,可通过外部点击、Escape 或选择文件关闭;当前已经是最新记录且处于预览模式时,再次点击文件总分类仍会重置到最新审核模式;历史批次、结构化 Diff、不可变展开和回退刷新整体对齐 `WB-PANEL-003`、`WB-APPLY-001`、`WB-APPLY-002`、`WB-APPLY-003`;确认回退后 bytes 与原始输入一致且有效改动标记消失。
- Not:不验证 unified diff 字符串;UI 只消费结构化 diff artifact。
#### `file-change-run-scope`
- 测试类型:`e2e / live`
- What:两个伙伴各完成一次真实终端写入后,每条 assistant 修改入口与右侧“本次运行改动”只展示该 `{ partnerId, runId }` 的文件。
- Why:文件改动归属已经是 run 级,后台或历史伙伴完成时不能把其他 run 的记录混入当前审核列表。
- How:在首个伙伴让真实 AI 只通过 `terminal_run` 创建第一个文件;再创建第二个伙伴(可共用同一文件地址),让真实 AI 创建另一个文件;分别打开两条修改入口,再回到首个伙伴复查右侧变更列表。
- Oracle:两条 run 和两个伙伴 ID 均不同;每条 assistant 卡片只含自身文件;右侧“本次运行改动 1”只含当前 run 文件,切回历史伙伴后仍不出现另一 run 的文件,对齐 `WB-PANEL-003`、`WB-APPLY-003`。
- Not:不要求两个终端命令实际时间重叠;重叠命令的允许重复归属由后端并发单测覆盖。
#### `ppt-create-diff-and-revert`
- 测试类型:`e2e / live`
@@ -640,24 +631,6 @@ pnpm test:update:e2e
- Oracle:正式 DOCX 包含最终单元格内容;修改记录为 `applied` 且路径保持文件地址内源文件绝对路径,不出现失败红条或“本次未检测到文件内容变化”;审核预览在对应单元格展示红绿净变化,未变单元格不整表高亮,对齐 `WB-PANEL-003-AC20`、`WB-PANEL-003-AC21`、`WB-APPLY-003-AC9`、`WB-COMPOSER-010-AC2`。
- Not:不使用 fake DOCX、fake AI event 或伪造修改记录;不验证粘贴副本保存链路,该链路由 `composer-attachments-context` 覆盖。
#### `terminal-rename-change-notice-and-revert`
- 测试类型:`e2e`
- What:真实 AI 通过终端 `mv` 重命名文件后,验证命令级差异记录、非专用变更说明和确认回退闭环。
- Why:终端和脚本没有专用 Diff,但仍属于本次运行的 AI 改动;若只覆盖内置编辑工具,会漏掉产品的重要通用执行路径。
- How:在临时文件地址要求真实 AI 只使用 `terminal_run` 重命名文件;等待 Run 完成后点击修改入口,检查前后文件名和内容未变化说明;点击回退并确认,再读取磁盘状态。
- Oracle:重命名记录在 terminal 命令结束后进入 assistant 修改入口和“本次运行改动”;预览区展示原文件名、当前文件名和“文件内容没有变化”,不出现“没有专用预览稿”;统一回退确认出现后才执行,最终原文件名及原始 bytes 恢复,重命名目标消失。
- Not:不尝试区分重叠 terminal 命令共同看到的变化;允许按各自命令窗口重复记录。
#### `terminal-update-delete-change-and-revert`
- 测试类型:`e2e / live`
- What:真实 AI 通过终端修改文本文件内容和删除文件后,分别验证行级 Diff 预览与回退恢复原始 bytes、删除记录说明与回退恢复文件。
- Why:终端是通用执行路径,内容修改和删除两条记录与内置文件工具共用同一回退入口;若只覆盖重命名,会漏掉终端产生的最常见两类可回退记录。
- How:在临时文件地址要求真实 AI 只使用 `terminal_run`,在一次 Run 中修改一个文本文件内容(如追加唯一标记行)并删除另一个文件;等待 Run 完成后分别打开两条修改入口:修改记录检查行级 Diff 预览(不出现“没有专用预览稿”),删除记录检查“本次删除了该文件”说明;分别点击回退并确认,再读取磁盘状态。
- Oracle:两条记录在 terminal 命令结束后进入 assistant 修改入口和“本次运行改动”;修改记录在审核模式展示普通行级 Diff,对齐 `WB-APPLY-003-AC4`;回退后文本文件恢复原始 bytes 且记录状态变为 `reverted`,对齐 `WB-APPLY-003-AC1`;删除记录展示删除说明,回退后文件恢复且 bytes 与删除前一致,对齐 `WB-APPLY-003-AC2`。
- Not:不验证 terminal 命令审批交互和重叠命令窗口归属,该能力由 AI tool terminal 回归和 `terminal-rename-change-notice-and-revert` 覆盖。
#### `docx-apply-force-revert-warning`
- 测试类型:`e2e`
-8
View File
@@ -644,14 +644,6 @@ export class ChatRunService {
},
listFileChangeRecords: async (request) =>
(await this.deps.aiFileChangeService.listRecords(request)).items,
beginTerminalCommandTracking: async (input) => {
await this.deps.aiFileChangeService.beginTerminalCommandTracking(input)
},
finishTerminalCommandTracking: async (commandId) =>
await this.deps.aiFileChangeService.finishTerminalCommandTracking(commandId),
discardTerminalCommandTracking: (commandId) => {
this.deps.aiFileChangeService.discardTerminalCommandTracking(commandId)
},
isCancelled: () => Boolean(this.activeRuns.get(runId)?.cancelled),
abortSignal: this.activeRuns.get(runId)?.abortController.signal,
sendEvent,
@@ -15,7 +15,7 @@ import {
createTextFileDiffArtifact
} from '../human/tools/textFile/textDiff'
import { decodeTextFile } from '../human/tools/textFile/textFileUtils'
import { TerminalCommandChangeTracker, WorkspaceObjectStore } from './WorkspaceChangeTracker'
import { WorkspaceObjectStore } from './WorkspaceObjectStore'
import { NO_EFFECTIVE_FILE_CHANGE_MESSAGE } from '../../../../shared/constants'
import {
ensurePartnerRuntimeDirs,
@@ -33,36 +33,8 @@ export interface AiFileChangeRecordStore {
}
export class AiFileChangeService {
private readonly terminalCommandChangeTracker = new TerminalCommandChangeTracker()
public constructor(private readonly store: AiFileChangeRecordStore) {}
public async beginTerminalCommandTracking(input: {
commandId: string
runId: string
partnerId: string
cwd: string
command: string
workspaceRoots: string[]
}): Promise<void> {
await this.terminalCommandChangeTracker.beginCommand(input)
}
public discardTerminalCommandTracking(commandId: string): void {
this.terminalCommandChangeTracker.discardCommand(commandId)
}
public async finishTerminalCommandTracking(commandId: string): Promise<AiFileChangeRecord[]> {
const records = await this.terminalCommandChangeTracker.finishCommand(commandId)
return await Promise.all(
records.map(async (record) => {
await this.saveRecord(record)
const response = await this.getRecord({ changeId: record.id })
return response.record || record
})
)
}
public async saveRecord(record: AiFileChangeRecord): Promise<void> {
const existing = (await this.store.getRecord({ changeId: record.id })).record
const next = await this.finalizeRecord(record, existing)
@@ -147,7 +119,6 @@ export class AiFileChangeService {
}
private hasNoEffectiveChange(record: AiFileChangeRecord): boolean {
if (record.source === 'terminal_tool' || record.source === 'workspace_scan') return false
if (record.operation === 'created') return !record.afterChecksum
if (record.operation === 'deleted') return !record.beforeChecksum
if (record.operation === 'renamed') {
@@ -1,324 +0,0 @@
import { createHash, randomUUID } from 'node:crypto'
import fs from 'node:fs'
import fsPromises from 'node:fs/promises'
import path from 'node:path'
import { pipeline } from 'node:stream/promises'
import type {
AiFileChangeCapability,
AiFileChangeOperation,
AiFileChangeRecord
} from '../../../../shared/types'
import {
ensurePartnerRuntimeDirs,
getPartnerObjectsDir
} from '../../assets/WorkspaceAssetService'
const IGNORED_DIRECTORY_NAMES = new Set(['.git', '.ncurator', 'node_modules'])
const TEXT_EXTENSIONS = new Set([
'.c',
'.cc',
'.conf',
'.cpp',
'.css',
'.csv',
'.env',
'.go',
'.h',
'.hpp',
'.html',
'.ini',
'.java',
'.js',
'.json',
'.jsx',
'.log',
'.md',
'.mdx',
'.mjs',
'.mts',
'.py',
'.rb',
'.rs',
'.scss',
'.sh',
'.sql',
'.svelte',
'.toml',
'.ts',
'.tsx',
'.txt',
'.vue',
'.xml',
'.yaml',
'.yml'
])
const EXCEL_EXTENSIONS = new Set(['.xlsx', '.xlsm', '.xltx', '.xltm'])
type WorkspaceObject = {
relativePath: string
absolutePath: string
objectPath: string
hash: string
size: number
mode: number
}
type TrackedTerminalCommand = {
runId: string
partnerId: string
scanRootPath: string
objectsRootDir: string
runSummary: string
baseline: Map<string, WorkspaceObject>
}
const isPathInsideRoot = (targetPath: string, rootPath: string): boolean => {
const resolvedTarget = path.resolve(targetPath)
const resolvedRoot = path.resolve(rootPath)
if (resolvedTarget === resolvedRoot) return true
const relativePath = path.relative(resolvedRoot, resolvedTarget)
return Boolean(relativePath) && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
}
const findContainingWorkspaceRoot = (cwd: string, workspaceRoots: string[]): string | null => {
const resolvedCwd = path.resolve(cwd)
for (const root of workspaceRoots) {
const resolvedRoot = path.resolve(String(root || '').trim())
if (!resolvedRoot) continue
if (isPathInsideRoot(resolvedCwd, resolvedRoot)) return resolvedRoot
}
return null
}
const hashFile = async (filePath: string): Promise<string> => {
const hash = createHash('sha256')
await pipeline(fs.createReadStream(filePath), hash)
return hash.digest('hex')
}
export class WorkspaceObjectStore {
public constructor(private readonly objectsRootDir: string) {}
public getObjectPath(hash: string): string {
return path.join(this.objectsRootDir, 'sha256', hash.slice(0, 2), hash.slice(2))
}
public async putFile(filePath: string): Promise<{ hash: string; objectPath: string }> {
const hash = await hashFile(filePath)
const objectPath = this.getObjectPath(hash)
try {
await fsPromises.access(objectPath)
return { hash, objectPath }
} catch {
// Continue with an atomic content-addressed write.
}
await fsPromises.mkdir(path.dirname(objectPath), { recursive: true })
const tempPath = `${objectPath}.${randomUUID()}.tmp`
try {
await fsPromises.copyFile(filePath, tempPath)
await fsPromises.rename(tempPath, objectPath).catch(async (error: NodeJS.ErrnoException) => {
if (error.code !== 'EEXIST') throw error
})
} finally {
await fsPromises.rm(tempPath, { force: true }).catch(() => undefined)
}
return { hash, objectPath }
}
public async putBuffer(content: Buffer): Promise<{ hash: string; objectPath: string }> {
const hash = createHash('sha256').update(content).digest('hex')
const objectPath = this.getObjectPath(hash)
try {
await fsPromises.access(objectPath)
return { hash, objectPath }
} catch {
await fsPromises.mkdir(path.dirname(objectPath), { recursive: true })
await fsPromises
.writeFile(objectPath, content, { flag: 'wx' })
.catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'EEXIST') throw error
})
return { hash, objectPath }
}
}
}
const scanDirectory = async (
scanRootPath: string,
objectsRootDir: string
): Promise<Map<string, WorkspaceObject>> => {
const objectStore = new WorkspaceObjectStore(objectsRootDir)
const result = new Map<string, WorkspaceObject>()
const visit = async (directoryPath: string, relativeDirectory: string): Promise<void> => {
const entries = await fsPromises.readdir(directoryPath, { withFileTypes: true })
entries.sort((left, right) => left.name.localeCompare(right.name))
for (const entry of entries) {
if (entry.isSymbolicLink()) continue
if (entry.isDirectory() && IGNORED_DIRECTORY_NAMES.has(entry.name)) continue
const relativePath = relativeDirectory ? path.join(relativeDirectory, entry.name) : entry.name
const absolutePath = path.join(directoryPath, entry.name)
if (entry.isDirectory()) {
await visit(absolutePath, relativePath)
continue
}
if (!entry.isFile()) continue
try {
const stats = await fsPromises.stat(absolutePath)
const object = await objectStore.putFile(absolutePath)
result.set(relativePath.replace(/\\/g, '/'), {
relativePath: relativePath.replace(/\\/g, '/'),
absolutePath,
objectPath: object.objectPath,
hash: object.hash,
size: stats.size,
mode: stats.mode
})
} catch (error) {
const code = error instanceof Error && 'code' in error ? String(error.code) : ''
if (code !== 'ENOENT' && code !== 'EACCES' && code !== 'EPERM') throw error
}
}
}
await visit(scanRootPath, '')
return result
}
const inferCapability = (
before: WorkspaceObject | undefined,
after: WorkspaceObject | undefined
): AiFileChangeCapability => {
const extension = path.extname(after?.absolutePath || before?.absolutePath || '').toLowerCase()
if (TEXT_EXTENSIONS.has(extension)) return 'text_file'
if (EXCEL_EXTENSIONS.has(extension)) return 'excel'
return 'terminal_exec'
}
const pairRenames = (
deleted: WorkspaceObject[],
created: WorkspaceObject[]
): Array<{ before: WorkspaceObject; after: WorkspaceObject }> => {
const createdByHash = new Map<string, WorkspaceObject[]>()
for (const item of created) {
const matches = createdByHash.get(item.hash) || []
matches.push(item)
createdByHash.set(item.hash, matches)
}
const pairs: Array<{ before: WorkspaceObject; after: WorkspaceObject }> = []
for (const before of deleted) {
const candidates = createdByHash.get(before.hash)
const after = candidates?.shift()
if (after) pairs.push({ before, after })
}
return pairs
}
export class TerminalCommandChangeTracker {
private readonly commands = new Map<string, TrackedTerminalCommand>()
public async beginCommand(input: {
commandId: string
runId: string
partnerId: string
/** Effective cwd: session start path after resolving a leading cd / Set-Location. */
cwd: string
command: string
workspaceRoots: string[]
}): Promise<void> {
const cwd = path.resolve(input.cwd)
// Only track when the effective cwd sits inside a configured workspace root; scan that cwd subtree.
if (!findContainingWorkspaceRoot(cwd, input.workspaceRoots)) return
await ensurePartnerRuntimeDirs(input.partnerId)
const objectsRootDir = getPartnerObjectsDir(input.partnerId)
const baseline = await scanDirectory(cwd, objectsRootDir)
this.commands.set(input.commandId, {
runId: input.runId,
partnerId: input.partnerId,
scanRootPath: cwd,
objectsRootDir,
runSummary: input.command.trim().slice(0, 160),
baseline
})
}
public discardCommand(commandId: string): void {
this.commands.delete(commandId)
}
public async finishCommand(commandId: string): Promise<AiFileChangeRecord[]> {
const tracked = this.commands.get(commandId)
if (!tracked) return []
this.commands.delete(commandId)
const current = await scanDirectory(tracked.scanRootPath, tracked.objectsRootDir)
const updated: Array<{ before: WorkspaceObject; after: WorkspaceObject }> = []
const deleted: WorkspaceObject[] = []
const created: WorkspaceObject[] = []
for (const [relativePath, before] of tracked.baseline) {
const after = current.get(relativePath)
if (!after) deleted.push(before)
else if (before.hash !== after.hash) updated.push({ before, after })
}
for (const [relativePath, after] of current) {
if (!tracked.baseline.has(relativePath)) created.push(after)
}
const renames = pairRenames(deleted, created)
const renamedBefore = new Set(renames.map((item) => item.before.relativePath))
const renamedAfter = new Set(renames.map((item) => item.after.relativePath))
const changes: Array<{
operation: AiFileChangeOperation
before?: WorkspaceObject
after?: WorkspaceObject
}> = [
...updated.map((item) => ({ operation: 'updated' as const, ...item })),
...renames.map((item) => ({ operation: 'renamed' as const, ...item })),
...deleted
.filter((item) => !renamedBefore.has(item.relativePath))
.map((before) => ({ operation: 'deleted' as const, before })),
...created
.filter((item) => !renamedAfter.has(item.relativePath))
.map((after) => ({ operation: 'created' as const, after }))
]
const records: AiFileChangeRecord[] = []
for (const change of changes) {
const targetPath = change.after?.absolutePath || change.before?.absolutePath
if (!targetPath) continue
const now = Date.now()
const capability = inferCapability(change.before, change.after)
const record: AiFileChangeRecord = {
id: randomUUID(),
partnerId: tracked.partnerId,
runId: tracked.runId,
status: 'applied',
capability,
path: targetPath,
fileName: path.basename(targetPath),
operation: change.operation,
source: 'terminal_tool',
beforePath: change.before?.absolutePath,
afterPath: change.after?.absolutePath,
changeCount: 1,
insertCount: 0,
deleteCount: 0,
beforeSnapshotPath: change.before?.objectPath,
afterSnapshotPath: change.after?.objectPath,
beforeObjectHash: change.before?.hash,
afterObjectHash: change.after?.hash,
beforeChecksum: change.before?.hash,
afterChecksum: change.after?.hash,
runSummary: tracked.runSummary,
createdAt: now,
updatedAt: now
}
records.push(record)
}
return records
}
}
@@ -0,0 +1,59 @@
import { createHash, randomUUID } from 'node:crypto'
import fs from 'node:fs'
import fsPromises from 'node:fs/promises'
import path from 'node:path'
import { pipeline } from 'node:stream/promises'
const hashFile = async (filePath: string): Promise<string> => {
const hash = createHash('sha256')
await pipeline(fs.createReadStream(filePath), hash)
return hash.digest('hex')
}
export class WorkspaceObjectStore {
public constructor(private readonly objectsRootDir: string) {}
public getObjectPath(hash: string): string {
return path.join(this.objectsRootDir, 'sha256', hash.slice(0, 2), hash.slice(2))
}
public async putFile(filePath: string): Promise<{ hash: string; objectPath: string }> {
const hash = await hashFile(filePath)
const objectPath = this.getObjectPath(hash)
try {
await fsPromises.access(objectPath)
return { hash, objectPath }
} catch {
// Continue with an atomic content-addressed write.
}
await fsPromises.mkdir(path.dirname(objectPath), { recursive: true })
const tempPath = `${objectPath}.${randomUUID()}.tmp`
try {
await fsPromises.copyFile(filePath, tempPath)
await fsPromises.rename(tempPath, objectPath).catch(async (error: NodeJS.ErrnoException) => {
if (error.code !== 'EEXIST') throw error
})
} finally {
await fsPromises.rm(tempPath, { force: true }).catch(() => undefined)
}
return { hash, objectPath }
}
public async putBuffer(content: Buffer): Promise<{ hash: string; objectPath: string }> {
const hash = createHash('sha256').update(content).digest('hex')
const objectPath = this.getObjectPath(hash)
try {
await fsPromises.access(objectPath)
return { hash, objectPath }
} catch {
await fsPromises.mkdir(path.dirname(objectPath), { recursive: true })
await fsPromises
.writeFile(objectPath, content, { flag: 'wx' })
.catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'EEXIST') throw error
})
return { hash, objectPath }
}
}
}
+1 -1
View File
@@ -130,7 +130,7 @@ export const fileChangeSummaryItemSchema = z.object({
id: z.string().min(1),
fileName: z.string().min(1),
path: z.string().min(1),
capability: z.enum(['docx', 'text_file', 'terminal_exec', 'ppt', 'excel', 'image']),
capability: z.enum(['docx', 'text_file', 'ppt', 'excel', 'image']),
operation: z.enum(['created', 'updated', 'deleted', 'renamed']),
changeCount: z.number().int().min(0),
insertCount: z.number().int().min(0),
@@ -12,11 +12,7 @@ export const terminalToolFamily = createWorkspaceBoundToolFamily({
platform: ctx.platform,
checkCancelled: ctx.checkCancelled,
humanRuntimeOverrides: ctx.humanRuntimeOverrides,
sendEvent: ctx.sendEvent,
saveFileChangeRecord: ctx.saveFileChangeRecord,
beginTerminalCommandTracking: ctx.beginTerminalCommandTracking,
finishTerminalCommandTracking: ctx.finishTerminalCommandTracking,
discardTerminalCommandTracking: ctx.discardTerminalCommandTracking
sendEvent: ctx.sendEvent
},
cache,
runId,
@@ -24,7 +20,6 @@ export const terminalToolFamily = createWorkspaceBoundToolFamily({
taskId,
workspaceRootPath: os.homedir(),
workspaceId: '',
workspaceRoots: ctx.workspaceRoots,
sessionManager: terminalSessionManager,
emitActivity
}).tools
@@ -16,7 +16,7 @@ export type ResolveLeadingCommandCwdInput = {
/**
* Resolve the effective cwd for a command that starts with an explicit directory change.
* Used both for session path updates and for file-change tracking baseline (before the PTY runs).
* Used for session path updates after the PTY runs.
*/
export const resolveLeadingCommandCwd = async (
input: ResolveLeadingCommandCwdInput
@@ -32,15 +32,3 @@ export const resolveLeadingCommandCwd = async (
return undefined
}
}
/** Session start path + optional leading cd → cwd used for terminal change tracking. */
export const resolveTerminalTrackingCwd = async (input: {
command: string
sessionStartPath: string
}): Promise<string> => {
const leading = await resolveLeadingCommandCwd({
command: input.command,
currentPath: input.sessionStartPath
})
return leading || path.resolve(input.sessionStartPath)
}
@@ -11,12 +11,10 @@ import {
inspectTerminalOutputQuality,
TERMINAL_UNUSABLE_OUTPUT_PLACEHOLDER
} from './terminalExec'
import { emitFileArtifactEvent } from '../../../events/fileArtifacts'
import { resolveTerminalTrackingCwd } from './resolveLeadingCommandCwd'
import type { TerminalCapabilityToolsInput, TerminalCapabilityToolsRuntimeState } from './types'
import { createTerminalRunToolSpec, TERMINAL_READ_TOOL_SPEC } from './toolSpecs'
import { mapTerminalReadStatus, mapTerminalRunStatus } from './terminalToolStatus'
import { TERMINAL_MAX_CONCURRENT_SESSIONS } from './terminalConstants'
import type { TerminalCapabilityToolsInput, TerminalCapabilityToolsRuntimeState } from './types'
import { createTerminalRunToolSpec, TERMINAL_READ_TOOL_SPEC } from './toolSpecs'
type PendingTerminalCommand = {
terminalId: string
@@ -28,7 +26,6 @@ type PendingTerminalCommand = {
stepIndex: number
command: string
cwd: string
trackingId?: string
emitActivity: (activity: CapabilityActivityInput) => void
finalized: boolean
finalization?: Promise<void>
@@ -121,34 +118,6 @@ const emitTerminalStepFailed = (input: PendingTerminalCommand, error: string): v
input.emitActivity({ actionType: 'terminal_exec', status: 'failed', summary: error })
}
const finishTerminalFileChangeTracking = async (pending: PendingTerminalCommand): Promise<void> => {
if (!pending.trackingId) return
try {
const records = await pending.ctx.finishTerminalCommandTracking(pending.trackingId)
records.forEach((record) => {
if (record.status !== 'applied') return
emitFileArtifactEvent({
ctx: pending.ctx,
runId: pending.runId,
taskId: pending.taskId,
capability:
record.capability === 'docx' || record.capability === 'text_file'
? record.capability
: 'terminal_exec',
artifactPath: record.path,
operation: record.operation,
applyId: record.id,
changeCount: record.changeCount,
insertCount: record.insertCount,
deleteCount: record.deleteCount,
stepId: pending.stepId
})
})
} catch (error) {
console.error(`[TERMINAL] file change tracking failed for ${pending.command}:`, error)
}
}
const finalizeCommand = async (input: {
commandId: string
exitCode: number
@@ -159,7 +128,6 @@ const finalizeCommand = async (input: {
if (pending.finalization) return await pending.finalization
pending.finalized = true
pending.finalization = (async () => {
await finishTerminalFileChangeTracking(pending)
emitTerminalStepCompleted(pending, input.exitCode, input.executionStatus || 'completed')
pendingCommands.delete(input.commandId)
})()
@@ -172,7 +140,6 @@ const failCommand = async (commandId: string, error: unknown): Promise<void> =>
if (pending.finalization) return await pending.finalization
pending.finalized = true
pending.finalization = (async () => {
await finishTerminalFileChangeTracking(pending)
emitTerminalStepFailed(pending, error instanceof Error ? error.message : String(error))
pendingCommands.delete(commandId)
})()
@@ -253,28 +220,10 @@ export const buildTerminalCapabilityTools = (input: TerminalCapabilityToolsInput
return failure
}
// Default session start is homedir; tracking uses effective cwd (start + leading cd).
const cwd = await resolveTerminalTrackingCwd({
command,
sessionStartPath: input.workspaceRootPath
})
// Default session start is homedir.
const cwd = input.workspaceRootPath
state.stepIndex += 1
const stepId = randomUUID()
const trackingId = randomUUID()
let trackingStarted = false
try {
await input.ctx.beginTerminalCommandTracking({
commandId: trackingId,
runId: input.runId,
partnerId: input.partnerId,
cwd,
command,
workspaceRoots: input.workspaceRoots
})
trackingStarted = true
} catch (error) {
console.error('[TERMINAL] file change baseline failed:', error)
}
emitTerminalStepStarted({
ctx: input.ctx,
runId: input.runId,
@@ -303,7 +252,6 @@ export const buildTerminalCapabilityTools = (input: TerminalCapabilityToolsInput
maxWaitSeconds
})
} catch (error) {
if (trackingStarted) input.ctx.discardTerminalCommandTracking(trackingId)
const message = error instanceof Error ? error.message : String(error)
const failure =
message === SESSION_LIMIT_ERROR
@@ -331,7 +279,6 @@ export const buildTerminalCapabilityTools = (input: TerminalCapabilityToolsInput
stepIndex: state.stepIndex,
command,
cwd,
trackingId: trackingStarted ? trackingId : undefined,
emitActivity: input.emitActivity,
finalized: false
})
@@ -15,7 +15,6 @@ export type TerminalArtifactCandidate = {
export type TerminalCapabilityToolsInput = WorkspaceCapabilityBuildInput<TerminalToolContext> & {
workspaceId: string
workspaceRoots: string[]
sessionManager: TerminalSessionManager
}
-8
View File
@@ -166,14 +166,6 @@ export const createAiRunContext = (payload: AiRunStartPayload, deps: AiRunDeps):
listFileChangeRecords: async (input) => {
return await deps.listFileChangeRecords(input)
},
beginTerminalCommandTracking: async (input) => {
await deps.beginTerminalCommandTracking(input)
},
finishTerminalCommandTracking: async (commandId) =>
await deps.finishTerminalCommandTracking(commandId),
discardTerminalCommandTracking: (commandId) => {
deps.discardTerminalCommandTracking(commandId)
},
documentsStore: deps.documentsStore,
embeddingService: deps.embeddingService,
assertKbSearchAvailable: async () => {
+1 -1
View File
@@ -9,7 +9,7 @@ import { AI_RUN_STATUSES } from './constants'
export const aiFileArtifactRefSchema: z.ZodType<AiFileArtifactRef> = z.object({
kind: z.literal('file'),
capability: z.enum(['terminal_exec', 'docx', 'ppt', 'excel', 'text_file', 'image']),
capability: z.enum(['docx', 'ppt', 'excel', 'text_file', 'image']),
path: z.string().min(1),
fileName: z.string().min(1),
operation: z.enum(['created', 'updated', 'deleted', 'renamed']),
+1 -24
View File
@@ -292,15 +292,6 @@ export type BrowserUseService = {
releaseRun?: (runId: string) => Promise<void>
}
export type TerminalCommandChangeTrackingInput = {
commandId: string
runId: string
partnerId: string
cwd: string
command: string
workspaceRoots: string[]
}
export type AiRunDeps = {
runtimeSettings: AiRuntimeSettings
llmStore: LlmConfigStore
@@ -313,9 +304,6 @@ export type AiRunDeps = {
docxApplyService: DocxApplyService
saveFileChangeRecord: (record: AiFileChangeRecord) => Promise<void>
listFileChangeRecords: (input: { runId: string }) => Promise<AiFileChangeRecord[]>
beginTerminalCommandTracking: (input: TerminalCommandChangeTrackingInput) => Promise<void>
finishTerminalCommandTracking: (commandId: string) => Promise<AiFileChangeRecord[]>
discardTerminalCommandTracking: (commandId: string) => void
isCancelled: () => boolean
abortSignal?: AbortSignal
sendEvent: (event: AiRunEvent) => void
@@ -406,9 +394,6 @@ export type AiRunContext = {
discardDocxApplyRun: () => void
saveFileChangeRecord: (record: AiFileChangeRecord) => Promise<void>
listFileChangeRecords: (input: { runId: string }) => Promise<AiFileChangeRecord[]>
beginTerminalCommandTracking: (input: TerminalCommandChangeTrackingInput) => Promise<void>
finishTerminalCommandTracking: (commandId: string) => Promise<AiFileChangeRecord[]>
discardTerminalCommandTracking: (commandId: string) => void
documentsStore: DocumentsStore
embeddingService: EmbeddingService
assertKbSearchAvailable: () => Promise<void>
@@ -425,15 +410,7 @@ export type AiEventDispatchContext = Pick<
export type TerminalToolContext = Pick<
AiRunContext,
| 'runId'
| 'platform'
| 'checkCancelled'
| 'sendEvent'
| 'humanRuntimeOverrides'
| 'saveFileChangeRecord'
| 'beginTerminalCommandTracking'
| 'finishTerminalCommandTracking'
| 'discardTerminalCommandTracking'
'runId' | 'platform' | 'checkCancelled' | 'sendEvent' | 'humanRuntimeOverrides'
>
export type KnowledgeRetrievalContext = Pick<
+2 -46
View File
@@ -7,35 +7,10 @@ import type {
AiFileArtifactRef,
AiRunEvent,
DocxApplyRecord,
FileChangesSummary,
JsonObject,
JsonValue
FileChangesSummary
} from '../../../../shared/types'
import type { ToolCallResult } from '../human/types'
const isJsonObject = (value: JsonValue | undefined): value is JsonObject => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
return true
}
const readNonEmptyString = (input: JsonObject, key: string): string | undefined => {
const value = input[key]
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}
const readArtifactOperation = (
input: JsonObject,
key: string,
fallback: AiFileArtifactOperation
): AiFileArtifactOperation => {
const value = input[key]
return value === 'created' || value === 'updated' || value === 'deleted' || value === 'renamed'
? value
: fallback
}
const createFileArtifactRef = (input: {
capability: AiFileArtifactRef['capability']
artifactPath: string
@@ -50,24 +25,6 @@ const createFileArtifactRef = (input: {
applyId: input.applyId
})
const extractTerminalArtifacts = (result: ToolCallResult): AiArtifactRef[] => {
if (result.toolName !== 'terminal_run' || !isJsonObject(result.data)) return []
const rawArtifacts = result.data.artifacts
if (!Array.isArray(rawArtifacts)) return []
return rawArtifacts.flatMap((item) => {
if (!isJsonObject(item)) return []
const artifactPath = readNonEmptyString(item, 'path')
if (!artifactPath) return []
return [
createFileArtifactRef({
capability: 'terminal_exec',
artifactPath,
operation: readArtifactOperation(item, 'operation', 'created')
})
]
})
}
const getArtifactKey = (artifact: AiArtifactRef): string =>
`file:${artifact.path}:${artifact.applyId || ''}`
@@ -85,8 +42,7 @@ export const mergeArtifactRefs = (
return Array.from(merged.values())
}
export const extractArtifactRefsFromToolResult = (result: ToolCallResult): AiArtifactRef[] =>
mergeArtifactRefs(extractTerminalArtifacts(result))
export const extractArtifactRefsFromToolResult = (_result: ToolCallResult): AiArtifactRef[] => []
export const extractArtifactRefsFromToolResults = (
results: readonly ToolCallResult[]
@@ -29,7 +29,6 @@ const normalizeCapability = (value: unknown): AiFileChangeCapability => {
const normalized = String(value ?? '').trim()
if (
normalized === 'text_file' ||
normalized === 'terminal_exec' ||
normalized === 'ppt' ||
normalized === 'excel' ||
normalized === 'image'
@@ -46,8 +45,6 @@ const normalizeOperation = (value: unknown): AiFileChangeOperation => {
const normalizeSource = (value: unknown): AiFileChangeSource | undefined => {
if (
value === 'workspace_scan' ||
value === 'terminal_tool' ||
value === 'text_tool' ||
value === 'docx_tool' ||
value === 'ppt_tool' ||
@@ -13,6 +13,7 @@ import { aiRunExecutionPermissionMigration } from './migrations/007_ai_run_execu
import { dropLlmStrategyPreferenceMigration } from './migrations/008_drop_llm_strategy_preference'
import { repairAiRuntimeSystemNotificationsColumnMigration } from './migrations/009_repair_ai_runtime_system_notifications_column'
import { upgradeFromV9Migration } from './migrations/010_upgrade_from_v9'
import { dropTerminalFileChangeRecordsMigration } from './migrations/011_drop_terminal_file_change_records'
export type SqliteDatabase = Database.Database
export type SqliteStatement = Database.Statement
@@ -27,10 +28,11 @@ const MIGRATIONS: readonly Migration[] = [
aiRunExecutionPermissionMigration,
dropLlmStrategyPreferenceMigration,
repairAiRuntimeSystemNotificationsColumnMigration,
upgradeFromV9Migration
upgradeFromV9Migration,
dropTerminalFileChangeRecordsMigration
]
export const CURRENT_SCHEMA_VERSION = 10
export const CURRENT_SCHEMA_VERSION = 11
const MIGRATION_NAME_ALIASES = new Map<number, ReadonlySet<string>>([
[2, new Set(['workspace_change_sets'])]
@@ -0,0 +1,11 @@
import type { Migration } from './types'
export const dropTerminalFileChangeRecordsMigration: Migration = {
version: 11,
name: 'drop_terminal_file_change_records',
sql: `
DELETE FROM ai_file_change
WHERE source IN ('terminal_tool', 'workspace_scan')
OR capability = 'terminal_exec';
`
}
+2 -16
View File
@@ -833,17 +833,9 @@ export interface WriteTextFileResponse {
}
export type AiFileChangeStatus = 'applied' | 'reverted' | 'failed'
export type AiFileChangeCapability =
| 'docx'
| 'text_file'
| 'terminal_exec'
| 'ppt'
| 'excel'
| 'image'
export type AiFileChangeCapability = 'docx' | 'text_file' | 'ppt' | 'excel' | 'image'
export type AiFileChangeOperation = 'created' | 'updated' | 'deleted' | 'renamed'
export type AiFileChangeSource =
| 'workspace_scan'
| 'terminal_tool'
| 'text_tool'
| 'docx_tool'
| 'ppt_tool'
@@ -1173,13 +1165,7 @@ export type AiRuntimeSettings = {
systemNotificationsEnabled: boolean
}
export type AiFileArtifactCapability =
| 'terminal_exec'
| 'docx'
| 'ppt'
| 'excel'
| 'text_file'
| 'image'
export type AiFileArtifactCapability = 'docx' | 'ppt' | 'excel' | 'text_file' | 'image'
export type AiFileArtifactOperation = AiFileChangeOperation
export type AiFileArtifactRef = {
kind: 'file'
@@ -72,14 +72,6 @@ const buildAiRunDeps = async (input: {
},
listFileChangeRecords: async (request) =>
(await aiFileChangeService.listRecords(request)).items,
beginTerminalCommandTracking: async (tracking) => {
await aiFileChangeService.beginTerminalCommandTracking(tracking)
},
finishTerminalCommandTracking: async (commandId) =>
await aiFileChangeService.finishTerminalCommandTracking(commandId),
discardTerminalCommandTracking: (commandId) => {
aiFileChangeService.discardTerminalCommandTracking(commandId)
},
chatMessageLogStore: input.runtime.storageService.chatMessageLog,
isCancelled: () => controller.signal.aborted,
abortSignal: controller.signal,
@@ -125,14 +125,6 @@ const buildAiRunDeps = async (input: {
},
listFileChangeRecords: async (request) =>
(await aiFileChangeService.listRecords(request)).items,
beginTerminalCommandTracking: async (tracking) => {
await aiFileChangeService.beginTerminalCommandTracking(tracking)
},
finishTerminalCommandTracking: async (commandId) =>
await aiFileChangeService.finishTerminalCommandTracking(commandId),
discardTerminalCommandTracking: (commandId) => {
aiFileChangeService.discardTerminalCommandTracking(commandId)
},
chatMessageLogStore: input.runtime.storageService.chatMessageLog,
isCancelled: () => controller.signal.aborted,
abortSignal: controller.signal,
@@ -19,29 +19,6 @@ const readCsvRows = async (filePath: string): Promise<Array<{ type: string; reco
return { type: type.trim(), recordId: recordId.trim(), amount: amount.trim() }
})
const isPathInsideRoot = (targetPath: string, rootPath: string): boolean => {
const resolvedTarget = path.resolve(targetPath)
const resolvedRoot = path.resolve(rootPath)
if (resolvedTarget === resolvedRoot) return true
const relativePath = path.relative(resolvedRoot, resolvedTarget)
return Boolean(relativePath) && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
}
const stripLeadingCd = (command: string): string =>
command.replace(/^\s*(?:cd|Set-Location)\s+(?:"[^"]+"|'[^']+'|[^\s;|&]+)\s*(?:&&|;)\s*/iu, '')
const VERIFY_COMMAND_HEAD =
/^(?:echo|cat|ls|dir|md5|md5sum|sha256sum|shasum|pwd|head|tail|wc|stat|file|type|Get-Content|Get-ChildItem)\b/iu
/** Read/list/hash oriented command after an optional leading cd — not expected to create file-change records. */
const isVerifyOrientedTerminalCommand = (command: string): boolean => {
const parts = stripLeadingCd(command)
.split(/\s*(?:&&|;)\s*/u)
.map((part) => part.trim())
.filter(Boolean)
return parts.length > 0 && parts.every((part) => VERIFY_COMMAND_HEAD.test(part))
}
describe('ai human live terminal backend', () => {
it('human-terminal-data-reconciliation-live', async () => {
await runWithStructuredOutputRetry(async () => {
@@ -74,44 +51,26 @@ describe('ai human live terminal backend', () => {
userInput:
'请对账 invoices.csv 和 payments.csv。为财务生成 reconciliation-exceptions.csv,列包括 type、recordId、amount,覆盖未开发票的订单和没有对应订单的付款。类型列请填写 unpaid_invoice(未付发票)或 unmatched_payment(无匹配付款)。告诉我匹配与异常的合计金额,不要修改任何输入文件。'
})
const fileChangeRecords = (
await runtime.storageService.aiFileChange.listRecords({})
).items
return {
output,
beforeHashes,
afterHashes: await Promise.all([hashFile(invoicesPath), hashFile(paymentsPath)]),
rows: await readCsvRows(path.join(runtime.workspaceRoot, 'reconciliation-exceptions.csv')),
events: runtime.eventCollector.snapshot(),
csvRecord: fileChangeRecords.find((record) =>
String(record.fileName || record.path || '').endsWith('reconciliation-exceptions.csv')
)
events: runtime.eventCollector.snapshot()
}
},
assert: async (result, runtime) => {
const starts = filterAiRunEventsByType(result.events, 'tool_call_started')
const toolNames = starts.map((event) => event.toolName)
const results = filterAiRunEventsByType(result.events, 'tool_call_result')
const terminalSteps = filterAiRunEventsByType(result.events, 'terminal_step_started')
const terminalRuns = filterAiRunEventsByType(result.events, 'tool_call_result')
const rowAmountKeys = result.rows
.map((row) => `${row.recordId}:${row.amount}`)
.sort()
const hasMeaningfulTypes = result.rows.every((row) => row.type.trim().length > 0)
// Only expect terminal_tool routing when a command's effective cwd is inside the
// workspace and the command is not a verify-only follow-up (cat/md5/ls after abs-path write).
const expectTerminalToolRecord = terminalSteps.some(
(event) =>
isPathInsideRoot(event.cwd, runtime.workspaceRoot) &&
!isVerifyOrientedTerminalCommand(event.command)
)
const routingOk =
!expectTerminalToolRecord ||
(result.csvRecord?.status === 'applied' && result.csvRecord?.source === 'terminal_tool')
runtime.assertThat(
result.output.status === 'completed' &&
JSON.stringify(result.beforeHashes) === JSON.stringify(result.afterHashes) &&
results.some((event) => event.toolName === 'terminal_run' && event.result?.ok === true) &&
routingOk &&
terminalRuns.some((event) => event.toolName === 'terminal_run' && event.result?.ok === true) &&
rowAmountKeys.join(',') === 'INV-003:60000,INV-005:30000' &&
hasMeaningfulTypes,
{
@@ -120,10 +79,7 @@ describe('ai human live terminal backend', () => {
'The live model must compute the reconciliation via terminal and deliver the correct finance exception file.',
data: {
toolNames,
rows: result.rows,
csvSource: result.csvRecord?.source,
expectTerminalToolRecord,
terminalCwds: terminalSteps.map((event) => event.cwd)
rows: result.rows
}
}
)
@@ -78,14 +78,6 @@ describe('ai human mock-llm backend', () => {
},
listFileChangeRecords: async (request) =>
(await aiFileChangeService.listRecords(request)).items,
beginTerminalCommandTracking: async (tracking) => {
await aiFileChangeService.beginTerminalCommandTracking(tracking)
},
finishTerminalCommandTracking: async (commandId) =>
await aiFileChangeService.finishTerminalCommandTracking(commandId),
discardTerminalCommandTracking: (commandId) => {
aiFileChangeService.discardTerminalCommandTracking(commandId)
},
isCancelled: () => false,
sendEvent: (event) => runtime.eventCollector.add(event),
recordLlmIo: (record) => runtime.eventCollector.addLlmIo(record),
@@ -196,14 +188,6 @@ describe('ai human mock-llm backend', () => {
},
listFileChangeRecords: async (request) =>
(await aiFileChangeService.listRecords(request)).items,
beginTerminalCommandTracking: async (tracking) => {
await aiFileChangeService.beginTerminalCommandTracking(tracking)
},
finishTerminalCommandTracking: async (commandId) =>
await aiFileChangeService.finishTerminalCommandTracking(commandId),
discardTerminalCommandTracking: (commandId) => {
aiFileChangeService.discardTerminalCommandTracking(commandId)
},
isCancelled: () => false,
sendEvent: (event) => runtime.eventCollector.add(event),
recordLlmIo: (record) => runtime.eventCollector.addLlmIo(record),
@@ -65,14 +65,6 @@ describe('ai human mock-llm backend', () => {
},
listFileChangeRecords: async (request) =>
(await aiFileChangeService.listRecords(request)).items,
beginTerminalCommandTracking: async (tracking) => {
await aiFileChangeService.beginTerminalCommandTracking(tracking)
},
finishTerminalCommandTracking: async (commandId) =>
await aiFileChangeService.finishTerminalCommandTracking(commandId),
discardTerminalCommandTracking: (commandId) => {
aiFileChangeService.discardTerminalCommandTracking(commandId)
},
isCancelled: () => false,
sendEvent: (event) => runtime.eventCollector.add(event),
recordLlmIo: (record) => runtime.eventCollector.addLlmIo(record),
@@ -35,14 +35,6 @@ const buildDeps = async (input: {
},
listFileChangeRecords: async (request) =>
(await aiFileChangeService.listRecords(request)).items,
beginTerminalCommandTracking: async (tracking) => {
await aiFileChangeService.beginTerminalCommandTracking(tracking)
},
finishTerminalCommandTracking: async (commandId) =>
aiFileChangeService.finishTerminalCommandTracking(commandId),
discardTerminalCommandTracking: (commandId) => {
aiFileChangeService.discardTerminalCommandTracking(commandId)
},
isCancelled: () => input.controller.signal.aborted,
abortSignal: input.controller.signal,
sendEvent: (event) => input.runtime.eventCollector.add(event),
@@ -1,172 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { describe, it } from 'vitest'
import type { AiRunEvent } from '../../../../../src/shared/types'
import type { TerminalToolContext } from '../../../../../src/main/services/ai/types'
import { createHumanRuntimeCache } from '../../../../../src/main/services/ai/human/node/utils/runtimeCache'
import { buildTerminalCapabilityTools } from '../../../../../src/main/services/ai/human/tools/terminal/terminalTools'
import type { TerminalSessionManager } from '../../../../../src/main/services/ai/human/tools/terminal/TerminalSessionManager'
import { createTerminalToolHarness } from '../../../../harness/backend/aiToolHarness'
import { runBackendCase } from '../../../../harness/backend/run-backend-case'
import { filterEventsByType } from '../testHelpers'
type TerminalRunCommandResult = {
terminalId: string
reason?: string
}
const isWindows = process.platform === 'win32'
describe('ai tool terminal backend', () => {
it('terminal-inline-edit-artifact-contract', async () => {
await runBackendCase({
caseId: 'terminal-inline-edit-artifact-contract',
harnessMode: 'tool-real',
domain: 'ai',
suite: 'tool',
groups: ['terminal'],
description: '验证终端命令完成后立即记录本命令产生的文件变化。',
fixtures: [],
runMode: 'single',
execute: async (runtime) => {
const sourceDir = path.join(runtime.workspaceRoot, 'generated')
await fs.mkdir(sourceDir, { recursive: true })
await fs.writeFile(path.join(runtime.workspaceRoot, 'notes.txt'), 'before\n')
await fs.writeFile(path.join(runtime.workspaceRoot, 'replacement.txt'), 'after\n')
const harness = createTerminalToolHarness(runtime)
try {
const updateExisting = await harness.invoke<TerminalRunCommandResult>('terminal_run', {
command: isWindows
? 'Copy-Item -LiteralPath replacement.txt -Destination notes.txt'
: 'cp replacement.txt notes.txt'
})
const createNew = await harness.invoke<TerminalRunCommandResult>('terminal_run', {
terminalId: updateExisting.terminalId,
command: isWindows
? 'New-Item -ItemType File -Path generated/output.txt'
: 'touch generated/output.txt'
})
return {
updateExisting,
createNew,
notesContent: await fs.readFile(path.join(runtime.workspaceRoot, 'notes.txt'), 'utf8'),
createdExists: await fs
.access(path.join(runtime.workspaceRoot, 'generated', 'output.txt'))
.then(() => true)
.catch(() => false),
records: await harness.ctx.listFileChangeRecords({ runId: runtime.runId }),
artifactEvents: filterEventsByType(runtime.eventCollector.snapshot(), 'file_artifact'),
approvalEvents: filterEventsByType(
runtime.eventCollector.snapshot(),
'approval_batch_required'
)
}
} finally {
await harness.release()
}
},
assert: async (result, runtime) => {
runtime.assertThat(
!result.updateExisting.reason &&
!result.createNew.reason &&
result.approvalEvents.length === 0 &&
result.notesContent === 'after\n' &&
result.createdExists &&
!Object.hasOwn(result.updateExisting, 'artifacts') &&
!Object.hasOwn(result.createNew, 'artifacts') &&
result.records.length === 2 &&
result.records.every((record) => record.source === 'terminal_tool') &&
result.records.every((record) => record.runId === runtime.runId) &&
result.artifactEvents.length === 2,
{
name: 'terminal-command-scoped-change-capture',
message: '终端工具应以命令前后快照记录真实文件变化,不依赖顶层 Run 收尾扫描。',
data: result
}
)
}
})
})
it('terminal-tracking-start-failure-cleanup', async () => {
await runBackendCase({
caseId: 'terminal-tracking-start-failure-cleanup',
harnessMode: 'tool-real',
domain: 'ai',
suite: 'tool',
groups: ['terminal'],
description: '验证终端启动失败时丢弃已创建的命令级文件追踪。',
fixtures: [],
runMode: 'single',
execute: async (runtime) => {
const baseCtx = runtime.createAiRunContext()
const events: AiRunEvent[] = []
const begunTrackingIds: string[] = []
const discardedTrackingIds: string[] = []
let finishCalls = 0
const ctx = {
runId: runtime.runId,
platform: baseCtx.platform,
checkCancelled: baseCtx.checkCancelled,
humanRuntimeOverrides: baseCtx.humanRuntimeOverrides,
sendEvent: (event: AiRunEvent) => events.push(event),
saveFileChangeRecord: baseCtx.saveFileChangeRecord,
beginTerminalCommandTracking: async ({ commandId }) => {
begunTrackingIds.push(commandId)
},
finishTerminalCommandTracking: async () => {
finishCalls += 1
return []
},
discardTerminalCommandTracking: (commandId) => {
discardedTrackingIds.push(commandId)
}
} satisfies TerminalToolContext
const failedSessionManager = {
list: () => [],
run: async () => {
throw new Error('terminal startup failed')
}
} as unknown as TerminalSessionManager
const terminalRun = buildTerminalCapabilityTools({
ctx,
cache: createHumanRuntimeCache(),
runId: runtime.runId,
partnerId: 'test-session',
taskId: runtime.taskId,
workspaceRootPath: runtime.workspaceRoot,
workspaceId: 'test-workspace',
sessionManager: failedSessionManager,
emitActivity: () => undefined
}).tools.find((tool) => tool.name === 'terminal_run')
if (!terminalRun) throw new Error('terminal_run tool is missing')
const result = await terminalRun.invoke({
command: isWindows ? 'Get-Location' : 'pwd',
commandExplanation: '验证终端启动失败时的追踪清理。'
})
return {
result,
begunTrackingIds,
discardedTrackingIds,
finishCalls,
artifactEvents: events.filter((event) => event.type === 'file_artifact')
}
},
assert: async (result, runtime) => {
runtime.assertThat(
result.begunTrackingIds.length === 1 &&
result.discardedTrackingIds.length === 1 &&
result.discardedTrackingIds[0] === result.begunTrackingIds[0] &&
result.finishCalls === 0 &&
result.artifactEvents.length === 0,
{
name: 'terminal-startup-failure-discards-file-tracking',
message: '终端启动失败后必须丢弃命令快照,不得保存修改记录或发送文件 artifact。',
data: result
}
)
}
})
})
})
@@ -1,7 +1,6 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { describe, it } from 'vitest'
import type { AiFileChangeRecord, AiRunEvent } from '../../../../../src/shared/types'
import { createTerminalToolHarness } from '../../../../harness/backend/aiToolHarness'
import { runBackendCase } from '../../../../harness/backend/run-backend-case'
@@ -31,42 +30,13 @@ describe('ai tool terminal backend', () => {
suite: 'tool',
groups: ['terminal'],
description:
'验证终端在工作区根目录执行命令、长命令让出控制权并可增量读取、文件追踪与退出码;允许后台分离执行。',
'验证终端在用户主目录执行命令、长命令让出控制权并可增量读取、退出码与分离执行。',
fixtures: [],
runMode: 'single',
execute: async (runtime) => {
await fs.mkdir(path.join(runtime.workspaceRoot, 'nested'))
const harness = createTerminalToolHarness(runtime, {}, { yieldMs: 2000 })
const waitForTrackedRecords = async (
fileNames: readonly string[]
): Promise<AiFileChangeRecord[]> => {
for (let attempt = 0; attempt < 60; attempt += 1) {
const records = await harness.ctx.listFileChangeRecords({ runId: runtime.runId })
if (fileNames.every((fileName) => records.some((record) => record.fileName === fileName))) {
return records
}
await new Promise<void>((resolve) => setTimeout(resolve, 50))
}
throw new Error(`terminal change records were not saved: ${fileNames.join(', ')}`)
}
const waitForArtifactEvents = async (
fileNames: readonly string[]
): Promise<Extract<AiRunEvent, { type: 'file_artifact' }>[]> => {
for (let attempt = 0; attempt < 60; attempt += 1) {
const events = runtime.eventCollector
.snapshot()
.filter((event): event is Extract<AiRunEvent, { type: 'file_artifact' }> =>
event.type === 'file_artifact'
)
if (fileNames.every((fileName) => events.some((event) => event.fileName === fileName))) {
return events
}
await new Promise<void>((resolve) => setTimeout(resolve, 50))
}
throw new Error(`terminal artifact events were not emitted: ${fileNames.join(', ')}`)
}
const pwdAtRoot = await harness.invoke<TerminalRunResult>('terminal_run', {
command: isWindows ? 'Get-Location' : 'pwd'
})
@@ -114,14 +84,22 @@ describe('ai tool terminal backend', () => {
maxWaitSeconds: 1
})
const watchedElapsedMs = Date.now() - watchedStartedAt
const trackedFileNames = [
'terminal-completed.txt',
'terminal-running.txt',
'terminal-nonzero.txt'
] as const
const records = await waitForTrackedRecords(trackedFileNames)
const artifactEvents = await waitForArtifactEvents(trackedFileNames)
await harness.release()
const writtenFiles = {
completed: await fs.readFile(
path.join(runtime.workspaceRoot, 'terminal-completed.txt'),
'utf8'
),
running: await fs.readFile(
path.join(runtime.workspaceRoot, 'terminal-running.txt'),
'utf8'
),
nonzero: await fs.readFile(
path.join(runtime.workspaceRoot, 'terminal-nonzero.txt'),
'utf8'
)
}
const fileChangeRecords = await harness.ctx.listFileChangeRecords({ runId: runtime.runId })
return {
pwdAtRoot,
cdNested,
@@ -134,8 +112,8 @@ describe('ai tool terminal backend', () => {
redirectAllowed,
watched,
watchedElapsedMs,
records,
artifactEvents
writtenFiles,
fileChangeRecords
}
},
assert: async (result, runtime) => {
@@ -182,31 +160,18 @@ describe('ai tool terminal backend', () => {
runtime.assertThat(
result.redirectAllowed.status === 'succeeded' &&
result.redirectAllowed.output.includes('redirect-ok') &&
result.nonZero.status === 'failed',
result.nonZero.status === 'failed' &&
result.writtenFiles.completed.includes('completed') &&
result.writtenFiles.running.includes('running') &&
result.writtenFiles.nonzero.includes('nonzero') &&
result.fileChangeRecords.length === 0,
{
name: 'terminal-returns-status-and-allows-fd-merge-redirection',
message: '命令应返回 succeeded/failed 状态,带 2>&1 fd 合并重定向的验证命令应正常执行。',
name: 'terminal-returns-status-writes-files-without-apply-records',
message:
'命令应返回 succeeded/failed 状态,写文件只落磁盘,不产生修改记录;带 2>&1 的验证命令应正常执行。',
data: result
}
)
const trackedFileNames = new Set(result.records.map((record) => record.fileName))
const artifactFileNames = new Set(result.artifactEvents.map((event) => event.fileName))
runtime.assertThat(
['terminal-completed.txt', 'terminal-running.txt', 'terminal-nonzero.txt'].every(
(fileName) => trackedFileNames.has(fileName) && artifactFileNames.has(fileName)
) &&
result.records
.filter((record) => trackedFileNames.has(record.fileName))
.every((record) => record.source === 'terminal_tool' && record.runId === runtime.runId),
{
name: 'terminal-finalizes-command-scoped-file-changes',
message: '命令完成和非零退出都必须按命令窗口保存文件变化并发出当前 run 的 artifact。',
data: {
records: result.records,
artifactEvents: result.artifactEvents
}
}
)
}
})
})
@@ -47,7 +47,7 @@ const createRecord = (input: Partial<AiFileChangeRecord>): AiFileChangeRecord =>
partnerId: input.partnerId || 'partner-1',
runId: input.runId || 'run-1',
status: input.status || 'applied',
capability: input.capability || 'terminal_exec',
capability: input.capability || 'text_file',
path: input.path || '',
fileName: input.fileName || 'target.txt',
operation: input.operation || 'updated',
@@ -89,86 +89,6 @@ describe('ai file change service', () => {
}
})
it('skips terminal tracking when effective cwd is outside workspace roots', async () => {
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'ncurator-outside-'))
const workspace = tempDir
const targetPath = path.join(workspace, 'ignored.txt')
try {
await service.beginTerminalCommandTracking({
commandId: 'outside-command',
runId: 'outside-run',
partnerId: 'partner-1',
cwd: outside,
workspaceRoots: [workspace],
command: `printf x > "${targetPath}"`
})
await fs.writeFile(targetPath, 'should not be tracked from outside cwd')
const completed = await service.finishTerminalCommandTracking('outside-command')
expect(completed).toEqual([])
expect((await service.listRecords({ runId: 'outside-run' })).items).toEqual([])
} finally {
await fs.rm(outside, { recursive: true, force: true })
}
})
it('tracks changes when effective cwd is inside a workspace root after leading cd', async () => {
const targetPath = path.join(tempDir, 'from-cd.txt')
await service.beginTerminalCommandTracking({
commandId: 'cd-command',
runId: 'cd-run',
partnerId: 'partner-1',
cwd: tempDir,
workspaceRoots: [tempDir],
command: `cd "${tempDir}" && printf 'ok\\n' > from-cd.txt`
})
await fs.writeFile(targetPath, 'ok\n')
const completed = await service.finishTerminalCommandTracking('cd-command')
expect(completed).toHaveLength(1)
expect(completed[0]?.operation).toBe('created')
expect(completed[0]?.source).toBe('terminal_tool')
expect(completed[0]?.path).toBe(targetPath)
})
it('captures terminal command changes and materializes text diff before the run result is shown', async () => {
const targetPath = path.join(tempDir, 'notes.txt')
await fs.writeFile(targetPath, 'line 1\nline 2\nline 3\n')
await service.beginTerminalCommandTracking({
commandId: 'scan-command',
runId: 'scan-run',
partnerId: 'partner-1',
cwd: tempDir,
workspaceRoots: [tempDir],
command: 'replace notes.txt'
})
await fs.writeFile(targetPath, 'line 1\nchanged\nline 3\n')
const completed = await service.finishTerminalCommandTracking('scan-command')
expect(completed).toHaveLength(1)
expect(completed[0]?.operation).toBe('updated')
expect(completed[0]?.previewPath).toBeTruthy()
expect(completed[0]?.beforeObjectHash).not.toBe(completed[0]?.afterObjectHash)
const listed = await service.listRecords({ runId: 'scan-run' })
expect(listed.items[0]?.previewPath).toBe(completed[0]?.previewPath)
expect(listed.items[0]?.insertCount).toBe(1)
expect(listed.items[0]?.deleteCount).toBe(1)
const opened = await service.getRecord({ changeId: completed[0]!.id })
const artifact = JSON.parse(await fs.readFile(opened.record!.previewPath!, 'utf8')) as Record<
string,
unknown
>
expect(artifact.version).toBe(2)
expect(artifact).not.toHaveProperty('afterLines')
expect(opened.record?.insertCount).toBe(1)
expect(opened.record?.deleteCount).toBe(1)
const reopened = await service.getRecord({ changeId: completed[0]!.id })
expect(reopened.record?.previewPath).toBe(opened.record?.previewPath)
})
it('stores direct tool snapshots immediately without a run-end scan', async () => {
const targetPath = path.join(tempDir, 'deck.pptx')
const previewPath = path.join(tempDir, '.ncurator', 'runtime', 'ppt-diff.json')
@@ -184,8 +104,7 @@ describe('ai file change service', () => {
fileName: 'deck.pptx',
operation: 'created',
previewPath
}),
tempDir
})
)
const completed = await service.getRecord({ changeId: 'ppt-change' })
@@ -247,77 +166,6 @@ describe('ai file change service', () => {
expect(completed.record?.error).toBe(NO_EFFECTIVE_FILE_CHANGE_MESSAGE)
})
it('captures create delete and rename in one terminal command', async () => {
const deletedPath = path.join(tempDir, 'deleted.txt')
const renamedBeforePath = path.join(tempDir, 'before-name.txt')
const renamedAfterPath = path.join(tempDir, 'after-name.txt')
await fs.writeFile(deletedPath, 'delete me')
await fs.writeFile(renamedBeforePath, 'unique rename content')
await service.beginTerminalCommandTracking({
commandId: 'operations-command',
runId: 'operations-run',
partnerId: 'partner-1',
cwd: tempDir,
workspaceRoots: [tempDir],
command: 'organize files'
})
await fs.rm(deletedPath)
await fs.rename(renamedBeforePath, renamedAfterPath)
await fs.writeFile(path.join(tempDir, 'created.txt'), 'new file')
const completed = await service.finishTerminalCommandTracking('operations-command')
expect(completed.map((item) => item.operation).sort()).toEqual([
'created',
'deleted',
'renamed'
])
const renamed = completed.find((item) => item.operation === 'renamed')
expect(renamed?.beforePath).toBe(renamedBeforePath)
expect(renamed?.afterPath).toBe(renamedAfterPath)
const openedRename = await service.getRecord({ changeId: renamed!.id })
expect(openedRename.record?.previewPath).toBeUndefined()
})
it('allows overlapping terminal command tracking without cross-run filtering', async () => {
const targetPath = path.join(tempDir, 'overlap.txt')
await fs.writeFile(targetPath, 'before')
await Promise.all([
service.beginTerminalCommandTracking({
commandId: 'command-a',
runId: 'run-a',
partnerId: 'partner-a',
cwd: tempDir,
workspaceRoots: [tempDir],
command: 'write overlap.txt'
}),
service.beginTerminalCommandTracking({
commandId: 'command-b',
runId: 'run-b',
partnerId: 'partner-b',
cwd: tempDir,
workspaceRoots: [tempDir],
command: 'write overlap.txt'
})
])
await fs.writeFile(targetPath, 'after')
const [changesA, changesB] = await Promise.all([
service.finishTerminalCommandTracking('command-a'),
service.finishTerminalCommandTracking('command-b')
])
expect(changesA).toHaveLength(1)
expect(changesB).toHaveLength(1)
expect(changesA[0]?.runId).toBe('run-a')
expect(changesB[0]?.runId).toBe('run-b')
expect(
(await service.listRecords({ partnerId: 'partner-a', runId: 'run-a' })).items
).toHaveLength(1)
expect(
(await service.listRecords({ partnerId: 'partner-b', runId: 'run-b' })).items
).toHaveLength(1)
})
it('restores deleted and renamed files during revert', async () => {
const deletedPath = path.join(tempDir, 'deleted.txt')
const deletedSnapshot = path.join(tempDir, 'deleted.snapshot')
@@ -3,8 +3,7 @@ import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
resolveLeadingCommandCwd,
resolveTerminalTrackingCwd
resolveLeadingCommandCwd
} from '../../../../src/main/services/ai/human/tools/terminal/resolveLeadingCommandCwd'
const tempRoots: string[] = []
@@ -87,32 +86,6 @@ describe('resolveLeadingCommandCwd', () => {
).resolves.toBeUndefined()
})
it('builds tracking cwd from session start plus leading cd', async () => {
const home = await createRoot()
const workspace = await createRoot()
await expect(
resolveTerminalTrackingCwd({
command: `printf x > "${path.join(workspace, 'a.txt')}"`,
sessionStartPath: home
})
).resolves.toBe(path.resolve(home))
await expect(
resolveTerminalTrackingCwd({
command: `cd "${workspace}" && printf x > a.txt`,
sessionStartPath: home
})
).resolves.toBe(path.resolve(workspace))
await expect(
resolveTerminalTrackingCwd({
command: `Set-Location -LiteralPath '${workspace}'; Set-Content a.txt x`,
sessionStartPath: home
})
).resolves.toBe(path.resolve(workspace))
})
it('resolves PowerShell -LiteralPath / -Path and cmd-style cd /d', async () => {
const root = await createRoot()
const nested = path.join(root, 'nested')
@@ -117,7 +117,8 @@ describe('SQLite migration contract', () => {
upgraded.close()
expect(migrationRows).toHaveLength(CURRENT_SCHEMA_VERSION)
expect(migrationRows.at(-1)).toEqual({ version: 10, name: 'upgrade_from_v9' })
expect(migrationRows).toContainEqual({ version: 10, name: 'upgrade_from_v9' })
expect(migrationRows.at(-1)).toEqual({ version: 11, name: 'drop_terminal_file_change_records' })
expect(partnerTable).toEqual({ name: 'partner' })
expect(sessionTable).toBeUndefined()
expect(searchTurnTable).toBeUndefined()
@@ -209,7 +210,7 @@ describe('SQLite migration contract', () => {
current.initialize()
current
.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)')
.run(11, 'future_schema', 1)
.run(12, 'future_schema', 1)
current.close()
const sqlite = new SqliteCore(databasePath)
@@ -220,15 +221,15 @@ describe('SQLite migration contract', () => {
thrown = error
}
expect(thrown).toBeInstanceOf(DatabaseVersionTooNewError)
expect(thrown).toMatchObject({ databaseVersion: 11, supportedDatabaseVersion: 10 })
expect(thrown).toMatchObject({ databaseVersion: 12, supportedDatabaseVersion: 11 })
const inspected = openRawDatabase(databasePath)
const rows = inspected
.prepare('SELECT version, name FROM schema_migrations ORDER BY version')
.all()
inspected.close()
expect(rows.at(-1)).toEqual({ version: 11, name: 'future_schema' })
expect(rows).toHaveLength(11)
expect(rows.at(-1)).toEqual({ version: 12, name: 'future_schema' })
expect(rows).toHaveLength(12)
})
it('accepts the known version 2 alias and rejects unknown names or gaps', async () => {
@@ -264,6 +265,40 @@ describe('SQLite migration contract', () => {
expect(() => new SqliteCore(gapPath).initialize()).toThrow(InvalidMigrationHistoryError)
})
it('drops leftover terminal file-change rows on upgrade to v11', async () => {
const databasePath = await createDatabasePath()
const prepared = new SqliteCore(databasePath)
prepared.initialize()
prepared.prepare('DELETE FROM schema_migrations WHERE version = 11').run()
prepared
.prepare(
`INSERT INTO partner (
id, name, avatar_shape, avatar_color, pinned, created_at, updated_at, last_viewed_at
) VALUES (?, ?, ?, ?, 0, 1, 1, 1)`
)
.run('partner-1', 'partner', 'circle', 'blue')
const insertChange = prepared.prepare(
`INSERT INTO ai_file_change (
id, partner_id, run_id, status, capability, path, file_name, operation, source,
change_count, insert_count, delete_count, created_at, updated_at
) VALUES (?, 'partner-1', 'run-1', 'applied', ?, '/tmp/a.txt', ?, 'updated', ?, 1, 0, 0, 1, 1)`
)
insertChange.run('keep-text', 'text_file', 'keep.txt', 'text_tool')
insertChange.run('drop-terminal-source', 'text_file', 'tracked.txt', 'terminal_tool')
insertChange.run('drop-scan-source', 'text_file', 'scanned.txt', 'workspace_scan')
insertChange.run('drop-terminal-capability', 'terminal_exec', 'exec.bin', 'text_tool')
prepared.close()
const upgraded = new SqliteCore(databasePath)
upgraded.initialize()
const rows = upgraded
.prepare('SELECT id, source, capability FROM ai_file_change ORDER BY id')
.all()
upgraded.close()
expect(rows).toEqual([{ id: 'keep-text', source: 'text_tool', capability: 'text_file' }])
})
it('keeps the published migration lock valid and rejects a changed locked migration', async () => {
const script = path.resolve('scripts/release/shared/sqlite-migration-lock.mjs')
const { stdout } = await execFileAsync(process.execPath, [script], { cwd: process.cwd() })
@@ -263,7 +263,8 @@ describe('sqlite storage core and stores', () => {
{ version: 7, name: 'ai_run_execution_permission' },
{ version: 8, name: 'drop_llm_strategy_preference' },
{ version: 9, name: 'repair_ai_runtime_system_notifications_column' },
{ version: 10, name: 'upgrade_from_v9' }
{ version: 10, name: 'upgrade_from_v9' },
{ version: 11, name: 'drop_terminal_file_change_records' }
])
const legacyTables = sqlite
@@ -1,174 +0,0 @@
import { describe, it } from 'vitest'
import { runE2ECase } from '../../../../harness/e2e/run-e2e-case'
import {
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS,
assertNoWorkbenchFakeSignals,
createWorkbenchNewSession,
getWorkbenchLocator,
recordWorkbenchAiEvidence,
selectWorkbenchWorkspace,
sendWorkbenchMessage,
setupWorkbenchWithTempWorkspace,
waitForWorkbenchAiRunCompletion,
workbenchSelectors
} from '../../../../harness/e2e/workbench'
const CASE_ID = 'file-change-run-scope'
const FIRST_FILE = 'run-scope-first.txt'
const SECOND_FILE = 'run-scope-second.txt'
const writeFileViaTerminalPrompt = (fileName: string, contents: string): string => {
const command =
process.platform === 'win32'
? `Set-Content -LiteralPath '${fileName}' -Value '${contents}' -NoNewline`
: `printf '${contents}' > ${fileName}`
return `请只使用 terminal_run,在工作区根目录执行:${command}。不要修改其他文件。`
}
const getRunStarted = (events: import('../../../../../src/shared/types').AiRunEvent[]) => {
const event = events.find(
(item): item is Extract<import('../../../../../src/shared/types').AiRunEvent, { type: 'run_started' }> =>
item.type === 'run_started'
)
if (!event) throw new Error('run_started event is missing')
return event
}
const getApplyEntryForFile = async (page: import('playwright').Page, fileName: string) => {
const entry = page
.locator(`[data-testid^="${workbenchSelectors.assistantTurnApplyPrefix}-"]`)
.filter({ hasText: fileName })
.first()
await entry.waitFor({ state: 'visible', timeout: 30_000 })
return entry
}
const readChangedFileList = async (page: import('playwright').Page) => {
await getWorkbenchLocator(page, workbenchSelectors.workspaceExplorerToggle).click()
const explorer = getWorkbenchLocator(page, workbenchSelectors.workspaceExplorerOverlay)
await explorer.waitFor({ state: 'visible', timeout: 30_000 })
const text = ((await explorer.textContent()) || '').trim()
await page.keyboard.press('Escape')
return text
}
describe('workbench result e2e', () => {
it(
CASE_ID,
async () => {
await runE2ECase({
caseId: CASE_ID,
domain: 'workbench',
suite: 'result',
groups: ['apply'],
description:
'真实 UI 在不同伙伴的两个 run 分别写入文件后,右侧本次运行改动不得串入另一个 run 的记录。',
fixtures: ['tests/fixtures/workspace'],
serviceMode: 'real',
execution: 'live',
windowMode: 'visible',
runMode: 'single',
execute: async (runtime) => {
const { page } = runtime
const setup = await setupWorkbenchWithTempWorkspace(page, { caseId: CASE_ID })
const firstStartedAtMs = Date.now()
await sendWorkbenchMessage(page, writeFileViaTerminalPrompt(FIRST_FILE, 'first-run'))
const firstEvidence = await waitForWorkbenchAiRunCompletion(
runtime,
firstStartedAtMs,
setup.env.modelName,
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS,
{ page, blockerPolicy: 'auto_approve' }
)
recordWorkbenchAiEvidence(runtime, firstEvidence, 0.8)
const firstRun = getRunStarted(firstEvidence.events)
const firstEntry = await getApplyEntryForFile(page, FIRST_FILE)
const firstCardText = ((await firstEntry.textContent()) || '').trim()
await firstEntry.click()
const firstChangedFileList = await readChangedFileList(page)
await createWorkbenchNewSession(page)
await selectWorkbenchWorkspace(page, setup.workspaceName)
const secondStartedAtMs = Date.now()
await sendWorkbenchMessage(page, writeFileViaTerminalPrompt(SECOND_FILE, 'second-run'))
const secondEvidence = await waitForWorkbenchAiRunCompletion(
runtime,
secondStartedAtMs,
setup.env.modelName,
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS,
{ page, blockerPolicy: 'auto_approve' }
)
recordWorkbenchAiEvidence(runtime, secondEvidence, 0.8)
const secondRun = getRunStarted(secondEvidence.events)
const secondEntry = await getApplyEntryForFile(page, SECOND_FILE)
const secondCardText = ((await secondEntry.textContent()) || '').trim()
await secondEntry.click()
const secondChangedFileList = await readChangedFileList(page)
await getWorkbenchLocator(
page,
`${workbenchSelectors.partnerItemPrefix}-${firstRun.partnerId}`
).click()
const restoredFirstEntry = await getApplyEntryForFile(page, FIRST_FILE)
await restoredFirstEntry.click()
const restoredFirstChangedFileList = await readChangedFileList(page)
return {
firstRunId: firstRun.runId,
secondRunId: secondRun.runId,
firstPartnerId: firstRun.partnerId,
secondPartnerId: secondRun.partnerId,
firstCardText,
secondCardText,
firstChangedFileList,
secondChangedFileList,
restoredFirstChangedFileList,
firstTerminalCalls: firstEvidence.events.filter(
(event) => event.type === 'tool_call_started' && event.toolName === 'terminal_run'
).length,
secondTerminalCalls: secondEvidence.events.filter(
(event) => event.type === 'tool_call_started' && event.toolName === 'terminal_run'
).length
}
},
assert: async (result, runtime) => {
const firstListIsIsolated =
result.firstChangedFileList.includes('本次运行改动 1') &&
result.firstChangedFileList.includes(FIRST_FILE) &&
!result.firstChangedFileList.includes(SECOND_FILE)
const secondListIsIsolated =
result.secondChangedFileList.includes('本次运行改动 1') &&
result.secondChangedFileList.includes(SECOND_FILE) &&
!result.secondChangedFileList.includes(FIRST_FILE)
const restoredFirstListIsIsolated =
result.restoredFirstChangedFileList.includes('本次运行改动 1') &&
result.restoredFirstChangedFileList.includes(FIRST_FILE) &&
!result.restoredFirstChangedFileList.includes(SECOND_FILE)
await runtime.assertThat(
result.firstRunId !== result.secondRunId &&
result.firstPartnerId !== result.secondPartnerId &&
result.firstCardText.includes(FIRST_FILE) &&
!result.firstCardText.includes(SECOND_FILE) &&
result.secondCardText.includes(SECOND_FILE) &&
!result.secondCardText.includes(FIRST_FILE) &&
firstListIsIsolated &&
secondListIsIsolated &&
restoredFirstListIsIsolated &&
result.firstTerminalCalls >= 1 &&
result.secondTerminalCalls >= 1,
{
name: 'workbench-file-change-run-scope',
message:
'每条 assistant 修改入口和右侧本次运行改动必须只显示自身 partnerId 与 runId 的文件记录。',
data: result
}
)
await assertNoWorkbenchFakeSignals(runtime)
}
})
},
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS * 2
)
})
@@ -1,125 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { describe, it } from 'vitest'
import { runE2ECase } from '../../../../harness/e2e/run-e2e-case'
import {
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS,
assertNoWorkbenchFakeSignals,
getWorkbenchLocator,
recordWorkbenchAiEvidence,
sendWorkbenchMessage,
setupWorkbenchWithTempWorkspace,
waitForApplyEntry,
waitForWorkbenchAiRunCompletion,
waitForWorkbenchApplyRecordStatus,
workbenchSelectors
} from '../../../../harness/e2e/workbench'
const CASE_ID = 'terminal-rename-change-notice-and-revert'
const BEFORE_NAME = 'workbench-preview.md'
const AFTER_NAME = 'workbench-preview-renamed.md'
describe('workbench result e2e', () => {
it(
CASE_ID,
async () => {
await runE2ECase({
caseId: CASE_ID,
domain: 'workbench',
suite: 'result',
groups: ['apply'],
description: '真实 AI 使用终端重命名文件后,验证扫描记录、重命名说明和确认回退。',
fixtures: ['tests/fixtures/workspace'],
serviceMode: 'real',
execution: 'live',
windowMode: 'visible',
runMode: 'single',
execute: async (runtime) => {
const { page } = runtime
const setup = await setupWorkbenchWithTempWorkspace(page, {
caseId: CASE_ID
})
const beforePath = path.join(setup.workspace.rootPath, BEFORE_NAME)
const afterPath = path.join(setup.workspace.rootPath, AFTER_NAME)
const beforeBytes = await fs.readFile(beforePath)
const startedAtMs = Date.now()
await sendWorkbenchMessage(
page,
`请只使用 terminal_run,在工作区根目录执行 mv ${BEFORE_NAME} ${AFTER_NAME}。不要修改文件内容,完成后确认新文件名存在。`
)
const evidence = await waitForWorkbenchAiRunCompletion(
runtime,
startedAtMs,
setup.env.modelName,
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS,
{ page, blockerPolicy: 'auto_approve' }
)
recordWorkbenchAiEvidence(runtime, evidence, 0.8)
const applyEntry = await waitForApplyEntry(page)
const applyEntryText = ((await applyEntry.textContent()) || '').trim()
await applyEntry.click()
const notice = getWorkbenchLocator(page, workbenchSelectors.applyChangeNotice)
await notice.waitFor({ state: 'visible', timeout: 30_000 })
const noticeText = ((await notice.textContent()) || '').trim()
const oldExistsAfterRename = await fs
.access(beforePath)
.then(() => true)
.catch(() => false)
const renamedBytes = await fs.readFile(afterPath)
await getWorkbenchLocator(page, workbenchSelectors.applyRevertButton).click()
await page.getByText('回退这次修改?').waitFor({ state: 'visible' })
const confirmText = ((await page.locator('.ant-modal').last().textContent()) || '').trim()
await getWorkbenchLocator(page, workbenchSelectors.applyRevertConfirmButton).click()
await waitForWorkbenchApplyRecordStatus(page, 'reverted')
const restoredBytes = await fs.readFile(beforePath)
const renamedExistsAfterRevert = await fs
.access(afterPath)
.then(() => true)
.catch(() => false)
return {
applyEntryText,
noticeText,
confirmText,
oldExistsAfterRename,
renamedBytesUnchanged: renamedBytes.equals(beforeBytes),
restoredBytesUnchanged: restoredBytes.equals(beforeBytes),
renamedExistsAfterRevert,
terminalCalls: evidence.events.filter(
(event) => event.type === 'tool_call_started' && event.toolName === 'terminal_run'
).length
}
},
assert: async (result, runtime) => {
await runtime.assertThat(
result.applyEntryText.includes(AFTER_NAME) &&
result.noticeText.includes('本次修改了文件名') &&
result.noticeText.includes(BEFORE_NAME) &&
result.noticeText.includes(AFTER_NAME) &&
result.noticeText.includes('文件内容没有变化') &&
!result.noticeText.includes('没有专用预览稿') &&
result.confirmText.includes('回退这次修改?') &&
result.confirmText.includes('文件将恢复到这次修改发生前的状态') &&
!result.oldExistsAfterRename &&
result.renamedBytesUnchanged &&
result.restoredBytesUnchanged &&
!result.renamedExistsAfterRevert &&
result.terminalCalls >= 1,
{
name: 'workbench-terminal-rename-notice-revert-real',
message:
'AI 通过终端重命名文件后,应保留命令级修改记录、展示前后文件名,并在确认后恢复原文件名。',
data: result
}
)
await assertNoWorkbenchFakeSignals(runtime)
}
})
},
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS
)
})
@@ -1,166 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { describe, it } from 'vitest'
import { runE2ECase } from '../../../../harness/e2e/run-e2e-case'
import {
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS,
assertNoWorkbenchFakeSignals,
getWorkbenchLocator,
getWorkbenchPrefixLocator,
recordWorkbenchAiEvidence,
sendWorkbenchMessage,
setupWorkbenchWithTempWorkspace,
waitForWorkbenchAiRunCompletion,
waitForWorkbenchApplyRecordStatus,
workbenchSelectors
} from '../../../../harness/e2e/workbench'
const CASE_ID = 'terminal-update-delete-change-and-revert'
const UPDATE_FILE = 'workbench-preview.md'
const DELETE_FILE = 'human-input.txt'
const MARKER = 'cedar-terminal-update-552'
const buildTerminalMutationPrompt = (workspaceRoot: string): string => {
if (process.platform === 'win32') {
// 单条命令内先切到工作区再改文件:追踪 cwd 依赖可解析的前导 Set-Location,
// 且 Windows 会话每次 terminal_run 都从用户主目录新建,不能依赖跨调用 cwd。
const command = [
`Set-Location -LiteralPath '${workspaceRoot}'`,
`Add-Content -LiteralPath '${UPDATE_FILE}' -Value "\`n${MARKER}" -Encoding utf8`,
`Remove-Item -LiteralPath '${DELETE_FILE}' -Force`
].join('; ')
return `请只使用一次 terminal_run,原样执行以下 PowerShell 命令(不要改写、不要拆成多条、不要使用其他工具):${command}。完成后确认 ${UPDATE_FILE} 已追加「${MARKER}」且 ${DELETE_FILE} 已删除。`
}
const command = [
`cd '${workspaceRoot}'`,
`printf '\\n${MARKER}' >> '${UPDATE_FILE}'`,
`rm -f '${DELETE_FILE}'`
].join('; ')
return `请只使用一次 terminal_run,原样执行以下命令(不要改写、不要拆成多条、不要使用其他工具):${command}。完成后确认 ${UPDATE_FILE} 已追加「${MARKER}」且 ${DELETE_FILE} 已删除。`
}
describe('workbench result e2e', () => {
it(
CASE_ID,
async () => {
await runE2ECase({
caseId: CASE_ID,
domain: 'workbench',
suite: 'result',
groups: ['apply'],
description: '真实 AI 使用终端修改文本内容并删除文件后,验证行级 Diff 预览、删除说明和两类回退。',
fixtures: ['tests/fixtures/workspace'],
serviceMode: 'real',
execution: 'live',
windowMode: 'visible',
runMode: 'single',
execute: async (runtime) => {
const { page } = runtime
const setup = await setupWorkbenchWithTempWorkspace(page, {
caseId: CASE_ID
})
const updatePath = path.join(setup.workspace.rootPath, UPDATE_FILE)
const deletePath = path.join(setup.workspace.rootPath, DELETE_FILE)
const updateBeforeBytes = await fs.readFile(updatePath)
const deleteBeforeBytes = await fs.readFile(deletePath)
const startedAtMs = Date.now()
await sendWorkbenchMessage(page, buildTerminalMutationPrompt(setup.workspace.rootPath))
const evidence = await waitForWorkbenchAiRunCompletion(
runtime,
startedAtMs,
setup.env.modelName,
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS,
{ page, blockerPolicy: 'auto_approve' }
)
recordWorkbenchAiEvidence(runtime, evidence, 0.8)
if (evidence.fileArtifactCount < 1) {
throw new Error(
`terminal mutation produced no file_artifact (count=${String(evidence.fileArtifactCount)}); tracking cwd likely missed the workspace`
)
}
const updateEntry = getWorkbenchPrefixLocator(
page,
workbenchSelectors.assistantTurnApplyPrefix
)
.filter({ hasText: UPDATE_FILE })
.first()
const deleteEntry = getWorkbenchPrefixLocator(
page,
workbenchSelectors.assistantTurnApplyPrefix
)
.filter({ hasText: DELETE_FILE })
.first()
await updateEntry.waitFor({ state: 'visible' })
await deleteEntry.waitFor({ state: 'visible' })
const updateEntryText = ((await updateEntry.textContent()) || '').trim()
const deleteEntryText = ((await deleteEntry.textContent()) || '').trim()
const updateAppliedBytes = await fs.readFile(updatePath, 'utf-8')
const deleteExistsAfterRun = await fs
.access(deletePath)
.then(() => true)
.catch(() => false)
await updateEntry.click()
const diffViewer = getWorkbenchLocator(page, workbenchSelectors.textDiffViewer)
await diffViewer.waitFor({ state: 'visible', timeout: 30_000 })
const diffText = ((await diffViewer.textContent()) || '').trim()
await getWorkbenchLocator(page, workbenchSelectors.applyRevertButton).click()
await page.getByText('回退这次修改?').waitFor({ state: 'visible' })
await getWorkbenchLocator(page, workbenchSelectors.applyRevertConfirmButton).click()
await waitForWorkbenchApplyRecordStatus(page, 'reverted')
const updateRevertedBytes = await fs.readFile(updatePath)
await deleteEntry.click()
const deleteNotice = getWorkbenchLocator(page, workbenchSelectors.applyChangeNotice)
await deleteNotice.waitFor({ state: 'visible', timeout: 30_000 })
const deleteNoticeText = ((await deleteNotice.textContent()) || '').trim()
await getWorkbenchLocator(page, workbenchSelectors.applyRevertButton).click()
await page.getByText('回退这次修改?').waitFor({ state: 'visible' })
await getWorkbenchLocator(page, workbenchSelectors.applyRevertConfirmButton).click()
await waitForWorkbenchApplyRecordStatus(page, 'reverted')
const deleteRestoredBytes = await fs.readFile(deletePath)
return {
updateEntryText,
deleteEntryText,
updateAppliedBytes,
deleteExistsAfterRun,
diffText,
updateRevertedToBaseline: updateRevertedBytes.equals(updateBeforeBytes),
deleteNoticeText,
deleteRestoredToBaseline: deleteRestoredBytes.equals(deleteBeforeBytes),
terminalCalls: evidence.events.filter(
(event) => event.type === 'tool_call_started' && event.toolName === 'terminal_run'
).length,
fileArtifactCount: evidence.fileArtifactCount
}
},
assert: async (result, runtime) => {
await runtime.assertThat(
result.fileArtifactCount >= 1 &&
result.updateEntryText.includes(UPDATE_FILE) &&
result.deleteEntryText.includes(DELETE_FILE) &&
result.updateAppliedBytes.includes(MARKER) &&
result.deleteExistsAfterRun === false &&
result.diffText.includes(MARKER) &&
!result.diffText.includes('没有专用预览稿') &&
result.updateRevertedToBaseline &&
result.deleteNoticeText.includes('本次删除了该文件') &&
result.deleteRestoredToBaseline &&
result.terminalCalls >= 1,
{
name: 'workbench-terminal-update-delete-revert-real',
message:
'AI 通过终端修改文本和删除文件后,应生成两条可回退记录;修改记录展示行级 Diff 且回退恢复原始 bytes,删除记录展示删除说明且回退恢复文件。',
data: result
}
)
await assertNoWorkbenchFakeSignals(runtime)
}
})
},
WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS
)
})
-1
View File
@@ -192,7 +192,6 @@ export const createTerminalToolHarness = (
taskId: runtime.taskId,
partnerId: contextInput.partnerId || contextInput.sessionId || 'test-partner',
workspaceRootPath: runtime.workspaceRoot,
workspaceRoots: ctx.workspaceRoots,
workspaceId: ctx.workspace?.workspaceId || 'test-workspace',
sessionManager,
emitActivity: (activity) => {
@@ -218,14 +218,6 @@ export const createRealAiDepsFactory = async (input: {
},
listFileChangeRecords: async (request) =>
(await aiFileChangeService.listRecords(request)).items,
beginTerminalCommandTracking: async (tracking) => {
await aiFileChangeService.beginTerminalCommandTracking(tracking)
},
finishTerminalCommandTracking: async (commandId) =>
await aiFileChangeService.finishTerminalCommandTracking(commandId),
discardTerminalCommandTracking: (commandId) => {
aiFileChangeService.discardTerminalCommandTracking(commandId)
},
isCancelled: contextInput.checkCancelledOverride || (() => false),
sendEvent: (event) => input.eventCollector.add(event),
recordLlmIo: (record) => input.eventCollector.addLlmIo(record),