diff --git a/.gitignore b/.gitignore index 338c0a7..050464e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ .prole-coder/ # Rust -/target/ +target/ # Node / pnpm / TypeScript node_modules/ @@ -30,6 +30,13 @@ pnpm-debug.log* yarn-debug.log* yarn-error.log* +# Python caches +__pycache__/ +*.py[cod] + +# Local mutable copies of tracked manual test fixtures +test/projects/agent_misc_tests_working/ + # OS files .DS_Store Thumbs.db diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..beb8736 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "ProleCoder: Extension Development Host", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/vscode/extension", + "${workspaceFolder}/test/projects/agent_misc_tests_working" + ], + "env": { + "PATH": "${workspaceFolder}/target/debug${pathSeparator}${env:PATH}", + "Path": "${workspaceFolder}/target/debug${pathSeparator}${env:Path}" + }, + "outFiles": [ + "${workspaceFolder}/vscode/extension/out/**/*.js" + ], + "preLaunchTask": "ProleCoder: build debug dependencies" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..8cdf18c --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,61 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "ProleCoder: build debug dependencies", + "dependsOrder": "sequence", + "dependsOn": [ + "ProleCoder: prepare manual test workspace", + "ProleCoder: build CLI", + "ProleCoder: build VS Code extension" + ], + "problemMatcher": [] + }, + { + "label": "ProleCoder: prepare manual test workspace", + "type": "shell", + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "if (-not (Test-Path -LiteralPath 'test/projects/agent_misc_tests_working')) { Copy-Item -LiteralPath 'test/projects/agent_misc_tests' -Destination 'test/projects/agent_misc_tests_working' -Recurse }" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [] + }, + { + "label": "ProleCoder: build CLI", + "type": "shell", + "command": "cargo", + "args": [ + "build", + "-p", + "prole-coder-cli" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "group": "build", + "problemMatcher": [] + }, + { + "label": "ProleCoder: build VS Code extension", + "type": "shell", + "command": "pnpm", + "args": [ + "--filter", + "prole-coder-vscode", + "build" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "group": "build", + "problemMatcher": "$tsc" + } + ] +} diff --git a/Cargo.lock b/Cargo.lock index fde29fa..81e9515 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -387,9 +403,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -626,6 +644,7 @@ name = "prole-coder-agent-core" version = "0.1.0" dependencies = [ "async-stream", + "base64", "futures-util", "ignore", "reqwest", @@ -1038,6 +1057,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1390,6 +1430,35 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/README.md b/README.md index d847d9d..f8167c5 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ DeepSeek API 默认支持上下文硬盘缓存。为了提高命中率: | 等级 | 示例 | 默认行为 | | --- | --- | --- | | Read | 读取文件、搜索、git status | 自动允许 | -| Write | apply_patch、格式化当前项目文件 | 展示 diff 后审批 | +| Write | apply_patch、格式化当前项目文件 | 受限 workspace 小规模代码 patch 默认允许;超过 5 个 `expectedFiles`、workspace policy 文件、高风险或显式写入审批路径展示 diff 后审批 | | Exec | 测试、构建、lint | 展示命令后审批或按规则允许 | | Network | 下载依赖、访问远程 API | 明确审批 | | Destructive | 删除、reset、清理未跟踪文件 | 总是审批,要求展示目标路径 | @@ -568,45 +568,27 @@ extension.ts ## 开发计划 -当前进度:Phase 1 Agent Core MVP 功能闭环、Phase 2 的 1M Context Capsule 核心收敛、Phase 3 的 VS Code 插件核心与共享 RPC 交互管线均已完成。Phase 4 的 VS Code 深度集成已完成,包含原 14 项能力以及 P4-15 到 P4-18 的 Codex-like UX 收敛:默认使用 VS Code 原生 Chat 右侧入口、简化审批 UX、保持连续会话心智并自动压缩历史上下文。DeepSeek provider、基础工具执行、Context Builder、Run Log、Turn Loop、CLI、RPC、审批、取消、真实 DeepSeek streaming/tool-call 验收、本地 fixture smoke、进程级 CLI smoke、小型真实仓库 CLI 联网验收、合并前测试收敛、Context Capsule、manifest、token estimator、attachments、provider summary、Run Log 体积控制、tool call JSON Schema 校验、200K/500K/900K 离线大上下文验收入口和 Phase 2e 展示型 demo 扩展均已完成;VS Code RPC server 启动监管、JSON-RPC request client、RPC 全双工 reader/writer 与事件发送队列、Sidebar Chat 事件渲染、Chat 输入发送真实 turn、真实审批回传、命令风险动态升级、Native diff editor patch 预览、Run List / resume、Context Capsule 可视化、命令子进程树清理、VSIX alpha 打包、extension-host 端到端验收、原生 `@prole` Chat Participant、自动上下文压缩和简化审批 UX 均已完成。之后进入 Phase 5:TUI 与生态扩展。 +当前进度:Phase 1 到 Phase 5 已完成;Phase 5:VS Code Codex-like UX 与开发工作流已完成原生 Chat、简化审批、自动上下文压缩、Output Channel、API key/model 配置、Git 工作流、Sidebar 连续会话 / Run 删除 / 折叠事件 UX、结构化 provider 配置错误恢复、真实试用回归修复、P5-15 第二批 UX 收敛、P5-16 第一批真实试用 UX 收敛、P5-17a 到 P5-17i 的交互细化,P5-18a 到 P5-18l 的 active work 单行状态、assistant 分段 / Activity 摘要和平凡工具过滤、steer 顺序与确认卡位置修复、pending steer queued 反馈、未知工具可恢复、steer 强指令注入、完成后中间过程按用户/steer 边界分段折叠、provider idle timeout 重试收口、已知工具 schema 错误可恢复、用户 / steer 消息不参与折叠、长 run timeline 裁剪 / 持久化边界与 run summary metadata 追踪,以及 P5-19 的四项目真实试用回归验收、Sidebar timeline 历史分页和合并前 UX backlog 收口。下一步进入 Phase 6:AGENT 性能调试;TUI 与生态扩展顺延到 Phase 7。 阶段完成口径:README 中某个 Phase 只有在 `docs/phase-tasks.md` 对应 Phase 下的所有任务都标记为 `[x]` 后,才能在高层开发计划中表述为“全部完成”。如果某阶段核心功能已完成但仍有 P1/P2 增强或发布/文档验收项未完成,README 必须继续把该阶段表述为进行中,并列出剩余任务。 ### Phase 0:项目章程 -- [x] 确定 `ProleCoder` 名称和 AGPL-3.0-or-later 许可证:项目显示名为 ProleCoder,仓库名和包名前缀固定为 `prole-coder`,许可证从 MIT 调整为更符合自由软件网络服务场景的 AGPL-3.0-or-later。 -- [x] 编写 README 技术方案、架构、开发计划和注意事项:README 已作为项目入口,覆盖定位、架构、环境配置、阶段计划和安全注意事项。 -- [x] 建立 Rust workspace:`agent-core`、`agent-rpc`、`cli`、`tui`。 -- [x] 建立 TypeScript/pnpm workspace:`packages/protocol` 和 `vscode/extension`。 -- [x] 建立基础环境配置:`rust-toolchain.toml`、`rustfmt.toml`、`tsconfig.base.json`、`.editorconfig`、`.gitattributes`、`.env.example`。 -- [x] 更新 `.gitignore`,排除本地状态、依赖目录、构建产物和密钥文件。 -- [x] 生成并保留 `Cargo.lock` 与 `pnpm-lock.yaml`:Rust 与 TypeScript 依赖解析结果已锁定,便于本机和 CI 复现。 -- [x] 在 Windows 本机跑通 `pnpm run check`:确认 Rust、TypeScript 和 VS Code 插件骨架在 Windows 开发环境中可完整检查。 -- [x] 建立 CI 骨架:GitHub Actions 已覆盖 Rust 格式化、Clippy、测试和 TypeScript workspace 检查。 -- [x] 建立 `CONTRIBUTING.md`、`CODE_OF_CONDUCT.md`、`SECURITY.md`:补齐开源协作、社区行为和漏洞报告的基础治理文件。 -- [x] 建立 `docs/` 设计文档目录和 `docs/adr/` 架构决策记录:README 保留总览,详细模块设计和关键取舍进入 docs。 -- [x] 设计正式 JSON-RPC 事件协议:定义 `agent.initialize`、`agent.sendTurn`、`agent.event`、审批、取消和错误响应等核心消息。 -- [x] 定义工具 schema、风险等级和审批模型:建立工具注册表、默认审批要求和 read/write/exec/network/destructive 风险分类。 +- [x] P0-1:项目身份、许可证与治理。 +- [x] P0-2:Rust / TypeScript workspace 与基础工程配置。 +- [x] P0-3:基础协议、工具 schema、风险等级与审批模型设计。 ### Phase 1:Agent Core MVP -- [x] DeepSeek provider 与 streaming 基础:实现 API adapter、data-only SSE parser、TurnProvider async/streaming 边界、CLI streaming wrapper、tool-call delta accumulator,并通过真实 streaming 与 forced tool-call live test 验收。 -- [x] Reasoning 与 Context Builder:实现 `reasoning_content` replay 状态机、基础 Context Builder 和 token 统计,并覆盖 replay 边界、token budget 与 `context.built` payload 测试。 -- [x] Workspace 工具执行层:实现 read/search/apply_patch/shell/git,覆盖路径约束、敏感路径拒绝、命令超时、结构化结果、`apply_patch` staging 失败恢复和工具取消信号。 -- [x] Run Log 与 summary:实现 `events.jsonl`、`summary.json`、基础脱敏、写入串行化和 `agent.listRuns`。 -- [x] Agent Turn Loop:串联 Context Builder、provider、reasoning、工具执行、审批、验证和 run log,并通过本地 fixture 端到端 smoke test。 -- [x] CLI `run` / `rpc` 最小闭环:支持 fixture/deepseek provider、工作区参数、JSON event、审批、验证命令、JSON-RPC error 输出、进程级 CLI fixture smoke 和小型真实仓库 CLI 联网验收。 -- [x] Agent RPC Server:实现 stdio 事件桥接、双向 request loop、真实 Turn Loop handler、实时事件输出、pending approval 队列、审批超时和取消语义。 -- [x] 审批前端基础:实现 CLI prompt、RPC approve/reject/cancel 分发、TypeScript 协议类型、TUI prompt 状态机和 VS Code modal approval adapter。 -- [x] Phase 1 合并前第一轮测试增强:完成 `pnpm run check` 基线验证、patch 失败恢复、reasoning 边界、CancellationToken 并发和 CLI event stream 顺序测试。 -- [x] 合并前测试基础设施收敛:提取共享 `agent-core::test_helpers::TestWorkspace`,统一当前分散在 agent-core、agent-rpc、cli、demo/live 测试中的临时工作区 helper。 -- [x] 合并前 live 测试配置收敛:统一 live API key 测试 helper,测试侧按 `PROLE_CODER_DEEPSEEK_API_KEY`、`DEEPSEEK_API_KEY`、`.secrets/deepseek-api-key` 的顺序读取。 -- [x] 合并前 RPC/CLI/protocol 验收补齐:补 RPC request loop pending approval 并发拒绝与 EOF shutdown 取消测试、CLI `rpc` 模式进程级 stdio smoke,以及 Rust/TypeScript/协议文档错误码交叉校验。 -- [x] 合并前最终验收:已运行 `pnpm run check`、`cargo test --workspace -- --list`、离线展示 demo、`git diff --check` 和敏感信息扫描;本轮 RPC/CLI/protocol 离线变更未新增必须阻塞合并的 DeepSeek live suite。 +- [x] P1-1:DeepSeek provider、streaming 与 reasoning 基础。 +- [x] P1-2:基础 Context Builder 与 workspace 工具执行层。 +- [x] P1-3:Run Log、Agent Turn Loop 与 CLI/RPC 最小闭环。 +- [x] P1-4:Agent RPC Server 与审批前端基础。 +- [x] P1-5:合并主线前测试、live 配置、RPC/CLI/protocol 和最终验收收敛。 说明:VS Code RPC server 启动监管与 JSON-RPC request client 已提前完成,归入 Phase 3 前置项;Agent Core MVP 验收不依赖完整 VS Code UI。 -细任务维护规则:高层阶段条目完成时,同步检查并更新 `docs/phase-tasks.md` 中对应的详细任务状态;详细设计文档新增后续任务时,也先在该索引里确定阶段。 +细任务维护规则:README 开发计划只保留阶段级和大任务摘要,使用 `P阶段-数字` 编号;每个任务的实现细节、验收命令、审查来源和 `P阶段-数字字母` 子任务拆分统一维护在 `docs/phase-tasks.md`。高层阶段条目标记完成前,必须确认该阶段在 `docs/phase-tasks.md` 的所有子项都已完成。 验收标准: @@ -617,14 +599,11 @@ extension.ts ### Phase 2:1M Context Capsule -- [x] Phase 2a-1:`read_file` 文件摘要元数据。已在 `read_file` 结果中加入完整文件的 `sha256` 和 `sizeBytes`,并同步 Rust/TypeScript 工具 result schema 与单元测试,为 manifest 文件摘要和工具结果一致性校验打底。 -- [x] Phase 2a-2:Context Capsule 数据模型与稳定 renderer。已定义 `ContextCapsule`、`ContextSection`、`ContextSectionItem`、`CachePlacement` 和 `context_capsule.v1` 稳定渲染流程;现有 provider 输入继续使用兼容字段 `content`,其值与 `rendered` 保持一致。 -- [x] Phase 2a-3:Workspace Manifest v0。已实现结构化 JSON、canonical `manifestHash`、默认 `maxEntries=500`、硬安全排除、默认工程排除、`.gitignore` + `.prole-coderignore`,并把 `workspace_manifest` 工具切换为可执行。 -- [x] Phase 2a-4:Context Builder manifest 接入。已在 Turn Loop 中自动生成 manifest summary 并放入 `StablePrefix`,同时扩展 `context.built` 事件输出 section token、manifest hash 和截断原因。 -- [x] Phase 2b:TokenEstimator 与稳定前缀。已建立 `TokenEstimator` trait、默认 `utf8_bytes` 估算器和 `CalibratedEstimator`,并在 `context.built` 中输出 `stablePrefixHash`、稳定前缀预算和校准 metadata;修改 `TurnSuffix` 不改变 `StablePrefix` 已有离线测试覆盖。 -- [x] Phase 2c:Attachments、provider summary 和 cache 实验。已让 `agent.sendTurn.attachments` 接入 file/selection/explicit_content/diagnostic 等来源,并加入数量、大小、路径和重复来源校验;Turn Loop 会写入 `provider.completed`,记录模型、duration、usage、cache hit/miss 和 streaming 摘要;DeepSeek streaming usage/cache 解析已有离线和 live 基础。 -- [x] Phase 2d:大仓库验收、超预算解释、Run Log 体积控制和 JSON Schema 校验层。已新增 200K、500K、900K ignored/manual Context Capsule benchmark;Context Builder 继续对 required context 超预算显式失败、optional context 写入 omitted reason;Run Log 写入入口统一执行脱敏和字符串/数组截断,并用 `runLogTruncation` 记录边界;tool call arguments 会先按工具注册表 JSON Schema 校验,再进入 typed deserialization、审批和执行。 -- [x] Phase 2e:合并主线前展示型 demo 扩展。已补齐 `demo-context`、`demo-truncation`、`demo-schema`、`demo-context-visual`、`demo-attachment` 和 `demo-live` provider summary 展示;这些入口默认 ignored,不进入普通 CI,作为人工观察和合并前验收使用。详细清单见 `docs/demos.md` 和 `docs/phase-tasks.md`。 +- [x] P2-1:Context Capsule 数据模型与 Workspace Manifest。 +- [x] P2-2:TokenEstimator 与稳定前缀。 +- [x] P2-3:Attachments、provider summary 和 cache 实验。 +- [x] P2-4:大仓库验收、超预算解释、Run Log 体积控制和 JSON Schema 校验层。 +- [x] P2-5:合并主线前展示型 demo 扩展。 验收标准: @@ -638,21 +617,11 @@ extension.ts ### Phase 3:VS Code 插件核心与共享 RPC 交互管线 -- [x] RPC 全双工 reader/writer 与事件发送队列:作为 VS Code/TUI 共享前置,已支持 `agent.sendTurn` 创建 run 后立即返回、后台有界队列持续事件推送和 active run 断连取消;已通过 `cargo test` 与 `cargo clippy --all-targets -- -D warnings`。 -- [x] 长 provider request 期间的 client 断连取消:stdio EOF / shutdown 会取消 active run,writer 失败会触发断连取消句柄。 -- [x] TypeScript extension scaffold:建立 VS Code 插件 TypeScript 工程、激活入口、基础命令和测试骨架。 -- [x] RPC server 管理:插件可启动 `prole rpc`,发送 `agent.initialize`,转发 `agent.event`,并在退出或错误时更新状态和提示。 -- [x] JSON-RPC request client:统一 request id、pending response、error response 和进程退出时的 pending request 清理。 -- [x] VS Code/protocol TypeScript 类型共享收敛:extension 已通过 workspace 依赖消费 `@prole-coder/protocol`,`rpcServer.ts` 的 `AgentEventEnvelope` 改为 protocol 类型 alias,并由 extension build/typecheck/test 脚本先构建 protocol 声明。 -- [x] VS Code RPC/commands 边界测试补齐:已覆盖 `RpcServerManager` 启动异常、stdio 缺失、无效 JSON、process error、stop、onEvent dispose、stderr preview、sendRequest 写入失败等路径,以及 openChat 启动失败、非 Error 错误、不可持久审批 approve 和审批消息 paths 拼接。 -- [x] Sidebar Chat 与 `agent.event` 渲染:已贡献 ProleCoder Activity Bar view 和 Webview Sidebar Chat,订阅 `RpcServerManager.onEvent()` 并展示 assistant delta、tool lifecycle、审批、context/provider 和 terminal event;assistant delta 会按 run/turn 合并为一条消息。 -- [x] 文本输入发送 turn,并通过 `agent.sendTurn` 驱动真实 Agent 回合:Sidebar Chat 已提供 prompt 输入和 mode 选择,Webview 通过 typed `RpcServerManager.sendTurn()` 调用真实 `agent.sendTurn`,并在 accepted 后等待同一 run 的 terminal event 收口输入状态。 -- [x] VS Code 审批 UI 接入真实 RPC pending queue:`ApprovalEventController` 订阅 `tool.approvalRequired`,复用 VS Code modal approval adapter,并通过 typed `RpcServerManager.approve()` / `reject()` 回传到 `agent.approve` / `agent.reject`。 -- [x] 命令风险分类器和动态风险升级:Agent Core 已在 shell 审批前识别依赖安装、网络访问、远程 git、删除和发布命令,升级 `tool.requested` / `tool.approvalRequired` 的风险,并通过 `riskReasons` 在 CLI/TUI/VS Code 审批展示升级原因。 -- [x] 更强进程树清理策略:命令类工具启动时建立可收束的进程树边界,Unix 使用独立 process group,Windows 使用新 process group、ParentProcessId descendant 枚举和 `taskkill /T /F` 兜底,取消和超时会清理 shell/search/git 等工具的子进程树。 -- [x] Native diff editor 展示 patch,并为 hunk 级审批预留交互边界:VS Code 侧缓存 `tool.requested` 的 `apply_patch` unified diff,在对应 `tool.approvalRequired` 前用 VS Code 原生 diff editor 展示虚拟补丁结果,并生成稳定 hunk approval boundary 供后续细粒度审批复用。 -- [x] Run List / resume:Sidebar Chat 已通过 typed `RpcServerManager.listRuns()` 拉取 run summary,并可点击历史 run 调用 `agent.resume` 清空当前事件视图后按 Run Log `seq` 重放。 -- [x] Context Capsule 可视化:Sidebar Chat 已消费 `context.built` metadata,展示 StablePrefix / DynamicPrelude / TurnSuffix token 分布、input/stable budget、cache/estimator 摘要、included/omitted sources 和 manifest 摘要。 +- [x] P3-1:RPC 全双工事件管线与断连取消。 +- [x] P3-2:VS Code extension scaffold、RPC server 管理和 JSON-RPC request client。 +- [x] P3-3:VS Code/protocol 类型共享与 RPC/commands 边界测试。 +- [x] P3-4:Sidebar Chat、真实 sendTurn 和 RPC pending queue 审批。 +- [x] P3-5:命令风险、进程树清理、Native diff、Run List/resume 和 Context Capsule 可视化。 验收标准: @@ -661,31 +630,27 @@ extension.ts - 关闭 stdin、writer 失败或插件停用会取消 active run,并在 run log 中收口到 terminal event。 - 更强进程树清理策略完成并通过可执行测试或清晰的手动验收说明。已新增 descendant process 取消回归测试。 - Sidebar Chat 能展示 `assistant.delta`、tool lifecycle 和 terminal event;Chat 输入能发送真实 `agent.sendTurn` 并收到最终结果。已完成首版输入发送和事件流收口。 -- VS Code 审批弹窗能消费 `tool.approvalRequired`,并把 approve/reject 回传到 `agent.approve` / `agent.reject`。已完成首版真实 RPC pending queue 接入。 +- VS Code 审批 UI 能消费 `tool.approvalRequired`,并把 approve/reject 回传到 `agent.approve` / `agent.reject`。已完成真实 RPC pending queue 接入,当前默认使用 Sidebar 内联审批卡片。 - Sidebar Chat 能展示最近 run 列表,并通过 `agent.resume` 回放历史事件。已完成首版 Run List / resume 接入。 - Sidebar Chat 能可视化 `context.built` 的 token 分段、来源纳入/省略和 manifest/cache/estimator metadata。已完成首版 Context Capsule 可视化。 - `docs/phase-tasks.md` 的 Phase 3 条目全部标记为 `[x]` 后,README 才能把 Phase 3 表述为整阶段完成。当前 Phase 3 已满足该条件。 ### Phase 4:VS Code 深度集成 -- [x] P4-1:VSIX dry-run packaging smoke,已通过 `pnpm run vsix:smoke` 验证 `.vscodeignore`、`workspace:*` 依赖边界、media asset、compiled `out/` 和 activationEvents;该 smoke 会临时生成并检查 VSIX,随后清理产物,不代表 P4-13 完成。 -- [x] P4-2:`@vscode/test-electron` 最小 harness,覆盖 extension activation、trusted workspace 和 Chat view 基础加载;`pnpm run vscode:test-electron` 可运行 smoke。 -- [x] P4-3:Provider capability model data contract,已通过 ADR 0006 和 `agent.initialize.capabilities.provider` 显式表达 thinking、tool calls/tool choice、FIM、stream/cache usage、上下文和输出限制。 -- [x] P4-4:事件 payload schema 与协议 fixture 对齐,已新增共享 fixture 与 Rust/TypeScript 测试,并补齐协议版本不匹配的 VS Code 提示边界。 -- [x] P4-5:RPC 高频事件输出节流与批量发送策略,实时 wire 层支持 `agent.eventBatch`,Run Log 与 replay 仍保持逐事件 `seq` 事实来源。 -- [x] P4-6:`agent.cancel` 类型化 helper 与 Chat Cancel UI,已新增 `RpcServerManager.cancel()`、Cancel 按钮和运行中 composer 状态收口。 -- [x] P4-7:Problems 面板 diagnostics 通过 diagnostic attachments 进入 Context Builder,VS Code 发送 turn 时会采集当前 Problems 快照,按协议 attachment 上限裁剪并优先保留 error。 -- [x] P4-8:Terminal command approval,审批 payload 支持命令、cwd、风险等级、风险原因、上一条 shell 输出摘要和持久化语义;P4-16 后主审批 modal 不再暴露复杂持久化选项。 -- [x] P4-9:审批持久化存储,RPC 队列支持 session/workspace 持久批准,并继续禁止 network/destructive 风险持久化。 -- [x] P4-10:provider、model、预算、审批策略和 RPC 命令配置界面,已新增 Open Settings 命令,打开 VS Code 设置并展示 RPC server capability、默认模型、预算、审批能力、RPC command/state;配置只包含非敏感 RPC/FIM 选项,不保存 API Key。 -- [x] P4-11:真实 hunk 级 patch 审批,首版限定 `apply_patch`,Core/RPC 支持 selected hunk 决策、校验未知/重复 hunk、Run Log 记录 selected/all 范围,VS Code modal 可选择 hunks 并通过 `agent.approve.hunks` 回传;审批事件 payload 已同步协议 fixture。 -- [x] P4-12:FIM completion preview,已新增 `agent.previewFim` RPC、DeepSeek beta FIM adapter、fixture provider 预览和 VS Code inline completion provider,模型选择只依赖 server capability 的 `supportsFim`。 -- [x] P4-13:VSIX alpha / pre-release 打包与插件安装说明,已新增 `pnpm run vsix:alpha`,在 `target/vsix/` 生成可安装 pre-release VSIX 与 SHA-256 校验和,并在 `docs/release.md` 记录 clean 环境安装验收路径。 -- [x] P4-14:补齐 end-to-end 集成测试覆盖,已在 `pnpm run vscode:test-electron` 中接入本地 JSON-RPC fixture server,覆盖 Chat sendTurn、Cancel、Problems diagnostics、自动审批回传、Run List / resume 和隔离 VS Code profile 启动;VSIX 安装后的 clean 环境基础交互继续按 `docs/release.md` 的 P4-13 路径手动验收。 -- [x] P4-15:原生 VS Code Chat Participant `@prole`,让常规入口默认打开 VS Code Chat 侧栏体验;保留 Activity Bar Webview 作为 Run List / Context Capsule / diff 等高级面板。 -- [x] P4-16:简化审批 UX,主审批动作收敛为 Approve / Reject,`apply_patch` 多 hunk 时保留 Select Hunks 边界;持久化策略继续由后端策略控制,不在主弹窗里暴露复杂选项。 -- [x] P4-17:Sidebar Chat 和原生 Chat Participant 自动注入压缩后的对话历史,作为 `explicit_content` attachment 进入已有 Context Capsule 管线,让连续对话自然承接上下文;Sidebar timeline 单条消息会先限长,避免极端长流式输出造成过大的中间文本。 -- [x] P4-18:补齐单元测试、extension-host E2E、VSIX smoke/alpha 打包验证和文档说明;已通过 `pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`、`pnpm run vscode:test-electron`、`pnpm run vsix:smoke` 和 `pnpm run vsix:alpha`,并补充 Chat Participant 早到 terminal event 缓冲回归测试。 +- [x] P4-1:VSIX dry-run packaging smoke。 +- [x] P4-2:`@vscode/test-electron` 最小 harness。 +- [x] P4-3:Provider capability model data contract。 +- [x] P4-4:事件 payload schema 与协议 fixture 对齐。 +- [x] P4-5:RPC 高频事件输出节流与批量发送策略。 +- [x] P4-6:`agent.cancel` 类型化 helper 与 Chat Cancel UI。 +- [x] P4-7:Problems 面板 diagnostics 通过 diagnostic attachments 进入 Context Builder。 +- [x] P4-8:Terminal command approval。 +- [x] P4-9:审批持久化存储。 +- [x] P4-10:provider、model、预算、审批策略和 RPC 命令配置界面。 +- [x] P4-11:真实 hunk 级 patch 审批。 +- [x] P4-12:FIM completion preview。 +- [x] P4-13:VSIX alpha / pre-release 打包与插件安装说明。 +- [x] P4-14:补齐 end-to-end 集成测试覆盖。 验收标准: @@ -694,32 +659,65 @@ extension.ts - VS Code 插件可通过 VSIX 安装到 clean 环境。 - fixture provider 下 Chat sendTurn、Cancel、Problems diagnostics、审批和 Run List / resume 至少有一条 extension-host 或可重复手动验收路径。 - 配置界面不保存 API Key,只管理非敏感配置。 +- `docs/phase-tasks.md` 的 Phase 4 条目已全部标记为 `[x]`,README 可以把 Phase 4 表述为整阶段完成。 + +### Phase 5:VS Code Codex-like UX 与开发工作流 + +- [x] P5-1:原生 VS Code Chat Participant `@prole`。 +- [x] P5-2:简化审批 UX。 +- [x] P5-3:自动上下文压缩。 +- [x] P5-4:UX 收敛测试与打包验收。 +- [x] P5-5:VS Code Output Channel 错误诊断。 +- [x] P5-6:DeepSeek API key SecretStorage、model selector 与 provider status。 +- [x] P5-7:统一 redaction 与 API key 错误恢复 UX。 +- [x] P5-8:Git context 只读采集与大 diff attachment 管线。 +- [x] P5-9:Generate Commit Message 写入 Source Control inputBox。 +- [x] P5-10:Generate PR Description markdown 生成。 +- [x] P5-11:Phase 5 UX 工作流验收与文档收敛。 +- [x] P5-12:Sidebar 连续会话、Run 删除与折叠事件 UX。 +- [x] P5-13:结构化 provider 配置错误码与恢复动作。 +- [x] P5-14:真实试用回归修复包:收敛 Sidebar 对话视图、内联确认、Work log 折叠、Markdown 渲染、webview 渲染诊断、provider/shell 稳定性和 composer / Settings 入口回归;具体子项与完成口径见 `docs/phase-tasks.md`。 +- [x] P5-15:真实试用 UX backlog 第二批收敛:补齐 Sidebar webview/Markdown/patch/resume/edit resend/output length 等真实试用回归,并把 Markdown renderer 模块化测试与 edit resend 后端 supersede 语义纳入协议和 run log。 +- [x] P5-16:真实试用 UX backlog 第一批收敛:完成模型回合预算 continuation approval、Provider Key/Model 图标按钮同排展示、Chat run mode 自动推断并隐藏默认 `edit` / `ask` / `plan` / `review` 下拉框,以及 Windows PowerShell 验证命令规则收敛,避免模型给测试命令追加 `2>&1` 后因 CLIXML/progress 噪声造成假失败。 +- [x] P5-17:Codex-like 交互细化:已完成用户消息笔形编辑按钮、Work log 按 provider/tool/approval 分组折叠、本对话命令审批复用、workspace-scoped 只读 shell 命令白名单免审批且敏感路径仍需审批、运行中 `agent.steer` 指导入口、steer 内联二次确认、最终总结后过程折叠、常见版本查询命令免审批,以及两段 provider 思考之间的文件/命令工作摘要;具体子项见 `docs/phase-tasks.md`。 +- [x] P5-18:真实试用 UX backlog 第三批收敛:已完成运行中 active work 单行状态栏、assistant 按工具/steer 边界分段、段间 `Activity` 摘要与平凡工具过滤、steer 消息顺序和确认卡位置修复、pending steer queued 反馈、`write_file` 等未知工具调用的可恢复纠偏、steer 强指令注入、完成后中间 assistant / Activity 按用户消息和 steer 边界分段折叠、provider idle timeout 重试与连接失败收口、已知工具 schema 错误的可恢复工具结果、用户 / steer 消息不参与完成后折叠、长 run timeline 裁剪只移除可重放过程项并保留不可丢对话内容,以及 run summary `changedFiles` / verification metadata 追踪增强,具体子项见 `docs/phase-tasks.md`。 +- [x] P5-19:真实试用 UX backlog 第四批收敛:已登记四项目真实试用回归验收,确认 `test/projects/agent_misc_tests_working` 四个项目共 18 个测试通过,UI 长 run 折叠与 steer 后消息保留效果正常,并补齐 Sidebar timeline 向上滚动历史分页;合并前持续 backlog 已收口,后续真实试用中发现的性能、稳定性和长任务问题转入 Phase 6。 + +验收标准: + - `ProleCoder: Open Chat` 优先打开 VS Code 原生 Chat 并填入 `@prole`,用户无需手动拖动 Activity Bar view 到右侧。 - 原生 Chat 和 Sidebar Chat 都通过真实 `agent.sendTurn` 驱动回合,并继续复用 Problems diagnostics、审批回传、Cancel、Run Log 和 Context Capsule。 - 连续对话会自动生成可审计、受限长度的上下文压缩 attachment;不会在 UI 文案里要求用户手动重开对话来延续上下文。 -- `docs/phase-tasks.md` 的 Phase 4 条目已全部标记为 `[x]`,README 可以把 Phase 4 表述为整阶段完成。 +- 主审批保持简单并默认在 Sidebar 内联卡片中完成;Sidebar 提供右上角 Settings 齿轮以及 Key/Model 直接入口,运行中过程事件只显示单行 active 状态,完整事件和错误诊断可在 `Output > ProleCoder` 查看。 +- `docs/phase-tasks.md` 的 Phase 5 条目已全部标记为 `[x]`,README 可以把 Phase 5 表述为整阶段完成;G4 自动 commit / push / create PR 仍是后续增强。 + +### Phase 6:AGENT 性能调试 + +- [ ] P6-1:性能观测基线:统一记录 context build、provider streaming、tool execution、run log、RPC event 和 VS Code render 耗时。 +- [ ] P6-2:本地 mini project 性能回归集:追踪 `test/projects/agent_misc_tests` 干净基线,`agent_misc_tests_working` 作为 ignored 工作副本。 +- [ ] P6-3:长上下文与缓存命中调试:分析 DeepSeek cache hit/miss、Context Capsule 大小、自动上下文压缩和 token 预算。 +- [ ] P6-4:工具调用与 patch 性能调试:定位 shell 输出、workspace snapshot、large patch args、run log 截断和审批预览瓶颈。 +- [ ] P6-5:VS Code Sidebar / RPC 吞吐调试:分析 event batching、Markdown 渲染、timeline paging、Output 日志和长 run UI 卡顿。 +- [ ] P6-6:性能预算、调优报告和合并后回归门槛。 -### Phase 5:TUI 与生态扩展 +### Phase 7:TUI 与生态扩展 -- [ ] TUI RPC 入口和事件流消费。 -- [ ] Chat/Plan/Diff/Tools/Context/Settings 页面。 -- [ ] TUI hunk 级审批、run resume 和配置文件。 -- [ ] TUI release binary。 -- [ ] 多 active run 与事件订阅模型。 -- [ ] 更细的 replay 语义。 -- [ ] MCP client、本地模型/私有推理服务 adapter、包管理器工具、issue/PR 工具和审计包导出。 +- [ ] P7-1:TUI RPC 入口、事件流消费和核心页面。 +- [ ] P7-2:TUI hunk 级审批、run resume、配置文件和 release binary。 +- [ ] P7-3:多 active run、replay 语义和事件订阅模型。 +- [ ] P7-4:生态扩展:MCP client、本地模型/私有推理服务 adapter、包管理器工具、issue/PR 工具和审计包导出。 -### Phase 6:自由软件发布 +### Phase 8:自由软件发布 -- [x] 确定许可证:AGPL-3.0-or-later 已作为项目许可证策略写入 README,后续发布阶段补齐正式 `LICENSE` 文件和源码提供说明。 -- [ ] 发布 `LICENSE`、源码获取说明和网络服务源码提供说明。 -- [ ] 发布源码包、Cargo crate、npm wrapper、VSIX、GitHub Release 校验和。 -- [ ] 建立公开 roadmap 和 issue 模板。 -- [ ] 增加 reproducible build 说明。 +- [x] P8-1:许可证策略确定。 +- [ ] P8-2:发布法律/源码提供文件。 +- [ ] P8-3:发布产物、校验和与源码包。 +- [ ] P8-4:公开 roadmap、issue 模板和贡献流程增强。 +- [ ] P8-5:reproducible build 说明。 ## 安全与注意事项 -- API Key 只能从环境变量、系统密钥链或用户配置读取,不能进入 run log。 +- API Key 只能从环境变量、VS Code SecretStorage 或系统密钥链读取,不能进入 run log。 - 默认排除 `.env`、密钥、证书、浏览器配置、包管理器 token 和大型二进制文件。 - 运行命令前展示 cwd、命令、环境变量差异和风险等级。 - Windows、Linux、macOS 的沙箱能力不同,必须分别实现和测试。 diff --git a/crates/agent-core/Cargo.toml b/crates/agent-core/Cargo.toml index 7153305..ae56790 100644 --- a/crates/agent-core/Cargo.toml +++ b/crates/agent-core/Cargo.toml @@ -10,13 +10,15 @@ path = "src/lib.rs" [dependencies] async-stream = "0.3" +base64 = "0.22" futures-util = "0.3" ignore = "0.4" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "system-proxy"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" thiserror = "2" +tokio = { version = "1", features = ["time"] } url = "2" [dev-dependencies] diff --git a/crates/agent-core/src/provider/deepseek_api.rs b/crates/agent-core/src/provider/deepseek_api.rs index 347645d..988744b 100644 --- a/crates/agent-core/src/provider/deepseek_api.rs +++ b/crates/agent-core/src/provider/deepseek_api.rs @@ -1,4 +1,6 @@ -use std::{collections::BTreeMap, env, fmt, pin::Pin, str, time::Duration}; +use std::{ + collections::BTreeMap, env, error::Error as StdError, fmt, pin::Pin, str, time::Duration, +}; use futures_util::{Stream, StreamExt}; use reqwest::StatusCode; @@ -13,6 +15,7 @@ pub const DEFAULT_MODEL: &str = DeepSeekModelId::V4_PRO; const CHAT_COMPLETIONS_PATH: &str = "chat/completions"; const FIM_COMPLETIONS_PATH: &str = "completions"; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(600); +const DEFAULT_HTTP_SEND_ATTEMPTS: usize = 3; const FIM_MAX_TOKENS_LIMIT: u32 = 4096; #[derive(Debug, Error)] @@ -40,8 +43,12 @@ pub enum DeepSeekApiError { ToolChoiceUnsupportedWithThinking, #[error("non-stream chat completion call received a streaming request")] StreamingRequestInNonStreamCall, - #[error("DeepSeek API request failed: {0}")] - Http(#[from] reqwest::Error), + #[error("DeepSeek API request failed: {details}")] + Http { + details: String, + #[source] + source: reqwest::Error, + }, #[error("DeepSeek API returned HTTP {status}: {body}")] Api { status: StatusCode, body: String }, #[error("DeepSeek API JSON response is invalid: {source}; body: {body}")] @@ -60,6 +67,31 @@ pub enum DeepSeekApiError { IncompleteStreamEvent { buffered_bytes: usize }, } +impl DeepSeekApiError { + fn http(source: reqwest::Error) -> Self { + Self::http_after_send_attempts(source, 1) + } + + fn http_after_send_attempts(source: reqwest::Error, attempts: usize) -> Self { + let mut details = format_reqwest_error(&source); + if attempts > 1 { + details.push_str(&format!("; retry attempts exhausted: {attempts}")); + } + + Self::Http { details, source } + } + + pub fn is_transient_transport_error(&self) -> bool { + match self { + Self::Http { source, .. } => { + retryable_send_error(source) || source.is_body() || source.is_decode() + } + Self::IncompleteStreamEvent { .. } => true, + _ => false, + } + } +} + pub type ChatCompletionStream = Pin> + Send>>; @@ -107,6 +139,8 @@ pub struct DeepSeekApiConfig { base_url: Url, model: DeepSeekModelId, timeout: Duration, + http_send_attempts: usize, + use_system_proxy: bool, } impl DeepSeekApiConfig { @@ -138,6 +172,8 @@ impl DeepSeekApiConfig { base_url, model: DeepSeekModelId::new(model)?, timeout: DEFAULT_TIMEOUT, + http_send_attempts: DEFAULT_HTTP_SEND_ATTEMPTS, + use_system_proxy: true, }) } @@ -163,6 +199,16 @@ impl DeepSeekApiConfig { self } + pub fn with_http_send_attempts(mut self, attempts: usize) -> Self { + self.http_send_attempts = attempts.max(1); + self + } + + pub fn without_system_proxy(mut self) -> Self { + self.use_system_proxy = false; + self + } + pub fn base_url(&self) -> &Url { &self.base_url } @@ -175,6 +221,14 @@ impl DeepSeekApiConfig { self.timeout } + pub fn http_send_attempts(&self) -> usize { + self.http_send_attempts + } + + pub fn use_system_proxy(&self) -> bool { + self.use_system_proxy + } + fn endpoint(&self, path: &str) -> Result { let mut base_url = self.base_url.clone(); if !base_url.path().ends_with('/') { @@ -200,6 +254,8 @@ impl fmt::Debug for DeepSeekApiConfig { .field("base_url", &self.base_url) .field("model", &self.model) .field("timeout", &self.timeout) + .field("http_send_attempts", &self.http_send_attempts) + .field("use_system_proxy", &self.use_system_proxy) .finish() } } @@ -212,9 +268,11 @@ pub struct DeepSeekApiAdapter { impl DeepSeekApiAdapter { pub fn new(config: DeepSeekApiConfig) -> Result { - let client = reqwest::Client::builder() - .timeout(config.timeout()) - .build()?; + let mut client = reqwest::Client::builder().timeout(config.timeout()); + if !config.use_system_proxy() { + client = client.no_proxy(); + } + let client = client.build().map_err(DeepSeekApiError::http)?; Ok(Self { client, config }) } @@ -240,11 +298,7 @@ impl DeepSeekApiAdapter { } let response = self - .client - .post(self.config.endpoint(CHAT_COMPLETIONS_PATH)?) - .bearer_auth(&self.config.api_key) - .json(&request) - .send() + .send_json_with_retries(CHAT_COMPLETIONS_PATH, &request) .await?; decode_chat_completion_response(response).await @@ -258,11 +312,7 @@ impl DeepSeekApiAdapter { request.validate_for_deepseek()?; let response = self - .client - .post(self.config.endpoint(CHAT_COMPLETIONS_PATH)?) - .bearer_auth(&self.config.api_key) - .json(&request) - .send() + .send_json_with_retries(CHAT_COMPLETIONS_PATH, &request) .await?; decode_chat_completion_stream(response).await @@ -274,22 +324,54 @@ impl DeepSeekApiAdapter { ) -> Result { request.validate_for_deepseek()?; let response = self - .client - .post(self.config.endpoint(FIM_COMPLETIONS_PATH)?) - .bearer_auth(&self.config.api_key) - .json(&request) - .send() + .send_json_with_retries(FIM_COMPLETIONS_PATH, &request) .await?; decode_fim_completion_response(response).await } + + async fn send_json_with_retries( + &self, + path: &str, + request: &T, + ) -> Result + where + T: Serialize + ?Sized, + { + let endpoint = self.config.endpoint(path)?; + let attempts = self.config.http_send_attempts(); + + for attempt in 1..=attempts { + let result = self + .client + .post(endpoint.clone()) + .bearer_auth(&self.config.api_key) + .json(request) + .send() + .await; + + match result { + Ok(response) => return Ok(response), + Err(source) => { + let retryable = retryable_send_error(&source); + if retryable && attempt < attempts { + continue; + } + + return Err(DeepSeekApiError::http_after_send_attempts(source, attempt)); + } + } + } + + unreachable!("send attempts loop always returns") + } } async fn decode_chat_completion_response( response: reqwest::Response, ) -> Result { let status = response.status(); - let body = response.text().await?; + let body = response.text().await.map_err(DeepSeekApiError::http)?; if !status.is_success() { return Err(DeepSeekApiError::Api { status, body }); @@ -302,7 +384,7 @@ async fn decode_fim_completion_response( response: reqwest::Response, ) -> Result { let status = response.status(); - let body = response.text().await?; + let body = response.text().await.map_err(DeepSeekApiError::http)?; if !status.is_success() { return Err(DeepSeekApiError::Api { status, body }); } @@ -315,7 +397,7 @@ async fn decode_chat_completion_stream( ) -> Result { let status = response.status(); if !status.is_success() { - let body = response.text().await?; + let body = response.text().await.map_err(DeepSeekApiError::http)?; return Err(DeepSeekApiError::Api { status, body }); } @@ -325,7 +407,7 @@ async fn decode_chat_completion_stream( let mut parser = SseEventParser::new(); while let Some(chunk) = byte_stream.next().await { - let chunk = chunk?; + let chunk = chunk.map_err(DeepSeekApiError::http)?; for event in parser.push_bytes(&chunk)? { let done = event == StreamEvent::Done; yield event; @@ -342,6 +424,80 @@ async fn decode_chat_completion_stream( })) } +fn format_reqwest_error(error: &reqwest::Error) -> String { + let mut details = Vec::new(); + details.push(redact_url_credentials(&error.to_string())); + + let mut kinds = Vec::new(); + if error.is_timeout() { + kinds.push("timeout"); + } + if error.is_connect() { + kinds.push("connect"); + } + if error.is_request() { + kinds.push("request"); + } + if error.is_body() { + kinds.push("body"); + } + if error.is_decode() { + kinds.push("decode"); + } + if !kinds.is_empty() { + details.push(format!("kind: {}", kinds.join("/"))); + } + if let Some(status) = error.status() { + details.push(format!("status: {status}")); + } + + let mut source = StdError::source(error); + while let Some(cause) = source { + let cause_message = redact_url_credentials(&cause.to_string()); + if !cause_message.is_empty() && !details.iter().any(|detail| detail == &cause_message) { + details.push(format!("caused by: {cause_message}")); + } + source = StdError::source(cause); + } + + details.join("; ") +} + +fn retryable_send_error(error: &reqwest::Error) -> bool { + error.is_timeout() || error.is_connect() || error.is_request() +} + +fn redact_url_credentials(message: &str) -> String { + let mut result = String::with_capacity(message.len()); + let mut rest = message; + + while let Some(scheme_index) = rest.find("://") { + let authority_start = scheme_index + "://".len(); + result.push_str(&rest[..authority_start]); + let after_scheme = &rest[authority_start..]; + let authority_end = after_scheme + .find(|character: char| { + character == '/' + || character == '\\' + || character == '?' + || character == '#' + || character.is_whitespace() + }) + .unwrap_or(after_scheme.len()); + let authority = &after_scheme[..authority_end]; + if let Some((_, host)) = authority.rsplit_once('@') { + result.push_str("[redacted]@"); + result.push_str(host); + } else { + result.push_str(authority); + } + rest = &after_scheme[authority_end..]; + } + + result.push_str(rest); + result +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ChatCompletionRequest { pub model: DeepSeekModelId, @@ -1115,12 +1271,23 @@ pub fn parse_stream_event_block(block: &str) -> Result, Deep #[cfg(test)] mod tests { + use std::{ + io::{Read, Write}, + net::{TcpListener, TcpStream}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + thread, + time::Duration, + }; + use super::{ ChatCompletionRequest, ChatFunctionCallDelta, ChatMessage, ChatTool, ChatToolCall, ChatToolCallAccumulator, ChatToolCallAccumulatorError, ChatToolCallDelta, ChatToolType, - DeepSeekApiConfig, DeepSeekApiError, DeepSeekModelId, FimCompletionRequest, - ReasoningEffort, SseEventParser, StreamEvent, StreamOptions, ThinkingConfig, ToolChoice, - parse_stream_event_block, + DeepSeekApiAdapter, DeepSeekApiConfig, DeepSeekApiError, DeepSeekModelId, + FimCompletionRequest, ReasoningEffort, SseEventParser, StreamEvent, StreamOptions, + ThinkingConfig, ToolChoice, parse_stream_event_block, redact_url_credentials, }; #[test] @@ -1171,6 +1338,43 @@ mod tests { ); } + #[test] + fn request_error_formatting_redacts_url_credentials() { + assert_eq!( + redact_url_credentials( + "proxy failed at https://user:secret@example.test:8443/path and http://plain.test" + ), + "proxy failed at https://[redacted]@example.test:8443/path and http://plain.test" + ); + } + + #[tokio::test] + async fn chat_completion_retries_transient_send_errors() { + let (base_url, attempts, server) = spawn_retry_fixture_server(); + let config = DeepSeekApiConfig::new("test-api-key", base_url, DeepSeekModelId::V4_PRO) + .expect("config should be valid") + .with_http_send_attempts(2) + .without_system_proxy(); + let adapter = DeepSeekApiAdapter::new(config).expect("adapter should be created"); + let request = ChatCompletionRequest::new( + DeepSeekModelId::new(DeepSeekModelId::V4_PRO).expect("model should be valid"), + vec![ChatMessage::user("hello")], + ) + .expect("request should be valid"); + + let response = adapter + .create_chat_completion(request) + .await + .expect("second send attempt should succeed"); + + server.join().expect("fixture server should finish"); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + assert_eq!( + response.choices[0].message.content.as_deref(), + Some("retry-ok") + ); + } + #[test] fn fim_request_serializes_prefix_suffix_and_non_streaming_default() { let request = FimCompletionRequest::new( @@ -1190,6 +1394,59 @@ mod tests { assert_eq!(json["stream"], false); } + fn spawn_retry_fixture_server() -> (String, Arc, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("fixture listener should bind"); + let base_url = format!( + "http://{}", + listener + .local_addr() + .expect("fixture listener should have an address") + ); + let attempts = Arc::new(AtomicUsize::new(0)); + let attempt_counter = Arc::clone(&attempts); + + let handle = thread::spawn(move || { + for attempt in 1..=2 { + let (mut stream, _) = listener.accept().expect("fixture connection should arrive"); + attempt_counter.fetch_add(1, Ordering::SeqCst); + + if attempt == 1 { + drop(stream); + continue; + } + + read_http_request(&mut stream); + let body = r#"{"id":"chatcmpl-test","choices":[{"index":0,"message":{"role":"assistant","content":"retry-ok"},"finish_reason":"stop"}],"created":1,"model":"deepseek-v4-pro","object":"chat.completion","usage":null,"system_fingerprint":null}"#; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .expect("fixture response should write"); + } + }); + + (base_url, attempts, handle) + } + + fn read_http_request(stream: &mut TcpStream) { + let mut buffer = [0_u8; 4096]; + let mut received = Vec::new(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("fixture read timeout should be set"); + + while !received.windows(4).any(|window| window == b"\r\n\r\n") { + match stream.read(&mut buffer) { + Ok(0) => break, + Ok(count) => received.extend_from_slice(&buffer[..count]), + Err(_) => break, + } + } + } + #[test] fn fim_request_rejects_invalid_max_tokens() { let request = FimCompletionRequest::new( diff --git a/crates/agent-core/src/run_log.rs b/crates/agent-core/src/run_log.rs index 0c29003..d259f21 100644 --- a/crates/agent-core/src/run_log.rs +++ b/crates/agent-core/src/run_log.rs @@ -148,7 +148,24 @@ impl RunLogStore { pub fn load_run_summary(&self, run_id: impl Into) -> Result { let run_id = validate_id("run id", run_id.into())?; - read_summary(&run_id, &self.summary_path(&run_id)?) + load_or_recover_summary( + &run_id, + &self.summary_path(&run_id)?, + &self.events_path(&run_id)?, + ) + } + + pub fn delete_run(&self, run_id: impl Into) -> Result<(), RunLogError> { + let run_id = validate_id("run id", run_id.into())?; + let run_dir = self.run_dir(&run_id)?; + if !run_dir.is_dir() { + return Err(RunLogError::RunNotFound { run_id }); + } + + fs::remove_dir_all(&run_dir).map_err(|source| RunLogError::Io { + path: run_dir, + source, + }) } pub fn list_run_summaries(&self) -> Result, RunLogError> { @@ -175,11 +192,16 @@ impl RunLogStore { let run_id = entry.file_name().to_string_lossy().into_owned(); let run_id = validate_id("run id", run_id)?; - let summary_path = entry.path().join(SUMMARY_FILE); - if !summary_path.is_file() { + let events_path = entry.path().join(EVENTS_FILE); + if !events_path.is_file() { continue; } - summaries.push(read_summary(&run_id, &summary_path)?); + let summary_path = entry.path().join(SUMMARY_FILE); + summaries.push(load_or_recover_summary( + &run_id, + &summary_path, + &events_path, + )?); } summaries.sort_by(|left, right| { @@ -206,6 +228,29 @@ pub trait RunLogWriter { turn_id: Option, payload: Value, ) -> Result; + + fn write_diagnostic_file( + &mut self, + relative_path: &Path, + contents: &str, + ) -> Result, RunLogError> { + let _ = (relative_path, contents); + Ok(None) + } + + fn read_payload_file(&mut self, relative_path: &Path) -> Result, RunLogError> { + let _ = relative_path; + Ok(None) + } + + fn append_payload_chunk( + &mut self, + relative_path: &Path, + chunk: &str, + ) -> Result, RunLogError> { + let _ = (relative_path, chunk); + Ok(None) + } } /// `RunLog` is a single-writer append handle. @@ -275,13 +320,41 @@ impl RunLog { .next_seq .checked_add(1) .ok_or(RunLogError::SequenceOverflow)?; - update_summary(&self.run_id, &self.summary_path, &event)?; + update_summary(&self.run_id, &self.summary_path, &self.events_path, &event)?; Ok(event) } pub fn load(&self) -> Result, RunLogError> { read_events(&self.run_id, &self.events_path) } + + pub fn write_run_diagnostic_file( + &mut self, + relative_path: &Path, + contents: &str, + ) -> Result { + write_run_text_file(&self.events_path, relative_path, contents, true) + } + + pub fn write_run_payload_file( + &mut self, + relative_path: &Path, + contents: &str, + ) -> Result { + write_run_text_file(&self.events_path, relative_path, contents, false) + } + + pub fn append_run_payload_chunk( + &mut self, + relative_path: &Path, + chunk: &str, + ) -> Result { + append_run_text_file(&self.events_path, relative_path, chunk) + } + + pub fn read_run_payload_file(&self, relative_path: &Path) -> Result { + read_run_text_file(&self.events_path, relative_path) + } } impl RunLogWriter for RunLog { @@ -297,6 +370,28 @@ impl RunLogWriter for RunLog { ) -> Result { self.append(event_type, turn_id, payload) } + + fn write_diagnostic_file( + &mut self, + relative_path: &Path, + contents: &str, + ) -> Result, RunLogError> { + self.write_run_diagnostic_file(relative_path, contents) + .map(Some) + } + + fn read_payload_file(&mut self, relative_path: &Path) -> Result, RunLogError> { + self.read_run_payload_file(relative_path).map(Some) + } + + fn append_payload_chunk( + &mut self, + relative_path: &Path, + chunk: &str, + ) -> Result, RunLogError> { + self.append_run_payload_chunk(relative_path, chunk) + .map(Some) + } } #[derive(Debug, Clone)] @@ -346,6 +441,35 @@ impl SerializedRunLog { self.lock()?.load() } + pub fn write_run_diagnostic_file( + &self, + relative_path: &Path, + contents: &str, + ) -> Result { + self.lock()? + .write_run_diagnostic_file(relative_path, contents) + } + + pub fn write_run_payload_file( + &self, + relative_path: &Path, + contents: &str, + ) -> Result { + self.lock()?.write_run_payload_file(relative_path, contents) + } + + pub fn append_run_payload_chunk( + &self, + relative_path: &Path, + chunk: &str, + ) -> Result { + self.lock()?.append_run_payload_chunk(relative_path, chunk) + } + + pub fn read_run_payload_file(&self, relative_path: &Path) -> Result { + self.lock()?.read_run_payload_file(relative_path) + } + fn lock(&self) -> Result, RunLogError> { self.inner .lock() @@ -368,6 +492,26 @@ impl RunLogWriter for SerializedRunLog { ) -> Result { self.append(event_type, turn_id, payload) } + + fn write_diagnostic_file( + &mut self, + relative_path: &Path, + contents: &str, + ) -> Result, RunLogError> { + SerializedRunLog::write_run_diagnostic_file(self, relative_path, contents).map(Some) + } + + fn read_payload_file(&mut self, relative_path: &Path) -> Result, RunLogError> { + SerializedRunLog::read_run_payload_file(self, relative_path).map(Some) + } + + fn append_payload_chunk( + &mut self, + relative_path: &Path, + chunk: &str, + ) -> Result, RunLogError> { + SerializedRunLog::append_run_payload_chunk(self, relative_path, chunk).map(Some) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -451,8 +595,15 @@ impl RunSummary { match event.event_type.as_str() { "run.started" => { - self.started_at_unix_ms = event.time_unix_ms; + if self.event_count == 1 { + self.started_at_unix_ms = event.time_unix_ms; + } self.updated_at_unix_ms = event.time_unix_ms; + self.status = RunSummaryStatus::Running; + self.completed_at_unix_ms = None; + self.summary = None; + self.changed_files.clear(); + self.verification_status = None; self.mode = string_field(&event.payload, "mode"); } "turn.started" => { @@ -574,6 +725,101 @@ fn append_event(path: &Path, event: &RunLogEvent) -> Result<(), RunLogError> { Ok(()) } +fn write_run_text_file( + events_path: &Path, + relative_path: &Path, + contents: &str, + append_newline: bool, +) -> Result { + let relative_path = normalize_workspace_relative_path(relative_path)?; + let run_dir = events_path + .parent() + .ok_or_else(|| RunLogError::InvalidStatePath { + path: events_path.to_path_buf(), + })?; + let path = run_dir.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| RunLogError::Io { + path: parent.to_path_buf(), + source, + })?; + } + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&path) + .map_err(|source| RunLogError::Io { + path: path.clone(), + source, + })?; + file.write_all(contents.as_bytes()) + .map_err(|source| RunLogError::Io { + path: path.clone(), + source, + })?; + if append_newline { + file.write_all(b"\n").map_err(|source| RunLogError::Io { + path: path.clone(), + source, + })?; + } + file.flush().map_err(|source| RunLogError::Io { + path: path.clone(), + source, + })?; + Ok(path) +} + +fn read_run_text_file(events_path: &Path, relative_path: &Path) -> Result { + let relative_path = normalize_workspace_relative_path(relative_path)?; + let run_dir = events_path + .parent() + .ok_or_else(|| RunLogError::InvalidStatePath { + path: events_path.to_path_buf(), + })?; + let path = run_dir.join(relative_path); + fs::read_to_string(&path).map_err(|source| RunLogError::Io { path, source }) +} + +fn append_run_text_file( + events_path: &Path, + relative_path: &Path, + chunk: &str, +) -> Result { + let relative_path = normalize_workspace_relative_path(relative_path)?; + let run_dir = events_path + .parent() + .ok_or_else(|| RunLogError::InvalidStatePath { + path: events_path.to_path_buf(), + })?; + let path = run_dir.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| RunLogError::Io { + path: parent.to_path_buf(), + source, + })?; + } + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|source| RunLogError::Io { + path: path.clone(), + source, + })?; + file.write_all(chunk.as_bytes()) + .map_err(|source| RunLogError::Io { + path: path.clone(), + source, + })?; + file.flush().map_err(|source| RunLogError::Io { + path: path.clone(), + source, + })?; + Ok(path) +} + fn write_summary(path: &Path, summary: &RunSummary) -> Result<(), RunLogError> { let mut file = OpenOptions::new() .write(true) @@ -628,10 +874,85 @@ fn read_summary(run_id: &str, path: &Path) -> Result { Ok(summary) } -fn update_summary(run_id: &str, path: &Path, event: &RunLogEvent) -> Result<(), RunLogError> { - let mut summary = read_summary(run_id, path)?; - summary.apply_event(event, path)?; - write_summary(path, &summary) +fn load_or_recover_summary( + run_id: &str, + summary_path: &Path, + events_path: &Path, +) -> Result { + match read_summary(run_id, summary_path) { + Ok(summary) => { + if summary_matches_events(run_id, &summary, events_path)? { + Ok(summary) + } else { + rebuild_summary_from_events(run_id, summary_path, events_path) + } + } + Err(error) if is_recoverable_summary_error(&error) => { + rebuild_summary_from_events(run_id, summary_path, events_path) + } + Err(error) => Err(error), + } +} + +fn update_summary( + run_id: &str, + summary_path: &Path, + events_path: &Path, + event: &RunLogEvent, +) -> Result<(), RunLogError> { + match read_summary(run_id, summary_path) { + Ok(mut summary) => match summary.apply_event(event, summary_path) { + Ok(()) => write_summary(summary_path, &summary), + Err(error) if is_recoverable_summary_error(&error) => { + rebuild_summary_from_events(run_id, summary_path, events_path).map(|_| ()) + } + Err(error) => Err(error), + }, + Err(error) if is_recoverable_summary_error(&error) => { + rebuild_summary_from_events(run_id, summary_path, events_path).map(|_| ()) + } + Err(error) => Err(error), + } +} + +fn summary_matches_events( + run_id: &str, + summary: &RunSummary, + events_path: &Path, +) -> Result { + let next_seq = next_sequence_from_events(run_id, events_path)?; + let event_count = next_seq + .checked_sub(1) + .ok_or(RunLogError::SequenceOverflow)?; + Ok(summary.last_seq == event_count && summary.event_count == event_count) +} + +fn rebuild_summary_from_events( + run_id: &str, + summary_path: &Path, + events_path: &Path, +) -> Result { + let events = read_events(run_id, events_path)?; + let created_at_unix_ms = events + .first() + .map(|event| event.time_unix_ms) + .unwrap_or(unix_time_millis()?); + let mut summary = RunSummary::new(run_id.to_owned(), created_at_unix_ms); + for event in &events { + summary.apply_event(event, summary_path)?; + } + write_summary(summary_path, &summary)?; + Ok(summary) +} + +fn is_recoverable_summary_error(error: &RunLogError) -> bool { + matches!( + error, + RunLogError::RunSummaryNotFound { .. } + | RunLogError::InvalidSummaryJson { .. } + | RunLogError::RunIdMismatch { .. } + | RunLogError::SummarySequenceMismatch { .. } + ) } fn next_sequence_from_events(run_id: &str, path: &Path) -> Result { @@ -977,7 +1298,7 @@ mod tests { use super::{ REDACTED_VALUE, RUN_LOG_MAX_ARRAY_ITEMS, RUN_LOG_MAX_STRING_BYTES, RUNS_DIR, RunLogError, - RunLogStore, RunLogWriter, RunSummaryStatus, SerializedRunLog, + RunLogStore, RunLogWriter, RunSummary, RunSummaryStatus, SerializedRunLog, write_summary, }; use crate::test_helpers::TestWorkspace; @@ -1064,6 +1385,67 @@ mod tests { assert_eq!(payload["stdout"], format!("visible {REDACTED_VALUE}")); } + #[test] + fn run_log_writes_diagnostic_files_inside_run_directory() { + let workspace = TestWorkspace::new("run-log"); + let store = RunLogStore::new(workspace.path()).expect("store should open"); + let mut run = store + .create_run("run_diagnostic") + .expect("run should be created"); + + let path = run + .write_run_diagnostic_file( + std::path::Path::new("diagnostics/invalid-tool-arguments.json"), + "hello diagnostic", + ) + .expect("diagnostic file should be written"); + + assert!(path.starts_with(store.runs_dir().join("run_diagnostic"))); + assert_eq!( + fs::read_to_string(path).expect("diagnostic file should be readable"), + "hello diagnostic\n" + ); + assert!(matches!( + run.write_run_diagnostic_file(std::path::Path::new("../outside.txt"), "bad"), + Err(RunLogError::InvalidStatePath { .. }) + )); + + let payload_path = run + .write_run_payload_file( + std::path::Path::new("payloads/apply_patch/patch.diff"), + "diff", + ) + .expect("payload file should be written"); + assert_eq!( + fs::read_to_string(&payload_path).expect("payload file should be readable"), + "diff" + ); + assert_eq!( + run.read_run_payload_file(std::path::Path::new("payloads/apply_patch/patch.diff")) + .expect("payload file should load"), + "diff" + ); + run.append_run_payload_chunk( + std::path::Path::new("payloads/apply_patch/chunked.diff"), + "di", + ) + .expect("first payload chunk should append"); + run.append_run_payload_chunk( + std::path::Path::new("payloads/apply_patch/chunked.diff"), + "ff", + ) + .expect("second payload chunk should append"); + assert_eq!( + run.read_run_payload_file(std::path::Path::new("payloads/apply_patch/chunked.diff")) + .expect("chunked payload file should load"), + "diff" + ); + assert!(matches!( + run.read_run_payload_file(std::path::Path::new("../outside.txt")), + Err(RunLogError::InvalidStatePath { .. }) + )); + } + #[test] fn run_log_truncates_large_payloads_and_records_boundaries() { let workspace = TestWorkspace::new("run-log"); @@ -1227,7 +1609,115 @@ mod tests { } #[test] - fn run_log_lists_summaries_by_recent_update_without_scanning_events() { + fn run_log_recovers_summary_from_events_after_stale_summary() { + let workspace = TestWorkspace::new("run-log"); + let store = RunLogStore::new(workspace.path()).expect("store should open"); + let mut run = store + .create_run("run_summary_recovery") + .expect("run should be created"); + + let started = run + .append_at(100, "run.started", None, json!({ "mode": "edit" })) + .expect("run started should append"); + run.append_at( + 110, + "turn.started", + Some("turn_1".to_owned()), + json!({ "userTask": "Fix the README" }), + ) + .expect("turn started should append"); + + let mut stale_summary = RunSummary::new("run_summary_recovery".to_owned(), 100); + stale_summary + .apply_event(&started, run.summary_path()) + .expect("stale summary should apply first event"); + write_summary(run.summary_path(), &stale_summary).expect("stale summary should be written"); + + let recovered = store + .load_run_summary("run_summary_recovery") + .expect("summary should recover from events"); + assert_eq!(recovered.last_seq, 2); + assert_eq!(recovered.title, "Fix the README"); + + write_summary(run.summary_path(), &stale_summary) + .expect("stale summary should be written again"); + run.append_at( + 120, + "run.completed", + Some("turn_1".to_owned()), + json!({ + "summary": "done", + "changedFiles": ["README.md"], + "verificationStatus": "passed", + }), + ) + .expect("append should recover stale summary before updating"); + + let summary = store + .load_run_summary("run_summary_recovery") + .expect("summary should load"); + assert_eq!(summary.last_seq, 3); + assert_eq!(summary.status, RunSummaryStatus::Completed); + assert_eq!(summary.summary.as_deref(), Some("done")); + assert_eq!(summary.changed_files, vec!["README.md"]); + } + + #[test] + fn run_log_summary_resets_terminal_fields_when_run_reopens_for_next_turn() { + let workspace = TestWorkspace::new("run-log"); + let store = RunLogStore::new(workspace.path()).expect("store should open"); + let mut run = store + .create_run("run_multi_turn_summary") + .expect("run should be created"); + + run.append_at(100, "run.started", None, json!({ "mode": "ask" })) + .expect("first run started should append"); + run.append_at( + 110, + "turn.started", + Some("turn_1".to_owned()), + json!({ "turnId": "turn_1", "userTask": "First task" }), + ) + .expect("first turn started should append"); + run.append_at( + 120, + "run.completed", + Some("turn_1".to_owned()), + json!({ + "summary": "First summary.", + "changedFiles": ["README.md"], + "verificationStatus": "passed" + }), + ) + .expect("first completion should append"); + run.append_at(200, "run.started", None, json!({ "mode": "edit" })) + .expect("second run started should append"); + run.append_at( + 210, + "turn.started", + Some("turn_2".to_owned()), + json!({ "turnId": "turn_2", "userTask": "Second task" }), + ) + .expect("second turn started should append"); + + let summary = store + .load_run_summary("run_multi_turn_summary") + .expect("summary should load"); + assert_eq!(summary.title, "Second task"); + assert_eq!(summary.status, RunSummaryStatus::Running); + assert_eq!(summary.started_at_unix_ms, 100); + assert_eq!(summary.updated_at_unix_ms, 210); + assert_eq!(summary.completed_at_unix_ms, None); + assert_eq!(summary.last_seq, 5); + assert_eq!(summary.event_count, 5); + assert_eq!(summary.mode.as_deref(), Some("edit")); + assert_eq!(summary.summary, None); + assert!(summary.changed_files.is_empty()); + assert_eq!(summary.verification_status, None); + } + + #[test] + fn run_log_lists_summaries_by_recent_update() { let workspace = TestWorkspace::new("run-log"); let store = RunLogStore::new(workspace.path()).expect("store should open"); let mut older = store @@ -1259,6 +1749,28 @@ mod tests { assert_eq!(summaries[1].updated_at_unix_ms, 100); } + #[test] + fn run_log_deletes_run_directory_safely() { + let workspace = TestWorkspace::new("run-log"); + let store = RunLogStore::new(workspace.path()).expect("store should open"); + store + .create_run("run_delete") + .expect("run should be created"); + + store + .delete_run("run_delete") + .expect("run should be deleted"); + + assert!(matches!( + store.load_run("run_delete"), + Err(RunLogError::RunNotFound { .. }) + )); + assert!(matches!( + store.delete_run("../outside"), + Err(RunLogError::InvalidIdentifier { .. }) + )); + } + #[test] fn serialized_run_log_serializes_concurrent_appenders() { let workspace = TestWorkspace::new("run-log"); diff --git a/crates/agent-core/src/tool.rs b/crates/agent-core/src/tool.rs index 1372079..9d82e38 100644 --- a/crates/agent-core/src/tool.rs +++ b/crates/agent-core/src/tool.rs @@ -13,6 +13,7 @@ pub enum ToolName { GitDiff, LspDiagnostics, PlanUpdate, + ModelTurnBudget, } impl ToolName { @@ -27,6 +28,7 @@ impl ToolName { Self::GitDiff => "git_diff", Self::LspDiagnostics => "lsp_diagnostics", Self::PlanUpdate => "plan_update", + Self::ModelTurnBudget => "model_turn_budget", } } } @@ -172,9 +174,20 @@ const SEARCH_ARGUMENT_SCHEMA: &str = r#"{ const APPLY_PATCH_ARGUMENT_SCHEMA: &str = r#"{ "type": "object", "additionalProperties": false, - "required": ["unifiedDiff", "expectedFiles"], + "required": ["expectedFiles"], "properties": { "unifiedDiff": { "type": "string", "minLength": 1 }, + "payloadRef": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "path"], + "properties": { + "kind": { "type": "string", "enum": ["run_file"] }, + "path": { "type": "string", "minLength": 1 }, + "sha256": { "type": "string", "minLength": 64 }, + "sizeBytes": { "type": "integer", "minimum": 0 } + } + }, "expectedFiles": { "type": "array", "minItems": 1, @@ -244,6 +257,12 @@ const PLAN_UPDATE_ARGUMENT_SCHEMA: &str = r#"{ } }"#; +const MODEL_TURN_BUDGET_ARGUMENT_SCHEMA: &str = r#"{ + "type": "object", + "additionalProperties": false, + "properties": {} +}"#; + pub const BUILTIN_TOOLS: &[ToolDefinition] = &[ ToolDefinition::new( ToolName::WorkspaceManifest, @@ -276,14 +295,14 @@ pub const BUILTIN_TOOLS: &[ToolDefinition] = &[ ToolName::ApplyPatch, "Apply a unified diff patch.", RiskLevel::Write, - ApprovalRequirement::Required, + ApprovalRequirement::None, ToolImplementationStatus::ExecutorImplemented, APPLY_PATCH_ARGUMENT_SCHEMA, STATUS_RESULT_SCHEMA, ), ToolDefinition::new( ToolName::Shell, - "Execute a non-interactive shell command.", + "Execute a non-interactive shell command from the selected workspace-relative cwd; use cwd instead of embedding cd into command.", RiskLevel::Exec, ApprovalRequirement::Required, ToolImplementationStatus::ExecutorImplemented, @@ -326,6 +345,15 @@ pub const BUILTIN_TOOLS: &[ToolDefinition] = &[ PLAN_UPDATE_ARGUMENT_SCHEMA, STATUS_RESULT_SCHEMA, ), + ToolDefinition::new( + ToolName::ModelTurnBudget, + "Approve continuing an agent turn after the provider-turn budget window is exhausted.", + RiskLevel::Exec, + ApprovalRequirement::Required, + ToolImplementationStatus::SchemaOnly, + MODEL_TURN_BUDGET_ARGUMENT_SCHEMA, + STATUS_RESULT_SCHEMA, + ), ]; pub fn find_builtin_tool(name: &str) -> Option<&'static ToolDefinition> { @@ -591,30 +619,76 @@ mod tests { use crate::approval::{ALL_RISK_LEVELS, ApprovalRequirement, RiskLevel}; #[test] - fn all_builtin_tools_have_matching_default_approval() { - for tool in BUILTIN_TOOLS { - assert_eq!( - tool.approval, - tool.risk.default_approval(), - "tool {} has mismatched approval requirement", - tool.name.as_str() - ); - } - } - - #[test] - fn write_and_exec_tools_require_approval() { + fn builtin_tools_have_explicit_approval_requirements() { let apply_patch = find_builtin_tool(ToolName::ApplyPatch.as_str()) .expect("apply_patch tool must be registered"); let shell = find_builtin_tool(ToolName::Shell.as_str()).expect("shell tool must be registered"); assert_eq!(apply_patch.risk, RiskLevel::Write); - assert_eq!(apply_patch.approval, ApprovalRequirement::Required); + assert_eq!(apply_patch.approval, ApprovalRequirement::None); assert_eq!(shell.risk, RiskLevel::Exec); assert_eq!(shell.approval, ApprovalRequirement::Required); } + #[test] + fn builtin_tool_approval_requirements_stay_within_supported_risk_constraints() { + for tool in BUILTIN_TOOLS { + if tool.risk == RiskLevel::Read { + assert_eq!( + tool.approval, + ApprovalRequirement::None, + "read-only tool `{}` must not require approval", + tool.name.as_str() + ); + } + + if tool.approval == ApprovalRequirement::Required { + assert_ne!( + tool.risk, + RiskLevel::Read, + "approval-required tool `{}` needs a non-read risk", + tool.name.as_str() + ); + } + + if tool.approval == ApprovalRequirement::AlwaysRequired { + assert_eq!( + tool.risk, + RiskLevel::Destructive, + "always-required tool `{}` must be destructive", + tool.name.as_str() + ); + } + + if tool.risk == RiskLevel::Destructive { + assert_eq!( + tool.approval, + ApprovalRequirement::AlwaysRequired, + "destructive tool `{}` must always require approval", + tool.name.as_str() + ); + } + + if matches!(tool.risk, RiskLevel::Exec | RiskLevel::Network) { + assert!( + tool.approval.is_required(), + "exec/network tool `{}` must require approval", + tool.name.as_str() + ); + } + + if tool.risk == RiskLevel::Write && tool.approval == ApprovalRequirement::None { + assert_eq!( + tool.name, + ToolName::ApplyPatch, + "write tool `{}` needs an explicit safety review before approval override", + tool.name.as_str() + ); + } + } + } + #[test] fn schemas_are_explicit_objects() { for tool in BUILTIN_TOOLS { @@ -669,6 +743,26 @@ mod tests { assert!(error.detail().contains("expected array")); } + #[test] + fn apply_patch_schema_accepts_run_scoped_payload_refs() { + let apply_patch = find_builtin_tool(ToolName::ApplyPatch.as_str()) + .expect("apply_patch tool must be registered"); + + validate_tool_arguments( + apply_patch, + &json!({ + "payloadRef": { + "kind": "run_file", + "path": "payloads/apply_patch/patch.diff", + "sha256": "0".repeat(64), + "sizeBytes": 1024, + }, + "expectedFiles": ["README.md"], + }), + ) + .expect("payloadRef apply_patch arguments should pass schema validation"); + } + #[test] fn tool_argument_schema_validator_accepts_nested_objects() { let plan_update = find_builtin_tool(ToolName::PlanUpdate.as_str()) diff --git a/crates/agent-core/src/tool_execution.rs b/crates/agent-core/src/tool_execution.rs index 6cf6b5f..197f0c3 100644 --- a/crates/agent-core/src/tool_execution.rs +++ b/crates/agent-core/src/tool_execution.rs @@ -1,5 +1,6 @@ use std::{ collections::BTreeSet, + ffi::OsStr, fs, io, path::{Component, Path, PathBuf}, process::{Child, Command, Stdio}, @@ -535,6 +536,10 @@ impl WorkspaceToolExecutor { fn resolve_workspace_path(&self, relative: &str) -> Result { let normalized = normalize_workspace_relative_path(relative)?; reject_sensitive_path(&normalized)?; + if normalized == "." { + return Ok(self.root.clone()); + } + let path = self.root.join(Path::new(&normalized)); let parent = path.parent().unwrap_or(&self.root); let canonical_parent = @@ -556,6 +561,10 @@ impl WorkspaceToolExecutor { relative: &str, ) -> Result { let path = self.resolve_workspace_path(relative)?; + if path == self.root { + return Ok(self.root.clone()); + } + let canonical = fs::canonicalize(&path).map_err(|source| ToolExecutionError::Io { path: path.clone(), source, @@ -835,9 +844,32 @@ fn run_shell_command( ) -> Result { #[cfg(windows)] { + let user_command = powershell_single_quoted_literal(command); + let script = format!( + concat!( + "$utf8 = New-Object System.Text.UTF8Encoding $false; ", + "[Console]::InputEncoding = $utf8; ", + "[Console]::OutputEncoding = $utf8; ", + "$OutputEncoding = $utf8; ", + "$ErrorActionPreference = 'Stop'; ", + "$ProgressPreference = 'SilentlyContinue'; ", + "try {{ Invoke-Expression -Command {user_command}; $proleSuccess = $? }} ", + "catch {{ [Console]::Error.WriteLine($_.ToString()); exit 1 }}; ", + "$proleSuccess = $?; ", + "if ($global:LASTEXITCODE -ne $null) {{ exit $global:LASTEXITCODE }}; ", + "if (-not $proleSuccess) {{ exit 1 }}" + ), + user_command = user_command + ); + let encoded_script = encode_powershell_command(&script); run_command( "powershell", - ["-NoProfile", "-NonInteractive", "-Command", command], + vec![ + "-NoProfile".to_owned(), + "-NonInteractive".to_owned(), + "-EncodedCommand".to_owned(), + encoded_script, + ], cwd, timeout, cancellation_token, @@ -850,13 +882,43 @@ fn run_shell_command( } } -fn run_command<'a>( +#[cfg(windows)] +fn powershell_single_quoted_literal(value: &str) -> String { + let mut escaped = String::with_capacity(value.len() + 2); + escaped.push('\''); + for character in value.chars() { + if character == '\'' { + escaped.push_str("''"); + } else { + escaped.push(character); + } + } + escaped.push('\''); + escaped +} + +#[cfg(windows)] +fn encode_powershell_command(script: &str) -> String { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + + let mut bytes = Vec::with_capacity(script.len() * 2); + for code_unit in script.encode_utf16() { + bytes.extend_from_slice(&code_unit.to_le_bytes()); + } + STANDARD.encode(bytes) +} + +fn run_command( program: &str, - args: impl IntoIterator, + args: I, cwd: &Path, timeout: Duration, cancellation_token: &CancellationToken, -) -> Result { +) -> Result +where + I: IntoIterator, + S: AsRef, +{ check_canceled(cancellation_token, program)?; let start = Instant::now(); let mut command = Command::new(program); @@ -899,10 +961,12 @@ fn run_command<'a>( program: program.to_owned(), source, })?; + let stdout = sanitize_command_output(&output.stdout); + let stderr = sanitize_command_output(&output.stderr); return Ok(CommandOutput { exit_code: output.status.code(), - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + stdout, + stderr, duration_ms: start.elapsed().as_millis(), }); } @@ -919,6 +983,87 @@ fn run_command<'a>( } } +fn sanitize_command_output(output: &[u8]) -> String { + String::from_utf8_lossy(&strip_powershell_progress_clixml_bytes(output)).into_owned() +} + +#[cfg(test)] +fn strip_powershell_progress_clixml(text: &str) -> String { + String::from_utf8_lossy(&strip_powershell_progress_clixml_bytes(text.as_bytes())).into_owned() +} + +fn strip_powershell_progress_clixml_bytes(output: &[u8]) -> Vec { + const MARKER: &[u8] = b"#< CLIXML"; + const OBJS_START: &[u8] = b""; + const PROGRESS: &[u8] = b"S=\"progress\""; + const ERROR: &[u8] = b"S=\"Error\""; + + if !contains_bytes(output, MARKER) { + return output.to_vec(); + } + + let mut result = Vec::with_capacity(output.len()); + let mut remaining = output; + while let Some(start) = find_bytes(remaining, MARKER) { + result.extend_from_slice(&remaining[..start]); + let after_marker = &remaining[start + MARKER.len()..]; + let after_marker_newline = strip_one_line_break_bytes(after_marker); + if !trim_start_ascii(after_marker_newline).starts_with(OBJS_START) { + result.extend_from_slice(MARKER); + remaining = after_marker; + continue; + } + + let Some(end) = find_bytes(after_marker_newline, END) else { + result.extend_from_slice(MARKER); + result.extend_from_slice(after_marker); + remaining = &[]; + break; + }; + let block_end = end + END.len(); + let block = &after_marker_newline[..block_end]; + if contains_bytes(block, PROGRESS) && !contains_bytes(block, ERROR) { + remaining = strip_one_line_break_bytes(&after_marker_newline[block_end..]); + } else { + result.extend_from_slice(MARKER); + result.extend_from_slice(after_marker); + remaining = &[]; + break; + } + } + result.extend_from_slice(remaining); + result +} + +fn strip_one_line_break_bytes(bytes: &[u8]) -> &[u8] { + bytes + .strip_prefix(b"\r\n") + .or_else(|| bytes.strip_prefix(b"\n")) + .unwrap_or(bytes) +} + +fn trim_start_ascii(bytes: &[u8]) -> &[u8] { + let start = bytes + .iter() + .position(|byte| !byte.is_ascii_whitespace()) + .unwrap_or(bytes.len()); + &bytes[start..] +} + +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + find_bytes(haystack, needle).is_some() +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() { + return Some(0); + } + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + #[cfg(unix)] fn configure_process_tree_root(command: &mut Command) { command.process_group(0); @@ -1656,12 +1801,24 @@ mod tests { use super::{ ApplyPatchArgs, GitDiffArgs, GitStatusArgs, ReadFileArgs, SearchArgs, ShellArgs, ShellResult, ToolExecutionError, ToolStatus, WorkspaceManifestArgs, WorkspaceToolExecutor, - redacted_tool_result_value, + redacted_tool_result_value, sanitize_command_output, strip_powershell_progress_clixml, }; use crate::cancellation::CancellationToken; use crate::hashing::sha256_hex; use crate::run_log::{REDACTED_VALUE, RUN_LOG_MAX_STRING_BYTES}; use crate::test_helpers::TestWorkspace; + use std::path::Path; + + fn wait_for_test_marker(path: &Path, timeout: std::time::Duration) -> bool { + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + if path.exists() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + path.exists() + } #[test] fn sha256_hex_matches_known_vectors() { @@ -1960,6 +2117,113 @@ mod tests { assert!(result.stdout.contains("hello")); } + #[test] + fn shell_accepts_dot_cwd_as_workspace_root() { + let workspace = TestWorkspace::new("tool-execution"); + let tools = WorkspaceToolExecutor::new(workspace.path()).expect("workspace should open"); + + #[cfg(windows)] + let command = "Write-Output hello"; + #[cfg(not(windows))] + let command = "printf hello"; + + let result = tools + .shell(ShellArgs { + command: command.to_owned(), + cwd: Some(".".to_owned()), + timeout_ms: Some(10_000), + }) + .expect("dot cwd should resolve to the workspace root"); + + assert_eq!(result.status, ToolStatus::Ok); + assert!(result.stdout.contains("hello")); + } + + #[cfg(windows)] + #[test] + fn shell_decodes_windows_powershell_stderr_as_utf8() { + let workspace = TestWorkspace::new("tool-execution"); + let tools = WorkspaceToolExecutor::new(workspace.path()).expect("workspace should open"); + + let result = tools + .shell(ShellArgs { + command: "[Console]::Error.WriteLine('错误信息'); exit 1".to_owned(), + cwd: None, + timeout_ms: Some(10_000), + }) + .expect("shell should return failed command output"); + + assert_eq!(result.status, ToolStatus::Failed); + assert!( + result.stderr.contains("错误信息"), + "stderr was {:?}", + result.stderr + ); + assert!(!result.stderr.contains('\u{fffd}')); + } + + #[cfg(windows)] + #[test] + fn shell_decodes_windows_powershell_parser_errors_as_utf8() { + let workspace = TestWorkspace::new("tool-execution"); + let tools = WorkspaceToolExecutor::new(workspace.path()).expect("workspace should open"); + + let result = tools + .shell(ShellArgs { + command: "cd /home/user/ledger && npm test".to_owned(), + cwd: Some(".".to_owned()), + timeout_ms: Some(10_000), + }) + .expect("shell should return parser error output"); + + assert_eq!(result.status, ToolStatus::Failed); + assert!( + result.stderr.contains("&&"), + "stderr was {:?}", + result.stderr + ); + assert!(!result.stderr.contains('\u{fffd}')); + } + + #[test] + fn command_output_strips_powershell_progress_clixml_noise() { + let clixml = concat!( + "#< CLIXML\r\n", + "", + "Preparing modules for first use.", + "\r\n", + "real stderr\r\n", + ); + + assert_eq!(strip_powershell_progress_clixml(clixml), "real stderr\r\n"); + } + + #[test] + fn command_output_strips_powershell_progress_clixml_with_non_utf8_payload() { + let mut clixml = concat!( + "#< CLIXML\r\n", + "", + "" + ) + .as_bytes() + .to_vec(); + clixml.extend_from_slice(&[0xff, 0xfe, 0xfd]); + clixml.extend_from_slice(b"\r\nreal stderr\r\n"); + + assert_eq!(sanitize_command_output(&clixml), "real stderr\r\n"); + } + + #[test] + fn command_output_preserves_powershell_error_clixml() { + let clixml = concat!( + "#< CLIXML\r\n", + "", + "real error\r\n", + ); + + assert_eq!(strip_powershell_progress_clixml(clixml), clixml); + } + #[test] fn shell_cancels_running_command() { let workspace = TestWorkspace::new("tool-execution"); @@ -2000,27 +2264,38 @@ mod tests { let tools = WorkspaceToolExecutor::new(workspace.path()).expect("workspace should open"); let cancellation_token = CancellationToken::new(); let cancel_from_thread = cancellation_token.clone(); + let ready_marker = workspace.path().join("tree-ready.txt"); + let ready_marker_for_thread = ready_marker.clone(); let cancel_thread = std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(100)); + assert!( + wait_for_test_marker(&ready_marker_for_thread, std::time::Duration::from_secs(10)), + "descendant process did not report ready before cancellation" + ); cancel_from_thread.cancel("stop process tree"); }); #[cfg(windows)] - let command = r#"cmd /C "ping -n 4 127.0.0.1 > nul && echo alive>tree-marker.txt""#; + let command = concat!( + "$child = Start-Process -FilePath powershell ", + "-ArgumentList '-NoProfile','-NonInteractive','-Command','Start-Sleep -Seconds 4; Set-Content -LiteralPath tree-marker.txt -Value alive' ", + "-WorkingDirectory . -PassThru; ", + "Set-Content -LiteralPath tree-ready.txt -Value ready; ", + "Wait-Process -Id $child.Id" + ); #[cfg(not(windows))] - let command = "(sleep 3; printf alive > tree-marker.txt) & wait"; + let command = + "(printf ready > tree-ready.txt; sleep 3; printf alive > tree-marker.txt) & wait"; - let error = tools - .shell_with_cancellation( - ShellArgs { - command: command.to_owned(), - cwd: None, - timeout_ms: Some(10_000), - }, - &cancellation_token, - ) - .expect_err("shell process tree should be canceled"); + let result = tools.shell_with_cancellation( + ShellArgs { + command: command.to_owned(), + cwd: None, + timeout_ms: Some(10_000), + }, + &cancellation_token, + ); cancel_thread.join().expect("cancel thread should join"); + let error = result.expect_err("shell process tree should be canceled"); assert!(matches!( error, diff --git a/crates/agent-core/src/turn_loop.rs b/crates/agent-core/src/turn_loop.rs index 595fa0d..cb64faf 100644 --- a/crates/agent-core/src/turn_loop.rs +++ b/crates/agent-core/src/turn_loop.rs @@ -1,4 +1,12 @@ -use std::{collections::HashSet, future::Future, path::Path, pin::Pin, time::Instant}; +use std::{ + collections::{BTreeMap, BTreeSet, HashSet, VecDeque}, + fs, + future::Future, + path::{Path, PathBuf}, + pin::Pin, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; use futures_util::{Stream, StreamExt}; use serde::{Serialize, de::DeserializeOwned}; @@ -13,6 +21,7 @@ use crate::{ ContextBuildError, ContextBuilder, ContextBuilderConfig, ContextItem, ContextItemKind, ContextManifestOmitted, ContextManifestReport, }, + hashing::sha256_hex, provider::deepseek_api::{ChatMessage, ChatToolCall}, reasoning::{ ReasoningContentError, ReasoningContentMode, ReasoningContentState, @@ -20,11 +29,11 @@ use crate::{ }, run_log::{RunLogError, RunLogEvent, RunLogWriter, redact_text}, tool::{ - ToolArgumentSchemaError, ToolDefinition, ToolName, find_builtin_tool, + BUILTIN_TOOLS, ToolArgumentSchemaError, ToolDefinition, ToolName, find_builtin_tool, validate_tool_arguments, }, tool_execution::{ - ApplyPatchArgs, PatchApprovalHunk, ReadFileArgs, ShellArgs, ShellResult, + ApplyPatchArgs, ApplyPatchResult, PatchApprovalHunk, ReadFileArgs, ShellArgs, ShellResult, ToolExecutionError, ToolStatus, WorkspaceManifestArgs, WorkspaceToolExecutor, filter_apply_patch_hunks, patch_approval_hunks, redacted_tool_result_value, }, @@ -32,8 +41,81 @@ use crate::{ const DEFAULT_MAX_ATTACHMENTS: usize = 32; const DEFAULT_MAX_ATTACHMENT_BYTES: u64 = 256 * 1024; +const DEFAULT_MAX_MODEL_TURNS: usize = 50; +const DEFAULT_PROVIDER_TRANSIENT_RETRIES: usize = 2; +const DEFAULT_PROVIDER_IDLE_TIMEOUT_SECS: u64 = 60; +const DEFAULT_PROVIDER_IDLE_TIMEOUT_ATTEMPTS: usize = 5; +const PROVIDER_STREAM_RETRY_PARTIAL_CONTENT_MAX_CHARS: usize = 2_000; +const PROVIDER_STREAM_RETRY_REASONING_NOTICE: &str = concat!( + "The interrupted stream had already produced hidden reasoning before a complete user-facing ", + "response arrived; ProleCoder did not replay that hidden reasoning text." +); const SHELL_APPROVAL_OUTPUT_SUMMARY_MAX_LINES: usize = 8; const SHELL_APPROVAL_OUTPUT_SUMMARY_MAX_BYTES: usize = 2 * 1024; +const TOOL_ARGUMENT_DIAGNOSTIC_MAX_BYTES: usize = 128 * 1024; +const WORKSPACE_DIFF_MAX_FILES: usize = 20_000; +const WORKSPACE_DIFF_HASH_MAX_BYTES: u64 = 2 * 1024 * 1024; +const WORKSPACE_DIFF_EXCLUDED_DIRS: &[&str] = &[ + ".git", + ".prole-coder", + "target", + "node_modules", + ".vscode-test", +]; +const MAX_AUTO_APPROVED_PATCH_FILES: usize = 5; +const WORKSPACE_POLICY_PATCH_FILES: &[&str] = &[".gitignore", ".prole-coderignore"]; +const READ_ONLY_VERSION_COMMANDS: &[&str] = &[ + "bun", "cargo", "code", "corepack", "deno", "dotnet", "git", "go", "java", "node", "npm", + "pip", "pip3", "pnpm", "prole", "python", "python3", "py", "rg", "rustc", "rustup", "tsc", + "yarn", +]; +const READ_ONLY_LOWERCASE_VERSION_COMMANDS: &[&str] = &[ + "bun", "deno", "node", "npm", "pnpm", "rg", "rustc", "tsc", "yarn", +]; +const READ_ONLY_VERSION_SUBCOMMANDS: &[&str] = &["go"]; +const MODEL_TURN_BUDGET_TOOL_NAME: &str = "model_turn_budget"; +const FINAL_RESPONSE_SUMMARY_INSTRUCTION: &str = concat!( + "When the task is complete, make the final assistant message a concise work summary for the user. ", + "Mention what changed, important files, verification or tests, and any blockers. ", + "Do not dump raw tool logs, JSON-RPC events, or intermediate provider/tool chatter; those details are recorded in ProleCoder Output." +); +const FINAL_RESPONSE_LENGTH_RETRY_INSTRUCTION: &str = concat!( + "Your previous response ended because the model reached its output length limit before a complete final user-facing summary. ", + "Do not call tools unless absolutely necessary. Return only a concise final work summary now, covering changes, important files, verification or tests, and blockers." +); +const TOOL_CALL_LENGTH_RETRY_INSTRUCTION: &str = concat!( + "Your previous response ended because the model reached its output length limit while composing a tool call. ", + "Discard any partial or truncated tool-call arguments from that response. ", + "If a tool is still needed, issue a fresh complete tool call with valid JSON arguments now; otherwise continue with the task briefly." +); +const PROVIDER_STREAM_RETRY_INSTRUCTION: &str = concat!( + "The previous provider response stream was interrupted by a transient connection error before ProleCoder received a completed response. ", + "Continue the same task from where you left off. Do not repeat completed tool results, and only call tools that are still needed." +); +const STEER_INSTRUCTION_PREFIX: &str = concat!( + "The user sent this steering instruction while the run is in progress. ", + "Treat it as the latest user instruction and follow it immediately for all subsequent ", + "user-visible assistant text and actions. If it conflicts with earlier user instructions, ", + "the steering instruction wins unless it conflicts with safety or tool rules.\n\n", + "Steering instruction:\n" +); +#[cfg(windows)] +const TOOL_USAGE_INSTRUCTION: &str = concat!( + "Tool usage rules: all workspace paths must be workspace-relative unless a tool explicitly says otherwise. ", + "Do not invent absolute paths such as /home/user/project, and do not use cd to move into guessed directories. ", + "For the shell tool, put the target directory in the cwd argument, usually \".\" or a workspace-relative subdirectory, and keep command to the command itself. ", + "Shell commands run under Windows PowerShell 5.1, so do not use POSIX-only paths or PowerShell 7-only operators such as && and ||. Use separate tool calls or Windows PowerShell-compatible syntax. ", + "For test and verification commands, do not append redirections such as 2>&1; the shell tool captures stdout and stderr separately, and PowerShell stream merging can emit CLIXML/progress noise that looks like a failed verification. ", + "There is no write_file tool; for text edits, use apply_patch with expectedFiles and a unified diff or payloadRef, and reread the target file before retrying a failed patch." +); +#[cfg(not(windows))] +const TOOL_USAGE_INSTRUCTION: &str = concat!( + "Tool usage rules: all workspace paths must be workspace-relative unless a tool explicitly says otherwise. ", + "Do not invent absolute paths such as /home/user/project, and do not use cd to move into guessed directories. ", + "For the shell tool, put the target directory in the cwd argument, usually \".\" or a workspace-relative subdirectory, and keep command to the command itself. ", + "Shell commands run under POSIX sh from the selected cwd. ", + "There is no write_file tool; for text edits, use apply_patch with expectedFiles and a unified diff or payloadRef, and reread the target file before retrying a failed patch." +); #[derive(Debug)] pub struct AgentTurnLoop { @@ -120,7 +202,9 @@ where AgentTurnLoopError::RunLog(_) | AgentTurnLoopError::EventSink(_) ) { - let (event_type, payload) = terminal_error_event(error); + let diagnostic_file = + write_invalid_tool_arguments_diagnostic_file(run_log, turn_id.as_str(), error); + let (event_type, payload) = terminal_error_event(error, diagnostic_file.as_deref()); let append_result = append_turn_event(run_log, event_sink, event_type, Some(turn_id), payload); if let Err(append_error) = append_result { @@ -145,6 +229,16 @@ where detail: "max_model_turns must be greater than zero".to_owned(), }); } + if self.config.provider_idle_timeout == Duration::ZERO { + return Err(AgentTurnLoopError::InvalidConfig { + detail: "provider_idle_timeout must be greater than zero".to_owned(), + }); + } + if self.config.provider_idle_timeout_attempts == 0 { + return Err(AgentTurnLoopError::InvalidConfig { + detail: "provider_idle_timeout_attempts must be greater than zero".to_owned(), + }); + } input.cancellation_token.check()?; append_turn_event( @@ -158,17 +252,20 @@ where "mode": input.mode.as_str(), }), )?; + let mut turn_started_payload = json!({ + "turnId": input.turn_id.clone(), + "userTask": input.user_task.clone(), + }); + if let Some(supersedes) = &input.supersedes { + turn_started_payload["supersedes"] = serde_json::to_value(supersedes)?; + } append_turn_event( run_log, event_sink, "turn.started", Some(input.turn_id.clone()), - json!({ - "turnId": input.turn_id.clone(), - "userTask": input.user_task.clone(), - }), + turn_started_payload, )?; - let context = self.build_context(&input)?; append_turn_event( run_log, @@ -181,125 +278,288 @@ where let mut messages = vec![ChatMessage::user(context.content)]; let mut tool_results = Vec::new(); let mut changed_files = Vec::new(); + let mut workspace_before = None; + let mut workspace_snapshot_failed = false; + let mut verification_status = VerificationStatus::Skipped; let mut last_shell_output_summary = None; - - for iteration in 1..=self.config.max_model_turns { - let prepared = self.reasoning.prepare_messages(&messages)?; - append_turn_event( - run_log, - event_sink, - "provider.requested", - Some(input.turn_id.clone()), - json!({ - "iteration": iteration, - "messageCount": prepared.messages.len(), - "reasoningState": reasoning_state_payload(prepared.state), - }), - )?; - - let provider_turn = self - .collect_provider_response( - TurnProviderRequest { - iteration, - messages: prepared.messages, - cancellation_token: input.cancellation_token.clone(), - }, - &input.turn_id, - iteration, - run_log, - event_sink, - ) - .await?; - let response = provider_turn.response; - - if !response.tool_calls.is_empty() - && self.reasoning.mode() == ReasoningContentMode::ThinkingEnabled - && response - .reasoning_content - .as_deref() - .is_none_or(|reasoning| reasoning.trim().is_empty()) - { - return Err(AgentTurnLoopError::MissingAssistantReasoningContent); - } - - if let Some(content) = response - .content - .as_deref() - .filter(|content| !content.is_empty()) - .filter(|_| !provider_turn.emitted_content_delta) - { + let mut provider_transient_retries_remaining = self.config.provider_transient_retries; + let mut provider_idle_timeout_retries_remaining = + self.config.provider_idle_timeout_attempts.saturating_sub(1); + + let mut next_iteration = 1usize; + loop { + let budget_start = next_iteration; + let budget_end = budget_start + self.config.max_model_turns - 1; + for iteration in budget_start..=budget_end { + drain_steer_messages(&input, run_log, event_sink, &mut messages)?; + let prepared = self.reasoning.prepare_messages(&messages)?; append_turn_event( run_log, event_sink, - "assistant.delta", + "provider.requested", Some(input.turn_id.clone()), json!({ "iteration": iteration, - "text": content, + "messageCount": prepared.messages.len(), + "reasoningState": reasoning_state_payload(prepared.state), }), )?; - } - if response.tool_calls.is_empty() { - let final_message = response.content.unwrap_or_default(); - append_turn_event( - run_log, - event_sink, - "run.completed", - Some(input.turn_id.clone()), - json!({ - "summary": final_message, - "changedFiles": changed_files.clone(), - "verificationStatus": "skipped", - }), - )?; + let provider_turn = match self + .collect_provider_response( + TurnProviderRequest { + iteration, + messages: prepared.messages, + cancellation_token: input.cancellation_token.clone(), + }, + &input.turn_id, + iteration, + run_log, + event_sink, + ) + .await + { + Ok(provider_turn) => { + provider_transient_retries_remaining = + self.config.provider_transient_retries; + provider_idle_timeout_retries_remaining = + self.config.provider_idle_timeout_attempts.saturating_sub(1); + provider_turn + } + Err(AgentTurnLoopError::ProviderIdleTimeout { + timeout_ms, + partial_content, + partial_reasoning_chars, + .. + }) if provider_idle_timeout_retries_remaining > 0 => { + provider_idle_timeout_retries_remaining -= 1; + append_turn_event( + run_log, + event_sink, + "provider.retrying", + Some(input.turn_id.clone()), + json!({ + "iteration": iteration, + "reason": "provider_idle_timeout", + "message": format!( + "provider request made no progress for {timeout_ms}ms" + ), + "timeoutMs": timeout_ms, + "partialContentChars": partial_content.chars().count(), + "partialReasoningChars": partial_reasoning_chars, + "retriesRemaining": provider_idle_timeout_retries_remaining, + }), + )?; + push_provider_stream_retry_messages( + &mut messages, + &partial_content, + partial_reasoning_chars, + ); + continue; + } + Err(AgentTurnLoopError::ProviderIdleTimeout { + timeout_ms, + partial_content, + partial_reasoning_chars, + .. + }) => { + return Err(AgentTurnLoopError::ProviderIdleTimeout { + timeout_ms, + attempts: self.config.provider_idle_timeout_attempts, + partial_content, + partial_reasoning_chars, + }); + } + Err(AgentTurnLoopError::ProviderStreamInterrupted { + source, + partial_content, + partial_reasoning_chars, + }) if provider_transient_retries_remaining > 0 => { + provider_transient_retries_remaining -= 1; + append_turn_event( + run_log, + event_sink, + "provider.retrying", + Some(input.turn_id.clone()), + json!({ + "iteration": iteration, + "reason": "transient_stream_error", + "message": source.to_string(), + "partialContentChars": partial_content.chars().count(), + "partialReasoningChars": partial_reasoning_chars, + "retriesRemaining": provider_transient_retries_remaining, + }), + )?; + push_provider_stream_retry_messages( + &mut messages, + &partial_content, + partial_reasoning_chars, + ); + continue; + } + Err(AgentTurnLoopError::ProviderStreamInterrupted { source, .. }) => { + return Err(AgentTurnLoopError::Provider(source)); + } + Err(error) => return Err(error), + }; + let mut response = provider_turn.response; - return Ok(AgentTurnOutcome { - final_message, - iterations: iteration, - tool_results, - changed_files, - }); - } + if response.completion.finish_reason == TurnProviderFinishReason::Length { + if response.tool_calls.is_empty() { + push_final_response_retry_messages(&mut messages, &response); + continue; + } - let tool_calls = response.tool_calls; - messages.push(ChatMessage::assistant_with_tool_calls( - response.content, - response.reasoning_content, - tool_calls.clone(), - )); + push_tool_call_length_retry_messages(&mut messages, &response); + continue; + } - for (tool_index, tool_call) in tool_calls.iter().enumerate() { - let tool_context = ToolCallContext { - turn_id: &input.turn_id, - iteration, - tool_index: tool_index + 1, - previous_shell_output_summary: last_shell_output_summary.as_deref(), - cancellation_token: &input.cancellation_token, - }; - let executed = - self.execute_tool_call(tool_call, tool_context, run_log, event_sink)?; - let follow_up_output_summary = executed.follow_up_output_summary.clone(); - changed_files.extend(executed.changed_files.iter().cloned()); - messages.push(ChatMessage::tool_result( - tool_call.id.clone(), - executed.message_content.clone(), + if recover_missing_tool_call_reasoning(&mut response, self.reasoning.mode()) { + append_turn_event( + run_log, + event_sink, + "provider.reasoningRecovered", + Some(input.turn_id.clone()), + json!({ + "iteration": iteration, + "reason": "missing_tool_call_reasoning_content", + "toolCallCount": response.tool_calls.len(), + }), + )?; + } + + if let Some(content) = response + .content + .as_deref() + .filter(|content| !content.is_empty()) + .filter(|_| !provider_turn.emitted_content_delta) + { + append_turn_event( + run_log, + event_sink, + "assistant.delta", + Some(input.turn_id.clone()), + json!({ + "iteration": iteration, + "text": content, + }), + )?; + } + + if response.tool_calls.is_empty() { + if response.completion.finish_reason != TurnProviderFinishReason::Stop { + return Err(AgentTurnLoopError::Provider(TurnProviderError::new( + format!( + "provider finished with `{}` before a final assistant summary", + turn_provider_finish_reason_label( + response.completion.finish_reason + ) + ), + ))); + } + let final_message = response.content.unwrap_or_default(); + let (final_changed_files, snapshot_error) = final_changed_files( + changed_files, + workspace_before.as_ref(), + self.tools.root(), + ); + if let Some(message) = snapshot_error { + append_workspace_snapshot_failed_event( + run_log, + event_sink, + &input.turn_id, + "afterToolExecution", + message, + )?; + } + append_turn_event( + run_log, + event_sink, + "run.completed", + Some(input.turn_id.clone()), + json!({ + "summary": final_message, + "changedFiles": final_changed_files.clone(), + "verificationStatus": verification_status.as_str(), + }), + )?; + + return Ok(AgentTurnOutcome { + final_message, + iterations: iteration, + tool_results, + changed_files: final_changed_files, + }); + } + + let tool_calls = response.tool_calls; + messages.push(ChatMessage::assistant_with_tool_calls( + response.content, + response.reasoning_content, + tool_calls.clone(), )); - tool_results.push(AgentToolResult { - tool_call_id: tool_call.id.clone(), - name: tool_call.function.name.clone(), - status: executed.status, - result: executed.log_result, - }); - if let Some(output_summary) = follow_up_output_summary { - last_shell_output_summary = Some(output_summary); + + for (tool_index, tool_call) in tool_calls.iter().enumerate() { + if workspace_before.is_none() + && !workspace_snapshot_failed + && should_capture_workspace_diff_baseline(tool_call.function.name.as_str()) + { + match workspace_file_snapshot(self.tools.root()) { + Ok(snapshot) => { + workspace_before = Some(snapshot); + } + Err(error) => { + workspace_snapshot_failed = true; + append_workspace_snapshot_failed_event( + run_log, + event_sink, + &input.turn_id, + "beforeToolExecution", + error.to_string(), + )?; + } + } + } + let tool_context = ToolCallContext { + turn_id: &input.turn_id, + iteration, + tool_index: tool_index + 1, + previous_shell_output_summary: last_shell_output_summary.as_deref(), + cancellation_token: &input.cancellation_token, + }; + let executed = + self.execute_tool_call(tool_call, tool_context, run_log, event_sink)?; + let follow_up_output_summary = executed.follow_up_output_summary.clone(); + verification_status = verification_status.combine(executed.verification_status); + changed_files.extend(executed.changed_files.iter().cloned()); + messages.push(ChatMessage::tool_result( + tool_call.id.clone(), + executed.message_content.clone(), + )); + tool_results.push(AgentToolResult { + tool_call_id: tool_call.id.clone(), + name: tool_call.function.name.clone(), + status: executed.status, + result: executed.log_result, + }); + if let Some(output_summary) = follow_up_output_summary { + last_shell_output_summary = Some(output_summary); + } } } - } - Err(AgentTurnLoopError::MaxModelTurnsExceeded { - max_model_turns: self.config.max_model_turns, - }) + next_iteration = budget_end + 1; + self.ensure_model_turn_budget_continuation( + &input.turn_id, + budget_end, + &input.cancellation_token, + run_log, + event_sink, + )?; + provider_transient_retries_remaining = self.config.provider_transient_retries; + provider_idle_timeout_retries_remaining = + self.config.provider_idle_timeout_attempts.saturating_sub(1); + } } async fn collect_provider_response( @@ -316,18 +576,74 @@ where let cancellation_token = request.cancellation_token.clone(); cancellation_token.check()?; let started = Instant::now(); - let mut stream = self - .provider - .complete_stream(request) - .await - .map_err(|error| provider_error_or_canceled(error, &cancellation_token))?; + let provider_idle_timeout = self.config.provider_idle_timeout; + let mut stream = match tokio::time::timeout( + provider_idle_timeout, + self.provider.complete_stream(request), + ) + .await + { + Ok(Ok(stream)) => stream, + Ok(Err(error)) => { + return match provider_error_or_canceled(error, &cancellation_token) { + AgentTurnLoopError::Provider(source) if source.is_transient() => { + Err(AgentTurnLoopError::ProviderStreamInterrupted { + source, + partial_content: String::new(), + partial_reasoning_chars: 0, + }) + } + error => Err(error), + }; + } + Err(_) => { + return Err(AgentTurnLoopError::ProviderIdleTimeout { + timeout_ms: provider_idle_timeout.as_millis(), + attempts: 1, + partial_content: String::new(), + partial_reasoning_chars: 0, + }); + } + }; cancellation_token.check()?; let mut response = None; let mut emitted_content_delta = false; + let mut partial_content = String::new(); + let mut partial_reasoning_chars = 0usize; - while let Some(event) = stream.next().await { + loop { + let next_event = match tokio::time::timeout(provider_idle_timeout, stream.next()).await + { + Ok(next_event) => next_event, + Err(_) => { + return Err(AgentTurnLoopError::ProviderIdleTimeout { + timeout_ms: provider_idle_timeout.as_millis(), + attempts: 1, + partial_content, + partial_reasoning_chars, + }); + } + }; + let Some(event) = next_event else { + break; + }; cancellation_token.check()?; - match event.map_err(|error| provider_error_or_canceled(error, &cancellation_token))? { + let event = match event { + Ok(event) => event, + Err(error) => { + return match provider_error_or_canceled(error, &cancellation_token) { + AgentTurnLoopError::Provider(source) if source.is_transient() => { + Err(AgentTurnLoopError::ProviderStreamInterrupted { + source, + partial_content, + partial_reasoning_chars, + }) + } + error => Err(error), + }; + } + }; + match event { TurnProviderEvent::AssistantDelta(delta) => { if response.is_some() { return Err(AgentTurnLoopError::ProviderEventAfterCompletion); @@ -339,6 +655,7 @@ where .filter(|content| !content.is_empty()) { emitted_content_delta = true; + partial_content.push_str(content); append_turn_event( run_log, event_sink, @@ -351,6 +668,14 @@ where }), )?; } + + if let Some(reasoning_content) = delta + .reasoning_content + .as_deref() + .filter(|reasoning_content| !reasoning_content.is_empty()) + { + partial_reasoning_chars += reasoning_content.chars().count(); + } } TurnProviderEvent::Completed(completed) => { if response.replace(completed).is_some() { @@ -416,6 +741,14 @@ where "stable workspace manifest summary", )); } + builder.add_item(ContextItem::project_rules( + TOOL_USAGE_INSTRUCTION, + "default tool usage contract", + )); + builder.add_item(ContextItem::project_rules( + FINAL_RESPONSE_SUMMARY_INSTRUCTION, + "default final response summary contract", + )); builder.add_item(ContextItem::user_task(input.user_task.clone())); for item in &input.context_items { builder.add_item(item.clone()); @@ -608,13 +941,25 @@ where ) -> Result { context.cancellation_token.check()?; let tool_name = tool_call.function.name.as_str(); - let definition = - find_builtin_tool(tool_name).ok_or_else(|| AgentTurnLoopError::UnknownTool { - tool_call_id: tool_call.id.clone(), - name: tool_name.to_owned(), - })?; + let Some(definition) = find_builtin_tool(tool_name) else { + return self.execute_unknown_tool_call(tool_call, context, run_log, event_sink); + }; let arguments_preview = parse_tool_arguments_value(tool_call)?; - validate_tool_call_arguments(definition, tool_call, &arguments_preview)?; + if let Err(source) = validate_tool_arguments(definition, &arguments_preview) { + return self.execute_invalid_tool_argument_schema_tool_call( + tool_call, + context, + InvalidToolArgumentSchemaCall { + definition, + arguments_preview, + source, + }, + run_log, + event_sink, + ); + } + let executable_arguments = + executable_tool_arguments(definition, tool_call, &arguments_preview, run_log)?; let risk_assessment = tool_risk_assessment(definition, &arguments_preview); append_turn_event( @@ -675,8 +1020,16 @@ where ) } ToolName::ApplyPatch => { - let args: ApplyPatchArgs = parse_tool_arguments(tool_call, &arguments_preview)?; - let approval_hunks = patch_approval_hunks(&args.unified_diff)?; + let args: ApplyPatchArgs = parse_tool_arguments(tool_call, &executable_arguments)?; + let approval_hunks = match patch_approval_hunks(&args.unified_diff) { + Ok(hunks) => hunks, + Err(error) if is_recoverable_apply_patch_error(&error) => { + return self.execute_failed_apply_patch_tool_call( + tool_call, context, error, run_log, event_sink, + ); + } + Err(error) => return Err(error.into()), + }; let approval_scope = self.ensure_approval( definition, &risk_assessment, @@ -694,7 +1047,17 @@ where )?; let args = match approval_scope { ApprovalScope::All => args, - ApprovalScope::Hunks { hunk_ids } => filter_apply_patch_hunks(args, &hunk_ids)?, + ApprovalScope::Hunks { hunk_ids } => { + match filter_apply_patch_hunks(args, &hunk_ids) { + Ok(args) => args, + Err(error) if is_recoverable_apply_patch_error(&error) => { + return self.execute_failed_apply_patch_tool_call( + tool_call, context, error, run_log, event_sink, + ); + } + Err(error) => return Err(error.into()), + } + } }; self.execute_without_approval( tool_call, @@ -704,18 +1067,29 @@ where event_sink, |tools, args, cancellation_token| { let result = - tools.apply_patch_with_cancellation(args, cancellation_token)?; + match tools.apply_patch_with_cancellation(args, cancellation_token) { + Ok(result) => result, + Err(error) if is_recoverable_apply_patch_error(&error) => { + failed_apply_patch_result(&error) + } + Err(error) => return Err(error.into()), + }; + let changed_files = if result.status == ToolStatus::Ok { + result.files.clone() + } else { + Vec::new() + }; tool_record( result.status, result.summary.clone(), - result.files.clone(), + changed_files, &result, ) }, ) } ToolName::Shell => { - let args: ShellArgs = parse_tool_arguments(tool_call, &arguments_preview)?; + let args: ShellArgs = parse_tool_arguments(tool_call, &executable_arguments)?; self.ensure_approval( definition, &risk_assessment, @@ -731,6 +1105,7 @@ where run_log, event_sink, )?; + let command = args.command.clone(); self.execute_without_approval( tool_call, context, @@ -739,7 +1114,7 @@ where event_sink, |tools, args, cancellation_token| { let result = tools.shell_with_cancellation(args, cancellation_token)?; - shell_tool_record(result) + shell_tool_record(&command, result) }, ) } @@ -780,6 +1155,10 @@ where tool_call_id: tool_call.id.clone(), name: tool_name.to_owned(), }), + ToolName::ModelTurnBudget => Err(AgentTurnLoopError::UnsupportedTool { + tool_call_id: tool_call.id.clone(), + name: tool_name.to_owned(), + }), } } @@ -830,6 +1209,141 @@ where Ok(executed) } + fn execute_failed_apply_patch_tool_call( + &self, + tool_call: &ChatToolCall, + context: ToolCallContext<'_>, + error: ToolExecutionError, + run_log: &mut L, + event_sink: &mut (impl TurnEventSink + ?Sized), + ) -> Result + where + L: RunLogWriter + ?Sized, + { + self.execute_without_approval( + tool_call, + context, + error, + run_log, + event_sink, + |_tools, error, _cancellation_token| { + let result = failed_apply_patch_result(&error); + tool_record(result.status, result.summary.clone(), Vec::new(), &result) + }, + ) + } + + fn execute_invalid_tool_argument_schema_tool_call( + &self, + tool_call: &ChatToolCall, + context: ToolCallContext<'_>, + invalid: InvalidToolArgumentSchemaCall<'_>, + run_log: &mut L, + event_sink: &mut (impl TurnEventSink + ?Sized), + ) -> Result + where + L: RunLogWriter + ?Sized, + { + context.cancellation_token.check()?; + let tool_name = tool_call.function.name.as_str(); + let risk_assessment = tool_risk_assessment(invalid.definition, &invalid.arguments_preview); + let validation_error = invalid.source.to_string(); + let summary = invalid_tool_schema_summary(tool_name, &validation_error); + let result = invalid_tool_schema_result( + invalid.definition.name, + tool_name, + &summary, + &validation_error, + ); + let message_content = serde_json::to_string(&result)?; + let executed = ExecutedToolCall { + status: ToolStatus::Failed, + summary, + message_content, + log_result: result, + changed_files: Vec::new(), + verification_status: None, + follow_up_output_summary: None, + }; + + append_turn_event( + run_log, + event_sink, + "tool.requested", + Some(context.turn_id.to_owned()), + tool_requested_payload( + tool_call.id.clone(), + tool_name, + &risk_assessment, + invalid.arguments_preview, + ), + )?; + append_turn_event( + run_log, + event_sink, + "tool.completed", + Some(context.turn_id.to_owned()), + json!({ + "toolCallId": tool_call.id.clone(), + "name": tool_name, + "status": executed.status.as_str(), + "summary": executed.summary.clone(), + "result": executed.log_result.clone(), + }), + )?; + + Ok(executed) + } + + fn execute_unknown_tool_call( + &self, + tool_call: &ChatToolCall, + context: ToolCallContext<'_>, + run_log: &mut L, + event_sink: &mut (impl TurnEventSink + ?Sized), + ) -> Result + where + L: RunLogWriter + ?Sized, + { + context.cancellation_token.check()?; + let tool_name = tool_call.function.name.as_str(); + let summary = unknown_tool_summary(tool_name); + let result = unknown_tool_result(tool_name, &summary); + let message_content = serde_json::to_string(&result)?; + let executed = ExecutedToolCall { + status: ToolStatus::Failed, + summary, + message_content, + log_result: result, + changed_files: Vec::new(), + verification_status: None, + follow_up_output_summary: None, + }; + + append_turn_event( + run_log, + event_sink, + "tool.requested", + Some(context.turn_id.to_owned()), + unknown_tool_requested_payload(tool_call.id.clone(), tool_name), + )?; + append_turn_event( + run_log, + event_sink, + "tool.completed", + Some(context.turn_id.to_owned()), + json!({ + "toolCallId": tool_call.id.clone(), + "name": tool_name, + "status": executed.status.as_str(), + "summary": executed.summary.clone(), + "result": executed.log_result.clone(), + }), + )?; + + Ok(executed) + } + #[allow(clippy::too_many_arguments)] fn ensure_approval( &mut self, @@ -847,7 +1361,11 @@ where run_log: &mut (impl RunLogWriter + ?Sized), event_sink: &mut (impl TurnEventSink + ?Sized), ) -> Result { - let approval = effective_approval_requirement(definition.approval, risk_assessment.risk); + let approval = effective_approval_requirement( + definition.approval, + risk_assessment.risk, + risk_assessment.approval_override, + ); if approval == ApprovalRequirement::None { return Ok(ApprovalScope::All); } @@ -952,23 +1470,121 @@ where } } } -} - -pub trait TurnEventSink { - fn on_event(&mut self, event: &RunLogEvent) -> Result<(), TurnEventSinkError>; -} -#[derive(Debug, Default, Clone, Copy)] -pub struct NoopTurnEventSink; + fn ensure_model_turn_budget_continuation( + &mut self, + turn_id: &str, + completed_iterations: usize, + cancellation_token: &CancellationToken, + run_log: &mut (impl RunLogWriter + ?Sized), + event_sink: &mut (impl TurnEventSink + ?Sized), + ) -> Result<(), AgentTurnLoopError> { + cancellation_token.check()?; + let request = TurnApprovalRequest { + approval_id: format!("approval_model_turn_budget_{completed_iterations}"), + tool_call_id: format!("model_turn_budget_{completed_iterations}"), + tool_name: MODEL_TURN_BUDGET_TOOL_NAME.to_owned(), + risk: RiskLevel::Exec, + title: "Continue agent turn".to_owned(), + detail: format!( + "The model has used {completed_iterations} provider turn(s) without finishing. Approve to continue for up to {} more provider turn(s).", + self.config.max_model_turns + ), + command: None, + cwd: None, + output_summary: None, + paths: None, + hunks: None, + risk_reasons: vec!["model turn budget reached".to_owned()], + persistable: false, + }; -impl TurnEventSink for NoopTurnEventSink { - fn on_event(&mut self, _event: &RunLogEvent) -> Result<(), TurnEventSinkError> { - Ok(()) - } -} + append_turn_event( + run_log, + event_sink, + "tool.approvalRequired", + Some(turn_id.to_owned()), + approval_payload(&request), + )?; -#[derive(Debug, Error)] -#[error("turn event sink failed: {message}")] + cancellation_token.check()?; + match self.approval_policy.decide(&request)? { + ApprovalDecision::Approved | ApprovalDecision::ApprovedHunks { .. } => { + append_turn_event( + run_log, + event_sink, + "tool.approvalResolved", + Some(turn_id.to_owned()), + approval_resolved_payload( + &request, + "approved", + None, + Some(&ApprovalScope::All), + ), + )?; + Ok(()) + } + ApprovalDecision::Rejected { reason } => { + append_turn_event( + run_log, + event_sink, + "tool.approvalResolved", + Some(turn_id.to_owned()), + approval_resolved_payload(&request, "rejected", Some(reason.as_str()), None), + )?; + Err(AgentTurnLoopError::ApprovalRejected { + approval_id: request.approval_id, + tool_call_id: request.tool_call_id, + reason, + }) + } + ApprovalDecision::Canceled { reason } => { + append_turn_event( + run_log, + event_sink, + "tool.approvalResolved", + Some(turn_id.to_owned()), + approval_resolved_payload(&request, "canceled", Some(reason.as_str()), None), + )?; + Err(AgentTurnLoopError::ApprovalCanceled { + approval_id: request.approval_id, + tool_call_id: request.tool_call_id, + reason, + }) + } + ApprovalDecision::Expired { reason } => { + append_turn_event( + run_log, + event_sink, + "tool.approvalResolved", + Some(turn_id.to_owned()), + approval_resolved_payload(&request, "expired", Some(reason.as_str()), None), + )?; + Err(AgentTurnLoopError::ApprovalExpired { + approval_id: request.approval_id, + tool_call_id: request.tool_call_id, + reason, + }) + } + } + } +} + +pub trait TurnEventSink { + fn on_event(&mut self, event: &RunLogEvent) -> Result<(), TurnEventSinkError>; +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopTurnEventSink; + +impl TurnEventSink for NoopTurnEventSink { + fn on_event(&mut self, _event: &RunLogEvent) -> Result<(), TurnEventSinkError> { + Ok(()) + } +} + +#[derive(Debug, Error)] +#[error("turn event sink failed: {message}")] pub struct TurnEventSinkError { message: String, } @@ -1022,6 +1638,123 @@ where ) } +fn push_final_response_retry_messages( + messages: &mut Vec, + response: &TurnProviderResponse, +) { + if let Some(content) = response + .content + .as_deref() + .filter(|content| !content.trim().is_empty()) + { + messages.push(ChatMessage::assistant(content.to_owned())); + } + messages.push(ChatMessage::user(FINAL_RESPONSE_LENGTH_RETRY_INSTRUCTION)); +} + +fn push_tool_call_length_retry_messages( + messages: &mut Vec, + response: &TurnProviderResponse, +) { + if let Some(content) = response + .content + .as_deref() + .filter(|content| !content.trim().is_empty()) + { + messages.push(ChatMessage::assistant(content.to_owned())); + } + messages.push(ChatMessage::user(TOOL_CALL_LENGTH_RETRY_INSTRUCTION)); +} + +fn push_provider_stream_retry_messages( + messages: &mut Vec, + partial_content: &str, + partial_reasoning_chars: usize, +) { + if let Some(content) = provider_stream_retry_partial_content(partial_content) { + messages.push(ChatMessage::assistant(content)); + } + let instruction = if partial_reasoning_chars == 0 { + PROVIDER_STREAM_RETRY_INSTRUCTION.to_owned() + } else { + format!("{PROVIDER_STREAM_RETRY_INSTRUCTION} {PROVIDER_STREAM_RETRY_REASONING_NOTICE}") + }; + messages.push(ChatMessage::user(instruction)); +} + +fn provider_stream_retry_partial_content(partial_content: &str) -> Option { + let partial_content = partial_content.trim(); + if partial_content.is_empty() { + return None; + } + + let char_count = partial_content.chars().count(); + if char_count <= PROVIDER_STREAM_RETRY_PARTIAL_CONTENT_MAX_CHARS { + return Some(partial_content.to_owned()); + } + + let suffix = partial_content + .chars() + .rev() + .take(PROVIDER_STREAM_RETRY_PARTIAL_CONTENT_MAX_CHARS) + .collect::() + .chars() + .rev() + .collect::(); + Some(format!( + "[earlier interrupted assistant text omitted]\n{suffix}" + )) +} + +fn recover_missing_tool_call_reasoning( + response: &mut TurnProviderResponse, + mode: ReasoningContentMode, +) -> bool { + if response.tool_calls.is_empty() || mode != ReasoningContentMode::ThinkingEnabled { + return false; + } + + if response + .reasoning_content + .as_deref() + .is_some_and(|reasoning| !reasoning.trim().is_empty()) + { + return false; + } + + response.reasoning_content = Some(recovered_tool_call_reasoning_content(&response.tool_calls)); + true +} + +fn recovered_tool_call_reasoning_content(tool_calls: &[ChatToolCall]) -> String { + let tool_names = tool_calls + .iter() + .filter_map(|tool_call| { + let name = tool_call.function.name.trim(); + (!name.is_empty()).then_some(name) + }) + .collect::>(); + + if tool_names.is_empty() { + return "Tool call selected.".to_owned(); + } + + format!( + "Tool call selected: {}.", + tool_names.into_iter().collect::>().join(", ") + ) +} + +fn turn_provider_finish_reason_label(reason: TurnProviderFinishReason) -> &'static str { + match reason { + TurnProviderFinishReason::Stop => "stop", + TurnProviderFinishReason::Length => "length", + TurnProviderFinishReason::ToolCalls => "tool_calls", + TurnProviderFinishReason::ContentFilter => "content_filter", + TurnProviderFinishReason::Error => "error", + } +} + fn attachment_path( index: usize, attachment: &TurnAttachment, @@ -1109,6 +1842,9 @@ fn attachment_dedup_signature(key: &str) -> String { pub struct AgentTurnLoopConfig { pub max_input_tokens: u64, pub max_model_turns: usize, + pub provider_transient_retries: usize, + pub provider_idle_timeout: Duration, + pub provider_idle_timeout_attempts: usize, pub reasoning_mode: ReasoningContentMode, pub max_attachments: usize, pub max_attachment_bytes: u64, @@ -1118,7 +1854,10 @@ impl Default for AgentTurnLoopConfig { fn default() -> Self { Self { max_input_tokens: 1_000_000, - max_model_turns: 8, + max_model_turns: DEFAULT_MAX_MODEL_TURNS, + provider_transient_retries: DEFAULT_PROVIDER_TRANSIENT_RETRIES, + provider_idle_timeout: Duration::from_secs(DEFAULT_PROVIDER_IDLE_TIMEOUT_SECS), + provider_idle_timeout_attempts: DEFAULT_PROVIDER_IDLE_TIMEOUT_ATTEMPTS, reasoning_mode: ReasoningContentMode::ThinkingEnabled, max_attachments: DEFAULT_MAX_ATTACHMENTS, max_attachment_bytes: DEFAULT_MAX_ATTACHMENT_BYTES, @@ -1133,6 +1872,8 @@ pub struct AgentTurnInput { pub mode: AgentRunMode, pub context_items: Vec, pub attachments: Vec, + pub supersedes: Option, + pub steer_queue: Option, pub cancellation_token: CancellationToken, } @@ -1144,6 +1885,8 @@ impl AgentTurnInput { mode: AgentRunMode::Edit, context_items: Vec::new(), attachments: Vec::new(), + supersedes: None, + steer_queue: None, cancellation_token: CancellationToken::new(), } } @@ -1168,12 +1911,71 @@ impl AgentTurnInput { self } + pub fn with_supersedes(mut self, supersedes: TurnSupersedes) -> Self { + self.supersedes = Some(supersedes); + self + } + + pub fn with_steer_queue(mut self, steer_queue: TurnSteerQueue) -> Self { + self.steer_queue = Some(steer_queue); + self + } + pub fn with_cancellation_token(mut self, cancellation_token: CancellationToken) -> Self { self.cancellation_token = cancellation_token; self } } +#[derive(Debug, Clone, Default)] +pub struct TurnSteerQueue { + inner: Arc>>, +} + +impl TurnSteerQueue { + pub fn push(&self, steer_id: impl Into, message: impl Into) { + let mut queue = self + .inner + .lock() + .expect("turn steer queue lock should not be poisoned"); + queue.push_back(TurnSteerMessage { + steer_id: steer_id.into(), + message: message.into(), + }); + } + + fn drain(&self) -> Vec { + let mut queue = self + .inner + .lock() + .expect("turn steer queue lock should not be poisoned"); + queue.drain(..).collect() + } +} + +impl PartialEq for TurnSteerQueue { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.inner, &other.inner) + } +} + +impl Eq for TurnSteerQueue {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TurnSteerMessage { + pub steer_id: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TurnSupersedes { + pub message_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TurnAttachment { pub kind: TurnAttachmentKind, @@ -1490,14 +2292,27 @@ pub trait TurnProvider { #[error("{message}")] pub struct TurnProviderError { message: String, + transient: bool, } impl TurnProviderError { pub fn new(message: impl Into) -> Self { Self { message: message.into(), + transient: false, + } + } + + pub fn transient(message: impl Into) -> Self { + Self { + message: message.into(), + transient: true, } } + + pub const fn is_transient(&self) -> bool { + self.transient + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1595,6 +2410,19 @@ pub enum AgentTurnLoopError { Reasoning(#[from] ReasoningContentError), #[error("provider failed: {0}")] Provider(#[from] TurnProviderError), + #[error("provider response stream interrupted: {source}")] + ProviderStreamInterrupted { + source: TurnProviderError, + partial_content: String, + partial_reasoning_chars: usize, + }, + #[error("provider request made no progress for {timeout_ms}ms after {attempts} attempts")] + ProviderIdleTimeout { + timeout_ms: u128, + attempts: usize, + partial_content: String, + partial_reasoning_chars: usize, + }, #[error("provider stream ended without a completed response")] ProviderStreamEndedWithoutCompletion, #[error("provider stream emitted more than one completed response")] @@ -1618,6 +2446,7 @@ pub enum AgentTurnLoopError { tool_call_id: String, name: String, source: serde_json::Error, + raw_arguments: Option, }, #[error("tool call `{tool_call_id}` for `{name}` failed JSON Schema validation: {source}")] InvalidToolArgumentSchema { @@ -1625,6 +2454,12 @@ pub enum AgentTurnLoopError { name: String, source: ToolArgumentSchemaError, }, + #[error("tool call `{tool_call_id}` for `{name}` has invalid payload reference: {detail}")] + InvalidToolPayloadReference { + tool_call_id: String, + name: String, + detail: String, + }, #[error("tool result serialization failed: {0}")] Serialization(#[from] serde_json::Error), #[error("too many attachments: got {count}, max {max_attachments}")] @@ -1664,8 +2499,6 @@ pub enum AgentTurnLoopError { }, #[error("approval policy failed: {0}")] ApprovalPolicy(#[from] ApprovalPolicyError), - #[error("model did not finish after {max_model_turns} turns")] - MaxModelTurnsExceeded { max_model_turns: usize }, } impl AgentTurnLoopError { @@ -1674,7 +2507,8 @@ impl AgentTurnLoopError { Self::InvalidConfig { .. } => "E_INVALID_CONFIG", Self::ContextBuild(_) => "E_CONTEXT_BUILD_FAILED", Self::Reasoning(_) => "E_REASONING_CONTENT", - Self::Provider(_) => "E_PROVIDER_ERROR", + Self::Provider(_) | Self::ProviderStreamInterrupted { .. } => "E_PROVIDER_ERROR", + Self::ProviderIdleTimeout { .. } => "E_PROVIDER_TIMEOUT", Self::ProviderStreamEndedWithoutCompletion => "E_PROVIDER_STREAM_INCOMPLETE", Self::ProviderCompletedMultipleTimes => "E_PROVIDER_STREAM_INVALID", Self::ProviderEventAfterCompletion => "E_PROVIDER_STREAM_INVALID", @@ -1684,9 +2518,9 @@ impl AgentTurnLoopError { Self::Canceled { .. } => "E_RUN_CANCELED", Self::UnknownTool { .. } => "E_UNKNOWN_TOOL", Self::UnsupportedTool { .. } => "E_UNSUPPORTED_TOOL", - Self::InvalidToolArguments { .. } | Self::InvalidToolArgumentSchema { .. } => { - "E_INVALID_TOOL_ARGUMENTS" - } + Self::InvalidToolArguments { .. } + | Self::InvalidToolArgumentSchema { .. } + | Self::InvalidToolPayloadReference { .. } => "E_INVALID_TOOL_ARGUMENTS", Self::Serialization(_) => "E_SERIALIZATION", Self::TooManyAttachments { .. } | Self::DuplicateAttachment { .. } @@ -1697,7 +2531,6 @@ impl AgentTurnLoopError { Self::ApprovalCanceled { .. } => "E_APPROVAL_CANCELED", Self::ApprovalExpired { .. } => "E_APPROVAL_EXPIRED", Self::ApprovalPolicy(_) => "E_APPROVAL_POLICY", - Self::MaxModelTurnsExceeded { .. } => "E_MAX_MODEL_TURNS", } } } @@ -1725,9 +2558,35 @@ struct ExecutedToolCall { message_content: String, log_result: Value, changed_files: Vec, + verification_status: Option, follow_up_output_summary: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VerificationStatus { + Skipped, + Passed, + Failed, +} + +impl VerificationStatus { + const fn as_str(self) -> &'static str { + match self { + Self::Skipped => "skipped", + Self::Passed => "passed", + Self::Failed => "failed", + } + } + + const fn combine(self, observed: Option) -> Self { + match (self, observed) { + (Self::Failed, _) | (_, Some(Self::Failed)) => Self::Failed, + (_, Some(Self::Passed)) => Self::Passed, + (status, _) => status, + } + } +} + struct CollectedProviderTurn { // `response` is the authoritative completed assistant message used for final text, // reasoning replay, and tool execution. Streaming deltas are only presentation/log events. @@ -1746,28 +2605,134 @@ struct ToolCallContext<'a> { cancellation_token: &'a CancellationToken, } +struct InvalidToolArgumentSchemaCall<'a> { + definition: &'a ToolDefinition, + arguments_preview: Value, + source: ToolArgumentSchemaError, +} + fn parse_tool_arguments_value(tool_call: &ChatToolCall) -> Result { serde_json::from_str(&tool_call.function.arguments).map_err(|source| { AgentTurnLoopError::InvalidToolArguments { tool_call_id: tool_call.id.clone(), name: tool_call.function.name.clone(), source, + raw_arguments: Some(tool_call.function.arguments.clone()), } }) } -fn validate_tool_call_arguments( +fn executable_tool_arguments( definition: &ToolDefinition, tool_call: &ChatToolCall, arguments: &Value, -) -> Result<(), AgentTurnLoopError> { - validate_tool_arguments(definition, arguments).map_err(|source| { - AgentTurnLoopError::InvalidToolArgumentSchema { - tool_call_id: tool_call.id.clone(), - name: tool_call.function.name.clone(), - source, + run_log: &mut (impl RunLogWriter + ?Sized), +) -> Result { + if definition.name != ToolName::ApplyPatch { + return Ok(arguments.clone()); + } + + apply_patch_arguments_for_execution(tool_call, arguments, run_log) +} + +fn apply_patch_arguments_for_execution( + tool_call: &ChatToolCall, + arguments: &Value, + run_log: &mut (impl RunLogWriter + ?Sized), +) -> Result { + let object = arguments.as_object().ok_or_else(|| { + invalid_payload_ref(tool_call, "apply_patch arguments must be a JSON object") + })?; + let has_unified_diff = object.get("unifiedDiff").is_some(); + let payload_ref = object.get("payloadRef"); + + match (has_unified_diff, payload_ref) { + (true, None) => Ok(arguments.clone()), + (false, Some(payload_ref)) => { + let unified_diff = read_run_payload_ref(tool_call, payload_ref, run_log)?; + let mut materialized = object.clone(); + materialized.remove("payloadRef"); + materialized.insert("unifiedDiff".to_owned(), Value::String(unified_diff)); + Ok(Value::Object(materialized)) } - }) + (true, Some(_)) => Err(invalid_payload_ref( + tool_call, + "apply_patch must provide either unifiedDiff or payloadRef, not both", + )), + (false, None) => Err(invalid_payload_ref( + tool_call, + "apply_patch must provide unifiedDiff or payloadRef", + )), + } +} + +fn read_run_payload_ref( + tool_call: &ChatToolCall, + payload_ref: &Value, + run_log: &mut (impl RunLogWriter + ?Sized), +) -> Result { + let object = payload_ref + .as_object() + .ok_or_else(|| invalid_payload_ref(tool_call, "payloadRef must be an object"))?; + let kind = object.get("kind").and_then(Value::as_str); + if kind != Some("run_file") { + return Err(invalid_payload_ref( + tool_call, + "payloadRef.kind must be `run_file`", + )); + } + + let path = object + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| invalid_payload_ref(tool_call, "payloadRef.path must be a string"))?; + let slash_path = path.replace('\\', "/"); + if !slash_path.starts_with("payloads/") { + return Err(invalid_payload_ref( + tool_call, + "payloadRef.path must be under payloads/", + )); + } + + let contents = run_log + .read_payload_file(Path::new(path)) + .map_err(|source| { + invalid_payload_ref(tool_call, format!("payloadRef.path is invalid: {source}")) + })? + .ok_or_else(|| { + invalid_payload_ref( + tool_call, + "active run log does not support payload references", + ) + })?; + if let Some(expected_size) = object.get("sizeBytes").and_then(Value::as_u64) { + let actual_size = contents.len() as u64; + if actual_size != expected_size { + return Err(invalid_payload_ref( + tool_call, + format!("payloadRef.sizeBytes expected {expected_size}, got {actual_size}"), + )); + } + } + if let Some(expected_sha256) = object.get("sha256").and_then(Value::as_str) { + let actual_sha256 = crate::hashing::sha256_hex(contents.as_bytes()); + if actual_sha256 != expected_sha256 { + return Err(invalid_payload_ref( + tool_call, + "payloadRef.sha256 does not match payload file", + )); + } + } + + Ok(contents) +} + +fn invalid_payload_ref(tool_call: &ChatToolCall, detail: impl Into) -> AgentTurnLoopError { + AgentTurnLoopError::InvalidToolPayloadReference { + tool_call_id: tool_call.id.clone(), + name: tool_call.function.name.clone(), + detail: detail.into(), + } } fn parse_tool_arguments( @@ -1779,6 +2744,7 @@ fn parse_tool_arguments( tool_call_id: tool_call.id.clone(), name: tool_call.function.name.clone(), source, + raw_arguments: None, } }) } @@ -1797,12 +2763,17 @@ fn tool_record( message_content, log_result, changed_files, + verification_status: None, follow_up_output_summary: None, }) } -fn shell_tool_record(result: ShellResult) -> Result { +fn shell_tool_record( + command: &str, + result: ShellResult, +) -> Result { let output_summary = shell_approval_output_summary(&result); + let verification_status = verification_status_for_shell_command(command, result.status); let log_result = redacted_tool_result_value(&result)?; let message_content = serde_json::to_string(&log_result)?; Ok(ExecutedToolCall { @@ -1811,10 +2782,92 @@ fn shell_tool_record(result: ShellResult) -> Result Option { + if !is_verification_shell_command(command) { + return None; + } + + Some(match status { + ToolStatus::Ok => VerificationStatus::Passed, + ToolStatus::Failed => VerificationStatus::Failed, + }) +} + +fn is_verification_shell_command(command: &str) -> bool { + let tokens = shell_command_tokens(command); + let Some(program) = tokens.first().map(String::as_str) else { + return false; + }; + + match program { + "cargo" => tokens + .iter() + .skip(1) + .any(|token| matches!(token.as_str(), "check" | "clippy" | "test")), + "npm" | "pnpm" | "yarn" | "bun" => is_javascript_verification_command(&tokens[1..]), + "node" => tokens.iter().skip(1).any(|token| token == "test"), + "py" | "python" | "python3" => tokens + .windows(2) + .any(|pair| pair[0] == "m" && matches!(pair[1].as_str(), "pytest" | "unittest")), + "pytest" => true, + "go" | "dotnet" => tokens.iter().skip(1).any(|token| token.as_str() == "test"), + "tsc" => true, + _ => false, + } +} + +fn shell_command_tokens(command: &str) -> Vec { + command + .split(|character: char| { + !(character.is_ascii_alphanumeric() + || character == '-' + || character == '_' + || character == '.' + || character == ':') + }) + .filter(|token| !token.is_empty()) + .map(|token| token.trim_start_matches('-').to_ascii_lowercase()) + .collect() +} + +fn is_javascript_verification_command(tokens: &[String]) -> bool { + for (index, token) in tokens.iter().enumerate() { + if token == "test" { + return true; + } + if token == "run" + && let Some(script) = tokens.get(index + 1) + && is_javascript_verification_script(script) + { + return true; + } + } + false +} + +fn is_javascript_verification_script(script: &str) -> bool { + matches!( + script, + "check" | "clippy" | "lint" | "test" | "tests" | "typecheck" | "type-check" + ) || script.starts_with("check:") + || script.starts_with("lint:") + || script.starts_with("test:") + || script.starts_with("typecheck:") + || script.starts_with("type-check:") +} + +fn should_capture_workspace_diff_baseline(tool_name: &str) -> bool { + tool_name.eq_ignore_ascii_case(ToolName::Shell.as_str()) +} + fn shell_approval_output_summary(result: &ShellResult) -> Option { let mut sections = Vec::new(); if let Some(stdout) = shell_output_tail_section("stdout", &result.stdout) { @@ -1870,52 +2923,539 @@ fn truncate_shell_output_summary(text: &str) -> String { format!("{}{}", &text[..end], TRUNCATED_MARKER) } -fn reasoning_state_payload(state: ReasoningContentState) -> Value { - match state { - ReasoningContentState::NoReplayRequired => { - json!({ "state": "no_replay_required" }) - } - ReasoningContentState::ReplayRequired { assistant_messages } => { - json!({ - "state": "replay_required", - "assistantMessages": assistant_messages, - }) - } - } +#[derive(Debug, Clone, PartialEq, Eq)] +struct WorkspaceFileSnapshot { + files: BTreeMap, } #[derive(Debug, Clone, PartialEq, Eq)] -struct ToolRiskAssessment { - risk: RiskLevel, - risk_reasons: Vec, +struct WorkspaceFileFingerprint { + len: u64, + modified_unix_ms: Option, + hash: Option, } -fn tool_risk_assessment(definition: &ToolDefinition, arguments: &Value) -> ToolRiskAssessment { - if definition.name == ToolName::Shell - && let Some(command) = arguments.get("command").and_then(Value::as_str) - { - let classification = classify_shell_command(command); +fn final_changed_files( + mut changed_files: Vec, + workspace_before: Option<&WorkspaceFileSnapshot>, + workspace_root: &Path, +) -> (Vec, Option) { + if let Some(before) = workspace_before { + match workspace_file_snapshot(workspace_root) { + Ok(after) => { + changed_files.extend(workspace_snapshot_changed_files(before, &after)); + } + Err(error) => { + return ( + sorted_unique_strings(changed_files), + Some(error.to_string()), + ); + } + } + } + (sorted_unique_strings(changed_files), None) +} + +fn append_workspace_snapshot_failed_event( + run_log: &mut L, + event_sink: &mut (impl TurnEventSink + ?Sized), + turn_id: &str, + phase: &str, + message: String, +) -> Result +where + L: RunLogWriter + ?Sized, +{ + append_turn_event( + run_log, + event_sink, + "workspace.snapshotFailed", + Some(turn_id.to_owned()), + json!({ + "phase": phase, + "message": message, + }), + ) +} + +fn workspace_file_snapshot(root: &Path) -> std::io::Result { + let root = fs::canonicalize(root)?; + let mut files = BTreeMap::new(); + let mut scanned_files = 0usize; + scan_workspace_dir(&root, &root, &mut files, &mut scanned_files)?; + Ok(WorkspaceFileSnapshot { files }) +} + +fn scan_workspace_dir( + root: &Path, + dir: &Path, + files: &mut BTreeMap, + scanned_files: &mut usize, +) -> std::io::Result<()> { + if *scanned_files >= WORKSPACE_DIFF_MAX_FILES { + return Ok(()); + } + + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(error) if dir == root => return Err(error), + Err(_) => return Ok(()), + }; + let mut entries = entries.filter_map(Result::ok).collect::>(); + entries.sort_by_key(|entry| entry.file_name()); + + for entry in entries { + if *scanned_files >= WORKSPACE_DIFF_MAX_FILES { + return Ok(()); + } + let path = entry.path(); + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(_) => continue, + }; + + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { + if is_workspace_diff_excluded_dir(&entry.file_name().to_string_lossy()) { + continue; + } + scan_workspace_dir(root, &path, files, scanned_files)?; + continue; + } + if !file_type.is_file() { + continue; + } + + let Some(relative_path) = workspace_relative_slash_path(root, &path) else { + continue; + }; + let Some(fingerprint) = workspace_file_fingerprint(&path) else { + continue; + }; + files.insert(relative_path, fingerprint); + *scanned_files += 1; + } + + Ok(()) +} + +fn workspace_file_fingerprint(path: &Path) -> Option { + let metadata = fs::metadata(path).ok()?; + let len = metadata.len(); + let modified_unix_ms = metadata + .modified() + .ok() + .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()); + let hash = if len <= WORKSPACE_DIFF_HASH_MAX_BYTES { + fs::read(path).ok().map(|bytes| sha256_hex(&bytes)) + } else { + None + }; + + Some(WorkspaceFileFingerprint { + len, + modified_unix_ms, + hash, + }) +} + +fn workspace_snapshot_changed_files( + before: &WorkspaceFileSnapshot, + after: &WorkspaceFileSnapshot, +) -> Vec { + let mut changed = Vec::new(); + let paths = before + .files + .keys() + .chain(after.files.keys()) + .cloned() + .collect::>(); + for path in paths { + if before.files.get(&path) != after.files.get(&path) { + changed.push(path); + } + } + changed +} + +fn sorted_unique_strings(values: Vec) -> Vec { + values + .into_iter() + .filter(|value| !value.is_empty()) + .collect::>() + .into_iter() + .collect() +} + +fn workspace_relative_slash_path(root: &Path, path: &Path) -> Option { + path.strip_prefix(root) + .ok() + .map(|relative| relative.to_string_lossy().replace('\\', "/")) + .filter(|relative| !relative.is_empty()) +} + +fn is_workspace_diff_excluded_dir(file_name: &str) -> bool { + WORKSPACE_DIFF_EXCLUDED_DIRS + .iter() + .any(|excluded| file_name.eq_ignore_ascii_case(excluded)) +} + +fn reasoning_state_payload(state: ReasoningContentState) -> Value { + match state { + ReasoningContentState::NoReplayRequired => { + json!({ "state": "no_replay_required" }) + } + ReasoningContentState::ReplayRequired { assistant_messages } => { + json!({ + "state": "replay_required", + "assistantMessages": assistant_messages, + }) + } + } +} + +fn drain_steer_messages( + input: &AgentTurnInput, + run_log: &mut (impl RunLogWriter + ?Sized), + event_sink: &mut (impl TurnEventSink + ?Sized), + messages: &mut Vec, +) -> Result<(), AgentTurnLoopError> { + let Some(steer_queue) = &input.steer_queue else { + return Ok(()); + }; + + for steer in steer_queue.drain() { + append_turn_event( + run_log, + event_sink, + "turn.steered", + Some(input.turn_id.clone()), + json!({ + "steerId": steer.steer_id, + "message": steer.message, + }), + )?; + messages.push(ChatMessage::user(format!( + "{STEER_INSTRUCTION_PREFIX}{}", + steer.message + ))); + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ToolRiskAssessment { + risk: RiskLevel, + risk_reasons: Vec, + approval_override: Option, +} + +fn tool_risk_assessment(definition: &ToolDefinition, arguments: &Value) -> ToolRiskAssessment { + if definition.name == ToolName::Shell + && let Some(command) = arguments.get("command").and_then(Value::as_str) + { + let cwd = arguments.get("cwd").and_then(Value::as_str); + if is_auto_approved_read_only_shell(command, cwd) { + return ToolRiskAssessment { + risk: RiskLevel::Read, + risk_reasons: vec!["read-only shell allowlist".to_owned()], + approval_override: Some(ApprovalRequirement::None), + }; + } + + let classification = classify_shell_command(command); return ToolRiskAssessment { risk: classification.risk, risk_reasons: classification.reason_summaries(), + approval_override: None, }; } + if definition.name == ToolName::ApplyPatch + && let Some(expected_files) = arguments.get("expectedFiles").and_then(Value::as_array) + { + if let Some(policy_file) = expected_files + .iter() + .filter_map(Value::as_str) + .find(|path| is_workspace_policy_patch_file(path)) + { + return ToolRiskAssessment { + risk: definition.risk, + risk_reasons: vec![format!( + "workspace policy file `{policy_file}` requires approval" + )], + approval_override: Some(ApprovalRequirement::Required), + }; + } + + let file_count = expected_files.len(); + if file_count > MAX_AUTO_APPROVED_PATCH_FILES { + return ToolRiskAssessment { + risk: definition.risk, + risk_reasons: vec![format!( + "bulk patch touches {file_count} files; limit for automatic workspace patch is {MAX_AUTO_APPROVED_PATCH_FILES}" + )], + approval_override: Some(ApprovalRequirement::Required), + }; + } + } + ToolRiskAssessment { risk: definition.risk, risk_reasons: Vec::new(), + approval_override: None, + } +} + +fn is_workspace_policy_patch_file(path: &str) -> bool { + let file_name = path.rsplit(['/', '\\']).next().unwrap_or(path); + WORKSPACE_POLICY_PATCH_FILES + .iter() + .any(|policy_file| file_name.eq_ignore_ascii_case(policy_file)) +} + +fn is_auto_approved_read_only_shell(command: &str, cwd: Option<&str>) -> bool { + if !is_safe_workspace_relative_token(cwd.unwrap_or(".")) { + return false; + } + + let Some(tokens) = split_simple_read_only_shell_tokens(command) else { + return false; + }; + let Some(program) = tokens.first().map(String::as_str) else { + return false; + }; + + if is_auto_approved_read_only_version_query(&tokens) { + return true; + } + + match program.to_ascii_lowercase().as_str() { + "rg" => { + read_only_shell_args_are_workspace_scoped(&tokens[1..]) + && tokens[1..] + .iter() + .all(|token| is_read_only_rg_argument(token)) + } + "get-content" | "gc" | "cat" | "type" | "select-string" | "get-childitem" | "gci" + | "dir" | "test-path" => read_only_shell_args_are_workspace_scoped(&tokens[1..]), + "git" => is_auto_approved_read_only_git(&tokens[1..]), + _ => false, + } +} + +fn is_auto_approved_read_only_version_query(tokens: &[String]) -> bool { + let [program, version_arg] = tokens else { + return false; + }; + if !is_plain_shell_program_name(program) { + return false; + } + + let program = program.to_ascii_lowercase(); + if !READ_ONLY_VERSION_COMMANDS.contains(&program.as_str()) { + return false; + } + + matches!(version_arg.as_str(), "--version" | "-V") + || (version_arg == "-v" && READ_ONLY_LOWERCASE_VERSION_COMMANDS.contains(&program.as_str())) + || (version_arg == "version" && READ_ONLY_VERSION_SUBCOMMANDS.contains(&program.as_str())) +} + +fn is_plain_shell_program_name(program: &str) -> bool { + let trimmed = program.trim(); + !trimmed.is_empty() + && !trimmed.starts_with('-') + && !trimmed.starts_with('.') + && !trimmed.chars().any(|ch| matches!(ch, '/' | '\\' | ':')) +} + +fn is_auto_approved_read_only_git(args: &[String]) -> bool { + let Some(subcommand) = args.first().map(String::as_str) else { + return false; + }; + if !matches!(subcommand, "diff" | "status" | "log" | "show") { + return false; + } + + args[1..].iter().all(|token| { + !matches!(token.as_str(), "--output" | "--ext-diff" | "--no-index") + && !token.starts_with("--output=") + && read_only_shell_token_is_workspace_scoped(token) + }) +} + +fn is_read_only_rg_argument(token: &str) -> bool { + if matches!(token, "--pre" | "--replace" | "--passthru") { + return false; + } + if token.starts_with("--pre=") || token.starts_with("--replace=") { + return false; + } + if token.starts_with("--") { + return true; + } + + !token.starts_with('-') || token.len() <= 1 || !token[1..].contains('r') +} + +fn read_only_shell_args_are_workspace_scoped(args: &[String]) -> bool { + args.iter() + .all(|token| read_only_shell_token_is_workspace_scoped(token)) +} + +fn read_only_shell_token_is_workspace_scoped(token: &str) -> bool { + if token.starts_with('-') { + if let Some((_, value)) = token.split_once('=') { + return is_safe_workspace_relative_token(value); + } + return true; + } + is_safe_workspace_relative_token(token) +} + +fn is_safe_workspace_relative_token(token: &str) -> bool { + let trimmed = token.trim(); + if trimmed.is_empty() || trimmed == "." { + return true; + } + + if trimmed.starts_with('/') || trimmed.starts_with('\\') || trimmed.starts_with('~') { + return false; + } + if trimmed.len() >= 2 && trimmed.as_bytes()[1] == b':' { + return false; + } + + !trimmed + .split(['/', '\\']) + .any(|component| component == "..") + && !is_sensitive_workspace_shell_token(trimmed) +} + +fn is_sensitive_workspace_shell_token(token: &str) -> bool { + let token = token.trim_start_matches(['!', '+']); + token.split(['/', '\\']).any(|component| { + let component = component.to_ascii_lowercase(); + matches!( + component.as_str(), + ".git" | ".secrets" | ".secret" | ".agents" | ".codex" | ".prole-coder" + ) || component == ".env" + || component.starts_with(".env.") + }) +} + +fn split_simple_read_only_shell_tokens(command: &str) -> Option> { + if command.trim().is_empty() + || command.contains('$') + || command + .chars() + .any(|character| matches!(character, '|' | '&' | ';' | '<' | '>' | '`' | '\n' | '\r')) + { + return None; + } + + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut quote = None; + for character in command.chars() { + if let Some(quote_character) = quote { + if character == quote_character { + quote = None; + } else { + current.push(character); + } + continue; + } + + match character { + '\'' | '"' => quote = Some(character), + ' ' | '\t' => { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + } + _ => current.push(character), + } + } + + if quote.is_some() { + return None; } + if !current.is_empty() { + tokens.push(current); + } + Some(tokens) } fn effective_approval_requirement( static_approval: ApprovalRequirement, risk: RiskLevel, + approval_override: Option, ) -> ApprovalRequirement { let risk_approval = risk.default_approval(); - if approval_requirement_rank(risk_approval) > approval_requirement_rank(static_approval) { - risk_approval + let base_approval = if static_approval == ApprovalRequirement::None { + // `ApprovalRequirement::None` is an explicit tool contract, not a shell risk + // classifier fallback: shell and model_turn_budget are registered as Required. + // The only write-capable built-in with static None is workspace apply_patch; + // network/destructive dynamic risk still upgrades to approval. + match risk { + RiskLevel::Network | RiskLevel::Destructive => risk_approval, + RiskLevel::Read | RiskLevel::Write | RiskLevel::Exec => ApprovalRequirement::None, + } } else { - static_approval + max_approval_requirement(static_approval, risk_approval) + }; + + match approval_override { + Some(ApprovalRequirement::None) => ApprovalRequirement::None, + Some(override_approval) => max_approval_requirement(base_approval, override_approval), + None => base_approval, + } +} + +fn max_approval_requirement( + left: ApprovalRequirement, + right: ApprovalRequirement, +) -> ApprovalRequirement { + if approval_requirement_rank(right) > approval_requirement_rank(left) { + right + } else { + left + } +} + +fn is_recoverable_apply_patch_error(error: &ToolExecutionError) -> bool { + matches!( + error, + ToolExecutionError::InvalidPatch(_) + | ToolExecutionError::PatchFileMismatch { .. } + | ToolExecutionError::PatchHunkMismatch { .. } + ) +} + +fn failed_apply_patch_result(error: &ToolExecutionError) -> ApplyPatchResult { + ApplyPatchResult { + status: ToolStatus::Failed, + summary: format!("Patch failed: {error}"), + error_code: Some(apply_patch_error_code(error).to_owned()), + files: Vec::new(), + // Recoverable patch errors are reported before workspace writes. The executor parses, + // validates expected files, applies all hunks in memory, and only then writes staged files. + // With no modified files there is intentionally no reverse patch to return. + reverse_patch: String::new(), + } +} + +fn apply_patch_error_code(error: &ToolExecutionError) -> &'static str { + match error { + ToolExecutionError::InvalidPatch(_) => "E_INVALID_PATCH", + ToolExecutionError::PatchFileMismatch { .. } => "E_PATCH_FILE_MISMATCH", + ToolExecutionError::PatchHunkMismatch { .. } => "E_PATCH_HUNK_MISMATCH", + _ => "E_PATCH_FAILED", } } @@ -1996,6 +3536,153 @@ fn tool_requested_payload( Value::Object(payload) } +fn unknown_tool_requested_payload(tool_call_id: String, tool_name: &str) -> Value { + let risk = unknown_tool_risk(tool_name); + json!({ + "toolCallId": tool_call_id, + "name": tool_name, + "risk": risk.as_str(), + "riskReasons": ["unknown tool requested; no executor available"], + "argumentsPreview": { + "omitted": "Arguments for unknown tools are not logged. Use the failed tool result guidance to retry with a supported tool." + } + }) +} + +fn unknown_tool_result(tool_name: &str, summary: &str) -> Value { + json!({ + "status": ToolStatus::Failed, + "errorCode": "E_UNKNOWN_TOOL", + "summary": summary, + "requestedTool": tool_name, + "availableTools": available_builtin_tool_names(), + "guidance": unknown_tool_guidance(tool_name), + }) +} + +fn unknown_tool_summary(tool_name: &str) -> String { + format!("Unknown tool `{tool_name}`. Retry using a supported ProleCoder tool.") +} + +fn invalid_tool_schema_result( + tool: ToolName, + tool_name: &str, + summary: &str, + validation_error: &str, +) -> Value { + json!({ + "status": ToolStatus::Failed, + "errorCode": "E_INVALID_TOOL_ARGUMENTS", + "summary": summary, + "requestedTool": tool_name, + "validationError": validation_error, + "guidance": invalid_tool_schema_guidance(tool), + }) +} + +fn invalid_tool_schema_summary(tool_name: &str, validation_error: &str) -> String { + format!( + "Tool arguments failed schema validation for `{tool_name}`: {validation_error}. Retry with the documented argument shape." + ) +} + +fn invalid_tool_schema_guidance(tool: ToolName) -> &'static str { + match tool { + ToolName::ReadFile => { + "Use read_file with path plus optional startLine/endLine only. Do not send limit, maxBytes, content, or other extra properties; for shorter reads, request a line range." + } + ToolName::Search => { + "Use search with the documented query and optional filter fields only. Remove unsupported properties before retrying." + } + ToolName::ApplyPatch => { + "Use apply_patch with expectedFiles and either unifiedDiff or payloadRef. Do not include both, and remove unsupported properties before retrying." + } + ToolName::Shell => { + "Use shell with command plus optional cwd/timeoutMs only. Put the directory in cwd instead of inventing extra execution fields." + } + ToolName::WorkspaceManifest => { + "Use workspace_manifest with only documented manifest options, or an empty object when no options are needed." + } + ToolName::GitStatus => "Use git_status with an empty object.", + ToolName::GitDiff => "Use git_diff with documented diff options only.", + ToolName::LspDiagnostics => { + "Use lsp_diagnostics with documented diagnostic request fields only." + } + ToolName::PlanUpdate => "Use plan_update with documented plan fields only.", + ToolName::ModelTurnBudget => { + "Do not call model_turn_budget directly; ProleCoder uses it internally for continuation approval." + } + } +} + +fn unknown_tool_guidance(tool_name: &str) -> String { + if tool_name == "write_file" { + return "ProleCoder does not provide write_file. For text edits, use apply_patch with expectedFiles plus unifiedDiff or payloadRef; reread the target file before retrying after a patch mismatch.".to_owned(); + } + + "Use one of the availableTools exactly as named. Do not invent tool names.".to_owned() +} + +fn unknown_tool_risk(tool_name: &str) -> RiskLevel { + let tokens = unknown_tool_name_tokens(tool_name); + if tokens + .iter() + .any(|token| matches!(token.as_str(), "delete" | "remove" | "destroy" | "reset")) + { + RiskLevel::Destructive + } else if tokens + .iter() + .any(|token| matches!(token.as_str(), "write" | "edit" | "patch" | "create")) + { + RiskLevel::Write + } else if tokens + .iter() + .any(|token| matches!(token.as_str(), "read" | "search" | "list")) + { + RiskLevel::Read + } else { + RiskLevel::Exec + } +} + +fn unknown_tool_name_tokens(tool_name: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut previous_was_lowercase_or_digit = false; + + for character in tool_name.chars() { + if !character.is_ascii_alphanumeric() { + push_unknown_tool_name_token(&mut tokens, &mut current); + previous_was_lowercase_or_digit = false; + continue; + } + + if character.is_ascii_uppercase() && previous_was_lowercase_or_digit { + push_unknown_tool_name_token(&mut tokens, &mut current); + } + current.push(character.to_ascii_lowercase()); + previous_was_lowercase_or_digit = + character.is_ascii_lowercase() || character.is_ascii_digit(); + } + + push_unknown_tool_name_token(&mut tokens, &mut current); + tokens +} + +fn push_unknown_tool_name_token(tokens: &mut Vec, current: &mut String) { + if current.is_empty() { + return; + } + tokens.push(std::mem::take(current)); +} + +fn available_builtin_tool_names() -> Vec<&'static str> { + BUILTIN_TOOLS + .iter() + .map(|definition| definition.name.as_str()) + .collect() +} + fn approval_payload(request: &TurnApprovalRequest) -> Value { let mut payload = Map::new(); payload.insert( @@ -2114,10 +3801,115 @@ fn provider_error_or_canceled( } } -fn terminal_error_event(error: &AgentTurnLoopError) -> (&'static str, Value) { - match error { - AgentTurnLoopError::ApprovalCanceled { - approval_id, +fn write_invalid_tool_arguments_diagnostic_file( + run_log: &mut (impl RunLogWriter + ?Sized), + turn_id: &str, + error: &AgentTurnLoopError, +) -> Option { + let run_id = run_log.run_id().to_owned(); + let (relative_path, payload) = invalid_tool_arguments_diagnostic(&run_id, turn_id, error)?; + let contents = match serde_json::to_string_pretty(&payload) { + Ok(contents) => contents, + Err(source) => { + eprintln!( + "failed to serialize diagnostic for `{}`: {source}", + error.code() + ); + return None; + } + }; + + match run_log.write_diagnostic_file(&relative_path, &contents) { + Ok(Some(path)) => Some(path.display().to_string()), + Ok(None) => None, + Err(source) => { + eprintln!( + "failed to write diagnostic for `{}`: {source}", + error.code() + ); + None + } + } +} + +fn invalid_tool_arguments_diagnostic( + run_id: &str, + turn_id: &str, + error: &AgentTurnLoopError, +) -> Option<(PathBuf, Value)> { + let AgentTurnLoopError::InvalidToolArguments { + tool_call_id, + name, + source, + raw_arguments: Some(raw_arguments), + } = error + else { + return None; + }; + let (raw_arguments, raw_arguments_truncated) = + diagnostic_raw_arguments(redact_text(raw_arguments)); + let file_name = diagnostic_file_name("invalid-tool-arguments", tool_call_id); + + Some(( + Path::new("diagnostics").join(file_name), + json!({ + "type": "invalid_tool_arguments", + "code": error.code(), + "message": error.to_string(), + "runId": run_id, + "turnId": turn_id, + "toolCallId": tool_call_id, + "toolName": name, + "jsonError": source.to_string(), + "rawArguments": raw_arguments, + "rawArgumentsTruncated": raw_arguments_truncated, + }), + )) +} + +fn diagnostic_raw_arguments(text: String) -> (String, bool) { + if text.len() <= TOOL_ARGUMENT_DIAGNOSTIC_MAX_BYTES { + return (text, false); + } + + let mut end = TOOL_ARGUMENT_DIAGNOSTIC_MAX_BYTES; + while !text.is_char_boundary(end) { + end -= 1; + } + let omitted = text.len() - end; + ( + format!("{}\n[truncated {omitted} bytes]", &text[..end]), + true, + ) +} + +fn diagnostic_file_name(prefix: &str, id: &str) -> String { + let mut safe = id + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') { + ch + } else { + '_' + } + }) + .take(80) + .collect::(); + if safe.is_empty() { + safe.push_str("tool_call"); + } + let hash = crate::hashing::sha256_hex(id.as_bytes()); + let short_hash = hash.get(..12).unwrap_or(hash.as_str()); + format!("{prefix}-{safe}-{short_hash}.json") +} + +fn terminal_error_event( + error: &AgentTurnLoopError, + diagnostic_file: Option<&str>, +) -> (&'static str, Value) { + match error { + AgentTurnLoopError::ApprovalCanceled { + approval_id, tool_call_id, reason, } @@ -2143,19 +3935,42 @@ fn terminal_error_event(error: &AgentTurnLoopError) -> (&'static str, Value) { "reason": reason, }), ), - _ => ( + AgentTurnLoopError::ProviderIdleTimeout { + timeout_ms, + attempts, + partial_content, + partial_reasoning_chars, + } => ( "run.failed", json!({ "code": error.code(), "message": error.to_string(), + "timeoutMs": timeout_ms, + "attempts": attempts, + "partialContentChars": partial_content.chars().count(), + "partialReasoningChars": partial_reasoning_chars, }), ), + _ => { + let mut payload = Map::new(); + payload.insert("code".to_owned(), json!(error.code())); + payload.insert("message".to_owned(), json!(error.to_string())); + if let Some(diagnostic_file) = diagnostic_file { + payload.insert("diagnosticFile".to_owned(), json!(diagnostic_file)); + } + ("run.failed", Value::Object(payload)) + } } } #[cfg(test)] mod tests { - use std::{collections::VecDeque, fs}; + use std::{ + collections::VecDeque, + fs, future, + path::{Path, PathBuf}, + time::Duration, + }; use futures_util::stream; use serde_json::json; @@ -2164,20 +3979,35 @@ mod tests { context::ContextItem, provider::deepseek_api::ChatToolCall, reasoning::ReasoningContentMode, - run_log::{RunLogEvent, RunLogStore}, + run_log::{REDACTED_VALUE, RunLogEvent, RunLogStore}, test_helpers::TestWorkspace, }; use super::{ AgentRunMode, AgentTurnInput, AgentTurnLoop, AgentTurnLoopConfig, AgentTurnLoopError, - ApprovalDecision, ApprovalPolicy, ApprovalPolicyError, AutoApprovePolicy, - CancellationToken, TextRange, TurnApprovalRequest, TurnAttachment, TurnEventSink, + ApprovalRequirement, AutoApprovePolicy, CancellationToken, MODEL_TURN_BUDGET_TOOL_NAME, + RiskLevel, TextRange, ToolName, ToolStatus, TurnAttachment, TurnEventSink, TurnEventSinkError, TurnProvider, TurnProviderCompletion, TurnProviderDelta, TurnProviderError, TurnProviderEvent, TurnProviderFinishReason, TurnProviderFuture, TurnProviderRequest, TurnProviderResponse, TurnProviderStream, - TurnProviderStreamingSummary, TurnProviderUsage, turn_provider_response_stream, + TurnProviderStreamingSummary, TurnProviderUsage, TurnSteerQueue, + effective_approval_requirement, find_builtin_tool, is_auto_approved_read_only_shell, + is_verification_shell_command, tool_risk_assessment, turn_provider_response_stream, + unknown_tool_risk, }; + #[test] + fn diagnostic_file_names_include_hash_for_sanitized_collisions() { + let left = super::diagnostic_file_name("invalid-tool-arguments", "call/a"); + let right = super::diagnostic_file_name("invalid-tool-arguments", "call:a"); + + assert!(left.starts_with("invalid-tool-arguments-call_a-")); + assert!(right.starts_with("invalid-tool-arguments-call_a-")); + assert_ne!(left, right); + assert!(left.ends_with(".json")); + assert!(right.ends_with(".json")); + } + #[tokio::test] async fn turn_loop_runs_read_tool_and_continues_to_final_answer() { let workspace = TestWorkspace::new("turn-loop"); @@ -2366,268 +4196,1260 @@ mod tests { } #[tokio::test] - async fn turn_loop_rejects_duplicate_attachments_before_provider_call() { + async fn turn_loop_recovers_missing_tool_call_reasoning_when_thinking_is_enabled() { let workspace = TestWorkspace::new("turn-loop"); + workspace.write("README.md", "hello from README\n"); let store = RunLogStore::new(workspace.path()).expect("run log store should open"); let mut run = store - .create_run("run_turn_duplicate_attachment") + .create_run("run_turn_recovered_reasoning") .expect("run should be created"); - let provider = ScriptedProvider::new(vec![TurnProviderResponse::final_text( - "should not be called", - )]); + let provider = ScriptedProvider::new(vec![ + TurnProviderResponse::tool_calls( + None, + None, + vec![ChatToolCall::function( + "call_1", + "read_file", + r#"{"path":"README.md"}"#, + )], + ), + TurnProviderResponse::final_text("README says hello."), + ]); let mut loop_runner = AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); - let error = loop_runner + let outcome = loop_runner .run_turn( - AgentTurnInput::new("turn_1", "Use duplicated attachments") - .with_attachment(TurnAttachment::explicit_content("same")) - .with_attachment(TurnAttachment::explicit_content("same")), + AgentTurnInput::new("turn_1", "Read README and summarize it") + .with_mode(AgentRunMode::Ask), &mut run, ) .await - .expect_err("duplicate attachment should fail"); + .expect("missing tool-call reasoning should be recovered"); + + assert_eq!(outcome.final_message, "README says hello."); + assert_eq!(outcome.iterations, 2); + assert_eq!(outcome.tool_results.len(), 1); + assert_eq!( + loop_runner.provider.requests[1].messages[1] + .reasoning_content + .as_deref(), + Some("Tool call selected: read_file.") + ); - assert!(matches!( - error, - AgentTurnLoopError::DuplicateAttachment { .. } - )); - assert!(loop_runner.provider.requests.is_empty()); let events = store - .load_run("run_turn_duplicate_attachment") + .load_run("run_turn_recovered_reasoning") .expect("events should load"); - assert!(events.iter().any(|event| { - event.event_type == "run.failed" && event.payload["code"] == "E_INVALID_ATTACHMENT" - })); + assert!(!events.iter().any(|event| event.event_type == "run.failed")); + let recovered = events + .iter() + .find(|event| event.event_type == "provider.reasoningRecovered") + .expect("reasoning recovery should be logged"); + assert_eq!( + recovered.payload["reason"], + "missing_tool_call_reasoning_content" + ); + assert_eq!(recovered.payload["toolCallCount"], 1); } #[tokio::test] - async fn turn_loop_rejects_oversized_attachment_before_provider_call() { + async fn turn_loop_injects_final_response_summary_contract() { let workspace = TestWorkspace::new("turn-loop"); let store = RunLogStore::new(workspace.path()).expect("run log store should open"); let mut run = store - .create_run("run_turn_oversized_attachment") + .create_run("run_turn_final_summary_contract") .expect("run should be created"); - let provider = ScriptedProvider::new(vec![TurnProviderResponse::final_text( - "should not be called", - )]); - let config = AgentTurnLoopConfig { - max_attachment_bytes: 4, - ..AgentTurnLoopConfig::default() - }; - let mut loop_runner = AgentTurnLoop::new(workspace.path(), provider) - .expect("turn loop should initialize") - .with_config(config); + let provider = + ScriptedProvider::new(vec![TurnProviderResponse::final_text("Work summary.")]); + let mut loop_runner = + AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); - let error = loop_runner - .run_turn( - AgentTurnInput::new("turn_1", "Use oversized attachment") - .with_attachment(TurnAttachment::explicit_content("too large")), - &mut run, - ) + loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Do a small task"), &mut run) .await - .expect_err("oversized attachment should fail"); + .expect("turn should complete"); - assert!(matches!( - error, - AgentTurnLoopError::AttachmentTooLarge { .. } - )); - assert!(loop_runner.provider.requests.is_empty()); + let prompt = loop_runner.provider.requests[0].messages[0] + .content + .as_deref() + .expect("provider prompt should include context"); + assert!(prompt.contains("final assistant message")); + assert!(prompt.contains("Do not dump raw tool logs")); + assert!(prompt.contains("Tool usage rules")); + assert!(prompt.contains("cwd argument")); + assert!(prompt.contains("do not use cd")); + #[cfg(windows)] + { + assert!(prompt.contains("Windows PowerShell 5.1")); + assert!(prompt.contains("do not append redirections such as 2>&1")); + assert!(prompt.contains("captures stdout and stderr separately")); + } + #[cfg(not(windows))] + assert!(prompt.contains("POSIX sh")); } #[tokio::test] - async fn turn_loop_logs_provider_completed_usage_cache_and_stream_summary() { + async fn turn_loop_retries_length_finished_final_response_until_summary() { let workspace = TestWorkspace::new("turn-loop"); let store = RunLogStore::new(workspace.path()).expect("run log store should open"); let mut run = store - .create_run("run_turn_provider_summary") + .create_run("run_turn_length_final_retry") .expect("run should be created"); - let completion = - TurnProviderCompletion::new("deepseek-v4-pro", TurnProviderFinishReason::Stop) - .with_usage(TurnProviderUsage { - prompt_tokens: Some(100), - completion_tokens: Some(20), - total_tokens: Some(120), - prompt_cache_hit_tokens: Some(64), - prompt_cache_miss_tokens: Some(36), - reasoning_tokens: Some(8), - }) - .with_streaming(TurnProviderStreamingSummary { - chunk_count: 3, - tool_call_delta_count: 0, - }); + let length_response = TurnProviderResponse { + content: None, + reasoning_content: None, + tool_calls: Vec::new(), + completion: TurnProviderCompletion::fixture(TurnProviderFinishReason::Length), + }; let provider = ScriptedProvider::new(vec![ - TurnProviderResponse::final_text("done").with_completion(completion), + length_response, + TurnProviderResponse::final_text("Final work summary."), ]); let mut loop_runner = AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); - loop_runner - .run_turn(AgentTurnInput::new("turn_1", "Say done"), &mut run) + let outcome = loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Finish the task"), &mut run) .await - .expect("turn should complete"); + .expect("length-truncated final response should be retried"); + + assert_eq!(outcome.final_message, "Final work summary."); + assert_eq!(outcome.iterations, 2); + assert_eq!(loop_runner.provider.requests.len(), 2); + assert_eq!(loop_runner.provider.requests[1].messages.len(), 2); + let retry_prompt = loop_runner.provider.requests[1] + .messages + .last() + .and_then(|message| message.content.as_deref()) + .expect("retry request should include a user instruction"); + assert!(retry_prompt.contains("output length limit")); + assert!(retry_prompt.contains("Return only a concise final work summary")); let events = store - .load_run("run_turn_provider_summary") + .load_run("run_turn_length_final_retry") .expect("events should load"); - let provider_completed = events + let finish_reasons = events .iter() - .find(|event| event.event_type == "provider.completed") - .expect("provider.completed should be emitted"); - assert_eq!(provider_completed.payload["iteration"], 1); - assert_eq!(provider_completed.payload["model"], "deepseek-v4-pro"); - assert_eq!(provider_completed.payload["finishReason"], "stop"); - assert!(provider_completed.payload["durationMs"].as_u64().is_some()); - assert_eq!(provider_completed.payload["usage"]["promptTokens"], 100); - assert_eq!( - provider_completed.payload["usage"]["promptCacheHitTokens"], - 64 - ); - assert_eq!( - provider_completed.payload["usage"]["promptCacheMissTokens"], - 36 - ); - assert_eq!(provider_completed.payload["usage"]["reasoningTokens"], 8); - assert_eq!(provider_completed.payload["streaming"]["chunkCount"], 3); - assert_eq!( - provider_completed.payload["streaming"]["toolCallDeltaCount"], - 0 - ); + .filter(|event| event.event_type == "provider.completed") + .map(|event| event.payload["finishReason"].clone()) + .collect::>(); + assert_eq!(finish_reasons, vec![json!("length"), json!("stop")]); + let completed = events + .iter() + .filter(|event| event.event_type == "run.completed") + .collect::>(); + assert_eq!(completed.len(), 1); + assert_eq!(completed[0].payload["summary"], "Final work summary."); + } + + #[test] + fn default_model_turn_budget_is_fifty() { + assert_eq!(AgentTurnLoopConfig::default().max_model_turns, 50); } #[tokio::test] - async fn turn_loop_requires_approval_for_shell_before_execution() { + async fn turn_loop_requests_approval_to_continue_after_model_turn_budget() { let workspace = TestWorkspace::new("turn-loop"); let store = RunLogStore::new(workspace.path()).expect("run log store should open"); let mut run = store - .create_run("run_turn_reject") + .create_run("run_turn_budget_continue") .expect("run should be created"); - let provider = ScriptedProvider::new(vec![TurnProviderResponse::tool_calls( - None, - Some("I need to run a command.".to_owned()), - vec![ChatToolCall::function( - "call_1", - "shell", - r#"{"command":"Write-Output hello","timeoutMs":1000}"#, - )], - )]); + let length_response = TurnProviderResponse { + content: Some("Partial work".to_owned()), + reasoning_content: None, + tool_calls: Vec::new(), + completion: TurnProviderCompletion::fixture(TurnProviderFinishReason::Length), + }; + let provider = ScriptedProvider::new(vec![ + length_response, + TurnProviderResponse::final_text("Completed after continuation."), + ]); + let config = AgentTurnLoopConfig { + max_model_turns: 1, + ..AgentTurnLoopConfig::default() + }; let mut loop_runner = - AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); + AgentTurnLoop::with_approval_policy(workspace.path(), provider, AutoApprovePolicy) + .expect("turn loop should initialize") + .with_config(config); - let error = loop_runner - .run_turn(AgentTurnInput::new("turn_1", "Run a command"), &mut run) + let outcome = loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Finish the task"), &mut run) .await - .expect_err("approval rejection should fail the turn"); + .expect("approved model turn budget continuation should complete"); - assert!(matches!(error, AgentTurnLoopError::ApprovalRejected { .. })); + assert_eq!(outcome.final_message, "Completed after continuation."); + assert_eq!(outcome.iterations, 2); let events = store - .load_run("run_turn_reject") + .load_run("run_turn_budget_continue") .expect("events should load"); - assert!( - events - .iter() - .any(|event| event.event_type == "tool.approvalRequired") - ); - assert!( - !events - .iter() - .any(|event| event.event_type == "tool.started") + let approval = events + .iter() + .find(|event| event.event_type == "tool.approvalRequired") + .expect("continuation approval should be requested"); + assert_eq!(approval.payload["toolName"], MODEL_TURN_BUDGET_TOOL_NAME); + assert_eq!(approval.payload["title"], "Continue agent turn"); + assert_eq!(approval.payload["persistable"], false); + assert_eq!( + approval.payload["riskReasons"], + json!(["model turn budget reached"]) ); - assert!(events.iter().any(|event| event.event_type == "run.failed")); + assert!(events.iter().any(|event| { + event.event_type == "tool.approvalResolved" + && event.payload["toolName"] == MODEL_TURN_BUDGET_TOOL_NAME + && event.payload["decision"] == "approved" + })); + assert!(!events.iter().any(|event| event.event_type == "run.failed")); } #[tokio::test] - async fn turn_loop_upgrades_shell_approval_risk_for_dependency_install() { + async fn turn_loop_resets_provider_transient_retry_budget_after_model_turn_continuation() { let workspace = TestWorkspace::new("turn-loop"); let store = RunLogStore::new(workspace.path()).expect("run log store should open"); let mut run = store - .create_run("run_turn_network_risk") + .create_run("run_turn_budget_retry_reset") .expect("run should be created"); - let provider = ScriptedProvider::new(vec![TurnProviderResponse::tool_calls( - None, - Some("I need to install dependencies.".to_owned()), - vec![ChatToolCall::function( - "call_shell", - "shell", - r#"{"command":"npm install","timeoutMs":1000}"#, - )], - )]); + let length_response = TurnProviderResponse { + content: Some("Partial work before continuation".to_owned()), + reasoning_content: None, + tool_calls: Vec::new(), + completion: TurnProviderCompletion::fixture(TurnProviderFinishReason::Length), + }; + let provider = EventScriptedProvider::new_result_streams(vec![ + vec![Err(TurnProviderError::transient("first connection reset"))], + vec![Ok(TurnProviderEvent::Completed(length_response))], + vec![Err(TurnProviderError::transient("second connection reset"))], + vec![Ok(TurnProviderEvent::Completed( + TurnProviderResponse::final_text("Completed after retry reset."), + ))], + ]); + let config = AgentTurnLoopConfig { + max_model_turns: 2, + provider_transient_retries: 1, + ..AgentTurnLoopConfig::default() + }; let mut loop_runner = - AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); + AgentTurnLoop::with_approval_policy(workspace.path(), provider, AutoApprovePolicy) + .expect("turn loop should initialize") + .with_config(config); - let error = loop_runner - .run_turn(AgentTurnInput::new("turn_1", "Install deps"), &mut run) + let outcome = loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Finish the task"), &mut run) .await - .expect_err("default policy should reject upgraded shell approval"); + .expect("continuation should reset transient retry quota"); - assert!(matches!(error, AgentTurnLoopError::ApprovalRejected { .. })); + assert_eq!(outcome.final_message, "Completed after retry reset."); + assert_eq!(outcome.iterations, 4); + assert_eq!(loop_runner.provider.requests.len(), 4); let events = store - .load_run("run_turn_network_risk") + .load_run("run_turn_budget_retry_reset") .expect("events should load"); - let requested = events + let retries = events .iter() - .find(|event| event.event_type == "tool.requested") + .filter(|event| event.event_type == "provider.retrying") + .collect::>(); + assert_eq!(retries.len(), 2); + assert!(retries.iter().all(|event| { + event.payload["reason"] == json!("transient_stream_error") + && event.payload["retriesRemaining"] == json!(0) + })); + assert!(events.iter().any(|event| { + event.event_type == "tool.approvalResolved" + && event.payload["toolName"] == MODEL_TURN_BUDGET_TOOL_NAME + && event.payload["decision"] == "approved" + })); + } + + #[tokio::test] + async fn turn_loop_cancels_model_turn_budget_continuation_before_approval_decision() { + let workspace = TestWorkspace::new("turn-loop"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_budget_cancel") + .expect("run should be created"); + let provider = ScriptedProvider::new(vec![ + TurnProviderResponse { + content: Some("Partial work".to_owned()), + reasoning_content: None, + tool_calls: Vec::new(), + completion: TurnProviderCompletion::fixture(TurnProviderFinishReason::Length), + }, + TurnProviderResponse::final_text("Should not be requested."), + ]); + let config = AgentTurnLoopConfig { + max_model_turns: 1, + ..AgentTurnLoopConfig::default() + }; + let cancellation_token = CancellationToken::new(); + let mut sink = CancelOnEventSink::new( + cancellation_token.clone(), + "tool.approvalRequired", + "stop before model turn continuation", + ); + let mut loop_runner = + AgentTurnLoop::with_approval_policy(workspace.path(), provider, AutoApprovePolicy) + .expect("turn loop should initialize") + .with_config(config); + + let error = loop_runner + .run_turn_with_event_sink( + AgentTurnInput::new("turn_1", "Finish the task") + .with_cancellation_token(cancellation_token), + &mut run, + &mut sink, + ) + .await + .expect_err("cancellation should stop before continuation approval decision"); + + assert!(matches!(error, AgentTurnLoopError::Canceled { .. })); + assert_eq!(loop_runner.provider.requests.len(), 1); + let events = store + .load_run("run_turn_budget_cancel") + .expect("events should load"); + assert!(events.iter().any(|event| { + event.event_type == "tool.approvalRequired" + && event.payload["toolName"] == MODEL_TURN_BUDGET_TOOL_NAME + })); + assert!(!events.iter().any(|event| { + event.event_type == "tool.approvalResolved" + && event.payload["toolName"] == MODEL_TURN_BUDGET_TOOL_NAME + })); + assert!(events.iter().any(|event| { + event.event_type == "run.canceled" + && event.payload["reason"] == "stop before model turn continuation" + })); + } + + #[tokio::test] + async fn turn_loop_retries_length_finished_tool_call_without_executing_partial_call() { + let workspace = TestWorkspace::new("turn-loop"); + workspace.write("README.md", "old\n"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_length_tool_retry") + .expect("run should be created"); + let patch = "--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-old\n+new\n"; + let length_response = TurnProviderResponse::tool_calls( + Some("The patch is needed; I will apply it.".to_owned()), + Some("I should edit the README.".to_owned()), + vec![ChatToolCall::function( + "partial_call", + "apply_patch", + "{\"unifiedDiff\"", + )], + ) + .with_completion(TurnProviderCompletion::fixture( + TurnProviderFinishReason::Length, + )); + let provider = ScriptedProvider::new(vec![ + length_response, + TurnProviderResponse::tool_calls( + None, + Some("I should issue a complete tool call.".to_owned()), + vec![ChatToolCall::function( + "call_1", + "apply_patch", + json!({ + "unifiedDiff": patch, + "expectedFiles": ["README.md"], + }) + .to_string(), + )], + ), + TurnProviderResponse::final_text("Updated README."), + ]); + let mut loop_runner = + AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); + + let outcome = loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Update README"), &mut run) + .await + .expect("length-truncated tool call should be retried"); + + assert_eq!(workspace.read("README.md"), "new\n"); + assert_eq!(outcome.final_message, "Updated README."); + assert_eq!(outcome.iterations, 3); + assert_eq!(loop_runner.provider.requests.len(), 3); + let retry_messages = &loop_runner.provider.requests[1].messages; + assert_eq!(retry_messages.len(), 3); + assert_eq!( + retry_messages[1].content.as_deref(), + Some("The patch is needed; I will apply it.") + ); + let retry_prompt = retry_messages[2] + .content + .as_deref() + .expect("retry request should include a user instruction"); + assert!(retry_prompt.contains("tool call")); + assert!(retry_prompt.contains("fresh complete tool call")); + + let events = store + .load_run("run_turn_length_tool_retry") + .expect("events should load"); + assert!(!events.iter().any(|event| { + event.event_type == "tool.requested" + && event.payload["toolCallId"] == json!("partial_call") + })); + assert!(events.iter().any(|event| { + event.event_type == "tool.requested" && event.payload["toolCallId"] == json!("call_1") + })); + let finish_reasons = events + .iter() + .filter(|event| event.event_type == "provider.completed") + .map(|event| event.payload["finishReason"].clone()) + .collect::>(); + assert_eq!( + finish_reasons, + vec![json!("length"), json!("tool_calls"), json!("stop")] + ); + assert!(!events.iter().any(|event| event.event_type == "run.failed")); + } + + #[tokio::test] + async fn turn_loop_rejects_duplicate_attachments_before_provider_call() { + let workspace = TestWorkspace::new("turn-loop"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_duplicate_attachment") + .expect("run should be created"); + let provider = ScriptedProvider::new(vec![TurnProviderResponse::final_text( + "should not be called", + )]); + let mut loop_runner = + AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); + + let error = loop_runner + .run_turn( + AgentTurnInput::new("turn_1", "Use duplicated attachments") + .with_attachment(TurnAttachment::explicit_content("same")) + .with_attachment(TurnAttachment::explicit_content("same")), + &mut run, + ) + .await + .expect_err("duplicate attachment should fail"); + + assert!(matches!( + error, + AgentTurnLoopError::DuplicateAttachment { .. } + )); + assert!(loop_runner.provider.requests.is_empty()); + let events = store + .load_run("run_turn_duplicate_attachment") + .expect("events should load"); + assert!(events.iter().any(|event| { + event.event_type == "run.failed" && event.payload["code"] == "E_INVALID_ATTACHMENT" + })); + } + + #[tokio::test] + async fn turn_loop_rejects_oversized_attachment_before_provider_call() { + let workspace = TestWorkspace::new("turn-loop"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_oversized_attachment") + .expect("run should be created"); + let provider = ScriptedProvider::new(vec![TurnProviderResponse::final_text( + "should not be called", + )]); + let config = AgentTurnLoopConfig { + max_attachment_bytes: 4, + ..AgentTurnLoopConfig::default() + }; + let mut loop_runner = AgentTurnLoop::new(workspace.path(), provider) + .expect("turn loop should initialize") + .with_config(config); + + let error = loop_runner + .run_turn( + AgentTurnInput::new("turn_1", "Use oversized attachment") + .with_attachment(TurnAttachment::explicit_content("too large")), + &mut run, + ) + .await + .expect_err("oversized attachment should fail"); + + assert!(matches!( + error, + AgentTurnLoopError::AttachmentTooLarge { .. } + )); + assert!(loop_runner.provider.requests.is_empty()); + } + + #[tokio::test] + async fn turn_loop_logs_provider_completed_usage_cache_and_stream_summary() { + let workspace = TestWorkspace::new("turn-loop"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_provider_summary") + .expect("run should be created"); + let completion = + TurnProviderCompletion::new("deepseek-v4-pro", TurnProviderFinishReason::Stop) + .with_usage(TurnProviderUsage { + prompt_tokens: Some(100), + completion_tokens: Some(20), + total_tokens: Some(120), + prompt_cache_hit_tokens: Some(64), + prompt_cache_miss_tokens: Some(36), + reasoning_tokens: Some(8), + }) + .with_streaming(TurnProviderStreamingSummary { + chunk_count: 3, + tool_call_delta_count: 0, + }); + let provider = ScriptedProvider::new(vec![ + TurnProviderResponse::final_text("done").with_completion(completion), + ]); + let mut loop_runner = + AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); + + loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Say done"), &mut run) + .await + .expect("turn should complete"); + + let events = store + .load_run("run_turn_provider_summary") + .expect("events should load"); + let provider_completed = events + .iter() + .find(|event| event.event_type == "provider.completed") + .expect("provider.completed should be emitted"); + assert_eq!(provider_completed.payload["iteration"], 1); + assert_eq!(provider_completed.payload["model"], "deepseek-v4-pro"); + assert_eq!(provider_completed.payload["finishReason"], "stop"); + assert!(provider_completed.payload["durationMs"].as_u64().is_some()); + assert_eq!(provider_completed.payload["usage"]["promptTokens"], 100); + assert_eq!( + provider_completed.payload["usage"]["promptCacheHitTokens"], + 64 + ); + assert_eq!( + provider_completed.payload["usage"]["promptCacheMissTokens"], + 36 + ); + assert_eq!(provider_completed.payload["usage"]["reasoningTokens"], 8); + assert_eq!(provider_completed.payload["streaming"]["chunkCount"], 3); + assert_eq!( + provider_completed.payload["streaming"]["toolCallDeltaCount"], + 0 + ); + } + + #[tokio::test] + async fn turn_loop_requires_approval_for_shell_before_execution() { + let workspace = TestWorkspace::new("turn-loop"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_reject") + .expect("run should be created"); + let provider = ScriptedProvider::new(vec![TurnProviderResponse::tool_calls( + None, + Some("I need to run a command.".to_owned()), + vec![ChatToolCall::function( + "call_1", + "shell", + r#"{"command":"Write-Output hello","timeoutMs":1000}"#, + )], + )]); + let mut loop_runner = + AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); + + let error = loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Run a command"), &mut run) + .await + .expect_err("approval rejection should fail the turn"); + + assert!(matches!(error, AgentTurnLoopError::ApprovalRejected { .. })); + let events = store + .load_run("run_turn_reject") + .expect("events should load"); + assert!( + events + .iter() + .any(|event| event.event_type == "tool.approvalRequired") + ); + assert!( + !events + .iter() + .any(|event| event.event_type == "tool.started") + ); + assert!(events.iter().any(|event| event.event_type == "run.failed")); + } + + #[test] + fn read_only_shell_allowlist_disables_shell_approval() { + let shell = + find_builtin_tool(ToolName::Shell.as_str()).expect("shell tool must be registered"); + for command in [ + "rg --files src", + "Get-Content README.md", + "git diff -- src/lib.rs", + "git status --short", + "git log --oneline", + "git show HEAD -- README.md", + "python --version", + "python -V", + "node --version", + "node -v", + "cargo --version", + "rustc -v", + "git --version", + "go version", + "code --version", + ] { + let assessment = tool_risk_assessment( + shell, + &json!({ + "command": command, + "cwd": ".", + }), + ); + assert_eq!(assessment.risk, RiskLevel::Read, "command: {command}"); + assert_eq!( + effective_approval_requirement( + shell.approval, + assessment.risk, + assessment.approval_override + ), + ApprovalRequirement::None, + "command: {command}" + ); + } + } + + #[test] + fn read_only_shell_allowlist_rejects_ambiguous_or_outside_commands() { + for command in [ + "rg foo | tee out.txt", + "Get-Content ..\\secret.txt", + "Get-Content C:\\secret.txt", + "git diff --output patch.txt", + "git diff --no-index a b", + "rg --replace x foo", + "rg -r replacement pattern", + "rg -rn pattern", + "rg -rU pattern", + "Get-Content .env", + "Get-Content .secrets\\deepseek-api-key", + "rg --files .git", + "python --version extra", + "python -c print(1)", + ".\\python --version", + "C:\\Python\\python.exe --version", + "go env", + "cargo -v build", + ] { + assert!( + !is_auto_approved_read_only_shell(command, Some(".")), + "command should not be allowlisted: {command}" + ); + } + assert!(!is_auto_approved_read_only_shell("rg --files", Some(".."))); + } + + #[test] + fn verification_shell_command_detection_accepts_common_test_markers() { + for command in [ + "node --test", + "npm test", + "pnpm --filter prole-coder-vscode test", + "pnpm run test:unit", + "yarn run lint", + "bun run typecheck", + "python -m unittest discover", + "py -m pytest tests", + "cargo check", + "cargo clippy", + "cargo --locked test", + "npm run typecheck", + "pytest tests", + "go test ./...", + "dotnet test", + "tsc --noEmit", + ] { + assert!( + is_verification_shell_command(command), + "command should be detected as verification: {command}" + ); + } + + for command in [ + "node --version", + "Write-Output hello", + "npm run deploy-check", + "echo test complete", + "python scripts/generate_testdata.py", + "cargo build", + ] { + assert!( + !is_verification_shell_command(command), + "command should not be detected as verification: {command}" + ); + } + } + + #[tokio::test] + async fn turn_loop_upgrades_shell_approval_risk_for_dependency_install() { + let workspace = TestWorkspace::new("turn-loop"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_network_risk") + .expect("run should be created"); + let provider = ScriptedProvider::new(vec![TurnProviderResponse::tool_calls( + None, + Some("I need to install dependencies.".to_owned()), + vec![ChatToolCall::function( + "call_shell", + "shell", + r#"{"command":"npm install","timeoutMs":1000}"#, + )], + )]); + let mut loop_runner = + AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); + + let error = loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Install deps"), &mut run) + .await + .expect_err("default policy should reject upgraded shell approval"); + + assert!(matches!(error, AgentTurnLoopError::ApprovalRejected { .. })); + let events = store + .load_run("run_turn_network_risk") + .expect("events should load"); + let requested = events + .iter() + .find(|event| event.event_type == "tool.requested") .expect("tool.requested should be emitted"); assert_eq!(requested.payload["risk"], "network"); assert_eq!( requested.payload["riskReasons"], - json!(["dependency install/update"]) + json!(["dependency install/update"]) + ); + let approval = events + .iter() + .find(|event| event.event_type == "tool.approvalRequired") + .expect("tool.approvalRequired should be emitted"); + assert_eq!(approval.payload["risk"], "network"); + assert_eq!(approval.payload["command"], "npm install"); + assert_eq!(approval.payload["cwd"], "."); + assert_eq!( + approval.payload["riskReasons"], + json!(["dependency install/update"]) + ); + assert_eq!(approval.payload["persistable"], false); + assert!( + approval.payload["detail"] + .as_str() + .expect("detail should be text") + .contains("risk upgrade: dependency install/update") + ); + } + + #[tokio::test] + async fn turn_loop_upgrades_shell_approval_risk_for_destructive_command() { + let workspace = TestWorkspace::new("turn-loop"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_destructive_risk") + .expect("run should be created"); + let provider = ScriptedProvider::new(vec![TurnProviderResponse::tool_calls( + None, + Some("I need to delete build output.".to_owned()), + vec![ChatToolCall::function( + "call_shell", + "shell", + r#"{"command":"rm -rf target","timeoutMs":1000}"#, + )], + )]); + let mut loop_runner = + AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); + + let error = loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Clean target"), &mut run) + .await + .expect_err("default policy should reject destructive shell approval"); + + assert!(matches!(error, AgentTurnLoopError::ApprovalRejected { .. })); + let events = store + .load_run("run_turn_destructive_risk") + .expect("events should load"); + let approval = events + .iter() + .find(|event| event.event_type == "tool.approvalRequired") + .expect("tool.approvalRequired should be emitted"); + assert_eq!(approval.payload["risk"], "destructive"); + assert_eq!(approval.payload["riskReasons"], json!(["file deletion"])); + assert_eq!(approval.payload["persistable"], false); + assert!( + !events + .iter() + .any(|event| event.event_type == "tool.started") + ); + } + + #[tokio::test] + async fn turn_loop_includes_previous_shell_output_summary_in_next_shell_approval() { + let workspace = TestWorkspace::new("turn-loop"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_shell_output_summary") + .expect("run should be created"); + #[cfg(windows)] + let first_command = "Write-Output first; Write-Output second"; + #[cfg(not(windows))] + let first_command = "printf 'first\\nsecond\\n'"; + #[cfg(windows)] + let second_command = "Write-Output done"; + #[cfg(not(windows))] + let second_command = "printf done"; + let provider = ScriptedProvider::new(vec![ + TurnProviderResponse::tool_calls( + None, + Some("Run two commands.".to_owned()), + vec![ + ChatToolCall::function( + "call_shell_1", + "shell", + json!({ + "command": first_command, + "timeoutMs": 10_000 + }) + .to_string(), + ), + ChatToolCall::function( + "call_shell_2", + "shell", + json!({ + "command": second_command, + "timeoutMs": 10_000 + }) + .to_string(), + ), + ], + ), + TurnProviderResponse::final_text("Commands finished."), + ]); + let mut loop_runner = + AgentTurnLoop::with_approval_policy(workspace.path(), provider, AutoApprovePolicy) + .expect("turn loop should initialize"); + + loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Run two commands"), &mut run) + .await + .expect("approved shell commands should complete"); + + let events = store + .load_run("run_turn_shell_output_summary") + .expect("events should load"); + let approvals = events + .iter() + .filter(|event| event.event_type == "tool.approvalRequired") + .collect::>(); + assert_eq!(approvals.len(), 2); + assert!(approvals[0].payload.get("outputSummary").is_none()); + let output_summary = approvals[1].payload["outputSummary"] + .as_str() + .expect("second approval should include previous shell output summary"); + assert!(output_summary.contains("exitCode: 0")); + assert!(output_summary.contains("stdout:")); + assert!(output_summary.contains("second")); + } + + #[tokio::test] + async fn turn_loop_summarizes_shell_file_changes_and_verification_status() { + let workspace = TestWorkspace::new("turn-loop"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_shell_summary_metadata") + .expect("run should be created"); + #[cfg(windows)] + let write_command = "Set-Content -Path shell-output.txt -Value changed -Encoding UTF8"; + #[cfg(not(windows))] + let write_command = "printf 'changed\\n' > shell-output.txt"; + let verify_command = "node --test"; + let provider = ScriptedProvider::new(vec![ + TurnProviderResponse::tool_calls( + None, + Some("Write a file and verify.".to_owned()), + vec![ + ChatToolCall::function( + "call_shell_write", + "shell", + json!({ + "command": write_command, + "timeoutMs": 10_000 + }) + .to_string(), + ), + ChatToolCall::function( + "call_shell_verify", + "shell", + json!({ + "command": verify_command, + "timeoutMs": 10_000 + }) + .to_string(), + ), + ], + ), + TurnProviderResponse::final_text("Done."), + ]); + let mut loop_runner = + AgentTurnLoop::with_approval_policy(workspace.path(), provider, AutoApprovePolicy) + .expect("turn loop should initialize"); + + let outcome = loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Write via shell"), &mut run) + .await + .expect("approved shell commands should complete"); + + assert_eq!(outcome.changed_files, vec!["shell-output.txt"]); + let events = store + .load_run("run_turn_shell_summary_metadata") + .expect("events should load"); + let completed = events + .iter() + .find(|event| event.event_type == "run.completed") + .expect("run should complete"); + assert_eq!( + completed.payload["changedFiles"], + json!(["shell-output.txt"]) + ); + assert_eq!(completed.payload["verificationStatus"], "passed"); + } + + #[tokio::test] + async fn turn_loop_reports_schema_validation_as_failed_tool_result_and_continues() { + let workspace = TestWorkspace::new("turn-loop"); + workspace.write("README.md", "hello\n"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_schema_recover") + .expect("run should be created"); + let provider = ScriptedProvider::new(vec![ + TurnProviderResponse::tool_calls( + None, + Some("I should read the README.".to_owned()), + vec![ChatToolCall::function( + "call_1", + "read_file", + r#"{"path":"README.md","limit":20}"#, + )], + ), + TurnProviderResponse::final_text("I will retry with startLine and endLine."), + ]); + let mut loop_runner = + AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); + + let outcome = loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Read README"), &mut run) + .await + .expect("schema validation result should be recoverable"); + + assert_eq!( + outcome.final_message, + "I will retry with startLine and endLine." + ); + assert_eq!(outcome.tool_results.len(), 1); + assert_eq!(outcome.tool_results[0].name, "read_file"); + assert_eq!(outcome.tool_results[0].status, ToolStatus::Failed); + assert_eq!( + outcome.tool_results[0].result["errorCode"], + json!("E_INVALID_TOOL_ARGUMENTS") + ); + assert_eq!(loop_runner.provider.requests.len(), 2); + let tool_result = loop_runner.provider.requests[1] + .messages + .iter() + .filter_map(|message| message.content.as_deref()) + .filter_map(|content| serde_json::from_str::(content).ok()) + .find(|content| content["errorCode"] == "E_INVALID_TOOL_ARGUMENTS") + .expect("schema validation result should be sent to the provider"); + assert_eq!(tool_result["requestedTool"], "read_file"); + assert_eq!( + tool_result["validationError"], + "$.limit: unexpected property" + ); + assert!( + tool_result["guidance"] + .as_str() + .is_some_and(|guidance| guidance.contains("startLine")) + ); + + let events = store + .load_run("run_turn_schema_recover") + .expect("events should load"); + assert!(!events.iter().any(|event| event.event_type == "run.failed")); + let requested = events + .iter() + .find(|event| event.event_type == "tool.requested") + .expect("schema-invalid known tool should still be logged as requested"); + assert_eq!(requested.payload["name"], "read_file"); + assert_eq!(requested.payload["argumentsPreview"]["limit"], 20); + let completed = events + .iter() + .find(|event| event.event_type == "tool.completed") + .expect("schema-invalid known tool should complete with failed result"); + assert_eq!(completed.payload["status"], "failed"); + assert_eq!( + completed.payload["result"]["errorCode"], + "E_INVALID_TOOL_ARGUMENTS" + ); + } + + #[tokio::test] + async fn turn_loop_writes_diagnostic_file_for_malformed_tool_arguments() { + let workspace = TestWorkspace::new("turn-loop"); + workspace.write("README.md", "hello\n"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_malformed_tool_args") + .expect("run should be created"); + let raw_secret = format!("sk-{}", "this-malformed-secret-123"); + let malformed_arguments = + format!("{{\"unifiedDiff\":\"--- a/README.md\\n+++ b/README.md\\n{raw_secret}"); + let provider = ScriptedProvider::new(vec![TurnProviderResponse::tool_calls( + None, + Some("I should edit the README.".to_owned()), + vec![ChatToolCall::function( + "call_bad_json", + "apply_patch", + malformed_arguments, + )], + )]); + let mut loop_runner = + AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); + + let error = loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Edit README"), &mut run) + .await + .expect_err("malformed tool JSON should fail the turn"); + + assert!(matches!( + error, + AgentTurnLoopError::InvalidToolArguments { + raw_arguments: Some(_), + .. + } + )); + let events = store + .load_run("run_turn_malformed_tool_args") + .expect("events should load"); + assert!( + !events + .iter() + .any(|event| event.event_type == "tool.requested") + ); + let failed = events + .iter() + .find(|event| { + event.event_type == "run.failed" + && event.payload["code"] == "E_INVALID_TOOL_ARGUMENTS" + }) + .expect("run.failed should be recorded"); + let diagnostic_file = failed.payload["diagnosticFile"] + .as_str() + .expect("run.failed should include diagnosticFile"); + let diagnostic_path = PathBuf::from(diagnostic_file); + assert!(diagnostic_path.is_file()); + + let contents = + fs::read_to_string(&diagnostic_path).expect("diagnostic file should be readable"); + assert!(contents.contains("invalid_tool_arguments")); + assert!(contents.contains("call_bad_json")); + assert!(contents.contains("apply_patch")); + assert!(contents.contains("unifiedDiff")); + assert!(!contents.contains(&raw_secret)); + assert!(contents.contains(REDACTED_VALUE)); + } + + #[tokio::test] + async fn turn_loop_executes_workspace_patch_without_approval_and_tracks_changed_files() { + let workspace = TestWorkspace::new("turn-loop"); + workspace.write("README.md", "old\n"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_patch") + .expect("run should be created"); + let patch = concat!( + "--- a/README.md\n", + "+++ b/README.md\n", + "@@ -1 +1 @@\n", + "-old\n", + "+new\n" + ); + let provider = ScriptedProvider::new(vec![ + TurnProviderResponse::tool_calls( + None, + Some("I should edit the README.".to_owned()), + vec![ChatToolCall::function( + "call_1", + "apply_patch", + json!({ + "unifiedDiff": patch, + "expectedFiles": ["README.md"], + }) + .to_string(), + )], + ), + TurnProviderResponse::final_text("Updated README."), + ]); + let mut loop_runner = + AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); + + let outcome = loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Update README"), &mut run) + .await + .expect("workspace patch should complete without approval"); + + assert_eq!(workspace.read("README.md"), "new\n"); + assert_eq!(outcome.changed_files, vec!["README.md"]); + let events = store + .load_run("run_turn_patch") + .expect("events should load"); + assert!( + !events + .iter() + .any(|event| event.event_type == "tool.approvalRequired") + ); + let completed = events + .iter() + .find(|event| event.event_type == "run.completed") + .expect("run should complete"); + assert_eq!(completed.payload["changedFiles"], json!(["README.md"])); + } + + #[tokio::test] + async fn turn_loop_requires_approval_for_bulk_workspace_patch() { + let workspace = TestWorkspace::new("turn-loop"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_bulk_patch") + .expect("run should be created"); + let mut patch = String::new(); + let mut expected_files = Vec::new(); + for index in 1..=6 { + let path = format!("src/file{index}.txt"); + workspace.write(&path, "old\n"); + expected_files.push(path.clone()); + patch.push_str(&format!( + "--- a/{path}\n+++ b/{path}\n@@ -1 +1 @@\n-old\n+new\n" + )); + } + let provider = ScriptedProvider::new(vec![TurnProviderResponse::tool_calls( + None, + Some("I should edit several files.".to_owned()), + vec![ChatToolCall::function( + "call_1", + "apply_patch", + json!({ + "unifiedDiff": patch, + "expectedFiles": expected_files, + }) + .to_string(), + )], + )]); + let mut loop_runner = + AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); + + let error = loop_runner + .run_turn( + AgentTurnInput::new("turn_1", "Update several files"), + &mut run, + ) + .await + .expect_err("bulk workspace patch should require approval"); + + assert!(matches!(error, AgentTurnLoopError::ApprovalRejected { .. })); + for index in 1..=6 { + assert_eq!(workspace.read(&format!("src/file{index}.txt")), "old\n"); + } + let events = store + .load_run("run_turn_bulk_patch") + .expect("events should load"); + let requested = events + .iter() + .find(|event| event.event_type == "tool.requested") + .expect("tool.requested should be recorded"); + assert_eq!(requested.payload["risk"], "write"); + assert_eq!( + requested.payload["riskReasons"], + json!(["bulk patch touches 6 files; limit for automatic workspace patch is 5"]) ); let approval = events .iter() - .find(|event| event.event_type == "tool.approvalRequired") - .expect("tool.approvalRequired should be emitted"); - assert_eq!(approval.payload["risk"], "network"); - assert_eq!(approval.payload["command"], "npm install"); - assert_eq!(approval.payload["cwd"], "."); + .find(|event| event.event_type == "tool.approvalRequired") + .expect("bulk patch should request approval"); + assert_eq!(approval.payload["risk"], "write"); + assert_eq!(approval.payload["paths"].as_array().map(Vec::len), Some(6)); assert_eq!( approval.payload["riskReasons"], - json!(["dependency install/update"]) + json!(["bulk patch touches 6 files; limit for automatic workspace patch is 5"]) ); - assert_eq!(approval.payload["persistable"], false); assert!( - approval.payload["detail"] - .as_str() - .expect("detail should be text") - .contains("risk upgrade: dependency install/update") + !events + .iter() + .any(|event| event.event_type == "tool.started") ); } #[tokio::test] - async fn turn_loop_upgrades_shell_approval_risk_for_destructive_command() { + async fn turn_loop_requires_approval_for_workspace_policy_patch() { let workspace = TestWorkspace::new("turn-loop"); + workspace.write(".gitignore", ".env\n"); let store = RunLogStore::new(workspace.path()).expect("run log store should open"); let mut run = store - .create_run("run_turn_destructive_risk") + .create_run("run_turn_policy_patch") .expect("run should be created"); + let patch = "--- a/.gitignore\n+++ b/.gitignore\n@@ -1 +0,0 @@\n-.env\n"; let provider = ScriptedProvider::new(vec![TurnProviderResponse::tool_calls( None, - Some("I need to delete build output.".to_owned()), + Some("I should update ignore rules.".to_owned()), vec![ChatToolCall::function( - "call_shell", - "shell", - r#"{"command":"rm -rf target","timeoutMs":1000}"#, + "call_1", + "apply_patch", + json!({ + "unifiedDiff": patch, + "expectedFiles": [".gitignore"], + }) + .to_string(), )], )]); let mut loop_runner = AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); let error = loop_runner - .run_turn(AgentTurnInput::new("turn_1", "Clean target"), &mut run) + .run_turn( + AgentTurnInput::new("turn_1", "Update ignore rules"), + &mut run, + ) .await - .expect_err("default policy should reject destructive shell approval"); + .expect_err("workspace policy patch should require approval"); assert!(matches!(error, AgentTurnLoopError::ApprovalRejected { .. })); + assert_eq!(workspace.read(".gitignore"), ".env\n"); let events = store - .load_run("run_turn_destructive_risk") + .load_run("run_turn_policy_patch") .expect("events should load"); + let requested = events + .iter() + .find(|event| event.event_type == "tool.requested") + .expect("tool.requested should be recorded"); + assert_eq!(requested.payload["risk"], "write"); + assert_eq!( + requested.payload["riskReasons"], + json!(["workspace policy file `.gitignore` requires approval"]) + ); let approval = events .iter() .find(|event| event.event_type == "tool.approvalRequired") - .expect("tool.approvalRequired should be emitted"); - assert_eq!(approval.payload["risk"], "destructive"); - assert_eq!(approval.payload["riskReasons"], json!(["file deletion"])); - assert_eq!(approval.payload["persistable"], false); + .expect("workspace policy patch should request approval"); + assert_eq!(approval.payload["risk"], "write"); + assert_eq!(approval.payload["paths"], json!([".gitignore"])); + assert_eq!( + approval.payload["riskReasons"], + json!(["workspace policy file `.gitignore` requires approval"]) + ); assert!( !events .iter() @@ -2636,174 +5458,378 @@ mod tests { } #[tokio::test] - async fn turn_loop_includes_previous_shell_output_summary_in_next_shell_approval() { + async fn turn_loop_reports_patch_hunk_mismatch_as_failed_tool_result_and_continues() { let workspace = TestWorkspace::new("turn-loop"); + workspace.write("README.md", "old\n"); let store = RunLogStore::new(workspace.path()).expect("run log store should open"); let mut run = store - .create_run("run_turn_shell_output_summary") + .create_run("run_turn_patch_hunk_mismatch") .expect("run should be created"); - #[cfg(windows)] - let first_command = "Write-Output first; Write-Output second"; - #[cfg(not(windows))] - let first_command = "printf 'first\\nsecond\\n'"; - #[cfg(windows)] - let second_command = "Write-Output done"; - #[cfg(not(windows))] - let second_command = "printf done"; + let patch = concat!( + "--- a/README.md\n", + "+++ b/README.md\n", + "@@ -1 +1 @@\n", + "-missing\n", + "+new\n" + ); let provider = ScriptedProvider::new(vec![ TurnProviderResponse::tool_calls( None, - Some("Run two commands.".to_owned()), - vec![ - ChatToolCall::function( - "call_shell_1", - "shell", - json!({ - "command": first_command, - "timeoutMs": 10_000 - }) - .to_string(), - ), - ChatToolCall::function( - "call_shell_2", - "shell", - json!({ - "command": second_command, - "timeoutMs": 10_000 - }) - .to_string(), - ), - ], + Some("I should edit the README.".to_owned()), + vec![ChatToolCall::function( + "call_1", + "apply_patch", + json!({ + "unifiedDiff": patch, + "expectedFiles": ["README.md"], + }) + .to_string(), + )], + ), + TurnProviderResponse::final_text( + "Patch context did not match; I will reread the file.", ), - TurnProviderResponse::final_text("Commands finished."), ]); let mut loop_runner = - AgentTurnLoop::with_approval_policy(workspace.path(), provider, AutoApprovePolicy) - .expect("turn loop should initialize"); + AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); - loop_runner - .run_turn(AgentTurnInput::new("turn_1", "Run two commands"), &mut run) + let outcome = loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Update README"), &mut run) .await - .expect("approved shell commands should complete"); + .expect("recoverable patch failure should be returned to the provider"); + + assert_eq!(workspace.read("README.md"), "old\n"); + assert!(outcome.changed_files.is_empty()); + assert_eq!( + outcome.final_message, + "Patch context did not match; I will reread the file." + ); + assert_eq!(loop_runner.provider.requests.len(), 2); + let tool_result = loop_runner.provider.requests[1].messages[2] + .content + .as_deref() + .and_then(|content| serde_json::from_str::(content).ok()) + .expect("failed apply_patch result should be sent to the provider"); + assert_eq!(tool_result["status"], "failed"); + assert_eq!(tool_result["errorCode"], "E_PATCH_HUNK_MISMATCH"); let events = store - .load_run("run_turn_shell_output_summary") + .load_run("run_turn_patch_hunk_mismatch") .expect("events should load"); - let approvals = events + assert!(!events.iter().any(|event| event.event_type == "run.failed")); + let completed = events .iter() - .filter(|event| event.event_type == "tool.approvalRequired") - .collect::>(); - assert_eq!(approvals.len(), 2); - assert!(approvals[0].payload.get("outputSummary").is_none()); - let output_summary = approvals[1].payload["outputSummary"] - .as_str() - .expect("second approval should include previous shell output summary"); - assert!(output_summary.contains("exitCode: 0")); - assert!(output_summary.contains("stdout:")); - assert!(output_summary.contains("second")); + .find(|event| event.event_type == "tool.completed") + .expect("tool.completed should record failed apply_patch"); + assert_eq!(completed.payload["status"], "failed"); + assert_eq!( + completed.payload["result"]["errorCode"], + "E_PATCH_HUNK_MISMATCH" + ); + assert_eq!(completed.payload["result"]["reversePatch"], ""); } #[tokio::test] - async fn turn_loop_rejects_tool_arguments_before_typed_deserialization() { + async fn turn_loop_reports_invalid_patch_preview_as_failed_tool_result_and_continues() { let workspace = TestWorkspace::new("turn-loop"); - workspace.write("README.md", "hello\n"); + workspace.write("README.md", "old\nsecond\n"); let store = RunLogStore::new(workspace.path()).expect("run log store should open"); let mut run = store - .create_run("run_turn_schema_reject") + .create_run("run_turn_invalid_patch_preview") .expect("run should be created"); - let provider = ScriptedProvider::new(vec![TurnProviderResponse::tool_calls( - None, - Some("I should read the README.".to_owned()), - vec![ChatToolCall::function( - "call_1", - "read_file", - r#"{"path":"README.md","unexpected":true}"#, - )], - )]); + let patch = concat!( + "--- a/README.md\n", + "+++ b/README.md\n", + "@@ -1,2 +1,2 @@\n", + "-old\n", + "+new\n", + "\n", + " second\n", + ); + let provider = ScriptedProvider::new(vec![ + TurnProviderResponse::tool_calls( + None, + Some("I should edit the README.".to_owned()), + vec![ChatToolCall::function( + "call_1", + "apply_patch", + json!({ + "unifiedDiff": patch, + "expectedFiles": ["README.md"], + }) + .to_string(), + )], + ), + TurnProviderResponse::final_text("Invalid patch was reported; I can retry."), + ]); let mut loop_runner = AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); - let error = loop_runner - .run_turn(AgentTurnInput::new("turn_1", "Read README"), &mut run) + let outcome = loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Update README"), &mut run) .await - .expect_err("schema validation should reject unexpected properties"); + .expect("invalid patch preview should be returned to the provider"); - assert!(matches!( - error, - AgentTurnLoopError::InvalidToolArgumentSchema { .. } - )); + assert_eq!(workspace.read("README.md"), "old\nsecond\n"); + assert!(outcome.changed_files.is_empty()); + assert_eq!( + outcome.final_message, + "Invalid patch was reported; I can retry." + ); + assert_eq!(loop_runner.provider.requests.len(), 2); + let tool_result = loop_runner.provider.requests[1].messages[2] + .content + .as_deref() + .and_then(|content| serde_json::from_str::(content).ok()) + .expect("failed apply_patch result should be sent to the provider"); + assert_eq!(tool_result["status"], "failed"); + assert_eq!(tool_result["errorCode"], "E_INVALID_PATCH"); + assert!( + tool_result["summary"] + .as_str() + .is_some_and(|summary| summary.contains("invalid patch line")) + ); + + let events = store + .load_run("run_turn_invalid_patch_preview") + .expect("events should load"); + assert!(!events.iter().any(|event| event.event_type == "run.failed")); + let completed = events + .iter() + .find(|event| event.event_type == "tool.completed") + .expect("tool.completed should record failed apply_patch preview"); + assert_eq!(completed.payload["status"], "failed"); + assert_eq!(completed.payload["result"]["errorCode"], "E_INVALID_PATCH"); + assert_eq!(completed.payload["result"]["reversePatch"], ""); + } + + #[test] + fn unknown_tool_risk_uses_name_tokens_not_substrings() { + assert_eq!(unknown_tool_risk("write_file"), RiskLevel::Write); + assert_eq!(unknown_tool_risk("delete-file"), RiskLevel::Destructive); + assert_eq!(unknown_tool_risk("readFile"), RiskLevel::Read); + + assert_eq!(unknown_tool_risk("thread_reader"), RiskLevel::Exec); + assert_eq!(unknown_tool_risk("bread_searcher"), RiskLevel::Exec); + assert_eq!(unknown_tool_risk("recreate_file"), RiskLevel::Exec); + assert_eq!(unknown_tool_risk("undelete_file"), RiskLevel::Exec); + } + + #[tokio::test] + async fn turn_loop_reports_unknown_tool_as_failed_tool_result_and_continues() { + let workspace = TestWorkspace::new("turn-loop"); + workspace.write("README.md", "old\n"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_unknown_tool") + .expect("run should be created"); + let raw_secret = format!("sk-{}", "unknown-tool-secret-123"); + let provider = ScriptedProvider::new(vec![ + TurnProviderResponse::tool_calls( + None, + Some("I should write a file.".to_owned()), + vec![ChatToolCall::function( + "call_write", + "write_file", + json!({ + "path": "README.md", + "content": format!("new {raw_secret}\n"), + }) + .to_string(), + )], + ), + TurnProviderResponse::final_text("I will retry with apply_patch."), + ]); + let mut loop_runner = + AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); + + let outcome = loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Update README"), &mut run) + .await + .expect("unknown tool result should be recoverable"); + + assert_eq!(workspace.read("README.md"), "old\n"); + assert_eq!(outcome.final_message, "I will retry with apply_patch."); + assert_eq!(outcome.tool_results.len(), 1); + assert_eq!(outcome.tool_results[0].name, "write_file"); + assert_eq!(outcome.tool_results[0].status, ToolStatus::Failed); + assert_eq!( + outcome.tool_results[0].result["errorCode"], + json!("E_UNKNOWN_TOOL") + ); + assert!( + outcome.tool_results[0].result["availableTools"] + .as_array() + .expect("availableTools should be an array") + .iter() + .any(|tool| tool == "apply_patch") + ); + assert_eq!(loop_runner.provider.requests.len(), 2); + let tool_result = loop_runner.provider.requests[1] + .messages + .iter() + .filter_map(|message| message.content.as_deref()) + .filter_map(|content| serde_json::from_str::(content).ok()) + .find(|content| content["errorCode"] == "E_UNKNOWN_TOOL") + .expect("unknown tool result should be sent to the provider"); + assert_eq!(tool_result["errorCode"], "E_UNKNOWN_TOOL"); + assert!( + tool_result["guidance"] + .as_str() + .is_some_and(|guidance| guidance.contains("apply_patch")) + ); + + let events = store + .load_run("run_turn_unknown_tool") + .expect("events should load"); + assert!(!events.iter().any(|event| event.event_type == "run.failed")); + let requested = events + .iter() + .find(|event| event.event_type == "tool.requested") + .expect("unknown tool request should be logged"); + assert_eq!(requested.payload["name"], "write_file"); + assert_eq!(requested.payload["risk"], "write"); + assert_eq!( + requested.payload["argumentsPreview"]["omitted"], + "Arguments for unknown tools are not logged. Use the failed tool result guidance to retry with a supported tool." + ); + let events_text = serde_json::to_string(&events).expect("events should serialize"); + assert!(!events_text.contains(&raw_secret)); + } + + #[tokio::test] + async fn turn_loop_applies_patch_from_run_scoped_payload_ref() { + let workspace = TestWorkspace::new("turn-loop"); + workspace.write("README.md", "old\n"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_patch_payload_ref") + .expect("run should be created"); + let patch = "--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-old\n+new\n"; + let split_at = patch + .find("@@") + .expect("fixture patch should contain a hunk"); + run.append_run_payload_chunk( + Path::new("payloads/apply_patch/patch.diff"), + &patch[..split_at], + ) + .expect("first payload chunk should append"); + run.append_run_payload_chunk( + Path::new("payloads/apply_patch/patch.diff"), + &patch[split_at..], + ) + .expect("second payload chunk should append"); + let provider = ScriptedProvider::new(vec![ + TurnProviderResponse::tool_calls( + None, + Some("I should edit the README.".to_owned()), + vec![ChatToolCall::function( + "call_1", + "apply_patch", + json!({ + "payloadRef": { + "kind": "run_file", + "path": "payloads/apply_patch/patch.diff", + "sha256": crate::hashing::sha256_hex(patch.as_bytes()), + "sizeBytes": patch.len(), + }, + "expectedFiles": ["README.md"], + }) + .to_string(), + )], + ), + TurnProviderResponse::final_text("Updated README."), + ]); + let mut loop_runner = + AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); + + loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Update README"), &mut run) + .await + .expect("payload-referenced patch should complete"); + + assert_eq!(workspace.read("README.md"), "new\n"); let events = store - .load_run("run_turn_schema_reject") + .load_run("run_turn_patch_payload_ref") .expect("events should load"); + let requested = events + .iter() + .find(|event| event.event_type == "tool.requested") + .expect("tool.requested should be recorded"); + assert!( + requested.payload["argumentsPreview"] + .get("unifiedDiff") + .is_none() + ); + assert_eq!( + requested.payload["argumentsPreview"]["payloadRef"]["path"], + "payloads/apply_patch/patch.diff" + ); assert!( !events .iter() - .any(|event| event.event_type == "tool.requested") + .any(|event| event.event_type == "tool.approvalRequired") ); - assert!(events.iter().any(|event| { - event.event_type == "run.failed" && event.payload["code"] == "E_INVALID_TOOL_ARGUMENTS" - })); } #[tokio::test] - async fn turn_loop_executes_approved_patch_and_tracks_changed_files() { + async fn turn_loop_rejects_patch_payload_ref_hash_mismatch() { let workspace = TestWorkspace::new("turn-loop"); workspace.write("README.md", "old\n"); let store = RunLogStore::new(workspace.path()).expect("run log store should open"); let mut run = store - .create_run("run_turn_patch") + .create_run("run_turn_patch_payload_ref_hash_mismatch") .expect("run should be created"); - let patch = concat!( - "--- a/README.md\n", - "+++ b/README.md\n", - "@@ -1 +1 @@\n", - "-old\n", - "+new\n" - ); - let provider = ScriptedProvider::new(vec![ - TurnProviderResponse::tool_calls( - None, - Some("I should edit the README.".to_owned()), - vec![ChatToolCall::function( - "call_1", - "apply_patch", - json!({ - "unifiedDiff": patch, - "expectedFiles": ["README.md"], - }) - .to_string(), - )], - ), - TurnProviderResponse::final_text("Updated README."), - ]); + let patch = "--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-old\n+new\n"; + run.write_run_payload_file(Path::new("payloads/apply_patch/patch.diff"), patch) + .expect("payload file should be written"); + let provider = ScriptedProvider::new(vec![TurnProviderResponse::tool_calls( + None, + Some("I should edit the README.".to_owned()), + vec![ChatToolCall::function( + "call_1", + "apply_patch", + json!({ + "payloadRef": { + "kind": "run_file", + "path": "payloads/apply_patch/patch.diff", + "sha256": "0".repeat(64), + "sizeBytes": patch.len(), + }, + "expectedFiles": ["README.md"], + }) + .to_string(), + )], + )]); let mut loop_runner = AgentTurnLoop::with_approval_policy(workspace.path(), provider, AutoApprovePolicy) .expect("turn loop should initialize"); - let outcome = loop_runner + let error = loop_runner .run_turn(AgentTurnInput::new("turn_1", "Update README"), &mut run) .await - .expect("approved patch should complete"); + .expect_err("hash mismatch should fail before tool execution"); - assert_eq!(workspace.read("README.md"), "new\n"); - assert_eq!(outcome.changed_files, vec!["README.md"]); + assert!(matches!( + error, + AgentTurnLoopError::InvalidToolPayloadReference { .. } + )); + assert_eq!(workspace.read("README.md"), "old\n"); let events = store - .load_run("run_turn_patch") + .load_run("run_turn_patch_payload_ref_hash_mismatch") .expect("events should load"); + assert!(events.iter().any(|event| { + event.event_type == "run.failed" && event.payload["code"] == "E_INVALID_TOOL_ARGUMENTS" + })); assert!( - events + !events .iter() - .any(|event| event.event_type == "tool.approvalRequired") + .any(|event| event.event_type == "tool.requested") ); - let completed = events - .iter() - .find(|event| event.event_type == "run.completed") - .expect("run should complete"); - assert_eq!(completed.payload["changedFiles"], json!(["README.md"])); } #[tokio::test] - async fn turn_loop_applies_only_approved_patch_hunks() { + async fn turn_loop_applies_all_workspace_patch_hunks_without_approval() { let workspace = TestWorkspace::new("turn-loop"); workspace.write("README.md", "one\nold\nthree\n\nkeep\nremove\n"); let store = RunLogStore::new(workspace.path()).expect("run log store should open"); @@ -2840,35 +5866,25 @@ mod tests { TurnProviderResponse::final_text("Updated README."), ]); let mut loop_runner = - AgentTurnLoop::with_approval_policy(workspace.path(), provider, HunkApprovePolicy) - .expect("turn loop should initialize"); + AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); let outcome = loop_runner .run_turn(AgentTurnInput::new("turn_1", "Update README"), &mut run) .await - .expect("hunk-approved patch should complete"); + .expect("workspace patch should complete without approval"); assert_eq!( workspace.read("README.md"), - "one\nold\nthree\n\nkeep\ninsert\nremove\n" + "one\nnew\nthree\n\nkeep\ninsert\nremove\n" ); assert_eq!(outcome.changed_files, vec!["README.md"]); let events = store .load_run("run_turn_patch_hunks") .expect("events should load"); - let required = events - .iter() - .find(|event| event.event_type == "tool.approvalRequired") - .expect("approval should be required"); - assert_eq!(required.payload["hunks"].as_array().map(Vec::len), Some(2)); - let resolved = events - .iter() - .find(|event| event.event_type == "tool.approvalResolved") - .expect("approval should resolve"); - assert_eq!(resolved.payload["hunks"]["scope"], "selected"); - assert_eq!( - resolved.payload["hunks"]["approved"], - json!(["README.md#2:old5+2:new5+3"]) + assert!( + !events + .iter() + .any(|event| event.event_type == "tool.approvalRequired") ); } @@ -2992,6 +6008,237 @@ mod tests { ); } + #[tokio::test] + async fn turn_loop_injects_queued_steer_before_next_provider_request() { + let workspace = TestWorkspace::new("turn-loop"); + workspace.write("README.md", "hello\n"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_steer") + .expect("run should be created"); + let steer_queue = TurnSteerQueue::default(); + let provider = ScriptedProvider::new(vec![ + TurnProviderResponse::tool_calls( + None, + Some("I will read first.".to_owned()), + vec![ChatToolCall::function( + "call_read", + "read_file", + r#"{"path":"README.md"}"#, + )], + ), + TurnProviderResponse::final_text("Used steer."), + ]); + let mut sink = SteerOnEventSink { + steer_queue: steer_queue.clone(), + inserted: false, + }; + let mut loop_runner = + AgentTurnLoop::new(workspace.path(), provider).expect("turn loop should initialize"); + + loop_runner + .run_turn_with_event_sink( + AgentTurnInput::new("turn_1", "Read README").with_steer_queue(steer_queue), + &mut run, + &mut sink, + ) + .await + .expect("turn should complete with steer"); + + assert_eq!(loop_runner.provider.requests.len(), 2); + assert!( + loop_runner.provider.requests[1] + .messages + .iter() + .any(|message| { + message + .content + .as_deref() + .is_some_and(|content| content.contains("Please focus on tests")) + }), + "second provider request should include queued steer" + ); + assert!( + loop_runner.provider.requests[1] + .messages + .iter() + .any(|message| { + message.content.as_deref().is_some_and(|content| { + content.contains("latest user instruction") + && content.contains("subsequent user-visible assistant text") + }) + }), + "queued steer should be framed as the latest user instruction" + ); + let events = store + .load_run("run_turn_steer") + .expect("events should load"); + assert!(events.iter().any(|event| { + event.event_type == "turn.steered" + && event.payload["message"] == json!("Please focus on tests") + })); + } + + #[tokio::test] + async fn turn_loop_retries_transient_provider_stream_interruption() { + let workspace = TestWorkspace::new("turn-loop"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_provider_retry") + .expect("run should be created"); + let provider = EventScriptedProvider::new_result_streams(vec![ + vec![ + Ok(TurnProviderEvent::AssistantDelta( + TurnProviderDelta::reasoning_content("private"), + )), + Ok(TurnProviderEvent::AssistantDelta( + TurnProviderDelta::content("partial "), + )), + Err(TurnProviderError::transient("connection reset by peer")), + ], + vec![Ok(TurnProviderEvent::Completed( + TurnProviderResponse::final_text("complete"), + ))], + ]); + let config = AgentTurnLoopConfig { + provider_transient_retries: 1, + ..AgentTurnLoopConfig::default() + }; + let mut loop_runner = AgentTurnLoop::new(workspace.path(), provider) + .expect("turn loop should initialize") + .with_config(config); + + let outcome = loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Say hello"), &mut run) + .await + .expect("transient provider interruption should be retried"); + + assert_eq!(outcome.final_message, "complete"); + assert_eq!(outcome.iterations, 2); + assert_eq!(loop_runner.provider.requests.len(), 2); + assert!( + loop_runner.provider.requests[1] + .messages + .iter() + .any(|message| { + message.content.as_deref().is_some_and(|content| { + content.contains(super::PROVIDER_STREAM_RETRY_INSTRUCTION) + }) + }) + ); + assert!( + loop_runner.provider.requests[1] + .messages + .iter() + .any(|message| message.content.as_deref() == Some("partial")) + ); + + let events = store + .load_run("run_turn_provider_retry") + .expect("events should load"); + let retries = events + .iter() + .filter(|event| event.event_type == "provider.retrying") + .collect::>(); + assert_eq!(retries.len(), 1); + assert_eq!(retries[0].payload["reason"], "transient_stream_error"); + assert_eq!(retries[0].payload["partialContentChars"], 8); + assert_eq!(retries[0].payload["partialReasoningChars"], 7); + assert_eq!(retries[0].payload["retriesRemaining"], 0); + } + + #[tokio::test] + async fn turn_loop_retries_and_fails_provider_start_idle_timeout() { + let workspace = TestWorkspace::new("turn-loop"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_provider_start_timeout") + .expect("run should be created"); + let provider = HangingProvider::default(); + let config = AgentTurnLoopConfig { + provider_transient_retries: 0, + provider_idle_timeout: Duration::from_millis(5), + provider_idle_timeout_attempts: 2, + ..AgentTurnLoopConfig::default() + }; + let mut loop_runner = AgentTurnLoop::new(workspace.path(), provider) + .expect("turn loop should initialize") + .with_config(config); + + let error = loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Say hello"), &mut run) + .await + .expect_err("idle provider should fail after configured attempts"); + + assert!(matches!( + error, + AgentTurnLoopError::ProviderIdleTimeout { + timeout_ms: 5, + attempts: 2, + .. + } + )); + assert_eq!(loop_runner.provider.requests.len(), 2); + + let events = store + .load_run("run_turn_provider_start_timeout") + .expect("events should load"); + let retries = events + .iter() + .filter(|event| event.event_type == "provider.retrying") + .collect::>(); + assert_eq!(retries.len(), 1); + assert_eq!(retries[0].payload["reason"], "provider_idle_timeout"); + assert_eq!(retries[0].payload["timeoutMs"], 5); + assert_eq!(retries[0].payload["retriesRemaining"], 0); + assert!(events.iter().any(|event| { + event.event_type == "run.failed" + && event.payload["code"] == "E_PROVIDER_TIMEOUT" + && event.payload["attempts"] == 2 + })); + } + + #[tokio::test] + async fn turn_loop_retries_provider_stream_idle_timeout() { + let workspace = TestWorkspace::new("turn-loop"); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let mut run = store + .create_run("run_turn_provider_stream_timeout") + .expect("run should be created"); + let provider = IdleThenCompleteProvider::new(); + let config = AgentTurnLoopConfig { + provider_transient_retries: 0, + provider_idle_timeout: Duration::from_millis(5), + provider_idle_timeout_attempts: 2, + ..AgentTurnLoopConfig::default() + }; + let mut loop_runner = AgentTurnLoop::new(workspace.path(), provider) + .expect("turn loop should initialize") + .with_config(config); + + let outcome = loop_runner + .run_turn(AgentTurnInput::new("turn_1", "Say hello"), &mut run) + .await + .expect("idle stream should be retried"); + + assert_eq!(outcome.final_message, "complete after idle retry"); + assert_eq!(outcome.iterations, 2); + assert_eq!(loop_runner.provider.requests.len(), 2); + + let events = store + .load_run("run_turn_provider_stream_timeout") + .expect("events should load"); + assert!(events.iter().any(|event| { + event.event_type == "provider.retrying" + && event.payload["reason"] == "provider_idle_timeout" + && event.payload["timeoutMs"] == 5 + })); + assert!(events.iter().any(|event| { + event.event_type == "run.completed" + && event.payload["summary"] == "complete after idle retry" + })); + } + #[tokio::test] async fn turn_loop_cancels_provider_stream_when_token_is_signaled() { let workspace = TestWorkspace::new("turn-loop"); @@ -3103,21 +6350,6 @@ mod tests { requests: Vec, } - struct HunkApprovePolicy; - - impl ApprovalPolicy for HunkApprovePolicy { - fn decide( - &mut self, - request: &TurnApprovalRequest, - ) -> Result { - assert_eq!(request.tool_name, "apply_patch"); - assert_eq!(request.hunks.as_ref().map(Vec::len), Some(2)); - Ok(ApprovalDecision::ApprovedHunks { - hunk_ids: vec!["README.md#2:old5+2:new5+3".to_owned()], - }) - } - } - impl ScriptedProvider { fn new(responses: Vec) -> Self { Self { @@ -3141,29 +6373,89 @@ mod tests { } struct EventScriptedProvider { - streams: VecDeque>, + streams: VecDeque>>, + requests: Vec, } impl EventScriptedProvider { fn new(streams: Vec>) -> Self { + Self { + streams: streams + .into_iter() + .map(|events| events.into_iter().map(Ok).collect()) + .collect(), + requests: Vec::new(), + } + } + + fn new_result_streams( + streams: Vec>>, + ) -> Self { Self { streams: streams.into(), + requests: Vec::new(), } } } impl TurnProvider for EventScriptedProvider { - fn complete_stream(&mut self, _request: TurnProviderRequest) -> TurnProviderFuture<'_> { + fn complete_stream(&mut self, request: TurnProviderRequest) -> TurnProviderFuture<'_> { Box::pin(async move { + self.requests.push(request); let events = self.streams.pop_front().ok_or_else(|| { TurnProviderError::new("event scripted provider has no stream") })?; - let stream: TurnProviderStream = Box::pin(stream::iter(events.into_iter().map(Ok))); + let stream: TurnProviderStream = Box::pin(stream::iter(events)); Ok(stream) }) } } + #[derive(Default)] + struct HangingProvider { + requests: Vec, + } + + impl TurnProvider for HangingProvider { + fn complete_stream(&mut self, request: TurnProviderRequest) -> TurnProviderFuture<'_> { + Box::pin(async move { + self.requests.push(request); + future::pending::>().await + }) + } + } + + struct IdleThenCompleteProvider { + idle_once: bool, + requests: Vec, + } + + impl IdleThenCompleteProvider { + fn new() -> Self { + Self { + idle_once: true, + requests: Vec::new(), + } + } + } + + impl TurnProvider for IdleThenCompleteProvider { + fn complete_stream(&mut self, request: TurnProviderRequest) -> TurnProviderFuture<'_> { + Box::pin(async move { + self.requests.push(request); + if self.idle_once { + self.idle_once = false; + let stream: TurnProviderStream = Box::pin(stream::pending()); + Ok(stream) + } else { + Ok(turn_provider_response_stream( + TurnProviderResponse::final_text("complete after idle retry"), + )) + } + }) + } + } + #[derive(Default)] struct RecordingEventSink { events: Vec, @@ -3176,6 +6468,21 @@ mod tests { } } + struct SteerOnEventSink { + steer_queue: TurnSteerQueue, + inserted: bool, + } + + impl TurnEventSink for SteerOnEventSink { + fn on_event(&mut self, event: &RunLogEvent) -> Result<(), TurnEventSinkError> { + if event.event_type == "tool.completed" && !self.inserted { + self.inserted = true; + self.steer_queue.push("steer_test", "Please focus on tests"); + } + Ok(()) + } + } + struct CancelOnEventSink { cancellation_token: CancellationToken, event_type: &'static str, diff --git a/crates/agent-rpc/src/lib.rs b/crates/agent-rpc/src/lib.rs index f0271df..7530eb6 100644 --- a/crates/agent-rpc/src/lib.rs +++ b/crates/agent-rpc/src/lib.rs @@ -23,7 +23,7 @@ use prole_coder_agent_core::{ ApprovalDecision, ApprovalPolicy, ApprovalPolicyError, TextRange as CoreTextRange, TurnApprovalRequest, TurnAttachment as CoreTurnAttachment, TurnAttachmentKind as CoreTurnAttachmentKind, TurnEventSink, TurnEventSinkError, - TurnProvider, + TurnProvider, TurnSteerQueue, TurnSupersedes as CoreTurnSupersedes, }, }; use serde::{Deserialize, Serialize}; @@ -54,8 +54,11 @@ pub const SEND_TURN_METHOD: RpcMethod = RpcMethod::new("sendTurn"); pub const APPROVE_METHOD: RpcMethod = RpcMethod::new("approve"); pub const REJECT_METHOD: RpcMethod = RpcMethod::new("reject"); pub const CANCEL_METHOD: RpcMethod = RpcMethod::new("cancel"); +pub const STEER_METHOD: RpcMethod = RpcMethod::new("steer"); pub const RESUME_METHOD: RpcMethod = RpcMethod::new("resume"); +pub const LOAD_RUN_EVENTS_METHOD: RpcMethod = RpcMethod::new("loadRunEvents"); pub const LIST_RUNS_METHOD: RpcMethod = RpcMethod::new("listRuns"); +pub const DELETE_RUN_METHOD: RpcMethod = RpcMethod::new("deleteRun"); pub const FIM_PREVIEW_METHOD: RpcMethod = RpcMethod::new("previewFim"); pub const EVENT_METHOD: RpcMethod = RpcMethod::new("event"); pub const EVENT_BATCH_METHOD: RpcMethod = RpcMethod::new("eventBatch"); @@ -83,6 +86,8 @@ pub const DEFAULT_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300); const RPC_LOOP_QUEUE_BOUND: usize = 256; const RPC_LIVE_EVENT_QUEUE_BOUND: usize = 256; const RPC_LIVE_EVENT_BATCH_MAX: usize = 64; +const DEFAULT_LOAD_RUN_EVENTS_LIMIT: usize = 200; +const MAX_LOAD_RUN_EVENTS_LIMIT: usize = 500; const APPROVAL_PERSISTENCE_FILE: &str = "approvals.v1.json"; const APPROVAL_PERSISTENCE_VERSION: u32 = 1; @@ -304,6 +309,23 @@ pub enum RpcRunMode { Ask, } +impl TryFrom<&str> for RpcRunMode { + type Error = AgentRpcHandlerError; + + fn try_from(value: &str) -> Result { + match value { + "plan" => Ok(Self::Plan), + "edit" => Ok(Self::Edit), + "review" => Ok(Self::Review), + "ask" => Ok(Self::Ask), + other => Err(AgentRpcHandlerError::new( + RPC_INTERNAL_INVARIANT, + format!("run summary contains invalid mode `{other}`"), + )), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SendTurnParams { @@ -313,6 +335,16 @@ pub struct SendTurnParams { pub mode: RpcRunMode, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub attachments: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub supersedes: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TurnSupersedes { + pub message_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -364,6 +396,48 @@ impl From for AgentRunMode { } } +fn core_turn_supersedes_from_rpc( + supersedes: &TurnSupersedes, +) -> Result { + let message_id = supersedes.message_id.trim(); + if message_id.is_empty() || message_id.len() > 256 { + return Err(AgentRpcHandlerError::new( + JSON_RPC_INVALID_PARAMS, + "supersedes.messageId must be a non-empty string no longer than 256 bytes", + )); + } + let turn_id = supersedes + .turn_id + .as_deref() + .map(|value| { + if value.is_empty() || value.len() > 128 { + Err(AgentRpcHandlerError::new( + JSON_RPC_INVALID_PARAMS, + "supersedes.turnId must be a non-empty identifier no longer than 128 bytes", + )) + } else if !is_rpc_identifier_fragment(value) { + Err(AgentRpcHandlerError::new( + JSON_RPC_INVALID_PARAMS, + "supersedes.turnId must contain only ASCII letters, digits, `_`, or `-`", + )) + } else { + Ok(value.to_owned()) + } + }) + .transpose()?; + + Ok(CoreTurnSupersedes { + message_id: message_id.to_owned(), + turn_id, + }) +} + +fn is_rpc_identifier_fragment(value: &str) -> bool { + value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') +} + fn core_turn_attachment_from_rpc( attachment: &TurnAttachment, ) -> Result { @@ -401,6 +475,28 @@ pub struct ResumeResult { pub replay_started: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoadRunEventsParams { + pub run_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub before_seq: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoadRunEventsResult { + pub run_id: String, + pub events: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_seq: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_seq: Option, + pub has_more_before: bool, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ListRunsParams { @@ -414,6 +510,19 @@ pub struct ListRunsResult { pub runs: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteRunParams { + pub run_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteRunResult { + pub run_id: String, + pub deleted: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RpcRunSummary { @@ -427,7 +536,7 @@ pub struct RpcRunSummary { pub last_seq: u64, pub event_count: u64, #[serde(skip_serializing_if = "Option::is_none")] - pub mode: Option, + pub mode: Option, #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -526,6 +635,21 @@ pub struct CancelResult { pub reason: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SteerParams { + pub run_id: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SteerResult { + pub run_id: String, + pub steer_id: String, + pub accepted: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FimPreviewParams { @@ -635,16 +759,31 @@ pub trait AgentRpcRequestHandler { params: CancelParams, ) -> Result, AgentRpcHandlerError>; + fn steer( + &mut self, + params: SteerParams, + ) -> Result, AgentRpcHandlerError>; + fn resume( &mut self, params: ResumeParams, ) -> Result, AgentRpcHandlerError>; + fn load_run_events( + &mut self, + params: LoadRunEventsParams, + ) -> Result, AgentRpcHandlerError>; + fn list_runs( &mut self, params: ListRunsParams, ) -> Result, AgentRpcHandlerError>; + fn delete_run( + &mut self, + params: DeleteRunParams, + ) -> Result, AgentRpcHandlerError>; + fn preview_fim( &mut self, params: FimPreviewParams, @@ -787,21 +926,24 @@ where Some(run_id) => run_id, None => generate_id("run")?, }; - let turn_id = "turn_1".to_owned(); let provider = self.provider_factory.create_provider(¶ms)?; let run_log = self .workspace()? .store - .create_run(run_id.clone()) + .open_or_create_run(run_id.clone()) .map_err(map_run_log_error)?; + let turn_id = next_turn_id(&run_log).map_err(map_run_log_error)?; let attachments = params .attachments .iter() .map(core_turn_attachment_from_rpc) .collect::, _>>()?; - let input = AgentTurnInput::new(turn_id.clone(), params.message.clone()) + let mut input = AgentTurnInput::new(turn_id.clone(), params.message.clone()) .with_mode(params.mode.into()) .with_attachments(attachments); + if let Some(supersedes) = ¶ms.supersedes { + input = input.with_supersedes(core_turn_supersedes_from_rpc(supersedes)?); + } let live_events = self.live_event_sender.clone(); let active_run = spawn_active_run(ActiveRunSpawn { @@ -932,6 +1074,47 @@ where .with_events(events)) } + fn steer( + &mut self, + params: SteerParams, + ) -> Result, AgentRpcHandlerError> { + self.drain_ready_active_run_events()?; + let active_run = self.active_run.as_ref().ok_or_else(|| { + AgentRpcHandlerError::new( + RPC_RUN_NOT_FOUND, + format!( + "run `{}` is not active in the current RPC handler", + params.run_id + ), + ) + })?; + if active_run.run_id != params.run_id { + return Err(AgentRpcHandlerError::new( + RPC_RUN_NOT_FOUND, + format!( + "run `{}` is not active in the current RPC handler", + params.run_id + ), + )); + } + if params.message.trim().is_empty() { + return Err(AgentRpcHandlerError::new( + JSON_RPC_INVALID_PARAMS, + "steer.message must not be empty", + )); + } + + let steer_id = generate_id("steer")?; + active_run + .steer_queue + .push(steer_id.clone(), params.message); + Ok(AgentRpcHandlerOutput::new(SteerResult { + run_id: params.run_id, + steer_id, + accepted: true, + })) + } + fn resume( &mut self, params: ResumeParams, @@ -972,6 +1155,56 @@ where .with_events(replay_events)) } + fn load_run_events( + &mut self, + params: LoadRunEventsParams, + ) -> Result, AgentRpcHandlerError> { + self.drain_ready_active_run_events()?; + let store = &self.workspace()?.store; + let run_id = params.run_id.clone(); + let events = match self + .active_run + .as_ref() + .filter(|active_run| active_run.run_id == params.run_id) + { + Some(active_run) => active_run.run_log.load().map_err(map_run_log_error)?, + None => store + .load_run(params.run_id.clone()) + .map_err(map_run_log_error)?, + }; + let before_seq = params.before_seq.unwrap_or(u64::MAX); + let limit = params + .limit + .unwrap_or(DEFAULT_LOAD_RUN_EVENTS_LIMIT) + .clamp(1, MAX_LOAD_RUN_EVENTS_LIMIT); + let eligible_events = events + .iter() + .filter(|event| event.seq < before_seq) + .collect::>(); + let start = eligible_events.len().saturating_sub(limit); + let page = eligible_events[start..] + .iter() + .map(|event| { + run_log_event_to_envelope(event).map_err(|source| { + AgentRpcHandlerError::new( + RPC_INTERNAL_INVARIANT, + format!("failed to serialize run event: {source}"), + ) + }) + }) + .collect::, _>>()?; + let first_seq = page.first().map(|event| event.seq); + let last_seq = page.last().map(|event| event.seq); + + Ok(AgentRpcHandlerOutput::new(LoadRunEventsResult { + run_id, + events: page, + first_seq, + last_seq, + has_more_before: start > 0, + })) + } + fn list_runs( &mut self, params: ListRunsParams, @@ -993,6 +1226,32 @@ where Ok(AgentRpcHandlerOutput::new(ListRunsResult { runs })) } + fn delete_run( + &mut self, + params: DeleteRunParams, + ) -> Result, AgentRpcHandlerError> { + self.drain_ready_active_run_events()?; + if self + .active_run + .as_ref() + .is_some_and(|active_run| active_run.run_id == params.run_id) + { + return Err(AgentRpcHandlerError::new( + RPC_RUN_ALREADY_ACTIVE, + format!("run `{}` is active and cannot be deleted", params.run_id), + )); + } + + self.workspace()? + .store + .delete_run(params.run_id.clone()) + .map_err(map_run_log_error)?; + Ok(AgentRpcHandlerOutput::new(DeleteRunResult { + run_id: params.run_id, + deleted: true, + })) + } + fn preview_fim( &mut self, params: FimPreviewParams, @@ -1117,6 +1376,18 @@ impl AgentTurnLoopRpcHandler { } } +fn next_turn_id(run_log: &RunLog) -> Result { + let turn_count = run_log + .load()? + .iter() + .filter(|event| event.event_type == "turn.started") + .count(); + let turn_number = turn_count + .checked_add(1) + .ok_or(RunLogError::SequenceOverflow)?; + Ok(format!("turn_{turn_number}")) +} + impl TryFrom<&RunSummary> for RpcRunSummary { type Error = AgentRpcHandlerError; @@ -1134,7 +1405,11 @@ impl TryFrom<&RunSummary> for RpcRunSummary { .map_err(map_rpc_error)?, last_seq: summary.last_seq, event_count: summary.event_count, - mode: summary.mode.clone(), + mode: summary + .mode + .as_deref() + .map(RpcRunMode::try_from) + .transpose()?, summary: summary.summary.clone(), changed_files: summary.changed_files.clone(), verification_status: summary.verification_status.clone(), @@ -1162,6 +1437,7 @@ struct RpcWorkspace { struct ActiveRpcRun { run_id: String, cancellation_token: CancellationToken, + steer_queue: TurnSteerQueue, approval_queue: RpcApprovalQueue, run_log: SerializedRunLog, events: mpsc::Receiver, @@ -1849,7 +2125,10 @@ where } = spawn; let buffer_internal_events = live_events.is_none(); let cancellation_token = CancellationToken::new(); - let worker_input = input.with_cancellation_token(cancellation_token.clone()); + let steer_queue = TurnSteerQueue::default(); + let worker_input = input + .with_steer_queue(steer_queue.clone()) + .with_cancellation_token(cancellation_token.clone()); let run_log = SerializedRunLog::new(run_log); let worker_run_log = run_log.clone(); let worker_approval_queue = approval_queue.clone(); @@ -1875,6 +2154,7 @@ where Ok(ActiveRpcRun { run_id, cancellation_token, + steer_queue, approval_queue, run_log, events: events_rx, @@ -2297,12 +2577,21 @@ where method if method == CANCEL_METHOD.qualified_name() => { self.handle_cancel(id, message.params, writer) } + method if method == STEER_METHOD.qualified_name() => { + self.handle_steer(id, message.params, writer) + } method if method == RESUME_METHOD.qualified_name() => { self.handle_resume(id, message.params, writer) } + method if method == LOAD_RUN_EVENTS_METHOD.qualified_name() => { + self.handle_load_run_events(id, message.params, writer) + } method if method == LIST_RUNS_METHOD.qualified_name() => { self.handle_list_runs(id, message.params, writer) } + method if method == DELETE_RUN_METHOD.qualified_name() => { + self.handle_delete_run(id, message.params, writer) + } method if method == FIM_PREVIEW_METHOD.qualified_name() => { self.handle_preview_fim(id, message.params, writer) } @@ -2456,6 +2745,30 @@ where } } + fn handle_steer( + &mut self, + id: Value, + params: Option, + writer: &mut W, + ) -> Result<(), AgentRpcError> + where + W: Write, + { + let params = + match parse_params::(params, STEER_METHOD.qualified_name().as_str()) { + Ok(params) => params, + Err(error) => return write_error(writer, id, error), + }; + + match self.handler.steer(params) { + Ok(output) => { + write_json_line(writer, &JsonRpcResponse::new(id, output.result))?; + emit_run_log_events(writer, &output.events) + } + Err(error) => write_error(writer, id, error.into_error_object()), + } + } + fn handle_send_turn( &mut self, id: Value, @@ -2506,6 +2819,29 @@ where } } + fn handle_load_run_events( + &mut self, + id: Value, + params: Option, + writer: &mut W, + ) -> Result<(), AgentRpcError> + where + W: Write, + { + let params = match parse_params::( + params, + LOAD_RUN_EVENTS_METHOD.qualified_name().as_str(), + ) { + Ok(params) => params, + Err(error) => return write_error(writer, id, error), + }; + + match self.handler.load_run_events(params) { + Ok(output) => write_json_line(writer, &JsonRpcResponse::new(id, output.result)), + Err(error) => write_error(writer, id, error.into_error_object()), + } + } + fn handle_list_runs( &mut self, id: Value, @@ -2532,6 +2868,32 @@ where } } + fn handle_delete_run( + &mut self, + id: Value, + params: Option, + writer: &mut W, + ) -> Result<(), AgentRpcError> + where + W: Write, + { + let params = match parse_params::( + params, + DELETE_RUN_METHOD.qualified_name().as_str(), + ) { + Ok(params) => params, + Err(error) => return write_error(writer, id, error), + }; + + match self.handler.delete_run(params) { + Ok(output) => { + write_json_line(writer, &JsonRpcResponse::new(id, output.result))?; + emit_run_log_events(writer, &output.events) + } + Err(error) => write_error(writer, id, error.into_error_object()), + } + } + fn handle_preview_fim( &mut self, id: Value, @@ -2898,7 +3260,7 @@ mod tests { use prole_coder_agent_core::{ approval::RiskLevel, provider::deepseek_api::ChatToolCall, - run_log::{RunLogEvent, RunLogStore}, + run_log::{RunLogError, RunLogEvent, RunLogStore, RunSummaryStatus}, test_helpers::TestWorkspace, turn_loop::{ AgentTurnInput, AgentTurnLoopConfig, ApprovalDecision, TurnApprovalRequest, @@ -2920,19 +3282,21 @@ mod tests { APPROVE_METHOD, ActiveRunSpawn, AgentInitializeParams, AgentInitializeResult, AgentRpcError, AgentRpcHandlerError, AgentRpcHandlerOutput, AgentRpcRequestHandler, AgentTurnLoopRpcHandler, ApproveParams, ApproveResult, CANCEL_METHOD, CancelParams, - CancelResult, EVENT_BATCH_METHOD, EVENT_METHOD, FIM_PREVIEW_METHOD, FimPreviewParams, - FimPreviewResult, INITIALIZE_METHOD, JSON_RPC_INTERNAL_ERROR, JSON_RPC_INVALID_PARAMS, - JSON_RPC_INVALID_REQUEST, JSON_RPC_METHOD_NOT_FOUND, JSON_RPC_PARSE_ERROR, - LIST_RUNS_METHOD, ListRunsParams, ListRunsResult, PROTOCOL_VERSION, REJECT_METHOD, - RESUME_METHOD, RPC_APPROVAL_DENIED, RPC_APPROVAL_NOT_FOUND, RPC_CONTEXT_BUDGET_EXCEEDED, - RPC_INTERNAL_INVARIANT, RPC_INVALID_TOOL_ARGUMENTS, RPC_PROVIDER_ERROR, - RPC_RUN_ALREADY_ACTIVE, RPC_RUN_CANCELED, RPC_RUN_NOT_FOUND, RPC_TOOL_EXECUTION_FAILED, - RPC_UNSUPPORTED_PROTOCOL, RPC_WORKSPACE_UNTRUSTED, RejectParams, RejectResult, - ResumeParams, ResumeResult, RpcApprovalPersistence, RpcApprovalQueue, RpcApprovalState, - RpcApprovedHunks, RpcRunState, RpcRunSummary, RpcRunSummaryStatus, RpcWorkspace, - SEND_TURN_METHOD, SendTurnParams, SendTurnResult, StdioEventBridge, - emit_live_run_log_events, format_unix_millis, run_log_event_to_notification, - run_log_events_to_batch_notification, run_stdio_request_loop, spawn_active_run, + CancelResult, DELETE_RUN_METHOD, DeleteRunParams, DeleteRunResult, EVENT_BATCH_METHOD, + EVENT_METHOD, FIM_PREVIEW_METHOD, FimPreviewParams, FimPreviewResult, INITIALIZE_METHOD, + JSON_RPC_INTERNAL_ERROR, JSON_RPC_INVALID_PARAMS, JSON_RPC_INVALID_REQUEST, + JSON_RPC_METHOD_NOT_FOUND, JSON_RPC_PARSE_ERROR, LIST_RUNS_METHOD, LOAD_RUN_EVENTS_METHOD, + ListRunsParams, ListRunsResult, LoadRunEventsParams, LoadRunEventsResult, PROTOCOL_VERSION, + REJECT_METHOD, RESUME_METHOD, RPC_APPROVAL_DENIED, RPC_APPROVAL_NOT_FOUND, + RPC_CONTEXT_BUDGET_EXCEEDED, RPC_INTERNAL_INVARIANT, RPC_INVALID_TOOL_ARGUMENTS, + RPC_PROVIDER_ERROR, RPC_RUN_ALREADY_ACTIVE, RPC_RUN_CANCELED, RPC_RUN_NOT_FOUND, + RPC_TOOL_EXECUTION_FAILED, RPC_UNSUPPORTED_PROTOCOL, RPC_WORKSPACE_UNTRUSTED, RejectParams, + RejectResult, ResumeParams, ResumeResult, RpcApprovalPersistence, RpcApprovalQueue, + RpcApprovalState, RpcApprovedHunks, RpcRunState, RpcRunSummary, RpcRunSummaryStatus, + RpcWorkspace, SEND_TURN_METHOD, STEER_METHOD, SendTurnParams, SendTurnResult, + StdioEventBridge, SteerParams, SteerResult, emit_live_run_log_events, format_unix_millis, + run_log_event_to_notification, run_log_events_to_batch_notification, + run_stdio_request_loop, spawn_active_run, }; #[test] @@ -2942,8 +3306,14 @@ mod tests { assert_eq!(APPROVE_METHOD.qualified_name(), "agent.approve"); assert_eq!(REJECT_METHOD.qualified_name(), "agent.reject"); assert_eq!(CANCEL_METHOD.qualified_name(), "agent.cancel"); + assert_eq!(STEER_METHOD.qualified_name(), "agent.steer"); assert_eq!(RESUME_METHOD.qualified_name(), "agent.resume"); + assert_eq!( + LOAD_RUN_EVENTS_METHOD.qualified_name(), + "agent.loadRunEvents" + ); assert_eq!(LIST_RUNS_METHOD.qualified_name(), "agent.listRuns"); + assert_eq!(DELETE_RUN_METHOD.qualified_name(), "agent.deleteRun"); assert_eq!(FIM_PREVIEW_METHOD.qualified_name(), "agent.previewFim"); assert_eq!(EVENT_METHOD.qualified_name(), "agent.event"); assert_eq!(EVENT_BATCH_METHOD.qualified_name(), "agent.eventBatch"); @@ -3300,7 +3670,11 @@ mod tests { "params": { "runId": "run_rpc", "message": "Read README", - "mode": "ask" + "mode": "ask", + "supersedes": { + "messageId": "run_rpc:2", + "turnId": "turn_1" + } } }) .to_string(), @@ -3315,6 +3689,13 @@ mod tests { assert_eq!(handler.initialized.len(), 1); assert_eq!(handler.send_turns.len(), 1); assert_eq!(handler.send_turns[0].message, "Read README"); + assert_eq!( + handler.send_turns[0] + .supersedes + .as_ref() + .map(|value| (value.message_id.as_str(), value.turn_id.as_deref())), + Some(("run_rpc:2", Some("turn_1"))) + ); let lines = output_lines(output); assert_eq!(lines.len(), 3); assert_eq!(lines[0]["id"], "init_1"); @@ -3434,6 +3815,84 @@ mod tests { assert_eq!(lines[2]["params"]["seq"], 2); } + #[test] + fn request_loop_loads_run_events_in_response_without_replay_notifications() { + let input = [ + json!({ + "jsonrpc": "2.0", + "id": "init_1", + "method": "agent.initialize", + "params": initialize_params() + }) + .to_string(), + json!({ + "jsonrpc": "2.0", + "id": "load_1", + "method": "agent.loadRunEvents", + "params": { + "runId": "run_rpc", + "beforeSeq": 10, + "limit": 2 + } + }) + .to_string(), + ] + .join("\n"); + let mut output = Vec::new(); + + let handler = + run_stdio_request_loop(Cursor::new(input), &mut output, TestHandler::default()) + .expect("request loop should complete"); + + assert_eq!(handler.load_run_events.len(), 1); + assert_eq!(handler.load_run_events[0].before_seq, Some(10)); + assert_eq!(handler.load_run_events[0].limit, Some(2)); + let lines = output_lines(output); + assert_eq!(lines.len(), 2); + assert_eq!(lines[1]["id"], "load_1"); + assert_eq!(lines[1]["result"]["events"][0]["seq"], 1); + assert_eq!(lines[1]["result"]["events"][1]["seq"], 2); + assert_eq!(lines[1]["result"]["hasMoreBefore"], false); + } + + #[test] + fn turn_loop_rpc_handler_loads_run_event_pages_without_replay_output() { + let workspace = TestWorkspace::new("rpc"); + let mut handler = AgentTurnLoopRpcHandler::new(final_provider_factory); + handler + .initialize( + serde_json::from_value(initialize_params_for(workspace.path_str())) + .expect("initialize params should deserialize"), + ) + .expect("handler should initialize"); + handler + .send_turn(SendTurnParams { + run_id: Some("run_load_events_rpc".to_owned()), + message: "Say hello".to_owned(), + mode: super::RpcRunMode::Ask, + attachments: Vec::new(), + supersedes: None, + }) + .expect("turn should complete"); + + let result = handler + .load_run_events(LoadRunEventsParams { + run_id: "run_load_events_rpc".to_owned(), + before_seq: Some(5), + limit: Some(2), + }) + .expect("run events should load") + .result; + + assert_eq!(result.run_id, "run_load_events_rpc"); + assert_eq!(result.events.len(), 2); + assert_eq!(result.events[0].seq, 3); + assert_eq!(result.events[1].seq, 4); + assert_eq!(result.first_seq, Some(3)); + assert_eq!(result.last_seq, Some(4)); + assert!(result.has_more_before); + } + #[test] fn request_loop_lists_run_summaries() { let input = [ @@ -3477,6 +3936,42 @@ mod tests { ); } + #[test] + fn request_loop_deletes_runs() { + let input = [ + json!({ + "jsonrpc": "2.0", + "id": "init_1", + "method": "agent.initialize", + "params": initialize_params() + }) + .to_string(), + json!({ + "jsonrpc": "2.0", + "id": "delete_1", + "method": "agent.deleteRun", + "params": { + "runId": "run_rpc" + } + }) + .to_string(), + ] + .join("\n"); + let mut output = Vec::new(); + + let handler = + run_stdio_request_loop(Cursor::new(input), &mut output, TestHandler::default()) + .expect("request loop should complete"); + + assert_eq!(handler.delete_runs.len(), 1); + assert_eq!(handler.delete_runs[0].run_id, "run_rpc"); + let lines = output_lines(output); + assert_eq!(lines.len(), 2); + assert_eq!(lines[1]["id"], "delete_1"); + assert_eq!(lines[1]["result"]["runId"], "run_rpc"); + assert_eq!(lines[1]["result"]["deleted"], true); + } + #[test] fn request_loop_handles_fim_preview_requests() { let input = [ @@ -3690,9 +4185,9 @@ mod tests { output.wait_for_line( |line| { - line["method"] == "agent.event" - && line["params"]["type"] == "run.completed" - && line["params"]["runId"] == "run_real_rpc" + line_has_agent_event(line, |event| { + event["type"] == "run.completed" && event["runId"] == "run_real_rpc" + }) }, RPC_TEST_TIMEOUT, ); @@ -3730,12 +4225,12 @@ mod tests { assert_eq!(lines[1]["result"]["turnId"], "turn_1"); let turn_response_index = line_index(&lines, |line| line["id"] == "turn_1"); let run_started_index = line_index(&lines, |line| { - line["method"] == "agent.event" && line["params"]["type"] == "run.started" + line_has_agent_event(line, |event| event["type"] == "run.started") }); assert!(turn_response_index < run_started_index); - assert!(lines.iter().any(|line| line["method"] == "agent.event" - && line["params"]["type"] == "run.completed" - && line["params"]["payload"]["summary"] == "RPC final answer")); + let events = agent_event_values(&lines); + assert!(events.iter().any(|event| event["type"] == "run.completed" + && event["payload"]["summary"] == "RPC final answer")); assert!(lines.iter().any(|line| { line["id"] == "resume_1" && line["result"]["nextSeq"] @@ -3770,6 +4265,170 @@ mod tests { ); } + #[test] + fn turn_loop_rpc_handler_appends_multiple_turns_to_existing_run() { + let workspace = TestWorkspace::new("rpc"); + let mut handler = AgentTurnLoopRpcHandler::new(final_provider_factory); + handler + .initialize( + serde_json::from_value(initialize_params_for(workspace.path_str())) + .expect("initialize params should deserialize"), + ) + .expect("handler should initialize"); + + let first = handler + .send_turn(SendTurnParams { + run_id: Some("run_multi_turn".to_owned()), + message: "First task".to_owned(), + mode: super::RpcRunMode::Ask, + attachments: Vec::new(), + supersedes: None, + }) + .expect("first turn should run"); + let second = handler + .send_turn(SendTurnParams { + run_id: Some("run_multi_turn".to_owned()), + message: "Second task".to_owned(), + mode: super::RpcRunMode::Ask, + attachments: Vec::new(), + supersedes: None, + }) + .expect("second turn should append to the same run"); + + assert_eq!(first.result.turn_id, "turn_1"); + assert_eq!(second.result.turn_id, "turn_2"); + let store = RunLogStore::new(workspace.path()).expect("store should open"); + let events = store + .load_run("run_multi_turn") + .expect("run log should load"); + assert_eq!( + events + .iter() + .filter(|event| event.event_type == "run.started") + .count(), + 2 + ); + assert_eq!( + events + .iter() + .filter(|event| event.event_type == "turn.started") + .map(|event| event.turn_id.as_deref()) + .collect::>(), + vec![Some("turn_1"), Some("turn_2")] + ); + let summary = store + .load_run_summary("run_multi_turn") + .expect("summary should load"); + assert_eq!(summary.title, "Second task"); + assert_eq!(summary.status, RunSummaryStatus::Completed); + assert_eq!(summary.summary.as_deref(), Some("RPC final answer")); + } + + #[test] + fn turn_loop_rpc_handler_records_superseded_turn_metadata() { + let workspace = TestWorkspace::new("rpc"); + let mut handler = AgentTurnLoopRpcHandler::new(final_provider_factory); + handler + .initialize( + serde_json::from_value(initialize_params_for(workspace.path_str())) + .expect("initialize params should deserialize"), + ) + .expect("handler should initialize"); + + handler + .send_turn(SendTurnParams { + run_id: Some("run_superseded_turn".to_owned()), + message: "Original request".to_owned(), + mode: super::RpcRunMode::Ask, + attachments: Vec::new(), + supersedes: None, + }) + .expect("first turn should run"); + handler + .send_turn(SendTurnParams { + run_id: Some("run_superseded_turn".to_owned()), + message: "Edited request".to_owned(), + mode: super::RpcRunMode::Ask, + attachments: Vec::new(), + supersedes: Some(super::TurnSupersedes { + message_id: "run_superseded_turn:2".to_owned(), + turn_id: Some("turn_1".to_owned()), + }), + }) + .expect("edited turn should run"); + + let store = RunLogStore::new(workspace.path()).expect("store should open"); + let events = store + .load_run("run_superseded_turn") + .expect("run log should load"); + let edited_turn_started = events + .iter() + .find(|event| { + event.event_type == "turn.started" && event.turn_id.as_deref() == Some("turn_2") + }) + .expect("edited turn.started event should be recorded"); + + assert_eq!( + edited_turn_started.payload["supersedes"]["messageId"], + json!("run_superseded_turn:2") + ); + assert_eq!( + edited_turn_started.payload["supersedes"]["turnId"], + json!("turn_1") + ); + } + + #[test] + fn send_turn_supersedes_rejects_invalid_turn_id() { + let error = super::core_turn_supersedes_from_rpc(&super::TurnSupersedes { + message_id: "run_superseded_turn:2".to_owned(), + turn_id: Some("turn_1\nnext".to_owned()), + }) + .expect_err("newline in supersedes turn id should be rejected"); + + assert_eq!(error.code, super::JSON_RPC_INVALID_PARAMS); + assert!( + error.message.contains("supersedes.turnId"), + "unexpected error message: {}", + error.message + ); + } + + #[test] + fn turn_loop_rpc_handler_deletes_inactive_run_logs() { + let workspace = TestWorkspace::new("rpc"); + let mut handler = AgentTurnLoopRpcHandler::new(final_provider_factory); + handler + .initialize( + serde_json::from_value(initialize_params_for(workspace.path_str())) + .expect("initialize params should deserialize"), + ) + .expect("handler should initialize"); + handler + .send_turn(SendTurnParams { + run_id: Some("run_delete_rpc".to_owned()), + message: "Say hello".to_owned(), + mode: super::RpcRunMode::Ask, + attachments: Vec::new(), + supersedes: None, + }) + .expect("turn should complete before deletion"); + + let result = handler + .delete_run(DeleteRunParams { + run_id: "run_delete_rpc".to_owned(), + }) + .expect("inactive run should delete"); + + assert_eq!(result.result.run_id, "run_delete_rpc"); + assert!(result.result.deleted); + let store = RunLogStore::new(workspace.path()).expect("store should open"); + assert!(matches!( + store.load_run("run_delete_rpc"), + Err(RunLogError::RunNotFound { .. }) + )); + } + #[test] fn request_loop_send_turn_returns_before_provider_completion() { let workspace = TestWorkspace::new("rpc"); @@ -3807,18 +4466,18 @@ mod tests { output.wait_for_line(|line| line["id"] == "turn_1", RPC_TEST_TIMEOUT); assert!( - !output.lines().iter().any(|line| { - line["method"] == "agent.event" && line["params"]["type"] == "run.completed" - }), + !agent_event_values(&output.lines()) + .iter() + .any(|event| event["type"] == "run.completed"), "sendTurn response must be written before the blocked provider completes" ); gate.release(); output.wait_for_line( |line| { - line["method"] == "agent.event" - && line["params"]["type"] == "run.completed" - && line["params"]["runId"] == "run_rpc_early_accept" + line_has_agent_event(line, |event| { + event["type"] == "run.completed" && event["runId"] == "run_rpc_early_accept" + }) }, RPC_TEST_TIMEOUT, ); @@ -3829,11 +4488,11 @@ mod tests { } #[test] - fn turn_loop_rpc_handler_waits_for_approval_and_applies_after_approve() { + fn turn_loop_rpc_handler_waits_for_shell_approval_and_continues_after_approve() { let workspace = TestWorkspace::new("rpc"); workspace.write("README.md", "old\n"); let (input, output, join) = - spawn_interactive_rpc_loop(AgentTurnLoopRpcHandler::new(patch_provider_factory)); + spawn_interactive_rpc_loop(AgentTurnLoopRpcHandler::new(shell_provider_factory)); send_rpc_line( &input, json!({ @@ -3851,16 +4510,16 @@ mod tests { "method": "agent.sendTurn", "params": { "runId": "run_rpc_approval", - "message": "Update README", + "message": "Run an approved command", "mode": "edit" } }), ); output.wait_for_line( |line| { - line["method"] == "agent.event" - && line["params"]["type"] == "tool.approvalRequired" - && line["params"]["runId"] == "run_rpc_approval" + line_has_agent_event(line, |event| { + event["type"] == "tool.approvalRequired" && event["runId"] == "run_rpc_approval" + }) }, RPC_TEST_TIMEOUT, ); @@ -3878,9 +4537,9 @@ mod tests { ); output.wait_for_line( |line| { - line["method"] == "agent.event" - && line["params"]["type"] == "run.completed" - && line["params"]["runId"] == "run_rpc_approval" + line_has_agent_event(line, |event| { + event["type"] == "run.completed" && event["runId"] == "run_rpc_approval" + }) }, RPC_TEST_TIMEOUT, ); @@ -3889,26 +4548,32 @@ mod tests { .expect("request loop thread should not panic") .expect("real turn loop handler should complete after approval"); - assert_eq!(workspace.read("README.md"), "new\n"); + assert_eq!(workspace.read("README.md"), "old\n"); let lines = output.lines(); + let events = agent_event_values(&lines); let approval_required_index = line_index(&lines, |line| { - line["method"] == "agent.event" && line["params"]["type"] == "tool.approvalRequired" + line_has_agent_event(line, |event| event["type"] == "tool.approvalRequired") }); let approve_response_index = line_index(&lines, |line| line["id"] == "approve_1"); let approval_resolved_index = line_index(&lines, |line| { - line["method"] == "agent.event" - && line["params"]["type"] == "tool.approvalResolved" - && line["params"]["payload"]["decision"] == "approved" + line_has_agent_event(line, |event| { + event["type"] == "tool.approvalResolved" + && event["payload"]["decision"] == "approved" + }) }); assert!(approval_required_index < approve_response_index); assert!(approve_response_index < approval_resolved_index); - let approval_payload = &lines[approval_required_index]["params"]["payload"]; + let approval_payload = &events + .iter() + .find(|event| event["type"] == "tool.approvalRequired") + .expect("approval required event should exist")["payload"]; assert_eq!(approval_payload["approvalId"], "approval_1_1"); - assert_eq!(approval_payload["toolCallId"], "call_patch"); - assert_eq!(approval_payload["toolName"], "apply_patch"); - assert_eq!(approval_payload["risk"], "write"); - assert_eq!(approval_payload["paths"], json!(["README.md"])); + assert_eq!(approval_payload["toolCallId"], "call_shell"); + assert_eq!(approval_payload["toolName"], "shell"); + assert_eq!(approval_payload["risk"], "exec"); + assert_eq!(approval_payload["command"], "echo rpc-shell-approved"); + assert_eq!(approval_payload["cwd"], "."); assert_eq!(approval_payload["persistable"], true); assert_eq!(lines[1]["id"], "turn_1"); assert_eq!(lines[1]["result"]["accepted"], true); @@ -3918,15 +4583,13 @@ mod tests { lines[approve_response_index]["result"]["persist"], "session" ); - assert!(lines.iter().any(|line| { - line["method"] == "agent.event" - && line["params"]["type"] == "run.completed" - && line["params"]["payload"]["changedFiles"] == json!(["README.md"]) + assert!(events.iter().any(|event| { + event["type"] == "run.completed" && event["payload"]["changedFiles"] == json!([]) })); } #[test] - fn turn_loop_rpc_handler_rejects_pending_approval_without_running_tool() { + fn turn_loop_rpc_handler_applies_workspace_patch_without_approval() { let workspace = TestWorkspace::new("rpc"); workspace.write("README.md", "old\n"); let (input, output, join) = @@ -3947,7 +4610,7 @@ mod tests { "id": "turn_1", "method": "agent.sendTurn", "params": { - "runId": "run_rpc_rejected_approval", + "runId": "run_rpc_patch_without_approval", "message": "Update README", "mode": "edit" } @@ -3955,9 +4618,66 @@ mod tests { ); output.wait_for_line( |line| { - line["method"] == "agent.event" - && line["params"]["type"] == "tool.approvalRequired" - && line["params"]["runId"] == "run_rpc_rejected_approval" + line_has_agent_event(line, |event| { + event["type"] == "run.completed" + && event["runId"] == "run_rpc_patch_without_approval" + }) + }, + RPC_TEST_TIMEOUT, + ); + drop(input); + join.join() + .expect("request loop thread should not panic") + .expect("real turn loop handler should complete"); + + assert_eq!(workspace.read("README.md"), "new\n"); + let events = agent_event_values(&output.lines()); + assert!( + !events + .iter() + .any(|event| event["type"] == "tool.approvalRequired"), + "workspace apply_patch should not pause for approval" + ); + assert!(events.iter().any(|event| { + event["type"] == "run.completed" + && event["payload"]["changedFiles"] == json!(["README.md"]) + })); + } + + #[test] + fn turn_loop_rpc_handler_rejects_pending_approval_without_running_tool() { + let workspace = TestWorkspace::new("rpc"); + workspace.write("README.md", "old\n"); + let (input, output, join) = + spawn_interactive_rpc_loop(AgentTurnLoopRpcHandler::new(shell_provider_factory)); + send_rpc_line( + &input, + json!({ + "jsonrpc": "2.0", + "id": "init_1", + "method": "agent.initialize", + "params": initialize_params_for(workspace.path_str()) + }), + ); + send_rpc_line( + &input, + json!({ + "jsonrpc": "2.0", + "id": "turn_1", + "method": "agent.sendTurn", + "params": { + "runId": "run_rpc_rejected_approval", + "message": "Run a command", + "mode": "edit" + } + }), + ); + output.wait_for_line( + |line| { + line_has_agent_event(line, |event| { + event["type"] == "tool.approvalRequired" + && event["runId"] == "run_rpc_rejected_approval" + }) }, RPC_TEST_TIMEOUT, ); @@ -3975,9 +4695,9 @@ mod tests { ); output.wait_for_line( |line| { - line["method"] == "agent.event" - && line["params"]["type"] == "run.failed" - && line["params"]["runId"] == "run_rpc_rejected_approval" + line_has_agent_event(line, |event| { + event["type"] == "run.failed" && event["runId"] == "run_rpc_rejected_approval" + }) }, RPC_TEST_TIMEOUT, ); @@ -3988,20 +4708,15 @@ mod tests { assert_eq!(workspace.read("README.md"), "old\n"); let lines = output.lines(); + let events = agent_event_values(&lines); let reject_response_index = line_index(&lines, |line| line["id"] == "reject_1"); assert_eq!(lines[reject_response_index]["result"]["state"], "rejected"); assert_eq!(lines[reject_response_index]["result"]["reason"], "not now"); - assert!(lines.iter().any(|line| { - line["method"] == "agent.event" - && line["params"]["type"] == "tool.approvalResolved" - && line["params"]["payload"]["decision"] == "rejected" - })); - assert!(lines.iter().any(|line| { - line["method"] == "agent.event" && line["params"]["type"] == "run.failed" - })); - assert!(!lines.iter().any(|line| { - line["method"] == "agent.event" && line["params"]["type"] == "tool.started" + assert!(events.iter().any(|event| { + event["type"] == "tool.approvalResolved" && event["payload"]["decision"] == "rejected" })); + assert!(events.iter().any(|event| event["type"] == "run.failed")); + assert!(!events.iter().any(|event| event["type"] == "tool.started")); } #[test] @@ -4009,7 +4724,7 @@ mod tests { let workspace = TestWorkspace::new("rpc"); workspace.write("README.md", "old\n"); let (input, output, join) = - spawn_interactive_rpc_loop(AgentTurnLoopRpcHandler::new(patch_provider_factory)); + spawn_interactive_rpc_loop(AgentTurnLoopRpcHandler::new(shell_provider_factory)); send_rpc_line( &input, json!({ @@ -4027,16 +4742,17 @@ mod tests { "method": "agent.sendTurn", "params": { "runId": "run_rpc_canceled_approval", - "message": "Update README", + "message": "Run a command", "mode": "edit" } }), ); output.wait_for_line( |line| { - line["method"] == "agent.event" - && line["params"]["type"] == "tool.approvalRequired" - && line["params"]["runId"] == "run_rpc_canceled_approval" + line_has_agent_event(line, |event| { + event["type"] == "tool.approvalRequired" + && event["runId"] == "run_rpc_canceled_approval" + }) }, RPC_TEST_TIMEOUT, ); @@ -4054,9 +4770,9 @@ mod tests { ); output.wait_for_line( |line| { - line["method"] == "agent.event" - && line["params"]["type"] == "run.canceled" - && line["params"]["runId"] == "run_rpc_canceled_approval" + line_has_agent_event(line, |event| { + event["type"] == "run.canceled" && event["runId"] == "run_rpc_canceled_approval" + }) }, RPC_TEST_TIMEOUT, ); @@ -4133,7 +4849,7 @@ mod tests { let workspace = TestWorkspace::new("rpc"); workspace.write("README.md", "old\n"); let (input, output, join) = - spawn_interactive_rpc_loop(AgentTurnLoopRpcHandler::new(patch_provider_factory)); + spawn_interactive_rpc_loop(AgentTurnLoopRpcHandler::new(shell_provider_factory)); send_rpc_line( &input, json!({ @@ -4151,16 +4867,17 @@ mod tests { "method": "agent.sendTurn", "params": { "runId": "run_rpc_active_disconnect", - "message": "Update README and wait for approval", + "message": "Run a command and wait for approval", "mode": "edit" } }), ); output.wait_for_line( |line| { - line["method"] == "agent.event" - && line["params"]["type"] == "tool.approvalRequired" - && line["params"]["runId"] == "run_rpc_active_disconnect" + line_has_agent_event(line, |event| { + event["type"] == "tool.approvalRequired" + && event["runId"] == "run_rpc_active_disconnect" + }) }, RPC_TEST_TIMEOUT, ); @@ -4219,7 +4936,7 @@ mod tests { fn turn_loop_rpc_handler_expires_pending_approval_without_running_tool() { let workspace = TestWorkspace::new("rpc"); workspace.write("README.md", "old\n"); - let mut handler = AgentTurnLoopRpcHandler::new(patch_provider_factory) + let mut handler = AgentTurnLoopRpcHandler::new(shell_provider_factory) .with_approval_timeout(Duration::from_millis(20)); handler .initialize( @@ -4231,9 +4948,10 @@ mod tests { let send_output = handler .send_turn(SendTurnParams { run_id: Some("run_rpc_expired_approval".to_owned()), - message: "Update README".to_owned(), + message: "Run a command".to_owned(), mode: super::RpcRunMode::Edit, attachments: Vec::new(), + supersedes: None, }) .expect("sendTurn should pause for approval"); assert!(send_output.events.iter().any(|event| { @@ -4302,6 +5020,7 @@ mod tests { range: None, text: None, }], + supersedes: None, }) .expect("file attachments should be accepted"); let context_built = send_output @@ -4628,6 +5347,20 @@ mod tests { events } + fn line_has_agent_event(line: &Value, predicate: impl Fn(&Value) -> bool) -> bool { + if line["method"] == "agent.event" { + return predicate(&line["params"]); + } + + if line["method"] == "agent.eventBatch" + && let Some(events) = line["params"]["events"].as_array() + { + return events.iter().any(predicate); + } + + false + } + fn line_index(lines: &[Value], predicate: impl Fn(&Value) -> bool) -> usize { lines .iter() @@ -4772,8 +5505,11 @@ mod tests { approvals: Vec, rejections: Vec, cancellations: Vec, + steers: Vec, resumes: Vec, + load_run_events: Vec, list_runs: Vec, + delete_runs: Vec, fim_previews: Vec, } @@ -4846,6 +5582,18 @@ mod tests { })) } + fn steer( + &mut self, + params: SteerParams, + ) -> Result, AgentRpcHandlerError> { + self.steers.push(params.clone()); + Ok(AgentRpcHandlerOutput::new(SteerResult { + run_id: params.run_id, + steer_id: "steer_rpc".to_owned(), + accepted: true, + })) + } + fn resume( &mut self, params: ResumeParams, @@ -4866,6 +5614,41 @@ mod tests { )])) } + fn load_run_events( + &mut self, + params: LoadRunEventsParams, + ) -> Result, AgentRpcHandlerError> { + let run_id = params.run_id.clone(); + self.load_run_events.push(params); + let events = [ + run_log_event( + 1, + "turn.started", + &run_id, + Some("turn_rpc"), + json!({ "userTask": "hello" }), + ), + run_log_event( + 2, + "assistant.delta", + &run_id, + Some("turn_rpc"), + json!({ "text": "hello" }), + ), + ]; + Ok(AgentRpcHandlerOutput::new(LoadRunEventsResult { + run_id, + events: events + .iter() + .map(super::run_log_event_to_envelope) + .collect::, _>>() + .expect("fixture events should serialize"), + first_seq: Some(1), + last_seq: Some(2), + has_more_before: false, + })) + } + fn list_runs( &mut self, params: ListRunsParams, @@ -4881,7 +5664,7 @@ mod tests { completed_at: Some("1970-01-01T00:00:01.000Z".to_owned()), last_seq: 3, event_count: 3, - mode: Some("ask".to_owned()), + mode: Some(super::RpcRunMode::Ask), summary: Some("Done".to_owned()), changed_files: Vec::new(), verification_status: Some("skipped".to_owned()), @@ -4889,6 +5672,18 @@ mod tests { })) } + fn delete_run( + &mut self, + params: DeleteRunParams, + ) -> Result, AgentRpcHandlerError> { + let run_id = params.run_id.clone(); + self.delete_runs.push(params); + Ok(AgentRpcHandlerOutput::new(DeleteRunResult { + run_id, + deleted: true, + })) + } + fn preview_fim( &mut self, params: FimPreviewParams, @@ -4927,6 +5722,28 @@ mod tests { ])) } + fn shell_provider_factory( + _params: &SendTurnParams, + ) -> Result { + Ok(ScriptedProvider::new(vec![ + TurnProviderResponse::tool_calls( + None, + Some("I should run a shell command.".to_owned()), + vec![ChatToolCall::function( + "call_shell", + "shell", + json!({ + "command": "echo rpc-shell-approved", + "cwd": ".", + "timeoutMs": 10_000, + }) + .to_string(), + )], + ), + TurnProviderResponse::final_text("Command approved and completed."), + ])) + } + fn patch_provider_factory( _params: &SendTurnParams, ) -> Result { diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 008b0a9..259a2fc 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -15,8 +15,8 @@ use prole_coder_agent_core::{ context::ContextBuildError, provider::deepseek_api::{ ChatCompletionStream, ChatFunctionDefinition, ChatTool, ChatToolCall, - ChatToolCallAccumulator, DeepSeekApiAdapter, DeepSeekApiConfig, DeepSeekModelId, - FimCompletionRequest, FinishReason, StreamEvent, ThinkingConfig, Usage, + ChatToolCallAccumulator, DeepSeekApiAdapter, DeepSeekApiConfig, DeepSeekApiError, + DeepSeekModelId, FimCompletionRequest, FinishReason, StreamEvent, ThinkingConfig, Usage, }, reasoning::ReasoningContentMode, run_log::{RunLog, RunLogError, RunLogStore}, @@ -46,9 +46,13 @@ use prole_coder_agent_rpc::{ use serde_json::{Value, json}; use thiserror::Error; -const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 1_024; +const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 65_536; const DEFAULT_VERIFY_TIMEOUT_MS: u64 = 120_000; const CLI_RUN_JSON_RPC_ID: &str = "cli.run"; +const DEEPSEEK_PROVIDER_NAME: &str = "deepseek"; +const MISSING_API_KEY_CONFIGURATION_ERROR: &str = "missingApiKey"; +const CONFIGURE_DEEPSEEK_API_KEY_ACTION_KIND: &str = "configureDeepSeekApiKey"; +const CONFIGURE_DEEPSEEK_API_KEY_ACTION_LABEL: &str = "Configure API Key"; pub fn run_cli(args: I, stdout: &mut W, stderr: &mut E) -> Result<(), CliError> where @@ -827,11 +831,12 @@ fn turn_loop_error_json_rpc_code(error: &AgentTurnLoopError) -> i64 { ) => RPC_INTERNAL_INVARIANT, AgentTurnLoopError::Reasoning(_) | AgentTurnLoopError::Provider(_) + | AgentTurnLoopError::ProviderStreamInterrupted { .. } + | AgentTurnLoopError::ProviderIdleTimeout { .. } | AgentTurnLoopError::ProviderStreamEndedWithoutCompletion | AgentTurnLoopError::ProviderCompletedMultipleTimes | AgentTurnLoopError::ProviderEventAfterCompletion - | AgentTurnLoopError::MissingAssistantReasoningContent - | AgentTurnLoopError::MaxModelTurnsExceeded { .. } => RPC_PROVIDER_ERROR, + | AgentTurnLoopError::MissingAssistantReasoningContent => RPC_PROVIDER_ERROR, AgentTurnLoopError::RunLog(error) => run_log_error_json_rpc_code(error), AgentTurnLoopError::EventSink(_) | AgentTurnLoopError::ApprovalPolicy(_) => { RPC_INTERNAL_INVARIANT @@ -841,7 +846,8 @@ fn turn_loop_error_json_rpc_code(error: &AgentTurnLoopError) -> i64 { | AgentTurnLoopError::UnsupportedTool { .. } | AgentTurnLoopError::Serialization(_) => RPC_TOOL_EXECUTION_FAILED, AgentTurnLoopError::InvalidToolArguments { .. } - | AgentTurnLoopError::InvalidToolArgumentSchema { .. } => RPC_INVALID_TOOL_ARGUMENTS, + | AgentTurnLoopError::InvalidToolArgumentSchema { .. } + | AgentTurnLoopError::InvalidToolPayloadReference { .. } => RPC_INVALID_TOOL_ARGUMENTS, AgentTurnLoopError::Canceled { .. } | AgentTurnLoopError::ApprovalCanceled { .. } | AgentTurnLoopError::ApprovalExpired { .. } => RPC_RUN_CANCELED, @@ -986,7 +992,7 @@ impl RpcTurnProviderFactory for CliRpcProviderFactory { self.max_output_tokens, self.thinking, ) - .map_err(|error| AgentRpcHandlerError::new(RPC_INTERNAL_INVARIANT, error.to_string())) + .map_err(cli_provider_rpc_error) } fn preview_fim( @@ -1015,11 +1021,33 @@ fn fixture_fim_preview_text(params: &FimPreviewParams) -> String { } } +fn cli_provider_rpc_error(error: CliError) -> AgentRpcHandlerError { + match error { + CliError::DeepSeek(error) => deepseek_configuration_rpc_error(error), + other => AgentRpcHandlerError::new(RPC_INTERNAL_INVARIANT, other.to_string()), + } +} + +fn deepseek_configuration_rpc_error(error: DeepSeekApiError) -> AgentRpcHandlerError { + let message = format!("DeepSeek provider configuration failed: {error}"); + let rpc_error = AgentRpcHandlerError::new(RPC_PROVIDER_ERROR, message); + match error { + DeepSeekApiError::MissingApiKey => rpc_error.with_data(json!({ + "provider": DEEPSEEK_PROVIDER_NAME, + "configurationError": MISSING_API_KEY_CONFIGURATION_ERROR, + "recoverableAction": { + "kind": CONFIGURE_DEEPSEEK_API_KEY_ACTION_KIND, + "label": CONFIGURE_DEEPSEEK_API_KEY_ACTION_LABEL, + }, + })), + _ => rpc_error, + } +} + fn deepseek_fim_preview( params: &FimPreviewParams, ) -> Result { - let config = DeepSeekApiConfig::from_env_for_fim() - .map_err(|error| AgentRpcHandlerError::new(RPC_PROVIDER_ERROR, error.to_string()))?; + let config = DeepSeekApiConfig::from_env_for_fim().map_err(deepseek_configuration_rpc_error)?; let adapter = DeepSeekApiAdapter::new(config) .map_err(|error| AgentRpcHandlerError::new(RPC_PROVIDER_ERROR, error.to_string()))?; let model = params @@ -1114,7 +1142,7 @@ impl TurnProvider for DeepSeekTurnProvider { .adapter .create_chat_completion_stream(chat_request) .await - .map_err(|error| TurnProviderError::new(error.to_string()))?; + .map_err(turn_provider_error_from_deepseek)?; Ok(deepseek_chat_stream_to_turn_provider_stream( stream, @@ -1142,7 +1170,7 @@ fn deepseek_chat_stream_to_turn_provider_stream( if cancellation_token.is_canceled() { Err(TurnProviderError::new(cancellation_token.cancellation_reason()))?; } - match event.map_err(|error| TurnProviderError::new(error.to_string()))? { + match event.map_err(turn_provider_error_from_deepseek)? { StreamEvent::Chunk(chunk) => { chunk_count += 1; model = Some(chunk.model.clone()); @@ -1263,6 +1291,15 @@ fn turn_provider_usage_from_deepseek(usage: Usage) -> TurnProviderUsage { } } +fn turn_provider_error_from_deepseek(error: DeepSeekApiError) -> TurnProviderError { + let message = error.to_string(); + if error.is_transient_transport_error() { + TurnProviderError::transient(message) + } else { + TurnProviderError::new(message) + } +} + fn executable_chat_tools() -> Result, TurnProviderError> { BUILTIN_TOOLS .iter() @@ -1288,6 +1325,7 @@ enum FixtureKind { Final, Readme, Patch, + Shell, } #[derive(Debug)] @@ -1339,6 +1377,25 @@ impl FixtureProvider { "Fixture provider applied CLI_SMOKE.txt and completed the run.", ), ], + FixtureKind::Shell => vec![ + TurnProviderResponse::tool_calls( + None, + Some("Run the deterministic CLI smoke command.".to_owned()), + vec![ChatToolCall::function( + "call_shell", + "shell", + json!({ + "command": "echo cli-shell-approved", + "cwd": ".", + "timeoutMs": 10_000, + }) + .to_string(), + )], + ), + TurnProviderResponse::final_text( + "Fixture provider ran the CLI shell command and completed the run.", + ), + ], }; Self { @@ -1448,6 +1505,7 @@ fn parse_fixture(value: &str) -> Result { "final" => Ok(FixtureKind::Final), "readme" => Ok(FixtureKind::Readme), "patch" => Ok(FixtureKind::Patch), + "shell" => Ok(FixtureKind::Shell), _ => Err(CliError::Usage(format!("unsupported fixture `{value}`"))), } } @@ -1503,7 +1561,7 @@ fn run_help_text() -> String { "Run options:", " --workspace Workspace root. Defaults to current directory.", " --provider ", - " --fixture ", + " --fixture ", " --mode ", " --run-id ", " --turn-id ", @@ -1511,7 +1569,7 @@ fn run_help_text() -> String { " --verify Run an explicit verification command after success.", " --json Emit agent.event JSON-RPC notifications.", " --max-input-tokens ", - " --max-model-turns ", + " --max-model-turns Provider request window before continuation approval (default 50).", " --max-output-tokens ", " --thinking ", ] @@ -1522,9 +1580,9 @@ fn rpc_help_text() -> String { [ "RPC options:", " --provider ", - " --fixture ", + " --fixture ", " --max-input-tokens ", - " --max-model-turns ", + " --max-model-turns Provider request window before continuation approval (default 50).", " --max-output-tokens ", " --thinking ", ] @@ -1539,15 +1597,15 @@ mod tests { provider::deepseek_api::{ ChatCompletionChunk, ChatCompletionChunkChoice, ChatCompletionDelta, ChatFunctionCallDelta, ChatToolCallDelta, ChatToolType, CompletionTokensDetails, - FinishReason, StreamEvent, Usage, + DeepSeekApiError, FinishReason, StreamEvent, Usage, }, run_log::{REDACTED_VALUE, RunLogStore}, test_helpers::TestWorkspace, - turn_loop::TurnProviderEvent, + turn_loop::{AgentTurnLoopError, TurnProviderEvent}, }; use prole_coder_agent_rpc::{ - FimPreviewParams, PROTOCOL_VERSION, RPC_APPROVAL_DENIED, RPC_TOOL_EXECUTION_FAILED, - RpcTurnProviderFactory, + FimPreviewParams, PROTOCOL_VERSION, RPC_APPROVAL_DENIED, RPC_INVALID_TOOL_ARGUMENTS, + RPC_PROVIDER_ERROR, RPC_TOOL_EXECUTION_FAILED, RpcTurnProviderFactory, }; use serde_json::{Value, json}; use std::{ @@ -1559,7 +1617,8 @@ mod tests { use super::{ CliCommand, CliRpcProviderFactory, FixtureKind, ProviderKind, RunCommand, ThinkingKind, - deepseek_chat_stream_to_turn_provider_stream, run_cli, run_cli_with_input, + deepseek_chat_stream_to_turn_provider_stream, deepseek_configuration_rpc_error, run_cli, + run_cli_with_input, turn_loop_error_json_rpc_code, turn_provider_error_from_deepseek, }; #[test] @@ -1738,6 +1797,12 @@ mod tests { let store = RunLogStore::new(workspace.path()).expect("run log store should open"); let events = store.load_run("run_cli_patch").expect("events should load"); assert_eq!(notifications.len(), events.len()); + assert!( + !events + .iter() + .any(|event| event.event_type == "tool.approvalRequired"), + "fixture patch should not require CLI approval" + ); let notification_seqs = notifications .iter() .map(|value| value["params"]["seq"].as_u64()) @@ -1750,9 +1815,54 @@ mod tests { } #[test] - fn fixture_patch_run_json_rejection_emits_json_rpc_error() { + fn fixture_patch_run_applies_without_approval_prompt() { let workspace = TestWorkspace::new("cli"); workspace.write("CLI_SMOKE.txt", "old\n"); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + run_cli( + [ + "prole", + "run", + "--provider", + "fixture", + "--fixture", + "patch", + "--workspace", + workspace.path_str(), + "--run-id", + "run_cli_patch_no_prompt", + "--turn-id", + "turn_cli_patch_no_prompt", + "Patch smoke file", + ], + &mut stdout, + &mut stderr, + ) + .expect("fixture patch run should succeed without approval"); + + assert_eq!(workspace.read("CLI_SMOKE.txt"), "new\n"); + assert!(stderr.is_empty()); + let store = RunLogStore::new(workspace.path()).expect("run log store should open"); + let events = store + .load_run("run_cli_patch_no_prompt") + .expect("events should load"); + assert!( + !events + .iter() + .any(|event| event.event_type == "tool.approvalRequired"), + "fixture patch should not require CLI approval" + ); + assert!(events.iter().any(|event| { + event.event_type == "run.completed" + && event.payload["changedFiles"] == json!(["CLI_SMOKE.txt"]) + })); + } + + #[test] + fn fixture_shell_run_json_rejection_emits_json_rpc_error() { + let workspace = TestWorkspace::new("cli"); let mut stdin = std::io::Cursor::new(b"n\n".to_vec()); let mut stdout = Vec::new(); let mut stderr = Vec::new(); @@ -1764,15 +1874,15 @@ mod tests { "--provider", "fixture", "--fixture", - "patch", + "shell", "--json", "--workspace", workspace.path_str(), "--run-id", - "run_cli_json_reject", + "run_cli_shell_json_reject", "--turn-id", - "turn_cli_json_reject", - "Patch smoke file", + "turn_cli_shell_json_reject", + "Run fixture shell command", ], &mut stdin, &mut stdout, @@ -1781,7 +1891,6 @@ mod tests { .expect_err("rejected json run should fail"); assert!(error.is_reported()); - assert_eq!(workspace.read("CLI_SMOKE.txt"), "old\n"); let stderr = String::from_utf8(stderr).expect("stderr should be UTF-8"); assert!(stderr.contains("Approval required")); assert!(!stderr.contains("status: failed")); @@ -1810,11 +1919,11 @@ mod tests { assert_eq!(error_response["error"]["data"]["kind"], "turn"); assert_eq!( error_response["error"]["data"]["runId"], - "run_cli_json_reject" + "run_cli_shell_json_reject" ); assert_eq!( error_response["error"]["data"]["turnId"], - "turn_cli_json_reject" + "turn_cli_shell_json_reject" ); } @@ -1880,9 +1989,8 @@ mod tests { } #[test] - fn fixture_patch_run_prompts_for_approval_and_applies_when_approved() { + fn fixture_shell_run_prompts_for_approval_and_continues_when_approved() { let workspace = TestWorkspace::new("cli"); - workspace.write("CLI_SMOKE.txt", "old\n"); let mut stdin = std::io::Cursor::new(b"y\n".to_vec()); let mut stdout = Vec::new(); let mut stderr = Vec::new(); @@ -1894,14 +2002,14 @@ mod tests { "--provider", "fixture", "--fixture", - "patch", + "shell", "--workspace", workspace.path_str(), "--run-id", - "run_cli_prompt_approve", + "run_cli_shell_prompt_approve", "--turn-id", "turn_cli_prompt", - "Patch smoke file", + "Run fixture shell command", ], &mut stdin, &mut stdout, @@ -1909,23 +2017,26 @@ mod tests { ) .expect("approved prompt run should succeed"); - assert_eq!(workspace.read("CLI_SMOKE.txt"), "new\n"); let stderr = String::from_utf8(stderr).expect("stderr should be UTF-8"); assert!(stderr.contains("Approval required")); assert!(stderr.contains("Approve this tool call?")); let store = RunLogStore::new(workspace.path()).expect("run log store should open"); let events = store - .load_run("run_cli_prompt_approve") + .load_run("run_cli_shell_prompt_approve") .expect("events should load"); assert!(events.iter().any(|event| { event.event_type == "tool.approvalResolved" && event.payload["decision"] == "approved" })); + assert!( + events + .iter() + .any(|event| event.event_type == "tool.started") + ); } #[test] - fn fixture_patch_run_prompts_for_approval_and_rejects() { + fn fixture_shell_run_prompts_for_approval_and_rejects() { let workspace = TestWorkspace::new("cli"); - workspace.write("CLI_SMOKE.txt", "old\n"); let mut stdin = std::io::Cursor::new(b"n\n".to_vec()); let mut stdout = Vec::new(); let mut stderr = Vec::new(); @@ -1937,14 +2048,14 @@ mod tests { "--provider", "fixture", "--fixture", - "patch", + "shell", "--workspace", workspace.path_str(), "--run-id", - "run_cli_prompt_reject", + "run_cli_shell_prompt_reject", "--turn-id", "turn_cli_prompt", - "Patch smoke file", + "Run fixture shell command", ], &mut stdin, &mut stdout, @@ -1953,12 +2064,11 @@ mod tests { .expect_err("rejected prompt run should fail"); assert!(error.to_string().contains("rejected by user")); - assert_eq!(workspace.read("CLI_SMOKE.txt"), "old\n"); let stderr = String::from_utf8(stderr).expect("stderr should be UTF-8"); assert!(stderr.contains("status: failed")); let store = RunLogStore::new(workspace.path()).expect("run log store should open"); let events = store - .load_run("run_cli_prompt_reject") + .load_run("run_cli_shell_prompt_reject") .expect("events should load"); assert!(events.iter().any(|event| { event.event_type == "tool.approvalResolved" @@ -2012,9 +2122,9 @@ mod tests { ); output.wait_for_line( |line| { - line["method"] == "agent.event" - && line["params"]["type"] == "run.completed" - && line["params"]["runId"] == "run_cli_rpc" + line_has_agent_event(line, |event| { + event["type"] == "run.completed" && event["runId"] == "run_cli_rpc" + }) }, Duration::from_secs(30), ); @@ -2027,10 +2137,11 @@ mod tests { assert_eq!(lines[1]["id"], "turn_1"); assert_eq!(lines[1]["result"]["accepted"], true); assert!(lines.iter().any(|line| { - line["method"] == "agent.event" - && line["params"]["type"] == "run.completed" - && line["params"]["payload"]["summary"] - == "Fixture provider completed without tool calls." + line_has_agent_event(line, |event| { + event["type"] == "run.completed" + && event["payload"]["summary"] + == "Fixture provider completed without tool calls." + }) })); let store = RunLogStore::new(workspace.path()).expect("run log store should open"); @@ -2066,6 +2177,45 @@ mod tests { assert!(result.text.contains("prole fixture")); } + #[test] + fn deepseek_missing_api_key_rpc_error_exposes_recoverable_action() { + let error = deepseek_configuration_rpc_error(DeepSeekApiError::MissingApiKey); + + assert_eq!(error.code, RPC_PROVIDER_ERROR); + assert!(error.message.contains("DEEPSEEK_API_KEY is required")); + let data = error + .data + .expect("missing key should include structured data"); + assert_eq!(data["provider"], "deepseek"); + assert_eq!(data["configurationError"], "missingApiKey"); + assert_eq!(data["recoverableAction"]["kind"], "configureDeepSeekApiKey"); + assert_eq!(data["recoverableAction"]["label"], "Configure API Key"); + } + + #[test] + fn deepseek_transient_stream_error_maps_to_transient_provider_error() { + let error = turn_provider_error_from_deepseek(DeepSeekApiError::IncompleteStreamEvent { + buffered_bytes: 42, + }); + + assert!(error.is_transient()); + assert!(error.to_string().contains("incomplete SSE event")); + } + + #[test] + fn invalid_tool_payload_ref_maps_to_invalid_tool_arguments_rpc_code() { + let error = AgentTurnLoopError::InvalidToolPayloadReference { + tool_call_id: "call_1".to_owned(), + name: "apply_patch".to_owned(), + detail: "payloadRef.sha256 does not match payload file".to_owned(), + }; + + assert_eq!( + turn_loop_error_json_rpc_code(&error), + RPC_INVALID_TOOL_ARGUMENTS + ); + } + type InteractiveCliRpc = ( mpsc::Sender, SharedOutput, @@ -2203,6 +2353,20 @@ mod tests { } } + fn line_has_agent_event(line: &Value, predicate: impl Fn(&Value) -> bool) -> bool { + if line["method"] == "agent.event" { + return predicate(&line["params"]); + } + + if line["method"] == "agent.eventBatch" + && let Some(events) = line["params"]["events"].as_array() + { + return events.iter().any(predicate); + } + + false + } + #[test] fn deepseek_stream_wrapper_aggregates_deltas() { let stream = Box::pin(stream::iter([ diff --git a/docs/approval-model.md b/docs/approval-model.md index 500a924..8c0305c 100644 --- a/docs/approval-model.md +++ b/docs/approval-model.md @@ -1,6 +1,6 @@ # 审批模型 -状态:`0.1.0` 设计已确定,基础类型、Turn Loop 审批编排、RPC pending approval 等待队列、审批超时、取消语义、shell 动态风险升级和 `apply_patch` hunk 级审批已实现。 +状态:`0.1.0` 设计已确定,基础类型、Turn Loop 审批编排、RPC pending approval 等待队列、审批超时、取消语义、shell 动态风险升级、workspace-scoped 只读 shell 白名单和 `apply_patch` hunk 级审批已实现。 审批模型用于保护工作区,避免未经审阅的写入、命令执行、网络访问和破坏性操作。审批是 Agent Core 的核心安全边界,不由前端单独实现。 @@ -9,7 +9,7 @@ | 等级 | 英文标识 | 示例 | 默认策略 | | --- | --- | --- | --- | | 读取 | `read` | read file、search、git status | 自动允许 | -| 写入 | `write` | apply patch、格式化已跟踪文件 | 需要审批 | +| 写入 | `write` | apply patch、格式化已跟踪文件 | 工具级策略决定;受限 workspace 小规模代码 `apply_patch` 默认免审批,超过 5 个 `expectedFiles` 或修改 workspace policy 文件仍需审批 | | 执行 | `exec` | 测试、构建、lint 命令 | 需要审批 | | 网络 | `network` | 下载依赖、远程 API、远程 git | 需要审批 | | 破坏性 | `destructive` | 删除、reset、清理未跟踪文件、强制 push | 总是审批 | @@ -24,7 +24,9 @@ network -> required destructive -> always_required ``` -工具定义中的风险等级是最低风险。Agent Core 可以基于具体参数升级风险,但不得降级。 +风险等级的默认审批只作为基线。工具注册表可以对已受路径沙箱和 schema 约束的工具显式覆盖审批需求:`apply_patch` 仍报告 `write` 风险,但因为只能修改当前 workspace 内、`expectedFiles` 明确列出的文件,所以小规模代码 patch 默认 `approval=none`,不会弹出审批卡片;当 `expectedFiles` 超过 5 个文件,或修改 `.gitignore` / `.prole-coderignore` 这类 workspace policy 文件时,Turn Loop 会动态升级为 `required`。 + +工具定义中的风险等级是默认风险。Agent Core 可以基于具体参数升级风险;唯一允许的降级例外是 `shell` 的 workspace-scoped 只读白名单命令,这类命令必须没有管道、重定向、变量展开、子命令、绝对路径、父级路径或敏感 workspace 路径。 ## 审批要求 @@ -95,7 +97,7 @@ pending 协议 `0.1.0` 中: - `read` 不需要持久审批。 -- `write` 可以支持 session/workspace 持久化,但必须由审批请求显式标记 `persistable: true`。 +- `write` 可以支持 session/workspace 持久化,但必须由审批请求显式标记 `persistable: true`;默认免审批的小规模 workspace 代码 `apply_patch` 不产生持久化 approval key。 - `exec` 可以支持 session/workspace 持久化,但必须由审批请求显式标记 `persistable: true`,并由用户选择持久范围。 - `network` 不允许持久化。 - `destructive` 永远不允许持久化。 @@ -108,12 +110,12 @@ pending - TypeScript:`packages/protocol/src/index.ts`、`vscode/extension/src/approvalFlow.ts`、`vscode/extension/src/commands.ts`。 - JSON-RPC 事件:`docs/json-rpc-protocol.md` 中的 `tool.requested`、`tool.approvalRequired`、`tool.approvalResolved`、`agent.approve`、`agent.reject`、`agent.cancel`。 -当前 Rust 和 TypeScript 已定义风险等级、审批要求、持久化枚举和状态机转换规则。Agent Turn Loop 已能在工具执行前写入 `tool.approvalRequired`,根据审批策略等待批准、拒绝、取消、过期或持久批准复用,并写入 `tool.approvalResolved`。CLI 二进制已有 stdin/stderr prompt;`agent-rpc` request loop 已能分发 `agent.approve` / `agent.reject` / `agent.cancel`;`AgentTurnLoopRpcHandler` 已实现单 active run 的 pending approval 队列和 session/workspace 持久批准存储。Agent Core 已在 shell 工具审批前执行命令风险分类:依赖安装、网络访问、远程 git 和发布命令会升级到 `network`,删除、强制 push、git reset/clean 等会升级到 `destructive`,并在 `tool.requested` / `tool.approvalRequired` 中写入 `riskReasons`、`command`、`cwd` 和上一条 shell 输出摘要。VS Code 插件已有 modal approval adapter 并接入真实 RPC pending queue;`apply_patch` 审批会先打开 VS Code 原生 diff 预览,可选择 hunk 后通过 `agent.approve.hunks` 回传,RPC/Core 会校验并只应用已批准 hunks;TUI 已有可测试的 prompt 状态机。 +当前 Rust 和 TypeScript 已定义风险等级、审批要求、持久化枚举和状态机转换规则。Agent Turn Loop 已能在需要审批的工具执行前写入 `tool.approvalRequired`,根据审批策略等待批准、拒绝、取消、过期或持久批准复用,并写入 `tool.approvalResolved`。受限 workspace 小规模代码 `apply_patch` 默认免审批并直接执行;超过 5 个 `expectedFiles` 的 bulk patch 或 workspace policy 文件 patch 会动态要求审批,hunk metadata 仍保留给高风险 patch 或显式审批路径。CLI 二进制已有 stdin/stderr prompt;`agent-rpc` request loop 已能分发 `agent.approve` / `agent.reject` / `agent.cancel`;`AgentTurnLoopRpcHandler` 已实现单 active run 的 pending approval 队列和 session/workspace 持久批准存储。Agent Core 已在 shell 工具审批前执行命令风险分类:依赖安装、网络访问、远程 git 和发布命令会升级到 `network`,删除、强制 push、git reset/clean 等会升级到 `destructive`,并在 `tool.requested` / `tool.approvalRequired` 中写入 `riskReasons`、`command`、`cwd` 和上一条 shell 输出摘要;严格只读的 `rg`、`Get-Content`、`git diff/status/log/show` 等 workspace-scoped 简单命令会改为 `read` 并免审批。模型 provider request 窗口耗尽时也会通过 `model_turn_budget` 这个 schema-only approval 复用同一 pending queue,批准后继续下一段窗口而不是直接失败。VS Code 插件已有 legacy modal approval adapter 和默认 Sidebar 内联 approval card,并接入真实 RPC pending queue;需要审批的 patch 路径可选择 hunk 后通过 `agent.approve.hunks` 回传,RPC/Core 会校验并只应用已批准 hunks;Sidebar 还支持同一 run 内“本对话批准此命令”的前端级复用,按 `runId + cwd + command` 自动批准后续相同 shell 审批。TUI 已有可测试的 prompt 状态机。 ## 后续增强 - 扩展 RPC 审批队列到多 active run、跨进程恢复和前端断连后的自动取消;当前实现只支持单 active run 的内存等待队列。 -- 扩展 patch 的动态风险升级;`shell` 的动态风险升级已覆盖下载依赖、访问网络、删除文件、发布和远程 git 操作,`apply_patch` 已支持 hunk 级批准。 -- 扩展持久批准的可审计 metadata、清理入口和高级 UI 管理;`network` 和 `destructive` 仍不允许持久化,VS Code 主审批弹窗不重新暴露 session/workspace 选项。 +- 扩展 patch 的动态风险升级;`shell` 的动态风险升级已覆盖下载依赖、访问网络、删除文件、发布和远程 git 操作,只读白名单已覆盖 workspace 内简单读命令,`apply_patch` 已支持超过 5 个文件的 bulk patch、workspace policy 文件动态审批和 hunk 级批准。 +- 扩展持久批准的可审计 metadata、清理入口和高级 UI 管理;`network` 和 `destructive` 仍不允许持久化,VS Code 主审批 UI 不重新暴露 session/workspace 选项。 - 继续增强 TUI 的真实 RPC pending 队列接入;VS Code 已能消费 `tool.approvalRequired` 并发送 `agent.approve` / `agent.reject`。 -- 增加跨前端一致性测试,确保同一工具请求在 CLI、TUI 和 VS Code 中展示的风险、路径、命令、风险原因和持久化能力语义一致;VS Code 主审批弹窗保持简化的 Approve / Reject。 +- 增加跨前端一致性测试,确保同一工具请求在 CLI、TUI 和 VS Code 中展示的风险、路径、命令、风险原因和持久化能力语义一致;VS Code 主审批 UI 保持简化的 Approve / Reject。 diff --git a/docs/architecture.md b/docs/architecture.md index e74538b..de47b65 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,7 +57,7 @@ TypeScript workspace: - Rust workspace、TypeScript workspace、VS Code 插件骨架和共享协议包已建立。 - `agent-core` 已包含 provider adapter、流式解析、streaming tool call delta accumulator、`reasoning_content` 状态机、工具/审批基础类型、协作式取消 token、read/search/apply_patch/shell/git 基础执行层、基础 run log、基础 Context Builder、基础 Agent Turn Loop、async / streaming `TurnProvider` 边界和 `TurnEventSink` 实时事件出口。 -- `agent-rpc` 已实现 Run Log 事件到 `agent.event` JSON-RPC notification 的 stdio 桥接,`StdioEventBridge` 可直接作为 `TurnEventSink` 使用;同时已实现 `agent.initialize` / `agent.sendTurn` / `agent.approve` / `agent.reject` / `agent.cancel` / `agent.resume` / `agent.listRuns` 的双向 request loop、全双工 live event queue、真实 `AgentTurnLoopRpcHandler`、单 active run 的 RPC pending approval 等待队列和断连取消。 +- `agent-rpc` 已实现 Run Log 事件到 `agent.event` JSON-RPC notification 的 stdio 桥接,`StdioEventBridge` 可直接作为 `TurnEventSink` 使用;同时已实现 `agent.initialize` / `agent.sendTurn` / `agent.approve` / `agent.reject` / `agent.cancel` / `agent.resume` / `agent.listRuns` / `agent.deleteRun` 的双向 request loop、全双工 live event queue、真实 `AgentTurnLoopRpcHandler`、同一 run 多 turn 追加、单 active run 的 RPC pending approval 等待队列和断连取消。 - CLI 已实现 `run` 最小闭环,能直接调用 Agent Core、通过 DeepSeek streaming wrapper 驱动真实 provider,在 `--json` 模式下随着 run log 写入实时输出 JSON-RPC event,失败时输出 JSON-RPC error response,并支持 stdin/stderr 交互式审批;CLI `rpc` 子命令已能作为 stdio RPC 入口驱动真实 handler;VS Code 插件已接入 RPC server 启动监管、JSON-RPC request client、Sidebar Chat、事件渲染、真实审批回传、Native diff editor patch 预览、Run List / resume 和 Context Capsule 可视化;TUI 已有审批 prompt 状态机但仍未接入完整 ratatui 界面,优先级排在 VS Code 核心体验之后。 ## 后续增强 diff --git a/docs/cli.md b/docs/cli.md index 92d8f32..124febb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -15,20 +15,20 @@ prole rpc [options] - `--workspace `:workspace root,默认当前目录。 - `--provider `:provider 类型,默认 `deepseek`。 -- `--fixture `:fixture provider 的确定性脚本。 +- `--fixture `:fixture provider 的确定性脚本。 - `--mode `:Agent run mode。 - `--run-id ` / `--turn-id `:显式指定本地 run/turn id。 - `--auto-approve` / `-y`:允许需要审批的工具执行。默认在 CLI 二进制中交互式询问;如果 stdin 已关闭或不可读,则拒绝该审批。 - `--verify `:回合成功后运行显式验证命令。因为它执行 shell command,必须同时传 `--auto-approve`。 - `--json`:输出 newline-delimited JSON-RPC。成功执行中输出 `agent.event` notifications;失败时最后输出 JSON-RPC error response。 -- `--max-input-tokens `、`--max-model-turns `、`--max-output-tokens `:预算与轮次限制。 +- `--max-input-tokens `、`--max-model-turns `、`--max-output-tokens `:预算与轮次窗口。`--max-model-turns` 默认 50,表示每次继续审批前最多允许多少次 provider request;达到窗口后会通过审批继续,而不是直接 `E_MAX_MODEL_TURNS` 失败。DeepSeek 默认输出上限为 65536 tokens,可按需要显式覆盖。 - `--thinking `:控制 DeepSeek thinking mode,默认 `enabled`。 `rpc` 子命令使用同一套 provider 相关参数: - `--provider ` -- `--fixture ` -- `--max-input-tokens `、`--max-model-turns `、`--max-output-tokens ` +- `--fixture ` +- `--max-input-tokens `、`--max-model-turns `、`--max-output-tokens `;`--max-model-turns` 是继续审批前的 provider request 窗口,默认 50;DeepSeek 默认 `--max-output-tokens` 为 65536,避免真实项目修复时过早触发模型输出长度截断。 - `--thinking ` `prole rpc` 从 stdin 读取 newline-delimited JSON-RPC request,并把 response / `agent.event` notification 写到 stdout。它当前接入 `AgentTurnLoopRpcHandler`:`agent.sendTurn` 会创建 run log、启动后台 Turn Loop worker,并立即返回 accepted;后续事件由全双工 live event queue 持续输出。`agent.approve` / `agent.reject` 会唤醒 pending approval 队列并继续输出后续事件。显式取消、审批过期、EOF shutdown 取消和 writer failure 断连取消已实现。 @@ -51,7 +51,7 @@ CLI DeepSeek provider 会把 streaming chunk 聚合成 Turn Loop 需要的完整 - `git_status` - `git_diff` -`lsp_diagnostics` 和 `plan_update` 仍是 `schema_only`,不会暴露给 CLI 默认 provider。 +`lsp_diagnostics`、`plan_update` 和用于本地继续审批的 `model_turn_budget` 仍是 `schema_only`,不会暴露给 CLI 默认 provider。 ### `fixture` @@ -59,9 +59,10 @@ fixture provider 不联网,用于本地 smoke test 和 CI。它使用确定性 - `final`:直接返回最终消息。 - `readme`:请求 `read_file README.md`,随后返回最终消息。 -- `patch`:请求 `apply_patch` 修改 `CLI_SMOKE.txt`,随后返回最终消息。 +- `patch`:请求免审批 `apply_patch` 修改 `CLI_SMOKE.txt`,随后返回最终消息。 +- `shell`:请求 `shell` 执行确定性 echo 命令,用于审批 smoke test。 -`patch` fixture 会触发写入审批。交互式运行时可以直接输入 `y` 批准;自动化测试或非交互脚本可以使用 `--auto-approve`。 +`shell` fixture 会触发命令审批。交互式运行时可以直接输入 `y` 批准;自动化测试或非交互脚本可以使用 `--auto-approve`。普通 workspace `patch` fixture 不再触发审批。 ## 输出 @@ -111,7 +112,7 @@ final: ... ## 交互式审批 -CLI 二进制默认会在 `apply_patch`、`shell` 等需要审批的工具执行前向 stderr 输出审批摘要,并从 stdin 读取 `y` / `n`。stdout 在 `--json` 模式下仍只保留 newline-delimited JSON-RPC 事件或错误响应,便于前端或脚本解析;人类提示不会混入 stdout。 +CLI 二进制默认会在 `shell` 等需要审批的工具执行前向 stderr 输出审批摘要,并从 stdin 读取 `y` / `n`。最多 5 个 `expectedFiles` 的普通 workspace 代码 `apply_patch` 默认免审批;超过阈值的 bulk patch、workspace policy 文件 patch、高风险 patch 或显式审批路径仍复用同一审批事件。stdout 在 `--json` 模式下仍只保留 newline-delimited JSON-RPC 事件或错误响应,便于前端或脚本解析;人类提示不会混入 stdout。 批准后,Run Log 会出现 `tool.approvalResolved`,随后进入 `tool.started`。拒绝后,Run Log 会出现 `tool.approvalResolved` 和 `run.failed`,对应工具不会执行。 diff --git a/docs/deepseek-api-adapter.md b/docs/deepseek-api-adapter.md index 7122842..8446555 100644 --- a/docs/deepseek-api-adapter.md +++ b/docs/deepseek-api-adapter.md @@ -90,7 +90,7 @@ crates/agent-core/src/provider/deepseek_api.rs 核心类型: -- `DeepSeekApiConfig`:base URL、模型、超时和 API Key。 +- `DeepSeekApiConfig`:base URL、模型、超时、HTTP send retry 次数和 API Key。 - `DeepSeekApiAdapter`:持有 `reqwest::Client`,负责发送 HTTP 请求。 - `ChatCompletionRequest`:DeepSeek chat completion 请求体。 - `ChatCompletionResponse`:非流式响应。 @@ -153,7 +153,9 @@ adapter 使用显式错误枚举: - DeepSeek 返回非 2xx 状态。 - JSON 响应或 SSE data 解析失败。 -非 2xx 响应保留 HTTP status 和响应 body,便于上层生成用户可读错误。API Key 不进入错误消息。 +HTTP client 默认启用 reqwest `system-proxy`,让 CLI、RPC server 和 VS Code 插件启动的子进程都尽量复用系统代理配置。发送失败时错误消息会保留 timeout/connect/request/body/decode 等分类和底层 source chain;如果 source 中包含带用户名密码的 URL,会先脱敏。非 2xx 响应保留 HTTP status 和响应 body,便于上层生成用户可读错误。API Key 不进入错误消息。 + +DeepSeek adapter 会对 `send().await` 阶段的 transient timeout/connect/request 错误做最多 3 次发送尝试,用于缓解 Windows 本机软件或代理偶发中止已建立连接时的 `os error 10053`。重试只发生在还没有拿到 HTTP response 的发送阶段;HTTP 非 2xx、JSON 解析错误、SSE 已建立后的中途断流不会自动重试,避免重复消费已开始的模型响应或掩盖真实 API 错误。测试 fixture 可通过 `DeepSeekApiConfig::without_system_proxy()` 禁用系统代理,真实 CLI/RPC 默认仍使用 system-proxy。 ## reasoning_content 规则 @@ -170,6 +172,7 @@ adapter 只负责序列化和反序列化 `reasoning_content` 字段。是否需 - 配置 Debug 不泄露 API Key。 - base URL 带路径时 endpoint 拼接正确。 +- HTTP 错误格式化不会泄露 URL 用户名密码。 - thinking request 序列化。 - `thinking.type = disabled` 时不会发送 `reasoning_effort`。 - `tool_choice` 与 thinking mode 的不兼容组合会在本地校验失败。 diff --git a/docs/json-rpc-protocol.md b/docs/json-rpc-protocol.md index 2ba7847..460d707 100644 --- a/docs/json-rpc-protocol.md +++ b/docs/json-rpc-protocol.md @@ -45,7 +45,7 @@ Agent Core } ``` -实时高频事件可以使用 `agent.eventBatch` notification 批量发送;Run Log 本身仍以单个 `seq` 事件作为事实来源,`agent.resume` replay 仍按 `agent.event` 重放。 +实时高频事件可以使用 `agent.eventBatch` notification 批量发送;Run Log 本身仍以单个 `seq` 事件作为事实来源,`agent.resume` replay 仍按 `agent.event` 重放;只读历史分页使用 `agent.loadRunEvents` 在 response 中返回 event envelope,不重新发送 live notification。 ```json { "jsonrpc": "2.0", @@ -267,6 +267,12 @@ interface SendTurnParams { message: string; mode: "plan" | "edit" | "review" | "ask"; attachments?: TurnAttachment[]; + supersedes?: TurnSupersedes; +} + +interface TurnSupersedes { + messageId: string; + turnId?: string; } interface TurnAttachment { @@ -294,10 +300,12 @@ interface SendTurnResult { Result 返回后,进度通过 `agent.event` notification 持续到达。 -当前 Rust request loop 已能解析 `agent.sendTurn` 并分发给 `AgentRpcRequestHandler`。`crates/agent-rpc::AgentTurnLoopRpcHandler` 已能创建 run、选择注入的 provider factory、启动后台 Turn Loop worker,并在创建 run 后立即返回 `accepted`。Run Log 事件会通过独立的 live event queue 发送到 request loop 的单 writer,并持续输出为 `agent.event` notification;遇到审批时,worker 会先检查 session/workspace 持久批准,再在 pending approval 队列中等待 `agent.approve` / `agent.reject` / `agent.cancel`,并在超时时写入取消事件。如果 request loop 读到 EOF 或 writer 失败,会触发 handler shutdown / disconnect cancel;对于 active run,shutdown 会把未决审批解析为 `decision: "canceled"` 并写入 `run.canceled`,或等待已有 terminal event 收口。 +当前 Rust request loop 已能解析 `agent.sendTurn` 并分发给 `AgentRpcRequestHandler`。`crates/agent-rpc::AgentTurnLoopRpcHandler` 已能创建 run、选择注入的 provider factory、启动后台 Turn Loop worker,并在创建 run 后立即返回 `accepted`。Run Log 事件会通过独立的 live event queue 发送到 request loop 的单 writer,并持续输出为 `agent.event` notification;遇到审批时,worker 会先检查 session/workspace 持久批准,再在 pending approval 队列中等待 `agent.approve` / `agent.reject` / `agent.cancel`,并在超时时写入取消事件。active run 还可以通过 `agent.steer` 接收运行中的用户补充指导,Turn Loop 会在下一次 provider request 前把它作为最新用户运行中指令注入。如果 request loop 读到 EOF 或 writer 失败,会触发 handler shutdown / disconnect cancel;对于 active run,shutdown 会把未决审批解析为 `decision: "canceled"` 并写入 `run.canceled`,或等待已有 terminal event 收口。 Phase 2c 起,Rust handler 会消费 `attachments` 并转换为 Context Capsule 来源:`file` 由 Core 在工作区内读取,复用工具执行层的路径和敏感目录保护;`selection` / `explicit_content` 由前端提供文本但受数量、大小、重复来源和路径校验限制;`diagnostic` 由 VS Code/TUI 等前端传入结构化诊断文本。VS Code 插件在发送 turn 时会把当前 Problems 快照转换为 diagnostic attachments,并按协议 attachment 上限优先保留 error;Phase 4 起,Sidebar Chat 与原生 `@prole` Chat Participant 会把历史对话/事件摘要压缩为受限长度的 `explicit_content` attachment,并为该自动上下文预留一个 attachment 槽位。Sidebar timeline 来源会在生成自动上下文前先限制单条消息长度,避免极端长 `assistant.delta` 合并内容造成过大的中间文本。当前默认限制是单 turn 最多 32 个 attachment,单个 attachment 文本最多 256 KiB;超过限制会让该 run 以 `run.failed` / `E_INVALID_ATTACHMENT` 结束。 +`supersedes` 用于编辑重发。前端可以在同一 run 内发送新 turn 时声明它覆盖了某个历史用户消息的 timeline `messageId`,并可附带原 `turnId`。Server 不会重写或删除旧 run log 事件,而是在新 `turn.started.payload.supersedes` 中追加审计元数据;resume/reload 时前端据此隐藏或标记被覆盖的用户消息,自动上下文压缩也应跳过这些 superseded 用户消息。`messageId` 是前端 timeline item id,不要求符合 `runId` 标识符字符集;Rust RPC handler 只接受非空且不超过 256 bytes 的 `messageId`。`turnId` 如果提供,必须是非空、不超过 128 bytes,且只包含 ASCII 字母、数字、`_` 或 `-`。 + ### `agent.approve` 批准一个 pending approval request。 @@ -379,6 +387,30 @@ interface CancelResult { - 当前内存队列默认审批超时为 300 秒;超时会把 approval 解析为 `decision: "expired"`,随后写入 `run.canceled`。测试和嵌入方可以通过 handler 配置缩短该时间。 - 当前取消是协作式,不是强制杀线程;stdio EOF / writer failure 已能取消 active run,包括等待审批和长时间 provider request 场景。命令类工具会在取消或超时时清理子进程树。 +### `agent.steer` + +向 active run 追加运行中的用户指导。该请求不创建新的 turn,不会取消当前 provider/tool 流程;Turn Loop 会在下一次 provider request 前把 steer 消息作为最新用户运行中指令注入,并写入 `turn.steered` 事件。 + +```ts +interface SteerParams { + runId: string; + message: string; +} + +interface SteerResult { + runId: string; + steerId: string; + accepted: true; +} +``` + +规则: + +- `runId` 必须匹配当前 active run;否则返回 `E_RUN_NOT_FOUND`。 +- `message` 必须是非空文本;空白消息返回 invalid params。 +- `agent.steer` 只是追加指导,不表示批准、拒绝或取消 pending approval。 +- 如果当前 run 在收到 steer 前已经完成,handler 会先 drain terminal event,再返回 `E_RUN_NOT_FOUND`。 + ### `agent.resume` 恢复或回放之前的 run。 @@ -406,6 +438,33 @@ interface ResumeResult { - 如果本地 run log 不存在,返回 `E_RUN_NOT_FOUND`。 - 当前 Rust request loop 已能解析 `agent.resume` 并分发给 handler;`AgentTurnLoopRpcHandler` 已能从 Run Log 按 `replayFromSeq` 重放事件。 +### `agent.loadRunEvents` + +从当前 workspace 的 run log 读取一页历史事件。该方法用于前端向上滚动加载更早 timeline,不会把返回事件作为 `agent.event` / `agent.eventBatch` notification 重新发送,也不会触发历史审批副作用。 + +```ts +interface LoadRunEventsParams { + runId: string; + beforeSeq?: number; + limit?: number; +} + +interface LoadRunEventsResult { + runId: string; + events: AgentEventEnvelope[]; + firstSeq?: number; + lastSeq?: number; + hasMoreBefore: boolean; +} +``` + +规则: + +- `beforeSeq` 是 exclusive 上界;省略时从 run log 末尾取最近一页。 +- `limit` 由 server 限制在 1 到 500 之间,默认 200。 +- 返回的 `events` 保持升序,使用与 live/replay 相同的 `AgentEventEnvelope` shape。 +- 如果本地 run log 不存在,返回 `E_RUN_NOT_FOUND`。 + ### `agent.listRuns` 列出当前 workspace 已知的本地 run。 @@ -443,6 +502,28 @@ interface ListRunsResult { - `limit` 省略时返回全部已知 run;传入时只返回前 N 条。 - 当前 Rust request loop 已能解析 `agent.listRuns` 并分发给 handler;`AgentTurnLoopRpcHandler` 已能从 Run Log summary metadata 返回列表。 +### `agent.deleteRun` + +删除当前 workspace 内的本地 run log。 + +```ts +interface DeleteRunParams { + runId: string; +} + +interface DeleteRunResult { + runId: string; + deleted: true; +} +``` + +规则: + +- `agent.deleteRun` 仅删除通过 run id 校验后的 `.prole-coder/runs/` 目录;非法 id 不会解析为文件路径。 +- 如果本地 run log 不存在,返回 `E_RUN_NOT_FOUND`。 +- 如果该 run 正在当前 RPC handler 内 active,返回 `E_RUN_ALREADY_ACTIVE`,前端应先取消或等待 run 收口。 +- 当前 Rust request loop 已能解析 `agent.deleteRun` 并分发给 handler;`AgentTurnLoopRpcHandler` 已能删除 inactive run 并保持 list/resume 语义一致。 + ### `agent.previewFim` 请求一次 fill-in-the-middle completion preview。该方法用于编辑器 inline completion,不创建 run,也不写入 run log。 @@ -535,9 +616,14 @@ interface RunFailed { code: string; message: string; details?: unknown; + diagnosticFile?: string; } ``` +当 tool-call `function.arguments` 本身不是合法 JSON 时,Agent Core 会把脱敏后的原始累计 arguments 写入当前 run 目录下的诊断文件,并在 `diagnosticFile` 返回该本地文件路径。该字段只用于本地排查,不要求前端把文件内容重新发送给模型。 + +当 provider 建立 streaming response 或等待下一段 stream event 超过配置的 idle timeout 且连续重试耗尽时,`run.failed.code` 为 `E_PROVIDER_TIMEOUT`,payload 还会包含 `timeoutMs`、`attempts`、`partialContentChars` 和 `partialReasoningChars`。该错误属于 provider 连接失败收口;JSON-RPC 方法级错误仍归入 `E_PROVIDER_ERROR` 对应的 provider 类错误码。 + ### `run.canceled` ```ts @@ -556,9 +642,23 @@ interface RunCanceled { interface TurnStarted { turnId: string; userTask: string; + supersedes?: TurnSupersedes; +} +``` + +`supersedes` 与 `agent.sendTurn.supersedes` 语义一致,只出现在编辑重发的新 turn 上;旧 turn 的 `turn.started` 仍保留原始 `userTask` 以满足本地审计和故障复盘。 + +### `turn.steered` + +```ts +interface TurnSteered { + steerId: string; + message: string; } ``` +该事件表示用户在 run 执行期间发送了补充指导。它用于 run log 审计和前端 timeline 展示;实际 provider 注入发生在下一次 provider request 前,注入内容会明确要求模型把 steer 当作最新用户指令遵循,因此如果 run 已在当前 provider/tool 循环内自然完成,steer 可能不会再影响模型输出。 + ### `assistant.delta` 面向用户展示的 assistant 输出。 @@ -688,6 +788,22 @@ interface ProviderRequested { } ``` +### `provider.retrying` + +该事件表示 Turn Loop 已决定重新发起 provider request。当前用于 transient stream error 和 provider idle timeout 两类可重试连接问题。 + +```ts +interface ProviderRetrying { + iteration: number; + reason: "transient_stream_error" | "provider_idle_timeout"; + message: string; + retriesRemaining: number; + partialContentChars: number; + partialReasoningChars: number; + timeoutMs?: number; +} +``` + ### `provider.completed` Phase 2c 新增。该事件表示一次 provider 调用结束,用于记录 usage、cache 和 streaming 摘要;它不替代 `provider.requested`。 @@ -713,6 +829,17 @@ interface ProviderCompleted { } ``` +### `workspace.snapshotFailed` + +```ts +interface WorkspaceSnapshotFailed { + phase: "beforeToolExecution" | "afterToolExecution"; + message: string; +} +``` + +该事件表示 Turn Loop 试图捕获 workspace 文件快照以统计 shell 副作用变更时失败。run 可以继续执行,但 `run.completed.changedFiles` 可能只包含工具显式报告的文件,前端应把它作为诊断信息写入 Output/run log。 + ### `tool.requested` ```ts @@ -745,6 +872,8 @@ interface ToolApprovalRequired { } ``` +`toolName` 使用内置工具名,包括 `shell`、`apply_patch`,以及 Turn Loop 本地发出的 continuation approval `model_turn_budget`。`model_turn_budget` 不是模型可主动调用的工具;它表示 provider request 预算窗口耗尽,需要客户端通过同一 pending approval queue 批准是否继续。 + ### `tool.approvalResolved` ```ts @@ -802,7 +931,7 @@ interface PatchProposed { } ``` -Patch 通过其中的 `approvalId` 使用 `agent.approve` 批准。`apply_patch` 首版支持在 `tool.approvalRequired.hunks` 中暴露可批准 hunk;client 可以发送 `agent.approve` 的 `hunks.approved` 只批准其中一部分。 +需要审批的 patch 通过其中的 `approvalId` 使用 `agent.approve` 批准。普通 workspace `apply_patch` 默认不产生审批;当未来高风险 patch 或显式审批路径发出 `tool.approvalRequired` 时,payload 可以在 `hunks` 中暴露可批准 hunk,client 可以发送 `agent.approve` 的 `hunks.approved` 只批准其中一部分。 ### `patch.applied` @@ -878,7 +1007,7 @@ JSON-RPC 标准错误保留标准语义。项目特定错误使用 `-32000` 到 | -32002 | `E_WORKSPACE_UNTRUSTED` | 当前 workspace 未信任,请求操作被禁用。 | | -32003 | `E_RUN_NOT_FOUND` | 请求的 run 不存在于本地状态。 | | -32004 | `E_RUN_ALREADY_ACTIVE` | 已存在冲突的 active run。 | -| -32010 | `E_INVALID_TOOL_ARGUMENTS` | tool-call 参数未通过 schema 校验。 | +| -32010 | `E_INVALID_TOOL_ARGUMENTS` | tool-call 参数不是合法 JSON、payload 引用非法或发生其他终止型参数错误;已知工具 schema mismatch 也会作为失败 `tool.completed.result.errorCode` 返回 provider 重试。 | | -32011 | `E_APPROVAL_NOT_FOUND` | approval id 未知、已过期或已使用。 | | -32012 | `E_APPROVAL_DENIED` | 审批被拒绝,操作无法继续。 | | -32020 | `E_CONTEXT_BUDGET_EXCEEDED` | 必需上下文无法放入配置的预算。 | @@ -907,6 +1036,34 @@ JSON-RPC 标准错误保留标准语义。项目特定错误使用 `-32000` 到 } ``` +Provider 配置错误可以在 `error.data` 中携带恢复动作。VS Code、TUI 等前端必须优先依据结构化字段决定 UX,不解析 `message` 文案: + +```ts +interface RpcRecoverableAction { + kind: "configureDeepSeekApiKey"; + label: string; +} + +interface ProviderConfigurationErrorData { + provider: "deepseek"; + configurationError: "missingApiKey"; + recoverableAction: RpcRecoverableAction; +} +``` + +例如 DeepSeek API key 缺失时,`agent.sendTurn` / `agent.previewFim` 会返回 `E_PROVIDER_ERROR`,并附带: + +```json +{ + "provider": "deepseek", + "configurationError": "missingApiKey", + "recoverableAction": { + "kind": "configureDeepSeekApiKey", + "label": "Configure API Key" + } +} +``` + ## 端到端示例 Client 发送 turn: diff --git a/docs/phase-tasks.md b/docs/phase-tasks.md index cc30038..fa0ef37 100644 --- a/docs/phase-tasks.md +++ b/docs/phase-tasks.md @@ -1,6 +1,6 @@ # 详细任务索引 -状态:Phase 1、Phase 2、Phase 3、Phase 4 已完成;Phase 4 包含原 14 项 VS Code 深度集成能力以及 P4-15 到 P4-18 的 Codex-like UX 收敛。 +状态:Phase 1、Phase 2、Phase 3、Phase 4、Phase 5 已完成。下一步进入 Phase 6:AGENT 性能调试;TUI 与生态扩展顺延到 Phase 7,发布与治理顺延到 Phase 8。 本文档是详细设计文档里的任务账本。README 保留高层开发计划;这里把各模块文档中出现的“已实现、尚未实现、后续增强、下一步”收敛为可勾选任务,避免后续工作只散落在说明文字里。 @@ -9,6 +9,9 @@ - 新增任何预期实现项时,必须在本文件登记阶段和状态。 - README 开发计划中的阶段条目标记完成前,应检查本文件中对应细任务是否已经完成。 - 如果一个 README 条目完成了它蕴含的细任务,应同步把本文件对应行标记为 `[x]`,并在说明中写清验收方式。 +- README 开发计划只写阶段和大任务摘要,并使用 `P阶段-数字` 编号;实现细节、验收命令、审查来源、后续拆分统一写在本文件。 +- 本文件记录 `P阶段-数字字母` 子任务,例如 `P5-14a` / `P5-14b`;README 中的父任务不需要在本文件重复登记为父项。即使某个 README 大任务目前只有一个实现项,也登记为 `P4-1a` 这类子项,后续扩展时继续追加字母。 +- 阶段内的“持续 backlog / 持续收敛”占位项必须始终放在该阶段任务表最后;新增具体子任务时放在占位项上方。 - 详细模块文档仍保留设计说明;本文件只记录阶段、状态和追踪入口。 ## 审计结论 @@ -24,111 +27,182 @@ | 状态 | 任务 | 来源 | 说明 | | --- | --- | --- | --- | -| [x] | 项目名称、AGPL-3.0-or-later、Rust/TypeScript workspace、pnpm workspace、Windows 环境说明、CI 骨架和治理文件 | `README.md`、`docs/adr/`、`CONTRIBUTING.md`、`CODE_OF_CONDUCT.md`、`SECURITY.md` | Phase 0 已完成并进入 README 高层计划。 | -| [x] | 建立 `docs/` 与 ADR,明确 README 只做入口和高层计划 | `docs/README.md`、`docs/adr/0005-keep-readme-as-entrypoint-and-move-design-to-docs.md` | 详细设计文档已按模块拆分。 | -| [x] | JSON-RPC 基础协议、工具 schema、风险等级和审批模型设计 | `docs/json-rpc-protocol.md`、`docs/tool-system.md`、`docs/approval-model.md` | Phase 0 设计完成;Phase 1 已实现基础执行闭环。 | +| [x] | P0-1a:项目名称、AGPL-3.0-or-later、治理文件与 README 入口定位 | `README.md`、`docs/adr/`、`CONTRIBUTING.md`、`CODE_OF_CONDUCT.md`、`SECURITY.md` | Phase 0 已完成并进入 README 高层计划。 | +| [x] | P0-2a:Rust/TypeScript workspace、pnpm workspace、Windows 环境说明、CI 骨架和基础环境配置 | `README.md`、`docs/adr/`、`CONTRIBUTING.md`、`CODE_OF_CONDUCT.md`、`SECURITY.md` | Phase 0 已完成并进入 README 高层计划。 | +| [x] | P0-3a:JSON-RPC 基础协议、工具 schema、风险等级和审批模型设计 | `docs/json-rpc-protocol.md`、`docs/tool-system.md`、`docs/approval-model.md` | Phase 0 设计完成;Phase 1 已实现基础执行闭环。 | ## Phase 1:Agent Core MVP | 状态 | 任务 | 来源 | 说明 | | --- | --- | --- | --- | -| [x] | DeepSeek API adapter、data-only SSE parser、streaming 基础和真实联网 smoke | `docs/deepseek-api-adapter.md`、`docs/agent-core.md` | 已有离线解析测试和 ignored live tests。 | -| [x] | streaming tool-call delta accumulator | `docs/deepseek-api-adapter.md`、`docs/roadmap.md` | 已覆盖 delta 拼装、冲突和缺失元数据测试,并有 live forced tool-call 验收。 | -| [x] | `reasoning_content` replay 状态机 | `docs/reasoning-content.md` | 已覆盖 replay required、缺失 reasoning、thinking disabled 等边界。 | -| [x] | 基础 Context Builder 与 token 预算报告 | `docs/context-capsule.md`、`docs/agent-core.md` | Phase 1 仅实现基础 builder;完整 1M Capsule 归入 Phase 2。 | -| [x] | read/search/apply_patch/shell/git 工具执行层 | `docs/tool-system.md` | 已覆盖路径约束、敏感路径拒绝、命令超时、结构化结果和工具取消。 | -| [x] | Run Log `events.jsonl`、`summary.json`、脱敏和写入串行化 | `docs/run-log.md` | 已接入 CLI 和 RPC;全双工 notification writer 不属于 Phase 1。 | -| [x] | Agent Turn Loop 基础编排、工具审批、验证命令和 run log 写入 | `docs/turn-loop.md`、`docs/agent-core.md` | 已有 fixture 端到端和 CLI smoke。 | -| [x] | `TurnProvider` async / streaming 边界和 `TurnEventSink` 实时事件出口 | `docs/turn-loop.md` | CLI `--json` 和 `StdioEventBridge` 已接入。 | -| [x] | CLI `run` / `rpc` 最小闭环和 JSON-RPC 错误输出 | `docs/cli.md` | 已有库级、进程级和 fixture smoke 测试。 | -| [x] | Agent RPC Server request loop、真实 Turn Loop handler、pending approval、取消、超时、EOF shutdown 和 `agent.listRuns` | `docs/rpc-server.md`、`docs/json-rpc-protocol.md` | 已覆盖审批批准/拒绝/取消/超时、并发拒绝、EOF shutdown、resume/listRuns。 | -| [x] | CLI/TUI/VS Code 审批前端基础原语 | `docs/approval-model.md`、`docs/tui.md`、`docs/vscode-extension.md` | CLI prompt、TUI prompt 状态机、VS Code modal adapter 已实现;完整 UI 接入归入后续阶段。 | -| [x] | Rust/TypeScript 工具注册表和错误码协议交叉校验 | `docs/tool-system.md`、`docs/json-rpc-protocol.md`、`packages/protocol` | 工具 registry fixture 与错误码表已进入默认测试。 | -| [x] | 合并前测试基础设施、live 配置收敛和离线最终验收 | `docs/testing.md`、`docs/demos.md` | `pnpm run check`、测试清单、离线 demo、diff/sensitive scan 已完成。 | -| [x] | VS Code RPC server 管理和 JSON-RPC request client 前置实现 | `docs/vscode-extension.md`、`docs/roadmap.md` | 属于 Phase 3 前置项,已提前完成;不作为 Phase 1 阻塞验收条件。 | +| [x] | P1-1a:DeepSeek API adapter、data-only SSE parser、streaming 基础和真实联网 smoke | `docs/deepseek-api-adapter.md`、`docs/agent-core.md` | 已有离线解析测试和 ignored live tests。 | +| [x] | P1-1b:streaming tool-call delta accumulator | `docs/deepseek-api-adapter.md`、`docs/roadmap.md` | 已覆盖 delta 拼装、冲突和缺失元数据测试,并有 live forced tool-call 验收。 | +| [x] | P1-1c:`reasoning_content` replay 状态机 | `docs/reasoning-content.md` | 已覆盖 replay required、缺失 reasoning、thinking disabled 等边界。 | +| [x] | P1-2a:基础 Context Builder 与 token 预算报告 | `docs/context-capsule.md`、`docs/agent-core.md` | Phase 1 仅实现基础 builder;完整 1M Capsule 归入 Phase 2。 | +| [x] | P1-2b:read/search/apply_patch/shell/git 工具执行层 | `docs/tool-system.md` | 已覆盖路径约束、敏感路径拒绝、命令超时、结构化结果和工具取消。 | +| [x] | P1-3a:Run Log `events.jsonl`、`summary.json`、脱敏和写入串行化 | `docs/run-log.md` | 已接入 CLI 和 RPC;全双工 notification writer 不属于 Phase 1。 | +| [x] | P1-3b:Agent Turn Loop 基础编排、工具审批、验证命令和 run log 写入 | `docs/turn-loop.md`、`docs/agent-core.md` | 已有 fixture 端到端和 CLI smoke。 | +| [x] | P1-3c:`TurnProvider` async / streaming 边界和 `TurnEventSink` 实时事件出口 | `docs/turn-loop.md` | CLI `--json` 和 `StdioEventBridge` 已接入。 | +| [x] | P1-3d:CLI `run` / `rpc` 最小闭环和 JSON-RPC 错误输出 | `docs/cli.md` | 已有库级、进程级和 fixture smoke 测试。 | +| [x] | P1-4a:Agent RPC Server request loop、真实 Turn Loop handler、pending approval、取消、超时、EOF shutdown 和 `agent.listRuns` | `docs/rpc-server.md`、`docs/json-rpc-protocol.md` | 已覆盖审批批准/拒绝/取消/超时、并发拒绝、EOF shutdown、resume/listRuns。 | +| [x] | P1-4b:CLI/TUI/VS Code 审批前端基础原语 | `docs/approval-model.md`、`docs/tui.md`、`docs/vscode-extension.md` | CLI prompt、TUI prompt 状态机、VS Code legacy modal adapter 已实现;后续默认 UX 已改为 Sidebar 内联审批。 | +| [x] | P1-5a:Rust/TypeScript 工具注册表和错误码协议交叉校验 | `docs/tool-system.md`、`docs/json-rpc-protocol.md`、`packages/protocol` | 工具 registry fixture 与错误码表已进入默认测试。 | +| [x] | P1-5b:合并前测试基础设施、live 配置收敛和离线最终验收 | `docs/testing.md`、`docs/demos.md` | `pnpm run check`、测试清单、离线 demo、diff/sensitive scan 已完成。 | +| [x] | P3-2a(前置实现):VS Code RPC server 管理和 JSON-RPC request client | `docs/vscode-extension.md`、`docs/roadmap.md` | 属于 Phase 3 前置项,已提前完成;不作为 Phase 1 阻塞验收条件。 | ## Phase 2:1M Context Capsule | 状态 | 任务 | 来源 | 说明 | | --- | --- | --- | --- | -| [x] | Phase 2a-1:`read_file` 增加 `sha256` / `sizeBytes` | `docs/tool-system.md`、`docs/context-capsule.md` | 已完成:`read_file` 返回完整文件的 `sha256` 和 `sizeBytes`,Rust/TypeScript result schema 与单元测试已同步。 | -| [x] | Phase 2a-2:定义 `ContextCapsule` / `ContextSection` / `CachePlacement` 和稳定 renderer | `README.md`、`docs/context-capsule.md`、`docs/agent-core.md` | 已完成:三层布局与 kind priority 解耦,`context_capsule.v1` renderer 可稳定生成 provider 输入,`content` 兼容别名与 `rendered` 保持一致。 | -| [x] | Phase 2a-3:workspace manifest v0 自动构建 | `README.md`、`docs/context-capsule.md`、`docs/tool-system.md` | 已完成:结构化 JSON、canonical `manifestHash`、默认 `maxEntries=500`、硬安全排除、默认工程排除、`.gitignore` + `.prole-coderignore`,并提供可执行 `workspace_manifest` 工具。 | -| [x] | Phase 2a-4:Context Builder 接入 manifest summary 和扩展 `context.built` payload | `docs/context-capsule.md`、`docs/json-rpc-protocol.md` | 已完成:Turn Loop 自动生成 manifest summary 进入 `StablePrefix`,`context.built` 输出 stable/dynamic/suffix token、sections、manifest hash 和 `max_entries_exceeded` 截断原因。 | -| [x] | Phase 2b-1:TokenEstimator trait 与 `CalibratedEstimator` | `docs/roadmap.md`、`docs/context-capsule.md`、`docs/deepseek-api-adapter.md` | 已完成:新增 `token_estimator` 模块,默认保持 `utf8_bytes`;`CalibratedEstimator` 只保存字节数/实际 token 数和聚合误差,报告 `exact=false`,并有离线 fixture 测试。 | -| [x] | Phase 2b-2:稳定前缀和缓存友好 prompt 布局 | `README.md`、`docs/context-capsule.md`、`docs/deepseek-api-adapter.md` | 已完成:`ContextBuilderConfig` 增加 30% 默认稳定前缀预算,`context.built` 输出 `stablePrefixHash` 和预算字段;修改 `TurnSuffix` 不改变 `StablePrefix`、可选稳定前缀超预算省略均有测试。 | -| [x] | Phase 2c-1:Context Builder 接入 attachments 和 diagnostics | `docs/json-rpc-protocol.md`、`docs/context-capsule.md`、`docs/vscode-extension.md` | 已完成:`agent.sendTurn.attachments` 从拒绝改为消费,支持 file、selection、explicit_content、diagnostic;Core/RPC 已覆盖路径、重复和大小限制测试。 | -| [x] | Phase 2c-2:`provider.completed` 事件和 DeepSeek cache hit/miss 实验 | `README.md`、`docs/roadmap.md`、`docs/deepseek-api-adapter.md`、`docs/testing.md` | 已完成基础闭环:Turn Loop 独立记录模型、duration、usage、cache hit/miss 和 stream 摘要,DeepSeek streaming wrapper 会从 usage chunk 填充字段;更大 cache hit/miss 手动实验留作 Phase 2d 前增强样本。 | -| [x] | Phase 2d-1:200K、500K、900K 样例仓库 token 预算与 Context Capsule 验收 | `README.md`、`docs/testing.md`、`docs/context-capsule.md` | 已完成:新增 `context_capsule_large_repository_budget_benchmark` ignored/manual 测试,本地跑通 200K、500K、900K 三档样例 Context Capsule,默认 CI 只编译不自动执行。 | -| [x] | Phase 2d-2:超预算解释、Run Log 体积/截断/脱敏边界和 tool call JSON Schema 校验层 | `docs/run-log.md`、`docs/security-model.md`、`docs/agent-core.md`、`docs/tool-system.md` | 已完成:required context 超预算失败和 optional omitted reason 继续由 Context Builder 测试覆盖;Run Log 写入入口统一脱敏和字符串/数组截断并记录 `runLogTruncation`;tool call arguments 在 typed deserialization 前执行注册表 JSON Schema 校验。 | -| [x] | Phase 2e-1:展示型 demo 基础收敛 | `README.md`、`docs/demos.md`、`docs/testing.md` | 已完成:新增 demo 短命令均登记在 `.cargo/config.toml`,测试默认 ignored;`docs/demos.md` 作为统一清单记录用途、运行命令和预期输出。 | -| [x] | Phase 2e-2:`cargo demo-context` | `README.md`、`docs/demos.md`、`docs/context-capsule.md` | 已完成:展示 manifest summary、Context Capsule sections、included/omitted sources 和 `context.built` payload;已运行 `cargo demo-context`。 | -| [x] | Phase 2e-3:`cargo demo-truncation` | `README.md`、`docs/demos.md`、`docs/run-log.md` | 已完成:展示 Run Log 脱敏、截断、`runLogTruncation`,并区分截断、空输出和缺失字段;已运行 `cargo demo-truncation`。 | -| [x] | Phase 2e-4:`cargo demo-schema` | `README.md`、`docs/demos.md`、`docs/tool-system.md` | 已完成:展示 tool call arguments 在 typed deserialization 前被 JSON Schema 拒绝,并输出稳定 `E_INVALID_TOOL_ARGUMENTS`;已运行 `cargo demo-schema`。 | -| [x] | Phase 2e-5:`cargo demo-context-visual` | `README.md`、`docs/demos.md`、`docs/context-capsule.md`、`docs/vscode-extension.md` | 已完成:用 ASCII 视图展示 StablePrefix、DynamicPrelude、TurnSuffix 的 token 分布,并输出原始 JSON;已运行 `cargo demo-context-visual`。 | -| [x] | Phase 2e-6:`cargo demo-attachment` | `README.md`、`docs/demos.md`、`docs/json-rpc-protocol.md`、`docs/context-capsule.md` | 已完成:展示 file、selection、explicit_content、diagnostic attachments 如何进入 Context Builder 和 provider prompt;已运行 `cargo demo-attachment`。 | -| [x] | Phase 2e-7:`cargo demo-live` provider summary 增强 | `README.md`、`docs/demos.md`、`docs/deepseek-api-adapter.md`、`docs/roadmap.md` | 已完成:现有 live demo 的人类可读事件摘要会展示 `provider.completed` 的模型、duration、usage、cache hit/miss 和 stream 字段;离线 fixture 已运行,联网入口仍按 `PROLE_CODER_LIVE_TESTS=1 cargo demo-live` 手动执行。 | +| [x] | P2-1a:`read_file` 增加 `sha256` / `sizeBytes` | `docs/tool-system.md`、`docs/context-capsule.md` | 已完成:`read_file` 返回完整文件的 `sha256` 和 `sizeBytes`,Rust/TypeScript result schema 与单元测试已同步。 | +| [x] | P2-1b:定义 `ContextCapsule` / `ContextSection` / `CachePlacement` 和稳定 renderer | `README.md`、`docs/context-capsule.md`、`docs/agent-core.md` | 已完成:三层布局与 kind priority 解耦,`context_capsule.v1` renderer 可稳定生成 provider 输入,`content` 兼容别名与 `rendered` 保持一致。 | +| [x] | P2-1c:workspace manifest v0 自动构建 | `README.md`、`docs/context-capsule.md`、`docs/tool-system.md` | 已完成:结构化 JSON、canonical `manifestHash`、默认 `maxEntries=500`、硬安全排除、默认工程排除、`.gitignore` + `.prole-coderignore`,并提供可执行 `workspace_manifest` 工具。 | +| [x] | P2-1d:Context Builder 接入 manifest summary 和扩展 `context.built` payload | `docs/context-capsule.md`、`docs/json-rpc-protocol.md` | 已完成:Turn Loop 自动生成 manifest summary 进入 `StablePrefix`,`context.built` 输出 stable/dynamic/suffix token、sections、manifest hash 和 `max_entries_exceeded` 截断原因。 | +| [x] | P2-2a:TokenEstimator trait 与 `CalibratedEstimator` | `docs/roadmap.md`、`docs/context-capsule.md`、`docs/deepseek-api-adapter.md` | 已完成:新增 `token_estimator` 模块,默认保持 `utf8_bytes`;`CalibratedEstimator` 只保存字节数/实际 token 数和聚合误差,报告 `exact=false`,并有离线 fixture 测试。 | +| [x] | P2-2b:稳定前缀和缓存友好 prompt 布局 | `README.md`、`docs/context-capsule.md`、`docs/deepseek-api-adapter.md` | 已完成:`ContextBuilderConfig` 增加 30% 默认稳定前缀预算,`context.built` 输出 `stablePrefixHash` 和预算字段;修改 `TurnSuffix` 不改变 `StablePrefix`、可选稳定前缀超预算省略均有测试。 | +| [x] | P2-3a:Context Builder 接入 attachments 和 diagnostics | `docs/json-rpc-protocol.md`、`docs/context-capsule.md`、`docs/vscode-extension.md` | 已完成:`agent.sendTurn.attachments` 从拒绝改为消费,支持 file、selection、explicit_content、diagnostic;Core/RPC 已覆盖路径、重复和大小限制测试。 | +| [x] | P2-3b:`provider.completed` 事件和 DeepSeek cache hit/miss 实验 | `README.md`、`docs/roadmap.md`、`docs/deepseek-api-adapter.md`、`docs/testing.md` | 已完成基础闭环:Turn Loop 独立记录模型、duration、usage、cache hit/miss 和 stream 摘要,DeepSeek streaming wrapper 会从 usage chunk 填充字段;更大 cache hit/miss 手动实验留作 P2-4 前增强样本。 | +| [x] | P2-4a:200K、500K、900K 样例仓库 token 预算与 Context Capsule 验收 | `README.md`、`docs/testing.md`、`docs/context-capsule.md` | 已完成:新增 `context_capsule_large_repository_budget_benchmark` ignored/manual 测试,本地跑通 200K、500K、900K 三档样例 Context Capsule,默认 CI 只编译不自动执行。 | +| [x] | P2-4b:超预算解释、Run Log 体积/截断/脱敏边界和 tool call JSON Schema 校验层 | `docs/run-log.md`、`docs/security-model.md`、`docs/agent-core.md`、`docs/tool-system.md` | 已完成:required context 超预算失败和 optional omitted reason 继续由 Context Builder 测试覆盖;Run Log 写入入口统一脱敏和字符串/数组截断并记录 `runLogTruncation`;tool call arguments 在 typed deserialization 前执行注册表 JSON Schema 校验。 | +| [x] | P2-5a:展示型 demo 基础收敛 | `README.md`、`docs/demos.md`、`docs/testing.md` | 已完成:新增 demo 短命令均登记在 `.cargo/config.toml`,测试默认 ignored;`docs/demos.md` 作为统一清单记录用途、运行命令和预期输出。 | +| [x] | P2-5b:`cargo demo-context` | `README.md`、`docs/demos.md`、`docs/context-capsule.md` | 已完成:展示 manifest summary、Context Capsule sections、included/omitted sources 和 `context.built` payload;已运行 `cargo demo-context`。 | +| [x] | P2-5c:`cargo demo-truncation` | `README.md`、`docs/demos.md`、`docs/run-log.md` | 已完成:展示 Run Log 脱敏、截断、`runLogTruncation`,并区分截断、空输出和缺失字段;已运行 `cargo demo-truncation`。 | +| [x] | P2-5d:`cargo demo-schema` | `README.md`、`docs/demos.md`、`docs/tool-system.md` | 已完成:展示 tool call arguments 在 typed deserialization 前被 JSON Schema 拒绝,并输出稳定 `E_INVALID_TOOL_ARGUMENTS`;已运行 `cargo demo-schema`。 | +| [x] | P2-5e:`cargo demo-context-visual` | `README.md`、`docs/demos.md`、`docs/context-capsule.md`、`docs/vscode-extension.md` | 已完成:用 ASCII 视图展示 StablePrefix、DynamicPrelude、TurnSuffix 的 token 分布,并输出原始 JSON;已运行 `cargo demo-context-visual`。 | +| [x] | P2-5f:`cargo demo-attachment` | `README.md`、`docs/demos.md`、`docs/json-rpc-protocol.md`、`docs/context-capsule.md` | 已完成:展示 file、selection、explicit_content、diagnostic attachments 如何进入 Context Builder 和 provider prompt;已运行 `cargo demo-attachment`。 | +| [x] | P2-5g:`cargo demo-live` provider summary 增强 | `README.md`、`docs/demos.md`、`docs/deepseek-api-adapter.md`、`docs/roadmap.md` | 已完成:现有 live demo 的人类可读事件摘要会展示 `provider.completed` 的模型、duration、usage、cache hit/miss 和 stream 字段;离线 fixture 已运行,联网入口仍按 `PROLE_CODER_LIVE_TESTS=1 cargo demo-live` 手动执行。 | ## Phase 3:VS Code 插件核心与共享 RPC 交互管线 | 状态 | 任务 | 来源 | 说明 | | --- | --- | --- | --- | -| [x] | RPC 全双工 reader/writer 与独立事件 writer 队列 | `docs/rpc-server.md`、`docs/turn-loop.md`、`docs/run-log.md`、`docs/roadmap.md` | 已完成:`agent.sendTurn` 创建 run 后立即返回 accepted,后台 live `agent.event` 由有界队列和单 writer 持续推送;交互式 RPC 测试覆盖 response-before-event、provider 未完成前早返回、审批批准/拒绝/取消和 resume/listRuns。验收:`cargo test`、`cargo clippy --all-targets -- -D warnings`。 | -| [x] | 长 provider request 期间的 client 断连取消 | `docs/rpc-server.md`、`docs/json-rpc-protocol.md`、`docs/approval-model.md` | 已完成:stdio EOF / shutdown 会取消 active run,writer 失败会触发断连取消句柄并取消 active run 与 pending approvals。 | -| [x] | TypeScript extension scaffold | `README.md`、`docs/vscode-extension.md` | 已完成基础命令和测试骨架。 | -| [x] | RPC server 启动监管 | `README.md`、`docs/vscode-extension.md` | 已能启动 `prole rpc`、发送 initialize、转发事件并处理退出。 | -| [x] | JSON-RPC request client | `README.md`、`docs/vscode-extension.md` | 已管理 request id、pending response、error response 和进程退出清理。 | -| [x] | VS Code/protocol TypeScript 类型共享收敛 | `packages/protocol`、`docs/json-rpc-protocol.md`、`docs/vscode-extension.md` | 已完成:extension 通过 workspace devDependency 消费 `@prole-coder/protocol`,`rpcServer.ts` re-export protocol `AgentEventEnvelope` 类型 alias,删除本地重复 envelope 定义;extension build/typecheck/test/lint 会先构建 protocol 声明。 | -| [x] | VS Code RPC/commands 边界测试补齐 | `docs/vscode-extension.md`、`.agents/communication/daily/2026-05-28/code_review.md` | 已完成:`rpcServer.test.ts` 覆盖 spawn throw、stdio 缺失、invalid JSON、process error、stop pending startup、onEvent dispose、stderrPreview、sendRequest 写入失败和非 agent.event 通知;`commands.test.ts` 覆盖 openChat 启动失败提示、非 Error fallback、`persistable: false` approve 和 paths 拼接。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`。 | -| [x] | Sidebar Chat 和 `agent.event` 渲染 | `README.md`、`docs/vscode-extension.md` | 已完成:VS Code 贡献 ProleCoder Activity Bar view 和 Webview Sidebar Chat;`ProleChatViewProvider` 订阅 `RpcServerManager.onEvent()`,通过 `ChatEventTimeline` 渲染 assistant delta、tool lifecycle、审批、context/provider 和 terminal event,并合并同一 run/turn 的 assistant delta。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`。 | -| [x] | 文本输入发送 turn 并接收真实 Agent 响应 | `README.md`、`docs/vscode-extension.md`、`docs/json-rpc-protocol.md` | 已完成:Sidebar Chat 提供 prompt 输入和 mode 选择,Webview submit 经过 `chatInput` 校验后调用 typed `RpcServerManager.sendTurn()`,accepted 后通过同一 run 的 `agent.event` terminal event 收口输入状态。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`。 | -| [x] | VS Code 审批 UI 接入真实 RPC pending queue | `docs/approval-model.md`、`docs/vscode-extension.md` | 已完成:新增 `ApprovalEventController` 订阅 `tool.approvalRequired`,校验 protocol payload 后调用 VS Code modal approval adapter,并通过 typed `RpcServerManager.approve()` / `reject()` 发送 `agent.approve` / `agent.reject`;重复 approvalId 不会重复弹窗。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`。 | -| [x] | 命令风险分类器和动态风险升级 | `README.md`、`docs/approval-model.md`、`docs/tool-system.md`、`docs/turn-loop.md` | 已完成:Agent Core 对 shell 命令做词法分段和显式命令族分类,递归检查 shell 包装器、`$(...)` 和传统反引号子命令,识别依赖安装、网络访问、远程 git、删除和发布命令,升级 `tool.requested` / `tool.approvalRequired` 风险并输出 `riskReasons`;VS Code/CLI/TUI 展示升级原因。验收:`cargo fmt --check`、`cargo test -p prole-coder-agent-core command_risk`、`cargo test -p prole-coder-agent-core turn_loop_upgrades_shell_approval_risk`、`cargo test`、`cargo clippy --all-targets -- -D warnings`、`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`、`git diff --check`。 | -| [x] | 更强进程树清理策略 | `docs/tool-system.md`、`docs/security-model.md`、`docs/roadmap.md` | 已完成:命令类工具启动时建立可收束的进程树边界,Unix 使用独立 process group,Windows 使用新 process group、ParentProcessId descendant 枚举和 `taskkill /T /F` 兜底;取消和超时会清理 shell/search/git 等工具的子进程树。验收:`cargo test -p prole-coder-agent-core shell_cancels_descendant_processes`、`cargo test -p prole-coder-agent-core shell_timeout_cleans_descendant_processes`、`cargo test -p prole-coder-agent-core shell_cancels_running_command`。 | -| [x] | Native diff editor 与 hunk 级审批边界 | `README.md`、`docs/vscode-extension.md` | 已完成:VS Code 侧新增 patch preview controller,缓存 `tool.requested.argumentsPreview.unifiedDiff`,在 `apply_patch` 审批 modal 前打开 VS Code 原生 diff editor;纯 TS parser 会生成稳定 hunk approval boundary,当前仍以 whole-patch approve/reject 回传,为后续 hunk 级决策预留结构。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`。 | -| [x] | Run List / resume | `README.md`、`docs/vscode-extension.md`、`docs/rpc-server.md` | 已完成:VS Code Sidebar Chat 通过 typed `RpcServerManager.listRuns()` 拉取最近 run summary,Run List 保留 loading/failed/selected 状态;点击历史 run 会调用 `agent.resume` 并清空当前事件视图,随后消费 replay 的 `agent.event`。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`。 | -| [x] | Context Capsule 可视化 | `README.md`、`docs/context-capsule.md`、`docs/vscode-extension.md` | 已完成:VS Code Sidebar Chat 新增 Context Capsule 面板,消费 `context.built` metadata,展示 StablePrefix / DynamicPrelude / TurnSuffix token 分布、input/stable budget、cache/estimator 摘要、included/omitted source 预览和 manifest 摘要。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`。 | +| [x] | P3-1a:RPC 全双工 reader/writer 与独立事件 writer 队列 | `docs/rpc-server.md`、`docs/turn-loop.md`、`docs/run-log.md`、`docs/roadmap.md` | 已完成:`agent.sendTurn` 创建 run 后立即返回 accepted,后台 live `agent.event` 由有界队列和单 writer 持续推送;交互式 RPC 测试覆盖 response-before-event、provider 未完成前早返回、审批批准/拒绝/取消和 resume/listRuns。验收:`cargo test`、`cargo clippy --all-targets -- -D warnings`。 | +| [x] | P3-1b:长 provider request 期间的 client 断连取消 | `docs/rpc-server.md`、`docs/json-rpc-protocol.md`、`docs/approval-model.md` | 已完成:stdio EOF / shutdown 会取消 active run,writer 失败会触发断连取消句柄并取消 active run 与 pending approvals。 | +| [x] | P3-2b:TypeScript extension scaffold | `README.md`、`docs/vscode-extension.md` | 已完成基础命令和测试骨架。 | +| [x] | P3-2c:RPC server 启动监管 | `README.md`、`docs/vscode-extension.md` | 已能启动 `prole rpc`、发送 initialize、转发事件并处理退出。 | +| [x] | P3-2d:JSON-RPC request client | `README.md`、`docs/vscode-extension.md` | 已管理 request id、pending response、error response 和进程退出清理。 | +| [x] | P3-3a:VS Code/protocol TypeScript 类型共享收敛 | `packages/protocol`、`docs/json-rpc-protocol.md`、`docs/vscode-extension.md` | 已完成:extension 通过 workspace devDependency 消费 `@prole-coder/protocol`,`rpcServer.ts` re-export protocol `AgentEventEnvelope` 类型 alias,删除本地重复 envelope 定义;extension build/typecheck/test/lint 会先构建 protocol 声明。 | +| [x] | P3-3b:VS Code RPC/commands 边界测试补齐 | `docs/vscode-extension.md`、`.agents/communication/daily/2026-05-28/code_review.md` | 已完成:`rpcServer.test.ts` 覆盖 spawn throw、stdio 缺失、invalid JSON、process error、stop pending startup、onEvent dispose、stderrPreview、sendRequest 写入失败和非 agent.event 通知;`commands.test.ts` 覆盖 openChat 启动失败提示、非 Error fallback、`persistable: false` approve 和 paths 拼接。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`。 | +| [x] | P3-4a:Sidebar Chat 和 `agent.event` 渲染 | `README.md`、`docs/vscode-extension.md` | 已完成:VS Code 贡献 ProleCoder Activity Bar view 和 Webview Sidebar Chat;`ProleChatViewProvider` 订阅 `RpcServerManager.onEvent()`,通过 `ChatEventTimeline` 渲染 assistant delta、tool lifecycle、审批、context/provider 和 terminal event,并合并同一 run/turn 的 assistant delta。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`。 | +| [x] | P3-4b:文本输入发送 turn 并接收真实 Agent 响应 | `README.md`、`docs/vscode-extension.md`、`docs/json-rpc-protocol.md` | 已完成:Sidebar Chat 提供 prompt 输入和 mode 选择,Webview submit 经过 `chatInput` 校验后调用 typed `RpcServerManager.sendTurn()`,accepted 后通过同一 run 的 `agent.event` terminal event 收口输入状态。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`。 | +| [x] | P3-4c:VS Code 审批 UI 接入真实 RPC pending queue | `docs/approval-model.md`、`docs/vscode-extension.md` | 已完成:新增 `ApprovalEventController` 订阅 `tool.approvalRequired`,校验 protocol payload 后调用 VS Code approval adapter,并通过 typed `RpcServerManager.approve()` / `reject()` 发送 `agent.approve` / `agent.reject`;重复 approvalId 不会重复提示。后续 P5-14 已把默认 UX 从系统 modal 切换为 Sidebar 内联审批卡片。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`。 | +| [x] | P3-5a:命令风险分类器和动态风险升级 | `README.md`、`docs/approval-model.md`、`docs/tool-system.md`、`docs/turn-loop.md` | 已完成:Agent Core 对 shell 命令做词法分段和显式命令族分类,递归检查 shell 包装器、`$(...)` 和传统反引号子命令,识别依赖安装、网络访问、远程 git、删除和发布命令,升级 `tool.requested` / `tool.approvalRequired` 风险并输出 `riskReasons`;VS Code/CLI/TUI 展示升级原因。验收:`cargo fmt --check`、`cargo test -p prole-coder-agent-core command_risk`、`cargo test -p prole-coder-agent-core turn_loop_upgrades_shell_approval_risk`、`cargo test`、`cargo clippy --all-targets -- -D warnings`、`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`、`git diff --check`。 | +| [x] | P3-5b:更强进程树清理策略 | `docs/tool-system.md`、`docs/security-model.md`、`docs/roadmap.md` | 已完成:命令类工具启动时建立可收束的进程树边界,Unix 使用独立 process group,Windows 使用新 process group、ParentProcessId descendant 枚举和 `taskkill /T /F` 兜底;取消和超时会清理 shell/search/git 等工具的子进程树。验收:`cargo test -p prole-coder-agent-core shell_cancels_descendant_processes`、`cargo test -p prole-coder-agent-core shell_timeout_cleans_descendant_processes`、`cargo test -p prole-coder-agent-core shell_cancels_running_command`。 | +| [x] | P3-5c:Native diff editor 与 hunk 级审批边界 | `README.md`、`docs/vscode-extension.md` | 已完成:VS Code 侧新增 patch preview controller,可缓存 `tool.requested.argumentsPreview.unifiedDiff`,在需要审批的 patch 路径前打开 VS Code 原生 diff editor;纯 TS parser 会生成稳定 hunk approval boundary,当前小规模普通 workspace 代码 `apply_patch` 默认免审批,边界为 bulk patch、workspace policy 文件 patch、高风险 patch 或显式 hunk 决策保留。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`。 | +| [x] | P3-5d:Run List / resume | `README.md`、`docs/vscode-extension.md`、`docs/rpc-server.md` | 已完成:VS Code Sidebar Chat 通过 typed `RpcServerManager.listRuns()` 拉取最近 run summary,Run List 保留 loading/failed/selected 状态;点击历史 run 会调用 `agent.resume` 并清空当前事件视图,随后消费 replay 的 `agent.event`。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`。 | +| [x] | P3-5e:Context Capsule 可视化 | `README.md`、`docs/context-capsule.md`、`docs/vscode-extension.md` | 已完成:VS Code Sidebar Chat 新增 Context Capsule 面板,消费 `context.built` metadata,展示 StablePrefix / DynamicPrelude / TurnSuffix token 分布、input/stable budget、cache/estimator 摘要、included/omitted source 预览和 manifest 摘要。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`。 | ## Phase 4:VS Code 深度集成 | 状态 | 任务 | 来源 | 说明 | | --- | --- | --- | --- | -| [x] | P4-1:VSIX dry-run packaging smoke | `README.md`、`docs/vscode-extension.md`、`docs/release.md` | 已完成:新增 `pnpm run vsix:smoke` / `vscode/extension/scripts/vsixDryRunSmoke.mjs`,构建 protocol 与 extension 后在 `target/` 下临时生成 VSIX,检查 `.vscodeignore`、`workspace:*` 运行时边界、media asset、compiled `out/`、activationEvents 和包内排除规则,并清理临时产物;不代表 P4-13 完成。验收:`pnpm -r --if-present vsix:smoke`。 | -| [x] | P4-2:`@vscode/test-electron` 最小 harness | `README.md`、`docs/vscode-extension.md` | 已完成:新增 `pnpm run vscode:test-electron` / `vscode/extension/scripts/runVscodeIntegrationTests.mjs`,测试 extension activation、trusted workspace、Chat view focus 和命令注册;测试工作区禁用 RPC autoStart,并已扩展为 P4-14 E2E 入口。 | -| [x] | P4-3:Provider capability model data contract | `README.md`、`docs/roadmap.md`、`docs/deepseek-api-adapter.md`、`docs/json-rpc-protocol.md` | 已完成:新增 ADR 0006;`agent.initialize.capabilities.provider` 暴露 DeepSeek V4 model capability data contract,包含 thinking、tool calls/tool choice、FIM、stream/cache usage、上下文和输出限制,首版不引入 heavy trait。 | -| [x] | P4-4:事件 payload schema 与协议 fixture 对齐 | `docs/json-rpc-protocol.md`、`docs/turn-loop.md`、`packages/protocol` | 已完成:新增 `docs/protocol/event-payloads.v1.json`,将 `provider.requested`、`tool.completed`、`run.completed` 纳入 Rust/TypeScript 兼容性测试;VS Code 初始化协议版本不匹配会给出明确提示。 | -| [x] | P4-5:RPC 高频事件输出节流与批量发送策略 | `docs/rpc-server.md`、`docs/vscode-extension.md`、`docs/json-rpc-protocol.md` | 已完成:实时 live event wire 层支持 `agent.eventBatch` 批量发送,VS Code manager 按序分发;Run Log 仍逐事件写入并保持 `seq` 为事实来源,`agent.resume` replay 仍按单事件结构输出。 | -| [x] | P4-6:`agent.cancel` 类型化 helper 与 Chat Cancel UI | `README.md`、`docs/vscode-extension.md`、`docs/json-rpc-protocol.md` | 已完成:新增 `RpcServerManager.cancel()` typed helper、Cancel 按钮和运行中 composer 状态;覆盖 typed cancel RPC 边界测试。 | -| [x] | P4-7:Problems 面板诊断进入 Context Builder | `README.md`、`docs/vscode-extension.md`、`docs/context-capsule.md` | 已完成:VS Code 发送 turn 时采集 Problems 快照,并通过 `agent.sendTurn.attachments` 的 diagnostic attachment 注入;前端按协议 attachment 上限裁剪、优先保留 error,Core/Context Builder 继续负责 attachment 校验和 token 预算。 | -| [x] | P4-8:Terminal command approval | `README.md`、`docs/vscode-extension.md`、`docs/approval-model.md` | 已完成:审批 payload 支持命令、cwd、风险等级、风险原因、上一条 shell 输出摘要字段和 never/session/workspace 持久化语义;P4-16 后 VS Code 主审批弹窗不再暴露复杂持久化选项。 | -| [x] | P4-9:审批持久化存储 | `docs/approval-model.md`、`docs/tool-system.md`、`docs/vscode-extension.md` | 已完成:RPC pending queue 支持 session 内存复用和 workspace `.prole-coder/approvals.v1.json` 存储;继续在 Core 和 RPC 层禁止 network/destructive 风险持久化。 | -| [x] | P4-10:provider、model、预算、审批策略和 RPC 命令配置界面 | `README.md`、`docs/vscode-extension.md` | 已完成:VS Code `ProleCoder: Open Settings` 打开扩展设置,并从 `agent.initialize` ready state 展示 provider、默认模型、context/output budget、模型 capability、审批策略、RPC command/args/autostart 和 state dir;配置贡献只包含非敏感 RPC/FIM 选项,API Key 仍只由 RPC server 环境读取。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`。 | -| [x] | P4-11:真实 hunk 级 patch 审批 | `docs/tool-system.md`、`docs/vscode-extension.md`、`docs/json-rpc-protocol.md` | 已完成:首版限定 `apply_patch`,Core 解析 unified diff 生成稳定 hunk id,`ApprovalDecision::ApprovedHunks` 会过滤 patch 后只应用已批准 hunks;RPC pending queue 校验未知、重复、空 hunk 和持久化误用;VS Code modal 提供 selected hunk quick pick;`tool.approvalRequired` / `tool.approvalResolved` 已扩展并纳入协议 fixture。验收:`cargo test -p prole-coder-agent-core filter_apply_patch_hunks_keeps_only_selected_hunks`、`cargo test -p prole-coder-agent-core turn_loop_applies_only_approved_patch_hunks`、`cargo test -p prole-coder-agent-rpc approval_queue_resolves_hunk_level_patch_decisions`、`pnpm -r test`。 | -| [x] | P4-12:FIM completion preview | `README.md`、`docs/deepseek-api-adapter.md`、`docs/vscode-extension.md` | 已完成:新增 `agent.previewFim` RPC 类型、Rust request loop 分发、CLI provider factory FIM preview、DeepSeek beta `/completions` FIM adapter、fixture provider 预览和 VS Code 原生 inline completion provider;前端模型选择只使用 P4-3 capability data 的 `supportsFim`,不靠模型名称推断。验收:`cargo test -p prole-coder-agent-rpc request_loop_handles_fim_preview_requests`、`cargo test -p prole-coder-cli fixture_rpc_provider_factory_returns_fim_preview`、`pnpm -r typecheck`、`pnpm -r test`。 | -| [x] | P4-13:VSIX alpha / pre-release 打包与安装说明 | `docs/release.md`、`docs/vscode-extension.md` | 已完成:新增 `pnpm run vsix:alpha` / `vscode/extension/scripts/packageAlphaVsix.mjs`,构建 protocol 与 extension 后在 `target/vsix/` 保留可安装 pre-release VSIX,并生成 SHA-256 校验和;脚本校验 VSIX manifest 的 pre-release 标记与 publisher/name/version 一致性,`docs/release.md` 记录 clean user-data/extensions 目录安装验收步骤。验收:`pnpm run vsix:alpha`。 | -| [x] | P4-14:补齐 end-to-end 集成测试覆盖 | `README.md`、`docs/vscode-extension.md`、`docs/testing.md` | 已完成:`pnpm run vscode:test-electron` 在隔离 user-data/extensions profile 中启动 VS Code test host,并通过 `vscode/extension/test/fixtures/rpcFixtureServer.mjs` 本地 JSON-RPC fixture 覆盖 extension activation、Chat sendTurn、Problems diagnostic attachments、自动审批回传、Cancel、Run List / resume 和 Chat timeline/submission/context 状态;VSIX 安装后的 clean 环境基础交互继续按 `docs/release.md` 的可重复手动路径验收。 | -| [x] | P4-15:原生 VS Code Chat Participant `@prole` | `README.md`、`docs/vscode-extension.md` | 已完成:贡献 `contributes.chatParticipants` 和 `vscode.chat.createChatParticipant`,让 `ProleCoder: Open Chat` 优先打开 VS Code 原生 Chat 侧栏并填入 `@prole`;Activity Bar Webview 保留为高级状态面板。验收:`pnpm -r typecheck`、`pnpm -r test`、`pnpm run vscode:test-electron`、`pnpm run vsix:smoke`、`pnpm run vsix:alpha`。 | -| [x] | P4-16:审批 UX 简化 | `README.md`、`docs/approval-model.md`、`docs/vscode-extension.md` | 已完成:主审批弹窗收敛为 Approve / Reject;`apply_patch` 多 hunk 继续提供 Select Hunks quick pick;持久化批准能力保留在后端策略与 RPC 队列,不在主审批弹窗里暴露复杂选项。验收:`commands.test.ts` 覆盖简化 choices、一次性 approve、reject/dismiss 和 hunk 选择。 | -| [x] | P4-17:自动上下文压缩 | `README.md`、`docs/context-capsule.md`、`docs/json-rpc-protocol.md` | 已完成:Sidebar Chat 和原生 Chat Participant 从历史对话/事件流生成受限长度的 `explicit_content` attachment,交给已有 Context Capsule 处理,让连续对话自然承接上下文;Sidebar timeline 单条消息会先限长,避免极端长流式输出造成过大的中间文本。验收:`automaticContext.test.ts` 和 `chatParticipantCore.test.ts` 覆盖压缩、预算裁剪、单条 timeline 消息限长、attachment 合并和 turn runner 注入。 | -| [x] | P4-18:测试与打包验收 | `README.md`、`docs/testing.md`、`docs/release.md` | 已完成:覆盖自动上下文压缩、原生 Chat Participant turn runner、简化审批 choices、extension-host E2E、VSIX smoke/alpha 打包和文档一致性,并补充 Chat Participant 早到 terminal event 缓冲回归测试。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`、`pnpm run vscode:test-electron`、`pnpm run vsix:smoke`、`pnpm run vsix:alpha`、`git diff --check` 和敏感信息扫描。 | - -## Phase 5:TUI 与生态扩展 +| [x] | P4-1a:VSIX dry-run packaging smoke | `README.md`、`docs/vscode-extension.md`、`docs/release.md` | 已完成:新增 `pnpm run vsix:smoke` / `vscode/extension/scripts/vsixDryRunSmoke.mjs`,构建 protocol 与 extension 后在 `target/` 下临时生成 VSIX,检查 `.vscodeignore`、`workspace:*` 运行时边界、media asset、compiled `out/`、activationEvents 和包内排除规则,并清理临时产物;不代表 P4-13 完成。验收:`pnpm -r --if-present vsix:smoke`。 | +| [x] | P4-2a:`@vscode/test-electron` 最小 harness | `README.md`、`docs/vscode-extension.md` | 已完成:新增 `pnpm run vscode:test-electron` / `vscode/extension/scripts/runVscodeIntegrationTests.mjs`,测试 extension activation、trusted workspace、Chat view focus 和命令注册;测试工作区禁用 RPC autoStart,并已扩展为 P4-14 E2E 入口。 | +| [x] | P4-3a:Provider capability model data contract | `README.md`、`docs/roadmap.md`、`docs/deepseek-api-adapter.md`、`docs/json-rpc-protocol.md` | 已完成:新增 ADR 0006;`agent.initialize.capabilities.provider` 暴露 DeepSeek V4 model capability data contract,包含 thinking、tool calls/tool choice、FIM、stream/cache usage、上下文和输出限制,首版不引入 heavy trait。 | +| [x] | P4-4a:事件 payload schema 与协议 fixture 对齐 | `docs/json-rpc-protocol.md`、`docs/turn-loop.md`、`packages/protocol` | 已完成:新增 `docs/protocol/event-payloads.v1.json`,将 `provider.requested`、`tool.completed`、`run.completed` 纳入 Rust/TypeScript 兼容性测试;VS Code 初始化协议版本不匹配会给出明确提示。 | +| [x] | P4-5a:RPC 高频事件输出节流与批量发送策略 | `docs/rpc-server.md`、`docs/vscode-extension.md`、`docs/json-rpc-protocol.md` | 已完成:实时 live event wire 层支持 `agent.eventBatch` 批量发送,VS Code manager 按序分发;Run Log 仍逐事件写入并保持 `seq` 为事实来源,`agent.resume` replay 仍按单事件结构输出。 | +| [x] | P4-6a:`agent.cancel` 类型化 helper 与 Chat Cancel UI | `README.md`、`docs/vscode-extension.md`、`docs/json-rpc-protocol.md` | 已完成:新增 `RpcServerManager.cancel()` typed helper、Cancel 按钮和运行中 composer 状态;覆盖 typed cancel RPC 边界测试。 | +| [x] | P4-7a:Problems 面板诊断进入 Context Builder | `README.md`、`docs/vscode-extension.md`、`docs/context-capsule.md` | 已完成:VS Code 发送 turn 时采集 Problems 快照,并通过 `agent.sendTurn.attachments` 的 diagnostic attachment 注入;前端按协议 attachment 上限裁剪、优先保留 error,Core/Context Builder 继续负责 attachment 校验和 token 预算。 | +| [x] | P4-8a:Terminal command approval | `README.md`、`docs/vscode-extension.md`、`docs/approval-model.md` | 已完成:审批 payload 支持命令、cwd、风险等级、风险原因、上一条 shell 输出摘要字段和 never/session/workspace 持久化语义;P5-2 后 VS Code 主审批 UI 不再暴露复杂持久化选项。 | +| [x] | P4-9a:审批持久化存储 | `docs/approval-model.md`、`docs/tool-system.md`、`docs/vscode-extension.md` | 已完成:RPC pending queue 支持 session 内存复用和 workspace `.prole-coder/approvals.v1.json` 存储;继续在 Core 和 RPC 层禁止 network/destructive 风险持久化。 | +| [x] | P4-10a:provider、model、预算、审批策略和 RPC 命令配置界面 | `README.md`、`docs/vscode-extension.md` | 已完成:VS Code `ProleCoder: Open Settings` 打开扩展设置,并从 `agent.initialize` ready state 展示 provider、默认模型、context/output budget、模型 capability、审批策略、RPC command/args/autostart 和 state dir;配置贡献只包含非敏感 RPC/FIM 选项,API Key 不进入 VS Code settings。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`。 | +| [x] | P4-11a:真实 hunk 级 patch 审批 | `docs/tool-system.md`、`docs/vscode-extension.md`、`docs/json-rpc-protocol.md` | 已完成:首版限定 `apply_patch`,Core 解析 unified diff 生成稳定 hunk id,`ApprovalDecision::ApprovedHunks` 会过滤 patch 后只应用已批准 hunks;RPC pending queue 校验未知、重复、空 hunk 和持久化误用;VS Code 审批 UI 提供 selected hunk 选择;`tool.approvalRequired` / `tool.approvalResolved` 已扩展并纳入协议 fixture。P5-15c 后小规模普通 workspace 代码 patch 默认免审批,hunk 审批能力保留给 bulk patch、workspace policy 文件 patch、高风险 patch 或显式审批路径。验收:`cargo test -p prole-coder-agent-core filter_apply_patch_hunks_keeps_only_selected_hunks`、`cargo test -p prole-coder-agent-rpc approval_queue_resolves_hunk_level_patch_decisions`、`pnpm -r test`。 | +| [x] | P4-12a:FIM completion preview | `README.md`、`docs/deepseek-api-adapter.md`、`docs/vscode-extension.md` | 已完成:新增 `agent.previewFim` RPC 类型、Rust request loop 分发、CLI provider factory FIM preview、DeepSeek beta `/completions` FIM adapter、fixture provider 预览和 VS Code 原生 inline completion provider;前端模型选择只使用 P4-3 capability data 的 `supportsFim`,不靠模型名称推断。验收:`cargo test -p prole-coder-agent-rpc request_loop_handles_fim_preview_requests`、`cargo test -p prole-coder-cli fixture_rpc_provider_factory_returns_fim_preview`、`pnpm -r typecheck`、`pnpm -r test`。 | +| [x] | P4-13a:VSIX alpha / pre-release 打包与安装说明 | `docs/release.md`、`docs/vscode-extension.md` | 已完成:新增 `pnpm run vsix:alpha` / `vscode/extension/scripts/packageAlphaVsix.mjs`,构建 protocol 与 extension 后在 `target/vsix/` 保留可安装 pre-release VSIX,并生成 SHA-256 校验和;脚本校验 VSIX manifest 的 pre-release 标记与 publisher/name/version 一致性,`docs/release.md` 记录 clean user-data/extensions 目录安装验收步骤。验收:`pnpm run vsix:alpha`。 | +| [x] | P4-14a:补齐 end-to-end 集成测试覆盖 | `README.md`、`docs/vscode-extension.md`、`docs/testing.md` | 已完成:`pnpm run vscode:test-electron` 在隔离 user-data/extensions profile 中启动 VS Code test host,并通过 `vscode/extension/test/fixtures/rpcFixtureServer.mjs` 本地 JSON-RPC fixture 覆盖 extension activation、Chat sendTurn、Problems diagnostic attachments、自动审批回传、Cancel、Run List / resume 和 Chat timeline/submission/context 状态;VSIX 安装后的 clean 环境基础交互继续按 `docs/release.md` 的可重复手动路径验收。 | + +## Phase 5:VS Code Codex-like UX 与开发工作流 + +状态:已完成。P5-1 到 P5-19z 已完成,覆盖原生 Chat、审批简化、自动上下文、测试验收、Output Channel、API key/model 配置、错误恢复、只读 Git context、GitLens-like commit / PR 文案生成工作流、Sidebar 连续会话 / Run 删除 / 折叠事件 UX、结构化 provider 配置错误码与恢复动作、真实试用回归修复包、第二批真实试用 UX 收敛、模型回合预算 continuation approval、Provider Key/Model 图标按钮、Chat run mode 自动推断、PowerShell 验证命令规则收敛、Codex-like 交互细化、第三批真实试用 UX 收敛、第四批真实试用回归验收记录、历史分页和合并前 UX backlog 收口。G4 自动 commit / push / create PR 留作后续增强。 | 状态 | 任务 | 来源 | 说明 | | --- | --- | --- | --- | -| [ ] | TUI RPC 入口和事件流消费 | `README.md`、`docs/tui.md` | 消费 `agent.event`,展示 run、turn、工具和审批状态。 | -| [ ] | TUI Chat / Plan / Diff / Tools / Context / Settings 页面 | `README.md`、`docs/tui.md` | 完整 ratatui 界面仍未实现。 | -| [ ] | TUI hunk 级审批、run resume、配置文件和 release binary | `README.md`、`docs/tui.md` | 建议在 VS Code 核心体验和共享事件管线稳定后推进。 | -| [ ] | 多 active run 与事件订阅模型 | `docs/rpc-server.md`、`docs/turn-loop.md`、`docs/tool-system.md` | 扩展 active run、审批队列、取消句柄和事件订阅模型,支持多个 run 或多个前端并发推进。 | -| [ ] | 更细的 replay 语义 | `docs/rpc-server.md`、`docs/tool-system.md`、`docs/run-log.md` | 明确 resume 时哪些事件原样回放、哪些需要历史标记,并与 pending approval / hunk 审批状态保持一致。 | -| [ ] | MCP client、本地模型/私有推理服务 adapter、包管理器工具、issue/PR 工具、审计包导出 | `docs/roadmap.md` | 生态扩展应在核心闭环、编辑器体验和 DeepSeek 差异化稳定后推进。 | +| [x] | P5-1a:原生 VS Code Chat Participant `@prole` | `README.md`、`docs/vscode-extension.md` | 已完成:贡献 `contributes.chatParticipants` 和 `vscode.chat.createChatParticipant`,让 `ProleCoder: Open Chat` 优先打开 VS Code 原生 Chat 侧栏并填入 `@prole`;Activity Bar Webview 保留为高级状态面板。验收:`pnpm -r typecheck`、`pnpm -r test`、`pnpm run vscode:test-electron`、`pnpm run vsix:smoke`、`pnpm run vsix:alpha`。 | +| [x] | P5-2a:审批 UX 简化 | `README.md`、`docs/approval-model.md`、`docs/vscode-extension.md` | 已完成:主审批动作收敛为 Approve / Reject;`apply_patch` 多 hunk 继续保留 hunk 选择边界,但最多 5 个 `expectedFiles` 的普通 workspace 代码 patch 默认免审批;持久化批准能力保留在后端策略与 RPC 队列,不在主审批 UI 里暴露复杂选项。验收:`commands.test.ts` 覆盖 legacy modal adapter 的简化 choices、一次性 approve、reject/dismiss 和 hunk 选择。 | +| [x] | P5-3a:自动上下文压缩 | `README.md`、`docs/context-capsule.md`、`docs/json-rpc-protocol.md` | 已完成:Sidebar Chat 和原生 Chat Participant 从历史对话/事件流生成受限长度的 `explicit_content` attachment,交给已有 Context Capsule 处理,让连续对话自然承接上下文;Sidebar timeline 单条消息会先限长,避免极端长流式输出造成过大的中间文本。验收:`automaticContext.test.ts` 和 `chatParticipantCore.test.ts` 覆盖压缩、预算裁剪、单条 timeline 消息限长、attachment 合并和 turn runner 注入。 | +| [x] | P5-4a:UX 收敛测试与打包验收 | `README.md`、`docs/testing.md`、`docs/release.md` | 已完成:覆盖自动上下文压缩、原生 Chat Participant turn runner、简化审批 choices、extension-host E2E、VSIX smoke/alpha 打包和文档一致性,并补充 Chat Participant 早到 terminal event 缓冲回归测试。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`、`pnpm run vscode:test-electron`、`pnpm run vsix:smoke`、`pnpm run vsix:alpha`、`git diff --check` 和敏感信息扫描。 | +| [x] | P5-5a:VS Code Output Channel 错误诊断 | `README.md`、`docs/vscode-extension.md`、`docs/testing.md` | 已完成:插件创建 `ProleCoder` Output Channel,Sidebar Chat 的 sendTurn、Run List refresh/resume/cancel 失败、原生 `@prole` Chat Participant turn 失败以及 RPC 启动/运行 warning 会写入完整日志;activation 层使用统一 notifier 分发 Output Channel 日志与 VS Code toast;侧边栏保留短状态并通过 title 暴露完整文本。验收:`logging.test.ts` 覆盖日志格式与输出分发,`chatParticipantCore.test.ts` 覆盖 RPC 失败写入 logger。 | +| [x] | P5-6a:DeepSeek API key SecretStorage、model selector 与 provider status | `README.md`、`docs/vscode-extension.md`、`docs/testing.md` | 已完成:新增 `ProleCoder: Configure DeepSeek API Key` / `Clear DeepSeek API Key` / `Select DeepSeek Model` / `Show Provider Status` 命令;API key 配置入口升级为 SecretStorage 多 key 管理器,列表展示 alias 与 masked key,支持 `+ Add` 添加 key+alias、选择 active key、行内 edit 按钮修改 alias 和 trash 按钮删除指定 key;删除非 active key 只刷新 SecretStorage/redaction,删除 active key 才更新 child env 并按 idle 状态重启 RPC;provider status 同时显示 key 来源/alias 与当前 model;Sidebar composer 常驻 Key/Model 按钮;SecretStorage 优先、process env fallback、missing 明确展示;RPC child env 继承 `process.env` 后覆盖 active `DEEPSEEK_API_KEY` 与选中的 `DEEPSEEK_MODEL`,不改变 CLI env 路径。 | +| [x] | P5-7a:统一 redaction 与 API key 错误恢复 UX | `docs/vscode-extension.md`、`docs/testing.md`、`docs/security-model.md` | 已完成:在 notifier/logger 边界统一脱敏 SecretStorage/env 中的 key,覆盖 Output Channel、toast 和 RPC startup failure;缺少 `DEEPSEEK_API_KEY` 时 Sidebar/原生 Chat 自动打开配置入口并在 Sidebar 错误状态保留修复按钮;API key 配置/清除或 model 切换后 idle 状态自动重启 RPC,active run 场景提示当前回合结束后生效。 | +| [x] | P5-8a:Git context 只读采集与大 diff attachment 管线 | `docs/vscode-extension.md`、`docs/testing.md`、`docs/context-capsule.md` | 已完成:优先使用 VS Code Git API 采集 repository、branch/upstream 和 staged diff;git CLI 仅作受控 fallback,cwd 来自 repository root;commit/PR 命令把 diff context 作为 `explicit_content` attachment 交给现有 Context Capsule 预算管线。 | +| [x] | P5-9a:Generate Commit Message 写入 Source Control inputBox | `README.md`、`docs/vscode-extension.md`、`docs/testing.md` | 已完成:新增命令从 staged diff 生成 Conventional Commit 风格候选 message,并写入 `repository.inputBox.value`;staged 为空时才询问是否使用 unstaged context;不自动 commit。 | +| [x] | P5-10a:Generate PR Description markdown 生成 | `README.md`、`docs/vscode-extension.md`、`docs/testing.md` | 已完成:基于 upstream tracking branch、`main`、`master` 或用户选择确定 base,采集 branch diff/stat/commit summary,生成 PR title/body markdown;首版提供有标题的 untitled markdown 预览/复制/打开入口,不自动创建 PR。 | +| [x] | P5-11a:Phase 5 UX 工作流验收与文档收敛 | `README.md`、`docs/roadmap.md`、`docs/testing.md` | 已完成:补齐 P5-6 到 P5-10 的单元测试、extension-host 验收、VSIX smoke/alpha 验证和文档一致性检查;Git workflow agent 终态事件已补幂等保护,明确 G4 自动 commit / push / create PR 为后续增强,需要接入审批模型后再做。验收:`pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`、`pnpm run vscode:test-electron`、`pnpm run vsix:smoke`、`pnpm run vsix:alpha`、`git diff --check` 和敏感信息扫描。 | +| [x] | P5-12a:Sidebar 连续会话、Run 删除与折叠事件 UX | `README.md`、`docs/vscode-extension.md`、`docs/json-rpc-protocol.md`、`docs/testing.md` | 已完成:RPC/protocol 新增 `agent.deleteRun`;Run Log 支持删除 inactive run;`agent.sendTurn.runId` 可复用已有 run 并自动递增 `turn_N`,Sidebar Chat 在 resume 后继续同一会话发送多轮 turn;tool/provider/request 等过程事件默认折叠,assistant 文本和最终 `run.completed.summary` 保持可见;完整事件 payload 写入 `Output > ProleCoder` 便于 debug;Turn Loop 默认注入最终回复摘要契约。验收:新增 Rust/TS 单元测试覆盖 deleteRun、多 turn run log、折叠 timeline、typed RPC delete 和 runHistory delete message。 | +| [x] | P5-13a:结构化 provider 配置错误码与恢复动作 | `docs/json-rpc-protocol.md`、`docs/vscode-extension.md`、`docs/testing.md` | 已完成:DeepSeek 缺少 API key 的 RPC `E_PROVIDER_ERROR` 返回 `data.provider` / `data.configurationError` / `data.recoverableAction`;VS Code Sidebar 和原生 Chat Participant 依据结构化 recoverable action 展示/触发 API key 配置入口,不再依赖后端英文错误消息;Run failed payload 同样可携带恢复动作。验收:新增 protocol、providerConfigurationUx、Chat Participant 和 CLI 单元测试覆盖结构化错误数据。 | +| [x] | P5-14a:Sidebar 对话专属视图与就地确认 | `docs/vscode-extension.md`、`docs/testing.md` | 已完成:进入 run 后切换 Sidebar 对话专属视图并隐藏 Runs/Context 管理面板;approval 和 run delete 改为 webview 就地确认 UI,避免系统 modal 打断对话流。验收:已补充相关 Sidebar 状态和消息处理测试。 | +| [x] | P5-14b:默认对话流、Work log 折叠和输入快捷键 | `docs/vscode-extension.md`、`docs/testing.md` | 已完成:默认只显示用户消息与 DeepSeek 回复;tool/provider/context/run completed 等过程事件收敛到默认折叠的 Work log;Work log 摘要只暴露当前工作状态;输入框 Enter 发送且 Shift+Enter 换行。验收:已补充 timeline / chat input 回归测试。 | +| [x] | P5-14c:malformed tool-call 本地诊断文件 | `docs/json-rpc-protocol.md`、`docs/run-log.md`、`docs/turn-loop.md` | 已完成:当 provider 返回的 tool-call `function.arguments` 无法解析为 JSON 时,Turn Loop 会在当前 run 的 `diagnostics/invalid-tool-arguments--.json` 写入脱敏后的累计 arguments,并在 `run.failed.diagnosticFile` / Sidebar failure card 指向该文件。验收:`cargo test -p prole-coder-agent-core diagnostic`、`pnpm --filter prole-coder-vscode test`。 | +| [x] | P5-14d:Sidebar UX extension-host E2E 回归 | `docs/testing.md`、`docs/vscode-extension.md` | 已完成:extension-host 测试新增 test-mode webview probe,覆盖真实 webview 中 resume 后继续发送新 turn、默认事件渲染、webview 内联确认后的 run 删除与 Run List 刷新、Sidebar 内联审批卡片、对话专属视图、Work log 默认折叠展示和 Enter / Shift+Enter 输入行为。验收:`pnpm run vscode:test-electron`。 | +| [x] | P5-14e:大 patch / 大文件分块式工具参数协议 | `docs/tool-system.md`、`docs/turn-loop.md`、`docs/run-log.md` | 已完成:Run Log 支持 run-scoped `payloads/` 文件写入、chunk append 和读取;`apply_patch` schema 新增 `payloadRef`,Turn Loop 可在 `tool.requested` 保留轻量 preview,并在执行前从当前 run 的 payload 文件 materialize `unifiedDiff`,校验 `sha256` / `sizeBytes` 后复用现有 schema、路径安全、hunk metadata 和 patch staging。小规模普通 workspace 代码 patch 默认免审批,超过 5 个 `expectedFiles` 的 bulk patch 或 workspace policy 文件 patch 会触发审批;chunk 追加阶段不写 workspace。验收:`cargo test -p prole-coder-agent-core payload`、`cargo test -p prole-coder-agent-core apply_patch_schema_accepts_run_scoped_payload_refs`。 | +| [x] | P5-14f:真实 provider / shell 运行稳定性收敛 | `docs/turn-loop.md`、`docs/tool-system.md`、`docs/vscode-extension.md`、`docs/testing.md` | 已完成:修复 `finishReason=length` 时误写空/截断 `run.completed.summary` 的问题,Core 会自动追问最终工作总结;补强 shell 工具的 `cwd`/平台提示与 Windows PowerShell UTF-8 stderr/stdout 输出,避免真实试用中出现 `/home/... &&` 误用后错误信息乱码;DeepSeek adapter 对发送阶段 transient timeout/connect/request 错误增加有界重试,缓解 Windows `os error 10053` 直接导致 run 失败的问题。 | +| [x] | P5-14g:Sidebar Markdown 渲染与渲染错误隔离 | `docs/vscode-extension.md`、`docs/testing.md` | 已完成:Sidebar 可见对话消息使用安全 DOM Markdown 渲染,覆盖标题、列表、代码块、表格、链接和行内格式;Work log / 工具输出继续保持纯文本便于排错;单条 Markdown 渲染失败会回退为纯文本,并通过 webview error -> `Output > ProleCoder` 记录诊断。验收:`pnpm --filter prole-coder-vscode typecheck`、`pnpm --filter prole-coder-vscode test`、`pnpm run vscode:test-electron`。 | +| [x] | P5-14h:Sidebar composer 交互与 Settings 入口回归修复 | `README.md`、`docs/vscode-extension.md`、`docs/testing.md` | 已完成:Enter 与 Send 按钮统一提交路径,Shift+Enter 继续换行;发送后立即显示 pending 用户消息,避免等待后端事件时界面空白;Settings 入口移到右上角齿轮按钮,composer 保留 API Key / Model 快捷入口。验收:`pnpm --filter prole-coder-vscode typecheck`、`pnpm --filter prole-coder-vscode test`、`pnpm run vscode:test-electron`。 | +| [x] | P5-15a:Sidebar webview bootstrap、历史加载与发送交互回归修复 | `docs/testing.md`、`docs/vscode-extension.md` | 已完成:webview 初始化和事件处理增加异常隔离并写入 `Output > ProleCoder`,初始化成功会发送 ready 诊断;CSP 明确允许 VS Code webview nonce script 执行,Markdown parser 在 HTML template literal 中的反斜杠转义已修正,避免 inline script parse error 后历史加载和发送事件全部失效;`agent.event` 高频 replay 的 snapshot/submission/context 推送改为短延迟合并,避免历史 `assistant.delta` 连续回放时反复完整 Markdown 重渲染卡住 Sidebar;安全 DOM Markdown renderer 已补齐 horizontal rule 分隔线渲染和表格 `\|` 转义边界;新增 `webviewHtml.test.ts` 解析生成后的内联脚本,extension-host fixture 用中文 Markdown 表格、inline code 与 horizontal rule 分块 replay 覆盖历史对话渲染;Enter 默认发送、Shift+Enter 换行,composition 状态下 Enter 先阻止 textarea 换行但不误发;Send/keyboard 交互继续由 extension-host probe 覆盖。 | +| [x] | P5-15b:Sidebar Markdown renderer 模块化与独立边界测试 | `docs/testing.md`、`docs/vscode-extension.md` | 已完成:抽取 `webviewMarkdown.ts` 作为 Sidebar 安全 DOM Markdown renderer 脚本模块,`chatView.ts` 只负责注入和调用;新增 `webviewMarkdown.test.ts` 用轻量 fake DOM 独立覆盖 horizontal rule、表格 `\|` 转义、嵌套/未闭合行内标记、链接安全边界和大文本渲染,`webviewHtml.test.ts` 继续覆盖生成后 inline script parse smoke。 | +| [x] | P5-15c:Workspace patch 免审批与 patch mismatch 可恢复 | `docs/approval-model.md`、`docs/tool-system.md`、`docs/vscode-extension.md` | 已完成:`apply_patch` 仍报告 `write` 风险,但最多 5 个 `expectedFiles` 的受限 workspace 代码修改默认 `approval=none`,不会在 Sidebar 打断对话;超过阈值的 bulk patch 或 workspace policy 文件 patch 会动态要求审批并展示风险原因;`InvalidPatch` / `PatchFileMismatch` / `PatchHunkMismatch` 会作为 `tool.completed status=failed` 返回给模型,便于重新读取文件后重试,而不是直接 `run.failed` 终止,且这些可恢复失败发生在写盘前,`reversePatch` 为空。 | +| [x] | P5-15d:历史 run resume 审批副作用隔离 | `docs/vscode-extension.md`、`docs/testing.md` | 已完成:VS Code `RpcServerManager` 会把 `agent.resume` 返回的 replay 事件在本地标记为 `replay: true`;`ApprovalEventController` 和 `PatchDiffPreviewController` 忽略 replayed `tool.approvalRequired` / patch preview,避免打开旧对话时重新弹出历史审批卡片或重开旧 diff;后续同一 run 的 live 事件达到 `nextSeq` 后恢复正常审批。验收:`pnpm --filter prole-coder-vscode typecheck`、`pnpm --filter prole-coder-vscode test`。 | +| [x] | P5-15e:Sidebar 编辑重发与发送中停止入口 | `docs/vscode-extension.md`、`docs/testing.md` | 已完成:用户消息新增 Edit 回填入口,可修改后在同一会话继续发送;当前 Sidebar 会隐藏被编辑消息,避免同屏保留旧消息副本,并把 superseded message id 写入 VS Code webview state 以跨 webview reload 保留;composer 发送按钮改为回车符号,turn 运行中切换为方块停止按钮并复用 `agent.cancel`,隐藏旧的独立 Cancel 按钮;Enter 只负责发送,运行中不会误触停止。验收:补充 extension-host probe 覆盖编辑重发、旧消息隐藏、superseded id 持久化和停止按钮取消路径。 | +| [x] | P5-15f:输出长度上限放宽与 length 截断工具调用续写 | `docs/cli.md`、`docs/turn-loop.md`、`docs/vscode-extension.md` | 已完成:CLI/RPC DeepSeek 默认 `--max-output-tokens` 从 1024 放宽到 65536,避免真实项目修复时过早触发长度截断;当 provider 以 `finishReason=length` 结束且已经拼出工具调用列表时,Turn Loop 不执行可能截断的工具调用,而是追加恢复提示,要求模型丢弃半截参数并重新发出完整 JSON 工具调用或继续简短工作。验收:新增 Turn Loop fixture 覆盖 length 截断工具调用不会执行 partial call、后续完整 `apply_patch` 可继续完成。 | +| [x] | P5-15g:Patch preview 宽容 hunk 计数解析 | `docs/vscode-extension.md`、`docs/testing.md` | 已完成:VS Code native diff preview 对模型生成的小幅 underdeclared hunk header 改为按实际 patch 行重新计算 old/new count,避免 `hunk contains more lines than declared` 只影响预览;严重超量仍会拒绝预览,防止损坏 patch 被误展示;Core 仍保留真实 patch 应用与路径安全校验。验收:补充 `patchPreview.test.ts` 覆盖计数偏小的 unified diff 仍能生成 preview、严重偏差会失败。 | +| [x] | P5-15h:编辑重发的后端历史覆盖 / 分叉语义 | `docs/json-rpc-protocol.md`、`docs/run-log.md`、`docs/vscode-extension.md` | 已完成:在 `agent.sendTurn` 增加可选 `supersedes` 元数据,Sidebar Edit 重发会传递被覆盖用户消息的 `messageId` 和可选 `turnId`;Rust RPC/Core 会把该信息写入新 `turn.started.payload.supersedes`,Run Log 继续追加保留旧输入用于审计;`ChatEventTimeline` 和自动上下文会据此隐藏/跳过 superseded 用户消息,reload/resume 后不再只依赖 webview state。验收:新增 TS timeline/input/automatic-context/Electron fixture 覆盖与 Rust `agent-rpc` run log 测试。 | +| [x] | P5-16a:模型回合预算 continuation approval | `docs/cli.md`、`docs/turn-loop.md`、`packages/protocol` | 已完成:Turn Loop 默认 `max_model_turns` 从 8 提高到 50,并把该值改为每次继续审批前的 provider request 窗口;窗口耗尽时写入 `tool.approvalRequired(toolName="model_turn_budget")`,前端/CLI 可通过现有 `agent.approve` 队列批准后继续下一段窗口,避免真实长任务直接 `E_MAX_MODEL_TURNS` 失败。验收:新增 Core 测试覆盖默认 50 与预算耗尽后批准继续,Protocol/VS Code 测试覆盖 `model_turn_budget` approval payload 校验。 | +| [x] | P5-16b:Provider Key/Model 图标按钮同排展示 | `vscode/extension/src/chatView.ts`、`vscode/extension/test/electron/index.ts` | 已完成:Sidebar composer 的 DeepSeek API key 与 model 入口改为同排的紧凑图标按钮,钥匙、大脑和发送/停止按钮均使用跟随 VS Code 主题色的黑白灰 SVG 图标,并保留 title / aria-label 供 tooltip、键盘和辅助功能使用;Electron probe 改为断言可访问标签而不是旧可见文字。 | +| [x] | P5-16c:自动推断 Chat run mode 并隐藏模式下拉框 | `vscode/extension/src/chatView.ts`、`vscode/extension/src/chatInput.ts`、`docs/vscode-extension.md` | 已完成:Sidebar composer 默认隐藏 `edit` / `ask` / `plan` / `review` 下拉框,普通发送不再提交隐藏 select 的默认值;`chatInput` 在协议边界根据用户文本自动推断 run mode,问答走 `ask`,实现/修复走 `edit`,计划讨论走 `plan`,代码审查走 `review`,并支持 `/ask`、`plan:` 等轻量显式前缀覆盖推断且发送前剥离前缀。验收:TS 单测覆盖推断、前缀剥离、无效 mode 拒绝和 webview HTML 隐藏 selector;extension-host probe 覆盖运行中/完成后 mode selector 均保持隐藏。 | +| [x] | P5-16d:PowerShell 验证命令规则与假失败收敛 | `docs/turn-loop.md`、`docs/tool-system.md`、`crates/agent-core/src/turn_loop.rs` | 已完成:根据真实试用中 `cargo test 2>&1` 在 Windows PowerShell 5.1 下把 CLIXML/progress 噪声带入 stderr、导致“stdout 全绿但工具状态 failed”的现象,补强 provider 工具使用契约:Windows 下模型不应为测试/验证命令手动追加 `2>&1`,应让 shell 工具分别捕获 stdout/stderr;Turn Loop prompt/context 测试覆盖该规则,Output/run log 继续保留真实 stderr。 | +| [x] | P5-17a:Sidebar 消息编辑图标细化 | `vscode/extension/src/chatView.ts`、`vscode/extension/test/electron/index.ts` | 已完成:把用户消息 Edit 文本按钮改为跟随 VS Code 主题色的简笔画笔图标按钮,保留 aria-label/title 供键盘、tooltip 和自动化测试使用;Electron probe 改为断言可访问标签。 | +| [x] | P5-17b:Work log 按指令调用分组 | `vscode/extension/src/chatEvents.ts`、`vscode/extension/src/chatView.ts` | 已完成:默认仍只显示用户/assistant 消息,过程事件在 Work log 内按 provider iteration、tool call、approval 等工作边界分组折叠,避免中间思考和工具生命周期混成一长串;完整细节继续写入 Output 和 run log。 | +| [x] | P5-17c:本对话命令审批复用 | `vscode/extension/src/chatView.ts`、`vscode/extension/src/approvalFlow.ts` | 已完成:Sidebar 审批卡支持“Approve for conversation”,按 runId + cwd + command 记忆 shell 审批,后续同 run 的同命令自动发送一次性 approve;该复用不跨 run、不写入持久批准存储。 | +| [x] | P5-17d:只读 shell 命令白名单免审批 | `crates/agent-core/src/turn_loop.rs`、`docs/tool-system.md` | 已完成:对当前 workspace 内严格只读的 `rg`、`Get-Content` / `gc` / `cat` / `type`、`Select-String`、`Get-ChildItem` / `gci` / `dir`、`Test-Path` 和 `git diff/status/log/show` 建立保守白名单,风险评估可把这些 shell 调用降为 `read` 并免审批;包含管道、重定向、变量展开、子命令、写文件参数、绝对/父级路径、`.env` / `.secrets` / `.git` 等敏感 workspace 路径的情况仍需审批。 | +| [x] | P5-17e:运行中 steer 指导入口 | `packages/protocol`、`crates/agent-rpc`、`crates/agent-core`、`vscode/extension/src/chatView.ts` | 已完成:新增 `agent.steer`,允许 Sidebar 在 turn 运行中通过输入框发送补充指导;Turn Loop 在下一次 provider 请求前注入 steer 消息并记录 `turn.steered` 事件,停止仍由方块按钮触发 cancel。 | +| [x] | P5-17f:composer 发送与 steer 二次确认 UX | `vscode/extension/src/chatView.ts` | 已完成:普通消息提交后立即清空输入框;turn 运行中输入框有 steer 文本时发送按钮切回发送图标,点击或回车先显示内联确认卡,用户可发送或删除该 steer;输入框为空时按钮仍是停止当前 turn。 | +| [x] | P5-17g:最终总结后 Work log 自动折叠 | `vscode/extension/src/chatView.ts` | 已完成:运行中 Work log 自动展开以展示当前 provider/tool 分组,任务结束并返回最终总结后重新折叠历史工作过程,保留按指令调用分组的可展开细节。 | +| [x] | P5-17h:版本查询只读 shell 白名单 | `crates/agent-core/src/turn_loop.rs`、`docs/tool-system.md`、`docs/turn-loop.md` | 已完成:`python --version`、`node --version`、`cargo --version` 等常见本地工具版本查询被识别为 workspace-safe 只读 shell 调用并免审批;带路径、额外参数或非版本查询形态仍按普通 shell 风险审批。 | +| [x] | P5-17i:Codex-like 思考段间工作摘要 | `vscode/extension/src/chatEvents.ts`、`vscode/extension/src/chatView.ts` | 已完成:Work log 在两段 provider 思考之间显示一句简短摘要,说明上一段修改了多少文件、执行了多少条 shell 指令;统计来自结构化 timeline item 字段,避免依赖截断后的 UI 文本。 | +| [x] | P5-18a:运行中单行 active work 状态栏 | `vscode/extension/src/chatView.ts` | 已完成:Sidebar 不再把 tool/provider/context 过程记录堆叠到对话中;turn 运行中只显示一个 `Working:` 活跃状态栏,完整工具生命周期继续写入 `Output > ProleCoder` 和 run log。 | +| [x] | P5-18b:assistant 分段与平凡工具过滤摘要 | `vscode/extension/src/chatEvents.ts` | 已完成:同一 turn 的 assistant delta 会在工具/steer 边界后开启新消息段,避免工具前说明与最终小结合并;下一段 assistant 回复出现前插入 `Activity` 摘要,统计修改文件数和 shell 指令数,并通过平凡工具库过滤 `read_file` / `search` / manifest / git diff/status 等只读过程。 | +| [x] | P5-18c:steer 顺序与确认卡位置 | `vscode/extension/src/chatEvents.ts`、`vscode/extension/src/chatView.ts` | 已完成:`turn.steered` 用户消息保持在后续 assistant 分段之前;steer 二次确认卡移动到聊天界面与 textarea 之间,而不是挤在底部按钮行里。 | +| [x] | P5-18d:pending steer 即时聊天反馈 | `vscode/extension/src/chatView.ts`、`vscode/extension/src/chatEvents.ts` | 已完成:用户确认 steer 后立即在聊天框显示本地 pending 用户消息;在真实 `turn.steered` 事件写入前,运行中的 active work 状态栏会显示在 pending steer 上方,等后端返回同一 `steerId` 的事件后自动切换为 run log 回放中的正式用户消息。 | +| [x] | P5-18e:未知工具调用可恢复与 `write_file` 纠偏 | `crates/agent-core/src/turn_loop.rs`、`docs/tool-system.md`、`docs/turn-loop.md` | 已完成:模型请求不存在的 `write_file` 等未知工具时不再直接 `run.failed`,Turn Loop 会记录失败的 `tool.requested` / `tool.completed` 并把 `E_UNKNOWN_TOOL` 工具结果喂回 provider,提示改用 `apply_patch`;未知工具参数不写入 run log,避免整文件内容或敏感文本被展开。 | +| [x] | P5-18f:steer queued 状态与强指令注入 | `crates/agent-core/src/turn_loop.rs`、`vscode/extension/src/chatView.ts`、`docs/json-rpc-protocol.md`、`docs/turn-loop.md` | 已完成:本地 pending steer 在真实 `turn.steered` 到达前显示为 `You (queued)`,避免误导用户以为模型已消费;Turn Loop 注入 steer 时明确声明其为最新用户运行中指令,后续用户可见回复和动作必须优先遵守,提升“改用中文”等运行中指导的遵循率。 | +| [x] | P5-18g:完成后中间 assistant / Activity 分段折叠 | `vscode/extension/src/chatEvents.ts`、`vscode/extension/test/chatEvents.test.ts` | 已完成:当 run 已完成且最终 assistant 总结可见时,最终总结前的中间过程会按用户消息 / steer 边界分成多个 `Earlier activity` 默认折叠块;展开后仍按原来的用户消息、assistant 分段和 `Activity` 摘要卡片渲染,保留每段从用户输入到下一次 steer / 总结之间的上下文。 | +| [x] | P5-18h:run summary changedFiles / verification metadata 追踪 | `crates/agent-core/src/turn_loop.rs`、`docs/turn-loop.md`、`docs/vscode-extension.md` | 已完成:Turn Loop 在首个 shell 工具执行前按需采集 workspace 文件指纹 baseline,完成时再 diff,避免 ask-only 或早失败 turn 预先扫描整个 workspace;快照排除 `.git`、`.prole-coder`、`target`、`node_modules` 和 `.vscode-test`,将 shell 写入造成的新增/修改/删除路径合并进 `run.completed.changedFiles`;常见验证命令形态会汇总为 `verificationStatus=passed/failed/skipped`,并拒绝 `npm run deploy-check`、`echo test complete`、`python scripts/generate_testdata.py` 等非验证误判。验收:新增 Core 单元测试覆盖 shell 写文件和验证命令最终 metadata。 | +| [x] | P5-18i:Provider idle timeout 重试与连接失败收口 | `crates/agent-core/src/turn_loop.rs`、`docs/json-rpc-protocol.md`、`docs/turn-loop.md` | 已完成:真实试用发现 run 停在 `provider.requested` 且 run log 没有后续事件时,Turn Loop 会对 `complete_stream` 建连和 streaming `next()` 等待设置默认 60 秒 no-progress timeout;连续最多 5 次 provider attempt 都没有进展时写入 `run.failed(code=E_PROVIDER_TIMEOUT)`,中间每次重试写入 `provider.retrying(reason=provider_idle_timeout, timeoutMs, retriesRemaining)`。验收:新增 Core fixture 覆盖建连 pending 最终失败、stream pending 后重试成功;Sidebar retry item 显示 `timeoutMs`。 | +| [x] | P5-18j:已知工具 schema 错误可恢复 | `crates/agent-core/src/turn_loop.rs`、`docs/tool-system.md`、`docs/turn-loop.md` | 已完成:真实试用发现模型向 `read_file` 传入 `limit` 等未知字段时,Turn Loop 不再直接 `run.failed(code=E_INVALID_TOOL_ARGUMENTS)`;合法 JSON 但不符合已知工具 schema 的参数会记录失败的 `tool.requested` / `tool.completed`,把 `E_INVALID_TOOL_ARGUMENTS`、schema 错误和纠偏 guidance 作为 tool result 喂回 provider,让模型可改用 `startLine` / `endLine` 等正确参数继续。验收:新增 Core fixture 覆盖 schema mismatch 可恢复并继续下一轮 provider request;malformed JSON 仍保留诊断文件和终止错误。 | +| [x] | P5-18k:完成后折叠保留用户 / steer 消息 | `vscode/extension/src/chatEvents.ts`、`vscode/extension/test/chatEvents.test.ts` | 已完成:真实试用发现最终总结出现后,`Earlier activity` 折叠块会把初始用户消息和运行中 steer 一起折叠隐藏;现在用户消息作为折叠边界并原样留在主时间线,折叠块只包含两条用户消息之间的 assistant / Activity / work 过程。验收:补充 timeline 单元测试覆盖用户消息和 steer 可见、展开后仍恢复原 assistant / Activity 卡片格式。 | +| [x] | P5-18l:长 run timeline 裁剪与持久化边界 | `vscode/extension/src/chatEvents.ts`、`vscode/extension/test/chatEvents.test.ts`、`docs/vscode-extension.md` | 已完成:真实试用发现长 run 中大量工具 / provider 过程事件会触发 Sidebar timeline 裁剪,导致初始用户消息、steer 和 steer 前后的记录从聊天框消失;现在默认过程 item 上限从 300 提高到 1200,裁剪只移除可从 workspace run log 重放的过程项,用户消息、steer、assistant 分段和 terminal 结果不参与裁剪;`.prole-coder/runs//events.jsonl` 继续作为不可丢对话内容的 workspace 持久源,直到 `agent.deleteRun` 删除该 run。验收:补充 timeline 单元测试覆盖长 run 裁剪后用户 / steer 仍可见,完成后折叠仍按用户 / steer 边界展开为原 assistant / Activity 卡片格式。 | +| [x] | P5-19a:四项目真实试用回归验收记录 | `docs/testing.md`、`docs/vscode-extension.md` | 已完成:真实 VS Code 插件试用在 `test/projects/agent_misc_tests_working` 对四个独立测试项目完成修复,最新 run `run_7196_1780870724369` 状态为 completed;人工复验 `01-js-ledger-lite`、`02-python-note-index`、`03-rust-path-rules`、`04-js-event-reducer` 的测试分别为 4/4、4/4、5/5、5/5 通过,共 18 个测试通过。验收同时确认 UI 中长 run 折叠和 steer 后消息保留效果正常;干净 baseline 已迁入 `test/projects/agent_misc_tests` 并纳入 git 追踪,`agent_misc_tests_working` 作为 ignored 本地工作副本。 | +| [x] | P5-19b:Sidebar timeline 向上滚动历史分页 | `crates/agent-rpc/src/lib.rs`、`packages/protocol/src/index.ts`、`vscode/extension/src/chatEvents.ts`、`vscode/extension/src/chatView.ts`、`docs/json-rpc-protocol.md`、`docs/vscode-extension.md` | 已完成:协议/RPC 新增只读 `agent.loadRunEvents`,按 `beforeSeq` / `limit` 从 workspace run log 返回历史 event envelope,不重新发 live notification;Sidebar timeline 保存已加载事件索引,向上滚动时先恢复被裁剪的过程项,必要时再请求更早事件页,避免用 `agent.resume` 触发旧审批副作用;webview 的滚动加载节流由 extension 在 `loadEarlierTimeline` 完成/失败后通过 `timelineHistory` 消息确认重置,不再依赖 1 秒本地计时。验收:新增 Rust request loop / handler 测试、TypeScript RPC manager 测试、timeline prepend/reveal 测试和 webview scroll/ack 消息覆盖。 | +| [x] | P5-19z:真实试用 UX backlog 合并前收口 | `README.md`、`docs/testing.md`、`docs/vscode-extension.md` | 已完成:Phase 5 合并主线前的真实试用 UX backlog 已收口;后续新发现的性能、稳定性、长任务、上下文压缩、Markdown 兼容性和大工具参数问题进入 Phase 6 性能调试或后续阶段,不再阻塞 Phase 5 完成。 | + +## Phase 6:AGENT 性能调试 -## Phase 6:发布与治理 +| 状态 | 任务 | 来源 | 说明 | +| --- | --- | --- | --- | +| [ ] | P6-1a:性能观测事件与日志字段基线 | `README.md`、`docs/roadmap.md`、`docs/testing.md` | 统一记录 context build、provider streaming、tool execution、run log append/load、RPC event batching、VS Code render 和 Markdown 渲染耗时;先定义字段和脱敏/截断边界,再接入调优。 | +| [ ] | P6-2a:mini project 性能回归集与恢复流程 | `README.md`、`test/projects/README.md`、`docs/testing.md` | 只追踪 `test/projects/agent_misc_tests` 干净基线,`agent_misc_tests_working` 作为 ignored 本地工作副本;补充恢复脚本/任务和真实插件试用耗时记录入口。 | +| [ ] | P6-3a:长上下文与缓存命中 profiling | `docs/context-capsule.md`、`docs/turn-loop.md`、`docs/testing.md` | 分析 Context Capsule 大小、自动上下文压缩、DeepSeek prompt cache hit/miss、max output 和 replay-required reasoning 对长任务耗时的影响。 | +| [ ] | P6-4a:工具调用、patch 和 workspace diff 性能调试 | `docs/tool-system.md`、`docs/turn-loop.md` | 定位 shell 输出、large patch arguments、patch preview、workspace snapshot diff、run log truncation 和审批预览在长任务中的瓶颈。 | +| [ ] | P6-5a:VS Code Sidebar / RPC 吞吐与卡顿分析 | `docs/vscode-extension.md`、`docs/rpc-server.md` | 评估 event batching、timeline paging、Markdown renderer、Output Channel 日志和 webview snapshot 合并策略,避免长 run 造成 UI 卡顿。 | +| [ ] | P6-6a:性能预算、调优报告和回归门槛 | `docs/testing.md`、`docs/roadmap.md` | 为合并主线后的真实任务建立可重复性能预算和回归记录,决定哪些指标阻塞后续 TUI/生态扩展。 | + +## Phase 7:TUI 与生态扩展 + +| 状态 | 任务 | 来源 | 说明 | +| --- | --- | --- | --- | +| [ ] | P7-1a:TUI RPC 入口和事件流消费 | `README.md`、`docs/tui.md` | 消费 `agent.event`,展示 run、turn、工具和审批状态。 | +| [ ] | P7-1b:TUI Chat / Plan / Diff / Tools / Context / Settings 页面 | `README.md`、`docs/tui.md` | 完整 ratatui 界面仍未实现。 | +| [ ] | P7-2a:TUI hunk 级审批、run resume、配置文件和 release binary | `README.md`、`docs/tui.md` | 建议在性能调试和共享事件管线稳定后推进。 | +| [ ] | P7-3a:多 active run 与事件订阅模型 | `docs/rpc-server.md`、`docs/turn-loop.md`、`docs/tool-system.md` | 扩展 active run、审批队列、取消句柄和事件订阅模型,支持多个 run 或多个前端并发推进。 | +| [ ] | P7-3b:更细的 replay 语义 | `docs/rpc-server.md`、`docs/tool-system.md`、`docs/run-log.md` | 明确 resume 时哪些事件原样回放、哪些需要历史标记,并与 pending approval / hunk 审批状态保持一致。 | +| [ ] | P7-4a:MCP client、本地模型/私有推理服务 adapter、包管理器工具、issue/PR 工具、审计包导出 | `docs/roadmap.md` | 生态扩展应在核心闭环、编辑器体验、性能预算和 DeepSeek 差异化稳定后推进。 | + +## Phase 8:发布与治理 | 状态 | 任务 | 来源 | 说明 | | --- | --- | --- | --- | -| [x] | 许可证策略确定为 AGPL-3.0-or-later | `README.md`、`docs/release.md`、`docs/adr/0003-use-agpl-3.0-or-later.md` | 正式发布文件仍在后续任务。 | -| [ ] | 发布 `LICENSE`、源码获取说明和网络服务源码提供说明 | `README.md`、`docs/release.md` | 发布前必需。 | -| [ ] | 发布源码包、Cargo crate、npm wrapper、VSIX、GitHub Release 校验和 | `README.md`、`docs/release.md` | 需要发布脚本和产物签名/校验策略。 | -| [ ] | 公开 roadmap、issue 模板和贡献流程增强 | `README.md`、`CONTRIBUTING.md` | 面向外部协作者。 | -| [ ] | reproducible build 说明 | `README.md`、`docs/release.md` | 发布可信度要求。 | +| [x] | P8-1a:许可证策略确定为 AGPL-3.0-or-later | `README.md`、`docs/release.md`、`docs/adr/0003-use-agpl-3.0-or-later.md` | 正式发布文件仍在后续任务。 | +| [ ] | P8-2a:发布 `LICENSE`、源码获取说明和网络服务源码提供说明 | `README.md`、`docs/release.md` | 发布前必需。 | +| [ ] | P8-3a:发布源码包、Cargo crate、npm wrapper、VSIX、GitHub Release 校验和 | `README.md`、`docs/release.md` | 需要发布脚本和产物签名/校验策略。 | +| [ ] | P8-4a:公开 roadmap、issue 模板和贡献流程增强 | `README.md`、`CONTRIBUTING.md` | 面向外部协作者。 | +| [ ] | P8-5a:reproducible build 说明 | `README.md`、`docs/release.md` | 发布可信度要求。 | diff --git a/docs/protocol/tool-registry.v1.json b/docs/protocol/tool-registry.v1.json index e4a741e..778820e 100644 --- a/docs/protocol/tool-registry.v1.json +++ b/docs/protocol/tool-registry.v1.json @@ -30,7 +30,7 @@ { "name": "apply_patch", "risk": "write", - "approval": "required", + "approval": "none", "status": "executor_implemented" }, { @@ -62,6 +62,12 @@ "risk": "read", "approval": "none", "status": "schema_only" + }, + { + "name": "model_turn_budget", + "risk": "exec", + "approval": "required", + "status": "schema_only" } ] } diff --git a/docs/release.md b/docs/release.md index f3bfcab..3daa7af 100644 --- a/docs/release.md +++ b/docs/release.md @@ -61,7 +61,7 @@ target/vsix/prole-coder-vscode-0.1.0-alpha.vsix.sha256 `vscode/extension/scripts/packageAlphaVsix.mjs` 使用 `@vscode/vsce` 的 `preRelease: true` 打包选项,保留 `package.json` 中的稳定版本号,并通过文件名中的 `alpha` 标识渠道。脚本会校验 VSIX manifest 中的 VS Code pre-release 标记、publisher/name/version 一致性、`onChatParticipant:prole-coder.chatParticipant` activation event 和 `@prole` Chat Participant 贡献点,并写出 SHA-256 校验和。产物位于被忽略的 `target/vsix/`,不提交到仓库。 -当前 alpha VSIX 用于本地安装和 clean 环境验收,不等同于 Marketplace / Open VSX 发布。正式对外发布前仍需在 Phase 6 补齐 `LICENSE` 文件、源码获取说明、发布 notes、公开 release checksum 和可复现构建说明。 +当前 alpha VSIX 用于本地安装和 clean 环境验收,不等同于 Marketplace / Open VSX 发布。正式对外发布前仍需在 Phase 8 补齐 `LICENSE` 文件、源码获取说明、发布 notes、公开 release checksum 和可复现构建说明。 ## VSIX clean 环境安装验收 diff --git a/docs/roadmap.md b/docs/roadmap.md index 9bd58a5..85a9d47 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # 路线图 -状态:草案,Phase 1 Agent Core MVP、合并主线前离线最终验收、Phase 2 的 1M Context Capsule 核心收敛、Phase 2e 展示型 demo 扩展、Phase 3 VS Code 插件核心与共享 RPC 事件队列、Phase 4 VS Code 深度集成与 Codex-like UX 收敛均已完成。 +状态:草案,Phase 1 Agent Core MVP、合并主线前离线最终验收、Phase 2 的 1M Context Capsule 核心收敛、P2-5 展示型 demo 扩展、Phase 3 VS Code 插件核心与共享 RPC 事件队列、Phase 4 VS Code 深度集成和 Phase 5 VS Code Codex-like UX 与开发工作流已完成;下一步进入 Phase 6 AGENT 性能调试,TUI 与生态扩展顺延到 Phase 7。 本文档把 README 中的大阶段拆成更可执行的优先级。README 保留项目入口和高层计划;这里记录跨模块的落地顺序、取舍和验收重点。具体任务的阶段、状态和来源统一登记在 `docs/phase-tasks.md`,阶段条目标记完成前应同步检查并更新该索引。 @@ -41,7 +41,7 @@ - streaming tool call 增量拼装验证:adapter 已区分 `ChatToolCallDelta` 与完整 `ChatToolCall`,`ChatToolCallAccumulator` 会按 `index` 拼接 arguments 并拒绝缺失或冲突元数据;`live_streaming_tool_call_accumulator_smoke_test` 已用真实 DeepSeek streaming 验收工具调用 delta 形态。 - Agent RPC Server 双向 request loop:`agent-rpc` 已支持 newline-delimited JSON-RPC request 读取、初始化顺序检查、`agent.initialize` / `agent.sendTurn` / `agent.resume` 分发、response/error 写回、EOF shutdown,以及 handler 返回事件的 `agent.event` 有序输出。 - RPC/CLI 实时事件输出:`AgentTurnLoop::run_turn_with_event_sink` 会在 Run Log 事件追加成功后立即调用 `TurnEventSink`;`StdioEventBridge` 已实现该接口,CLI `--json` 输出顺序与本地 `events.jsonl` 的 `seq` 一致,不再等 run 完成后批量回放。 -- CLI/RPC/TUI/VS Code 审批基础:Turn Loop 会写入 `tool.approvalRequired` 和 `tool.approvalResolved`;CLI 二进制支持 stdin/stderr 交互式 y/n 审批;RPC request loop 已分发 `agent.approve` / `agent.reject`;TypeScript 协议类型已补齐;TUI prompt 状态机和 VS Code modal approval adapter 已有测试覆盖。 +- CLI/RPC/TUI/VS Code 审批基础:Turn Loop 会写入 `tool.approvalRequired` 和 `tool.approvalResolved`;CLI 二进制支持 stdin/stderr 交互式 y/n 审批;RPC request loop 已分发 `agent.approve` / `agent.reject`;TypeScript 协议类型已补齐;TUI prompt 状态机、VS Code legacy modal adapter 和默认 Sidebar 内联审批入口已有测试覆盖。 - 真实 RPC Turn Loop handler:`AgentTurnLoopRpcHandler` 已通过 provider factory 复用 Core Turn Loop,`agent.sendTurn` 会创建 run log、驱动 provider 和工具执行,并把结果事件交给 request loop;CLI `rpc` 子命令已提供 stdio 入口。 - RPC 真实审批等待队列:`AgentTurnLoopRpcHandler` 会在 `tool.approvalRequired` 处登记 pending approval,后台 Turn Loop worker 等待 `agent.approve` / `agent.reject`,批准后继续执行工具,拒绝后记录 `tool.approvalResolved` 和 `run.failed`。 - RPC 审批超时/取消:pending approval 已记录过期时间;`agent.cancel` 和 request loop EOF shutdown 会取消等待审批的 active run,超时会自动解析为 expired,这些路径都会记录 `tool.approvalResolved` 和 `run.canceled`。 @@ -55,7 +55,7 @@ - `pnpm run check` 基线验证:Windows 本机已通过默认 CI 等价检查。 - Context Builder token 预算测试:当前已覆盖 token 报告、可选上下文超预算省略、必需上下文超预算失败和 `context.built` payload 形状。 -- Patch apply 失败恢复:`apply_patch` 已改为先 staging 再写盘,并有多文件失败不留半修改的回归测试。 +- Patch apply 失败恢复:`apply_patch` 已改为先 staging 再写盘,并有多文件失败不留半修改的回归测试;hunk mismatch / file mismatch / invalid patch 会作为 failed tool result 回传给模型重试。 - `reasoning_content` 状态机边界:已覆盖空消息、多个 tool-call assistant message 和 replay 计数。 - `CancellationToken` 并发语义:已覆盖 clone 共享状态、首次取消原因保持和并发取消。 - CLI event stream 顺序:进程级 smoke test 已验证 event `seq` 连续递增和关键事件子序列。 @@ -72,7 +72,7 @@ - 进入 Phase 3 VS Code 插件核心与共享 RPC 交互管线。 - RPC 全双工事件 writer 队列已完成:`agent.sendTurn` 会在创建 run 后返回 accepted,后台通过有界队列和单 writer 持续推送 live `agent.event`,断连时会取消 active run。 -- TUI 保留为正式前端,但优先级调整到 VS Code 核心体验之后,复用同一套 RPC 事件管线和审批模型。 +- TUI 保留为正式前端,但优先级调整到 VS Code 核心体验和 AGENT 性能调试之后,复用同一套 RPC 事件管线和审批模型。 Phase 1 收官后优化池: @@ -86,8 +86,8 @@ Phase 1 收官后优化池: P0 不追求: - 完整 VS Code Sidebar:已移入 Phase 3。 -- 完整 TUI:已移入 Phase 5。 -- VS Code/TUI 真实前端 UI 接入:Phase 3 优先 VS Code,Phase 5 再补齐 TUI。 +- 完整 TUI:已移入 Phase 7。 +- VS Code/TUI 真实前端 UI 接入:Phase 3 优先 VS Code,Phase 7 再补齐 TUI。 - MCP 生态。 - 多 provider UI。 - 大仓库 1M token 基准。 @@ -105,33 +105,39 @@ P0 不追求: 目标:让 VS Code 插件成为 Agent Core 的薄前端,而不是第二套 Agent。 -Phase 3 已交付 VS Code 插件核心体验;Phase 4 已完成 18 项 VS Code 深度集成任务,在不推翻现有 Sidebar Chat 的前提下补齐 Codex-like 原生 Chat 入口、简化审批和自动上下文压缩;TUI 随后进入 Phase 5,与生态扩展一起推进。Marketplace 发布不阻塞 Phase 4,当前已具备可安装 VSIX alpha / pre-release 产物和安装说明。Phase 2e 展示型 demo 已经给 VS Code Context Viz / Approval / Run Log UI 提供可观察样本。 +Phase 3 已交付 VS Code 插件核心体验;Phase 4 已完成 14 项 VS Code 深度集成任务;Phase 5 已完成 Codex-like UX 与开发工作流。P5-1 到 P5-19z 已完成原生 Chat 入口、简化审批、自动上下文压缩、UX 验收、Output Channel 错误诊断、插件内多 API key/model 配置、统一 redaction/错误恢复、只读 Git context、commit message 写入 SCM inputBox、PR markdown 生成、Sidebar 连续会话、Run 删除和折叠事件 UX、结构化 provider 配置错误码与恢复动作、真实试用回归修复包、Markdown/edit resend 后端语义、模型回合预算 continuation approval、Provider Key/Model 图标入口、run mode 自动推断、PowerShell 验证命令规则、Work log 分组、本对话命令审批复用、workspace-scoped 只读 shell 白名单、运行中 steer、steer 二次确认、终态后过程折叠、版本查询免审批、思考段间工作摘要、active work 单行状态、assistant 分段、pending steer 即时反馈、未知工具可恢复、provider idle timeout 重试、已知工具 schema mismatch 可恢复、长 run timeline 裁剪边界、run summary metadata 追踪、四项目真实试用回归验收、Sidebar timeline 历史分页和合并前 UX backlog 收口。下一步进入 Phase 6 AGENT 性能调试;TUI 与生态扩展顺延到 Phase 7。Marketplace 发布不阻塞 Phase 4/5,当前已具备可安装 VSIX alpha / pre-release 产物和安装说明。P2-5 展示型 demo 已经给 VS Code Context Viz / Approval / Run Log UI 提供可观察样本。 优先事项: - VSIX dry-run packaging smoke 和 `@vscode/test-electron` 最小 harness 已完成,已提前验证打包、activation、trusted workspace 和 Chat view 基础加载。 -- Phase 4 新增 P4-15 到 P4-18 的 Codex-like UX 收敛已完成:原生 `@prole` Chat Participant、`Open Chat` 右侧 Chat 入口、简化审批 choices 和自动上下文压缩 attachment。 +- Phase 5 的 P5-1 到 P5-19z Codex-like UX 与开发工作流已完成;后续真实试用发现的性能、稳定性和长任务问题进入 Phase 6。 +- G4 自动 commit / push / create PR 暂不纳入 Phase 5 完成口径,后续需要接入审批模型后再做。 - Provider capability model data contract 已完成,首版通过 `agent.initialize` 暴露给前端,不引入 heavy trait。 - 事件 payload schema、协议 fixture 与 RPC 高频事件批量发送已完成,batch 不改变 Run Log `seq` 和 replay 语义。 - `agent.cancel` 类型化 helper 与 Chat Cancel UI 已接入,并与 Terminal approval 做轻量 composer UX review。 - Problems 面板诊断已通过 diagnostic attachments 进入 Context Builder,插件不新增独立 diagnostics 状态同步 RPC。 -- Terminal command approval 已支持命令、cwd、风险等级、风险原因、输出摘要字段和持久化语义;P4-16 后 VS Code 主审批弹窗保持 Approve / Reject。 +- Terminal command approval 已支持命令、cwd、风险等级、风险原因、输出摘要字段和持久化语义;P5-2 后 VS Code 主审批动作保持 Approve / Reject,P5-14 默认改为 Sidebar 内联审批卡片。 - 审批持久化存储已支持 session/workspace,继续禁止 network/destructive 风险持久化。 - 配置界面依赖 Provider capability model;provider、model、预算、审批策略和 RPC 命令配置都不得保存 API Key。 -- 真实 hunk 级 patch 审批首版限定 `apply_patch`,再扩展 Core/RPC 审批决策和 Run Log 记录。 +- P5-12 已完成:Sidebar Chat 复用 `agent.sendTurn.runId` 继续同一 run 多轮对话,支持 `agent.deleteRun` 删除 inactive run;tool/provider/request 等过程事件完整 payload 写入 `Output > ProleCoder`,可见对话流的过程展示已在 P5-18 收敛为运行中单行状态和回复间 Activity 摘要。 +- P5-13 已完成:provider 配置失败从前端字符串匹配升级为 RPC 结构化错误数据,缺少 DeepSeek API key 时返回 `E_PROVIDER_ERROR` 和 `data.recoverableAction`,供 VS Code/TUI 统一展示配置动作。 +- P5-14:真实试用回归修复包已完成,用于收敛 Sidebar 对话视图、内联确认、Work log 折叠、Markdown 渲染、webview 渲染诊断、provider/shell 稳定性和 composer / Settings 入口回归;具体 P5-14a 到 P5-14h 子项见 `docs/phase-tasks.md`。 +- P5-15 / P5-16 / P5-17a-i / P5-18a-l / P5-19a-b:真实试用 UX backlog 已分批收敛,包括 Markdown/edit resend、模型回合预算、Provider Key/Model 图标入口、run mode 自动推断、PowerShell 验证命令规则、Work log 分组、本对话命令审批复用、只读 shell 白名单、运行中 steer、steer 二次确认、终态后过程折叠、版本查询免审批、Activity 摘要、active work 单行状态、assistant 分段和平凡工具过滤、pending steer 即时反馈、未知工具可恢复、provider idle timeout 重试、已知工具 schema mismatch 可恢复、长 run timeline 裁剪边界、run summary metadata 追踪、四项目真实试用回归验收,以及 Sidebar timeline 历史分页。 +- P5-19z 已完成合并前 UX backlog 收口;新的 Runs、Key/Model/Settings、审批、Chat、Output 日志、上下文压缩和大工具参数稳定性问题进入 Phase 6 或后续阶段。 +- 真实 hunk 级 patch 边界首版限定 `apply_patch`;最多 5 个 `expectedFiles` 的普通 workspace 代码 patch 默认免审批,超过阈值的 bulk patch、workspace policy 文件 patch、高风险 patch 或显式审批路径仍使用 hunk 审批边界。 - FIM completion preview 依赖 Provider capability model,优先评估 VS Code 原生 inline completion 接入。 - VSIX alpha / pre-release 交付已完成,`pnpm run vsix:alpha` 会生成可安装 pre-release VSIX 和 SHA-256 校验和;end-to-end 集成测试已通过本地 JSON-RPC fixture server 覆盖 Chat sendTurn、Cancel、Problems diagnostics、自动审批、Run List / resume。 已完成的 Phase 3 基础: -- 原生 diff editor 展示 patch 已完成:VS Code 在 `apply_patch` 审批前打开虚拟 after 文档 diff,并保留 hunk boundary。 +- 原生 diff editor 展示 patch 已完成:VS Code 已具备虚拟 after 文档 diff 和 hunk boundary;最多 5 个 `expectedFiles` 的普通 workspace 代码 patch 默认免审批,diff / hunk 审批边界保留给 bulk patch、workspace policy 文件 patch、高风险 patch 或显式审批路径。 - Run List / resume 已完成:Sidebar Chat 用 `agent.listRuns` 展示最近 run summary,点击历史 run 后调用 `agent.resume` 并复用同一 `agent.event` 渲染路径。 - Context Capsule 可视化已完成:Sidebar Chat 消费 `context.built` metadata,展示三层 token 分布、来源纳入/省略、manifest、cache 和 estimator 摘要。 - Phase 3 命令风险分类器已完成:识别网络访问、依赖安装、远程 git、发布和破坏性命令,并在审批前升级风险。 -已完成的验收重点: +当前验收重点: -- Phase 4 的 18 个条目已全部在 `docs/phase-tasks.md` 标记 `[x]`,README 可以写 Phase 4 全部完成。 +- Phase 4 和 Phase 5 的条目已全部在 `docs/phase-tasks.md` 标记 `[x]`;README 可以把 Phase 5 写成整阶段完成。 - VS Code 插件可通过 VSIX 安装到 clean 环境。 - fixture provider 下 Chat sendTurn、Cancel、Problems diagnostics、审批和 Run List / resume 至少有一条 extension-host 或可重复手动验收路径。 - CLI 与 VS Code 对同一 fixture task 的关键 Run Log event type 顺序一致。 @@ -146,30 +152,30 @@ Phase 3 已交付 VS Code 插件核心体验;Phase 4 已完成 18 项 VS Code 目标:把 DeepSeek V4 的长上下文和思考模式变成可见、可审计的工作流。 -Phase 2 的 1M Context Capsule 按 4 个增量轮次推进: +Phase 2 的 1M Context Capsule 按 5 个 README 大项推进: -1. **Phase 2a:Context Capsule 数据模型与 Manifest v0** +1. **P2-1:Context Capsule 数据模型与 Workspace Manifest** - [x] `read_file` 增加 `sha256` / `sizeBytes`。 - [x] 定义 `ContextCapsule`、`ContextSection`、`CachePlacement` 和稳定 renderer。 - [x] 实现 workspace manifest v0:结构化 JSON、canonical `manifestHash`、默认 `maxEntries=500`、硬安全排除、默认工程排除、`.gitignore` + `.prole-coderignore`。 - [x] Context Builder 接入 manifest summary,并扩展 `context.built` payload。 -2. **Phase 2b:TokenEstimator 与稳定前缀** +2. **P2-2:TokenEstimator 与稳定前缀** - [x] 建立 `TokenEstimator` trait,保留 `utf8_bytes` 默认估算器。 - [x] 增加基于 provider usage 样本的 `CalibratedEstimator`,但仍标注 `exact=false`,且不保存 prompt 原文。 - [x] 按 `CachePlacement::{StablePrefix, DynamicPrelude, TurnSuffix}` 构建缓存友好 prompt,并输出 `stablePrefixHash` 与稳定前缀预算。 -3. **Phase 2c:Attachments、provider summary 与 cache 实验** +3. **P2-3:Attachments、provider summary 与 cache 实验** - [x] 接入 `agent.sendTurn.attachments` 的 file、selection/explicit_content、diagnostic。 - [x] 新增 `provider.completed` 事件,记录模型、duration、usage、cache hit/miss 和 stream 摘要。 - - [x] 建立 DeepSeek cache hit/miss ignored live experiment 的基础解析路径;更大重复前缀样本归入 Phase 2d 前增强。 + - [x] 建立 DeepSeek cache hit/miss ignored live experiment 的基础解析路径;更大重复前缀样本归入 P2-4 前增强。 -4. **Phase 2d:大仓库验收与体积控制** +4. **P2-4:大仓库验收与体积控制** - [x] 200K、500K、900K 样例仓库 token 预算和 Context Capsule ignored/manual 验收。 - [x] 超预算解释、Run Log 输出截断和脱敏包边界。 - [x] tool call JSON Schema 通用校验层,且在 typed deserialization 前执行。 -5. **Phase 2e:合并主线前展示型 demo 扩展** +5. **P2-5:合并主线前展示型 demo 扩展** - [x] `demo-context`:展示 manifest summary、Context Capsule sections、included/omitted sources 和 `context.built` payload。 - [x] `demo-truncation`:展示 Run Log 脱敏、截断、`runLogTruncation`,并区分截断、空输出和缺失字段。 - [x] `demo-schema`:展示 tool call arguments 在 typed deserialization 前被 JSON Schema 拒绝。 @@ -190,7 +196,19 @@ Phase 2 的 1M Context Capsule 按 4 个增量轮次推进: - 同一输入两次构建的 `StablePrefix` 渲染完全一致,修改 `TurnSuffix` 不影响稳定前缀。 - Manifest 的 ignore、sha256、manifest hash、截断和 omitted reason 均可离线测试。 -## P3:生态扩展 +## P3:AGENT 性能调试 + +目标:在 VS Code 体验合并主线后,先把长任务的性能、稳定性和可诊断性量化下来,再推进新的前端和生态扩展。 + +候选事项: + +- 统一 context build、provider streaming、tool execution、run log、RPC event 和 VS Code render 的耗时字段。 +- 用 `test/projects/agent_misc_tests` 作为干净 mini project 性能回归集,`agent_misc_tests_working` 作为本地 ignored 工作副本。 +- 分析 DeepSeek cache hit/miss、自动上下文压缩、reasoning replay 和 max output 对长任务耗时的影响。 +- 定位 shell 输出、large patch arguments、workspace snapshot diff、patch preview 和审批预览的瓶颈。 +- 建立性能预算、调优记录和合并后回归门槛。 + +## P4:生态扩展 目标:在核心闭环、编辑器体验和 DeepSeek 差异化稳定后,再扩展通用能力。 diff --git a/docs/rpc-server.md b/docs/rpc-server.md index 11a8b06..4470397 100644 --- a/docs/rpc-server.md +++ b/docs/rpc-server.md @@ -1,6 +1,6 @@ # Agent RPC Server -状态:`0.1.0` Phase 1 基础 stdio 事件桥接、`TurnEventSink` 实时输出桥接、双向 request loop、真实 Turn Loop handler、RPC pending approval 等待队列、审批超时、pending run 取消语义、EOF shutdown 取消、provider/tool 协作式取消信号和 Run Log 写入串行化已实现;Phase 3 已完成 reader/writer 全双工事件队列、`agent.sendTurn` 早返回和 writer failure 断连取消;Phase 4 已完成初始化 capability data contract、事件 payload fixture 和实时 `agent.eventBatch` wire 层批量发送。 +状态:`0.1.0` Phase 1 基础 stdio 事件桥接、`TurnEventSink` 实时输出桥接、双向 request loop、真实 Turn Loop handler、RPC pending approval 等待队列、审批超时、pending run 取消语义、EOF shutdown 取消、provider/tool 协作式取消信号和 Run Log 写入串行化已实现;Phase 3 已完成 reader/writer 全双工事件队列、`agent.sendTurn` 早返回和 writer failure 断连取消;Phase 4 已完成初始化 capability data contract、事件 payload fixture 和实时 `agent.eventBatch` wire 层批量发送;Phase 5 已接入 active run `agent.steer`。 Agent RPC Server 是 CLI、TUI、VS Code 插件和 Rust Agent Core 之间的协议边界。它不重新实现工具执行、上下文构建或 turn loop;它负责把前端 request 转换为 Core 调用,把 Core / Run Log 事件转换为 JSON-RPC notification。 @@ -27,13 +27,14 @@ Agent RPC Server 是 CLI、TUI、VS Code 插件和 Rust Agent Core 之间的协 - `AgentRpcRequestHandler`:RPC request loop 与真实 Core 执行逻辑之间的 handler trait,并提供 EOF shutdown hook。 - `AgentTurnLoopRpcHandler`:通过 provider factory 复用 Core `AgentTurnLoop` 的真实 handler。它会在 `agent.sendTurn` 时创建 run log、启动后台 Turn Loop worker,并在创建 run 后立即返回 accepted;live 事件通过有界队列交给 request loop 的单 writer 持续输出。 - `RpcApprovalQueue` / `RpcApprovalPolicy`:在 `tool.approvalRequired` 事件出现时登记 pending approval,并让后台 Turn Loop 在 `ApprovalPolicy::decide` 中等待 `agent.approve` / `agent.reject` / `agent.cancel` 或超时唤醒。 +- `TurnSteerQueue`:active run 持有的运行中指导队列。`agent.steer` 会把用户补充消息追加到队列,后台 Turn Loop 在下一次 provider request 前写入 `turn.steered` 并注入该消息。 - `SerializedRunLog`:RPC active run 持有共享的同步 run log,worker append 和 `agent.resume` load 通过同一把锁串行化。 - `CancellationToken`:RPC active run 持有一个可克隆 token,并注入 `AgentTurnInput`;`agent.cancel` 会设置 token,让 provider wrapper 和命令类工具协作式停止。 - `AgentRpcServer`:维护初始化状态,解析单行 JSON-RPC request,分发给 handler,并写回 response / error。 - `agent.listRuns`:通过 Run Log summary metadata 返回本地 run 列表,不扫描完整事件日志。 - `run_stdio_request_loop`:使用 reader thread 读取 newline-delimited JSON-RPC message,同时消费 live event queue;所有 response、error、replay event、live `agent.event` / `agent.eventBatch` notification 都经同一个 writer 串行输出。stdin EOF 或 writer failure 会取消 active run 并 flush 收尾事件。 -当前 request loop 已支持 `agent.initialize`、`agent.sendTurn`、`agent.approve`、`agent.reject`、`agent.cancel`、`agent.resume` 和 `agent.listRuns` 的基础分发。`AgentTurnLoopRpcHandler` 已实现真实 `agent.sendTurn`、基于 Run Log 的 `agent.resume`、基于 summary metadata 的 `agent.listRuns`、单 active run 的 pending approval 等待队列、pending approval 超时、取消、EOF shutdown 取消和 provider/tool 协作式停止。RPC crate 本身仍不直接绑定 DeepSeek provider 或 fixture provider;具体 provider 由外部 factory 注入,CLI 的 `rpc` 子命令当前提供 DeepSeek / fixture factory。 +当前 request loop 已支持 `agent.initialize`、`agent.sendTurn`、`agent.approve`、`agent.reject`、`agent.cancel`、`agent.steer`、`agent.resume`、`agent.listRuns` 和 `agent.deleteRun` 的基础分发。`AgentTurnLoopRpcHandler` 已实现真实 `agent.sendTurn`、基于 Run Log 的 `agent.resume`、基于 summary metadata 的 `agent.listRuns`、单 active run 的 pending approval 等待队列、pending approval 超时、运行中 steer、取消、EOF shutdown 取消和 provider/tool 协作式停止。RPC crate 本身仍不直接绑定 DeepSeek provider 或 fixture provider;具体 provider 由外部 factory 注入,CLI 的 `rpc` 子命令当前提供 DeepSeek / fixture factory。 本机可通过以下命令启动 stdio RPC server: @@ -43,7 +44,7 @@ prole rpc 测试和前端开发时可使用 `prole rpc --provider fixture --fixture final` 获得不联网的确定性 provider。 -VS Code 插件当前已提供基础进程监管:插件激活后会按 `prole-coder.rpc.command` 和 `prole-coder.rpc.args` 启动该 stdio server,发送 `agent.initialize`,把 stdout 中的 `agent.event` / `agent.eventBatch` notification 转发给前端事件 handler,并在进程退出或启动失败时更新状态和提示用户。扩展侧 `RpcServerManager` 已提供 typed `sendTurn`、`approve`、`reject`、`listRuns` 和 `resume` helper;Sidebar Chat 会用 `agent.listRuns` 填充最近 run 列表,并用 `agent.resume` 回放历史事件。 +VS Code 插件当前已提供基础进程监管:插件激活后会按 `prole-coder.rpc.command` 和 `prole-coder.rpc.args` 启动该 stdio server,发送 `agent.initialize`,把 stdout 中的 `agent.event` / `agent.eventBatch` notification 转发给前端事件 handler,并在进程退出或启动失败时更新状态和提示用户。扩展侧 `RpcServerManager` 已提供 typed `sendTurn`、`approve`、`reject`、`cancel`、`steer`、`listRuns`、`resume` 和 `deleteRun` helper;Sidebar Chat 会用 `agent.listRuns` 填充最近 run 列表,并用 `agent.resume` 回放历史事件。 ## 数据流 @@ -111,11 +112,11 @@ RPC 层不负责重新脱敏 payload。当前 Run Log 写入时已经调用基 `agent.sendTurn` 创建 run 后返回 accepted;后续 live events 由后台 worker 通过有界队列持续投递给 request loop。request loop 会把当前可立即取出的连续 live events 批量写为 `agent.eventBatch`,单个 live event 仍写为 `agent.event`。`agent.resume` 等 replay 型方法仍可随 response 返回一组历史 `RunLogEvent`,request loop 会先写 JSON-RPC response,再按顺序写 replay `agent.event` notification。这样保持“request 已被接受”和“事件开始抵达”的边界清晰,同时允许长 provider request 期间继续向前端推送事件,且不改变 Run Log `seq` 与 replay 语义。 -`AgentTurnLoopRpcHandler` 已不再用拒绝策略模拟审批。`agent.sendTurn` 会启动后台 Turn Loop worker,并立即返回 accepted;worker 通过 `TurnEventSink` 把 `tool.approvalRequired` 等事件写入 Run Log 后同步投递到 live queue。如果需要审批,worker 在内存队列中等待。随后 `agent.approve` / `agent.reject` 会解析对应 `approvalId`、唤醒 worker,并继续输出 `tool.approvalResolved`、工具执行和 run 结束事件。`agent.cancel` 会设置 active run 的 `CancellationToken`,同时取消尚未解析的 pending approval;等待审批时会写入 `tool.approvalResolved(decision="canceled")` 和 `run.canceled`,provider/tool 执行中取消会以 `E_RUN_CANCELED` 写入 `run.canceled`。request loop 读到 EOF 或写 stdout 失败时会触发断连取消;默认 300 秒审批超时会写入 `tool.approvalResolved(decision="expired")` 和 `run.canceled`。 +`AgentTurnLoopRpcHandler` 已不再用拒绝策略模拟审批。`agent.sendTurn` 会启动后台 Turn Loop worker,并立即返回 accepted;worker 通过 `TurnEventSink` 把 `tool.approvalRequired` 等事件写入 Run Log 后同步投递到 live queue。如果需要审批,worker 在内存队列中等待。随后 `agent.approve` / `agent.reject` 会解析对应 `approvalId`、唤醒 worker,并继续输出 `tool.approvalResolved`、工具执行和 run 结束事件。`agent.steer` 会校验 active `runId` 并把非空消息追加到该 run 的 steer queue;下一次 provider request 前,worker 会写入 `turn.steered` 并把 steer 文本注入消息历史。`agent.cancel` 会设置 active run 的 `CancellationToken`,同时取消尚未解析的 pending approval;等待审批时会写入 `tool.approvalResolved(decision="canceled")` 和 `run.canceled`,provider/tool 执行中取消会以 `E_RUN_CANCELED` 写入 `run.canceled`。request loop 读到 EOF 或写 stdout 失败时会触发断连取消;默认 300 秒审批超时会写入 `tool.approvalResolved(decision="expired")` 和 `run.canceled`。 同一个 active run 的 Run Log 由 `SerializedRunLog` 保护:后台 Turn Loop worker 是唯一实际追加者,`agent.resume` 如果读取的是当前 active run,会通过同一个同步句柄 load,而不是直接绕过锁读取磁盘文件。这样能保证前端 replay 看到的 `seq` 总是来自完整事件边界。 -这意味着当前 RPC server 已具备真实审批等待、取消、超时、EOF / writer failure 断连取消、协作式 provider/tool 停止语义、命令子进程树清理,以及 `agent.sendTurn` 早返回后的后台 live event streaming/batching。后续增强重点转向多 active run 和更强 sandbox。 +这意味着当前 RPC server 已具备真实审批等待、运行中 steer、取消、超时、EOF / writer failure 断连取消、协作式 provider/tool 停止语义、命令子进程树清理,以及 `agent.sendTurn` 早返回后的后台 live event streaming/batching。后续增强重点转向多 active run 和更强 sandbox。 ## Request Loop 规则 @@ -149,6 +150,7 @@ RPC 层不负责重新脱敏 payload。当前 Run Log 写入时已经调用基 - `agentRejectMethod` - `agentCancelMethod` - `agentListRunsMethod` +- `agentDeleteRunMethod` - `agentEventMethod` - `JsonRpcRequest` - `JsonRpcResponse` @@ -161,6 +163,7 @@ RPC 层不负责重新脱敏 payload。当前 Run Log 写入时已经调用基 - `CancelParams` / `CancelResult` - `ResumeParams` / `ResumeResult` - `ListRunsParams` / `ListRunsResult` / `RpcRunSummary` +- `DeleteRunParams` / `DeleteRunResult` - `AgentEventEnvelope` - `AgentEventNotification` diff --git a/docs/run-log.md b/docs/run-log.md index a81915e..d2b03f9 100644 --- a/docs/run-log.md +++ b/docs/run-log.md @@ -19,6 +19,8 @@ Run Log 是 Agent Core 的本地审计记录。它记录一次 run 中发生的 ```text /.prole-coder/runs//events.jsonl /.prole-coder/runs//summary.json +/.prole-coder/runs//diagnostics/*.json +/.prole-coder/runs//payloads/** ``` `.prole-coder/` 已在 `.gitignore` 中排除,run log 不应进入 Git 仓库。 @@ -47,20 +49,27 @@ crates/agent-core/src/run_log.rs `SerializedRunLog` 用于 RPC 等跨线程场景。它把同一个 `RunLog` 放入 `Mutex`,所有 clone 共享同一个 `next_seq` 和文件句柄状态;每次 append 都先拿锁,写入完成并推进 `seq` 后释放。`load` 也走同一把锁,避免 active run 正在写入时,`agent.resume` 从磁盘读到半条事件或不一致的序列。 -Run Log 本身只负责 append/load 串行化,不直接管理 stdout。Phase 3 的 RPC live event queue 建在 `TurnEventSink` 之上:事件先成功追加到 Run Log,再投递给 request loop 的单 writer 输出为 `agent.event` notification。因此前端看到的 live notification 与后续 `agent.resume` 回放共享同一组 `seq` 和 payload。 +Run Log 本身负责 append/load 串行化,并允许 Turn Loop 在同一 run 目录下写入受控诊断文件。Phase 3 的 RPC live event queue 建在 `TurnEventSink` 之上:事件先成功追加到 Run Log,再投递给 request loop 的单 writer 输出为 `agent.event` notification。因此前端看到的 live notification 与后续 `agent.resume` 回放共享同一组 `seq` 和 payload。 + +当 provider 返回的 tool-call `function.arguments` 无法解析为 JSON 时,Turn Loop 会在 `diagnostics/invalid-tool-arguments--.json` 写入脱敏后的累计 arguments、JSON 解析错误和 run/turn/tool metadata;对应 `run.failed` payload 会带 `diagnosticFile` 路径,便于 VS Code Output 或 Sidebar failure card 直接定位。文件名中的短哈希来自原始 tool call id,用于避免 sanitize 或截断后的名称碰撞。诊断文件继续留在 `.prole-coder/`,不应上传或同步。 + +大工具参数使用 `payloads/`:本地聚合器可以把 provider streaming chunk 追加到当前 run 的 payload 文件,最终 tool call 只通过 `payloadRef` 引用该文件。Run Log 只允许 workspace-relative run-scoped 路径,拒绝绝对路径和 `..`;payload 文件不会自动追加换行,避免改变 patch 内容。 ## Summary Metadata 每个 run 创建时会同步创建 `summary.json`。之后每次成功追加事件,Run Log 会根据事件更新 summary: - `run.started`:记录 `startedAtUnixMs`、`mode` 和运行状态。 -- `turn.started`:使用已脱敏的 `userTask` 更新 `title`。 +- `turn.started`:使用已脱敏的 `userTask` 更新 `title`;如果 payload 带 `supersedes`,表示该新 turn 是编辑重发并覆盖某个历史用户消息的展示语义,Run Log 仍保留旧 turn 原始输入用于审计。 +- `turn.steered`:记录 active run 运行中追加的用户指导,便于前端回放和本地故障复盘。 - `run.completed`:状态变为 `completed`,记录完成时间、最终摘要、变更文件和验证状态。 - `run.failed`:状态变为 `failed`,记录失败消息。 - `run.canceled`:状态变为 `canceled`,记录取消原因。 - `verification.completed`:更新最终验证状态。 -`summary.json` 还记录 `lastSeq`、`eventCount` 和 `updatedAtUnixMs`。`RunLogStore::list_run_summaries` 只读取 summary 文件,并按更新时间从新到旧排序;它不会为了列出 run 而扫描完整 `events.jsonl`。如果遇到旧版本或半写入 run 目录缺少 `summary.json`,列表接口会跳过该目录;针对单个 run 的 `load_run_summary` 仍会返回明确错误。 +`summary.json` 还记录 `lastSeq`、`eventCount` 和 `updatedAtUnixMs`。`events.jsonl` 是事实来源:如果进程在写入 event 后、更新 summary 前崩溃,或 summary 文件缺失/半写入损坏,`RunLogStore::load_run_summary` 和 `list_run_summaries` 会从完整 `events.jsonl` 重放并修复 `summary.json`,避免 run log 因 summary 落后一拍而永久不可追加。 + +RPC 层提供两个读取入口:`agent.resume` 用于恢复 run 并重放事件 notification;只读历史分页使用 `agent.loadRunEvents`,按 `beforeSeq` / `limit` 直接返回 event envelope,不重新发送 live notification,也不触发旧审批副作用。VS Code Sidebar 向上滚动历史时走 `agent.loadRunEvents` 从 workspace run log 读取更早事件页。 ## 事件格式 @@ -84,6 +93,8 @@ Run Log 本身只负责 append/load 串行化,不直接管理 stdout。Phase 3 - `type` 使用 `docs/json-rpc-protocol.md` 中的事件名,例如 `run.started`、`assistant.delta`、`tool.completed`。 - `payload` 当前是 `serde_json::Value`,具体 schema 后续会和 JSON-RPC 协议、TypeScript 协议包对齐。 +编辑重发不会修改或删除旧 JSONL 行。前端通过 `agent.sendTurn.supersedes` 把被覆盖消息的 timeline `messageId` 和可选 `turnId` 写入新 `turn.started.payload.supersedes`;`agent.resume` 回放时同一 payload 原样返回,VS Code/TUI 可以隐藏或标记被覆盖的用户消息,同时调试工具仍可从旧 `turn.started.userTask` 查看原始输入。 + ## 路径与标识符规则 - workspace root 必须是已经存在的目录。 @@ -110,6 +121,9 @@ Run Log 本身只负责 append/load 串行化,不直接管理 stdout。Phase 3 - 拒绝不安全的 run id 和 state dir。 - 读取时发现序列缺口会失败。 - 写入前脱敏敏感字段和明显密钥片段。 +- malformed tool-call arguments 会写入 run-scoped 诊断文件,并在 `run.failed.diagnosticFile` 暴露本地路径。 +- run-scoped payload 文件支持 chunk append 和读取,用于 `apply_patch.payloadRef` 这类大参数引用;chunk 追加不触碰 workspace 文件。 +- 编辑重发的 `turn.started.payload.supersedes` 会随 run log 保存;RPC/VS Code 测试覆盖同一 run 内新 turn 覆盖旧用户消息且 resume 后可重建隐藏集合。 - 超长字符串和数组会被截断,并记录 `runLogTruncation` 元数据。 - `SerializedRunLog` 多线程 clone 并发追加时,仍生成连续 `seq`,并可被重新打开为正确的下一条序号。 - summary metadata 随事件追加更新,并可按最近更新时间列出。 diff --git a/docs/security-model.md b/docs/security-model.md index 6123bb6..98f1db0 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -7,6 +7,7 @@ ## 边界 - API Key 不得进入 run log。 +- VS Code 插件中的 DeepSeek API key 存入 SecretStorage;多 key 管理器只展示 alias 与 masked key,支持选择 active key、修改 alias 和删除指定 key,优先级高于进程环境变量;传给 RPC 子进程时只通过 child env 覆盖 active `DEEPSEEK_API_KEY`。DeepSeek model ID 不是密钥,可通过 VS Code 普通设置保存,并在 child env 中覆盖 `DEEPSEEK_MODEL`。 - `.env` 和本地状态必须被 git 忽略。 - tool call 执行前必须校验 schema。 - 写入应通过 patch application。 @@ -30,11 +31,12 @@ - 工具结果进入 run log 或 prompt 前可通过统一入口转为已脱敏、已截断 JSON;截断边界通过 `runLogTruncation` 记录。 - 写入前检查 workspace 路径。 - 命令类工具取消或超时时清理子进程树;Unix 使用独立 process group,Windows 使用新 process group、ParentProcessId descendant 枚举和 `taskkill /T /F` 兜底。 +- VS Code 插件统一在 notifier/logger/command warning 边界对 SecretStorage/env API key 脱敏,覆盖 Output Channel、toast、RPC startup failure 和 Git workflow generation failure。 - CI 检查格式、lint、测试和类型。 ## 后续增强 -- 扩展统一脱敏层,覆盖更多 API Key 形态、环境变量、证书、前端历史回放和 provider 错误正文。 +- 扩展统一脱敏层,覆盖更多 API Key 形态、证书、前端历史回放和 provider 错误正文。 - 为敏感路径建立三层拒绝/忽略规则:硬安全排除默认覆盖 `.env`、`.secrets/`、`.secret/`、`.git/`、`.agents/`、证书、token 文件和常见云服务凭据,不能被用户 ignore 规则重新纳入;默认工程排除覆盖 `target/`、`node_modules/`、`dist/`、`build/`;用户上下文排除使用 `.gitignore` 和 `.prole-coderignore`。 - 持续扩展命令风险分类器覆盖面;当前已在审批前识别网络访问、依赖安装、发布、远程 git 操作、删除和 reset 等高风险行为。 - 按平台实现并测试更强 sandbox 边界;Windows、Linux 和 macOS 的能力差异需要在文档和测试中分别说明。 diff --git a/docs/testing.md b/docs/testing.md index 2afc635..79e1313 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -65,18 +65,35 @@ pnpm run vscode:test-electron - Problems diagnostics 被采集为 `agent.sendTurn.attachments` 的 diagnostic attachment。 - `tool.approvalRequired` 经过 test-only auto approval requester 回传为真实 `agent.approve`。 - Chat Cancel UI 边界通过真实 `agent.cancel` 请求收口。 -- Run List refresh 和 `agent.resume` replay 通过同一 `agent.event` 渲染路径更新 timeline。 +- Run List refresh、`agent.resume` replay、`agent.loadRunEvents` 历史分页和 `agent.deleteRun` run 删除通过 typed RPC / timeline 渲染路径更新。 test-only command 和 auto approval 同时要求 VS Code `ExtensionMode.Test` 以及 `PROLE_CODER_VSCODE_TEST=1` / `PROLE_CODER_VSCODE_TEST_AUTO_APPROVE=1` 环境变量,普通扩展激活不会注册这些测试入口。 -P4-15 到 P4-18 的 Codex-like UX 收敛继续复用这条 extension-host 入口,并补齐以下确定性覆盖: +Phase 5 P5-1 到 P5-5 的 Codex-like UX 与诊断收敛继续复用 Phase 4 extension-host 入口,并补齐以下确定性覆盖: - `automaticContext.test.ts` 覆盖历史对话压缩、字符预算裁剪、空历史跳过、Sidebar timeline 转换、单条 timeline 消息限长和 attachment 上限合并。 - `chatParticipantCore.test.ts` 覆盖原生 `@prole` Chat Participant turn runner、命令到 run mode 的映射、sendTurn response 前早到事件缓冲、assistant delta streaming、缺少 RPC client 的错误和自动上下文进度提示。 -- `commands.test.ts` 覆盖简化后的审批 choices:主弹窗只暴露 `Approve` / `Reject`,`Approve` 映射一次性批准,`apply_patch` 多 hunk 走 `Select Hunks`。 +- `logging.test.ts` 覆盖 `ProleCoder` Output Channel 日志格式与输出分发;`chatParticipantCore.test.ts` 还覆盖 RPC turn 失败写入 logger,确保完整错误可在 Output 面板诊断。 + +- `commands.test.ts` 覆盖 legacy modal adapter 的简化审批 choices:主动作只暴露 `Approve` / `Reject`,`Approve` 映射一次性批准;`chatApprovals.test.ts` 覆盖 Sidebar 内联 approval card 的 approve/reject、部分 hunk、全 hunk 和空 hunk 选择解析。 - `test/electron/index.ts` 覆盖 VS Code manifest 中的 `contributes.chatParticipants`,并通过 `ProleCoder: Open Chat` 入口验证原生 Chat 入口不会依赖手动拖动 Activity Bar view。 - `pnpm run vsix:smoke` 和 `pnpm run vsix:alpha` 会校验 VSIX manifest 中的 `onChatParticipant:prole-coder.chatParticipant` activation event 以及 `@prole` Chat Participant 贡献点。 +Phase 5 P5-6 到 P5-13 的 API key/model、Git 工作流、Sidebar 连续会话和结构化 provider 配置错误恢复 UX 已补齐以下确定性覆盖: + +- `providerSecrets.test.ts` 覆盖 SecretStorage 多 key store 解析、malformed entry/重复 id/空 key 边界、写入前显式校验、active key 选择、masked key 展示、所有 stored key redaction、process env fallback、missing status、child env 覆盖值、model 配置优先级、`DEEPSEEK_MODEL` 注入和 redaction source。 +- `providerConfigurationUx.test.ts` 覆盖 RPC error data、run failed payload 和 Chat Participant metadata 中的结构化 `recoverableAction` 解析,确认缺少 API key 的恢复入口不依赖后端英文错误消息。 +- `notifier.test.ts` 覆盖 Output Channel/toast message 统一脱敏 SecretStorage/env key。 +- `providerSecretCommands.test.ts` 覆盖 Key 管理器的 `+ Add` 添加 key+alias、选择已有 active key、行内 edit 按钮修改 alias、trash 按钮删除非 active key、Clear API key、Select DeepSeek Model、含 model 的 provider status、idle 状态 RPC restart、active run 场景提示稍后生效。 +- `gitWorkflow.test.ts` 覆盖 staged diff、unstaged fallback、upstream/main base 选择、Generate Commit Message 写入 `repository.inputBox.value` 且不自动 commit、agent 重复 terminal event 只采纳首个终态、Generate PR Description 输出 markdown 且不自动创建 PR。 +- `rpcServer.test.ts` 覆盖 RPC child env 注入、key 轮换后重启使用新 env,以及 typed `agent.deleteRun` request;`providerSecretCommands.test.ts` 覆盖 model 切换后的 env 更新与 idle restart;`test/electron/index.ts` 覆盖新增命令在 VS Code test host 中注册。 +- `chatEvents.test.ts` 覆盖 tool/raw 过程事件默认折叠、用户消息/DeepSeek 回复默认可见、过程事件收敛到 Work log、无 assistant 时 `run.completed` 摘要仍可见,以及 `turn.started.payload.supersedes` 对旧用户消息的隐藏语义;`automaticContext.test.ts` 覆盖 superseded 用户消息不会进入自动上下文;`runHistory.test.ts` 覆盖 `deleteRun` webview message 解析;`chatApprovals.test.ts` 覆盖内联审批消息解析;`webviewSerialization.test.ts` 覆盖 webview 初始 JSON 的 `undefined` 与 `<` 转义;`webviewHtml.test.ts` 覆盖生成后的 Sidebar HTML 内联脚本可被 JavaScript parser 解析,防止 template literal 反斜杠转义回归导致 webview ready 静默超时;`webviewMarkdown.test.ts` 独立覆盖 Markdown renderer 的 horizontal rule、表格转义、链接安全边界、未闭合 inline 标记和大文本;extension-host webview probe 覆盖可见对话 Markdown 的代码块、表格、链接、inline code、horizontal rule、中文表格 summary、编辑重发 supersede metadata 和分块 replay 历史 `assistant.delta`;Rust `agent-rpc`/`run_log` 测试覆盖同一 run 多 turn 续号、编辑重发 supersede payload 和 inactive run 删除;`prole-coder-cli` 测试覆盖 DeepSeek missing API key RPC structured data。 +- 文档/打包验收已运行 `pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`、`pnpm run vscode:test-electron`、`pnpm run vsix:smoke`、`pnpm run vsix:alpha`、`git diff --check` 和敏感信息扫描。 + +P5-14 作为真实试用回归修复包,已把已发现的 Sidebar 对话、Work log、Markdown、provider/shell 稳定性和 composer / Settings 入口问题拆成 `docs/phase-tasks.md` 中的 P5-14a 到 P5-14h 子任务。新增 Runs、Key/Model、审批、Chat、Output 日志或上下文压缩体验问题时,应先补可重复测试或手动验收说明,再登记到 P5-15、P5-16、P5-17 或后续 Phase 任务;Phase 5 仍保留真实试用后的持续 backlog,不得在占位任务完成前标记整阶段完成。 + +P5-15 已完成真实 VS Code 试用回归的第二批确定性覆盖:Sidebar webview bootstrap、历史加载、Send 按钮、Enter/Shift+Enter、composition 状态输入、Markdown renderer 独立边界、patch mismatch 可恢复、历史 resume 副作用隔离、编辑重发/停止入口、输出长度截断续写、patch preview hunk 计数宽容,以及 edit resend 的后端 supersede 语义。P5-16 已完成模型回合预算 continuation approval、Provider Key/Model 图标按钮、run mode 自动推断和 PowerShell 验证命令规则收敛。P5-17 已补充 pen edit icon、Work log 分组、本对话审批复用、只读 shell 白名单、运行中 steer、steer 内联确认、终态后 Work log 折叠、版本查询白名单和思考段间文件/命令摘要的确定性覆盖:Rust Turn Loop 测试覆盖只读白名单、版本查询免审批与 queued steer 注入,Protocol/RPC/VS Code 测试覆盖 `agent.steer` typed request,extension 单元测试继续覆盖 webview inline script parse、steer 确认 DOM、发送按钮状态同步、timeline 工作计数和 Electron probe 的可访问标签。P5-18 已补充 assistant 按工具/steer 边界分段、`Activity` 摘要插入、平凡工具过滤、steer 消息顺序、steer 确认卡位置、运行中 active work 单行状态、pending steer 本地消息送达判断、unknown `write_file` 可恢复工具结果、pending steer queued 文案、completed run 按用户消息 / steer 边界分段折叠并展开恢复原卡片格式、provider 建连/stream idle timeout 重试收口、已知工具 schema mismatch 可恢复工具结果、完成后折叠保留用户 / steer 消息、长 run timeline 裁剪只移除可重放过程项、保留不可丢对话内容,以及 run summary changedFiles / verification metadata 追踪的覆盖。P5-19 已记录最新真实试用回归验收:`test/projects/agent_misc_tests_working` 中四个独立项目的测试分别为 4/4、4/4、5/5、5/5 通过,共 18 个测试通过;并补充 `agent.loadRunEvents` / Sidebar timeline 向上滚动历史分页覆盖。P5-19z 已完成合并前 UX backlog 收口,新的真实试用性能、稳定性和长任务问题进入 Phase 6。涉及 HTML template literal 内联脚本的改动仍应通过 `webviewHtml.test.ts` parse smoke 覆盖,涉及 Markdown renderer 的改动应同步扩展 `webviewMarkdown.test.ts`。若本机 VS Code mutex 阻塞 `pnpm run vscode:test-electron`,必须至少运行 extension 单元测试并记录需要关闭测试实例后重跑 E2E。 + ## 新增测试的协作要求 - PR 或提交说明中标明测试类型:unit、integration、regression、live、demo 或 stress。 @@ -147,7 +164,7 @@ Phase 2 的默认 CI 应优先覆盖离线、确定性测试: - token estimator metadata:`utf8_bytes` 和校准估算器都必须明确 `exact=false`,不能误报为真实 tokenizer;校准 fixture 覆盖系数、误差和不保存 prompt 原文的边界。 - attachment fixture:file、selection、explicit_content、diagnostic 都能进入 Context Capsule;路径越界、重复 attachment、超大小 selection / explicit content 和 diagnostic 形状错误均有稳定错误。 - provider summary:`provider.completed` 独立记录模型、duration、usage、cache hit/miss 和 streaming 摘要;DeepSeek streaming wrapper 从 include_usage chunk 填充这些字段。 -- JSON Schema validation:tool call arguments 在 typed deserialization 前通过 schema validator,未知字段、错误类型、空字符串/空数组等会稳定失败。 +- JSON Schema validation:tool call arguments 在 typed deserialization 前通过 schema validator;malformed JSON 和终止型 payload 错误会稳定失败,已知工具的未知字段、错误类型、空字符串/空数组等 schema mismatch 会作为失败 tool result 返回 provider 重试。 - Run Log 体积边界:工具结果、verification 输出和 Run Log payload 共用脱敏/截断函数,并记录 `runLogTruncation`。 以下验收必须保持 ignored/manual,不进入普通 CI: @@ -156,7 +173,7 @@ Phase 2 的默认 CI 应优先覆盖离线、确定性测试: - 200K、500K、900K 样例仓库 Context Capsule 生成和 token 预算报告。 - 真实多文件任务展示 manifest、选中文件/诊断、token 预算、provider usage/cache 和最终验证结果。 -Phase 2d 的大上下文手动入口: +P2-4 的大上下文手动入口: ```powershell cargo test -p prole-coder-agent-core --test context_capsule_benchmark context_capsule_large_repository_budget_benchmark -- --ignored --exact --nocapture @@ -164,7 +181,7 @@ cargo test -p prole-coder-agent-core --test context_capsule_benchmark context_ca 该测试生成 200K、500K、900K 三档确定性样例 Context Capsule,输出 `inputTokens`、section tokens 和 omitted source 数量;默认 CI 只编译 ignored test,不自动执行。 -Phase 2c/2d 的 cache usage 手动入口: +P2-3/P2-4 的 cache usage 手动入口: ```powershell cargo test -p prole-coder-agent-core --test deepseek_api_live live_cache_usage_summary_smoke_test -- --ignored --exact --nocapture @@ -200,4 +217,4 @@ rg -n "sk-[A-Za-z0-9_-]+|C:\\User[s]\\|/Users/[^/]+/|/home/[^/]+/|DEEPSEEK_(CODE 展示型 demo 的完整清单、运行命令和预期输出见 `demos.md`。`cargo demo`、`cargo demo-live`、`cargo demo-context`、`cargo demo-context-visual`、`cargo demo-truncation`、`cargo demo-schema` 和 `cargo demo-attachment` 均来自 `.cargo/config.toml`;新增或调整展示命令时,应先更新 `demos.md`,并且只在 demo 已实现、可运行后再加入 Cargo alias。 -Phase 2e 已补齐 context、truncation、schema、context-visual、attachment,并增强 `demo-live` 的 provider summary 展示。它们仍应默认 ignored,不进入普通 CI 自动执行,作为人工观察和阶段合并前验收入口。 +P2-5 已补齐 context、truncation、schema、context-visual、attachment,并增强 `demo-live` 的 provider summary 展示。它们仍应默认 ignored,不进入普通 CI 自动执行,作为人工观察和阶段合并前验收入口。 diff --git a/docs/tool-system.md b/docs/tool-system.md index 2afdc3b..8561268 100644 --- a/docs/tool-system.md +++ b/docs/tool-system.md @@ -1,6 +1,6 @@ # 工具系统 -状态:`0.1.0` 设计已确定,Phase 1 基础执行层、审批前 shell 动态风险升级、Phase 3 RPC/VS Code 审批接入、VS Code Native diff patch 预览、`apply_patch` hunk 级审批和命令子进程树清理已实现。 +状态:`0.1.0` 设计已确定,Phase 1 基础执行层、审批前 shell 动态风险升级、Phase 3 RPC/VS Code 审批接入、VS Code Native diff patch 预览、`apply_patch` hunk 级审批、run-scoped 大 payload 引用和命令子进程树清理已实现。 工具系统通过显式 schema 和类型化结果向 Agent Core 暴露工作区操作。模型不得直接执行文件写入、shell 命令或网络访问;它只能请求工具,工具请求必须经过 schema 校验和审批策略。 @@ -23,7 +23,7 @@ - `network` - `destructive` -工具定义中的风险等级是最低风险。Agent Core 可以基于具体参数把风险升级,但不得降级。 +工具定义中的风险等级是默认风险。Agent Core 可以基于具体参数把风险升级;唯一允许的降级例外是 `shell` 的严格只读白名单命令,且必须证明命令和 `cwd` 都限制在当前 workspace 内。 ## 工具定义结构 @@ -58,7 +58,7 @@ pub struct ToolDefinition { `implementationStatus` / `implementation_status` 只描述当前仓库是否已有基础执行器实现: - `executor_implemented`:已接入 `WorkspaceToolExecutor`,可被基础 Agent Turn Loop 调用。 -- `schema_only`:协议名称、参数 schema、风险和审批策略已注册,但执行器尚未实现;如果模型在当前阶段请求这类工具,Turn Loop 必须返回显式 unsupported tool 错误。 +- `schema_only`:协议名称、参数 schema、风险和审批策略已注册,但执行器尚未实现;如果模型在当前阶段请求这类工具,Turn Loop 必须返回显式 unsupported tool 错误。`model_turn_budget` 是例外:它不是模型可调用工具,而是 Turn Loop 本地发出的 continuation approval 控制点。 ## 通用结果字段 @@ -166,11 +166,12 @@ pub struct ToolDefinition { 风险:`write`。 -审批:`required`。 +审批:`none`。`apply_patch` 只能修改当前 workspace 内、`expectedFiles` 明确列出的文件;最多 5 个 `expectedFiles` 的普通 workspace 代码修改默认直接执行,不打断对话。超过 5 个文件的 bulk patch,或修改 `.gitignore` / `.prole-coderignore` 这类 workspace policy 文件时,会动态要求审批。路径越界、敏感路径和非法 patch 仍会被拒绝;未来如果 patch 动态升级为 destructive 风险,仍可复用已有 hunk approval 边界。 参数: -- `unifiedDiff`:统一 diff。 +- `unifiedDiff`:统一 diff。小 patch 可直接内联该字段。 +- `payloadRef`:可选 run-scoped 大 payload 引用;与 `unifiedDiff` 互斥。当前支持 `{ kind: "run_file", path, sha256?, sizeBytes? }`,`path` 必须位于当前 run 的 `payloads/` 目录下。 - `expectedFiles`:预期修改文件列表。 结果: @@ -192,6 +193,8 @@ pub struct ToolDefinition { - `cwd`:workspace-relative 工作目录,省略时使用 workspace root。 - `timeoutMs`:超时时间。 +模型不应在 `command` 中 `cd` 到猜测的绝对路径;需要切换目录时使用 `cwd`,且 `cwd` 必须是 workspace-relative。Windows 上执行器使用 Windows PowerShell 5.1,并在启动脚本中把 PowerShell 层 stdout/stderr 设置为 UTF-8;模型不应使用 POSIX-only 路径或 PowerShell 7-only 的 `&&` / `||` 操作符。测试和验证命令不需要也不应手动追加 `2>&1`,因为 shell 工具已经分别捕获 stdout/stderr;在 PowerShell 5.1 中合并流可能把 CLIXML/progress 记录带入 stderr,使本来通过的验证看起来失败。 + 结果: - `exitCode` @@ -199,7 +202,9 @@ pub struct ToolDefinition { - `stderr` - `durationMs` -说明:`shell` 的静态风险是 `exec`。Agent Core 会在执行前分类具体命令,并递归检查 shell 包装器、`$(...)` 和传统反引号子命令;依赖安装、网络访问、远程 git 和发布命令会升级为 `network`,删除和破坏性 git 操作会升级为 `destructive`。升级原因通过 `riskReasons` 写入 `tool.requested` 和 `tool.approvalRequired`;协议 `0.1.0` 不允许自动降级或静默执行。 +说明:`shell` 的静态风险是 `exec`。Agent Core 会在执行前分类具体命令,并递归检查 shell 包装器、`$(...)` 和传统反引号子命令;依赖安装、网络访问、远程 git 和发布命令会升级为 `network`,删除和破坏性 git 操作会升级为 `destructive`。升级原因通过 `riskReasons` 写入 `tool.requested` 和 `tool.approvalRequired`。 + +P5-17 起,当前 workspace 内严格只读的简单 shell 命令可被降级为 `read` 并免审批执行:首版白名单包括 `rg`、`Get-Content` / `gc` / `cat` / `type`、`Select-String`、`Get-ChildItem` / `gci` / `dir`、`Test-Path`,以及 `git diff` / `status` / `log` / `show`。常见本地工具的版本查询(如 `python --version`、`node -v`、`cargo --version`、`go version`)也可免审批,但只接受裸命令名加一个版本参数。该白名单只接受无管道、无重定向、无变量展开、无子命令、无绝对路径、无 `..` 路径且不触及 `.env`、`.secrets`、`.git`、`.agents`、`.codex`、`.prole-coder` 等敏感 workspace 路径的简单参数;`rg --pre`、`rg --replace`、`rg -r` / 含 `r` 的组合短参数、`git --output`、`git --ext-diff`、`git --no-index` 等可能执行外部程序、写文件或越界读取的参数仍保持 `exec` 审批。 ### `git_status` @@ -276,7 +281,7 @@ pub struct ToolDefinition { - TypeScript 协议类型:`packages/protocol/src/index.ts`。 - 共享协议 fixture:`docs/protocol/tool-registry.v1.json`。 -`crates/agent-rpc` 已实现 Run Log 事件到 `agent.event` notification 的桥接,并已分发 `agent.approve` / `agent.reject`。真实 RPC handler 已能把工具请求、审批请求、审批决定和工具结果暴露给 CLI/VS Code/TUI;VS Code 已接入真实 pending 队列、命令风险展示、Native diff patch 预览和 `apply_patch` selected hunk 审批,TUI 真实队列接入仍在后续阶段。 +`crates/agent-rpc` 已实现 Run Log 事件到 `agent.event` notification 的桥接,并已分发 `agent.approve` / `agent.reject`。真实 RPC handler 已能把工具请求、审批请求、审批决定和工具结果暴露给 CLI/VS Code/TUI;VS Code 已接入真实 pending 队列、命令风险展示、Native diff patch 预览和需要审批 patch 路径的 selected hunk 边界,TUI 真实队列接入仍在后续阶段。 ## 协议一致性测试 @@ -303,8 +308,8 @@ fixture 中的 `tools` 被当作无序集合校验;测试会按工具名规整 - `workspace_manifest`:生成 workspace manifest v0,默认遵守 `.gitignore` 和 `.prole-coderignore`,硬排除 `.git/`、`.secrets/`、`.secret/`、`.agents/`、`.codex/` 和 `.prole-coder/`,并返回稳定排序条目、manifest hash、git 状态和截断原因。 - `read_file`:只读取 workspace 内 UTF-8 文本文件,支持 1-based 行范围,并返回完整文件的 `sha256` 和 `sizeBytes`。 - `search`:通过 `rg --json --fixed-strings` 搜索,默认排除 `.git/`、`.secrets/`、`.secret/`、`.env*`、`node_modules/` 和 `target/`。 -- `apply_patch`:应用受限 unified diff,要求 patch 实际文件集合与 `expectedFiles` 完全一致;执行时会先在内存中完成全部文件的 hunk 校验和 staging,再统一写盘,因此解析或 hunk mismatch 不会留下部分文件已修改的状态;成功后返回 reverse patch。Core 会从 unified diff 生成稳定 hunk id,RPC/VS Code 首版支持 selected hunk 审批;文件创建和删除如果只批准部分 hunk 会被拒绝,避免生成不可审计的半文件操作。 -- `shell`:在 workspace 内执行非交互式命令,支持超时,执行前进行命令风险分类,返回 exit code、stdout、stderr 和耗时。 +- `apply_patch`:应用受限 unified diff,要求 patch 实际文件集合与 `expectedFiles` 完全一致;执行时会先在内存中完成全部文件的 hunk 校验和 staging,再统一写盘,因此解析或 hunk mismatch 不会留下部分文件已修改的状态;成功后返回 reverse patch。最多 5 个 `expectedFiles` 的普通 workspace 代码 patch 默认不触发审批,超过阈值或修改 workspace policy 文件会动态要求审批;Core 仍会从 unified diff 生成稳定 hunk id,供高风险 patch 或显式审批路径复用 selected hunk 边界。文件创建和删除如果只批准部分 hunk 会被拒绝,避免生成不可审计的半文件操作。大 patch 可以先分块追加到当前 run 的 `payloads/` 文件,再通过 `payloadRef` 引用;Turn Loop 会校验 `sha256` / `sizeBytes`,然后 materialize 为 `unifiedDiff` 进入同一条 schema、路径安全和 patch staging 链路。 +- `shell`:在 workspace 内执行非交互式命令,支持 workspace-relative `cwd`、超时和命令风险分类,返回 exit code、stdout、stderr 和耗时;Windows PowerShell wrapper 会先设置 UTF-8 输出,避免 PowerShell 本地化错误信息在 run log / Output Channel 中乱码。严格只读的 `rg`、`Get-Content`、`git diff/status/log/show` 和常见 `--version` / `-v` / `-V` 查询等简单命令可按 workspace 白名单免审批,其他 shell 命令仍按 `exec` 或动态升级后的风险审批。 - `git_status`:读取 `git status --short --branch` 或普通 `git status`。 - `git_diff`:读取 unstaged 或 staged diff,支持限定 workspace-relative 路径。 @@ -316,7 +321,7 @@ fixture 中的 `tools` 被当作无序集合校验;测试会按工具名规整 当前实现暂不包含 LSP diagnostics 和 plan update 的执行逻辑;它们仍只有 schema 和静态风险定义。 -当前执行层已接入基础 Agent Turn Loop、审批策略、取消信号和 run log。写入与命令执行会触发审批请求,并记录 `tool.approvalResolved`;CLI 二进制可以通过 stdin/stderr 做真实 y/n 审批,测试可使用显式 auto-approve 策略验证已批准路径。Run Log 事件已能通过 RPC 桥接发送给前端;`AgentTurnLoopRpcHandler` 已能通过 `agent.sendTurn` 真实驱动 Core,并在 `tool.approvalRequired` 处检查 session/workspace 持久批准,或等待 `agent.approve` / `agent.reject` / `agent.cancel` / 审批超时。`apply_patch` 的审批 payload 会携带 hunk metadata,`agent.approve.hunks` 会被 RPC 校验后交给 Core 过滤 patch 并只执行已批准 hunks。`shell` 会在审批前分类命令并动态升级风险,审批 payload 会包含命令、cwd 和上一条 shell 输出摘要;`shell`、`search`、`git_status` 和 `git_diff` 会在子进程轮询循环中检查 `CancellationToken`,取消或超时时清理整棵命令子进程树并让 Turn Loop 写入 `run.canceled`。VS Code 已接入真实 RPC 审批队列;TUI 真实队列接入仍需要后续实现。 +当前执行层已接入基础 Agent Turn Loop、审批策略、取消信号和 run log。命令执行和动态升级为 network/destructive 的操作会触发审批请求,并记录 `tool.approvalResolved`;受限 workspace 小规模代码 `apply_patch` 默认直接执行,超过 5 个 `expectedFiles` 的 bulk patch 或 workspace policy 文件 patch 会进入审批。CLI 二进制可以通过 stdin/stderr 做真实 y/n 审批,测试可使用显式 auto-approve 策略验证已批准路径。Run Log 事件已能通过 RPC 桥接发送给前端;`AgentTurnLoopRpcHandler` 已能通过 `agent.sendTurn` 真实驱动 Core,并在 `tool.approvalRequired` 处检查 session/workspace 持久批准,或等待 `agent.approve` / `agent.reject` / `agent.cancel` / 审批超时。`apply_patch` 的 hunk metadata 仍可供审批路径携带,`agent.approve.hunks` 会被 RPC 校验后交给 Core 过滤 patch 并只执行已批准 hunks。`shell` 会在审批前分类命令、动态升级高风险命令,并对严格 workspace-scoped 的只读白名单命令改为免审批;审批 payload 会包含命令、cwd 和上一条 shell 输出摘要;`shell`、`search`、`git_status` 和 `git_diff` 会在子进程轮询循环中检查 `CancellationToken`,取消或超时时清理整棵命令子进程树并让 Turn Loop 写入 `run.canceled`。如果模型请求不存在的工具,例如 `write_file`,Turn Loop 会记录失败的 `tool.requested` / `tool.completed` 并把 `E_UNKNOWN_TOOL` 工具结果返回给 provider,提示改用现有工具;未知工具参数不会写入 run log。VS Code 已接入真实 RPC 审批队列;TUI 真实队列接入仍需要后续实现。 ## 后续增强 @@ -344,7 +349,7 @@ fixture 中的 `tools` 被当作无序集合校验;测试会按工具名规整 Phase 2d 已增加通用 tool argument validator,优先使用工具注册表中的 JSON Schema 校验模型参数。 -校验顺序为:解析 arguments 字符串为 `serde_json::Value` -> schema validation -> typed deserialization -> 审批 -> 执行工具。 +校验顺序为:解析 arguments 字符串为 `serde_json::Value` -> schema validation -> typed deserialization -> 审批 -> 执行工具。Malformed JSON 仍是终止型 `E_INVALID_TOOL_ARGUMENTS` 并写入脱敏诊断文件;已知工具的 schema mismatch 不再直接终止 run,而是记录失败的 `tool.requested` / `tool.completed`,把错误码、schema 错误和工具专属 guidance 作为 tool result 返回给模型重试。 Schema 校验不能只作为 typed deserialization 失败后的补救,因为 Rust 结构体反序列化可能忽略未知字段,而 schema 才能稳定表达 `additionalProperties`、枚举、范围和互斥字段。当前 validator 覆盖本仓库工具 schema 使用到的 JSON Schema 子集:`type`、`required`、`properties`、`additionalProperties: false`、`items`、`enum`、`minLength`、`minItems` 和 `minimum`。 @@ -357,7 +362,8 @@ Schema 校验不能只作为 typed deserialization 失败后的补救,因为 R ### `apply_patch` - 当前实现只支持受限 unified diff;后续需要支持更完整的 git patch 语法,包括 rename、copy、mode change 和更严格的 no-newline 语义。 -- 已增加 VS Code patch 预览和 `apply_patch` hunk 级审批;后续继续增强冲突诊断、失败时的精确 hunk mismatch 信息,以及 rename/copy/mode change 等更完整 patch 语法下的审批边界。 +- 已增加 VS Code patch 预览和 `apply_patch` hunk 级审批边界;最多 5 个 `expectedFiles` 的普通 workspace 代码 patch 默认免审批,超过阈值或修改 workspace policy 文件会动态要求审批。Hunk mismatch / file mismatch / invalid patch 现在作为 failed tool result 返回给模型,便于重新读取文件后重试;这些可恢复错误发生在 workspace 写入前,因此 failed result 不携带 reverse patch。后续继续增强 rename/copy/mode change 等更完整 patch 语法下的审批边界。 +- 已完成 `apply_patch.payloadRef` 第一版:provider 或本地聚合器可以把大 patch 分块追加到 run-scoped `payloads/` 文件,最终调用 `apply_patch` 时只传轻量引用;后续可继续扩展为显式 RPC chunk 方法、payload GC 和更完整的大文件写入协议。 - 用修改前快照生成 reverse patch,并在 run log 中保存 patch id、审批 id 和可审计回滚信息。 - 如果需要抵抗磁盘写入中途失败,应进一步引入临时文件、原子替换或备份恢复机制;当前 staging 主要保证解析和 hunk 校验失败不会产生半应用 patch。 - 明确二进制文件和生成文件策略,避免文本 patch 意外改写不可审计内容。 @@ -365,6 +371,7 @@ Schema 校验不能只作为 typed deserialization 失败后的补救,因为 R ### `shell` - 已在执行前加入命令风险分类:网络、破坏性、依赖安装、发布、远程 git 操作等会升级审批,并输出 `riskReasons`;分类器会递归检查 shell 包装器、`$(...)` 和传统反引号子命令。 +- 已加入严格只读 shell 白名单,允许 workspace 内简单 `rg`、`Get-Content`、`git diff/status/log/show` 和常见版本查询等命令免审批执行;包含管道、重定向、变量展开、子命令、绝对路径、父级路径、敏感 workspace 路径或可能写文件/执行外部程序的参数时仍需审批。 - 已通过 Run Log 统一脱敏/截断限制输出大小并记录截断原因;取消和超时会清理命令子进程树。 - 记录环境变量差异,但默认隐藏或脱敏敏感变量。 - 后续按平台分别实现更强的 sandbox 策略;Windows、Linux 和 macOS 不能假设具备相同隔离能力。 diff --git a/docs/tui.md b/docs/tui.md index 9d9be81..698f83f 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -1,6 +1,6 @@ # 终端界面(TUI) -状态:草案,保留为正式前端但优先级排在 VS Code 核心体验之后,当前归入 Phase 5。 +状态:草案,保留为正式前端但优先级排在 VS Code 核心体验和 AGENT 性能调试之后,当前归入 Phase 7。 TUI 是 `ProleCoder` 的终端前端。它应支持键盘驱动的代码工作流,同时与 VS Code 插件共享 Agent Core 行为。 @@ -40,7 +40,7 @@ TUI 是 `ProleCoder` 的终端前端。它应支持键盘驱动的代码工作 下一步: -- 等 VS Code 核心体验和共享 RPC 事件管线稳定后,进入 TUI Phase 5 实现。 +- 等 VS Code 核心体验、AGENT 性能调试和共享 RPC 事件管线稳定后,进入 TUI Phase 7 实现。 - 为 `prole-coder-tui` 增加命令行入口和配置加载。 - 连接 Rust Agent RPC Server 的 stdio request loop,消费 `agent.event` 事件流。 - 把 `ApprovalPromptModel` 接到真实 `tool.approvalRequired`,并把用户决定发送为 `agent.approve` / `agent.reject`。 diff --git a/docs/turn-loop.md b/docs/turn-loop.md index 8ab84f8..beb51d5 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -1,6 +1,6 @@ # Agent Turn Loop -状态:Phase 1 基础编排、TurnProvider async / streaming 边界、真实 DeepSeek 文本 streaming 联网验收、streaming tool call 增量拼装验证、基础 RPC 事件桥接、双向 request loop、真实 RPC Turn Loop handler、`TurnEventSink` 实时事件输出、CLI 交互式审批、CLI JSON-RPC 错误输出、RPC pending approval 等待队列、审批超时、取消语义、provider/tool 协作式取消信号、Run Log 写入串行化、Run summary metadata 和 tool call JSON Schema 预校验已实现;Phase 3 已完成 RPC 全双工事件队列、VS Code Chat/审批/Run List/Context Viz 接入和命令子进程树清理;Phase 4 已将 `provider.requested`、`tool.completed`、`run.completed` 纳入共享事件 payload fixture。TUI 真实 RPC UI 仍在后续阶段。 +状态:Phase 1 基础编排、TurnProvider async / streaming 边界、真实 DeepSeek 文本 streaming 联网验收、streaming tool call 增量拼装验证、基础 RPC 事件桥接、双向 request loop、真实 RPC Turn Loop handler、`TurnEventSink` 实时事件输出、CLI 交互式审批、CLI JSON-RPC 错误输出、RPC pending approval 等待队列、审批超时、取消语义、provider/tool 协作式取消信号、provider idle timeout 重试、Run Log 写入串行化、Run summary metadata、tool call JSON Schema 预校验、运行中 steer 注入、workspace-scoped 只读 shell 白名单和未知工具可恢复工具结果已实现;Phase 3 已完成 RPC 全双工事件队列、VS Code Chat/审批/Run List/Context Viz 接入和命令子进程树清理;Phase 4 已将 `provider.requested`、`tool.completed`、`run.completed` 纳入共享事件 payload fixture。TUI 真实 RPC UI 仍在后续阶段。 Agent Turn Loop 是 Agent Core 的回合编排层。它负责把已经实现的 Context Builder、`reasoning_content` 状态机、provider 边界、工具执行、审批和 Run Log 串成同一条可复现事件流。 @@ -13,10 +13,11 @@ crates/agent-core/src/turn_loop.rs 当前实现提供: - `AgentTurnLoop`:持有 provider、审批策略、工具执行器、reasoning 状态机和回合配置。 -- `AgentTurnLoopConfig`:配置最大输入 token、最大模型子回合数和 reasoning 模式。CLI 会把 `--thinking enabled|disabled` 同步映射到 provider thinking 选项与 Turn Loop reasoning 状态机,避免在 thinking disabled 时仍要求工具调用携带 `reasoning_content`。 +- `AgentTurnLoopConfig`:配置最大输入 token、每次继续审批前的 provider request 窗口、provider no-progress timeout / attempts 和 reasoning 模式。CLI 会把 `--thinking enabled|disabled` 同步映射到 provider thinking 选项与 Turn Loop reasoning 状态机,避免在 thinking disabled 时仍要求工具调用携带 `reasoning_content`。 - `TurnProvider`:异步 streaming provider trait。`complete_stream` 返回 `TurnProviderEvent` 流,provider 可以先发送 `AssistantDelta`,再发送唯一的 `Completed` 响应。 - `TurnProviderEvent`:当前包含 `AssistantDelta` 和 `Completed`。`AssistantDelta` 只用于前端展示和 run log 增量;`Completed` 必须包含完整 content、`reasoning_content` 和 tool calls,供后续 reasoning replay 与工具执行使用。 - `CancellationToken`:协作式取消信号。`AgentTurnInput` 持有 token,Turn Loop 会把它传给 `TurnProviderRequest` 和 `WorkspaceToolExecutor`。 +- `TurnSteerQueue`:运行中用户指导队列。RPC/前端可以在 active run 内追加 steer 文本,Turn Loop 会在下一次 provider request 前写入 `turn.steered` 并把该指导作为最新用户运行中指令注入。 - `ApprovalPolicy`:审批策略 trait。策略可以批准、拒绝、取消、过期,或返回策略错误;Turn Loop 会把决定写入 `tool.approvalResolved`。 - `RejectAllApprovalPolicy`:默认拒绝所有需要审批的工具,避免写入和命令被静默执行。 - `AutoApprovePolicy`:测试用策略,用于验证已批准工具的执行路径。 @@ -42,12 +43,14 @@ AgentTurnInput -> WorkspaceToolExecutor -> redacted_tool_result_value -> tool.completed + -> drain queued turn.steered messages -> append tool result message -> next provider.complete -> run.completed ``` 工具结果写入 run log 或进入下一轮 prompt 前会通过 `redacted_tool_result_value` 转成已脱敏 JSON。原始工具结果仍由工具执行层返回,便于即时诊断和后续 UI 展示,但 Turn Loop 的持久化与模型回传路径使用脱敏结果。 +Turn Loop 默认会向 provider 注入工具使用契约,要求所有工具路径保持 workspace-relative,并要求 `shell` 使用 `cwd` 参数切换工作目录,避免模型生成猜测的绝对路径或当前平台不支持的 shell 语法。Windows 构建中该契约会明确 shell 运行在 Windows PowerShell 5.1 下,不应使用 POSIX-only 路径或 PowerShell 7-only 的 `&&` / `||`;测试和验证命令也不应手动追加 `2>&1` 这类合并输出流的重定向,因为 shell 工具已经分别捕获 stdout/stderr,PowerShell stream merging 可能把 CLIXML/progress 噪声带进 stderr 并造成假失败。 ## Run Log 事件 @@ -55,8 +58,11 @@ AgentTurnInput - `run.started` - `turn.started` +- `turn.steered` - `context.built` - `provider.requested` +- `provider.retrying` +- `provider.completed` - `assistant.delta` - `tool.requested` - `tool.approvalRequired` @@ -66,13 +72,29 @@ AgentTurnInput - `run.completed` - `run.failed` -`provider.requested` 只记录消息数量、iteration 和 reasoning replay 状态,不记录完整模型输入。完整上下文由 `context.built` 的 token/source/section/manifest 报告和后续工具结果共同复现;更细的 provider request 摘要应在后续 schema 中设计。 +`provider.requested` 只记录消息数量、iteration 和 reasoning replay 状态,不记录完整模型输入。完整上下文由 `context.built` 的 token/source/section/manifest 报告、`turn.steered` 审计事件和后续工具结果共同复现;更细的 provider request 摘要应在后续 schema 中设计。 + +Turn Loop 会在 provider 建立 stream 和等待下一段 streaming event 两个边界检测 no-progress timeout。默认 60 秒没有任何 stream 进展时,系统会丢弃当前 provider future/stream,写入 `provider.retrying(reason="provider_idle_timeout", timeoutMs, retriesRemaining)` 并重新发起下一次 provider request;连续 5 次 attempt 都没有返回时,run 会以 `run.failed(code="E_PROVIDER_TIMEOUT")` 收口,payload 记录 `timeoutMs`、`attempts` 和已收到的 partial content/reasoning 字符数。普通 transient stream error 仍使用 `provider.retrying(reason="transient_stream_error")`。 + +`turn.steered` 记录用户在 active run 期间追加的补充指导。Turn Loop 会在每次 provider request 前 drain 当前队列,并在模型消息中明确声明 steer 是最新用户运行中指令,因此该指导会影响下一次模型调用;如果 run 在收到 steer 后没有再进入 provider request,事件可能不会出现。 provider stream 中的 content delta 会立即写入 `assistant.delta`,payload 包含 `stream: true`。如果 provider 没有发送 content delta,Turn Loop 会在收到 `Completed` 后把完整 final content 作为一次 `assistant.delta` 写入。Provider-private `reasoning_content` delta 不写入 `assistant.delta`,只由 provider 聚合后放入 `Completed.reasoning_content`,供 `ReasoningContentStateMachine` 校验和下一轮 replay。 `Completed.content` 是最终 assistant 消息文本的权威来源,用于 `run.completed.summary` 或 assistant tool-call replay。`assistant.delta` 是展示和 run log 增量事件,不反向推断最终文本;当 provider 已经发送过可见 content delta 时,Turn Loop 不会在 `Completed` 时重复写一份完整 `assistant.delta`。因此 reasoning delta 不进入用户可见 summary,tool call 前的可见文本如果存在,应由 provider 同时保留在最终 `Completed.content` 中。 -DeepSeek streaming tool call delta 在 CLI provider wrapper 内通过 `ChatToolCallAccumulator` 拼装为完整 `ChatToolCall` 后才进入 `Completed.tool_calls`。Turn Loop 不直接处理 provider 私有 delta 形态,只要求 provider 在 `Completed` 中提供完整、可校验、可执行的工具调用列表。 +如果 `provider.completed.finishReason` 为 `length` 且没有 tool calls,Turn Loop 不会把空文本或截断文本当作 `run.completed.summary`;它会把截断 assistant 回合保留在内部消息历史中,并追加一次只要求最终工作总结的用户追问。只有后续 provider 以 `stop` 返回无工具调用的 final content 时才写入 `run.completed`。其他非 `stop` 的无工具最终响应会按 provider error 失败,避免 UI 展示“完成但没有总结”的假终态。 + +如果 `finishReason=length` 时 provider 已经返回了工具调用列表,Turn Loop 也不会执行该轮工具调用,因为 streaming JSON arguments 可能刚好在输出上限处被截断。此时它会保留该轮可见 assistant 文本,并追加一条恢复提示,要求模型丢弃半截工具参数,重新发出完整 JSON 工具调用或继续简短工作;只有后续非截断响应里的完整工具调用才会进入 schema 校验和执行路径。 + +`run.completed.changedFiles` 会合并工具执行层显式上报的路径和本 turn 中 shell 写入的 workspace 快照差异,用于兜底捕获 shell 生成或修改文件的情况。快照 baseline 只在首个 shell 工具执行前按需采集,避免 ask-only 或早失败 turn 在启动时扫描整个 workspace;完成时的快照排除 `.git`、`.prole-coder`、`target`、`node_modules` 和 `.vscode-test`,并对较小文件计算 hash,对较大文件使用大小/mtime 指纹。`run.completed.verificationStatus` 会根据已知 shell 验证命令形态汇总,例如 `cargo test/check/clippy`、`npm/pnpm/yarn/bun test` 或 `run test:*`、`python -m unittest/pytest`、`pytest`、`go test`、`dotnet test` 和 `tsc`:任何验证命令失败则为 `failed`,至少一个验证命令成功且无失败则为 `passed`,否则为 `skipped`。 + +Turn Loop 默认允许每个预算窗口最多 50 次 provider request。窗口耗尽时不会直接写入 `E_MAX_MODEL_TURNS` 失败,而是写入 `tool.approvalRequired(toolName="model_turn_budget")`,由前端/CLI 通过同一套 `agent.approve` / `agent.reject` 队列决定是否继续;批准后再开启下一段 provider request 窗口,拒绝、取消或过期才按已有审批错误路径结束当前 run。 + +DeepSeek streaming tool call delta 在 CLI provider wrapper 内通过 `ChatToolCallAccumulator` 拼装为完整 `ChatToolCall` 后才进入 `Completed.tool_calls`。Turn Loop 不直接处理 provider 私有 delta 形态,只要求 provider 在 `Completed` 中提供完整、可校验、可执行的工具调用列表。如果累计后的 `function.arguments` 不是合法 JSON,Turn Loop 会以 `E_INVALID_TOOL_ARGUMENTS` 失败,并把脱敏后的累计 arguments 写入当前 run 的 `diagnostics/invalid-tool-arguments--.json`,同时在 `run.failed.diagnosticFile` 暴露该本地路径。若 arguments 是合法 JSON 但未通过已知工具 schema(例如 `read_file` 传入 `limit`),Turn Loop 会记录失败的 `tool.requested` / `tool.completed`,把 `E_INVALID_TOOL_ARGUMENTS`、schema 错误和纠偏 guidance 作为 tool result 喂回 provider,让模型可按正确参数重试。 + +`apply_patch` 支持大 payload 引用:`tool.requested.argumentsPreview` 可以只包含 `payloadRef`,Turn Loop 在执行前从当前 run 的 `payloads/` 文件读取完整 diff,校验 `sha256` / `sizeBytes` 后 materialize 为 `unifiedDiff`。materialize 之后仍走同一套 schema 校验、路径安全、hunk metadata 和 patch staging;最多 5 个 `expectedFiles` 的普通 workspace 代码 patch 默认不触发审批,超过阈值或修改 workspace policy 文件会动态要求审批,selected hunk 过滤只在高风险 patch 或显式审批路径存在时启用。chunk 追加阶段不会写 workspace。 + +如果 provider 请求不存在的工具名,例如 `write_file`,Turn Loop 不会执行任何 workspace 写入,也不会直接把 run 标记失败。它会写入 `tool.requested` 和 `tool.completed(status="failed")`,把 `E_UNKNOWN_TOOL` 作为工具结果返回给 provider,并在结果中列出可用工具和纠偏提示。未知工具参数不会写入 run log,避免整文件内容或敏感文本因为错误工具名被展开;文本修改应改用 `apply_patch`。 `run.started` 记录规范化后的 workspace root,用于本地审计和前端展示当前 run 绑定的工作区。该路径只应进入本地 run log 和本机前端事件流,不应被上传到公开仓库或远程日志。 @@ -80,16 +102,15 @@ DeepSeek streaming tool call delta 在 CLI provider wrapper 内通过 `ChatToolC `read_file`、`search`、`git_status` 和 `git_diff` 使用静态 `read` 风险,不需要审批。 -`apply_patch` 和 `shell` 当前根据工具定义触发审批: +`apply_patch` 保持 `write` 风险和 workspace/path/sensitive-file 约束,但最多 5 个 `expectedFiles` 的普通 workspace 代码修改默认 `approval=none`,不会写入 `tool.approvalRequired`;超过阈值的 bulk patch 或 `.gitignore` / `.prole-coderignore` patch 会动态要求审批。解析错误、文件集合不匹配和 hunk mismatch 会作为 `tool.completed status=failed` 返回给模型,让模型重新读取文件后重试;这些可恢复错误发生在写盘前,failed result 的 `reversePatch` 为空。 -- 默认策略 `RejectAllApprovalPolicy` 会拒绝执行,Turn Loop 写入 `tool.approvalRequired` 和 `tool.approvalResolved` 后以 `E_APPROVAL_REJECTED` 失败。 -- 测试可使用 `AutoApprovePolicy` 验证已批准路径。 +`shell` 当前根据工具定义触发审批。默认策略 `RejectAllApprovalPolicy` 会拒绝需要审批的 shell 执行,Turn Loop 写入 `tool.approvalRequired` 和 `tool.approvalResolved` 后以 `E_APPROVAL_REJECTED` 失败;测试可使用 `AutoApprovePolicy` 验证已批准路径。严格只读且 workspace-scoped 的简单 shell 命令例外:`rg`、`Get-Content` / `gc` / `cat` / `type`、`Select-String`、`Get-ChildItem` / `gci` / `dir`、`Test-Path`、`git diff/status/log/show` 和常见本地工具版本查询可以降为 `read` 并免审批,但版本查询只接受裸命令名加一个版本参数,`.env`、`.secrets`、`.git`、`.agents`、`.codex`、`.prole-coder` 等敏感 workspace 路径仍会排除在白名单外;`rg --replace`、`rg -r` 和组合短参数中包含 `r` 的调用不进入只读白名单。 -`shell` 的工具定义仍以 `exec` 作为最低风险,但 Turn Loop 会在审批前调用命令风险分类器。分类器会递归检查 shell 包装器、`$(...)` 和传统反引号子命令;依赖安装、网络访问、远程 git 和发布命令会升级为 `network`;删除、强制 push、`git reset --hard`、`git clean` 等会升级为 `destructive`。升级结果会同时写入 `tool.requested` 和 `tool.approvalRequired` 的 `risk` / `riskReasons`,供 CLI、RPC、VS Code 和后续 TUI 使用。 +`shell` 的工具定义仍以 `exec` 作为默认风险,但 Turn Loop 会在审批前调用命令风险分类器。分类器会递归检查 shell 包装器、`$(...)` 和传统反引号子命令;依赖安装、网络访问、远程 git 和发布命令会升级为 `network`;删除、强制 push、`git reset --hard`、`git clean` 等会升级为 `destructive`;包含管道、重定向、变量展开、子命令、绝对路径、父级路径、敏感 workspace 路径或可能写文件/执行外部程序参数的 shell 命令不会进入只读白名单。分类结果会同时写入 `tool.requested` 和 `tool.approvalRequired` 的 `risk` / `riskReasons`,供 CLI、RPC、VS Code 和后续 TUI 使用。 -RPC handler 当前提供单 active run 的真实审批等待、协作式取消和全双工事件输出:当 `tool.approvalRequired` 写入 run log 后,RPC 层会把该请求登记为 pending approval,后台 Turn Loop worker 在 `ApprovalPolicy::decide` 中等待;同一事件也会通过 live event queue 投递给 request loop 的单 writer。前端发送 `agent.approve` 会继续进入 `tool.started` 和工具执行;发送 `agent.reject` 会写入拒绝事件并使当前 run 失败;发送 `agent.cancel` 会设置 active run 的 `CancellationToken`,等待审批时写入 `tool.approvalResolved(decision="canceled")` 和 `run.canceled`,provider/tool 执行中取消时写入 `run.canceled(code="E_RUN_CANCELED")`;超过默认 300 秒没有决定会写入 `tool.approvalResolved(decision="expired")` 和 `run.canceled`。 +RPC handler 当前提供单 active run 的真实审批等待、运行中 steer、协作式取消和全双工事件输出:当 `tool.approvalRequired` 写入 run log 后,RPC 层会把该请求登记为 pending approval,后台 Turn Loop worker 在 `ApprovalPolicy::decide` 中等待;同一事件也会通过 live event queue 投递给 request loop 的单 writer。前端发送 `agent.approve` 会继续进入 `tool.started` 和工具执行;发送 `agent.reject` 会写入拒绝事件并使当前 run 失败;发送 `agent.steer` 会把补充指导放入 active run 的 `TurnSteerQueue`,下一次 provider request 前写入 `turn.steered` 并作为最新用户运行中指令注入模型消息;发送 `agent.cancel` 会设置 active run 的 `CancellationToken`,等待审批时写入 `tool.approvalResolved(decision="canceled")` 和 `run.canceled`,provider/tool 执行中取消时写入 `run.canceled(code="E_RUN_CANCELED")`;超过默认 300 秒没有决定会写入 `tool.approvalResolved(decision="expired")` 和 `run.canceled`。 -RPC active run 的 Run Log 使用 `SerializedRunLog`:后台 Turn Loop worker 追加事件,`agent.resume` 读取 active run 时也通过同一个同步句柄。这样可以保证本地 `events.jsonl`、live notification 和 replay 的 `seq` 边界一致。 +RPC active run 的 Run Log 使用 `SerializedRunLog`:后台 Turn Loop worker 追加事件,`agent.resume` 和只读 `agent.loadRunEvents` 读取 active run 时也通过同一个同步句柄。这样可以保证本地 `events.jsonl`、live notification、replay 和历史分页的 `seq` 边界一致。 ## 当前测试覆盖 @@ -98,14 +119,21 @@ RPC active run 的 Run Log 使用 `SerializedRunLog`:后台 Turn Loop worker - provider 请求 `read_file`,Turn Loop 执行工具、写入 run log、把 tool result message 回传给下一次 provider,并最终完成 run。 - provider 请求 `shell`,默认审批策略拒绝执行,run 失败且不会写入 `tool.started`。 - provider 请求高风险 `shell` 命令时,Turn Loop 会在审批前升级 `tool.requested` / `tool.approvalRequired` 风险并写入 `riskReasons`;网络和破坏性升级均覆盖 `persistable: false`。 -- provider 请求 `apply_patch`,测试审批策略批准后修改文件、记录 `changedFiles`,并完成 run。 +- provider 请求 workspace-scoped 只读 shell 命令时,Turn Loop 会把该 shell 调用降为 `read` 并免审批;含管道、重定向、绝对路径、父级路径、敏感 workspace 路径或写入/外部执行参数的命令仍需审批。常见 `python --version`、`node -v`、`cargo --version` 等版本查询按单参数只读命令处理。 +- provider 请求 `apply_patch`,最多 5 个 `expectedFiles` 的普通 workspace 代码 patch 免审批修改文件、记录 `changedFiles` 并完成 run;shell 写入文件也会通过 workspace 前后快照进入最终 `changedFiles`;超过阈值的 bulk patch 或 workspace policy 文件 patch 会触发审批;hunk mismatch 等可恢复 patch 错误会作为 failed tool result 回传并继续模型回合。 +- provider 请求未知工具如 `write_file` 时,Turn Loop 会把 `E_UNKNOWN_TOOL` 作为 failed tool result 回传,记录失败工具事件,不写入未知工具参数,也不直接 `run.failed`。 - thinking disabled 配置下,provider 返回无 `reasoning_content` 的工具调用时,Turn Loop 会按非 reasoning replay 路径继续执行工具并完成 run。 - provider 发送多个 streaming content delta,Turn Loop 写入多条 `assistant.delta`,并避免在 `Completed` 时重复写入完整文本。 +- provider 建立 stream 或等待下一段 stream event 时如果超过配置的 idle timeout 没有进展,Turn Loop 会写入 `provider.retrying(reason="provider_idle_timeout")` 并重试;连续耗尽 attempts 后写入 `run.failed(code="E_PROVIDER_TIMEOUT")`。 +- provider 最终响应以 `finishReason=length` 且没有工具调用结束时,Turn Loop 会自动追问一次最终工作总结,并且只有后续 `stop` 响应才写入 `run.completed.summary`。 +- provider 响应以 `finishReason=length` 且携带工具调用列表结束时,Turn Loop 会要求模型重新发出完整工具调用,不会执行可能截断的 partial call。 +- provider request 窗口耗尽时,Turn Loop 会发出 `model_turn_budget` continuation approval;批准后继续下一段窗口,不再直接 `E_MAX_MODEL_TURNS` 失败。 +- active run 收到 queued steer 后,Turn Loop 会在下一次 provider request 前写入 `turn.steered`,并把该补充指导作为最新用户运行中指令加入消息历史。 - provider stream 或 shell 工具收到 `CancellationToken` 后,Turn Loop 写入 `run.canceled`,并返回 `E_RUN_CANCELED`。 - Turn Loop 每次成功追加 Run Log 事件后,会把同一条事件交给 `TurnEventSink`,sink 看到的事件序列与本地 `events.jsonl` 一致。 - `SerializedRunLog` 并发 append 测试验证多个 clone 同时写同一 run 时仍生成连续 `seq`。 - DeepSeek wrapper 能把 streaming tool call delta 拼成完整工具调用,并在缺少必要 metadata 时失败。 -- tool call arguments 会先按工具注册表 JSON Schema 校验,再进入 typed deserialization;未知字段和错误类型会返回 `E_INVALID_TOOL_ARGUMENTS`。 +- tool call arguments 会先解析为 JSON,再按工具注册表 JSON Schema 校验,最后进入 typed deserialization;malformed JSON 和非法 `payloadRef` 会返回终止型 `E_INVALID_TOOL_ARGUMENTS`,malformed JSON 还会写入脱敏诊断文件。已知工具的 schema mismatch(未知字段、错误类型、非法空值等)会以失败 tool result 返回 provider,不直接终止 run。 这些测试验证的是模块集成骨架,不需要真实 DeepSeek API Key,也不会联网。真实 tool call delta 形态由 `live_streaming_tool_call_accumulator_smoke_test` 作为手动 opt-in live test 验收。 @@ -113,7 +141,7 @@ RPC active run 的 Run Log 使用 `SerializedRunLog`:后台 Turn Loop worker - 多 active run 关联和持久批准存储。 - 更强 sandbox 策略。 -- RPC request loop 里的 verification 编排;CLI `run` 已支持用户显式 `--verify`。 +- 更完整的 verification 编排;当前 Turn Loop 已能从常见 shell 验证命令形态汇总 `run.completed.verificationStatus`,CLI `run` 仍支持用户显式 `--verify`。 ## 后续增强 diff --git a/docs/vscode-extension.md b/docs/vscode-extension.md index e6cb73f..4e95be6 100644 --- a/docs/vscode-extension.md +++ b/docs/vscode-extension.md @@ -1,6 +1,6 @@ # 编辑器插件(VS Code Extension) -状态:Phase 3 VS Code 插件核心体验已完成;Phase 4 VS Code 深度集成已完成,包含原 14 项深度集成能力以及 P4-15 到 P4-18 的 Codex-like 原生 Chat UX 收敛。基础命令、审批弹窗 adapter、RPC server 启动监管、初始化握手、JSON-RPC request client、VS Code/protocol TypeScript 类型共享、RPC/commands 边界测试、Sidebar Chat 事件渲染、Chat 输入发送真实 turn、真实审批回传、共享 RPC 全双工事件管线、命令风险动态升级展示、Native diff editor patch 预览、Run List / resume、Context Capsule 可视化、VSIX alpha 打包、extension-host E2E、原生 `@prole` Chat Participant、简化审批 UX 和自动上下文压缩均已实现。 +状态:Phase 3 VS Code 插件核心体验已完成;Phase 4 VS Code 深度集成已完成 14 项深度集成能力;Phase 5 VS Code Codex-like UX 与开发工作流已完成。基础命令、RPC server 启动监管、初始化握手、JSON-RPC request client、VS Code/protocol TypeScript 类型共享、RPC/commands 边界测试、Sidebar Chat 事件渲染、Chat 输入发送真实 turn、真实审批回传、共享 RPC 全双工事件管线、命令风险动态升级展示、Native diff editor patch 预览、Run List / resume、Context Capsule 可视化、VSIX alpha 打包、extension-host E2E、原生 `@prole` Chat Participant、简化审批 UX、自动上下文压缩、`ProleCoder` Output Channel 错误诊断、API key/model 配置、统一 redaction、Git 工作流、Sidebar 连续会话、Run 删除、折叠事件 UX、结构化 provider 配置错误恢复、P5-14 真实试用回归修复包、P5-15 Markdown renderer / edit resend 后端语义收敛、P5-16 第一批真实试用 UX 收敛、P5-17 Codex-like 交互细化、P5-18a-l 真实试用 UX 收敛、P5-19 四项目真实试用回归验收、Sidebar timeline 历史分页和合并前 UX backlog 收口均已实现;下一步进入 Phase 6 AGENT 性能调试。 VS Code 插件是 `ProleCoder` 的一等前端。它必须通过 JSON-RPC server 复用 Rust Agent Core,而不是在 TypeScript 侧重新实现 agent loop、context builder、provider 调用或 tool execution。 @@ -27,7 +27,7 @@ VS Code 插件是 `ProleCoder` 的一等前端。它必须通过 JSON-RPC server - 按行解析 stdout 上的 JSON-RPC response / notification。 - 把 `agent.event` notification 转发给注册的事件 handler。 - 通过 `sendRequest()` 发送 JSON-RPC request,并按 request id 管理 pending response。 -- 提供 typed `sendTurn()`、`cancel()`、`approve()`、`reject()`、`listRuns()` 和 `resume()` helper,避免 UI 层直接拼常用 JSON-RPC method string。 +- 提供 typed `sendTurn()`、`cancel()`、`approve()`、`reject()`、`listRuns()`、`resume()` 和 `loadRunEvents()` helper,避免 UI 层直接拼常用 JSON-RPC method string。 - 把 JSON-RPC error response 转换为 `RpcRequestError`,保留 `code` 和 `data`。 - server 停止、退出或出错时,会拒绝尚未完成的 pending request。 - 记录 stderr 尾部,供后续错误提示和诊断使用。 @@ -45,22 +45,28 @@ VS Code 插件是 `ProleCoder` 的一等前端。它必须通过 JSON-RPC server - 在 Activity Bar 暴露 ProleCoder view container 和 Chat view。 - 通过 `RpcServerManager.onEvent()` 订阅 live `agent.event`。 -- 使用 `ChatEventTimeline` 把 `assistant.delta`、tool lifecycle、approval、context/provider 和 terminal event 转换为 timeline item。 -- 同一 run/turn 的连续 `assistant.delta` 会合并为一条 assistant 消息,避免流式输出刷屏。 -- 提供 prompt 输入、mode 选择和运行中 Cancel 按钮;通过 Webview `submitTurn` 消息调用 typed `RpcServerManager.sendTurn()`,发送时把 Problems 快照转换为 diagnostic attachments,并按协议 attachment 上限优先保留 error;accepted 后等待同一 run 的 terminal event 收口输入状态,Cancel 会调用 typed `RpcServerManager.cancel()`。 +- `RpcServerManager` 会在 `agent.resume` response 后把本次回放范围内的事件标记为本地 `replay: true`。Sidebar timeline 仍正常渲染这些历史事件,但审批 controller、patch diff preview 等会忽略 replayed approval events,避免打开旧 run 时重复弹出历史审批。 +- 使用 `ChatEventTimeline` 把 `assistant.delta`、tool lifecycle、approval、context/provider、`turn.steered` 和 terminal event 转换为 timeline item;默认对话流只显示 `You` 用户消息、`DeepSeek` 回复、回复间 `Activity` 摘要和失败/取消错误。tool/provider/context/run completed 等过程事件不再在对话中堆叠展示:运行中只显示一个 `Working:` 单行状态栏,下一段 assistant 回复到来时才在两段回复之间插入修改文件数和 shell 指令数摘要,并过滤 `read_file` / `search` / manifest / git diff/status 等平凡只读工具;run 完成且最终 assistant 总结可见后,中间过程会按用户消息 / steer 边界分成多个 `Earlier activity` 默认折叠块,用户消息和 steer 原样留在主时间线,展开折叠块后继续用原来的 assistant 分段和 `Activity` 卡片格式渲染。Sidebar 内存 timeline 默认最多保留 1200 条过程 item,并且只裁剪可从 workspace run log 重放的过程项;用户消息、steer、assistant 分段和 terminal 结果不参与裁剪,`.prole-coder/runs//events.jsonl` 是这些不可丢对话内容的持久源,直到用户删除该 run。用户向上滚动 timeline 时,Sidebar 会先从已加载 run event 恢复被裁掉的过程项,必要时再通过只读 `agent.loadRunEvents` 从 run log 加载更早事件页;该路径不使用 `agent.resume`,因此不会重放旧审批通知,且 webview 的滚动加载节流只在 extension 回传 `timelineHistory` 完成/失败状态后重置,避免慢后端下重复 postMessage。完整事件和 payload 写入 `Output > ProleCoder` 与 run log。可见对话消息使用模块化安全 DOM Markdown renderer,覆盖标题、列表、引用、代码块、表格、链接、inline code 和 horizontal rule,并通过独立单元测试覆盖表格转义、链接边界、未闭合 inline 标记和大文本;工具输出仍保留纯文本在 Output/run log 中用于排错,单条消息渲染失败会回退为纯文本并写入 Output;历史 replay 或 streaming 中的高频 `agent.event` 会合并 snapshot/submission/context 推送,避免连续 `assistant.delta` 触发重复完整 Markdown 重渲染。 +- 同一 run/turn 且未跨工具或 steer 边界的连续 `assistant.delta` 会合并为一条 assistant 消息;跨工具/steer 后会开启新的 assistant 分段,避免工具前说明和最终小结混在一起。 +- 提供 prompt 输入、mode 自动推断和运行中停止/steer 入口;通过 Webview `submitTurn` 消息调用 typed `RpcServerManager.sendTurn()`,发送时把 Problems 快照转换为 diagnostic attachments,并按协议 attachment 上限优先保留 error;如果当前已 resume/发送过 run,会复用该 `runId` 继续同一会话并由后端递增 `turn_N`;提交普通消息后输入框立即清空,accepted 后等待同一 run 的 terminal event 收口输入状态。运行中输入框为空时发送按钮显示停止并调用 typed `RpcServerManager.cancel()`;运行中输入框有文本时按钮切回发送图标,点击或 Enter 会先在聊天界面和 textarea 之间显示内联 steer 确认卡,用户确认后才发送 typed `RpcServerManager.steer()`。webview 会立即在聊天框显示本地 pending steer 用户消息,标题为 `You (queued)`;在后端尚未写入同一 `steerId` 的 `turn.steered` 前,运行中的 `Working:` 状态栏显示在该 pending steer 上方,表示模型仍在处理 steer 注入前的工作。后端在下一次 provider request 前注入补充指导并写入 `turn.steered` 后,本地 pending 消息会让位给 run log 回放中的正式用户消息,且该消息在后续 assistant 分段之前可见。 +- Sidebar 右上角提供齿轮 Settings 入口;composer 常驻 API Key / Model 快捷入口。Enter 和回车符号发送按钮走同一提交路径,Shift+Enter 保留换行;发送后会先在当前对话中显示本地 pending 用户消息,等待真实 run event 覆盖;用户消息可通过笔形图标按钮回填到 composer,修改后在同一会话继续发送,Sidebar 会在 `agent.sendTurn.supersedes` 中传递被覆盖消息的 `messageId` / `turnId`,后端把它写入新 `turn.started.payload.supersedes`,因此 webview reload 和 `agent.resume` 后仍能稳定隐藏旧用户消息并让自动上下文跳过旧输入。VS Code webview state 仅作为即时本地缓存。 +- P5-16 已把 composer 的 `edit` / `ask` / `plan` / `review` mode 下拉框改为默认隐藏;普通 Sidebar 发送不再提交隐藏 selector 的默认值,而是在 `chatInput` 协议边界根据用户文本自动推断 run mode:问答走 `ask`,实现/修复走 `edit`,计划讨论走 `plan`,代码审查走 `review`。用户仍可用 `/ask`、`/plan`、`/review`、`/edit` 或 `ask:` / `plan:` / `review:` / `edit:` 这类轻量前缀强制 mode,前缀会在发送给 agent 前剥离;RPC `agent.sendTurn.mode` 协议保持不变。 +- Run List 支持 `agent.listRuns` / `agent.resume` / `agent.loadRunEvents` / `agent.deleteRun`,可回放历史 run、按需加载更早 timeline、继续多轮会话,也可删除 inactive run。 +- 失败状态会在 Sidebar Chat 中显示短消息,并把 sendTurn、Run List refresh/resume/delete/cancel、原生 `@prole` Chat Participant 和 RPC 启动/运行 warning 的完整错误写入 VS Code `Output > ProleCoder`;Sidebar Chat 还会把完整 `agent.event` payload 写入 Output 便于 debug。 `vscode/extension/src/commands.ts` 还提供 `requestApproval`: -- 使用 VS Code modal warning 展示审批摘要。 -- 主审批弹窗只展示 `Approve` / `Reject`;`Approve` 映射为一次性批准,关闭弹窗映射为拒绝。 -- `apply_patch` 多 hunk 审批继续提供 `Select Hunks` quick pick,持久化批准能力保留在 Core/RPC 策略与队列中,不在主弹窗暴露复杂选项。 +- 默认使用 Sidebar 内联审批卡片展示审批摘要。 +- 主审批动作只展示 `Approve` / `Reject`;`Approve` 映射为一次性批准,`Reject` 映射为拒绝。 +- shell 审批卡可选择“Approve for conversation”,Sidebar 会按 `runId + cwd + command` 在当前会话内记忆该命令,后续同 run 的相同 shell 审批自动发送一次性 approve;这不同于 RPC session/workspace 持久批准,不会跨 run 或跨进程保存。 +- 最多 5 个 `expectedFiles` 的普通 workspace 代码 `apply_patch` 默认直接执行,不弹出审批卡片;超过阈值的 bulk patch、workspace policy 文件 patch 以及需要审批的 shell / network / destructive 操作仍使用 Sidebar 内联 approval card。`apply_patch` 多 hunk 审批边界保留在 Core/RPC/VS Code 中,供高风险 patch 或显式审批路径复用;Native diff preview 会对模型生成的小幅 hunk header 计数偏差做宽容预览解析,但严重超量仍拒绝预览,真实写盘仍由 Core 的 patch staging、路径安全和 hunk 校验决定;持久化批准能力保留在 Core/RPC 策略与队列中,不在主审批 UI 暴露复杂选项。 `vscode/extension/src/approvalFlow.ts` 当前接入真实 RPC pending approval: - 订阅 `RpcServerManager.onEvent()`,只处理 `tool.approvalRequired`。 - 校验 approval payload 的 `approvalId`、`toolCallId`、`toolName`、`risk`、`title`、`detail`、`persistable`、`command`、`cwd`、`outputSummary` 和 `paths`。 -- 复用 `requestApproval` 打开 VS Code modal,并把 approve/reject 结果发送为 typed `RpcServerManager.approve()` / `reject()`。 -- 记录已处理的 approvalId,避免重复事件触发重复弹窗。 +- 复用 `ApprovalEventController` 的顺序和去重逻辑,默认通过 `ProleChatViewProvider.requestApproval()` 打开 Sidebar 内联卡片,并把 approve/reject 结果发送为 typed `RpcServerManager.approve()` / `reject()`。 +- 记录已处理的 approvalId,避免重复事件触发重复提示。 ## 配置 @@ -68,7 +74,8 @@ VS Code 插件是 `ProleCoder` 的一等前端。它必须通过 JSON-RPC server { "prole-coder.rpc.autoStart": true, "prole-coder.rpc.command": "prole", - "prole-coder.rpc.args": ["rpc"] + "prole-coder.rpc.args": ["rpc"], + "prole-coder.provider.model": "deepseek-v4-pro" } ``` @@ -81,7 +88,9 @@ VS Code 插件是 `ProleCoder` 的一等前端。它必须通过 JSON-RPC server } ``` -配置不保存 API Key。DeepSeek API Key 仍应由 Rust CLI/RPC server 按既有规则从环境变量或被忽略的本地 `.secrets/` 文件读取。 +CLI/RPC 的 DeepSeek 默认输出上限为 65536 tokens;通常不需要额外传 `--max-output-tokens`。这些设置可通过 Sidebar composer 的 Settings 按钮或命令面板 `ProleCoder: Open Settings` 打开;需要手动编辑 JSON 时使用 VS Code `Preferences: Open User Settings (JSON)`,也可以在测试 workspace 的 `.vscode/settings.json` 中写入 workspace 级配置。`prole-coder.rpc.args` 改动后需要重启 RPC server 或 reload Extension Development Host,已启动的子进程不会自动继承新参数。 + +配置不保存 API Key。DeepSeek API Key 由插件命令写入 VS Code SecretStorage 的多 key 管理器,或由 CLI/RPC server 继续按既有规则从环境变量读取;Key 管理器展示 alias 与 masked key,支持添加 key+alias、选择 active key、改 alias 和删除指定 key。DeepSeek model ID 是非敏感配置,可通过 `prole-coder.provider.model` 或 Sidebar 的 Model 按钮选择。 ## MVP 分层 @@ -96,9 +105,9 @@ Phase 3 P0 顺序: 3. 渲染 `agent.event` 事件流。已完成 Sidebar Chat 首版,能消费 manager 转发的事件。 4. 支持文本输入并通过 `agent.sendTurn` 发送真实 turn。已完成首版 Sidebar Chat 输入发送。 5. 通过 JSON-RPC request client 回传用户动作。已完成 approval approve/reject 回传。 -6. 展示审批请求和命令输出摘要。已完成首版 `tool.approvalRequired` modal 接入真实 RPC pending queue。 -7. 接入命令风险分类器输出,在审批 UI 中展示动态升级后的风险等级和原因。已完成:approval modal 和 Sidebar Chat 时间线都会展示 `riskReasons`。 -8. 使用 VS Code 原生 diff editor 展示 patch,并为 hunk 级审批预留交互边界。已完成:`PatchDiffPreviewController` 缓存 `tool.requested` 中的 `apply_patch` unified diff,在审批 modal 前打开虚拟 after 文档与 workspace before 文档的原生 diff,并保存 whole-patch 模式下的稳定 hunk boundary。 +6. 展示审批请求和命令输出摘要。已完成 `tool.approvalRequired` 接入真实 RPC pending queue;默认 UX 已从系统 modal 收敛为 Sidebar 内联审批卡片。 +7. 接入命令风险分类器输出,在审批 UI 中展示动态升级后的风险等级和原因。已完成:approval UI 和 Sidebar Chat 时间线都会展示 `riskReasons`。 +8. 使用 VS Code 原生 diff editor 展示 patch,并为 hunk 级审批预留交互边界。已完成:`PatchDiffPreviewController` 可缓存 `tool.requested` 中的 `apply_patch` unified diff,在需要审批的 patch 路径前打开虚拟 after 文档与 workspace before 文档的原生 diff,并保存 whole-patch 模式下的稳定 hunk boundary;小规模普通 workspace 代码 patch 默认免审批,超过 5 个 `expectedFiles` 的 bulk patch 和 workspace policy 文件 patch 仍进入审批路径。 9. 展示 Run List / resume。已完成:Sidebar Chat 顶部 Run List 调用 `agent.listRuns` 展示最近 run summary,点击历史 run 后调用 `agent.resume`,清空当前事件视图并消费 replay 的 `agent.event`。 10. 展示 Context Capsule 可视化。已完成:Sidebar Chat 消费 `context.built` metadata,展示三层 token 分布、input/stable budget、cache/estimator 摘要、included/omitted sources 和 manifest 摘要。 @@ -110,32 +119,51 @@ Phase 3 P0 验收标准: - stdin EOF、writer BrokenPipe 或插件停用会取消 active run;run log 最终出现 `run.canceled` 或已有 terminal event。 - Sidebar Chat 能消费 `agent.event` 并展示 `assistant.delta`、tool lifecycle 和 terminal event。已完成首版事件渲染。 - Chat 输入能发送真实 `agent.sendTurn`,并通过事件流收到最终结果。已完成首版输入发送和事件流收口。 -- `tool.approvalRequired` 触发 VS Code modal,approve/reject 能回传到 `agent.approve` / `agent.reject`。已完成真实 RPC pending queue 接入;`apply_patch` 首版支持 selected hunk quick pick 并通过 `agent.approve.hunks` 回传。 +- `tool.approvalRequired` 触发 Sidebar 内联审批卡片,approve/reject 能回传到 `agent.approve` / `agent.reject`。已完成真实 RPC pending queue 接入;最多 5 个 `expectedFiles` 的普通 workspace 代码 `apply_patch` 不触发审批,bulk patch / workspace policy 文件 / selected hunk / `agent.approve.hunks` 继续保留给需要审批的 patch 路径。 - Sidebar Chat 能通过 `agent.listRuns` 展示最近 run,并用 `agent.resume` 回放历史事件。已完成首版 Run List / resume 接入。 - Sidebar Chat 能把 `context.built` 渲染为 Context Capsule 面板,展示 token 分段、来源和 manifest/cache/estimator metadata。已完成首版 Context Capsule 可视化。 -- `ProleCoder: Open Settings` 能打开 VS Code 设置,并显示 server capability、模型预算、审批策略、RPC command/state;扩展配置不保存 API Key。 +- `ProleCoder: Open Settings` 和 Sidebar Settings 按钮能打开 VS Code 设置,并显示 server capability、模型预算、审批策略、RPC command/state;扩展配置不保存 API Key,DeepSeek model ID 作为非敏感配置保存。 - Inline completion 首版通过 `agent.previewFim` 请求 RPC server 的 FIM preview,只有 server capability 明确标记 `supportsFim` 的模型会被使用。 -Phase 4 深度集成权威清单与 `docs/phase-tasks.md` 对齐: - -1. P4-1:VSIX dry-run packaging smoke,已完成:`pnpm run vsix:smoke` 会构建 extension,临时生成 VSIX,检查 `.vscodeignore`、`workspace:*` 运行时边界、media asset、compiled `out/` 和 activationEvents,并清理产物;不标记最终 VSIX 交付完成。 -2. P4-2:`@vscode/test-electron` 最小 harness,已完成:`pnpm run vscode:test-electron` 覆盖 activation、trusted workspace、Chat view focus 和命令注册,测试工作区禁用 RPC autoStart。 -3. P4-3:Provider capability model data contract,已完成:`agent.initialize.capabilities.provider` 暴露 DeepSeek V4 model capability,首版不引入 heavy trait。 -4. P4-4:事件 payload schema 与协议 fixture 对齐,已完成:共享 fixture 覆盖 `provider.requested`、`tool.completed`、`run.completed`,并处理协议版本不匹配提示。 -5. P4-5:RPC 高频事件输出节流与批量发送策略,已完成:实时 live event 支持 `agent.eventBatch`,保持 Run Log `seq` 与 replay 语义稳定。 -6. P4-6:`agent.cancel` 类型化 helper 与 Chat Cancel UI,已完成:`RpcServerManager.cancel()` 和 Sidebar Chat Cancel 按钮接入真实 RPC。 -7. P4-7:通过 diagnostic attachments 读取 Problems 面板诊断并交给 Agent Core,已完成:发送 turn 时采集 Problems 快照,并按协议 attachment 上限裁剪。 -8. P4-8:Terminal command approval 展示命令、cwd、风险等级、上一条 shell 输出摘要和持久化语义,已完成:shared protocol payload 和后端策略已支持;P4-16 后主审批弹窗不再暴露持久化选项。 -9. P4-9:审批持久化存储,已完成:RPC 队列支持 session/workspace 持久批准,并继续禁止 network/destructive 风险持久化。 -10. P4-10:provider、model、预算、审批策略和 RPC 命令配置界面,已完成:Open Settings 命令展示 `agent.initialize` 返回的 capability data、RPC command/state 和 API Key 不落 VS Code settings 的边界。 -11. P4-11:真实 hunk 级 patch 审批,已完成:`apply_patch` 可选择 hunks,RPC/Core 校验 hunk id 并只应用已批准 hunks,审批事件 payload 已同步 fixture。 -12. P4-12:FIM completion preview,已完成:VS Code 原生 inline completion 通过 `agent.previewFim` 获取 preview,模型选择只依赖 server capability。 -13. P4-13:VSIX alpha / pre-release 打包与安装说明,已完成:`pnpm run vsix:alpha` 会生成 `target/vsix/prole-coder-vscode-0.1.0-alpha.vsix` 和 `.sha256` 校验和,VSIX manifest 标记为 VS Code pre-release;`docs/release.md` 记录 clean user-data/extensions 目录下的安装验收步骤。 -14. P4-14:补齐 end-to-end 集成测试覆盖,已完成:`pnpm run vscode:test-electron` 使用本地 JSON-RPC fixture server 覆盖 Chat sendTurn、Cancel、Problems diagnostics、自动审批回传、Run List / resume,并使用隔离 VS Code profile 避免本机状态影响测试;VSIX 安装后基础交互按 `docs/release.md` 的 clean 环境路径验收。 -15. P4-15:原生 Chat 入口,已完成:贡献 `@prole` Chat Participant,并让 `ProleCoder: Open Chat` 优先打开 VS Code Chat 侧栏;普通 Activity Bar Webview 继续承载 Run List、Context Capsule 和更详细事件视图。 -16. P4-16:简化审批,已完成:主审批按钮保持 Approve / Reject;多 hunk patch 继续保留 Select Hunks;持久化审批能力仍由 Core/RPC 策略约束,不把复杂策略放进主弹窗。 -17. P4-17:自动上下文压缩,已完成:Sidebar Chat 和原生 Chat Participant 会把历史对话/事件摘要压缩为 `explicit_content` attachment,交给 Context Capsule 处理,让连续对话自然承接上下文。 -18. P4-18:测试与打包验收,已完成:已覆盖 `pnpm -r typecheck`、`pnpm -r lint`、`pnpm -r test`、`pnpm run vscode:test-electron`、`pnpm run vsix:smoke` 和 `pnpm run vsix:alpha`。 +Phase 4 深度集成清单与 `docs/phase-tasks.md` 对齐;实现细节和验收命令以任务索引为准: + +1. P4-1:VSIX dry-run packaging smoke。 +2. P4-2:`@vscode/test-electron` 最小 harness。 +3. P4-3:Provider capability model data contract。 +4. P4-4:事件 payload schema 与协议 fixture 对齐。 +5. P4-5:RPC 高频事件输出节流与批量发送策略。 +6. P4-6:`agent.cancel` 类型化 helper 与 Chat Cancel UI。 +7. P4-7:Problems 面板诊断进入 Context Builder。 +8. P4-8:Terminal command approval。 +9. P4-9:审批持久化存储。 +10. P4-10:provider、model、预算、审批策略和 RPC 命令配置界面。 +11. P4-11:真实 hunk 级 patch 审批。 +12. P4-12:FIM completion preview。 +13. P4-13:VSIX alpha / pre-release 打包与安装说明。 +14. P4-14:补齐 end-to-end 集成测试覆盖。 + +Phase 5 Codex-like UX 与开发工作流清单与 `docs/phase-tasks.md` 对齐;复杂项在任务索引里继续拆子项: + +1. P5-1:原生 Chat 入口。 +2. P5-2:简化审批。 +3. P5-3:自动上下文压缩。 +4. P5-4:UX 收敛测试与打包验收。 +5. P5-5:VS Code Output Channel 错误诊断。 +6. P5-6:DeepSeek API key SecretStorage、model selector 与 provider status。 +7. P5-7:统一 redaction 与 API key 错误恢复 UX。 +8. P5-8:Git context 只读采集与大 diff attachment 管线。 +9. P5-9:Generate Commit Message。 +10. P5-10:Generate PR Description。 +11. P5-11:Phase 5 UX 工作流验收。 +12. P5-12:Sidebar 连续会话、Run 删除与折叠事件 UX。 +13. P5-13:结构化 provider 配置错误码与恢复动作。 +14. P5-14:真实试用回归修复包。 +15. P5-15:真实试用 UX backlog 第二批收敛。 +16. P5-16:真实试用 UX backlog 第一批收敛。 +17. P5-17:Codex-like 交互细化。 +18. P5-18:真实试用 UX backlog 第三批收敛。 +19. P5-19:真实试用 UX backlog 第四批收敛。 +20. P5-19z:真实试用 UX backlog 合并前收口。 在这些能力稳定前,不在插件侧重复实现 context builder、tool execution 或 provider 调用。 diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 578eda9..b56f446 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -8,7 +8,10 @@ export const agentResumeMethod = "agent.resume" as const; export const agentApproveMethod = "agent.approve" as const; export const agentRejectMethod = "agent.reject" as const; export const agentCancelMethod = "agent.cancel" as const; +export const agentSteerMethod = "agent.steer" as const; export const agentListRunsMethod = "agent.listRuns" as const; +export const agentDeleteRunMethod = "agent.deleteRun" as const; +export const agentLoadRunEventsMethod = "agent.loadRunEvents" as const; export const agentPreviewFimMethod = "agent.previewFim" as const; export interface ProtocolErrorDefinition { @@ -39,6 +42,22 @@ export const rpcErrorCodes = { internalInvariant: -32060, } as const; +export const rpcRecoverableActionKinds = ["configureDeepSeekApiKey"] as const; +export type RpcRecoverableActionKind = (typeof rpcRecoverableActionKinds)[number]; + +export interface RpcRecoverableAction { + readonly kind: RpcRecoverableActionKind; + readonly label: string; +} + +export type ProviderConfigurationErrorCode = "missingApiKey"; + +export interface ProviderConfigurationErrorData { + readonly provider: "deepseek"; + readonly configurationError: ProviderConfigurationErrorCode; + readonly recoverableAction: RpcRecoverableAction; +} + export const protocolErrorDefinitions = [ { code: jsonRpcErrorCodes.parseError, name: "Parse error" }, { code: jsonRpcErrorCodes.invalidRequest, name: "Invalid Request" }, @@ -152,6 +171,7 @@ export const toolNames = [ "git_diff", "lsp_diagnostics", "plan_update", + "model_turn_budget", ] as const; export type ToolName = (typeof toolNames)[number]; @@ -289,7 +309,7 @@ export const toolDefinitions = [ name: "apply_patch", description: "应用统一 diff patch。", risk: "write", - approval: "required", + approval: "none", implementationStatus: "executor_implemented", argumentSchema: { type: "object", @@ -402,6 +422,20 @@ export const toolDefinitions = [ }, resultSchema: statusResultSchema, }, + { + name: "model_turn_budget", + description: "Approve continuing an agent turn after the provider-turn budget window is exhausted.", + risk: "exec", + approval: "required", + implementationStatus: "schema_only", + // Intentionally zero-argument: only `{}` is valid for this local continuation control point. + argumentSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + resultSchema: statusResultSchema, + }, ] as const satisfies readonly ToolDefinition[]; export function findToolDefinition(name: string): ToolDefinition | undefined { @@ -461,11 +495,17 @@ export interface TurnAttachment { readonly text?: string; } +export interface TurnSupersedes { + readonly messageId: string; + readonly turnId?: string; +} + export interface SendTurnParams { readonly runId?: string; readonly message: string; readonly mode: RpcRunMode; readonly attachments?: readonly TurnAttachment[]; + readonly supersedes?: TurnSupersedes; } export interface SendTurnResult { @@ -485,6 +525,20 @@ export interface ResumeResult { readonly replayStarted: boolean; } +export interface LoadRunEventsParams { + readonly runId: string; + readonly beforeSeq?: number; + readonly limit?: number; +} + +export interface LoadRunEventsResult { + readonly runId: string; + readonly events: readonly AgentEventEnvelope[]; + readonly firstSeq?: number; + readonly lastSeq?: number; + readonly hasMoreBefore: boolean; +} + export type RunSummaryStatus = "running" | "completed" | "failed" | "canceled"; export interface ListRunsParams { @@ -510,6 +564,15 @@ export interface ListRunsResult { readonly runs: readonly RunSummary[]; } +export interface DeleteRunParams { + readonly runId: string; +} + +export interface DeleteRunResult { + readonly runId: string; + readonly deleted: true; +} + export interface ApproveParams { readonly approvalId: string; readonly persist?: ApprovalPersistence; @@ -549,6 +612,17 @@ export interface CancelResult { readonly reason?: string; } +export interface SteerParams { + readonly runId: string; + readonly message: string; +} + +export interface SteerResult { + readonly runId: string; + readonly steerId: string; + readonly accepted: true; +} + export interface FimPreviewParams { readonly prefix: string; readonly suffix?: string; diff --git a/packages/protocol/test/index.test.ts b/packages/protocol/test/index.test.ts index f887785..0283079 100644 --- a/packages/protocol/test/index.test.ts +++ b/packages/protocol/test/index.test.ts @@ -10,7 +10,9 @@ import { agentApproveMethod, agentRejectMethod, agentCancelMethod, + agentSteerMethod, agentListRunsMethod, + agentDeleteRunMethod, agentEventBatchMethod, agentPreviewFimMethod, type ApprovalRequest, @@ -18,19 +20,25 @@ import { type ApproveResult, type CancelParams, type CancelResult, + type DeleteRunParams, + type DeleteRunResult, type AgentEventBatchParams, type FimPreviewParams, type FimPreviewResult, type ListRunsParams, type ListRunsResult, + type ProviderConfigurationErrorData, type ProviderCapabilities, type ProviderCompletedPayload, type ProviderRequestedPayload, type RunCompletedPayload, type RunLogPayloadMetadata, type RunSummary, + type RpcRecoverableAction, type RejectParams, type RejectResult, + type SteerParams, + type SteerResult, type ServerCapabilities, type ToolCompletedPayload, type ToolApprovalRequiredPayload, @@ -47,6 +55,7 @@ import { riskLevels, toolDefinitions, toolNames, + rpcRecoverableActionKinds, } from "../src/index.js"; interface ToolRegistryFixture { @@ -129,7 +138,9 @@ test("JSON-RPC method constants match protocol document", () => { assert.equal(agentApproveMethod, "agent.approve"); assert.equal(agentRejectMethod, "agent.reject"); assert.equal(agentCancelMethod, "agent.cancel"); + assert.equal(agentSteerMethod, "agent.steer"); assert.equal(agentListRunsMethod, "agent.listRuns"); + assert.equal(agentDeleteRunMethod, "agent.deleteRun"); assert.equal(agentPreviewFimMethod, "agent.previewFim"); assert.equal(agentEventMethod, "agent.event"); assert.equal(agentEventBatchMethod, "agent.eventBatch"); @@ -191,6 +202,21 @@ test("protocol error code registry matches protocol document", () => { } }); +test("provider configuration errors expose recoverable actions", () => { + const action = { + kind: "configureDeepSeekApiKey", + label: "Configure API Key", + } satisfies RpcRecoverableAction; + const data = { + provider: "deepseek", + configurationError: "missingApiKey", + recoverableAction: action, + } satisfies ProviderConfigurationErrorData; + + assert.deepEqual(rpcRecoverableActionKinds, ["configureDeepSeekApiKey"]); + assert.equal(data.recoverableAction.kind, "configureDeepSeekApiKey"); +}); + test("approval request and decision params use stable protocol fields", () => { const request = { approvalId: "approval_1", @@ -272,6 +298,15 @@ test("approval request and decision params use stable protocol fields", () => { state: "canceled", reason: cancel.reason, } satisfies CancelResult; + const steer = { + runId: "run_1", + message: "Focus on the failing tests before continuing.", + } satisfies SteerParams; + const steerResult = { + runId: steer.runId, + steerId: "steer_1", + accepted: true, + } satisfies SteerResult; const fimPreview = { prefix: "fn main() {", suffix: "}", @@ -301,6 +336,7 @@ test("approval request and decision params use stable protocol fields", () => { assert.equal(approveResult.state, "approved"); assert.equal(reject.reason, rejectResult.reason); assert.equal(cancelResult.state, "canceled"); + assert.equal(steerResult.accepted, true); assert.equal(fimPreview.languageId, "rust"); assert.equal(fimPreviewResult.finishReason, "stop"); assert.equal(expiredPayload.decision, "expired"); @@ -332,6 +368,17 @@ test("run summary params and results use stable protocol fields", () => { assert.equal(result.runs[0]?.runId, "run_1"); assert.equal(result.runs[0]?.status, "completed"); assert.equal(result.runs[0]?.lastSeq, 8); + + const deleteParams = { + runId: "run_1", + } satisfies DeleteRunParams; + const deleteResult = { + runId: "run_1", + deleted: true, + } satisfies DeleteRunResult; + + assert.equal(deleteParams.runId, "run_1"); + assert.equal(deleteResult.deleted, true); }); test("attachments and provider completed payload use phase 2c and 2d fields", () => { @@ -551,8 +598,14 @@ function sortedTools(tools: readonly ToolRegistryTool[]): ToolRegistryTool[] { return [...tools].sort((left, right) => left.name.localeCompare(right.name)); } -test("tool approval defaults match risk defaults", () => { +test("tool approval defaults match risk defaults except explicit overrides", () => { for (const tool of toolDefinitions) { + if (tool.name === "apply_patch") { + assert.equal(tool.risk, "write"); + assert.equal(tool.approval, "none"); + continue; + } + assert.equal( tool.approval, riskDefaultApproval[tool.risk], @@ -561,9 +614,9 @@ test("tool approval defaults match risk defaults", () => { } }); -test("mutating and executing tools require approval", () => { +test("workspace patch is write risk without approval while shell requires approval", () => { assert.equal(findToolDefinition("apply_patch")?.risk, "write"); - assert.equal(findToolDefinition("apply_patch")?.approval, "required"); + assert.equal(findToolDefinition("apply_patch")?.approval, "none"); assert.equal(findToolDefinition("shell")?.risk, "exec"); assert.equal(findToolDefinition("shell")?.approval, "required"); }); diff --git a/test/projects/README.md b/test/projects/README.md new file mode 100644 index 0000000..00854a7 --- /dev/null +++ b/test/projects/README.md @@ -0,0 +1,10 @@ +# ProleCoder Manual Test Projects + +This directory contains small synthetic projects for manual VS Code extension and agent UX testing. + +- `agent_misc_tests/` is the tracked clean baseline copy. +- `agent_misc_tests_working/` is the ignored mutable workspace opened by `.vscode/launch.json`. + +The fixtures are original and intentionally not derived from SWE-bench, Terminal-Bench, or other public benchmark task datasets. + +When a manual test run modifies the working copy, restore it from the clean baseline before the next full UX pass. The VS Code pre-launch task creates the working copy from `agent_misc_tests/` when it is missing. Runtime outputs such as `.prole-coder/`, `target/`, `node_modules/`, and `__pycache__/` should stay untracked. diff --git a/test/projects/agent_misc_tests/.vscode/settings.json b/test/projects/agent_misc_tests/.vscode/settings.json new file mode 100644 index 0000000..e995ed9 --- /dev/null +++ b/test/projects/agent_misc_tests/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "prole-coder.rpc.command": "C:\\Users\\Shenglin\\Developer\\prole-coder\\target\\debug\\prole.exe", + "prole-coder.rpc.args": ["rpc"] +} diff --git a/test/projects/agent_misc_tests/01-js-ledger-lite/.vscode/settings.json b/test/projects/agent_misc_tests/01-js-ledger-lite/.vscode/settings.json new file mode 100644 index 0000000..e995ed9 --- /dev/null +++ b/test/projects/agent_misc_tests/01-js-ledger-lite/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "prole-coder.rpc.command": "C:\\Users\\Shenglin\\Developer\\prole-coder\\target\\debug\\prole.exe", + "prole-coder.rpc.args": ["rpc"] +} diff --git a/test/projects/agent_misc_tests/01-js-ledger-lite/AGENT_TASK.md b/test/projects/agent_misc_tests/01-js-ledger-lite/AGENT_TASK.md new file mode 100644 index 0000000..061fb3e --- /dev/null +++ b/test/projects/agent_misc_tests/01-js-ledger-lite/AGENT_TASK.md @@ -0,0 +1,23 @@ +# Task: Fix ledger summary behavior + +## Goal + +Fix the ledger helpers in `src/ledger.js` so the tests pass while keeping the exported function names stable. + +## Requirements + +- `parseAmount(value)` should accept numbers and strings. +- String amounts may include `$`, commas, whitespace, a leading minus sign, or accounting parentheses such as `($42.10)`. +- Invalid amounts should throw a useful `TypeError`. +- `summarizeByCategory(transactions)` should ignore transactions whose `status` is `voided`. +- Categories should be normalized with trim + lowercase. +- Sums should be rounded to cents in a stable way. +- `topCategories(summary, limit)` should sort by absolute spend descending, then category name ascending. + +## Validation + +Run from this directory: + +```powershell +npm test +``` diff --git a/test/projects/agent_misc_tests/01-js-ledger-lite/PROJECT.md b/test/projects/agent_misc_tests/01-js-ledger-lite/PROJECT.md new file mode 100644 index 0000000..0f1e0e2 --- /dev/null +++ b/test/projects/agent_misc_tests/01-js-ledger-lite/PROJECT.md @@ -0,0 +1,13 @@ +# Ledger Lite + +Ledger Lite is a tiny synthetic JavaScript utility project for manual agent testing. It models a simple personal finance helper with parsing, category summaries, and ranking logic. + +This project is intentionally original and does not copy tasks, files, tests, or prompts from SWE-bench, Terminal-Bench, or similar benchmark datasets. + +What this tests: + +- Reading a small project description. +- Finding and fixing logic bugs. +- Preserving a public API. +- Running Node built-in tests. +- Handling edge cases in string parsing and stable sorting. diff --git a/test/projects/agent_misc_tests/01-js-ledger-lite/package.json b/test/projects/agent_misc_tests/01-js-ledger-lite/package.json new file mode 100644 index 0000000..4ddcf68 --- /dev/null +++ b/test/projects/agent_misc_tests/01-js-ledger-lite/package.json @@ -0,0 +1,9 @@ +{ + "name": "ledger-lite", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test" + } +} diff --git a/test/projects/agent_misc_tests/01-js-ledger-lite/src/ledger.js b/test/projects/agent_misc_tests/01-js-ledger-lite/src/ledger.js new file mode 100644 index 0000000..f8947a6 --- /dev/null +++ b/test/projects/agent_misc_tests/01-js-ledger-lite/src/ledger.js @@ -0,0 +1,27 @@ +export function parseAmount(value) { + if (typeof value === "number") { + return value; + } + + const cleaned = String(value).replace("$", "").trim(); + return Number(cleaned); +} + +export function summarizeByCategory(transactions) { + const summary = new Map(); + + for (const transaction of transactions) { + const category = transaction.category.trim(); + const current = summary.get(category) ?? 0; + summary.set(category, current + parseAmount(transaction.amount)); + } + + return Object.fromEntries(summary.entries()); +} + +export function topCategories(summary, limit = 3) { + return Object.entries(summary) + .sort((left, right) => right[1] - left[1]) + .slice(0, limit) + .map(([category, amount]) => ({ category, amount })); +} diff --git a/test/projects/agent_misc_tests/01-js-ledger-lite/test/ledger.test.js b/test/projects/agent_misc_tests/01-js-ledger-lite/test/ledger.test.js new file mode 100644 index 0000000..7090340 --- /dev/null +++ b/test/projects/agent_misc_tests/01-js-ledger-lite/test/ledger.test.js @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseAmount, summarizeByCategory, topCategories } from "../src/ledger.js"; + +test("parseAmount accepts common ledger formats", () => { + assert.equal(parseAmount(12.5), 12.5); + assert.equal(parseAmount(" $1,234.50 "), 1234.5); + assert.equal(parseAmount("-$19.99"), -19.99); + assert.equal(parseAmount("($42.10)"), -42.1); +}); + +test("parseAmount rejects invalid amounts", () => { + assert.throws(() => parseAmount("not money"), TypeError); + assert.throws(() => parseAmount(undefined), TypeError); +}); + +test("summarizeByCategory ignores voided transactions and normalizes categories", () => { + const summary = summarizeByCategory([ + { amount: "$10.005", category: " Food ", status: "posted" }, + { amount: "5.005", category: "food", status: "posted" }, + { amount: "$999.00", category: "Food", status: "voided" }, + { amount: "($3.50)", category: "Transport", status: "posted" } + ]); + + assert.deepEqual(summary, { + food: 15.01, + transport: -3.5 + }); +}); + +test("topCategories sorts by absolute amount then category", () => { + assert.deepEqual( + topCategories({ books: -20, food: 20, rent: -900, travel: -20 }, 3), + [ + { category: "rent", amount: -900 }, + { category: "books", amount: -20 }, + { category: "food", amount: 20 } + ] + ); +}); diff --git a/test/projects/agent_misc_tests/02-python-note-index/.vscode/settings.json b/test/projects/agent_misc_tests/02-python-note-index/.vscode/settings.json new file mode 100644 index 0000000..e995ed9 --- /dev/null +++ b/test/projects/agent_misc_tests/02-python-note-index/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "prole-coder.rpc.command": "C:\\Users\\Shenglin\\Developer\\prole-coder\\target\\debug\\prole.exe", + "prole-coder.rpc.args": ["rpc"] +} diff --git a/test/projects/agent_misc_tests/02-python-note-index/AGENT_TASK.md b/test/projects/agent_misc_tests/02-python-note-index/AGENT_TASK.md new file mode 100644 index 0000000..02c4f8e --- /dev/null +++ b/test/projects/agent_misc_tests/02-python-note-index/AGENT_TASK.md @@ -0,0 +1,22 @@ +# Task: Fix note index parsing and CLI output + +## Goal + +Fix `src/note_index.py` so the note index builder and CLI pass the tests without adding third-party dependencies. + +## Requirements + +- Parse the simple YAML-like front matter at the top of each note. +- Support `tags: [A, B]` and `tags: A, B` forms. +- Tag filtering should be case-insensitive. +- Notes should be sorted by date descending, then title ascending. +- Missing titles should fall back to the file stem. +- The CLI should print one line per note as `YYYY-MM-DD | Title | tag1, tag2`. + +## Validation + +Run from this directory: + +```powershell +python -m unittest discover -s tests +``` diff --git a/test/projects/agent_misc_tests/02-python-note-index/PROJECT.md b/test/projects/agent_misc_tests/02-python-note-index/PROJECT.md new file mode 100644 index 0000000..f649932 --- /dev/null +++ b/test/projects/agent_misc_tests/02-python-note-index/PROJECT.md @@ -0,0 +1,13 @@ +# Note Index + +Note Index is a tiny synthetic Python project for manual agent testing. It scans Markdown notes with simple front matter and prints a stable index. + +This project is intentionally original and does not copy tasks, files, tests, or prompts from SWE-bench, Terminal-Bench, or similar benchmark datasets. + +What this tests: + +- Python standard-library only development. +- Parser behavior with small front matter variants. +- CLI output stability. +- Case-insensitive filtering. +- Sorting and fallback behavior. diff --git a/test/projects/agent_misc_tests/02-python-note-index/src/__init__.py b/test/projects/agent_misc_tests/02-python-note-index/src/__init__.py new file mode 100644 index 0000000..0d2b6b4 --- /dev/null +++ b/test/projects/agent_misc_tests/02-python-note-index/src/__init__.py @@ -0,0 +1 @@ +# Package marker for unittest imports. diff --git a/test/projects/agent_misc_tests/02-python-note-index/src/note_index.py b/test/projects/agent_misc_tests/02-python-note-index/src/note_index.py new file mode 100644 index 0000000..4b10f6d --- /dev/null +++ b/test/projects/agent_misc_tests/02-python-note-index/src/note_index.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Note: + title: str + date: str + tags: tuple[str, ...] + path: Path + + +def parse_front_matter(text: str) -> dict[str, str]: + if not text.startswith("---\n"): + return {} + + _, raw, _body = text.split("---", 2) + data: dict[str, str] = {} + for line in raw.splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + data[key.strip()] = value.strip() + return data + + +def parse_tags(value: str) -> tuple[str, ...]: + if not value: + return () + return (value.strip().lower(),) + + +def read_note(path: Path) -> Note: + data = parse_front_matter(path.read_text(encoding="utf-8")) + return Note( + title=data.get("title", path.name), + date=data.get("date", "0000-00-00"), + tags=parse_tags(data.get("tags", "")), + path=path, + ) + + +def build_index(notes_dir: Path, tag: str | None = None) -> list[Note]: + notes = [read_note(path) for path in notes_dir.glob("*.md")] + if tag is not None: + notes = [note for note in notes if tag in note.tags] + return sorted(notes, key=lambda note: note.date) + + +def format_note(note: Note) -> str: + return f"{note.date} | {note.title} | {', '.join(note.tags)}" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("notes_dir", type=Path) + parser.add_argument("--tag") + args = parser.parse_args(argv) + + for note in build_index(args.notes_dir, args.tag): + print(format_note(note)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/projects/agent_misc_tests/02-python-note-index/tests/test_note_index.py b/test/projects/agent_misc_tests/02-python-note-index/tests/test_note_index.py new file mode 100644 index 0000000..ef5d009 --- /dev/null +++ b/test/projects/agent_misc_tests/02-python-note-index/tests/test_note_index.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from src.note_index import build_index, parse_tags + + +class NoteIndexTests(unittest.TestCase): + def make_notes(self, root: Path) -> Path: + notes = root / "notes" + notes.mkdir() + (notes / "api-design.md").write_text( + "---\n" + "title: API Design\n" + "date: 2026-01-10\n" + "tags: [Architecture, RPC]\n" + "---\n" + "Details\n", + encoding="utf-8", + ) + (notes / "release-plan.md").write_text( + "---\n" + "title: Release Plan\n" + "date: 2026-02-05\n" + "tags: planning, rpc\n" + "---\n" + "Details\n", + encoding="utf-8", + ) + (notes / "untitled-note.md").write_text( + "---\n" + "date: 2026-01-20\n" + "tags: misc\n" + "---\n" + "Details\n", + encoding="utf-8", + ) + return notes + + def test_parse_tags_accepts_bracket_and_csv_forms(self) -> None: + self.assertEqual(parse_tags("[Architecture, RPC]"), ("architecture", "rpc")) + self.assertEqual(parse_tags("planning, rpc"), ("planning", "rpc")) + + def test_build_index_filters_case_insensitively_and_sorts_descending(self) -> None: + with tempfile.TemporaryDirectory() as temp: + notes = self.make_notes(Path(temp)) + result = build_index(notes, tag="RPC") + + self.assertEqual([note.title for note in result], ["Release Plan", "API Design"]) + + def test_missing_title_uses_file_stem(self) -> None: + with tempfile.TemporaryDirectory() as temp: + notes = self.make_notes(Path(temp)) + result = build_index(notes, tag="misc") + + self.assertEqual(result[0].title, "untitled-note") + + def test_cli_prints_stable_lines(self) -> None: + with tempfile.TemporaryDirectory() as temp: + notes = self.make_notes(Path(temp)) + completed = subprocess.run( + [sys.executable, "-m", "src.note_index", str(notes), "--tag", "rpc"], + check=True, + capture_output=True, + text=True, + ) + + self.assertEqual( + completed.stdout.splitlines(), + [ + "2026-02-05 | Release Plan | planning, rpc", + "2026-01-10 | API Design | architecture, rpc", + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/projects/agent_misc_tests/03-rust-path-rules/.vscode/settings.json b/test/projects/agent_misc_tests/03-rust-path-rules/.vscode/settings.json new file mode 100644 index 0000000..e995ed9 --- /dev/null +++ b/test/projects/agent_misc_tests/03-rust-path-rules/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "prole-coder.rpc.command": "C:\\Users\\Shenglin\\Developer\\prole-coder\\target\\debug\\prole.exe", + "prole-coder.rpc.args": ["rpc"] +} diff --git a/test/projects/agent_misc_tests/03-rust-path-rules/AGENT_TASK.md b/test/projects/agent_misc_tests/03-rust-path-rules/AGENT_TASK.md new file mode 100644 index 0000000..27b37d7 --- /dev/null +++ b/test/projects/agent_misc_tests/03-rust-path-rules/AGENT_TASK.md @@ -0,0 +1,22 @@ +# Task: Implement workspace path normalization + +## Goal + +Fix `src/lib.rs` so workspace-relative path normalization is predictable and safe. + +## Requirements + +- Accept relative file paths and normalize `\` to `/`. +- Collapse `.` and repeated separators. +- Resolve safe `..` segments that stay inside the workspace. +- Reject paths that escape above the workspace. +- Reject absolute Unix paths, Windows drive paths, UNC paths, empty paths, and paths with NUL bytes. +- Return normalized paths without leading `./`. + +## Validation + +Run from this directory: + +```powershell +cargo test +``` diff --git a/test/projects/agent_misc_tests/03-rust-path-rules/Cargo.lock b/test/projects/agent_misc_tests/03-rust-path-rules/Cargo.lock new file mode 100644 index 0000000..5f4849b --- /dev/null +++ b/test/projects/agent_misc_tests/03-rust-path-rules/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "path-rules-lab" +version = "0.1.0" diff --git a/test/projects/agent_misc_tests/03-rust-path-rules/Cargo.toml b/test/projects/agent_misc_tests/03-rust-path-rules/Cargo.toml new file mode 100644 index 0000000..b23decc --- /dev/null +++ b/test/projects/agent_misc_tests/03-rust-path-rules/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "path-rules-lab" +version = "0.1.0" +edition = "2024" +publish = false + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +dbg_macro = "deny" +todo = "deny" +unwrap_used = "deny" diff --git a/test/projects/agent_misc_tests/03-rust-path-rules/PROJECT.md b/test/projects/agent_misc_tests/03-rust-path-rules/PROJECT.md new file mode 100644 index 0000000..c78ce55 --- /dev/null +++ b/test/projects/agent_misc_tests/03-rust-path-rules/PROJECT.md @@ -0,0 +1,12 @@ +# Path Rules Lab + +Path Rules Lab is a tiny synthetic Rust crate for manual agent testing. It asks the agent to implement workspace-relative path normalization with clear safety boundaries. + +This project is intentionally original and does not copy tasks, files, tests, or prompts from SWE-bench, Terminal-Bench, or similar benchmark datasets. + +What this tests: + +- Rust editing and test execution. +- Security-minded path normalization. +- Handling Windows and Unix path edge cases. +- Keeping implementation small and deterministic. diff --git a/test/projects/agent_misc_tests/03-rust-path-rules/src/lib.rs b/test/projects/agent_misc_tests/03-rust-path-rules/src/lib.rs new file mode 100644 index 0000000..7ddc9eb --- /dev/null +++ b/test/projects/agent_misc_tests/03-rust-path-rules/src/lib.rs @@ -0,0 +1,52 @@ +pub fn normalize_workspace_path(input: &str) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() || trimmed.starts_with('/') || trimmed.contains("..") { + return None; + } + + Some(trimmed.replace('\\', "/")) +} + +#[cfg(test)] +mod tests { + use super::normalize_workspace_path; + + #[test] + fn normalizes_common_relative_paths() { + assert_eq!( + normalize_workspace_path("./src//lib.rs"), + Some("src/lib.rs".to_string()) + ); + assert_eq!( + normalize_workspace_path("notes\\daily\\plan.md"), + Some("notes/daily/plan.md".to_string()) + ); + } + + #[test] + fn resolves_safe_parent_segments() { + assert_eq!( + normalize_workspace_path("src/generated/../lib.rs"), + Some("src/lib.rs".to_string()) + ); + } + + #[test] + fn rejects_paths_that_escape_workspace() { + assert_eq!(normalize_workspace_path("../secret.txt"), None); + assert_eq!(normalize_workspace_path("src/../../secret.txt"), None); + } + + #[test] + fn rejects_absolute_and_platform_specific_paths() { + assert_eq!(normalize_workspace_path("/tmp/file.txt"), None); + assert_eq!(normalize_workspace_path("C:\\Users\\name\\file.txt"), None); + assert_eq!(normalize_workspace_path("\\\\server\\share\\file.txt"), None); + } + + #[test] + fn rejects_empty_and_nul_paths() { + assert_eq!(normalize_workspace_path(" "), None); + assert_eq!(normalize_workspace_path("src/\0file.rs"), None); + } +} diff --git a/test/projects/agent_misc_tests/04-js-event-reducer/.vscode/settings.json b/test/projects/agent_misc_tests/04-js-event-reducer/.vscode/settings.json new file mode 100644 index 0000000..e995ed9 --- /dev/null +++ b/test/projects/agent_misc_tests/04-js-event-reducer/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "prole-coder.rpc.command": "C:\\Users\\Shenglin\\Developer\\prole-coder\\target\\debug\\prole.exe", + "prole-coder.rpc.args": ["rpc"] +} diff --git a/test/projects/agent_misc_tests/04-js-event-reducer/AGENT_TASK.md b/test/projects/agent_misc_tests/04-js-event-reducer/AGENT_TASK.md new file mode 100644 index 0000000..0516295 --- /dev/null +++ b/test/projects/agent_misc_tests/04-js-event-reducer/AGENT_TASK.md @@ -0,0 +1,22 @@ +# Task: Fix event timeline reducer + +## Goal + +Fix `src/timelineReducer.js` so the reducer keeps a compact and accurate UI timeline. + +## Requirements + +- Merge consecutive `assistant.delta` events that share the same `runId` and `turnId`. +- Keep separate assistant messages for different turns. +- Add approval items when `approval.requested` arrives. +- Mark approval items as resolved when `approval.completed` arrives. +- When a run reaches `run.completed`, `run.failed`, or `run.canceled`, set `activeRunId` to `undefined`. +- Preserve unknown events as compact system items. + +## Validation + +Run from this directory: + +```powershell +npm test +``` diff --git a/test/projects/agent_misc_tests/04-js-event-reducer/PROJECT.md b/test/projects/agent_misc_tests/04-js-event-reducer/PROJECT.md new file mode 100644 index 0000000..e8b4d87 --- /dev/null +++ b/test/projects/agent_misc_tests/04-js-event-reducer/PROJECT.md @@ -0,0 +1,13 @@ +# Event Reducer Lab + +Event Reducer Lab is a tiny synthetic JavaScript project for manual agent testing. It models a compact UI timeline reducer for agent events. + +This project is intentionally original and does not copy tasks, files, tests, or prompts from SWE-bench, Terminal-Bench, or similar benchmark datasets. + +What this tests: + +- Stateful reducer reasoning. +- Merging streamed assistant deltas. +- Updating existing timeline items. +- Terminal state handling. +- Preserving unknown events without special-casing every type. diff --git a/test/projects/agent_misc_tests/04-js-event-reducer/package.json b/test/projects/agent_misc_tests/04-js-event-reducer/package.json new file mode 100644 index 0000000..7f75b3e --- /dev/null +++ b/test/projects/agent_misc_tests/04-js-event-reducer/package.json @@ -0,0 +1,9 @@ +{ + "name": "event-reducer-lab", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test" + } +} diff --git a/test/projects/agent_misc_tests/04-js-event-reducer/src/timelineReducer.js b/test/projects/agent_misc_tests/04-js-event-reducer/src/timelineReducer.js new file mode 100644 index 0000000..4badbc9 --- /dev/null +++ b/test/projects/agent_misc_tests/04-js-event-reducer/src/timelineReducer.js @@ -0,0 +1,41 @@ +export function createInitialState() { + return { + activeRunId: undefined, + items: [] + }; +} + +export function reduceEvent(state, event) { + const next = { + activeRunId: state.activeRunId, + items: [...state.items] + }; + + if (event.type === "turn.started") { + next.activeRunId = event.runId; + next.items.push({ kind: "turn", runId: event.runId, turnId: event.turnId, text: event.payload?.message ?? "" }); + return next; + } + + if (event.type === "assistant.delta") { + next.items.push({ kind: "assistant", runId: event.runId, turnId: event.turnId, text: event.payload?.text ?? "" }); + return next; + } + + if (event.type === "approval.requested") { + next.items.push({ kind: "approval", id: event.payload?.approvalId, status: "pending" }); + return next; + } + + if (event.type === "run.completed") { + next.items.push({ kind: "terminal", status: "completed", runId: event.runId }); + return next; + } + + next.items.push({ kind: "system", text: event.type }); + return next; +} + +export function reduceEvents(events) { + return events.reduce(reduceEvent, createInitialState()); +} diff --git a/test/projects/agent_misc_tests/04-js-event-reducer/test/timelineReducer.test.js b/test/projects/agent_misc_tests/04-js-event-reducer/test/timelineReducer.test.js new file mode 100644 index 0000000..7b0145c --- /dev/null +++ b/test/projects/agent_misc_tests/04-js-event-reducer/test/timelineReducer.test.js @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { reduceEvents } from "../src/timelineReducer.js"; + +function event(type, payload = {}, overrides = {}) { + return { + type, + runId: "run_1", + turnId: "turn_1", + payload, + ...overrides + }; +} + +test("assistant deltas merge for the same turn", () => { + const state = reduceEvents([ + event("turn.started", { message: "hello" }), + event("assistant.delta", { text: "Hi" }), + event("assistant.delta", { text: " there" }) + ]); + + assert.deepEqual( + state.items.filter((item) => item.kind === "assistant"), + [{ kind: "assistant", runId: "run_1", turnId: "turn_1", text: "Hi there" }] + ); +}); + +test("assistant deltas stay separate across turns", () => { + const state = reduceEvents([ + event("assistant.delta", { text: "first" }, { turnId: "turn_1" }), + event("assistant.delta", { text: "second" }, { turnId: "turn_2" }) + ]); + + assert.equal(state.items.filter((item) => item.kind === "assistant").length, 2); +}); + +test("approval completion resolves the pending approval item", () => { + const state = reduceEvents([ + event("approval.requested", { approvalId: "approval_1", command: "write file" }), + event("approval.completed", { approvalId: "approval_1", decision: "approved" }) + ]); + + assert.deepEqual(state.items, [ + { kind: "approval", id: "approval_1", status: "approved", command: "write file" } + ]); +}); + +test("terminal events clear active run and preserve status", () => { + const state = reduceEvents([ + event("turn.started", { message: "hello" }), + event("run.completed", { summary: "done" }) + ]); + + assert.equal(state.activeRunId, undefined); + assert.deepEqual(state.items.at(-1), { kind: "terminal", status: "completed", runId: "run_1" }); +}); + +test("unknown events are preserved as system items", () => { + const state = reduceEvents([event("provider.completed", { model: "synthetic-model" })]); + + assert.deepEqual(state.items, [{ kind: "system", text: "provider.completed" }]); +}); diff --git a/test/projects/agent_misc_tests/README.md b/test/projects/agent_misc_tests/README.md new file mode 100644 index 0000000..c01424b --- /dev/null +++ b/test/projects/agent_misc_tests/README.md @@ -0,0 +1,20 @@ +# Agent Misc Tests + +This workspace contains small synthetic tasks for manual ProleCoder testing. + +The fixtures are original and intentionally not derived from SWE-bench, Terminal-Bench, or other public benchmark task datasets. They are meant for smoke testing agent behavior, approval UX, file editing, test running, and multi-turn context handling. + +Recommended flow: + +1. Open this directory in the Extension Development Host. +2. Pick one subdirectory. +3. Ask `@prole` to read that subdirectory's `AGENT_TASK.md` and complete the task. +4. Approve commands and edits only when they match the task. +5. Run the listed validation command. + +Suggested order: + +1. `01-js-ledger-lite` - JavaScript bug fix with Node built-in tests. +2. `02-python-note-index` - Python parser and CLI behavior fix with unittest. +3. `03-rust-path-rules` - Rust path normalization and safety checks. +4. `04-js-event-reducer` - JavaScript event timeline reducer behavior. diff --git a/test/projects/agent_misc_tests/TASKS.md b/test/projects/agent_misc_tests/TASKS.md new file mode 100644 index 0000000..d68d8dd --- /dev/null +++ b/test/projects/agent_misc_tests/TASKS.md @@ -0,0 +1,37 @@ +# Synthetic Agent Task Prompts + +Use one prompt at a time in the VS Code Chat sidebar. + +## Task 1: JS ledger bug fix + +```text +@prole /edit In 01-js-ledger-lite, read AGENT_TASK.md and fix the ledger behavior. Keep the public API stable and run the listed tests. +``` + +## Task 2: Python note index + +```text +@prole /edit In 02-python-note-index, read AGENT_TASK.md and make the note index parser and CLI pass the tests. Avoid adding third-party dependencies. +``` + +## Task 3: Rust workspace path rules + +```text +@prole /edit In 03-rust-path-rules, read AGENT_TASK.md and implement robust workspace-relative path normalization. Run cargo test for that crate. +``` + +## Task 4: JS event reducer + +```text +@prole /edit In 04-js-event-reducer, read AGENT_TASK.md and fix the event reducer so the timeline state tests pass. +``` + +## Multi-turn follow-up checks + +```text +@prole /review Review the changes you just made in the current subdirectory and point out one possible edge case not covered by tests. +``` + +```text +@prole /ask Summarize what files changed and which validation command passed. +``` diff --git a/vscode/extension/package.json b/vscode/extension/package.json index 4540210..a85feba 100644 --- a/vscode/extension/package.json +++ b/vscode/extension/package.json @@ -13,11 +13,7 @@ }, "categories": ["Other"], "activationEvents": [ - "onStartupFinished", - "onCommand:prole-coder.openChat", - "onCommand:prole-coder.openSettings", - "onView:prole-coder.chat", - "onChatParticipant:prole-coder.chatParticipant" + "onStartupFinished" ], "main": "./out/extension.js", "contributes": { @@ -29,6 +25,30 @@ { "command": "prole-coder.openSettings", "title": "ProleCoder: Open Settings" + }, + { + "command": "prole-coder.configureDeepSeekApiKey", + "title": "ProleCoder: Configure DeepSeek API Key" + }, + { + "command": "prole-coder.clearDeepSeekApiKey", + "title": "ProleCoder: Clear DeepSeek API Key" + }, + { + "command": "prole-coder.showProviderStatus", + "title": "ProleCoder: Show Provider Status" + }, + { + "command": "prole-coder.selectDeepSeekModel", + "title": "ProleCoder: Select DeepSeek Model" + }, + { + "command": "prole-coder.generateCommitMessage", + "title": "ProleCoder: Generate Commit Message" + }, + { + "command": "prole-coder.generatePrDescription", + "title": "ProleCoder: Generate PR Description" } ], "chatParticipants": [ @@ -120,6 +140,13 @@ "minimum": 1000, "maximum": 100000, "description": "Maximum prefix and suffix characters sent to the RPC server for a FIM preview." + }, + "prole-coder.provider.model": { + "type": "string", + "default": "", + "enum": ["", "deepseek-v4-pro", "deepseek-v4-flash"], + "enumItemLabels": ["Provider default", "DeepSeek V4 Pro", "DeepSeek V4 Flash"], + "description": "Optional DeepSeek model id used for chat and agent turns. Leave empty to use the provider default or DEEPSEEK_MODEL from the environment." } } } diff --git a/vscode/extension/scripts/runVscodeIntegrationTests.mjs b/vscode/extension/scripts/runVscodeIntegrationTests.mjs index edac8c5..8e31c27 100644 --- a/vscode/extension/scripts/runVscodeIntegrationTests.mjs +++ b/vscode/extension/scripts/runVscodeIntegrationTests.mjs @@ -18,7 +18,7 @@ const extensionsPath = resolve(repoRoot, "target", `vscode-test-extensions-${pro delete process.env.ELECTRON_RUN_AS_NODE; process.env.PROLE_CODER_VSCODE_TEST = "1"; -process.env.PROLE_CODER_VSCODE_TEST_AUTO_APPROVE = "1"; +process.env.PROLE_CODER_VSCODE_TEST_AUTO_APPROVE = "0"; process.env.PROLE_CODER_VSCODE_TEST_RPC_LOG = rpcFixtureLogPath; mkdirSync(dirname(settingsPath), { recursive: true }); diff --git a/vscode/extension/src/approvalFlow.ts b/vscode/extension/src/approvalFlow.ts index 5970c52..68b7397 100644 --- a/vscode/extension/src/approvalFlow.ts +++ b/vscode/extension/src/approvalFlow.ts @@ -40,6 +40,7 @@ const TOOL_NAME_LOOKUP = { git_diff: true, lsp_diagnostics: true, plan_update: true, + model_turn_budget: true, } as const satisfies Record; export interface ApprovalRpcClient { @@ -89,6 +90,10 @@ export class ApprovalEventController implements DisposableLike { } private handleEvent(event: AgentEventEnvelope): void { + if (event.replay === true) { + return; + } + if (event.type !== APPROVAL_EVENT_TYPE) { return; } @@ -171,6 +176,7 @@ export function approvalPromptRequestFromEvent( return { approvalId: event.payload.approvalId, + runId: event.runId, toolCallId: event.payload.toolCallId, toolName: event.payload.toolName, risk: event.payload.risk, @@ -189,6 +195,7 @@ export function approvalPromptRequestFromEvent( hunks: event.payload.hunks.map((hunk) => ({ id: hunk.id, filePath: hunk.filePath, + fileIndex: hunk.fileIndex, hunkIndex: hunk.hunkIndex, oldStart: hunk.oldStart, oldCount: hunk.oldCount, diff --git a/vscode/extension/src/automaticContext.ts b/vscode/extension/src/automaticContext.ts index 0fd737a..d3b5643 100644 --- a/vscode/extension/src/automaticContext.ts +++ b/vscode/extension/src/automaticContext.ts @@ -85,7 +85,12 @@ export function automaticContextAttachmentFromTimeline( snapshot: ChatTimelineSnapshot, options: AutomaticContextOptions = {}, ): TurnAttachment | undefined { - return automaticContextAttachmentFromMessages(messagesFromTimeline(snapshot.items), options); + const supersededItemIds = new Set(snapshot.supersededItemIds ?? []); + const items = + supersededItemIds.size === 0 + ? snapshot.items + : snapshot.items.filter((item) => supersededItemIds.has(item.id) !== true); + return automaticContextAttachmentFromMessages(messagesFromTimeline(items), options); } export function mergeTurnAttachments( diff --git a/vscode/extension/src/chatApprovals.ts b/vscode/extension/src/chatApprovals.ts new file mode 100644 index 0000000..0f33056 --- /dev/null +++ b/vscode/extension/src/chatApprovals.ts @@ -0,0 +1,179 @@ +import { + APPROVAL_REJECTED_REASON, + type ApprovalPersistence, + type ApprovalPromptDecision, + type ApprovalPromptHunk, + type ApprovalPromptRequest, +} from "./commands"; + +const APPROVAL_DISPLAY_TEXT_LIMIT = 1200; +const APPROVAL_TRUNCATED_NOTICE = "\n[truncated for sidebar; see Output or run logs for full text]"; + +export interface ChatApprovalSnapshot { + readonly approvalId: string; + readonly runId?: string; + readonly toolCallId: string; + readonly toolName: string; + readonly risk: string; + readonly title: string; + readonly detail: string; + readonly persistable: boolean; + readonly command?: string; + readonly cwd?: string; + readonly outputSummary?: string; + readonly paths?: readonly string[]; + readonly riskReasons?: readonly string[]; + readonly hunks?: readonly ChatApprovalHunkSnapshot[]; +} + +export interface ChatApprovalHunkSnapshot extends ApprovalPromptHunk {} + +export function chatApprovalSnapshotFromRequest( + request: ApprovalPromptRequest, +): ChatApprovalSnapshot { + return { + approvalId: request.approvalId, + ...(request.runId === undefined ? {} : { runId: request.runId }), + toolCallId: request.toolCallId, + toolName: request.toolName, + risk: request.risk, + title: truncateDisplayText(request.title), + detail: approvalDetailForSnapshot(request), + persistable: request.persistable, + ...(request.command === undefined ? {} : { command: truncateDisplayText(request.command) }), + ...(request.cwd === undefined ? {} : { cwd: request.cwd }), + ...(request.outputSummary === undefined + ? {} + : { outputSummary: truncateDisplayText(request.outputSummary) }), + ...(request.paths === undefined ? {} : { paths: request.paths }), + ...(request.riskReasons === undefined ? {} : { riskReasons: request.riskReasons }), + ...(request.hunks === undefined + ? {} + : { + hunks: request.hunks.map((hunk) => ({ ...hunk })), + }), + }; +} + +function approvalDetailForSnapshot(request: ApprovalPromptRequest): string { + if (isDuplicatedCommandDetail(request.detail, request.command)) { + return ""; + } + + return truncateDisplayText(request.detail); +} + +function isDuplicatedCommandDetail(detail: string, command: string | undefined): boolean { + if (command === undefined || command.length === 0) { + return false; + } + + const match = detail.trim().match(/^Execute `([\s\S]*)`$/); + return match?.[1] === command; +} + +function truncateDisplayText(value: string): string { + if (value.length <= APPROVAL_DISPLAY_TEXT_LIMIT) { + return value; + } + + const sliceLength = Math.max(0, APPROVAL_DISPLAY_TEXT_LIMIT - APPROVAL_TRUNCATED_NOTICE.length); + return `${value.slice(0, sliceLength)}${APPROVAL_TRUNCATED_NOTICE}`; +} + +export function approvalDecisionFromWebviewMessage( + message: unknown, + request: ApprovalPromptRequest, +): ApprovalPromptDecision | undefined { + if (!isRecord(message) || message["type"] !== "approvalDecision") { + return undefined; + } + + if (message["approvalId"] !== request.approvalId) { + return undefined; + } + + if (message["decision"] === "reject") { + return { + kind: "reject", + approvalId: request.approvalId, + reason: APPROVAL_REJECTED_REASON, + }; + } + + if (message["decision"] !== "approve") { + return undefined; + } + + const approvedHunks = approvedHunkIdsFromMessage(message["approvedHunks"], request); + if (approvedHunks !== undefined && approvedHunks.length === 0) { + return { + kind: "reject", + approvalId: request.approvalId, + reason: "no patch hunks selected in VS Code", + }; + } + + const allHunkCount = request.hunks?.length ?? 0; + const partialHunks = + approvedHunks !== undefined && approvedHunks.length > 0 && approvedHunks.length < allHunkCount + ? { + hunks: { + approved: approvedHunks, + }, + } + : {}; + const hasPartialHunks = "hunks" in partialHunks; + + return { + kind: "approve", + approvalId: request.approvalId, + persist: approvalPersistenceFromMessage(message["persist"], request, hasPartialHunks), + ...partialHunks, + }; +} + +function approvalPersistenceFromMessage( + value: unknown, + request: ApprovalPromptRequest, + hasPartialHunks: boolean, +): ApprovalPersistence { + if (!request.persistable || hasPartialHunks) { + return "never"; + } + + if (value === "conversation" || value === "session") { + return "session"; + } + if (value === "workspace") { + return "workspace"; + } + return "never"; +} + +function approvedHunkIdsFromMessage( + value: unknown, + request: ApprovalPromptRequest, +): readonly string[] | undefined { + if (!Array.isArray(value) || request.hunks === undefined || request.hunks.length === 0) { + return undefined; + } + + const knownHunks = new Set(request.hunks.map((hunk) => hunk.id)); + const seen = new Set(); + const selected: string[] = []; + for (const entry of value) { + if (typeof entry !== "string" || !knownHunks.has(entry) || seen.has(entry)) { + continue; + } + + seen.add(entry); + selected.push(entry); + } + + return selected; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/vscode/extension/src/chatEvents.ts b/vscode/extension/src/chatEvents.ts index c954149..683739f 100644 --- a/vscode/extension/src/chatEvents.ts +++ b/vscode/extension/src/chatEvents.ts @@ -2,6 +2,24 @@ import type { AgentEventEnvelope } from "@prole-coder/protocol" with { "resolution-mode": "import", }; +const TIMELINE_DISPLAY_TEXT_LIMIT = 1200; +const TIMELINE_COMPACT_JSON_LIMIT = 800; +const TIMELINE_TRUNCATED_NOTICE = "\n[truncated for sidebar; see Output or run logs for full text]"; +// Keep enough process events for long real-world turns while treating run log replay +// as the durable source. User, assistant, and terminal items are never counted here. +const DEFAULT_TIMELINE_PROCESS_ITEM_LIMIT = 1200; +// Dedicated read-only tools are folded out of Activity summaries. Shell remains +// counted as a command because even read-only shell calls are explicit process work. +const TRIVIAL_WORK_TOOL_NAMES = new Set([ + "git_diff", + "git_status", + "lsp_diagnostics", + "plan_update", + "read_file", + "search", + "workspace_manifest", +]); + export type ChatTimelineKind = | "assistant" | "approval" @@ -11,7 +29,7 @@ export type ChatTimelineKind = | "run" | "terminal" | "tool" - | "turn"; + | "user"; export type ChatTimelineTone = "danger" | "neutral" | "running" | "success" | "warning"; @@ -28,13 +46,26 @@ export interface ChatTimelineItem { readonly title: string; readonly body?: string | undefined; readonly detail?: string | undefined; + readonly defaultCollapsed?: boolean; + readonly workGroupId?: string; + readonly workGroupTitle?: string; + readonly changedFileCount?: number; + readonly commandCount?: number; + readonly toolName?: string; + readonly steerId?: string; + readonly children?: readonly ChatTimelineItem[]; } export interface ChatTimelineSnapshot { readonly eventCount: number; readonly items: readonly ChatTimelineItem[]; + readonly visibleItems?: readonly ChatTimelineItem[]; + readonly workItems?: readonly ChatTimelineItem[]; + readonly supersededItemIds?: readonly string[]; readonly latestRunId?: string; readonly latestStatus?: string; + readonly oldestLoadedSeq?: number; + readonly hiddenProcessItemCount?: number; } export interface ChatEventTimelineOptions { @@ -42,52 +73,144 @@ export interface ChatEventTimelineOptions { } export class ChatEventTimeline { - private readonly maxItems: number; + private maxItems: number; + private readonly eventsBySeq = new Map(); private readonly items: ChatTimelineItem[] = []; private readonly assistantItemsByTurn = new Map(); + private readonly supersededItemIds = new Set(); private eventCount = 0; private latestRunId: string | undefined; private latestStatus: string | undefined; + private oldestSeq: number | undefined; + private hiddenProcessItemCount = 0; constructor(options: ChatEventTimelineOptions = {}) { - this.maxItems = options.maxItems ?? 300; + this.maxItems = options.maxItems ?? DEFAULT_TIMELINE_PROCESS_ITEM_LIMIT; } append(event: AgentEventEnvelope): ChatTimelineSnapshot { - this.eventCount += 1; + const key = eventMapKey(event); + if (this.eventsBySeq.has(key)) { + return this.snapshot(); + } + this.eventsBySeq.set(key, event); + this.rememberOldestSeq(event.seq); + this.eventCount = this.eventsBySeq.size; + this.appendEventItem(event); + + return this.snapshot(); + } + + prepend(events: readonly AgentEventEnvelope[]): ChatTimelineSnapshot { + let added = 0; + for (const event of events) { + const key = eventMapKey(event); + if (this.eventsBySeq.has(key)) { + continue; + } + this.eventsBySeq.set(key, event); + this.rememberOldestSeq(event.seq); + added += 1; + } + if (added > 0) { + this.maxItems += added; + this.rebuildFromLoadedEvents(); + } + + return this.snapshot(); + } + + revealHiddenProcessItems(count = 200): ChatTimelineSnapshot { + if (this.hiddenProcessItemCount > 0) { + this.maxItems += Math.min(count, this.hiddenProcessItemCount); + this.rebuildFromLoadedEvents(); + } + + return this.snapshot(); + } + + hasHiddenProcessItems(): boolean { + return this.hiddenProcessItemCount > 0; + } + + oldestLoadedSeq(): number | undefined { + return this.oldestSeq; + } + + private appendEventItem(event: AgentEventEnvelope, trim = true): void { this.latestRunId = event.runId; + const supersededItemId = supersededItemIdFromEvent(event); + if (supersededItemId !== undefined) { + this.supersededItemIds.add(supersededItemId); + } if (event.type === "assistant.delta") { - this.appendAssistantDelta(event); + this.appendAssistantDelta(event, trim); } else { const item = createTimelineItem(event); this.items.push(item); this.latestStatus = latestStatusFor(item); - this.trimItems(); + if (breaksAssistantSegment(event.type)) { + this.assistantItemsByTurn.delete(assistantKey(event)); + } + if (trim) { + this.trimItems(); + } } - - return this.snapshot(); } clear(): ChatTimelineSnapshot { + this.eventsBySeq.clear(); this.items.length = 0; this.assistantItemsByTurn.clear(); + this.supersededItemIds.clear(); this.eventCount = 0; this.latestRunId = undefined; this.latestStatus = undefined; + this.oldestSeq = undefined; + this.hiddenProcessItemCount = 0; return this.snapshot(); } snapshot(): ChatTimelineSnapshot { + const items = this.items.map((item) => ({ ...item })); + const oldestLoadedSeq = this.oldestLoadedSeq(); + const presentation = presentTimelineItems(items, this.supersededItemIds); return { eventCount: this.eventCount, - items: this.items.map((item) => ({ ...item })), + items, + visibleItems: presentation.visibleItems, + workItems: presentation.workItems, + ...(this.supersededItemIds.size === 0 + ? {} + : { supersededItemIds: Array.from(this.supersededItemIds) }), ...(this.latestRunId === undefined ? {} : { latestRunId: this.latestRunId }), ...(this.latestStatus === undefined ? {} : { latestStatus: this.latestStatus }), + ...(oldestLoadedSeq === undefined ? {} : { oldestLoadedSeq }), + ...(this.hiddenProcessItemCount === 0 + ? {} + : { hiddenProcessItemCount: this.hiddenProcessItemCount }), }; } - private appendAssistantDelta(event: AgentEventEnvelope): void { + private rebuildFromLoadedEvents(): void { + const events = Array.from(this.eventsBySeq.values()).sort((left, right) => left.seq - right.seq); + this.items.length = 0; + this.assistantItemsByTurn.clear(); + this.supersededItemIds.clear(); + this.eventCount = events.length; + this.latestRunId = undefined; + this.latestStatus = undefined; + this.oldestSeq = events[0]?.seq; + this.hiddenProcessItemCount = 0; + + for (const event of events) { + this.appendEventItem(event, false); + } + this.trimItems(); + } + + private appendAssistantDelta(event: AgentEventEnvelope, trim = true): void { const key = assistantKey(event); const existingId = this.assistantItemsByTurn.get(key); const payload = record(event.payload); @@ -117,24 +240,42 @@ export class ChatEventTimeline { this.items.push(item); this.assistantItemsByTurn.set(key, item.id); this.latestStatus = "Assistant streaming"; - this.trimItems(); + if (trim) { + this.trimItems(); + } } private trimItems(): void { - if (this.items.length <= this.maxItems) { - return; + let discardableCount = 0; + for (const item of this.items) { + if (isDiscardableTimelineItem(item)) { + discardableCount += 1; + } } - this.items.splice(0, this.items.length - this.maxItems); - this.rebuildAssistantIndex(); - } + if (discardableCount <= this.maxItems) { + return; + } - private rebuildAssistantIndex(): void { - this.assistantItemsByTurn.clear(); + const discardableToRemove = discardableCount - this.maxItems; + this.hiddenProcessItemCount = discardableToRemove; + const kept: ChatTimelineItem[] = []; + let removed = 0; for (const item of this.items) { - if (item.kind === "assistant") { - this.assistantItemsByTurn.set(`${item.runId}:${item.turnId ?? ""}`, item.id); + if (removed < discardableToRemove && isDiscardableTimelineItem(item)) { + removed += 1; + continue; } + kept.push(item); + } + + this.items.length = 0; + this.items.push(...kept); + } + + private rememberOldestSeq(seq: number): void { + if (this.oldestSeq === undefined || seq < this.oldestSeq) { + this.oldestSeq = seq; } } } @@ -158,7 +299,7 @@ export function createTimelineItem(event: AgentEventEnvelope): ChatTimelineItem id: `assistant:${event.runId}:${event.turnId ?? "run"}:${event.seq}`, kind: "assistant", tone: "neutral", - title: "Assistant", + title: "DeepSeek", body: textField(payload, "text") ?? textField(payload, "delta") ?? compactJson(event.payload), detail: `seq ${event.seq}`, }; @@ -169,15 +310,27 @@ export function createTimelineItem(event: AgentEventEnvelope): ChatTimelineItem tone: "running", title: "Run started", body: joinParts([label("Mode", textField(payload, "mode")), label("Workspace", textField(payload, "workspaceRoot"))]), + defaultCollapsed: true, }; case "turn.started": return { ...base, - kind: "turn", - tone: "running", - title: "Turn started", + kind: "user", + tone: "neutral", + title: "You", body: textField(payload, "userTask") ?? textField(payload, "prompt") ?? compactJson(event.payload), }; + case "turn.steered": { + const steerId = textField(payload, "steerId"); + return { + ...base, + kind: "user", + tone: "neutral", + title: "You", + body: textField(payload, "message") ?? compactJson(event.payload), + ...(steerId === undefined ? {} : { steerId }), + }; + } case "context.built": return { ...base, @@ -190,6 +343,21 @@ export function createTimelineItem(event: AgentEventEnvelope): ChatTimelineItem label("Sources", arrayCount(payload, "includedSources")), label("Omitted", arrayCount(payload, "omittedSources")), ]), + defaultCollapsed: true, + }; + case "provider.requested": + return { + ...base, + kind: "provider", + tone: "running", + title: "Provider request", + ...providerWorkGroup(event, payload), + body: joinParts([ + label("Iteration", valueText(payload, "iteration")), + label("Messages", valueText(payload, "messageCount")), + label("Reasoning", valueText(payload, "reasoningState")), + ]), + defaultCollapsed: true, }; case "provider.completed": return { @@ -197,12 +365,31 @@ export function createTimelineItem(event: AgentEventEnvelope): ChatTimelineItem kind: "provider", tone: "neutral", title: "Provider completed", + ...providerWorkGroup(event, payload), body: joinParts([ label("Model", textField(payload, "model")), label("Finish", textField(payload, "finishReason")), label("Duration", suffix(valueText(payload, "durationMs"), "ms")), label("Total tokens", nestedValueText(payload, "usage", "totalTokens")), ]), + defaultCollapsed: true, + }; + case "provider.retrying": + return { + ...base, + kind: "provider", + tone: "warning", + title: "Provider retrying", + ...providerWorkGroup(event, payload), + body: joinParts([ + label("Iteration", valueText(payload, "iteration")), + label("Reason", displayTextField(payload, "reason")), + label("Timeout", suffix(valueText(payload, "timeoutMs"), "ms")), + label("Retries remaining", valueText(payload, "retriesRemaining")), + label("Partial content", suffix(valueText(payload, "partialContentChars"), " chars")), + displayTextField(payload, "message"), + ]), + defaultCollapsed: true, }; case "tool.requested": return { @@ -210,11 +397,14 @@ export function createTimelineItem(event: AgentEventEnvelope): ChatTimelineItem kind: "tool", tone: "neutral", title: `Tool requested: ${toolName(payload)}`, + toolName: toolName(payload), + ...toolWorkGroup(event, payload), body: joinParts([ - label("Risk", textField(payload, "risk")), - label("Reasons", arrayText(payload, "riskReasons")), - label("Args", valueText(payload, "argumentsPreview")), + label("Risk", displayTextField(payload, "risk")), + label("Reasons", displayArrayText(payload, "riskReasons")), + label("Args", displayValueText(payload, "argumentsPreview")), ]), + defaultCollapsed: true, }; case "tool.approvalRequired": return { @@ -222,14 +412,17 @@ export function createTimelineItem(event: AgentEventEnvelope): ChatTimelineItem kind: "approval", tone: "warning", title: `Approval required: ${textField(payload, "toolName") ?? "tool"}`, + toolName: textField(payload, "toolName") ?? "tool", + ...toolWorkGroup(event, payload), body: joinParts([ - textField(payload, "title"), - textField(payload, "detail"), - label("Risk", textField(payload, "risk")), - label("Reasons", arrayText(payload, "riskReasons")), - label("Command", textField(payload, "command")), - label("Paths", arrayText(payload, "paths")), + displayTextField(payload, "title"), + displayTextField(payload, "detail"), + label("Risk", displayTextField(payload, "risk")), + label("Reasons", displayArrayText(payload, "riskReasons")), + label("Command", displayTextField(payload, "command")), + label("Paths", displayArrayText(payload, "paths")), ]), + defaultCollapsed: true, }; case "tool.approvalResolved": { const decision = textField(payload, "decision") ?? "resolved"; @@ -238,7 +431,12 @@ export function createTimelineItem(event: AgentEventEnvelope): ChatTimelineItem kind: "approval", tone: approvalTone(decision), title: `Approval ${decision}`, - body: joinParts([label("Tool", textField(payload, "toolName")), label("Reason", textField(payload, "reason"))]), + ...toolWorkGroup(event, payload), + body: joinParts([ + label("Tool", displayTextField(payload, "toolName")), + label("Reason", displayTextField(payload, "reason")), + ]), + defaultCollapsed: true, }; } case "tool.started": @@ -247,20 +445,33 @@ export function createTimelineItem(event: AgentEventEnvelope): ChatTimelineItem kind: "tool", tone: "running", title: `Tool started: ${toolName(payload)}`, + toolName: toolName(payload), + ...toolWorkGroup(event, payload), body: label("Call", textField(payload, "toolCallId")), + defaultCollapsed: true, }; case "tool.completed": { const status = textField(payload, "status"); + const completedToolName = toolName(payload); + const changedFileCount = + completedToolName === "apply_patch" && (status === "ok" || status === "success") + ? nestedArrayCount(payload, "result", "files") + : undefined; return { ...base, kind: "tool", tone: status === "ok" || status === "success" ? "success" : "warning", - title: `Tool completed: ${toolName(payload)}`, + title: `Tool completed: ${completedToolName}`, + toolName: completedToolName, + ...toolWorkGroup(event, payload), body: joinParts([ label("Status", status), - textField(payload, "summary"), - label("Files", nestedValueText(payload, "result", "files")), + displayTextField(payload, "summary"), + label("Files", displayNestedValueText(payload, "result", "files")), ]), + defaultCollapsed: true, + ...(changedFileCount === undefined ? {} : { changedFileCount }), + ...(completedToolName === "shell" ? { commandCount: 1 } : {}), }; } case "run.completed": @@ -277,7 +488,11 @@ export function createTimelineItem(event: AgentEventEnvelope): ChatTimelineItem kind: "terminal", tone: "danger", title: "Run failed", - body: joinParts([label("Code", textField(payload, "code")), textField(payload, "message")]), + body: joinParts([ + label("Code", displayTextField(payload, "code")), + displayTextField(payload, "message"), + label("Diagnostic file", displayTextField(payload, "diagnosticFile")), + ]), }; case "run.canceled": return { @@ -285,7 +500,7 @@ export function createTimelineItem(event: AgentEventEnvelope): ChatTimelineItem kind: "terminal", tone: "warning", title: "Run canceled", - body: joinParts([label("Code", textField(payload, "code")), textField(payload, "reason")]), + body: joinParts([label("Code", displayTextField(payload, "code")), displayTextField(payload, "reason")]), }; default: return { @@ -294,14 +509,184 @@ export function createTimelineItem(event: AgentEventEnvelope): ChatTimelineItem tone: "neutral", title: event.type, body: compactJson(event.payload), + defaultCollapsed: true, }; } } +export function presentTimelineItems( + items: readonly ChatTimelineItem[], + supersededItemIds: ReadonlySet = new Set(), +): { + readonly visibleItems: readonly ChatTimelineItem[]; + readonly workItems: readonly ChatTimelineItem[]; +} { + const hasAssistantMessage = items.some((item) => item.kind === "assistant"); + const visibleItems: ChatTimelineItem[] = []; + const workItems: ChatTimelineItem[] = []; + let hasEmittedAssistant = false; + let summaryStats = emptyWorkSummaryStats(); + + for (const item of items) { + if (supersededItemIds.has(item.id)) { + continue; + } + if (isWorkLogItem(item, hasAssistantMessage)) { + workItems.push(item); + summaryStats = collectWorkSummaryStats(summaryStats, item); + } else { + if (hasEmittedAssistant && hasWorkSummaryStats(summaryStats)) { + visibleItems.push(workSummaryItem(summaryStats, item)); + summaryStats = emptyWorkSummaryStats(); + } else if (!hasEmittedAssistant && item.kind === "assistant") { + summaryStats = emptyWorkSummaryStats(); + } + visibleItems.push(item); + if (item.kind === "assistant") { + hasEmittedAssistant = true; + } + } + } + + return { + visibleItems: collapseCompletedRunIntermediates(visibleItems, items, supersededItemIds), + workItems, + }; +} + +export function isWorkLogItem(item: ChatTimelineItem, hasAssistantMessage = false): boolean { + if (item.kind === "user" || item.kind === "assistant") { + return false; + } + + if (item.type === "run.failed" || item.type === "run.canceled") { + return false; + } + + if (item.type === "run.completed" && !hasAssistantMessage) { + return false; + } + + return true; +} + +export function isPersistentTimelineItem(item: ChatTimelineItem): boolean { + return item.kind === "assistant" || item.kind === "terminal" || item.kind === "user"; +} + +function isDiscardableTimelineItem(item: ChatTimelineItem): boolean { + return !isPersistentTimelineItem(item); +} + function assistantKey(event: AgentEventEnvelope): string { return `${event.runId}:${event.turnId ?? ""}`; } +function eventMapKey(event: AgentEventEnvelope): string { + return `${event.runId}:${event.seq}`; +} + +function breaksAssistantSegment(type: string): boolean { + return type === "turn.steered" || type.startsWith("tool."); +} + +function collapseCompletedRunIntermediates( + visibleItems: readonly ChatTimelineItem[], + sourceItems: readonly ChatTimelineItem[], + supersededItemIds: ReadonlySet, +): readonly ChatTimelineItem[] { + const completed = sourceItems.some( + (item) => item.type === "run.completed" && supersededItemIds.has(item.id) !== true, + ); + if (!completed) { + return visibleItems; + } + + let finalAssistantIndex = -1; + for (let index = visibleItems.length - 1; index >= 0; index -= 1) { + if (visibleItems[index]?.kind === "assistant") { + finalAssistantIndex = index; + break; + } + } + if (finalAssistantIndex <= 0) { + return visibleItems; + } + + const collapsedBeforeFinal = collapseCompletedIntermediateItems(visibleItems.slice(0, finalAssistantIndex)); + if (collapsedBeforeFinal === undefined) { + return visibleItems; + } + + return [...collapsedBeforeFinal, ...visibleItems.slice(finalAssistantIndex)]; +} + +function collapseCompletedIntermediateItems(items: readonly ChatTimelineItem[]): readonly ChatTimelineItem[] | undefined { + const collapsedItems: ChatTimelineItem[] = []; + let current: ChatTimelineItem[] = []; + let collapsedGroupCount = 0; + + for (const item of items) { + if (item.kind === "user") { + if (current.length > 0) { + collapsedItems.push(completedIntermediateGroupItem(current)); + collapsedGroupCount += 1; + current = []; + } + collapsedItems.push(item); + continue; + } + current.push(item); + } + + if (current.length > 0) { + collapsedItems.push(completedIntermediateGroupItem(current)); + collapsedGroupCount += 1; + } + return collapsedGroupCount === 0 ? undefined : collapsedItems; +} + +function completedIntermediateGroupItem(items: readonly ChatTimelineItem[]): ChatTimelineItem { + const first = items[0]; + const last = items[items.length - 1] ?? first; + const runId = first?.runId ?? "run"; + const turnId = first?.turnId; + return { + id: `completed-intermediate:${runId}:${turnId ?? "run"}:${first?.seq ?? 0}-${last?.lastSeq ?? last?.seq ?? 0}`, + seq: first?.seq ?? 0, + lastSeq: last?.lastSeq ?? last?.seq ?? first?.seq ?? 0, + time: last?.time ?? first?.time ?? "", + type: "run.intermediate", + runId, + ...(turnId === undefined ? {} : { turnId }), + kind: "run", + tone: "neutral", + title: "Earlier activity", + body: completedIntermediateGroupBody(items), + defaultCollapsed: true, + children: items.map((item) => ({ ...item })), + }; +} + +function completedIntermediateGroupBody(items: readonly ChatTimelineItem[]): string { + const first = items[0]; + const leadingText = truncate(firstLine(first?.body ?? first?.title ?? ""), 160); + return joinParts([ + `Collapsed ${countWithNoun(items.length, "timeline item", "timeline items")}.`, + leadingText === undefined ? undefined : `Starts with: ${leadingText}`, + ]) ?? ""; +} + +function supersededItemIdFromEvent(event: AgentEventEnvelope): string | undefined { + if (event.type !== "turn.started") { + return undefined; + } + + const payload = record(event.payload); + const supersedes = record(payload?.["supersedes"]); + return textField(supersedes, "messageId"); +} + function latestStatusFor(item: ChatTimelineItem): string { switch (item.type) { case "run.completed": @@ -335,6 +720,118 @@ function toolName(payload: Record | undefined): string { return textField(payload, "name") ?? textField(payload, "toolName") ?? "tool"; } +interface WorkSummaryStats { + readonly runId?: string; + readonly turnId?: string; + readonly firstSeq?: number; + readonly lastSeq?: number; + readonly time?: string; + readonly changedFileCount: number; + readonly commandCount: number; +} + +function emptyWorkSummaryStats(): WorkSummaryStats { + return { + changedFileCount: 0, + commandCount: 0, + }; +} + +function collectWorkSummaryStats(stats: WorkSummaryStats, item: ChatTimelineItem): WorkSummaryStats { + if (!isSummarizableWorkItem(item)) { + return stats; + } + + const changedFileCount = stats.changedFileCount + positiveInteger(item.changedFileCount); + const commandCount = stats.commandCount + positiveInteger(item.commandCount); + if (changedFileCount === stats.changedFileCount && commandCount === stats.commandCount) { + return stats; + } + + const turnId = stats.turnId ?? item.turnId; + return { + runId: stats.runId ?? item.runId, + ...(turnId === undefined ? {} : { turnId }), + firstSeq: stats.firstSeq ?? item.seq, + lastSeq: item.lastSeq, + time: item.time, + changedFileCount, + commandCount, + }; +} + +function isSummarizableWorkItem(item: ChatTimelineItem): boolean { + if (item.type !== "tool.completed") { + return false; + } + + const name = item.toolName ?? ""; + return TRIVIAL_WORK_TOOL_NAMES.has(name) !== true; +} + +function hasWorkSummaryStats(stats: WorkSummaryStats): boolean { + return stats.changedFileCount > 0 || stats.commandCount > 0; +} + +function workSummaryItem(stats: WorkSummaryStats, nextItem: ChatTimelineItem): ChatTimelineItem { + const firstSeq = stats.firstSeq ?? nextItem.seq; + const lastSeq = stats.lastSeq ?? firstSeq; + const runId = stats.runId ?? nextItem.runId; + const turnId = stats.turnId ?? nextItem.turnId; + return { + id: `work-summary:${runId}:${stats.turnId ?? nextItem.turnId ?? "run"}:${firstSeq}-${lastSeq}`, + seq: firstSeq, + lastSeq, + time: stats.time ?? nextItem.time, + type: "work.summary", + runId, + ...(turnId === undefined ? {} : { turnId }), + kind: "run", + tone: "neutral", + title: "Activity", + body: workSummaryText(stats), + }; +} + +function workSummaryText(stats: WorkSummaryStats): string { + return `Modified ${countWithNoun(stats.changedFileCount, "file", "files")}, ran ${countWithNoun( + stats.commandCount, + "command", + "commands", + )}.`; +} + +function countWithNoun(count: number, singular: string, plural: string): string { + const value = positiveInteger(count); + return `${value} ${value === 1 ? singular : plural}`; +} + +function positiveInteger(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; +} + +function providerWorkGroup( + event: AgentEventEnvelope, + payload: Record | undefined, +): Pick { + const iteration = valueText(payload, "iteration") ?? "unknown"; + return { + workGroupId: `${event.runId}:${event.turnId ?? "run"}:provider:${iteration}`, + workGroupTitle: `Provider iteration ${iteration}`, + }; +} + +function toolWorkGroup( + event: AgentEventEnvelope, + payload: Record | undefined, +): Pick { + const toolCallId = textField(payload, "toolCallId") ?? textField(payload, "approvalId") ?? String(event.seq); + return { + workGroupId: `${event.runId}:${event.turnId ?? "run"}:tool:${toolCallId}`, + workGroupTitle: `Tool: ${toolName(payload)}`, + }; +} + function record(value: unknown): Record | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) @@ -354,6 +851,14 @@ function valueText(payload: Record | undefined, key: string): s return stringifyValue(payload[key]); } +function displayTextField(payload: Record | undefined, key: string): string | undefined { + return truncate(textField(payload, key), TIMELINE_DISPLAY_TEXT_LIMIT); +} + +function displayValueText(payload: Record | undefined, key: string): string | undefined { + return truncate(valueText(payload, key), TIMELINE_DISPLAY_TEXT_LIMIT); +} + function nestedValueText( payload: Record | undefined, outer: string, @@ -362,9 +867,30 @@ function nestedValueText( return valueText(record(payload?.[outer]), inner); } +function displayNestedValueText( + payload: Record | undefined, + outer: string, + inner: string, +): string | undefined { + return truncate(nestedValueText(payload, outer, inner), TIMELINE_DISPLAY_TEXT_LIMIT); +} + function arrayCount(payload: Record | undefined, key: string): string | undefined { + const count = arrayCountNumber(payload, key); + return count === undefined ? undefined : count.toString(); +} + +function arrayCountNumber(payload: Record | undefined, key: string): number | undefined { const value = payload?.[key]; - return Array.isArray(value) ? value.length.toString() : undefined; + return Array.isArray(value) ? value.length : undefined; +} + +function nestedArrayCount( + payload: Record | undefined, + outer: string, + inner: string, +): number | undefined { + return arrayCountNumber(record(payload?.[outer]), inner); } function arrayText(payload: Record | undefined, key: string): string | undefined { @@ -372,6 +898,10 @@ function arrayText(payload: Record | undefined, key: string): s return Array.isArray(value) ? value.map((entry) => String(entry)).join(", ") : undefined; } +function displayArrayText(payload: Record | undefined, key: string): string | undefined { + return truncate(arrayText(payload, key), TIMELINE_DISPLAY_TEXT_LIMIT); +} + function stringifyValue(value: unknown): string | undefined { if (value === undefined || value === null) { return undefined; @@ -390,7 +920,7 @@ function stringifyValue(value: unknown): string | undefined { function compactJson(value: unknown): string { try { - return truncate(JSON.stringify(value), 800); + return truncate(JSON.stringify(value), TIMELINE_COMPACT_JSON_LIMIT) ?? ""; } catch { return String(value); } @@ -409,6 +939,20 @@ function joinParts(parts: Array): string | undefined { return present.length > 0 ? present.join("\n") : undefined; } -function truncate(value: string, maxLength: number): string { - return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}...`; +function truncate(value: string | undefined, maxLength: number): string | undefined { + if (value === undefined || value.length <= maxLength) { + return value; + } + + const sliceLength = Math.max(0, maxLength - TIMELINE_TRUNCATED_NOTICE.length); + return `${value.slice(0, sliceLength)}${TIMELINE_TRUNCATED_NOTICE}`; +} + +function firstLine(value: string): string { + return ( + value + .split(/\r?\n/u) + .map((part) => part.trim()) + .find((part) => part.length > 0) ?? "" + ); } diff --git a/vscode/extension/src/chatInput.ts b/vscode/extension/src/chatInput.ts index 40b8ac7..33b2c06 100644 --- a/vscode/extension/src/chatInput.ts +++ b/vscode/extension/src/chatInput.ts @@ -1,4 +1,4 @@ -import type { RpcRunMode, SendTurnParams, TurnAttachment } from "@prole-coder/protocol" with { +import type { RpcRunMode, SendTurnParams, TurnAttachment, TurnSupersedes } from "@prole-coder/protocol" with { "resolution-mode": "import", }; @@ -15,6 +15,7 @@ export const CHAT_RUN_MODES = ["edit", "ask", "plan", "review"] as const satisfi export interface ChatTurnSubmission { readonly message: string; readonly mode: RpcRunMode; + readonly supersedes?: TurnSupersedes; } export type ChatTurnSubmissionParseResult = @@ -32,21 +33,37 @@ export function parseChatTurnSubmission(value: unknown): ChatTurnSubmissionParse return { ok: false, error: "Invalid chat submission." }; } - const message = typeof value["message"] === "string" ? value["message"].trim() : ""; + let message = typeof value["message"] === "string" ? value["message"].trim() : ""; if (message.length === 0) { return { ok: false, error: "Enter a message before sending." }; } - const mode = value["mode"]; - if (!isRpcRunMode(mode)) { + const prefixedMode = parseExplicitModePrefix(message); + if (prefixedMode !== undefined) { + message = prefixedMode.message; + if (message.length === 0) { + return { ok: false, error: "Enter a message after the run mode prefix." }; + } + } + + const rawMode = value["mode"]; + if (rawMode !== undefined && !isRpcRunMode(rawMode)) { return { ok: false, error: "Choose a valid run mode." }; } + const explicitMode = isRpcRunMode(rawMode) ? rawMode : undefined; + const mode = prefixedMode?.mode ?? explicitMode ?? inferChatRunMode(message); + + const supersedes = parseTurnSupersedes(value["supersedes"]); + if (supersedes === false) { + return { ok: false, error: "Invalid edit resend metadata." }; + } return { ok: true, value: { message, mode, + ...(supersedes === undefined ? {} : { supersedes }), }, }; } @@ -59,6 +76,7 @@ export function sendTurnParams( message: submission.message, mode: submission.mode, ...(attachments.length === 0 ? {} : { attachments }), + ...(submission.supersedes === undefined ? {} : { supersedes: submission.supersedes }), }; } @@ -66,6 +84,98 @@ export function isRpcRunMode(value: unknown): value is RpcRunMode { return typeof value === "string" && Object.prototype.hasOwnProperty.call(CHAT_RUN_MODE_LOOKUP, value); } +export function inferChatRunMode(message: string): RpcRunMode { + const text = message.trim(); + if (text.length === 0) { + return DEFAULT_CHAT_MODE; + } + + if (matchesAny(text, REVIEW_PATTERNS)) { + return "review"; + } + if (matchesAny(text, PLAN_PATTERNS)) { + return "plan"; + } + if (isQuestionLike(text)) { + return "ask"; + } + if (matchesAny(text, EDIT_PATTERNS)) { + return "edit"; + } + if (matchesAny(text, ASK_PATTERNS)) { + return "ask"; + } + + return DEFAULT_CHAT_MODE; +} + +const EXPLICIT_SLASH_MODE_PREFIX = /^\/(ask|plan|review|edit)(?:\s+|$)/i; +const EXPLICIT_LABEL_MODE_PREFIX = /^(ask|plan|review|edit)\s*:\s*/i; + +const REVIEW_PATTERNS = [ + /\b(code\s+review|review|audit|inspect)\b/i, + /代码审查|审查|评审/, +]; +const PLAN_PATTERNS = [ + /\b(plan|proposal|design|roadmap|architecture|discuss|brainstorm)\b/i, + /计划|方案|设计|讨论|规划|路线图|架构/, +]; +const EDIT_PATTERNS = [ + /\b(fix|repair|implement|add|update|change|modify|refactor|delete|remove|create|write|complete|restore|reset|generate)\b/i, + /修复|改动|修改|实现|开发|补齐|添加|删除|移除|创建|生成|还原|复原|完成|整理|更新|标记/, +]; +const ASK_PATTERNS = [ + /\b(what|why|how|where|when|explain|summari[sz]e|show|tell)\b/i, + /是什么|为什么|怎么|如何|是否|能不能|可以吗|有没有|介绍|解释|总结|查看|检查一下/, +]; + +function parseExplicitModePrefix(message: string): { readonly mode: RpcRunMode; readonly message: string } | undefined { + const slash = message.match(EXPLICIT_SLASH_MODE_PREFIX); + if (slash !== null && isRpcRunMode(slash[1])) { + return { + mode: slash[1], + message: message.slice(slash[0].length).trim(), + }; + } + + const label = message.match(EXPLICIT_LABEL_MODE_PREFIX); + if (label !== null && isRpcRunMode(label[1])) { + return { + mode: label[1], + message: message.slice(label[0].length).trim(), + }; + } + + return undefined; +} + +function matchesAny(message: string, patterns: readonly RegExp[]): boolean { + return patterns.some((pattern) => pattern.test(message)); +} + +function isQuestionLike(message: string): boolean { + return /[??]\s*$/.test(message); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } + +function parseTurnSupersedes(value: unknown): TurnSupersedes | undefined | false { + if (value === undefined) { + return undefined; + } + if (!isRecord(value)) { + return false; + } + + const messageId = typeof value["messageId"] === "string" ? value["messageId"].trim() : ""; + if (messageId.length === 0) { + return false; + } + const turnId = typeof value["turnId"] === "string" ? value["turnId"].trim() : ""; + return { + messageId, + ...(turnId.length === 0 ? {} : { turnId }), + }; +} diff --git a/vscode/extension/src/chatParticipant.ts b/vscode/extension/src/chatParticipant.ts index fc7e562..33ff7cc 100644 --- a/vscode/extension/src/chatParticipant.ts +++ b/vscode/extension/src/chatParticipant.ts @@ -15,21 +15,30 @@ import { type ChatParticipantRpcClient, } from "./chatParticipantCore"; import { diagnosticAttachmentsFromProblems } from "./diagnostics"; +import type { ProleLogger } from "./logging"; +import { + isConfigureDeepSeekApiKeyAction, + providerConfigurationActionFromMetadata, +} from "./providerConfigurationUx"; +import { CONFIGURE_DEEPSEEK_API_KEY_COMMAND } from "./providerSecretCommands"; +import type { MessageRedactor } from "./redaction"; export function registerProleChatParticipant( context: vscode.ExtensionContext, rpcClient: ChatParticipantRpcClient | undefined, workspaceRoot: string | undefined, + logger?: ProleLogger, + redactor?: MessageRedactor, ): vscode.Disposable { const participant = vscode.chat.createChatParticipant( CHAT_PARTICIPANT_ID, - (request, chatContext, response, token) => { + async (request, chatContext, response, token) => { const automaticContext = automaticContextAttachmentFromMessages( messagesFromChatHistory(chatContext.history), ); const diagnostics = collectDiagnosticAttachments(workspaceRoot); const attachments = mergeTurnAttachments(automaticContext, diagnostics); - return runChatParticipantTurn({ + const result = await runChatParticipantTurn({ ...(rpcClient === undefined ? {} : { rpcClient }), request: { prompt: request.prompt, @@ -37,8 +46,15 @@ export function registerProleChatParticipant( ...(attachments.length === 0 ? {} : { attachments }), }, response, + ...(logger === undefined ? {} : { logger }), + ...(redactor === undefined ? {} : { redactor }), token, }); + const action = providerConfigurationActionFromMetadata(result.metadata); + if (isConfigureDeepSeekApiKeyAction(action)) { + await vscode.commands.executeCommand(CONFIGURE_DEEPSEEK_API_KEY_COMMAND); + } + return result; }, ); participant.iconPath = vscode.Uri.joinPath(context.extensionUri, "media", "prole-coder-view.svg"); diff --git a/vscode/extension/src/chatParticipantCore.ts b/vscode/extension/src/chatParticipantCore.ts index 3b17845..d315a7c 100644 --- a/vscode/extension/src/chatParticipantCore.ts +++ b/vscode/extension/src/chatParticipantCore.ts @@ -13,6 +13,13 @@ import { type ConversationContextMessage, } from "./automaticContext"; import { DEFAULT_CHAT_MODE, sendTurnParams } from "./chatInput"; +import type { ProleLogger } from "./logging"; +import { + providerConfigurationActionFromError, + providerConfigurationActionFromPayload, + type ProviderConfigurationAction, +} from "./providerConfigurationUx"; +import type { MessageRedactor } from "./redaction"; import type { AgentEventEnvelope, DisposableLike } from "./rpcServer"; export const CHAT_PARTICIPANT_ID = "prole-coder.chatParticipant"; @@ -53,6 +60,8 @@ export interface ChatParticipantTurnOptions { readonly rpcClient?: ChatParticipantRpcClient; readonly request: ChatParticipantTurnRequest; readonly response: ChatParticipantResponseStream; + readonly logger?: ProleLogger; + readonly redactor?: MessageRedactor; readonly token?: CancellationTokenLike; } @@ -113,7 +122,7 @@ export async function runChatParticipantTurn( return; } - handleParticipantEvent(event, options.response, finish); + handleParticipantEvent(event, options.response, finish, options.redactor); }); const cancellationSubscription = options.token?.onCancellationRequested(() => { cancellationState.requested = true; @@ -133,7 +142,7 @@ export async function runChatParticipantTurn( for (const event of bufferedEvents) { if (event.runId === runId) { - handleParticipantEvent(event, options.response, finish); + handleParticipantEvent(event, options.response, finish, options.redactor); } } @@ -151,7 +160,13 @@ export async function runChatParticipantTurn( return await terminalPromise; } catch (error) { - return errorResult(`ProleCoder turn failed: ${errorMessage(error)}`, runId); + const message = redactMessage( + `ProleCoder turn failed: ${errorMessage(error)}`, + options.redactor, + ); + const action = providerConfigurationActionFromError(error); + options.logger?.error(message); + return errorResult(message, runId, action); } finally { eventSubscription.dispose(); cancellationSubscription?.dispose(); @@ -179,6 +194,7 @@ function handleParticipantEvent( event: AgentEventEnvelope, response: ChatParticipantResponseStream, finish: (result: ChatParticipantResult) => void, + redactor?: MessageRedactor, ): void { const payload = record(event.payload); switch (event.type) { @@ -209,9 +225,10 @@ function handleParticipantEvent( }); return; case "run.failed": { - const message = terminalMessage(payload, "Run failed."); + const message = redactMessage(terminalMessage(payload, "Run failed."), redactor); + const action = providerConfigurationActionFromPayload(payload); response.markdown(`\n\n${message}`); - finish(errorResult(message, event.runId)); + finish(errorResult(message, event.runId, action)); return; } case "run.canceled": { @@ -261,16 +278,25 @@ function terminalMessage(payload: Record | undefined, fallback: ); } -function errorResult(message: string, runId?: string): ChatParticipantResult { +function errorResult( + message: string, + runId?: string, + action?: ProviderConfigurationAction, +): ChatParticipantResult { return { errorDetails: { message }, metadata: { status: "failed", ...(runId === undefined ? {} : { runId }), + ...(action === undefined ? {} : { providerConfigurationAction: action }), }, }; } +function redactMessage(message: string, redactor: MessageRedactor | undefined): string { + return redactor?.redact(message) ?? message; +} + function canceledResult(runId?: string): ChatParticipantResult { return { metadata: { diff --git a/vscode/extension/src/chatView.ts b/vscode/extension/src/chatView.ts index fdb51cd..f8f3133 100644 --- a/vscode/extension/src/chatView.ts +++ b/vscode/extension/src/chatView.ts @@ -4,12 +4,18 @@ import * as vscode from "vscode"; import type { CancelParams, CancelResult, + DeleteRunParams, + DeleteRunResult, + LoadRunEventsParams, + LoadRunEventsResult, ListRunsParams, ListRunsResult, ResumeParams, ResumeResult, SendTurnParams, SendTurnResult, + SteerParams, + SteerResult, } from "@prole-coder/protocol" with { "resolution-mode": "import", }; @@ -18,27 +24,50 @@ import { automaticContextAttachmentFromTimeline, mergeTurnAttachments, } from "./automaticContext"; +import { + approvalDecisionFromWebviewMessage, + chatApprovalSnapshotFromRequest, + type ChatApprovalSnapshot, +} from "./chatApprovals"; import { CHAT_RUN_MODES, DEFAULT_CHAT_MODE, parseChatTurnSubmission, sendTurnParams } from "./chatInput"; import { ChatEventTimeline, type ChatTimelineSnapshot } from "./chatEvents"; +import { OPEN_SETTINGS_COMMAND, type ApprovalPromptDecision, type ApprovalPromptRequest } from "./commands"; import { contextVizFromEvent, emptyContextViz, type ContextVizSnapshot, } from "./contextViz"; import { diagnosticAttachmentsFromProblems } from "./diagnostics"; +import type { ProleLogger } from "./logging"; +import { + isConfigureDeepSeekApiKeyAction, + providerConfigurationActionFromError, + providerConfigurationActionFromPayload, + type ProviderConfigurationAction, +} from "./providerConfigurationUx"; +import { + CONFIGURE_DEEPSEEK_API_KEY_COMMAND, + SELECT_DEEPSEEK_MODEL_COMMAND, +} from "./providerSecretCommands"; +import type { MessageRedactor } from "./redaction"; import type { AgentEventEnvelope, DisposableLike } from "./rpcServer"; import { RUN_LIST_LIMIT, + deletedRunList, failedRunList, idleRunList, isRefreshRunsMessage, + deleteRunIdFromMessage, loadingRunList, readyRunList, resumeRunIdFromMessage, type RunListSnapshot, } from "./runHistory"; +import { WEBVIEW_MARKDOWN_RENDERER_SCRIPT } from "./webviewMarkdown"; +import { safeScriptJson } from "./webviewSerialization"; export const CHAT_VIEW_ID = "prole-coder.chat"; +const WEBVIEW_EVENT_POST_DEBOUNCE_MS = 16; export interface ChatRpcEventSource { onEvent(handler: (event: AgentEventEnvelope) => void): DisposableLike; @@ -52,12 +81,22 @@ export interface ChatCancelClient { cancel(params: CancelParams): Promise; } +export interface ChatSteerClient { + steer(params: SteerParams): Promise; +} + export interface ChatRunHistoryClient { listRuns(params?: ListRunsParams): Promise; resume(params: ResumeParams): Promise; + loadRunEvents(params: LoadRunEventsParams): Promise; + deleteRun(params: DeleteRunParams): Promise; } -export type ChatRpcClient = ChatRpcEventSource & ChatTurnSender & ChatCancelClient & ChatRunHistoryClient; +export type ChatRpcClient = ChatRpcEventSource & + ChatTurnSender & + ChatCancelClient & + ChatSteerClient & + ChatRunHistoryClient; interface SnapshotWebviewMessage { readonly type: "snapshot"; @@ -79,15 +118,65 @@ interface ContextWebviewMessage { readonly context: ContextVizSnapshot; } +interface ApprovalWebviewMessage { + readonly type: "approval"; + readonly approval?: ChatApprovalSnapshot; +} + +interface SteerResultWebviewMessage { + readonly type: "steerResult"; + readonly ok: boolean; + readonly message: string; + readonly steerId?: string; +} + +interface TimelineHistoryWebviewMessage { + readonly type: "timelineHistory"; + readonly inFlight: boolean; +} + type ExtensionToWebviewMessage = | SnapshotWebviewMessage | SubmissionWebviewMessage | RunsWebviewMessage - | ContextWebviewMessage; + | ContextWebviewMessage + | ApprovalWebviewMessage + | TimelineHistoryWebviewMessage + | SteerResultWebviewMessage + | TestProbeWebviewMessage; + +interface TestProbeWebviewMessage { + readonly type: "testProbe"; + readonly id: string; + readonly action: unknown; +} + +interface TestProbeResultWebviewMessage { + readonly type: "testProbeResult"; + readonly id: string; + readonly ok: boolean; + readonly result?: unknown; + readonly error?: string; +} + +interface WebviewErrorMessage { + readonly type: "webviewError"; + readonly message: string; + readonly stack?: string; +} + +interface WebviewReadyMessage { + readonly type: "webviewReady"; +} type ChatSubmissionStatus = "idle" | "sending" | "running" | "completed" | "failed" | "canceled"; type TerminalSubmissionStatus = Extract; +export interface ChatSubmissionAction { + readonly type: ProviderConfigurationAction["type"]; + readonly label: string; +} + export interface ChatSubmissionSnapshot { readonly busy: boolean; readonly status: ChatSubmissionStatus; @@ -96,12 +185,14 @@ export interface ChatSubmissionSnapshot { readonly turnId?: string; readonly error?: string; readonly canceling?: boolean; + readonly action?: ChatSubmissionAction; } interface TerminalRunState { readonly status: TerminalSubmissionStatus; readonly message: string; readonly error?: string; + readonly action?: ChatSubmissionAction; } export interface ChatViewTestState { @@ -109,6 +200,24 @@ export interface ChatViewTestState { readonly submission: ChatSubmissionSnapshot; readonly runs: RunListSnapshot; readonly context: ContextVizSnapshot; + readonly approval?: ChatApprovalSnapshot; +} + +interface PendingApprovalState { + readonly request: ApprovalPromptRequest; + resolve(decision: ApprovalPromptDecision): void; +} + +interface PendingTestProbe { + readonly timeout: ReturnType; + resolve(result: unknown): void; + reject(error: Error): void; +} + +interface PendingWebviewReady { + readonly timeout: ReturnType; + resolve(): void; + reject(error: Error): void; } export class ProleChatViewProvider implements vscode.WebviewViewProvider, DisposableLike { @@ -118,28 +227,47 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos private submission: ChatSubmissionSnapshot = idleSubmission(); private runList: RunListSnapshot = idleRunList(); private contextViz: ContextVizSnapshot = emptyContextViz(); + private activeConversationRunId: string | undefined; + private pendingApproval: PendingApprovalState | undefined; + private readonly pendingTestProbes = new Map(); + private readonly webviewReadyWaiters = new Set(); private rpcSubscription: DisposableLike | undefined; private viewMessageSubscription: DisposableLike | undefined; private view: vscode.WebviewView | undefined; + private webviewReady = false; + private webviewGeneration = 0; + private webviewPostFlushTimer: ReturnType | undefined; + private snapshotPostQueued = false; + private submissionPostQueued = false; + private contextPostQueued = false; + private cancelActiveTurnRequested = false; + private historyLoadInFlight = false; + private readonly conversationApprovalGrants = new Map>(); constructor( private readonly extensionUri: vscode.Uri, rpcClient?: ChatRpcClient, private readonly workspaceRoot?: string, + private readonly logger?: ProleLogger, + private readonly redactor?: MessageRedactor, ) { this.rpcClient = rpcClient; this.rpcSubscription = rpcClient?.onEvent((event) => { + this.logger?.info(this.redact(formatAgentEventLog(event))); this.timeline.append(event); const contextViz = contextVizFromEvent(event); if (contextViz !== undefined) { this.contextViz = contextViz; } const terminal = this.updateSubmissionForEvent(event); - this.postSnapshot(); + if (terminal) { + this.rejectPendingApproval("run ended before approval was resolved"); + } + this.queueSnapshotPost(); if (contextViz !== undefined) { - this.postContext(); + this.queueContextPost(); } - this.postSubmission(); + this.queueSubmissionPost(); if (terminal && this.view !== undefined) { void this.refreshRuns("Refreshing runs..."); } @@ -148,6 +276,8 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos resolveWebviewView(webviewView: vscode.WebviewView): void { this.view = webviewView; + this.webviewReady = false; + const webviewGeneration = (this.webviewGeneration += 1); webviewView.webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri], @@ -162,12 +292,25 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos this.submission, this.runList, this.contextViz, + this.pendingApprovalSnapshot(), ); - this.postSnapshot(); this.postSubmission(); + this.postSnapshot(); this.postRuns(); this.postContext(); + this.postApproval(); void this.refreshRuns(); + setTimeout(() => { + if ( + this.view === webviewView && + this.webviewGeneration === webviewGeneration && + this.webviewReady !== true + ) { + this.logger?.error( + "Sidebar webview did not report ready within 3000ms; scripts may be blocked or the webview may need reload.", + ); + } + }, 3000); } openChatView(): Thenable { @@ -179,38 +322,168 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos } testState(): ChatViewTestState { + const approval = this.pendingApprovalSnapshot(); return { timeline: this.timeline.snapshot(), submission: this.submission, runs: this.runList, context: this.contextViz, + ...(approval === undefined ? {} : { approval }), + }; + } + + async testProbeWebview(action: unknown): Promise { + if (this.view === undefined) { + throw new Error("chat webview is not available"); + } + + await this.waitForWebviewReady(); + this.flushQueuedWebviewPosts(); + + const id = randomUUID(); + const message: TestProbeWebviewMessage = { + type: "testProbe", + id, + action, }; + + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pendingTestProbes.delete(id); + reject(new Error(`timed out waiting for chat webview test probe ${id}`)); + }, 2000); + this.pendingTestProbes.set(id, { timeout, resolve, reject }); + void this.view?.webview.postMessage(message).then((delivered) => { + if (!delivered) { + this.rejectTestProbe(id, new Error("chat webview did not accept the test probe message")); + } + }); + }); + } + + isIdle(): boolean { + return !this.submission.busy; } dispose(): void { + this.rejectPendingApproval("approval view disposed"); + this.rejectPendingTestProbes("chat view disposed"); + this.rejectPendingWebviewReady("chat view disposed"); + this.clearQueuedWebviewPosts(); this.rpcSubscription?.dispose(); this.viewMessageSubscription?.dispose(); this.rpcSubscription = undefined; this.viewMessageSubscription = undefined; } + requestApproval(request: ApprovalPromptRequest): Promise { + const grantKey = conversationApprovalGrantKey(request); + if (grantKey !== undefined && this.hasConversationApprovalGrant(request.runId, grantKey)) { + return Promise.resolve({ + kind: "approve", + approvalId: request.approvalId, + persist: "never", + }); + } + + this.rejectPendingApproval("superseded by a newer approval request"); + void this.openChatView(); + + return new Promise((resolve) => { + this.pendingApproval = { + request, + resolve, + }; + this.postApproval(); + }); + } + private async handleWebviewMessage(message: unknown): Promise { + const testProbeResult = testProbeResultFromMessage(message); + if (testProbeResult !== undefined) { + this.resolveTestProbe(testProbeResult); + return; + } + + const webviewError = webviewErrorFromMessage(message); + if (webviewError !== undefined) { + const stack = webviewError.stack === undefined ? "" : `\n${webviewError.stack}`; + this.logger?.error(this.redact(`Sidebar webview error: ${webviewError.message}${stack}`)); + return; + } + + if (isWebviewReadyMessage(message)) { + this.logger?.info("Sidebar webview ready."); + this.markWebviewReady(); + return; + } + if (isRefreshRunsMessage(message)) { await this.refreshRuns(); return; } + if (isShowRunsMessage(message)) { + this.showRuns(); + return; + } + + if (isLoadEarlierTimelineMessage(message)) { + await this.loadEarlierTimeline(); + return; + } + + const approvalDecision = + this.pendingApproval === undefined + ? undefined + : this.approvalDecisionFromWebviewMessage(message, this.pendingApproval.request); + if (approvalDecision !== undefined) { + this.resolvePendingApproval(approvalDecision); + return; + } + const resumeRunId = resumeRunIdFromMessage(message); if (resumeRunId !== undefined) { await this.resumeRun(resumeRunId); return; } + const deleteRunId = deleteRunIdFromMessage(message); + if (deleteRunId !== undefined) { + await this.deleteRun(deleteRunId); + return; + } + const cancelRunId = cancelRunIdFromMessage(message); if (cancelRunId !== undefined) { await this.cancelTurn(cancelRunId); return; } + if (isCancelTurnMessage(message)) { + await this.cancelActiveTurn(); + return; + } + + const steerMessage = steerTurnMessageFromWebviewMessage(message); + if (steerMessage !== undefined) { + await this.steerActiveTurn(steerMessage); + return; + } + + if (isConfigureDeepSeekApiKeyMessage(message)) { + await this.configureDeepSeekApiKey(); + return; + } + + if (isSelectDeepSeekModelMessage(message)) { + await this.selectDeepSeekModel(); + return; + } + + if (isOpenSettingsMessage(message)) { + await this.openSettings(); + return; + } if (!isRecord(message) || message["type"] !== "submitTurn") { return; @@ -245,6 +518,7 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos return; } + this.cancelActiveTurnRequested = false; this.setSubmission({ busy: true, status: "sending", @@ -258,12 +532,18 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos automaticContext, this.collectDiagnosticAttachments(), ); + const activeRunId = this.activeConversationRunId; + if (activeRunId !== undefined) { + this.terminalRuns.delete(activeRunId); + } + const params = sendTurnParams(parsed.value, attachments); const result = await this.rpcClient.sendTurn( - sendTurnParams(parsed.value, attachments), + activeRunId === undefined ? params : { ...params, runId: activeRunId }, ); + this.activeConversationRunId = result.runId; void this.refreshRuns("Refreshing runs..."); const terminal = this.terminalRuns.get(result.runId); - this.setSubmission( + const nextSubmission: ChatSubmissionSnapshot = terminal === undefined ? { busy: true, @@ -272,16 +552,32 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos runId: result.runId, turnId: result.turnId, } - : terminalSubmission(result.runId, result.turnId, terminal), - ); + : terminalSubmission(result.runId, result.turnId, terminal); + this.setSubmission(nextSubmission); + if (this.cancelActiveTurnRequested && nextSubmission.busy) { + this.cancelActiveTurnRequested = false; + await this.cancelTurn(result.runId); + } else { + this.cancelActiveTurnRequested = false; + } } catch (error) { + this.cancelActiveTurnRequested = false; const messageText = `Failed to send turn: ${errorMessage(error)}`; + const redacted = this.redact(messageText); + const action = chatSubmissionActionFromProviderAction( + providerConfigurationActionFromError(error), + ); + this.logger?.error(redacted); this.setSubmission({ ...idleSubmission(), status: "failed", - message: messageText, - error: messageText, + message: redacted, + error: redacted, + ...(action === undefined ? {} : { action }), }); + if (isConfigureDeepSeekApiKeyAction(action)) { + await this.configureDeepSeekApiKey(); + } } } @@ -290,7 +586,7 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos type: "snapshot", snapshot: this.timeline.snapshot(), }; - void this.view?.webview.postMessage(message); + this.postToWebview(message); } private postSubmission(): void { @@ -298,7 +594,7 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos type: "submission", submission: this.submission, }; - void this.view?.webview.postMessage(message); + this.postToWebview(message); } private postRuns(): void { @@ -306,7 +602,7 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos type: "runs", runs: this.runList, }; - void this.view?.webview.postMessage(message); + this.postToWebview(message); } private postContext(): void { @@ -314,7 +610,108 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos type: "context", context: this.contextViz, }; - void this.view?.webview.postMessage(message); + this.postToWebview(message); + } + + private queueSnapshotPost(): void { + this.snapshotPostQueued = true; + this.scheduleWebviewPostFlush(); + } + + private queueSubmissionPost(): void { + this.submissionPostQueued = true; + this.scheduleWebviewPostFlush(); + } + + private queueContextPost(): void { + this.contextPostQueued = true; + this.scheduleWebviewPostFlush(); + } + + private scheduleWebviewPostFlush(): void { + if (this.webviewPostFlushTimer !== undefined) { + return; + } + + this.webviewPostFlushTimer = setTimeout(() => { + this.flushQueuedWebviewPosts(); + }, WEBVIEW_EVENT_POST_DEBOUNCE_MS); + } + + private flushQueuedWebviewPosts(): void { + if (this.webviewPostFlushTimer !== undefined) { + clearTimeout(this.webviewPostFlushTimer); + this.webviewPostFlushTimer = undefined; + } + + const postSnapshot = this.snapshotPostQueued; + const postSubmission = this.submissionPostQueued; + const postContext = this.contextPostQueued; + this.snapshotPostQueued = false; + this.submissionPostQueued = false; + this.contextPostQueued = false; + + if (postSubmission) { + this.postSubmission(); + } + if (postSnapshot) { + this.postSnapshot(); + } + if (postContext) { + this.postContext(); + } + } + + private clearQueuedWebviewPosts(): void { + if (this.webviewPostFlushTimer !== undefined) { + clearTimeout(this.webviewPostFlushTimer); + this.webviewPostFlushTimer = undefined; + } + this.snapshotPostQueued = false; + this.submissionPostQueued = false; + this.contextPostQueued = false; + } + + private postApproval(): void { + const approval = this.pendingApprovalSnapshot(); + const message: ExtensionToWebviewMessage = { + type: "approval", + ...(approval === undefined ? {} : { approval }), + }; + this.postToWebview(message); + } + + private postTimelineHistory(inFlight: boolean): void { + this.postToWebview({ + type: "timelineHistory", + inFlight, + }); + } + + private postToWebview(message: ExtensionToWebviewMessage): void { + if (this.view === undefined || !this.webviewReady) { + return; + } + void this.view.webview.postMessage(message); + } + + private markWebviewReady(): void { + if (this.webviewReady) { + return; + } + + this.webviewReady = true; + for (const pending of this.webviewReadyWaiters) { + clearTimeout(pending.timeout); + pending.resolve(); + } + this.webviewReadyWaiters.clear(); + + this.postSubmission(); + this.postSnapshot(); + this.postRuns(); + this.postContext(); + this.postApproval(); } private setSubmission(submission: ChatSubmissionSnapshot): void { @@ -332,6 +729,12 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos this.postContext(); } + private pendingApprovalSnapshot(): ChatApprovalSnapshot | undefined { + return this.pendingApproval === undefined + ? undefined + : chatApprovalSnapshotFromRequest(this.pendingApproval.request); + } + private async refreshRuns(message = "Loading runs..."): Promise { if (this.rpcClient === undefined) { this.setRunList( @@ -343,9 +746,12 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos this.setRunList(loadingRunList(this.runList, message)); try { const result = await this.rpcClient.listRuns({ limit: RUN_LIST_LIMIT }); - this.setRunList(readyRunList(result, this.runList.selectedRunId)); + this.setRunList(readyRunList(result, this.activeConversationRunId ?? this.runList.selectedRunId)); } catch (error) { - this.setRunList(failedRunList(`Failed to load runs: ${errorMessage(error)}`, this.runList)); + const messageText = `Failed to load runs: ${errorMessage(error)}`; + const redacted = this.redact(messageText); + this.logger?.error(redacted); + this.setRunList(failedRunList(redacted, this.runList)); } } @@ -369,13 +775,118 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos this.setRunList(loadingRunList(this.runList, "Replaying run...")); try { const result = await this.rpcClient.resume({ runId }); + this.activeConversationRunId = result.runId; const message = result.replayStarted ? `Replaying ${result.runId} through seq ${result.nextSeq - 1}.` : `No events to replay for ${result.runId}.`; this.setRunList(readyRunList({ runs: this.runList.runs }, result.runId, message)); } catch (error) { - this.setRunList(failedRunList(`Failed to resume run: ${errorMessage(error)}`, this.runList)); + const messageText = `Failed to resume run: ${errorMessage(error)}`; + const redacted = this.redact(messageText); + this.logger?.error(redacted); + this.setRunList(failedRunList(redacted, this.runList)); + } + } + + private async loadEarlierTimeline(): Promise { + if (this.historyLoadInFlight) { + return; + } + + const runId = this.activeConversationRunId ?? this.timeline.snapshot().latestRunId; + if (runId === undefined) { + this.postTimelineHistory(false); + return; + } + + if (this.timeline.hasHiddenProcessItems()) { + this.timeline.revealHiddenProcessItems(); + this.postSnapshot(); + this.postTimelineHistory(false); + return; + } + + if (this.rpcClient === undefined) { + this.postTimelineHistory(false); + return; + } + + const beforeSeq = this.timeline.oldestLoadedSeq(); + if (beforeSeq === undefined || beforeSeq <= 1) { + this.postTimelineHistory(false); + return; + } + + this.historyLoadInFlight = true; + this.postTimelineHistory(true); + try { + const result = await this.rpcClient.loadRunEvents({ + runId, + beforeSeq, + limit: 200, + }); + if (result.events.length > 0) { + this.timeline.prepend(result.events); + this.activeConversationRunId = result.runId; + this.postSnapshot(); + } + } catch (error) { + this.logger?.warn(this.redact(`Failed to load earlier run events: ${errorMessage(error)}`)); + } finally { + this.historyLoadInFlight = false; + this.postTimelineHistory(false); + } + } + + private async deleteRun(runId: string): Promise { + if (this.rpcClient === undefined) { + this.setRunList( + failedRunList("Open a trusted workspace before deleting a run.", this.runList), + ); + return; + } + + if (this.submission.busy) { + this.setRunList(failedRunList("A turn is already running.", this.runList)); + return; + } + + this.setRunList(loadingRunList(this.runList, "Deleting run...")); + try { + const result = await this.rpcClient.deleteRun({ runId }); + if (this.activeConversationRunId === result.runId) { + this.activeConversationRunId = undefined; + this.timeline.clear(); + this.terminalRuns.delete(result.runId); + this.setSubmission(idleSubmission()); + this.setContextViz(emptyContextViz()); + this.postSnapshot(); + } + this.setRunList(deletedRunList(this.runList, result.runId, `Deleted ${result.runId}.`)); + void this.refreshRuns("Refreshing runs..."); + } catch (error) { + const messageText = `Failed to delete run: ${errorMessage(error)}`; + const redacted = this.redact(messageText); + this.logger?.error(redacted); + this.setRunList(failedRunList(redacted, this.runList)); + } + } + + private showRuns(): void { + if (this.submission.busy) { + return; } + + this.activeConversationRunId = undefined; + this.timeline.clear(); + this.setSubmission(idleSubmission()); + this.setContextViz(emptyContextViz()); + this.postSnapshot(); + this.setRunList({ + status: this.runList.status, + runs: this.runList.runs, + ...(this.runList.message === undefined ? {} : { message: this.runList.message }), + }); } private async cancelTurn(runId: string): Promise { @@ -415,15 +926,85 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos }); } catch (error) { const messageText = `Failed to cancel turn: ${errorMessage(error)}`; + const redacted = this.redact(messageText); + this.logger?.error(redacted); this.setSubmission({ ...this.submission, - message: messageText, - error: messageText, + message: redacted, + error: redacted, canceling: false, }); } } + private async cancelActiveTurn(): Promise { + const runId = this.submission.runId; + if (runId !== undefined && runId.length > 0) { + await this.cancelTurn(runId); + return; + } + + if (!this.submission.busy || this.submission.canceling) { + return; + } + + this.cancelActiveTurnRequested = true; + this.setSubmission({ + ...this.submission, + message: "Cancel requested...", + canceling: true, + }); + } + + private async steerActiveTurn(params: SteerParams): Promise { + if (this.rpcClient === undefined) { + this.setSubmission({ + ...this.submission, + message: "Open a trusted workspace before steering a turn.", + error: "No trusted workspace is available.", + }); + this.postSteerResult(false, params.message); + return; + } + + if (!this.submission.busy || this.submission.runId !== params.runId) { + this.setSubmission({ + ...this.submission, + message: "No active turn is available for steering.", + }); + this.postSteerResult(false, params.message); + return; + } + + try { + const result = await this.rpcClient.steer(params); + this.setSubmission({ + ...this.submission, + message: "Steer queued.", + }); + this.postSteerResult(true, params.message, result.steerId); + } catch (error) { + const messageText = `Failed to steer turn: ${errorMessage(error)}`; + const redacted = this.redact(messageText); + this.logger?.error(redacted); + this.setSubmission({ + ...this.submission, + message: redacted, + error: redacted, + }); + this.postSteerResult(false, params.message); + } + } + + private postSteerResult(ok: boolean, message: string, steerId?: string): void { + this.postToWebview({ + type: "steerResult", + ok, + message, + ...(steerId === undefined ? {} : { steerId }), + }); + } + private collectDiagnosticAttachments(): NonNullable { if (this.workspaceRoot === undefined) { return []; @@ -464,25 +1045,178 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos this.terminalRuns.delete(oldest); } } -} -function renderChatViewHtml( - webview: vscode.Webview, - snapshot: ChatTimelineSnapshot, - submission: ChatSubmissionSnapshot, - runList: RunListSnapshot, - contextViz: ContextVizSnapshot, -): string { - const nonce = nonceValue(); - const initialSnapshot = safeScriptJson(snapshot); - const initialSubmission = safeScriptJson(submission); - const initialRuns = safeScriptJson(runList); - const initialContext = safeScriptJson(contextViz); - return ` - - + private resolvePendingApproval(decision: ApprovalPromptDecision): void { + if ( + this.pendingApproval === undefined || + this.pendingApproval.request.approvalId !== decision.approvalId + ) { + return; + } + + const pending = this.pendingApproval; + this.pendingApproval = undefined; + this.postApproval(); + pending.resolve(decision); + } + + private approvalDecisionFromWebviewMessage( + message: unknown, + request: ApprovalPromptRequest, + ): ApprovalPromptDecision | undefined { + if (isConversationApprovalMessage(message, request)) { + const grantKey = conversationApprovalGrantKey(request); + if (grantKey !== undefined) { + this.rememberConversationApprovalGrant(request.runId, grantKey); + } + return { + kind: "approve", + approvalId: request.approvalId, + persist: "never", + }; + } + + return approvalDecisionFromWebviewMessage(message, request); + } + + private hasConversationApprovalGrant(runId: string | undefined, grantKey: string): boolean { + if (runId === undefined || runId.length === 0) { + return false; + } + return this.conversationApprovalGrants.get(runId)?.has(grantKey) === true; + } + + private rememberConversationApprovalGrant(runId: string | undefined, grantKey: string): void { + if (runId === undefined || runId.length === 0) { + return; + } + let grants = this.conversationApprovalGrants.get(runId); + if (grants === undefined) { + grants = new Set(); + this.conversationApprovalGrants.set(runId, grants); + } + grants.add(grantKey); + while (this.conversationApprovalGrants.size > 20) { + const oldest = this.conversationApprovalGrants.keys().next().value; + if (oldest === undefined) { + return; + } + this.conversationApprovalGrants.delete(oldest); + } + } + + private rejectPendingApproval(reason: string): void { + if (this.pendingApproval === undefined) { + return; + } + + const pending = this.pendingApproval; + this.pendingApproval = undefined; + this.postApproval(); + pending.resolve({ + kind: "reject", + approvalId: pending.request.approvalId, + reason, + }); + } + + private resolveTestProbe(message: TestProbeResultWebviewMessage): void { + const pending = this.pendingTestProbes.get(message.id); + if (pending === undefined) { + return; + } + + clearTimeout(pending.timeout); + this.pendingTestProbes.delete(message.id); + if (message.ok) { + pending.resolve(message.result); + } else { + pending.reject(new Error(message.error ?? "chat webview test probe failed")); + } + } + + private rejectTestProbe(id: string, error: Error): void { + const pending = this.pendingTestProbes.get(id); + if (pending === undefined) { + return; + } + + clearTimeout(pending.timeout); + this.pendingTestProbes.delete(id); + pending.reject(error); + } + + private rejectPendingTestProbes(reason: string): void { + for (const [id, pending] of this.pendingTestProbes) { + clearTimeout(pending.timeout); + pending.reject(new Error(reason)); + this.pendingTestProbes.delete(id); + } + } + + private waitForWebviewReady(): Promise { + if (this.webviewReady) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.webviewReadyWaiters.delete(pending); + reject(new Error("timed out waiting for chat webview ready")); + }, 5000); + const pending: PendingWebviewReady = { + timeout, + resolve, + reject, + }; + this.webviewReadyWaiters.add(pending); + }); + } + + private rejectPendingWebviewReady(reason: string): void { + for (const pending of this.webviewReadyWaiters) { + clearTimeout(pending.timeout); + pending.reject(new Error(reason)); + this.webviewReadyWaiters.delete(pending); + } + } + + private redact(message: string): string { + return this.redactor?.redact(message) ?? message; + } + + private async configureDeepSeekApiKey(): Promise { + await vscode.commands.executeCommand(CONFIGURE_DEEPSEEK_API_KEY_COMMAND); + } + + private async selectDeepSeekModel(): Promise { + await vscode.commands.executeCommand(SELECT_DEEPSEEK_MODEL_COMMAND); + } + + private async openSettings(): Promise { + await vscode.commands.executeCommand(OPEN_SETTINGS_COMMAND); + } +} + +function renderChatViewHtml( + webview: vscode.Webview, + snapshot: ChatTimelineSnapshot, + submission: ChatSubmissionSnapshot, + runList: RunListSnapshot, + contextViz: ContextVizSnapshot, + approval: ChatApprovalSnapshot | undefined, +): string { + const nonce = nonceValue(); + const initialSnapshot = safeScriptJson(snapshot); + const initialSubmission = safeScriptJson(submission); + const initialRuns = safeScriptJson(runList); + const initialContext = safeScriptJson(contextViz); + const initialApproval = safeScriptJson(approval); + return ` + + - + - - -
-
-
ProleCoder
-
No run events yet.
-
-
-
-
Runs
- -
-
-
-
-
-
-
Context Capsule
-
No context yet.
-
-
-
-
-
- -
- - - -
-
-
-
- `; } -function safeScriptJson(value: unknown): string { - return JSON.stringify(value).replaceAll("<", "\\u003c"); -} - function nonceValue(): string { return randomUUID().replaceAll("-", ""); } @@ -1485,6 +4356,19 @@ function idleSubmission(): ChatSubmissionSnapshot { }; } +function chatSubmissionActionFromProviderAction( + action: ProviderConfigurationAction | undefined, +): ChatSubmissionAction | undefined { + if (action === undefined) { + return undefined; + } + + return { + type: action.type, + label: action.label, + }; +} + function terminalSubmission( runId: string, turnId: string | undefined, @@ -1495,6 +4379,7 @@ function terminalSubmission( status: terminal.status, message: terminal.message, ...(terminal.error === undefined ? {} : { error: terminal.error }), + ...(terminal.action === undefined ? {} : { action: terminal.action }), runId, ...(turnId === undefined ? {} : { turnId }), }; @@ -1510,10 +4395,14 @@ function terminalRunState(event: AgentEventEnvelope): TerminalRunState | undefin if (event.type === "run.failed") { const message = terminalMessage(event, "Run failed."); + const action = chatSubmissionActionFromProviderAction( + providerConfigurationActionFromPayload(event.payload), + ); return { status: "failed", message, error: message, + ...(action === undefined ? {} : { action }), }; } @@ -1533,8 +4422,26 @@ function terminalMessage(event: AgentEventEnvelope, fallback: string): string { return typeof message === "string" && message.length > 0 ? message : fallback; } +function formatAgentEventLog(event: AgentEventEnvelope): string { + const turn = event.turnId === undefined ? "" : ` turn=${event.turnId}`; + return `agent.event #${event.seq} ${event.type} run=${event.runId}${turn} ${stringifyLogPayload(event.payload)}`; +} + +function stringifyLogPayload(payload: unknown): string { + try { + const serialized = JSON.stringify(payload); + return truncateLogText(serialized === undefined ? "undefined" : serialized); + } catch { + return truncateLogText(String(payload)); + } +} + +function truncateLogText(value: string): string { + return value.length <= 4096 ? value : `${value.slice(0, 4093)}...`; +} + function cancelRunIdFromMessage(message: unknown): string | undefined { - if (!isRecord(message) || message["type"] !== "cancelTurn") { + if (!isCancelTurnMessage(message)) { return undefined; } @@ -1542,6 +4449,111 @@ function cancelRunIdFromMessage(message: unknown): string | undefined { return typeof runId === "string" && runId.length > 0 ? runId : undefined; } +function isCancelTurnMessage(message: unknown): message is Record { + return isRecord(message) && message["type"] === "cancelTurn"; +} + +function steerTurnMessageFromWebviewMessage(message: unknown): SteerParams | undefined { + if (!isRecord(message) || message["type"] !== "steerTurn") { + return undefined; + } + + const runId = message["runId"]; + const steerMessage = message["message"]; + if ( + typeof runId !== "string" || + runId.length === 0 || + typeof steerMessage !== "string" || + steerMessage.trim().length === 0 + ) { + return undefined; + } + + return { + runId, + message: steerMessage.trim(), + }; +} + +function isConversationApprovalMessage(message: unknown, request: ApprovalPromptRequest): boolean { + return ( + isRecord(message) && + message["type"] === "approvalDecision" && + message["approvalId"] === request.approvalId && + message["decision"] === "approve" && + message["persist"] === "conversation" + ); +} + +function conversationApprovalGrantKey(request: ApprovalPromptRequest): string | undefined { + if (request.toolName !== "shell" || request.command === undefined || request.command.trim().length === 0) { + return undefined; + } + const cwd = request.cwd === undefined || request.cwd.trim().length === 0 ? "." : request.cwd.trim(); + return `${cwd}\n${request.command.trim()}`; +} + +function isConfigureDeepSeekApiKeyMessage(message: unknown): boolean { + return isRecord(message) && message["type"] === "configureDeepSeekApiKey"; +} + +function isSelectDeepSeekModelMessage(message: unknown): boolean { + return isRecord(message) && message["type"] === "selectDeepSeekModel"; +} + +function isOpenSettingsMessage(message: unknown): boolean { + return isRecord(message) && message["type"] === "openSettings"; +} + +function isShowRunsMessage(message: unknown): boolean { + return isRecord(message) && message["type"] === "showRuns"; +} + +function isLoadEarlierTimelineMessage(message: unknown): boolean { + return isRecord(message) && message["type"] === "loadEarlierTimeline"; +} + +function testProbeResultFromMessage(message: unknown): TestProbeResultWebviewMessage | undefined { + if (!isRecord(message) || message["type"] !== "testProbeResult") { + return undefined; + } + + const id = message["id"]; + const ok = message["ok"]; + if (typeof id !== "string" || typeof ok !== "boolean") { + return undefined; + } + + return { + type: "testProbeResult", + id, + ok, + ...(message["result"] === undefined ? {} : { result: message["result"] }), + ...(typeof message["error"] === "string" ? { error: message["error"] } : {}), + }; +} + +function webviewErrorFromMessage(message: unknown): WebviewErrorMessage | undefined { + if (!isRecord(message) || message["type"] !== "webviewError") { + return undefined; + } + + const errorMessage = message["message"]; + if (typeof errorMessage !== "string" || errorMessage.length === 0) { + return undefined; + } + + return { + type: "webviewError", + message: errorMessage, + ...(typeof message["stack"] === "string" ? { stack: message["stack"] } : {}), + }; +} + +function isWebviewReadyMessage(message: unknown): message is WebviewReadyMessage { + return isRecord(message) && message["type"] === "webviewReady"; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } diff --git a/vscode/extension/src/commands.ts b/vscode/extension/src/commands.ts index 9830916..ebd7d0c 100644 --- a/vscode/extension/src/commands.ts +++ b/vscode/extension/src/commands.ts @@ -93,6 +93,7 @@ export type ApprovalPersistence = "never" | "session" | "workspace"; export interface ApprovalPromptRequest { readonly approvalId: string; + readonly runId?: string; readonly toolCallId: string; readonly toolName: string; readonly risk: string; @@ -110,6 +111,7 @@ export interface ApprovalPromptRequest { export interface ApprovalPromptHunk { readonly id: string; readonly filePath: string; + readonly fileIndex: number; readonly hunkIndex: number; readonly oldStart: number; readonly oldCount: number; @@ -342,10 +344,6 @@ function formatApprovalMessage(request: ApprovalPromptRequest): string { detail.push(`Cwd: ${request.cwd}`); } - if (request.outputSummary !== undefined) { - detail.push(`Output: ${request.outputSummary}`); - } - if (request.paths !== undefined && request.paths.length > 0) { detail.push(`Paths: ${request.paths.join(", ")}`); } diff --git a/vscode/extension/src/extension.ts b/vscode/extension/src/extension.ts index 03696a1..56cfa3e 100644 --- a/vscode/extension/src/extension.ts +++ b/vscode/extension/src/extension.ts @@ -10,13 +10,54 @@ import { } from "./commands"; import { createPatchDiffPreviewController } from "./diffPreview"; import { registerFimInlineCompletionProvider } from "./fimPreviewVscode"; +import { + GENERATE_COMMIT_MESSAGE_COMMAND, + GENERATE_PR_DESCRIPTION_COMMAND, + generateCommitMessage, + generatePrDescription, +} from "./gitWorkflow"; +import { createVscodeGitRepositoryProvider, createVscodeMarkdownSink } from "./gitWorkflowVscode"; +import { createOutputLogger } from "./logging"; +import { createExtensionNotifier, type ExtensionNotifier } from "./notifier"; +import { + registerProviderSecretCommands, + type SecretQuickPickController, + type SecretQuickPickItem, +} from "./providerSecretCommands"; +import { + DEEPSEEK_API_KEY_STORE_SECRET_ID, + DEEPSEEK_API_KEY_SECRET_ID, + deepSeekEnvOverride, + providerSecretRedactionValues, + resolveDeepSeekApiKey, + resolveDeepSeekModel, +} from "./providerSecrets"; +import { MutableSecretRedactor } from "./redaction"; import { RpcServerManager, readRpcServerLaunchConfig } from "./rpcServer"; -export function activate(context: vscode.ExtensionContext): void { +export async function activate(context: vscode.ExtensionContext): Promise { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - const rpcServer = createRpcServerManager(context); - const chatView = new ProleChatViewProvider(context.extensionUri, rpcServer, workspaceRoot); - const chatParticipant = registerProleChatParticipant(context, rpcServer, workspaceRoot); + const outputChannel = vscode.window.createOutputChannel("ProleCoder"); + const logger = createOutputLogger(outputChannel); + const secretRedactor = new MutableSecretRedactor(); + const initialSecretStatus = resolveDeepSeekApiKey({ + secretValue: await context.secrets.get(DEEPSEEK_API_KEY_SECRET_ID), + keyStoreValue: await context.secrets.get(DEEPSEEK_API_KEY_STORE_SECRET_ID), + processEnv: process.env, + }); + const providerConfiguration = vscode.workspace.getConfiguration("prole-coder.provider"); + const configuredModel = providerConfiguration.get("model", ""); + const initialModelId = resolveDeepSeekModel({ + configuredModel: typeof configuredModel === "string" ? configuredModel : "", + processEnv: process.env, + }); + secretRedactor.update(providerSecretRedactionValues(initialSecretStatus)); + const notifier = createExtensionNotifier(logger, vscode.window, secretRedactor); + context.subscriptions.push(outputChannel); + + const rpcServer = createRpcServerManager(context, notifier, deepSeekEnvOverride(initialSecretStatus, initialModelId)); + const chatView = new ProleChatViewProvider(context.extensionUri, rpcServer, workspaceRoot, logger, secretRedactor); + const chatParticipant = registerProleChatParticipant(context, rpcServer, workspaceRoot, logger, secretRedactor); const openChat = registerOpenChatCommand( vscode.commands, vscode.window, @@ -27,10 +68,10 @@ export function activate(context: vscode.ExtensionContext): void { vscode.commands, { showInformationMessage(message) { - return vscode.window.showInformationMessage(message); + return notifier.info(message); }, showWarningMessage(message) { - return vscode.window.showWarningMessage(message); + return notifier.warn(message); }, openSettings(query) { return vscode.commands.executeCommand("workbench.action.openSettings", query); @@ -43,20 +84,73 @@ export function activate(context: vscode.ExtensionContext): void { retainContextWhenHidden: true, }, }); + const secretWindow = { + showInputBox: vscode.window.showInputBox.bind(vscode.window), + showQuickPick: vscode.window.showQuickPick.bind(vscode.window), + createQuickPick: (): SecretQuickPickController => + vscode.window.createQuickPick() as unknown as SecretQuickPickController, + showInformationMessage: vscode.window.showInformationMessage.bind(vscode.window), + showWarningMessage: vscode.window.showWarningMessage.bind(vscode.window), + }; + const providerSecretCommands = registerProviderSecretCommands({ + commands: vscode.commands, + window: secretWindow, + secrets: context.secrets, + processEnv: process.env, + redactor: secretRedactor, + rpcServer, + isRpcIdle: () => chatView.isIdle(), + providerConfiguration, + configurationTarget: vscode.ConfigurationTarget.Global, + renameAliasButton: { + iconPath: new vscode.ThemeIcon("edit"), + tooltip: "Rename alias", + }, + deleteKeyButton: { + iconPath: new vscode.ThemeIcon("trash"), + tooltip: "Delete key", + }, + }); + const gitRepositoryProvider = createVscodeGitRepositoryProvider(); + const markdownSink = createVscodeMarkdownSink(); + const gitWorkflowCommands = [ + vscode.commands.registerCommand(GENERATE_COMMIT_MESSAGE_COMMAND, () => + generateCommitMessage({ + repositories: gitRepositoryProvider, + window: vscode.window, + agent: rpcServer, + redactor: secretRedactor, + }), + ), + vscode.commands.registerCommand(GENERATE_PR_DESCRIPTION_COMMAND, () => + generatePrDescription({ + repositories: gitRepositoryProvider, + window: vscode.window, + agent: rpcServer, + markdownSink, + redactor: secretRedactor, + }), + ), + ]; - context.subscriptions.push(openChat, openSettings, chatView, chatViewRegistration, chatParticipant); + context.subscriptions.push( + openChat, + openSettings, + chatView, + chatViewRegistration, + chatParticipant, + ...providerSecretCommands, + ...gitWorkflowCommands, + ); registerTestCommands(context, chatView); if (rpcServer !== undefined && workspaceRoot !== undefined) { const patchDiffPreviewController = createPatchDiffPreviewController(context, rpcServer, workspaceRoot); + const approvalRequester = testApprovalRequester(context) ?? inlineChatApprovalRequester(chatView); const approvalController = new ApprovalEventController( rpcServer, vscode.window, - { - warn(message) { - return vscode.window.showWarningMessage(message); - }, - }, - testApprovalRequester(context), + notifier, + approvalRequester, patchDiffPreviewController, ); context.subscriptions.push(patchDiffPreviewController, approvalController); @@ -64,9 +158,8 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push(rpcServer); if (rpcServer.autoStart) { void rpcServer.start().catch((error: unknown) => { - void vscode.window.showWarningMessage( - `prole-coder RPC server failed to start: ${errorMessage(error)}`, - ); + const message = `prole-coder RPC server failed to start: ${errorMessage(error)}`; + void notifier.error(message); }); } } @@ -76,7 +169,11 @@ export function deactivate(): void { // VS Code disposes context subscriptions, including the RPC server manager. } -function createRpcServerManager(context: vscode.ExtensionContext): RpcServerManager | undefined { +function createRpcServerManager( + context: vscode.ExtensionContext, + notifier: ExtensionNotifier, + processEnv: Record, +): RpcServerManager | undefined { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; if (workspaceRoot === undefined) { return undefined; @@ -89,14 +186,8 @@ function createRpcServerManager(context: vscode.ExtensionContext): RpcServerMana trusted: vscode.workspace.isTrusted, }, extensionVersion: extensionVersion(context), - notifier: { - info(message) { - return vscode.window.showInformationMessage(message); - }, - warn(message) { - return vscode.window.showWarningMessage(message); - }, - }, + processEnv, + notifier, }); } @@ -134,6 +225,9 @@ function registerTestCommands(context: vscode.ExtensionContext, chatView: ProleC chatView.testHandleWebviewMessage(message), ), vscode.commands.registerCommand("prole-coder.test.chatState", () => chatView.testState()), + vscode.commands.registerCommand("prole-coder.test.chatProbe", (action: unknown) => + chatView.testProbeWebview(action), + ), ); } @@ -151,3 +245,7 @@ function testApprovalRequester(context: vscode.ExtensionContext): ApprovalReques persist: "never", }); } + +function inlineChatApprovalRequester(chatView: ProleChatViewProvider): ApprovalRequester { + return (_window, request) => chatView.requestApproval(request); +} diff --git a/vscode/extension/src/gitWorkflow.ts b/vscode/extension/src/gitWorkflow.ts new file mode 100644 index 0000000..ff4d59d --- /dev/null +++ b/vscode/extension/src/gitWorkflow.ts @@ -0,0 +1,372 @@ +import type { SendTurnParams, SendTurnResult, TurnAttachment } from "@prole-coder/protocol" with { + "resolution-mode": "import", +}; + +import type { MessageRedactor } from "./redaction"; +import type { AgentEventEnvelope, DisposableLike } from "./rpcServer"; + +export const GENERATE_COMMIT_MESSAGE_COMMAND = "prole-coder.generateCommitMessage"; +export const GENERATE_PR_DESCRIPTION_COMMAND = "prole-coder.generatePrDescription"; + +const USE_UNSTAGED_CHANGES_LABEL = "Use Unstaged"; + +type AgentTextTerminalResult = + | { + readonly state: "completed"; + readonly text: string; + } + | { + readonly state: "failed"; + readonly error: Error; + }; + +export interface GitWorkflowRepository { + readonly label: string; + readonly rootPath: string; + readonly branch?: string | undefined; + readonly upstream?: string | undefined; + readonly inputBox?: { + value: string; + }; + stagedDiff(): Promise; + unstagedDiff(): Promise; + diffStat(base: string): Promise; + branchDiff(base: string): Promise; + commitSummary(base: string): Promise; + refExists(ref: string): Promise; +} + +export interface GitWorkflowRepositoryProvider { + repositories(): Promise; +} + +export interface GitWorkflowWindow { + showInformationMessage(message: string): unknown; + showWarningMessage( + message: string, + ...items: string[] + ): string | undefined | PromiseLike; + showQuickPick?( + items: readonly GitWorkflowRepositoryQuickPickItem[], + options: { readonly placeHolder: string }, + ): + | GitWorkflowRepositoryQuickPickItem + | undefined + | PromiseLike; + showInputBox?(options: { readonly prompt: string; readonly title: string }): string | undefined | PromiseLike; +} + +export interface GitWorkflowRepositoryQuickPickItem { + readonly label: string; + readonly description: string; + readonly repository: GitWorkflowRepository; +} + +export interface GitWorkflowAgent { + onEvent(handler: (event: AgentEventEnvelope) => void): DisposableLike; + sendTurn(params: SendTurnParams): Promise; +} + +export interface GitWorkflowMarkdownSink { + showMarkdown(content: string, title: string): Promise | void; + copyToClipboard?(content: string): Promise | void; +} + +export interface GenerateCommitMessageOptions { + readonly repositories: GitWorkflowRepositoryProvider; + readonly window: GitWorkflowWindow; + readonly agent?: GitWorkflowAgent | undefined; + readonly redactor?: MessageRedactor | undefined; +} + +export interface GeneratePrDescriptionOptions extends GenerateCommitMessageOptions { + readonly markdownSink: GitWorkflowMarkdownSink; +} + +export async function generateCommitMessage(options: GenerateCommitMessageOptions): Promise { + try { + await generateCommitMessageUnchecked(options); + } catch (error) { + options.window.showWarningMessage( + redactMessage(`Failed to generate commit message: ${errorMessage(error)}`, options.redactor), + ); + } +} + +async function generateCommitMessageUnchecked(options: GenerateCommitMessageOptions): Promise { + if (options.agent === undefined) { + options.window.showWarningMessage("Open a trusted workspace before generating a commit message."); + return; + } + + const repository = await selectRepository(options.repositories, options.window); + if (repository === undefined) { + return; + } + + const diff = await commitDiff(repository, options.window); + if (diff === undefined) { + return; + } + + const message = await collectAgentText(options.agent, { + message: "Generate a concise Conventional Commit message for the attached staged diff. Respond with only the commit message.", + mode: "ask", + attachments: [explicitContentAttachment("git/staged.diff", diff)], + }); + + const value = message.trim(); + if (repository.inputBox !== undefined) { + repository.inputBox.value = value; + } + options.window.showInformationMessage("Commit message generated in Source Control."); +} + +export async function generatePrDescription(options: GeneratePrDescriptionOptions): Promise { + try { + await generatePrDescriptionUnchecked(options); + } catch (error) { + options.window.showWarningMessage( + redactMessage(`Failed to generate PR description: ${errorMessage(error)}`, options.redactor), + ); + } +} + +async function generatePrDescriptionUnchecked(options: GeneratePrDescriptionOptions): Promise { + if (options.agent === undefined) { + options.window.showWarningMessage("Open a trusted workspace before generating a PR description."); + return; + } + + const repository = await selectRepository(options.repositories, options.window); + if (repository === undefined) { + return; + } + + const base = await selectBaseRef(repository, options.window); + if (base === undefined) { + return; + } + + const [diffStat, branchDiff, commits] = await Promise.all([ + repository.diffStat(base), + repository.branchDiff(base), + repository.commitSummary(base), + ]); + const content = await collectAgentText(options.agent, { + message: + "Generate a pull request title and markdown body for the attached branch context. Include Summary, Tests, Risks, and Follow-ups sections. Respond with markdown only.", + mode: "ask", + attachments: [ + explicitContentAttachment( + `git/pr-${base}.md`, + [ + `Base: ${base}`, + `Branch: ${repository.branch ?? "unknown"}`, + "", + "## Commits", + commits, + "", + "## Diff Stat", + diffStat, + "", + "## Diff", + branchDiff, + ].join("\n"), + ), + ], + }); + + const markdown = content.trim(); + await options.markdownSink.showMarkdown(markdown, "ProleCoder PR Description"); + await options.markdownSink.copyToClipboard?.(markdown); + options.window.showInformationMessage("PR description generated as markdown."); +} + +async function selectRepository( + provider: GitWorkflowRepositoryProvider, + window: GitWorkflowWindow, +): Promise { + const repositories = await provider.repositories(); + if (repositories.length === 0) { + window.showWarningMessage("Open a Git repository before using ProleCoder Git workflows."); + return undefined; + } + + if (repositories.length === 1) { + return repositories[0]; + } + + if (window.showQuickPick === undefined) { + window.showWarningMessage("Multiple Git repositories are open; choose a repository first."); + return undefined; + } + + const selected = await window.showQuickPick( + repositories.map((repository) => ({ + label: repository.label, + description: repository.rootPath, + repository, + })), + { + placeHolder: "Choose a repository for the ProleCoder Git workflow", + }, + ); + return selected?.repository; +} + +async function commitDiff( + repository: GitWorkflowRepository, + window: GitWorkflowWindow, +): Promise { + const staged = await repository.stagedDiff(); + if (staged.trim().length > 0) { + return staged; + } + + const selected = await window.showWarningMessage( + "No staged diff found. Use unstaged changes as context?", + USE_UNSTAGED_CHANGES_LABEL, + ); + if (selected !== USE_UNSTAGED_CHANGES_LABEL) { + return undefined; + } + + const unstaged = await repository.unstagedDiff(); + if (unstaged.trim().length === 0) { + window.showWarningMessage("No unstaged changes found."); + return undefined; + } + return unstaged; +} + +async function selectBaseRef( + repository: GitWorkflowRepository, + window: GitWorkflowWindow, +): Promise { + if (repository.upstream !== undefined && repository.upstream.length > 0) { + return repository.upstream; + } + + for (const candidate of ["main", "master"]) { + if (await repository.refExists(candidate)) { + return candidate; + } + } + + const value = await window.showInputBox?.({ + title: "Choose PR Base", + prompt: "Enter the base branch or ref for the PR description.", + }); + const normalized = value?.trim(); + if (normalized === undefined || normalized.length === 0) { + window.showWarningMessage("Choose a base branch before generating a PR description."); + return undefined; + } + return normalized; +} + +async function collectAgentText(agent: GitWorkflowAgent, params: SendTurnParams): Promise { + let runId: string | undefined; + const bufferedEvents: AgentEventEnvelope[] = []; + const textParts: string[] = []; + let terminalResult: AgentTextTerminalResult | undefined; + let resolveTerminal: ((result: AgentTextTerminalResult) => void) | undefined; + + const terminalPromise = new Promise((resolve) => { + resolveTerminal = resolve; + }); + + const subscription = agent.onEvent((event) => { + if (runId === undefined) { + bufferedEvents.push(event); + return; + } + handleAgentEvent(event, runId, textParts, finish); + }); + + try { + const result = await agent.sendTurn(params); + runId = result.runId; + for (const event of bufferedEvents) { + handleAgentEvent(event, runId, textParts, finish); + } + if (terminalResult !== undefined) { + return terminalText(terminalResult); + } + return terminalText(await terminalPromise); + } finally { + subscription.dispose(); + } + + function finish(result: AgentTextTerminalResult): void { + if (terminalResult !== undefined) { + return; + } + terminalResult = result; + resolveTerminal?.(result); + } +} + +function handleAgentEvent( + event: AgentEventEnvelope, + runId: string, + textParts: string[], + finish: (result: AgentTextTerminalResult) => void, +): void { + if (event.runId !== runId) { + return; + } + + if (event.type === "assistant.delta" && isRecord(event.payload) && typeof event.payload["text"] === "string") { + textParts.push(event.payload["text"]); + return; + } + + if (event.type === "run.completed") { + finish({ state: "completed", text: textParts.join("") }); + return; + } + + if (event.type === "run.failed") { + finish({ state: "failed", error: new Error(terminalMessage(event, "Agent run failed.")) }); + return; + } + + if (event.type === "run.canceled") { + finish({ state: "failed", error: new Error(terminalMessage(event, "Agent run canceled.")) }); + } +} + +function terminalText(result: AgentTextTerminalResult): string { + if (result.state === "completed") { + return result.text; + } + throw result.error; +} + +function explicitContentAttachment(path: string, text: string): TurnAttachment { + return { + kind: "explicit_content", + path, + text, + }; +} + +function terminalMessage(event: AgentEventEnvelope, fallback: string): string { + const payload = isRecord(event.payload) ? event.payload : undefined; + const message = payload?.["message"] ?? payload?.["reason"] ?? payload?.["summary"]; + return typeof message === "string" && message.length > 0 ? message : fallback; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function redactMessage(message: string, redactor: MessageRedactor | undefined): string { + return redactor?.redact(message) ?? message; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/vscode/extension/src/gitWorkflowVscode.ts b/vscode/extension/src/gitWorkflowVscode.ts new file mode 100644 index 0000000..67d4748 --- /dev/null +++ b/vscode/extension/src/gitWorkflowVscode.ts @@ -0,0 +1,141 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import * as vscode from "vscode"; + +import type { GitWorkflowRepository, GitWorkflowRepositoryProvider } from "./gitWorkflow"; + +const execFileAsync = promisify(execFile); + +interface VscodeGitExtension { + getAPI(version: 1): VscodeGitApi; +} + +interface VscodeGitApi { + readonly repositories: readonly VscodeGitRepository[]; +} + +interface VscodeGitRepository { + readonly rootUri: vscode.Uri; + readonly inputBox: { + value: string; + }; + readonly state: { + readonly HEAD?: { + readonly name?: string; + readonly upstream?: { + readonly name?: string; + readonly remote?: string; + }; + }; + }; + diffIndexWithHEAD?(): Promise; +} + +export function createVscodeGitRepositoryProvider(): GitWorkflowRepositoryProvider { + return { + async repositories() { + const extension = vscode.extensions.getExtension("vscode.git"); + const gitExtension = extension?.isActive === true ? extension.exports : await extension?.activate(); + const api = gitExtension?.getAPI(1); + return api?.repositories.map(repositoryFromVscodeGit) ?? []; + }, + }; +} + +export function createVscodeMarkdownSink(): { + showMarkdown(content: string, title: string): Promise; + copyToClipboard(content: string): Promise; +} { + return { + async showMarkdown(content, title) { + const document = await vscode.workspace.openTextDocument( + vscode.Uri.from({ + scheme: "untitled", + path: "/prole-coder/" + Date.now().toString(36) + "/" + untitledMarkdownName(title), + }), + ); + const editor = await vscode.window.showTextDocument(document, { + preview: false, + }); + const inserted = await editor.edit((edit) => { + edit.insert(new vscode.Position(0, 0), content); + }); + if (!inserted) { + throw new Error("Failed to open markdown preview."); + } + await vscode.languages.setTextDocumentLanguage(editor.document, "markdown"); + await vscode.commands.executeCommand("workbench.action.keepEditor"); + }, + async copyToClipboard(content) { + await vscode.env.clipboard.writeText(content); + }, + }; +} + +function untitledMarkdownName(title: string): string { + const base = title + .trim() + .replace(/[<>:"/\\|?*\x00-\x1f]+/gu, "-") + .replace(/\s+/gu, " ") + .slice(0, 80) + .trim(); + return (base.length === 0 ? "ProleCoder PR Description" : base) + ".md"; +} + +function repositoryFromVscodeGit(repository: VscodeGitRepository): GitWorkflowRepository { + const rootPath = repository.rootUri.fsPath; + return { + label: repository.rootUri.fsPath.split(/[\\/]/u).at(-1) ?? repository.rootUri.fsPath, + rootPath, + branch: repository.state.HEAD?.name, + upstream: upstreamName(repository.state.HEAD?.upstream), + inputBox: repository.inputBox, + async stagedDiff() { + if (repository.diffIndexWithHEAD !== undefined) { + const diff = await repository.diffIndexWithHEAD(); + if (diff.trim().length > 0) { + return diff; + } + } + return git(rootPath, ["diff", "--cached", "--no-ext-diff", "--"]); + }, + async unstagedDiff() { + return git(rootPath, ["diff", "--no-ext-diff", "--"]); + }, + async diffStat(base) { + return git(rootPath, ["diff", "--stat", "--no-ext-diff", base + "...HEAD", "--"]); + }, + async branchDiff(base) { + return git(rootPath, ["diff", "--no-ext-diff", base + "...HEAD", "--"]); + }, + async commitSummary(base) { + return git(rootPath, ["log", "--oneline", base + "..HEAD"]); + }, + async refExists(ref) { + const result = await git(rootPath, ["rev-parse", "--verify", "--quiet", ref]).catch(() => ""); + return result.trim().length > 0; + }, + }; +} + +async function git(cwd: string, args: readonly string[]): Promise { + const result = await execFileAsync("git", [...args], { + cwd, + windowsHide: true, + maxBuffer: 16 * 1024 * 1024, + }); + return result.stdout; +} + +function upstreamName(upstream: { readonly name?: string; readonly remote?: string } | undefined): string | undefined { + if (upstream === undefined) { + return undefined; + } + const name = upstream.name; + const remote = upstream.remote; + if (name === undefined || name.length === 0) { + return undefined; + } + return remote === undefined || remote.length === 0 ? name : remote + "/" + name; +} diff --git a/vscode/extension/src/logging.ts b/vscode/extension/src/logging.ts new file mode 100644 index 0000000..ee1161a --- /dev/null +++ b/vscode/extension/src/logging.ts @@ -0,0 +1,32 @@ +export type ProleLogLevel = "error" | "info" | "warn"; + +export interface ProleLogger { + error(message: string): void; + info(message: string): void; + warn(message: string): void; +} + +export interface OutputAppender { + appendLine(value: string): unknown; +} + +export function createOutputLogger( + output: OutputAppender, + now: () => Date = () => new Date(), +): ProleLogger { + return { + error(message) { + output.appendLine(formatLogLine("error", message, now())); + }, + info(message) { + output.appendLine(formatLogLine("info", message, now())); + }, + warn(message) { + output.appendLine(formatLogLine("warn", message, now())); + }, + }; +} + +export function formatLogLine(level: ProleLogLevel, message: string, time: Date): string { + return `[${time.toISOString()}] ${level.toUpperCase()} ${message}`; +} diff --git a/vscode/extension/src/notifier.ts b/vscode/extension/src/notifier.ts new file mode 100644 index 0000000..b98b5b6 --- /dev/null +++ b/vscode/extension/src/notifier.ts @@ -0,0 +1,38 @@ +import type { ProleLogger } from "./logging"; +import type { MessageRedactor } from "./redaction"; +import { passthroughRedactor } from "./redaction"; + +export interface ExtensionNotifier { + info(message: string): unknown; + warn(message: string): unknown; + error(message: string): unknown; +} + +export interface ToastMessenger { + showInformationMessage(message: string): unknown; + showWarningMessage(message: string): unknown; +} + +export function createExtensionNotifier( + logger: ProleLogger, + window: ToastMessenger, + redactor: MessageRedactor = passthroughRedactor, +): ExtensionNotifier { + return { + info(message) { + const redacted = redactor.redact(message); + logger.info(redacted); + return window.showInformationMessage(redacted); + }, + warn(message) { + const redacted = redactor.redact(message); + logger.warn(redacted); + return window.showWarningMessage(redacted); + }, + error(message) { + const redacted = redactor.redact(message); + logger.error(redacted); + return window.showWarningMessage(redacted); + }, + }; +} diff --git a/vscode/extension/src/patchPreview.ts b/vscode/extension/src/patchPreview.ts index 3ee4897..0978d1a 100644 --- a/vscode/extension/src/patchPreview.ts +++ b/vscode/extension/src/patchPreview.ts @@ -3,6 +3,7 @@ export const PATCH_APPROVAL_EVENT_TYPE = "tool.approvalRequired"; const APPLY_PATCH_TOOL_NAME = "apply_patch"; const DEFAULT_MAX_PENDING_PATCHES = 50; +const HUNK_DECLARED_COUNT_TOLERANCE = 5; export interface DisposableLike { dispose(): unknown; @@ -16,6 +17,7 @@ export interface PatchPreviewEvent { readonly type: string; readonly runId: string; readonly turnId?: string; + readonly replay?: boolean; readonly payload: unknown; } @@ -154,7 +156,11 @@ export class PatchDiffPreviewController implements DisposableLike { } prepareApproval(event: PatchPreviewEvent, request: PatchApprovalRequest): Promise { - if (event.type !== PATCH_APPROVAL_EVENT_TYPE || request.toolName !== APPLY_PATCH_TOOL_NAME) { + if ( + event.replay === true || + event.type !== PATCH_APPROVAL_EVENT_TYPE || + request.toolName !== APPLY_PATCH_TOOL_NAME + ) { return Promise.resolve(); } @@ -165,6 +171,10 @@ export class PatchDiffPreviewController implements DisposableLike { } private handleEvent(event: PatchPreviewEvent): void { + if (event.replay === true) { + return; + } + if (event.type !== PATCH_PREVIEW_EVENT_TYPE) { return; } @@ -314,6 +324,8 @@ export function parseUnifiedDiff(unifiedDiff: string): ParsedPatch { hunks.push({ hunkIndex: hunks.length, ...header, + oldCount: parsed.oldCount, + newCount: parsed.newCount, lines: parsed.lines, }); index = parsed.nextIndex; @@ -421,16 +433,35 @@ function parseHunkLines( lines: readonly string[], startIndex: number, header: Omit, -): { readonly lines: readonly ParsedPatchLine[]; readonly nextIndex: number } { +): { + readonly lines: readonly ParsedPatchLine[]; + readonly nextIndex: number; + readonly oldCount: number; + readonly newCount: number; +} { const hunkLines: ParsedPatchLine[] = []; let oldSeen = 0; let newSeen = 0; let index = startIndex; - while (oldSeen < header.oldCount || newSeen < header.newCount) { + while (index < lines.length) { const line = lines[index]; if (line === undefined) { - throw new PatchPreviewError("patch ended before hunk was complete"); + break; + } + const nextLine = lines[index + 1]; + if ( + line.startsWith("@@ ") || + line.startsWith("diff --git ") || + (line.startsWith("--- ") && nextLine !== undefined && nextLine.startsWith("+++ ")) + ) { + break; + } + if (line.length === 0) { + if (index === lines.length - 1) { + break; + } + throw new PatchPreviewError("invalid blank patch line in hunk"); } if (line.startsWith("\\ No newline at end of file")) { index += 1; @@ -457,13 +488,20 @@ function parseHunkLines( throw new PatchPreviewError(`invalid patch line \`${line}\``); } - if (oldSeen > header.oldCount || newSeen > header.newCount) { - throw new PatchPreviewError("hunk contains more lines than declared"); - } index += 1; } - return { lines: hunkLines, nextIndex: index }; + if (hunkLines.length === 0) { + throw new PatchPreviewError("hunk has no patch lines"); + } + if ( + oldSeen > header.oldCount + HUNK_DECLARED_COUNT_TOLERANCE || + newSeen > header.newCount + HUNK_DECLARED_COUNT_TOLERANCE + ) { + throw new PatchPreviewError("hunk contains too many more lines than declared"); + } + + return { lines: hunkLines, nextIndex: index, oldCount: oldSeen, newCount: newSeen }; } function parseHunkHeader(line: string): Omit { diff --git a/vscode/extension/src/providerConfigurationUx.ts b/vscode/extension/src/providerConfigurationUx.ts new file mode 100644 index 0000000..340b3c0 --- /dev/null +++ b/vscode/extension/src/providerConfigurationUx.ts @@ -0,0 +1,95 @@ +import type { RpcRecoverableAction } from "@prole-coder/protocol" with { + "resolution-mode": "import", +}; + +export const CONFIGURE_DEEPSEEK_API_KEY_ACTION_KIND = "configureDeepSeekApiKey"; +export const CONFIGURE_DEEPSEEK_API_KEY_ACTION_LABEL = "Configure API Key"; + +export interface ConfigureDeepSeekApiKeyAction { + readonly type: typeof CONFIGURE_DEEPSEEK_API_KEY_ACTION_KIND; + readonly label: string; +} + +export type ProviderConfigurationAction = ConfigureDeepSeekApiKeyAction; + +export function providerConfigurationActionFromError( + error: unknown, +): ProviderConfigurationAction | undefined { + const data = isRecord(error) ? error["data"] : undefined; + return providerConfigurationActionFromData(data); +} + +export function providerConfigurationActionFromPayload( + payload: unknown, +): ProviderConfigurationAction | undefined { + return providerConfigurationActionFromData(payload); +} + +export function providerConfigurationActionFromMetadata( + metadata: unknown, +): ProviderConfigurationAction | undefined { + const action = isRecord(metadata) ? metadata["providerConfigurationAction"] : undefined; + return actionFromProviderConfigurationAction(action) ?? + providerConfigurationActionFromData({ recoverableAction: action }); +} + +export function providerConfigurationActionFromData( + data: unknown, +): ProviderConfigurationAction | undefined { + const record = isRecord(data) ? data : undefined; + return actionFromRecoverableAction(record?.["recoverableAction"]); +} + +export function isConfigureDeepSeekApiKeyAction( + action: ProviderConfigurationAction | undefined, +): action is ConfigureDeepSeekApiKeyAction { + return action?.type === CONFIGURE_DEEPSEEK_API_KEY_ACTION_KIND; +} + +function actionFromRecoverableAction( + action: unknown, +): ProviderConfigurationAction | undefined { + if (!isRecoverableAction(action)) { + return undefined; + } + + if (action.kind === CONFIGURE_DEEPSEEK_API_KEY_ACTION_KIND) { + return { + type: CONFIGURE_DEEPSEEK_API_KEY_ACTION_KIND, + label: action.label.length > 0 ? action.label : CONFIGURE_DEEPSEEK_API_KEY_ACTION_LABEL, + }; + } + + return undefined; +} + +function actionFromProviderConfigurationAction( + action: unknown, +): ProviderConfigurationAction | undefined { + if ( + isRecord(action) && + action["type"] === CONFIGURE_DEEPSEEK_API_KEY_ACTION_KIND && + typeof action["label"] === "string" + ) { + return { + type: CONFIGURE_DEEPSEEK_API_KEY_ACTION_KIND, + label: action["label"].length > 0 + ? action["label"] + : CONFIGURE_DEEPSEEK_API_KEY_ACTION_LABEL, + }; + } + + return undefined; +} + +function isRecoverableAction(value: unknown): value is RpcRecoverableAction { + return ( + isRecord(value) && + value["kind"] === CONFIGURE_DEEPSEEK_API_KEY_ACTION_KIND && + typeof value["label"] === "string" + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/vscode/extension/src/providerSecretCommands.ts b/vscode/extension/src/providerSecretCommands.ts new file mode 100644 index 0000000..35e8aa9 --- /dev/null +++ b/vscode/extension/src/providerSecretCommands.ts @@ -0,0 +1,570 @@ +import { randomUUID } from "node:crypto"; + +import { + DEEPSEEK_API_KEY_STORE_SECRET_ID, + DEEPSEEK_API_KEY_SECRET_ID, + DEEPSEEK_MODEL_OPTIONS, + deepSeekEnvOverride, + formatDeepSeekModelStatus, + formatProviderSecretStatus, + maskDeepSeekApiKey, + normalizeDeepSeekKeyAlias, + normalizeDeepSeekModelId, + parseDeepSeekApiKeyStore, + providerSecretRedactionValues, + resolveDeepSeekApiKey, + resolveDeepSeekModel, + selectedDeepSeekApiKeyEntry, + serializeDeepSeekApiKeyStore, + type DeepSeekApiKeyEntry, + type DeepSeekApiKeyStore, + type DeepSeekSecretResolution, +} from "./providerSecrets"; +import type { MutableSecretRedactor } from "./redaction"; + +export const CONFIGURE_DEEPSEEK_API_KEY_COMMAND = "prole-coder.configureDeepSeekApiKey"; +export const CLEAR_DEEPSEEK_API_KEY_COMMAND = "prole-coder.clearDeepSeekApiKey"; +export const SHOW_PROVIDER_STATUS_COMMAND = "prole-coder.showProviderStatus"; +export const SELECT_DEEPSEEK_MODEL_COMMAND = "prole-coder.selectDeepSeekModel"; +const DELETE_DEEPSEEK_API_KEY_CONFIRM_LABEL = "Delete"; + +export interface CommandRegistry { + registerCommand(command: string, callback: () => unknown): DisposableLike; +} + +export interface DisposableLike { + dispose(): unknown; +} + +export interface SecretStorageLike { + get(key: string): PromiseLike; + store(key: string, value: string): PromiseLike; + delete(key: string): PromiseLike; +} + +export interface SecretWindowMessenger { + showInputBox(options: { + readonly ignoreFocusOut: true; + readonly password?: boolean; + readonly placeHolder?: string; + readonly prompt: string; + readonly title: string; + readonly value?: string; + }): string | undefined | PromiseLike; + showQuickPick( + items: readonly T[], + options: { + readonly ignoreFocusOut: true; + readonly placeHolder: string; + readonly title: string; + }, + ): T | undefined | PromiseLike; + createQuickPick(): SecretQuickPickController; + showInformationMessage(message: string): unknown; + showWarningMessage(message: string, ...items: readonly string[]): unknown; +} + +export interface SecretQuickInputButton { + readonly iconPath?: unknown; + readonly tooltip?: string; +} + +export interface SecretQuickPickItem { + readonly label: string; + readonly description?: string; + readonly detail?: string; + readonly buttons?: readonly SecretQuickInputButton[]; + readonly alwaysShow?: boolean; +} + +export interface SecretQuickPickItemButtonEvent { + readonly button: SecretQuickInputButton; + readonly item: T; +} + +export interface SecretQuickPickController extends DisposableLike { + title: string | undefined; + placeholder: string | undefined; + ignoreFocusOut: boolean; + matchOnDescription: boolean; + items: readonly T[]; + readonly selectedItems: readonly T[]; + onDidAccept(callback: () => unknown): DisposableLike; + onDidHide(callback: () => unknown): DisposableLike; + onDidTriggerItemButton(callback: (event: SecretQuickPickItemButtonEvent) => unknown): DisposableLike; + show(): void; + hide(): void; +} + +export interface ProviderConfigurationLike { + get(section: string, defaultValue: T): T; + update(section: string, value: unknown, configurationTarget?: unknown): PromiseLike | void; +} + +export interface ProviderSecretRpcServer { + readonly status: string; + setProcessEnv(env: Record): void; + start(): Promise; + stop(): void; +} + +export interface RegisterProviderSecretCommandOptions { + readonly commands: CommandRegistry; + readonly window: SecretWindowMessenger; + readonly secrets: SecretStorageLike; + readonly processEnv?: Record | undefined; + readonly redactor?: MutableSecretRedactor; + readonly rpcServer?: ProviderSecretRpcServer | undefined; + readonly isRpcIdle?: () => boolean; + readonly providerConfiguration?: ProviderConfigurationLike | undefined; + readonly configurationTarget?: unknown; + readonly renameAliasButton?: SecretQuickInputButton | undefined; + readonly deleteKeyButton?: SecretQuickInputButton | undefined; +} + +export interface ProviderSecretRuntimeState { + status: DeepSeekSecretResolution; + modelId?: string | undefined; +} + +export function registerProviderSecretCommands( + options: RegisterProviderSecretCommandOptions, +): readonly DisposableLike[] { + const state: ProviderSecretRuntimeState = { + status: resolveDeepSeekApiKey({ processEnv: options.processEnv }), + modelId: currentDeepSeekModel(options), + }; + + void refreshProviderSecretState(options, state); + + return [ + options.commands.registerCommand(CONFIGURE_DEEPSEEK_API_KEY_COMMAND, async () => { + return showDeepSeekApiKeyManager(options, state); + }), + options.commands.registerCommand(CLEAR_DEEPSEEK_API_KEY_COMMAND, async () => { + await clearSelectedDeepSeekApiKey(options); + const status = await refreshProviderSecretState(options, state); + await restartRpcAfterSecretChange(options, "DeepSeek API key cleared."); + return options.window.showInformationMessage(formatProviderSecretStatus(status)); + }), + options.commands.registerCommand(SHOW_PROVIDER_STATUS_COMMAND, async () => { + const status = await refreshProviderSecretState(options, state); + return options.window.showInformationMessage( + `${formatProviderSecretStatus(status)}; ${formatDeepSeekModelStatus(state.modelId)}`, + ); + }), + options.commands.registerCommand(SELECT_DEEPSEEK_MODEL_COMMAND, async () => { + const item = await options.window.showQuickPick(deepSeekModelPickItems(state.modelId), { + title: "Select DeepSeek Model", + placeHolder: "Choose the DeepSeek model used for chat and agent turns.", + ignoreFocusOut: true, + }); + if (item === undefined) { + return undefined; + } + + const modelId = normalizeDeepSeekModelId(item.modelId); + if (modelId === undefined) { + return options.window.showWarningMessage("DeepSeek model was not changed."); + } + + state.modelId = modelId; + await options.providerConfiguration?.update("model", modelId, options.configurationTarget); + const status = await refreshProviderSecretState(options, state); + await restartRpcAfterProviderChange(options, "DeepSeek model updated.", "DeepSeek model updated"); + options.window.showInformationMessage(formatDeepSeekModelStatus(modelId)); + return status; + }), + ]; +} + +export async function refreshProviderSecretState( + options: Pick< + RegisterProviderSecretCommandOptions, + "processEnv" | "providerConfiguration" | "redactor" | "rpcServer" | "secrets" + >, + state?: ProviderSecretRuntimeState, +): Promise { + const [secretValue, keyStoreValue] = await Promise.all([ + options.secrets.get(DEEPSEEK_API_KEY_SECRET_ID), + options.secrets.get(DEEPSEEK_API_KEY_STORE_SECRET_ID), + ]); + const status = resolveDeepSeekApiKey({ + secretValue, + keyStoreValue, + processEnv: options.processEnv, + }); + const modelId = state?.modelId ?? currentDeepSeekModel(options); + options.rpcServer?.setProcessEnv(deepSeekEnvOverride(status, modelId)); + options.redactor?.update(providerSecretRedactionValues(status)); + if (state !== undefined) { + state.status = status; + state.modelId = modelId; + } + return status; +} + +async function restartRpcAfterProviderChange( + options: Pick, + prefix: string, + failurePrefix: string, +): Promise { + const rpcServer = options.rpcServer; + if (rpcServer === undefined || rpcServer.status === "stopped" || rpcServer.status === "failed") { + return; + } + + if (rpcServer.status !== "ready") { + options.window.showInformationMessage(`${prefix} It will be used after the RPC server restarts.`); + return; + } + + if (options.isRpcIdle?.() === false) { + options.window.showInformationMessage(`${prefix} It will be used after the current turn finishes.`); + return; + } + + rpcServer.stop(); + try { + await rpcServer.start(); + } catch (error) { + options.window.showWarningMessage( + redactMessage(`${failurePrefix}, but RPC restart failed: ${errorMessage(error)}`, options.redactor), + ); + } +} + +async function restartRpcAfterSecretChange( + options: Pick, + prefix: string, +): Promise { + await restartRpcAfterProviderChange(options, prefix, "DeepSeek API key updated"); +} + +async function showDeepSeekApiKeyManager( + options: RegisterProviderSecretCommandOptions, + state: ProviderSecretRuntimeState, +): Promise { + let store = await readDeepSeekApiKeyStore(options.secrets); + const quickPick = options.window.createQuickPick(); + quickPick.title = "Select DeepSeek API Key"; + quickPick.placeholder = "Choose a key, add one, or use item buttons to rename or delete."; + quickPick.ignoreFocusOut = true; + quickPick.matchOnDescription = true; + quickPick.items = deepSeekApiKeyPickItems(store, { + deleteKeyButton: options.deleteKeyButton, + renameAliasButton: options.renameAliasButton, + }); + + return new Promise((resolve) => { + let finished = false; + let actionInProgress = false; + const disposables: DisposableLike[] = []; + + const finish = (value: DeepSeekSecretResolution | undefined): void => { + if (finished) { + return; + } + finished = true; + for (const disposable of disposables) { + disposable.dispose(); + } + quickPick.dispose(); + resolve(value); + }; + + disposables.push( + quickPick.onDidHide(() => { + if (!actionInProgress) { + finish(undefined); + } + }), + quickPick.onDidAccept(() => { + const item = quickPick.selectedItems[0]; + if (item === undefined) { + return; + } + actionInProgress = true; + quickPick.hide(); + void (async () => { + if (item.kind === "add") { + finish(await addDeepSeekApiKey(options, state, store)); + return; + } + + store = { + ...store, + selectedKeyId: item.keyId, + }; + await saveDeepSeekApiKeyStore(options.secrets, store); + const status = await refreshProviderSecretState(options, state); + await restartRpcAfterSecretChange(options, "DeepSeek API key selected."); + options.window.showInformationMessage(formatProviderSecretStatus(status)); + finish(status); + })(); + }), + quickPick.onDidTriggerItemButton((event) => { + if (event.item.kind !== "key") { + return; + } + + actionInProgress = true; + quickPick.hide(); + void (async () => { + finish( + event.button === options.deleteKeyButton + ? await deleteDeepSeekApiKey(options, state, store, event.item.keyId) + : await renameDeepSeekApiKeyAlias(options, state, store, event.item.keyId), + ); + })(); + }), + ); + + quickPick.show(); + }); +} + +async function addDeepSeekApiKey( + options: RegisterProviderSecretCommandOptions, + state: ProviderSecretRuntimeState, + store: DeepSeekApiKeyStore, +): Promise { + const aliasValue = await options.window.showInputBox({ + title: "Add DeepSeek API Key", + prompt: "Enter an alias for this key.", + placeHolder: "Personal account", + value: `DeepSeek key ${store.entries.length + 1}`, + ignoreFocusOut: true, + }); + if (aliasValue === undefined) { + return undefined; + } + + const apiKeyValue = await options.window.showInputBox({ + title: "Add DeepSeek API Key", + prompt: "Enter the DeepSeek API key. It is stored in VS Code SecretStorage.", + password: true, + ignoreFocusOut: true, + }); + if (apiKeyValue === undefined) { + return undefined; + } + + const apiKey = apiKeyValue.trim(); + if (apiKey.length === 0) { + options.window.showWarningMessage("DeepSeek API key was not changed."); + return undefined; + } + + const entry: DeepSeekApiKeyEntry = { + id: randomUUID(), + alias: normalizeDeepSeekKeyAlias(aliasValue, `DeepSeek key ${store.entries.length + 1}`), + apiKey, + }; + const nextStore: DeepSeekApiKeyStore = { + selectedKeyId: entry.id, + entries: [...store.entries, entry], + }; + await saveDeepSeekApiKeyStore(options.secrets, nextStore); + const status = await refreshProviderSecretState(options, state); + await restartRpcAfterSecretChange(options, "DeepSeek API key saved."); + options.window.showInformationMessage(formatProviderSecretStatus(status)); + return status; +} + +async function renameDeepSeekApiKeyAlias( + options: RegisterProviderSecretCommandOptions, + state: ProviderSecretRuntimeState, + store: DeepSeekApiKeyStore, + keyId: string, +): Promise { + const entry = store.entries.find((candidate) => candidate.id === keyId); + if (entry === undefined) { + return undefined; + } + + const aliasValue = await options.window.showInputBox({ + title: "Rename DeepSeek API Key", + prompt: "Enter a new alias for this key.", + value: entry.alias, + ignoreFocusOut: true, + }); + if (aliasValue === undefined) { + return undefined; + } + + const alias = normalizeDeepSeekKeyAlias(aliasValue, entry.alias); + const nextStore: DeepSeekApiKeyStore = { + selectedKeyId: store.selectedKeyId, + entries: store.entries.map((candidate) => (candidate.id === keyId ? { ...candidate, alias } : candidate)), + }; + await saveDeepSeekApiKeyStore(options.secrets, nextStore); + const status = await refreshProviderSecretState(options, state); + options.window.showInformationMessage(`DeepSeek API key alias updated: ${alias}`); + return status; +} + +async function deleteDeepSeekApiKey( + options: RegisterProviderSecretCommandOptions, + state: ProviderSecretRuntimeState, + store: DeepSeekApiKeyStore, + keyId: string, +): Promise { + const entry = store.entries.find((candidate) => candidate.id === keyId); + if (entry === undefined) { + return undefined; + } + + const confirmation = await options.window.showWarningMessage( + `Delete DeepSeek API key "${entry.alias}"?`, + DELETE_DEEPSEEK_API_KEY_CONFIRM_LABEL, + ); + if (confirmation !== DELETE_DEEPSEEK_API_KEY_CONFIRM_LABEL) { + return undefined; + } + + const activeKeyId = selectedDeepSeekApiKeyEntry(store)?.id; + const entries = store.entries.filter((candidate) => candidate.id !== keyId); + const nextSelectedKeyId = + activeKeyId === keyId + ? entries[0]?.id + : entries.some((candidate) => candidate.id === store.selectedKeyId) + ? store.selectedKeyId + : entries[0]?.id; + await saveDeepSeekApiKeyStore(options.secrets, { + selectedKeyId: nextSelectedKeyId, + entries, + }); + const status = await refreshProviderSecretState(options, state); + if (activeKeyId === keyId) { + await restartRpcAfterSecretChange(options, "DeepSeek API key deleted."); + } + options.window.showInformationMessage(`DeepSeek API key deleted: ${entry.alias}`); + return status; +} + +async function clearSelectedDeepSeekApiKey( + options: Pick, +): Promise { + const store = await readDeepSeekApiKeyStore(options.secrets); + const selected = selectedDeepSeekApiKeyEntry(store); + if (selected === undefined) { + await options.secrets.delete(DEEPSEEK_API_KEY_SECRET_ID); + await options.secrets.delete(DEEPSEEK_API_KEY_STORE_SECRET_ID); + return; + } + + const entries = store.entries.filter((entry) => entry.id !== selected.id); + await saveDeepSeekApiKeyStore(options.secrets, { + selectedKeyId: entries[0]?.id, + entries, + }); +} + +async function readDeepSeekApiKeyStore( + secrets: SecretStorageLike, +): Promise { + const [keyStoreValue, legacySecretValue] = await Promise.all([ + secrets.get(DEEPSEEK_API_KEY_STORE_SECRET_ID), + secrets.get(DEEPSEEK_API_KEY_SECRET_ID), + ]); + const store = parseDeepSeekApiKeyStore(keyStoreValue); + if (store.entries.length > 0) { + return store; + } + + const legacySecret = legacySecretValue?.trim(); + if (legacySecret === undefined || legacySecret.length === 0) { + return store; + } + + return { + selectedKeyId: "legacy", + entries: [ + { + id: "legacy", + alias: "Default key", + apiKey: legacySecret, + }, + ], + }; +} + +async function saveDeepSeekApiKeyStore( + secrets: SecretStorageLike, + store: DeepSeekApiKeyStore, +): Promise { + if (store.entries.length === 0) { + await secrets.delete(DEEPSEEK_API_KEY_STORE_SECRET_ID); + } else { + await secrets.store(DEEPSEEK_API_KEY_STORE_SECRET_ID, serializeDeepSeekApiKeyStore(store)); + } + await secrets.delete(DEEPSEEK_API_KEY_SECRET_ID); +} + +interface DeepSeekApiKeyPickItem extends SecretQuickPickItem { + readonly kind: "add" | "key"; + readonly keyId: string; +} + +function deepSeekApiKeyPickItems( + store: DeepSeekApiKeyStore, + buttons: { + readonly deleteKeyButton?: SecretQuickInputButton | undefined; + readonly renameAliasButton?: SecretQuickInputButton | undefined; + }, +): readonly DeepSeekApiKeyPickItem[] { + const selected = selectedDeepSeekApiKeyEntry(store); + const keyItems = store.entries.map((entry) => ({ + label: entry.alias, + description: `${maskDeepSeekApiKey(entry.apiKey)}${entry.id === selected?.id ? " (current)" : ""}`, + detail: "Stored in VS Code SecretStorage", + buttons: [buttons.renameAliasButton, buttons.deleteKeyButton].filter( + (button): button is SecretQuickInputButton => button !== undefined, + ), + kind: "key" as const, + keyId: entry.id, + })); + return [ + ...keyItems, + { + label: "+ Add DeepSeek API Key", + description: "Store a new key and alias in VS Code SecretStorage", + alwaysShow: true, + kind: "add", + keyId: "__add__", + }, + ]; +} + +function currentDeepSeekModel( + options: Pick, +): string | undefined { + const configuredModel = options.providerConfiguration?.get("model", ""); + return resolveDeepSeekModel({ + configuredModel: typeof configuredModel === "string" ? configuredModel : "", + processEnv: options.processEnv, + }); +} + +interface DeepSeekModelPickItem extends SecretQuickPickItem { + readonly modelId: string; +} + +function deepSeekModelPickItems(currentModelId: string | undefined): readonly DeepSeekModelPickItem[] { + const current = normalizeDeepSeekModelId(currentModelId); + return DEEPSEEK_MODEL_OPTIONS.map((model) => ({ + label: model.displayName, + description: model.id === current ? `${model.id} (current)` : model.id, + detail: model.detail, + modelId: model.id, + })); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function redactMessage(message: string, redactor: MutableSecretRedactor | undefined): string { + return redactor?.redact(message) ?? message; +} diff --git a/vscode/extension/src/providerSecrets.ts b/vscode/extension/src/providerSecrets.ts new file mode 100644 index 0000000..36f44ea --- /dev/null +++ b/vscode/extension/src/providerSecrets.ts @@ -0,0 +1,291 @@ +export const DEEPSEEK_API_KEY_SECRET_ID = "prole-coder.deepseek-api-key"; +export const DEEPSEEK_API_KEY_STORE_SECRET_ID = "prole-coder.deepseek-api-keys.v1"; +export const DEEPSEEK_API_KEY_ENV = "DEEPSEEK_API_KEY"; +export const DEEPSEEK_MODEL_ENV = "DEEPSEEK_MODEL"; + +export interface DeepSeekModelOption { + readonly id: string; + readonly displayName: string; + readonly detail: string; +} + +export const DEEPSEEK_MODEL_OPTIONS: readonly DeepSeekModelOption[] = [ + { + id: "deepseek-v4-pro", + displayName: "DeepSeek V4 Pro", + detail: "1M context, 384K max output", + }, + { + id: "deepseek-v4-flash", + displayName: "DeepSeek V4 Flash", + detail: "1M context, 384K max output", + }, +]; + +export type ProviderSecretSource = "missing" | "process-env" | "secret-storage"; + +export interface DeepSeekSecretResolution { + readonly provider: "deepseek"; + readonly source: ProviderSecretSource; + readonly apiKey?: string; + readonly keyAlias?: string; + readonly redactionValues?: readonly string[]; +} + +export interface ResolveDeepSeekApiKeyInput { + readonly secretValue?: string | undefined; + readonly keyStoreValue?: string | undefined; + readonly processEnv?: Record | undefined; +} + +export interface ResolveDeepSeekModelInput { + readonly configuredModel?: string | undefined; + readonly processEnv?: Record | undefined; +} + +export interface DeepSeekApiKeyEntry { + readonly id: string; + readonly alias: string; + readonly apiKey: string; +} + +export interface DeepSeekApiKeyStore { + readonly selectedKeyId?: string | undefined; + readonly entries: readonly DeepSeekApiKeyEntry[]; +} + +export function resolveDeepSeekApiKey(input: ResolveDeepSeekApiKeyInput): DeepSeekSecretResolution { + const keyStore = parseDeepSeekApiKeyStore(input.keyStoreValue); + const selectedKey = selectedDeepSeekApiKeyEntry(keyStore); + const legacySecretValue = normalizedSecret(input.secretValue); + const storedRedactionValues = uniqueStrings([ + ...keyStore.entries.map((entry) => entry.apiKey), + ...(legacySecretValue === undefined ? [] : [legacySecretValue]), + ]); + + if (selectedKey !== undefined) { + return { + provider: "deepseek", + source: "secret-storage", + apiKey: selectedKey.apiKey, + keyAlias: selectedKey.alias, + redactionValues: storedRedactionValues, + }; + } + + if (legacySecretValue !== undefined) { + return { + provider: "deepseek", + source: "secret-storage", + apiKey: legacySecretValue, + redactionValues: storedRedactionValues, + }; + } + + const envValue = normalizedSecret(input.processEnv?.[DEEPSEEK_API_KEY_ENV]); + if (envValue !== undefined) { + return { + provider: "deepseek", + source: "process-env", + apiKey: envValue, + redactionValues: [envValue], + }; + } + + return { + provider: "deepseek", + source: "missing", + }; +} + +export function deepSeekEnvOverride( + resolution: DeepSeekSecretResolution, + modelId?: string | undefined, +): Record { + const env: Record = {}; + const normalizedModelId = normalizeDeepSeekModelId(modelId); + if (resolution.apiKey !== undefined) { + env[DEEPSEEK_API_KEY_ENV] = resolution.apiKey; + } + if (normalizedModelId !== undefined) { + env[DEEPSEEK_MODEL_ENV] = normalizedModelId; + } + return env; +} + +export function providerSecretRedactionValues( + resolution: DeepSeekSecretResolution, +): readonly string[] { + return uniqueStrings([ + ...(resolution.redactionValues ?? []), + ...(resolution.apiKey === undefined ? [] : [resolution.apiKey]), + ]); +} + +export function formatProviderSecretStatus(resolution: DeepSeekSecretResolution): string { + const source = + resolution.source === "secret-storage" + ? "VS Code SecretStorage" + : resolution.source === "process-env" + ? "process env" + : "missing"; + return resolution.keyAlias === undefined ? `DeepSeek API key: ${source}` : `DeepSeek API key: ${source} (${resolution.keyAlias})`; +} + +export function resolveDeepSeekModel(input: ResolveDeepSeekModelInput): string | undefined { + return ( + normalizeDeepSeekModelId(input.configuredModel) ?? + normalizeDeepSeekModelId(input.processEnv?.[DEEPSEEK_MODEL_ENV]) + ); +} + +export function normalizeDeepSeekModelId(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized === undefined || normalized.length === 0 ? undefined : normalized; +} + +export function formatDeepSeekModelStatus(modelId: string | undefined): string { + const normalized = normalizeDeepSeekModelId(modelId); + if (normalized === undefined) { + return "DeepSeek model: provider default"; + } + const option = DEEPSEEK_MODEL_OPTIONS.find((model) => model.id === normalized); + return option === undefined + ? `DeepSeek model: ${normalized}` + : `DeepSeek model: ${option.displayName} (${option.id})`; +} + +export function parseDeepSeekApiKeyStore(value: string | undefined): DeepSeekApiKeyStore { + if (value === undefined || value.trim().length === 0) { + return emptyDeepSeekApiKeyStore(); + } + + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return emptyDeepSeekApiKeyStore(); + } + + if (!isRecord(parsed) || parsed["version"] !== 1 || !Array.isArray(parsed["keys"])) { + return emptyDeepSeekApiKeyStore(); + } + + const entries: DeepSeekApiKeyEntry[] = []; + const seenIds = new Set(); + for (const item of parsed["keys"]) { + if (!isRecord(item)) { + continue; + } + + const id = normalizeId(item["id"]); + const apiKey = typeof item["apiKey"] === "string" ? normalizedSecret(item["apiKey"]) : undefined; + if (id === undefined || apiKey === undefined || seenIds.has(id)) { + continue; + } + + seenIds.add(id); + entries.push({ + id, + alias: normalizeDeepSeekKeyAlias(typeof item["alias"] === "string" ? item["alias"] : undefined, "DeepSeek API key"), + apiKey, + }); + } + + return { + selectedKeyId: normalizeId(parsed["selectedKeyId"]), + entries, + }; +} + +export function serializeDeepSeekApiKeyStore(store: DeepSeekApiKeyStore): string { + const entries = validatedDeepSeekApiKeyStoreEntries(store.entries); + return JSON.stringify({ + version: 1, + selectedKeyId: normalizeId(store.selectedKeyId), + keys: entries, + }); +} + +export function selectedDeepSeekApiKeyEntry( + store: DeepSeekApiKeyStore, +): DeepSeekApiKeyEntry | undefined { + return store.entries.find((entry) => entry.id === store.selectedKeyId) ?? store.entries[0]; +} + +export function maskDeepSeekApiKey(value: string): string { + const normalized = normalizedSecret(value); + if (normalized === undefined) { + return ""; + } + if (normalized.length <= 8) { + return "********"; + } + return `${normalized.slice(0, 4)}********${normalized.slice(-4)}`; +} + +export function normalizeDeepSeekKeyAlias(value: string | undefined, fallback: string): string { + const normalized = value?.trim().replace(/\s+/g, " "); + return normalized === undefined || normalized.length === 0 ? fallback : normalized.slice(0, 80); +} + +export function emptyDeepSeekApiKeyStore(): DeepSeekApiKeyStore { + return { + entries: [], + }; +} + +function normalizedSecret(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized === undefined || normalized.length === 0 ? undefined : normalized; +} + +function normalizeId(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim(); + return normalized.length === 0 ? undefined : normalized; +} + +function validatedDeepSeekApiKeyStoreEntries( + entries: readonly DeepSeekApiKeyEntry[], +): readonly DeepSeekApiKeyEntry[] { + const seenIds = new Set(); + return entries.map((entry, index) => { + if (!isRecord(entry)) { + throw new Error(`DeepSeek API key store entry ${index + 1} must be an object.`); + } + + const id = normalizeId(entry["id"]); + if (id === undefined) { + throw new Error(`DeepSeek API key store entry ${index + 1} must have a non-empty id.`); + } + if (seenIds.has(id)) { + throw new Error(`DeepSeek API key store entry id "${id}" must be unique.`); + } + + const apiKey = typeof entry["apiKey"] === "string" ? normalizedSecret(entry["apiKey"]) : undefined; + if (apiKey === undefined) { + throw new Error(`DeepSeek API key store entry "${id}" must have a non-empty apiKey.`); + } + + seenIds.add(id); + return { + id, + alias: normalizeDeepSeekKeyAlias( + typeof entry["alias"] === "string" ? entry["alias"] : undefined, + "DeepSeek API key", + ), + apiKey, + }; + }); +} + +function uniqueStrings(values: readonly string[]): readonly string[] { + return Array.from(new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/vscode/extension/src/redaction.ts b/vscode/extension/src/redaction.ts new file mode 100644 index 0000000..b81ae57 --- /dev/null +++ b/vscode/extension/src/redaction.ts @@ -0,0 +1,36 @@ +export interface MessageRedactor { + redact(message: string): string; +} + +export class MutableSecretRedactor implements MessageRedactor { + private secretValues: readonly string[] = []; + + update(secretValues: readonly string[]): void { + this.secretValues = uniqueSecrets(secretValues); + } + + redact(message: string): string { + let redacted = message; + for (const secret of this.secretValues) { + redacted = redacted.replaceAll(secret, "[redacted]"); + } + return redacted; + } +} + +export const passthroughRedactor: MessageRedactor = { + redact(message) { + return message; + }, +}; + +function uniqueSecrets(secretValues: readonly string[]): readonly string[] { + const values = new Set(); + for (const value of secretValues) { + const normalized = value.trim(); + if (normalized.length > 0) { + values.add(normalized); + } + } + return [...values].sort((left, right) => right.length - left.length); +} diff --git a/vscode/extension/src/rpcServer.ts b/vscode/extension/src/rpcServer.ts index 1a6a86c..0b96096 100644 --- a/vscode/extension/src/rpcServer.ts +++ b/vscode/extension/src/rpcServer.ts @@ -6,8 +6,12 @@ import type { ApproveResult, CancelParams, CancelResult, + DeleteRunParams, + DeleteRunResult, FimPreviewParams, FimPreviewResult, + LoadRunEventsParams, + LoadRunEventsResult, ListRunsParams, ListRunsResult, RejectParams, @@ -17,6 +21,8 @@ import type { SendTurnParams, SendTurnResult, ServerCapabilities, + SteerParams, + SteerResult, } from "@prole-coder/protocol" with { "resolution-mode": "import", }; @@ -27,10 +33,13 @@ export const RPC_EVENT_METHOD = "agent.event"; export const RPC_EVENT_BATCH_METHOD = "agent.eventBatch"; export const RPC_SEND_TURN_METHOD = "agent.sendTurn"; export const RPC_RESUME_METHOD = "agent.resume"; +export const RPC_LOAD_RUN_EVENTS_METHOD = "agent.loadRunEvents"; export const RPC_LIST_RUNS_METHOD = "agent.listRuns"; +export const RPC_DELETE_RUN_METHOD = "agent.deleteRun"; export const RPC_APPROVE_METHOD = "agent.approve"; export const RPC_REJECT_METHOD = "agent.reject"; export const RPC_CANCEL_METHOD = "agent.cancel"; +export const RPC_STEER_METHOD = "agent.steer"; export const RPC_PREVIEW_FIM_METHOD = "agent.previewFim"; export const DEFAULT_RPC_COMMAND = "prole"; export const DEFAULT_RPC_ARGS = ["rpc"] as const; @@ -70,6 +79,7 @@ export interface RpcServerConfiguration { export interface RpcSpawnOptions { readonly cwd: string; + readonly env?: Record; } export interface RpcWritable { @@ -95,7 +105,9 @@ export interface RpcProcessFactory { spawn(command: string, args: readonly string[], options: RpcSpawnOptions): RpcChildProcess; } -export type AgentEventEnvelope = ProtocolAgentEventEnvelope; +export type AgentEventEnvelope = ProtocolAgentEventEnvelope & { + readonly replay?: boolean; +}; export interface DisposableLike { dispose(): unknown; @@ -105,6 +117,7 @@ export interface RpcServerManagerOptions { readonly launch: RpcServerLaunchConfig; readonly workspace: RpcServerWorkspace; readonly extensionVersion: string; + readonly processEnv?: Record; readonly processFactory?: RpcProcessFactory; readonly notifier?: RpcServerNotifier; } @@ -129,6 +142,7 @@ interface JsonRpcNotification { } interface PendingRpcRequest { + readonly method: string; resolve(value: TResult): void; reject(error: Error): void; } @@ -149,6 +163,10 @@ export const nodeRpcProcessFactory: RpcProcessFactory = { spawn(command, args, options) { return spawn(command, [...args], { cwd: options.cwd, + env: { + ...process.env, + ...options.env, + }, stdio: "pipe", windowsHide: true, }); @@ -183,6 +201,8 @@ export class RpcServerManager implements DisposableLike { private readonly notifier: RpcServerNotifier | undefined; private readonly eventHandlers = new Set<(event: AgentEventEnvelope) => void>(); private readonly pendingRequests = new Map>(); + private readonly replayUpperBounds = new Map(); + private processEnv: Record; private child: RpcChildProcess | undefined; private startPromise: Promise | undefined; @@ -202,6 +222,7 @@ export class RpcServerManager implements DisposableLike { this.extensionVersion = options.extensionVersion; this.processFactory = options.processFactory ?? nodeRpcProcessFactory; this.notifier = options.notifier; + this.processEnv = { ...(options.processEnv ?? {}) }; } get status(): RpcServerStatus { @@ -220,6 +241,10 @@ export class RpcServerManager implements DisposableLike { return this.launch; } + setProcessEnv(env: Record): void { + this.processEnv = { ...env }; + } + start(): Promise { if (this.readyState !== undefined && this.currentStatus === "ready") { return Promise.resolve(this.readyState); @@ -245,6 +270,7 @@ export class RpcServerManager implements DisposableLike { try { child = this.processFactory.spawn(this.launch.command, this.launch.args, { cwd: this.workspace.root, + env: this.processEnv, }); } catch (error) { const spawnError = asError(error); @@ -260,8 +286,8 @@ export class RpcServerManager implements DisposableLike { this.child = child; child.stdout.on("data", (chunk) => this.handleStdoutData(chunk)); child.stderr.on("data", (chunk) => this.handleStderrData(chunk)); - child.on("exit", (code, signal) => this.handleExit(code, signal)); - child.on("error", (error) => this.handleProcessError(error)); + child.on("exit", (code, signal) => this.handleExit(child, code, signal)); + child.on("error", (error) => this.handleProcessError(child, error)); this.startPromise = new Promise((resolve, reject) => { this.resolveStart = resolve; @@ -305,6 +331,7 @@ export class RpcServerManager implements DisposableLike { const promise = new Promise((resolve, reject) => { this.pendingRequests.set(id, { + method, resolve: resolve as (value: unknown) => void, reject, }); @@ -328,10 +355,18 @@ export class RpcServerManager implements DisposableLike { return this.sendRequest(RPC_RESUME_METHOD, params); } + loadRunEvents(params: LoadRunEventsParams): Promise { + return this.sendRequest(RPC_LOAD_RUN_EVENTS_METHOD, params); + } + listRuns(params: ListRunsParams = {}): Promise { return this.sendRequest(RPC_LIST_RUNS_METHOD, params); } + deleteRun(params: DeleteRunParams): Promise { + return this.sendRequest(RPC_DELETE_RUN_METHOD, params); + } + approve(params: ApproveParams): Promise { return this.sendRequest(RPC_APPROVE_METHOD, params); } @@ -344,6 +379,10 @@ export class RpcServerManager implements DisposableLike { return this.sendRequest(RPC_CANCEL_METHOD, params); } + steer(params: SteerParams): Promise { + return this.sendRequest(RPC_STEER_METHOD, params); + } + previewFim(params: FimPreviewParams): Promise { return this.sendRequest(RPC_PREVIEW_FIM_METHOD, params); } @@ -468,8 +507,9 @@ export class RpcServerManager implements DisposableLike { } private dispatchAgentEvent(event: AgentEventEnvelope): void { + const eventToDispatch = this.markReplayEvent(event); for (const handler of this.eventHandlers) { - handler(event); + handler(eventToDispatch); } } @@ -486,14 +526,48 @@ export class RpcServerManager implements DisposableLike { return; } + if (pending.method === RPC_RESUME_METHOD && isResumeResult(message.result)) { + this.markReplayRange(message.result); + } pending.resolve(message.result); } + private markReplayRange(result: ResumeResult): void { + if (result.replayStarted && result.nextSeq > 0) { + this.replayUpperBounds.set(result.runId, result.nextSeq); + } + } + + private markReplayEvent(event: AgentEventEnvelope): AgentEventEnvelope { + const nextLiveSeq = this.replayUpperBounds.get(event.runId); + if (nextLiveSeq === undefined) { + return event; + } + + if (event.seq >= nextLiveSeq) { + this.replayUpperBounds.delete(event.runId); + return event; + } + + return { + ...event, + replay: true, + }; + } + private handleStderrData(chunk: Buffer | string): void { this.stderrTail = `${this.stderrTail}${chunk.toString()}`.slice(-4096); } - private handleExit(code: number | null, signal: NodeJS.Signals | null): void { + private handleExit( + exitedChild: RpcChildProcess, + code: number | null, + signal: NodeJS.Signals | null, + ): void { + if (this.child !== exitedChild) { + return; + } + const wasIntentional = this.intentionalStop; this.child = undefined; this.readyState = undefined; @@ -524,7 +598,11 @@ export class RpcServerManager implements DisposableLike { } } - private handleProcessError(error: Error): void { + private handleProcessError(processChild: RpcChildProcess, error: Error): void { + if (this.child !== processChild) { + return; + } + if (this.currentStatus === "starting") { this.failStarting(error); return; @@ -660,6 +738,15 @@ function isAgentEventBatchParams(value: unknown): value is { ); } +function isResumeResult(value: unknown): value is ResumeResult { + return ( + isRecord(value) && + typeof value["runId"] === "string" && + typeof value["nextSeq"] === "number" && + typeof value["replayStarted"] === "boolean" + ); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } diff --git a/vscode/extension/src/runHistory.ts b/vscode/extension/src/runHistory.ts index 677e6a8..6200b41 100644 --- a/vscode/extension/src/runHistory.ts +++ b/vscode/extension/src/runHistory.ts @@ -22,6 +22,11 @@ export interface ResumeRunMessage { readonly runId: string; } +export interface DeleteRunMessage { + readonly type: "deleteRun"; + readonly runId: string; +} + export function idleRunList(): RunListSnapshot { return { status: "idle", @@ -58,6 +63,16 @@ export function readyRunList( }; } +export function deletedRunList( + previous: RunListSnapshot, + deletedRunId: string, + message?: string, +): RunListSnapshot { + const runs = previous.runs.filter((run) => run.runId !== deletedRunId); + const selectedRunId = previous.selectedRunId === deletedRunId ? undefined : previous.selectedRunId; + return readyRunList({ runs }, selectedRunId, message); +} + export function failedRunList( message: string, previous: RunListSnapshot = idleRunList(), @@ -88,6 +103,20 @@ export function resumeRunIdFromMessage(message: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } +export function deleteRunIdFromMessage(message: unknown): string | undefined { + if (!isRecord(message) || message["type"] !== "deleteRun") { + return undefined; + } + + const runId = message["runId"]; + if (typeof runId !== "string") { + return undefined; + } + + const trimmed = runId.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } diff --git a/vscode/extension/src/webviewMarkdown.ts b/vscode/extension/src/webviewMarkdown.ts new file mode 100644 index 0000000..60d290f --- /dev/null +++ b/vscode/extension/src/webviewMarkdown.ts @@ -0,0 +1,411 @@ +export const WEBVIEW_MARKDOWN_RENDERER_SCRIPT = ` + function appendMarkdownBlocks(parent, markdown) { + for (const block of renderMarkdownBlocks(markdown)) { + parent.append(block); + } + } + + function renderMarkdownBlocks(markdown) { + const lines = markdown.replace(/\\r\\n?/g, "\\n").split("\\n"); + const blocks = []; + let index = 0; + + while (index < lines.length) { + const line = lines[index]; + if (line.trim().length === 0) { + index += 1; + continue; + } + + if (isFenceStart(line)) { + const rendered = renderMarkdownCodeFence(lines, index); + blocks.push(rendered.element); + index = rendered.nextIndex; + continue; + } + + if (isMarkdownTableAt(lines, index)) { + const rendered = renderMarkdownTable(lines, index); + blocks.push(rendered.element); + index = rendered.nextIndex; + continue; + } + + if (isMarkdownHorizontalRule(line)) { + blocks.push(document.createElement("hr")); + index += 1; + continue; + } + + const heading = line.match(/^(#{1,6})\\s+(.+)$/); + if (heading) { + const level = Math.min(6, Math.max(3, heading[1].length + 2)); + const element = document.createElement("h" + String(level)); + appendInlineMarkdown(element, heading[2].trim()); + blocks.push(element); + index += 1; + continue; + } + + const listKind = markdownListKind(line); + if (listKind) { + const rendered = renderMarkdownList(lines, index, listKind); + blocks.push(rendered.element); + index = rendered.nextIndex; + continue; + } + + if (/^\\s*>\\s?/.test(line)) { + const rendered = renderMarkdownBlockquote(lines, index); + blocks.push(rendered.element); + index = rendered.nextIndex; + continue; + } + + const rendered = renderMarkdownParagraph(lines, index); + blocks.push(rendered.element); + index = rendered.nextIndex; + } + + if (blocks.length === 0) { + const paragraph = document.createElement("p"); + paragraph.textContent = ""; + return [paragraph]; + } + return blocks; + } + + function renderMarkdownCodeFence(lines, startIndex) { + const fence = markdownFence(); + const firstLine = lines[startIndex].trim(); + const language = firstLine.slice(fence.length).trim(); + const codeLines = []; + let index = startIndex + 1; + while (index < lines.length && !isFenceStart(lines[index])) { + codeLines.push(lines[index]); + index += 1; + } + if (index < lines.length) { + index += 1; + } + + const pre = document.createElement("pre"); + const code = document.createElement("code"); + if (language.length > 0) { + code.setAttribute("data-language", language.slice(0, 32)); + } + code.textContent = codeLines.join("\\n"); + pre.append(code); + return { element: pre, nextIndex: index }; + } + + function renderMarkdownList(lines, startIndex, kind) { + const list = document.createElement(kind); + let index = startIndex; + while (index < lines.length && markdownListKind(lines[index]) === kind) { + const item = document.createElement("li"); + appendInlineMarkdown(item, markdownListText(lines[index])); + list.append(item); + index += 1; + } + return { element: list, nextIndex: index }; + } + + function renderMarkdownBlockquote(lines, startIndex) { + const quoteLines = []; + let index = startIndex; + while (index < lines.length && /^\\s*>\\s?/.test(lines[index])) { + quoteLines.push(lines[index].replace(/^\\s*>\\s?/, "")); + index += 1; + } + + const blockquote = document.createElement("blockquote"); + const paragraph = document.createElement("p"); + appendInlineMarkdown(paragraph, quoteLines.join(" ").trim()); + blockquote.append(paragraph); + return { element: blockquote, nextIndex: index }; + } + + function renderMarkdownParagraph(lines, startIndex) { + const paragraphLines = []; + let index = startIndex; + while (index < lines.length) { + const line = lines[index]; + if ( + line.trim().length === 0 || + isFenceStart(line) || + isMarkdownTableAt(lines, index) || + isMarkdownHorizontalRule(line) || + /^(#{1,6})\\s+/.test(line) || + markdownListKind(line) || + /^\\s*>\\s?/.test(line) + ) { + break; + } + paragraphLines.push(line.trim()); + index += 1; + } + + const paragraph = document.createElement("p"); + appendInlineMarkdown(paragraph, paragraphLines.join(" ")); + return { element: paragraph, nextIndex: index }; + } + + function renderMarkdownTable(lines, startIndex) { + const table = document.createElement("table"); + const thead = document.createElement("thead"); + const tbody = document.createElement("tbody"); + const header = document.createElement("tr"); + for (const cellText of splitMarkdownTableRow(lines[startIndex])) { + const cell = document.createElement("th"); + appendInlineMarkdown(cell, cellText); + header.append(cell); + } + thead.append(header); + + let index = startIndex + 2; + while (index < lines.length && lines[index].includes("|") && lines[index].trim().length > 0) { + const row = document.createElement("tr"); + for (const cellText of splitMarkdownTableRow(lines[index])) { + const cell = document.createElement("td"); + appendInlineMarkdown(cell, cellText); + row.append(cell); + } + tbody.append(row); + index += 1; + } + + table.append(thead, tbody); + return { element: table, nextIndex: index }; + } + + function appendInlineMarkdown(parent, text) { + const marker = markdownBacktick(); + let index = 0; + while (index < text.length) { + const codeStart = text.indexOf(marker, index); + if (codeStart < 0) { + appendInlineText(parent, text.slice(index)); + return; + } + if (codeStart > index) { + appendInlineText(parent, text.slice(index, codeStart)); + } + const codeEnd = text.indexOf(marker, codeStart + 1); + if (codeEnd < 0) { + appendInlineText(parent, text.slice(codeStart)); + return; + } + const code = document.createElement("code"); + code.textContent = text.slice(codeStart + 1, codeEnd); + parent.append(code); + index = codeEnd + 1; + } + } + + function appendInlineText(parent, text) { + let index = 0; + while (index < text.length) { + const token = findNextInlineToken(text, index); + if (!token) { + parent.append(document.createTextNode(text.slice(index))); + return; + } + if (token.start > index) { + parent.append(document.createTextNode(text.slice(index, token.start))); + } + if (token.type === "link") { + const link = document.createElement("a"); + link.href = token.href; + link.target = "_blank"; + link.rel = "noreferrer noopener"; + appendInlineText(link, token.label); + parent.append(link); + } else if (token.type === "strong") { + const strong = document.createElement("strong"); + appendInlineText(strong, token.text); + parent.append(strong); + } else if (token.type === "em") { + const emphasis = document.createElement("em"); + appendInlineText(emphasis, token.text); + parent.append(emphasis); + } + index = token.end; + } + } + + function findNextInlineToken(text, startIndex) { + const candidates = []; + const link = findNextMarkdownLink(text, startIndex); + if (link) { + candidates.push(link); + } + const strong = findNextDelimitedInline(text, startIndex, "**", "strong"); + if (strong) { + candidates.push(strong); + } + const emphasis = findNextEmphasis(text, startIndex); + if (emphasis) { + candidates.push(emphasis); + } + candidates.sort((left, right) => left.start - right.start); + return candidates[0]; + } + + function findNextMarkdownLink(text, startIndex) { + let searchIndex = startIndex; + while (searchIndex < text.length) { + const labelStart = text.indexOf("[", searchIndex); + if (labelStart < 0) { + return undefined; + } + const labelEnd = text.indexOf("]", labelStart + 1); + if (labelEnd < 0) { + return undefined; + } + if (text.charAt(labelEnd + 1) !== "(") { + searchIndex = labelEnd + 1; + continue; + } + const hrefEnd = text.indexOf(")", labelEnd + 2); + if (hrefEnd < 0) { + return undefined; + } + const href = safeMarkdownHref(text.slice(labelEnd + 2, hrefEnd).trim()); + if (href) { + return { + type: "link", + start: labelStart, + end: hrefEnd + 1, + label: text.slice(labelStart + 1, labelEnd), + href, + }; + } + searchIndex = hrefEnd + 1; + } + return undefined; + } + + function findNextDelimitedInline(text, startIndex, delimiter, type) { + const start = text.indexOf(delimiter, startIndex); + if (start < 0) { + return undefined; + } + const end = text.indexOf(delimiter, start + delimiter.length); + if (end < 0) { + return undefined; + } + return { + type, + start, + end: end + delimiter.length, + text: text.slice(start + delimiter.length, end), + }; + } + + function findNextEmphasis(text, startIndex) { + let start = findSingleAsterisk(text, startIndex); + while (start >= 0) { + const end = findSingleAsterisk(text, start + 1); + if (end >= 0) { + return { type: "em", start, end: end + 1, text: text.slice(start + 1, end) }; + } + start = findSingleAsterisk(text, start + 1); + } + return undefined; + } + + function findSingleAsterisk(text, startIndex) { + let index = text.indexOf("*", startIndex); + while (index >= 0) { + if (text.charAt(index - 1) !== "*" && text.charAt(index + 1) !== "*") { + return index; + } + index = text.indexOf("*", index + 1); + } + return -1; + } + + function safeMarkdownHref(rawHref) { + try { + const parsed = new URL(rawHref); + if (parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.protocol === "mailto:") { + return parsed.toString(); + } + } catch { + return undefined; + } + return undefined; + } + + function isFenceStart(line) { + return line.trim().startsWith(markdownFence()); + } + + function markdownFence() { + return markdownBacktick() + markdownBacktick() + markdownBacktick(); + } + + function markdownBacktick() { + return String.fromCharCode(96); + } + + function markdownBackslash() { + return String.fromCharCode(92); + } + + function isMarkdownHorizontalRule(line) { + return /^\\s*(?:-{3,}|\\*{3,}|_{3,})\\s*$/.test(line); + } + + function markdownListKind(line) { + if (/^\\s*[-*+]\\s+/.test(line)) { + return "ul"; + } + if (/^\\s*\\d+[.)]\\s+/.test(line)) { + return "ol"; + } + return undefined; + } + + function markdownListText(line) { + return line.replace(/^\\s*(?:[-*+]|\\d+[.)])\\s+/, "").trim(); + } + + function isMarkdownTableAt(lines, index) { + if (index + 1 >= lines.length || !lines[index].includes("|")) { + return false; + } + const separatorCells = splitMarkdownTableRow(lines[index + 1]); + return separatorCells.length > 0 && separatorCells.every((cell) => /^:?-{3,}:?$/.test(cell)); + } + + function splitMarkdownTableRow(line) { + let trimmed = line.trim(); + if (trimmed.startsWith("|")) { + trimmed = trimmed.slice(1); + } + if (trimmed.endsWith("|")) { + trimmed = trimmed.slice(0, -1); + } + + const cells = []; + let current = ""; + let escaped = false; + for (const char of trimmed) { + if (escaped) { + current += char; + escaped = false; + } else if (char === markdownBackslash()) { + escaped = true; + } else if (char === "|") { + cells.push(current.trim()); + current = ""; + } else { + current += char; + } + } + cells.push(current.trim()); + return cells; + } +`; diff --git a/vscode/extension/src/webviewSerialization.ts b/vscode/extension/src/webviewSerialization.ts new file mode 100644 index 0000000..6378864 --- /dev/null +++ b/vscode/extension/src/webviewSerialization.ts @@ -0,0 +1,4 @@ +export function safeScriptJson(value: unknown): string { + const serialized = JSON.stringify(value); + return (serialized === undefined ? "undefined" : serialized).replaceAll("<", "\\u003c"); +} diff --git a/vscode/extension/test/approvalFlow.test.ts b/vscode/extension/test/approvalFlow.test.ts index 980caae..a6c4427 100644 --- a/vscode/extension/test/approvalFlow.test.ts +++ b/vscode/extension/test/approvalFlow.test.ts @@ -131,6 +131,26 @@ test("approval controller treats the same approval id in different runs as disti ); }); +test("approval controller ignores replayed approval events from historical run resume", async () => { + const rpc = new FakeApprovalRpcClient(); + let promptCount = 0; + const controller = new ApprovalEventController(rpc, fakeWindow, fakeNotifier(), async (_window, request) => { + promptCount += 1; + return { + kind: "approve", + approvalId: request.approvalId, + persist: "never", + }; + }); + + rpc.emit({ ...approvalEvent(), replay: true }); + await controller.whenIdle(); + + assert.equal(promptCount, 0); + assert.deepEqual(rpc.approvals, []); + assert.deepEqual(rpc.rejections, []); +}); + test("approval controller prepares approval preview before prompting", async () => { const rpc = new FakeApprovalRpcClient(); const order: string[] = []; @@ -237,6 +257,7 @@ test("approvalPromptRequestFromEvent maps protocol payloads to modal requests", assert.deepEqual(request, { approvalId: "approval_1", + runId: "run_1", toolCallId: "tool_call_1", toolName: "shell", risk: "exec", @@ -251,6 +272,7 @@ test("approvalPromptRequestFromEvent maps protocol payloads to modal requests", { id: "README.md#1:old1+3:new1+3", filePath: "README.md", + fileIndex: 0, hunkIndex: 0, oldStart: 1, oldCount: 3, @@ -260,6 +282,7 @@ test("approvalPromptRequestFromEvent maps protocol payloads to modal requests", { id: "README.md#2:old5+2:new5+3", filePath: "README.md", + fileIndex: 0, hunkIndex: 1, oldStart: 5, oldCount: 2, @@ -272,6 +295,15 @@ test("approvalPromptRequestFromEvent maps protocol payloads to modal requests", }); }); +test("approvalPromptRequestFromEvent accepts model turn budget continuation approvals", () => { + const request = approvalPromptRequestFromEvent( + approvalEvent({ toolName: "model_turn_budget" }), + ); + + assert.equal(request?.toolName, "model_turn_budget"); + assert.equal(request?.approvalId, "approval_1"); +}); + const fakeWindow: ApprovalWindowMessenger = { showWarningMessage() { return undefined; diff --git a/vscode/extension/test/automaticContext.test.ts b/vscode/extension/test/automaticContext.test.ts index 382558a..d529a5e 100644 --- a/vscode/extension/test/automaticContext.test.ts +++ b/vscode/extension/test/automaticContext.test.ts @@ -61,9 +61,9 @@ test("automatic context can be built from sidebar timeline snapshots", () => { time: "1970-01-01T00:00:00.000Z", type: "turn.started", runId: "run_1", - kind: "turn", - tone: "running", - title: "Turn started", + kind: "user", + tone: "neutral", + title: "You", body: "Fix README", }, { @@ -75,7 +75,7 @@ test("automatic context can be built from sidebar timeline snapshots", () => { runId: "run_1", kind: "assistant", tone: "neutral", - title: "Assistant", + title: "DeepSeek", body: "README updated.", }, ], @@ -85,6 +85,43 @@ test("automatic context can be built from sidebar timeline snapshots", () => { assert.ok(attachment?.text?.includes("README updated.")); }); +test("automatic context skips superseded sidebar user messages", () => { + const attachment = automaticContextAttachmentFromTimeline({ + eventCount: 2, + latestRunId: "run_1", + supersededItemIds: ["run_1:1"], + items: [ + { + id: "run_1:1", + seq: 1, + lastSeq: 1, + time: "1970-01-01T00:00:00.000Z", + type: "turn.started", + runId: "run_1", + kind: "user", + tone: "neutral", + title: "You", + body: "Old request", + }, + { + id: "run_1:2", + seq: 2, + lastSeq: 2, + time: "1970-01-01T00:00:01.000Z", + type: "turn.started", + runId: "run_1", + kind: "user", + tone: "neutral", + title: "You", + body: "Edited request", + }, + ], + }); + + assert.ok(attachment?.text?.includes("Edited request")); + assert.equal(attachment?.text?.includes("Old request"), false); +}); + test("automatic context caps oversized sidebar timeline messages before compression", () => { const attachment = automaticContextAttachmentFromTimeline( { diff --git a/vscode/extension/test/chatApprovals.test.ts b/vscode/extension/test/chatApprovals.test.ts new file mode 100644 index 0000000..d26aca0 --- /dev/null +++ b/vscode/extension/test/chatApprovals.test.ts @@ -0,0 +1,249 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + approvalDecisionFromWebviewMessage, + chatApprovalSnapshotFromRequest, +} from "../src/chatApprovals.js"; +import { APPROVAL_REJECTED_REASON, type ApprovalPromptRequest } from "../src/commands.js"; + +test("chat approval snapshots expose request details without mutating hunks", () => { + const request = sampleApprovalRequest(); + + const snapshot = chatApprovalSnapshotFromRequest(request); + + assert.deepEqual(snapshot, request); + assert.notEqual(snapshot.hunks, request.hunks); + assert.deepEqual(snapshot.hunks, request.hunks); +}); + +test("chat approval snapshots truncate long display fields without mutating the request", () => { + const command = `Set-Content -Path script.py -Value '${"print(1)\\n".repeat(400)}'`; + const outputSummary = "previous output\n".repeat(300); + const request = sampleApprovalRequest({ command, outputSummary }, false); + + const snapshot = chatApprovalSnapshotFromRequest(request); + + assert.equal(request.command, command); + assert.equal(request.outputSummary, outputSummary); + assert.ok(snapshot.command); + assert.ok(snapshot.outputSummary); + assert.ok(snapshot.command.length < command.length); + assert.ok(snapshot.outputSummary.length < outputSummary.length); + assert.ok(snapshot.command.includes("[truncated for sidebar")); + assert.ok(snapshot.outputSummary.includes("[truncated for sidebar")); +}); + +test("chat approval snapshots omit duplicated shell command details", () => { + const command = `@'\nprint("hello")\n'@ | Set-Content -LiteralPath script.py`; + const request = sampleApprovalRequest({ + toolName: "shell", + title: "Run shell command", + detail: `Execute \`${command}\``, + command, + }); + + const snapshot = chatApprovalSnapshotFromRequest(request); + + assert.equal(snapshot.detail, ""); + assert.equal(snapshot.command, command); +}); + +test("chat approval messages map approve and reject decisions", () => { + const request = sampleApprovalRequest({}, false); + + assert.deepEqual( + approvalDecisionFromWebviewMessage( + { + type: "approvalDecision", + approvalId: "approval_1", + decision: "approve", + }, + request, + ), + { + kind: "approve", + approvalId: "approval_1", + persist: "never", + }, + ); + + assert.deepEqual( + approvalDecisionFromWebviewMessage( + { + type: "approvalDecision", + approvalId: "approval_1", + decision: "reject", + }, + request, + ), + { + kind: "reject", + approvalId: "approval_1", + reason: APPROVAL_REJECTED_REASON, + }, + ); +}); + +test("chat approval messages map conversation approval to session persistence", () => { + const request = sampleApprovalRequest({}, false); + + assert.deepEqual( + approvalDecisionFromWebviewMessage( + { + type: "approvalDecision", + approvalId: "approval_1", + decision: "approve", + persist: "conversation", + }, + request, + ), + { + kind: "approve", + approvalId: "approval_1", + persist: "session", + }, + ); + + assert.deepEqual( + approvalDecisionFromWebviewMessage( + { + type: "approvalDecision", + approvalId: "approval_1", + decision: "approve", + persist: "conversation", + }, + sampleApprovalRequest({ persistable: false }, false), + ), + { + kind: "approve", + approvalId: "approval_1", + persist: "never", + }, + ); +}); + +test("chat approval hunk selection returns partial hunk approvals", () => { + const request = sampleApprovalRequest(); + + assert.deepEqual( + approvalDecisionFromWebviewMessage( + { + type: "approvalDecision", + approvalId: "approval_1", + decision: "approve", + approvedHunks: ["hunk_2", "unknown", "hunk_2"], + }, + request, + ), + { + kind: "approve", + approvalId: "approval_1", + persist: "never", + hunks: { + approved: ["hunk_2"], + }, + }, + ); +}); + +test("chat approval hunk selection approves whole patch when all hunks are selected", () => { + const request = sampleApprovalRequest(); + + assert.deepEqual( + approvalDecisionFromWebviewMessage( + { + type: "approvalDecision", + approvalId: "approval_1", + decision: "approve", + approvedHunks: ["hunk_1", "hunk_2"], + }, + request, + ), + { + kind: "approve", + approvalId: "approval_1", + persist: "never", + }, + ); +}); + +test("chat approval ignores unrelated messages and rejects empty hunk approval", () => { + const request = sampleApprovalRequest(); + + assert.equal( + approvalDecisionFromWebviewMessage( + { + type: "approvalDecision", + approvalId: "other", + decision: "approve", + }, + request, + ), + undefined, + ); + assert.deepEqual( + approvalDecisionFromWebviewMessage( + { + type: "approvalDecision", + approvalId: "approval_1", + decision: "approve", + approvedHunks: [], + }, + request, + ), + { + kind: "reject", + approvalId: "approval_1", + reason: "no patch hunks selected in VS Code", + }, + ); +}); + +function sampleApprovalRequest( + overrides: Partial = {}, + includeHunks = true, +): ApprovalPromptRequest { + return { + approvalId: "approval_1", + toolCallId: "tool_call_1", + toolName: "apply_patch", + risk: "write", + title: "Apply patch", + detail: "Modify README.md", + persistable: true, + command: "apply patch", + cwd: ".", + outputSummary: "patch preview ready", + paths: ["README.md"], + riskReasons: ["modifies files"], + ...(includeHunks + ? { + hunks: [ + { + id: "hunk_1", + filePath: "README.md", + fileIndex: 0, + hunkIndex: 0, + oldStart: 1, + oldCount: 2, + newStart: 1, + newCount: 3, + }, + { + id: "hunk_2", + filePath: "README.md", + fileIndex: 0, + hunkIndex: 1, + oldStart: 8, + oldCount: 1, + newStart: 9, + newCount: 2, + section: "next block", + }, + ], + } + : {}), + ...overrides, + }; +} diff --git a/vscode/extension/test/chatEvents.test.ts b/vscode/extension/test/chatEvents.test.ts index 2e7313f..9c3792b 100644 --- a/vscode/extension/test/chatEvents.test.ts +++ b/vscode/extension/test/chatEvents.test.ts @@ -1,7 +1,13 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { ChatEventTimeline, createTimelineItem } from "../src/chatEvents.js"; +import { + ChatEventTimeline, + createTimelineItem, + isPersistentTimelineItem, + isWorkLogItem, + presentTimelineItems, +} from "../src/chatEvents.js"; import type { AgentEventEnvelope } from "../src/rpcServer.js"; test("chat timeline merges assistant delta events for the same turn", () => { @@ -31,6 +37,197 @@ test("chat timeline keeps assistant delta events for different turns separate", ); }); +test("chat timeline splits assistant messages across tool work and inserts compact activity summaries", () => { + const timeline = new ChatEventTimeline(); + + timeline.append(agentEvent(1, "turn.started", { userTask: "Fix the project" })); + timeline.append(agentEvent(2, "assistant.delta", { text: "I will inspect the code." })); + timeline.append(agentEvent(3, "tool.completed", { name: "read_file", status: "ok", summary: "Read README.md." })); + timeline.append( + agentEvent(4, "tool.completed", { + name: "shell", + status: "ok", + summary: "tests failed", + }), + ); + timeline.append( + agentEvent(5, "tool.completed", { + name: "apply_patch", + result: { + files: ["src/lib.rs", "README.md"], + }, + status: "ok", + summary: "applied patch", + }), + ); + const snapshot = timeline.append(agentEvent(6, "assistant.delta", { text: "Done." })); + + assert.deepEqual( + snapshot.items.map((item) => item.title), + ["You", "DeepSeek", "Tool completed: read_file", "Tool completed: shell", "Tool completed: apply_patch", "DeepSeek"], + ); + assert.deepEqual( + snapshot.visibleItems?.map((item) => item.title), + ["You", "DeepSeek", "Activity", "DeepSeek"], + ); + assert.equal(snapshot.visibleItems?.[2]?.body, "Modified 2 files, ran 1 command."); +}); + +test("chat timeline keeps steer messages before later assistant segments", () => { + const timeline = new ChatEventTimeline(); + + timeline.append(agentEvent(1, "assistant.delta", { text: "Working." })); + timeline.append(agentEvent(2, "tool.completed", { name: "shell", status: "ok", summary: "tests passed" })); + timeline.append(agentEvent(3, "turn.steered", { steerId: "steer_1", message: "Please summarize in Chinese." })); + const snapshot = timeline.append(agentEvent(4, "assistant.delta", { text: "好的。" })); + + assert.deepEqual( + snapshot.visibleItems?.map((item) => item.title), + ["DeepSeek", "Activity", "You", "DeepSeek"], + ); + assert.deepEqual( + snapshot.visibleItems?.map((item) => item.body), + ["Working.", "Modified 0 files, ran 1 command.", "Please summarize in Chinese.", "好的。"], + ); + assert.equal(snapshot.visibleItems?.[2]?.steerId, "steer_1"); +}); + +test("chat timeline groups completed intermediate assistant and activity items", () => { + const timeline = new ChatEventTimeline(); + + timeline.append(agentEvent(1, "turn.started", { userTask: "Fix everything" })); + timeline.append(agentEvent(2, "assistant.delta", { text: "I will inspect." })); + timeline.append(agentEvent(3, "tool.completed", { name: "shell", status: "ok", summary: "tests failed" })); + timeline.append(agentEvent(4, "turn.steered", { steerId: "steer_1", message: "Use Chinese." })); + timeline.append(agentEvent(5, "assistant.delta", { text: "I will continue." })); + timeline.append( + agentEvent(6, "tool.completed", { + name: "apply_patch", + result: { + files: ["src/lib.rs"], + }, + status: "ok", + summary: "patched", + }), + ); + timeline.append(agentEvent(7, "assistant.delta", { text: "最终总结。" })); + const snapshot = timeline.append(agentEvent(8, "run.completed", { summary: "最终总结。" })); + + assert.deepEqual( + snapshot.visibleItems?.map((item) => item.title), + ["You", "Earlier activity", "You", "Earlier activity", "DeepSeek"], + ); + assert.equal(snapshot.visibleItems?.[1]?.type, "run.intermediate"); + assert.equal(snapshot.visibleItems?.[1]?.defaultCollapsed, true); + assert.equal(snapshot.visibleItems?.[1]?.body, "Collapsed 2 timeline items.\nStarts with: I will inspect."); + assert.deepEqual( + snapshot.visibleItems?.[1]?.children?.map((item) => item.title), + ["DeepSeek", "Activity"], + ); + assert.equal(snapshot.visibleItems?.[2]?.body, "Use Chinese."); + assert.equal(snapshot.visibleItems?.[2]?.steerId, "steer_1"); + assert.deepEqual( + snapshot.visibleItems?.[3]?.children?.map((item) => item.body), + ["I will continue.", "Modified 1 file, ran 0 commands."], + ); + assert.equal(snapshot.visibleItems?.[4]?.defaultCollapsed, undefined); +}); + +test("chat timeline presents user and DeepSeek messages while folding work events", () => { + const timeline = new ChatEventTimeline(); + + timeline.append(agentEvent(1, "run.started", { mode: "ask" })); + timeline.append(agentEvent(2, "turn.started", { userTask: "Read the code" })); + timeline.append(agentEvent(3, "context.built", { inputTokens: 123 })); + timeline.append(agentEvent(4, "assistant.delta", { text: "I read it." })); + timeline.append(agentEvent(5, "tool.requested", { name: "read_file" })); + const snapshot = timeline.append(agentEvent(6, "run.completed", { summary: "I read it." })); + + assert.deepEqual( + snapshot.visibleItems?.map((item) => item.title), + ["You", "DeepSeek"], + ); + assert.deepEqual( + snapshot.workItems?.map((item) => item.type), + ["run.started", "context.built", "tool.requested", "run.completed"], + ); + assert.equal(snapshot.items.find((item) => item.type === "turn.started")?.kind, "user"); +}); + +test("chat timeline hides superseded user messages while retaining audit items", () => { + const timeline = new ChatEventTimeline(); + + timeline.append(agentEvent(1, "turn.started", { userTask: "old request" })); + const snapshot = timeline.append( + agentEvent( + 2, + "turn.started", + { + userTask: "edited request", + supersedes: { + messageId: "run_1:1", + turnId: "turn_1", + }, + }, + { turnId: "turn_2" }, + ), + ); + + assert.deepEqual( + snapshot.items.map((item) => item.body), + ["old request", "edited request"], + ); + assert.deepEqual(snapshot.supersededItemIds, ["run_1:1"]); + assert.deepEqual( + snapshot.visibleItems?.map((item) => item.body), + ["edited request"], + ); +}); + +test("chat timeline only applies supersedes metadata from turn started events", () => { + const timeline = new ChatEventTimeline(); + + timeline.append(agentEvent(1, "turn.started", { userTask: "old request" })); + const snapshot = timeline.append( + agentEvent(2, "provider.completed", { + supersedes: { + messageId: "run_1:1", + }, + }), + ); + + assert.equal(snapshot.supersededItemIds, undefined); + assert.deepEqual( + snapshot.visibleItems?.map((item) => item.body), + ["old request"], + ); +}); + +test("chat timeline keeps completed summary visible when no assistant message exists", () => { + const completed = createTimelineItem(agentEvent(1, "run.completed", { summary: "done" })); + const presentation = presentTimelineItems([completed]); + + assert.deepEqual( + presentation.visibleItems.map((item) => item.type), + ["run.completed"], + ); + assert.equal(presentation.workItems.length, 0); +}); + +test("chat timeline keeps failure and cancellation visible outside work log", () => { + const failure = createTimelineItem(agentEvent(1, "run.failed", { message: "boom" })); + const canceled = createTimelineItem(agentEvent(2, "run.canceled", { reason: "stop" })); + const presentation = presentTimelineItems([failure, canceled]); + + assert.equal(isWorkLogItem(failure, true), false); + assert.equal(isWorkLogItem(canceled, true), false); + assert.deepEqual( + presentation.visibleItems.map((item) => item.type), + ["run.failed", "run.canceled"], + ); + assert.equal(presentation.workItems.length, 0); +}); + test("chat timeline renders tool lifecycle and terminal events", () => { const timeline = new ChatEventTimeline(); @@ -61,10 +258,69 @@ test("chat timeline renders tool lifecycle and terminal events", () => { ["Tool requested: shell", "Tool started: shell", "Tool completed: shell", "Run completed"], ); assert.equal(snapshot.items[2]?.tone, "success"); + assert.equal(snapshot.items[2]?.defaultCollapsed, true); assert.equal(snapshot.items[3]?.kind, "terminal"); + assert.equal(snapshot.items[3]?.defaultCollapsed, undefined); assert.equal(snapshot.latestStatus, "Completed"); }); +test("chat timeline annotates work events with command and changed file counts", () => { + const shell = createTimelineItem( + agentEvent(1, "tool.completed", { + name: "shell", + status: "failed", + summary: "command failed", + }), + ); + const patch = createTimelineItem( + agentEvent(2, "tool.completed", { + name: "apply_patch", + result: { + files: ["src/lib.rs", "README.md"], + }, + status: "ok", + summary: "applied patch", + }), + ); + const failedPatch = createTimelineItem( + agentEvent(3, "tool.completed", { + name: "apply_patch", + result: { + files: ["ignored.md"], + }, + status: "failed", + summary: "patch failed", + }), + ); + assert.equal(shell.commandCount, 1); + assert.equal(shell.changedFileCount, undefined); + assert.equal(patch.commandCount, undefined); + assert.equal(patch.changedFileCount, 2); + assert.equal(failedPatch.changedFileCount, undefined); +}); + +test("chat timeline renders provider retry events as collapsed work log items", () => { + const item = createTimelineItem( + agentEvent(1, "provider.retrying", { + iteration: 4, + reason: "transient_stream_error", + timeoutMs: 60000, + retriesRemaining: 1, + partialContentChars: 128, + message: "connection reset", + }), + ); + + assert.equal(item.kind, "provider"); + assert.equal(item.tone, "warning"); + assert.equal(item.title, "Provider retrying"); + assert.equal(item.defaultCollapsed, true); + assert.ok(item.body?.includes("Timeout: 60000ms")); + assert.ok(item.body?.includes("Retries remaining: 1")); + assert.ok(item.body?.includes("Partial content: 128 chars")); + assert.equal(isWorkLogItem(item, true), true); +}); + test("chat timeline renders approval and failure events with warning or danger tones", () => { const approval = createTimelineItem( agentEvent(1, "tool.approvalRequired", { @@ -80,6 +336,7 @@ test("chat timeline renders approval and failure events with warning or danger t agentEvent(2, "run.failed", { code: "E_INVALID_TOOL_ARGUMENTS", message: "invalid tool call", + diagnosticFile: "C:\\workspace\\.prole-coder\\runs\\run_1\\diagnostics\\invalid-tool-arguments-call_1.json", }), ); @@ -90,33 +347,144 @@ test("chat timeline renders approval and failure events with warning or danger t assert.equal(failure.kind, "terminal"); assert.equal(failure.tone, "danger"); assert.ok(failure.body?.includes("invalid tool call")); + assert.ok(failure.body?.includes("Diagnostic file:")); +}); + +test("chat timeline truncates long approval commands for sidebar rendering", () => { + const command = `Set-Content -Path script.py -Value '${"print(1)\\n".repeat(400)}'`; + const item = createTimelineItem( + agentEvent(1, "tool.approvalRequired", { + toolName: "shell", + risk: "exec", + title: "Run shell command", + command, + }), + ); + + assert.ok(item.body); + assert.ok(item.body.length < command.length); + assert.ok(item.body.includes("[truncated for sidebar")); + assert.equal(item.body.includes("print(1)\\n".repeat(300)), false); +}); + +test("chat timeline trims old process items while preserving persistent messages", () => { + const timeline = new ChatEventTimeline({ maxItems: 1 }); + + timeline.append(agentEvent(1, "run.started", { mode: "ask" })); + timeline.append(agentEvent(2, "turn.started", { userTask: "hello" })); + timeline.append(agentEvent(3, "assistant.delta", { text: "working" })); + timeline.append(agentEvent(4, "context.built", { inputTokens: 123 })); + const snapshot = timeline.append(agentEvent(5, "run.completed", { summary: "done" })); + + assert.equal(snapshot.eventCount, 5); + assert.deepEqual( + snapshot.items.map((item) => item.seq), + [2, 3, 4, 5], + ); + assert.deepEqual( + snapshot.items.map((item) => isPersistentTimelineItem(item)), + [true, true, false, true], + ); + assert.equal(snapshot.hiddenProcessItemCount, 1); + assert.equal(snapshot.oldestLoadedSeq, 1); }); -test("chat timeline trims old items while preserving event count", () => { - const timeline = new ChatEventTimeline({ maxItems: 2 }); +test("chat timeline can reveal hidden process items from loaded run events", () => { + const timeline = new ChatEventTimeline({ maxItems: 1 }); timeline.append(agentEvent(1, "run.started", { mode: "ask" })); timeline.append(agentEvent(2, "turn.started", { userTask: "hello" })); - const snapshot = timeline.append(agentEvent(3, "run.completed", { summary: "done" })); + timeline.append(agentEvent(3, "assistant.delta", { text: "working" })); + timeline.append(agentEvent(4, "context.built", { inputTokens: 123 })); + timeline.append(agentEvent(5, "run.completed", { summary: "done" })); + const snapshot = timeline.revealHiddenProcessItems(); - assert.equal(snapshot.eventCount, 3); assert.deepEqual( snapshot.items.map((item) => item.seq), - [2, 3], + [1, 2, 3, 4, 5], ); + assert.equal(snapshot.hiddenProcessItemCount, undefined); }); -test("chat timeline rebuilds assistant indexes after trimming old items", () => { +test("chat timeline prepends older run log events before current items", () => { + const timeline = new ChatEventTimeline({ maxItems: 10 }); + + timeline.append(agentEvent(3, "assistant.delta", { text: "later" })); + timeline.append(agentEvent(4, "run.completed", { summary: "done" })); + const snapshot = timeline.prepend([ + agentEvent(1, "turn.started", { userTask: "hello" }), + agentEvent(2, "context.built", { inputTokens: 123 }), + ]); + + assert.deepEqual( + snapshot.items.map((item) => item.seq), + [1, 2, 3, 4], + ); + assert.equal(snapshot.oldestLoadedSeq, 1); +}); + +test("chat timeline keeps assistant segment boundaries after trimming process items", () => { const timeline = new ChatEventTimeline({ maxItems: 1 }); timeline.append(agentEvent(1, "assistant.delta", { text: "old" })); - timeline.append(agentEvent(2, "run.started", { mode: "ask" })); - const snapshot = timeline.append(agentEvent(3, "assistant.delta", { text: "new" })); + timeline.append(agentEvent(2, "tool.completed", { name: "shell", status: "ok", summary: "tests passed" })); + timeline.append(agentEvent(3, "context.built", { inputTokens: 123 })); + const snapshot = timeline.append(agentEvent(4, "assistant.delta", { text: "new" })); - assert.equal(snapshot.eventCount, 3); - assert.equal(snapshot.items.length, 1); - assert.equal(snapshot.items[0]?.seq, 3); - assert.equal(snapshot.items[0]?.body, "new"); + assert.equal(snapshot.eventCount, 4); + assert.deepEqual( + snapshot.items.map((item) => item.seq), + [1, 3, 4], + ); + assert.deepEqual( + snapshot.items.filter((item) => item.kind === "assistant").map((item) => item.body), + ["old", "new"], + ); +}); + +test("chat timeline keeps long run conversation boundaries after process trimming", () => { + const timeline = new ChatEventTimeline({ maxItems: 3 }); + + timeline.append(agentEvent(1, "turn.started", { userTask: "Initial request" })); + timeline.append(agentEvent(2, "assistant.delta", { text: "I will inspect." })); + for (let seq = 3; seq < 12; seq += 1) { + timeline.append(agentEvent(seq, "tool.completed", { name: "read_file", status: "ok", summary: `Read ${seq}.` })); + } + timeline.append(agentEvent(12, "turn.steered", { steerId: "steer_1", message: "Use Chinese." })); + timeline.append(agentEvent(13, "assistant.delta", { text: "I will continue." })); + timeline.append( + agentEvent(14, "tool.completed", { + name: "apply_patch", + result: { + files: ["src/lib.rs"], + }, + status: "ok", + summary: "patched", + }), + ); + timeline.append(agentEvent(15, "assistant.delta", { text: "Final summary." })); + const snapshot = timeline.append(agentEvent(16, "run.completed", { summary: "Final summary." })); + + assert.deepEqual( + snapshot.items.filter((item) => item.kind === "user").map((item) => item.body), + ["Initial request", "Use Chinese."], + ); + assert.deepEqual( + snapshot.visibleItems?.filter((item) => item.kind === "user").map((item) => item.body), + ["Initial request", "Use Chinese."], + ); + assert.deepEqual( + snapshot.visibleItems?.map((item) => item.title), + ["You", "Earlier activity", "You", "Earlier activity", "DeepSeek"], + ); + assert.deepEqual( + snapshot.visibleItems?.[1]?.children?.map((item) => item.title), + ["DeepSeek"], + ); + assert.deepEqual( + snapshot.visibleItems?.[3]?.children?.map((item) => item.title), + ["DeepSeek", "Activity"], + ); }); test("chat timeline renders raw unknown events with compact payloads", () => { @@ -128,6 +496,7 @@ test("chat timeline renders raw unknown events with compact payloads", () => { assert.equal(item.kind, "raw"); assert.equal(item.title, "custom.event"); + assert.equal(item.defaultCollapsed, true); assert.ok(item.body?.includes("unmapped")); }); diff --git a/vscode/extension/test/chatInput.test.ts b/vscode/extension/test/chatInput.test.ts index c51a0b1..d5d1d1e 100644 --- a/vscode/extension/test/chatInput.test.ts +++ b/vscode/extension/test/chatInput.test.ts @@ -4,6 +4,7 @@ import test from "node:test"; import { CHAT_RUN_MODES, DEFAULT_CHAT_MODE, + inferChatRunMode, isRpcRunMode, parseChatTurnSubmission, sendTurnParams, @@ -28,6 +29,71 @@ test("chat input accepts trimmed messages with valid run modes", () => { } }); +test("chat input infers run mode when the sidebar omits the hidden selector value", () => { + const samples: Array = [ + ["why does this fail?", "ask"], + ["is this task complete?", "ask"], + ["should I add more tests?", "ask"], + ["制定一个开发计划", "plan"], + ["修复测试失败", "edit"], + ["fix the bug where the app crashes", "edit"], + ["review the current changes", "review"], + ["继续处理这个任务", "edit"], + ]; + + for (const [message, mode] of samples) { + const parsed = parseChatTurnSubmission({ message }); + assert.equal(parsed.ok, true); + if (parsed.ok) { + assert.equal(parsed.value.mode, mode); + assert.equal(sendTurnParams(parsed.value).mode, mode); + } + } +}); + +test("chat input strips explicit run mode prefixes before sending", () => { + const slash = parseChatTurnSubmission({ message: " /ask explain the logs " }); + assert.equal(slash.ok, true); + if (slash.ok) { + assert.deepEqual(slash.value, { + message: "explain the logs", + mode: "ask", + }); + } + + const label = parseChatTurnSubmission({ message: "plan: split the backlog", mode: "edit" }); + assert.equal(label.ok, true); + if (label.ok) { + assert.deepEqual(label.value, { + message: "split the backlog", + mode: "plan", + }); + } +}); + +test("chat input forwards edit resend supersede metadata", () => { + const parsed = parseChatTurnSubmission({ + message: " updated request ", + mode: "edit", + supersedes: { + messageId: " run_1:2 ", + turnId: " turn_1 ", + }, + }); + + assert.equal(parsed.ok, true); + if (parsed.ok) { + assert.deepEqual(sendTurnParams(parsed.value), { + message: "updated request", + mode: "edit", + supersedes: { + messageId: "run_1:2", + turnId: "turn_1", + }, + }); + } +}); + test("chat input rejects empty messages and invalid modes", () => { assert.deepEqual(parseChatTurnSubmission({ message: " ", mode: "edit" }), { ok: false, @@ -37,6 +103,14 @@ test("chat input rejects empty messages and invalid modes", () => { ok: false, error: "Choose a valid run mode.", }); + assert.deepEqual(parseChatTurnSubmission({ message: "hello", mode: "edit", supersedes: {} }), { + ok: false, + error: "Invalid edit resend metadata.", + }); + assert.deepEqual(parseChatTurnSubmission({ message: "/review", mode: "edit" }), { + ok: false, + error: "Enter a message after the run mode prefix.", + }); }); test("chat input forwards diagnostic attachments into sendTurn params", () => { @@ -73,4 +147,6 @@ test("chat input exposes protocol run modes and default mode", () => { assert.deepEqual([...CHAT_RUN_MODES], ["edit", "ask", "plan", "review"]); assert.equal(isRpcRunMode("ask"), true); assert.equal(isRpcRunMode("debug"), false); + assert.equal(inferChatRunMode("Can you explain this file?"), "ask"); + assert.equal(inferChatRunMode("代码审查一下当前改动"), "review"); }); diff --git a/vscode/extension/test/chatParticipantCore.test.ts b/vscode/extension/test/chatParticipantCore.test.ts index 9d78d9b..1b06b43 100644 --- a/vscode/extension/test/chatParticipantCore.test.ts +++ b/vscode/extension/test/chatParticipantCore.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { SendTurnParams } from "@prole-coder/protocol" with { +import type { SendTurnParams, SendTurnResult } from "@prole-coder/protocol" with { "resolution-mode": "import", }; @@ -113,6 +113,59 @@ test("runChatParticipantTurn handles terminal events buffered before sendTurn re }); }); +test("runChatParticipantTurn preserves run failed recovery actions", async () => { + const rpc = new FailedRecoveryChatParticipantRpcClient(); + const response = new FakeChatResponseStream(); + const result = await withTimeout( + runChatParticipantTurn({ + rpcClient: rpc, + request: { + prompt: "hello", + }, + response, + }), + ); + + assert.equal(response.markdownParts.join(""), "\n\nMissing DeepSeek API key."); + assert.deepEqual(result.metadata?.["providerConfigurationAction"], { + type: "configureDeepSeekApiKey", + label: "Configure API Key", + }); +}); + +test("runChatParticipantTurn logs RPC failures before returning chat errors", async () => { + const rpc = new RejectingChatParticipantRpcClient( + new StructuredRpcRequestError( + "DeepSeek provider configuration failed: DEEPSEEK_API_KEY is required", + { + provider: "deepseek", + configurationError: "missingApiKey", + recoverableAction: { + kind: "configureDeepSeekApiKey", + label: "Configure API Key", + }, + }, + ), + ); + const response = new FakeChatResponseStream(); + const logger = new FakeLogger(); + const result = await runChatParticipantTurn({ + rpcClient: rpc, + request: { + prompt: "hello", + }, + response, + logger, + }); + + assert.ok(result.errorDetails?.message.includes("DEEPSEEK_API_KEY is required")); + assert.deepEqual(result.metadata?.["providerConfigurationAction"], { + type: "configureDeepSeekApiKey", + label: "Configure API Key", + }); + assert.deepEqual(logger.errors, [result.errorDetails?.message]); +}); + test("runChatParticipantTurn returns chat errors without an RPC client", async () => { const response = new FakeChatResponseStream(); const result = await runChatParticipantTurn({ @@ -168,7 +221,7 @@ class FakeChatParticipantRpcClient implements ChatParticipantRpcClient { } class EarlyTerminalChatParticipantRpcClient extends FakeChatParticipantRpcClient { - override async sendTurn(params: SendTurnParams) { + override async sendTurn(params: SendTurnParams): Promise { this.sendTurns.push(params); this.emit(agentEvent(1, "assistant.delta", { text: "early" })); this.emit(agentEvent(2, "run.completed", { summary: "done" })); @@ -180,6 +233,45 @@ class EarlyTerminalChatParticipantRpcClient extends FakeChatParticipantRpcClient } } +class FailedRecoveryChatParticipantRpcClient extends FakeChatParticipantRpcClient { + override async sendTurn(params: SendTurnParams): Promise { + this.sendTurns.push(params); + queueMicrotask(() => { + this.emit(agentEvent(1, "run.failed", { + code: "E_PROVIDER_ERROR", + message: "Missing DeepSeek API key.", + recoverableAction: { + kind: "configureDeepSeekApiKey", + label: "Configure API Key", + }, + })); + }); + return { + runId: "run_chat_1", + turnId: "turn_chat_1", + accepted: true as const, + }; + } +} + +class RejectingChatParticipantRpcClient extends FakeChatParticipantRpcClient { + constructor(private readonly error: Error) { + super(); + } + + override async sendTurn(params: SendTurnParams): Promise { + this.sendTurns.push(params); + throw this.error; + } +} + +class StructuredRpcRequestError extends Error { + constructor(message: string, readonly data: unknown) { + super(message); + this.name = "RpcRequestError"; + } +} + class FakeChatResponseStream implements ChatParticipantResponseStream { readonly markdownParts: string[] = []; readonly progressParts: string[] = []; @@ -193,6 +285,24 @@ class FakeChatResponseStream implements ChatParticipantResponseStream { } } +class FakeLogger { + readonly errors: string[] = []; + readonly infos: string[] = []; + readonly warnings: string[] = []; + + error(message: string): void { + this.errors.push(message); + } + + info(message: string): void { + this.infos.push(message); + } + + warn(message: string): void { + this.warnings.push(message); + } +} + function agentEvent(seq: number, type: string, payload: unknown): AgentEventEnvelope { return { seq, diff --git a/vscode/extension/test/commands.test.ts b/vscode/extension/test/commands.test.ts index e3d89d5..7e48b55 100644 --- a/vscode/extension/test/commands.test.ts +++ b/vscode/extension/test/commands.test.ts @@ -286,7 +286,7 @@ test("requestApproval maps the simple approve choice to one-shot approval", asyn assert.equal(modal, true); assert.ok(message?.includes("Command: cargo test")); assert.ok(message?.includes("Cwd: crates/cli")); - assert.ok(message?.includes("Output: last run passed")); + assert.equal(message?.includes("Output: last run passed"), false); assert.deepEqual(items, [APPROVAL_APPROVE_LABEL, APPROVAL_REJECT_LABEL]); }); @@ -350,6 +350,7 @@ test("requestApproval maps selected patch hunks to one-shot approve params", asy { id: "README.md#1:old1+3:new1+3", filePath: "README.md", + fileIndex: 0, hunkIndex: 0, oldStart: 1, oldCount: 3, @@ -359,6 +360,7 @@ test("requestApproval maps selected patch hunks to one-shot approve params", asy { id: "README.md#2:old5+2:new5+3", filePath: "README.md", + fileIndex: 0, hunkIndex: 1, oldStart: 5, oldCount: 2, diff --git a/vscode/extension/test/electron/index.ts b/vscode/extension/test/electron/index.ts index a394f66..13ddb57 100644 --- a/vscode/extension/test/electron/index.ts +++ b/vscode/extension/test/electron/index.ts @@ -6,6 +6,19 @@ import * as vscode from "vscode"; const extensionId = "prole-coder.prole-coder-vscode"; const TEST_CHAT_MESSAGE_COMMAND = "prole-coder.test.chatMessage"; const TEST_CHAT_STATE_COMMAND = "prole-coder.test.chatState"; +const TEST_CHAT_PROBE_COMMAND = "prole-coder.test.chatProbe"; +const SEND_LABEL = "Send message"; +const STOP_LABEL = "Stop current turn"; +const CONTRIBUTED_COMMANDS = [ + "prole-coder.openChat", + "prole-coder.openSettings", + "prole-coder.configureDeepSeekApiKey", + "prole-coder.clearDeepSeekApiKey", + "prole-coder.showProviderStatus", + "prole-coder.selectDeepSeekModel", + "prole-coder.generateCommitMessage", + "prole-coder.generatePrDescription", +] as const; export async function run(): Promise { const extension = vscode.extensions.getExtension(extensionId); @@ -21,14 +34,18 @@ export async function run(): Promise { await vscode.commands.executeCommand("prole-coder.chat.focus"); const commands = await vscode.commands.getCommands(true); - assert.equal(commands.includes("prole-coder.openChat"), true); + for (const command of CONTRIBUTED_COMMANDS) { + assert.equal(commands.includes(command), true); + } assert.equal(commands.includes(TEST_CHAT_MESSAGE_COMMAND), true); assert.equal(commands.includes(TEST_CHAT_STATE_COMMAND), true); + assert.equal(commands.includes(TEST_CHAT_PROBE_COMMAND), true); await vscode.commands.executeCommand("prole-coder.openChat"); await exerciseChatSendTurnDiagnosticsAndApproval(); await exerciseChatCancel(); await exerciseRunListAndResume(); + await exerciseChatKeyboardSubmit(); } async function exerciseChatSendTurnDiagnosticsAndApproval(): Promise { @@ -52,17 +69,55 @@ async function exerciseChatSendTurnDiagnosticsAndApproval(): Promise { mode: "edit", }); + const approvalReady = await waitFor("inline approval card in chat webview", async () => { + const current = await chatState(); + return current.approval?.approvalId === "approval-run-approval-1-turn-1" && + current.submission.runId === "run-approval-1" + ? current + : undefined; + }); + assert.equal(approvalReady.context.status, "ready"); + assert.equal(approvalReady.timeline.latestRunId, "run-approval-1"); + assert.ok(approvalReady.timeline.items.some((item) => item.type === "tool.approvalRequired")); + + const approvalProbe = await chatProbeSnapshot(); + assert.equal(approvalProbe.conversationActive, true); + assert.equal(approvalProbe.runsHidden, true); + assert.equal(approvalProbe.contextHidden, true); + assert.equal(approvalProbe.approvalVisible, true); + assert.equal(approvalProbe.workLogVisible, true); + assert.equal(approvalProbe.workLogOpen, false); + assert.match(approvalProbe.workLogTitle, /^Working: /); + assert.deepEqual(approvalProbe.providerActions, [ + "Configure DeepSeek API key", + "Select DeepSeek model", + ]); + assert.equal(approvalProbe.settingsActionVisible, true); + assert.ok(approvalProbe.visibleItemTitles.includes("You")); + assert.ok(!approvalProbe.visibleItemTitles.includes("Approval required: shell")); + + await chatProbe({ type: "click", selector: ".approval-action.approve" }); + const state = await waitFor("completed approval-backed chat turn", async () => { const current = await chatState(); return current.submission.status === "completed" && - current.submission.runId === "run-approval-1" + current.submission.runId === "run-approval-1" && + current.timeline.items.some((item) => item.type === "tool.approvalResolved") ? current : undefined; }); assert.equal(state.context.status, "ready"); - assert.equal(state.timeline.latestRunId, "run-approval-1"); - assert.ok(state.timeline.items.some((item) => item.type === "tool.approvalRequired")); assert.ok(state.timeline.items.some((item) => item.type === "tool.approvalResolved")); + const completedProbe = await chatProbeSnapshot(); + assert.equal(completedProbe.approvalVisible, false); + assert.equal(completedProbe.workLogVisible, false); + assert.equal(completedProbe.workLogOpen, false); + assert.ok(completedProbe.visibleItemTitles.includes("You")); + assert.ok(completedProbe.visibleItemTitles.includes("DeepSeek")); + assert.ok(completedProbe.markdownBlockCount >= 2); + assert.ok(completedProbe.markdownCodeBlocks.includes("ok")); + assert.ok(completedProbe.markdownLinks.includes("https://example.com/docs")); + assert.ok(completedProbe.markdownTables >= 1); const sendTurn = await waitFor("logged sendTurn with diagnostics", async () => logEntry((entry) => entry.method === "agent.sendTurn" && entry.params?.message === "integration approval flow"), @@ -79,7 +134,7 @@ async function exerciseChatSendTurnDiagnosticsAndApproval(): Promise { ); const approve = await waitFor("logged approval response", async () => - logEntry((entry) => entry.method === "agent.approve" && entry.params?.approvalId === "approval-approval-1"), + logEntry((entry) => entry.method === "agent.approve" && entry.params?.approvalId === "approval-run-approval-1-turn-1"), ); assert.equal(approve.params?.persist, "never"); } finally { @@ -89,27 +144,25 @@ async function exerciseChatSendTurnDiagnosticsAndApproval(): Promise { async function exerciseChatCancel(): Promise { await postChatMessage({ - type: "submitTurn", - message: "integration cancel flow", - mode: "edit", + type: "showRuns", }); - const running = await waitFor("running cancelable chat turn", async () => { - const current = await chatState(); - return current.submission.status === "running" && current.submission.runId === "run-cancel-1" - ? current - : undefined; - }); - assert.equal(running.submission.busy, true); - - await postChatMessage({ - type: "cancelTurn", - runId: "run-cancel-1", - }); + await chatProbe({ type: "setPrompt", value: "integration cancel flow" }); + const sendingProbe = await chatProbe({ type: "click", selector: "#send" }); + assert.equal(sendingProbe.snapshot.sendLabel, STOP_LABEL); + assert.equal(sendingProbe.snapshot.sendTitle, STOP_LABEL); + assert.equal(sendingProbe.snapshot.sendIsStop, true); + assert.deepEqual(sendingProbe.snapshot.sendVisibleIcons, ["stop"]); + assert.equal(sendingProbe.snapshot.sendDisabled, false); + assert.equal(sendingProbe.snapshot.modeHidden, true); + assert.equal(sendingProbe.snapshot.cancelDisabled, true); + await chatProbe({ type: "click", selector: "#send" }); const canceled = await waitFor("canceled chat turn", async () => { const current = await chatState(); - return current.submission.status === "canceled" && current.submission.runId === "run-cancel-1" + return current.submission.status === "canceled" && + current.submission.runId === "run-cancel-1" && + current.timeline.items.some((item) => item.type === "run.canceled") ? current : undefined; }); @@ -150,10 +203,194 @@ async function exerciseRunListAndResume(): Promise { : undefined; }); assert.equal(resumed.submission.status, "idle"); + const resumedProbe = await chatProbeSnapshot(); + assert.equal(resumedProbe.conversationActive, true); + assert.ok(resumedProbe.visibleItemTitles.includes("DeepSeek")); + assert.ok(resumedProbe.markdownTables >= 1); + assert.ok(resumedProbe.markdownHorizontalRules >= 1); + assert.ok(resumedProbe.markdownInlineCodes.includes("parseAmount")); + assert.ok(resumedProbe.markdownInlineCodes.includes("src/ledger.js")); + assert.ok(resumedProbe.markdownText.includes("代码库检查总结")); await waitFor("logged resume request", async () => logEntry((entry) => entry.method === "agent.resume" && entry.params?.runId === "run-history-1"), ); + + await postChatMessage({ + type: "submitTurn", + message: "integration resume follow up", + mode: "edit", + }); + + await waitFor("inline approval for resumed run follow-up", async () => { + const current = await chatState(); + return current.approval?.approvalId === "approval-run-history-1-turn-3" ? current : undefined; + }); + await chatProbe({ type: "click", selector: ".approval-action.approve" }); + + const continued = await waitFor("completed resumed run follow-up turn", async () => { + const current = await chatState(); + return current.submission.status === "completed" && + current.submission.runId === "run-history-1" && + current.timeline.items.some((item) => item.turnId === "turn-3" && item.type === "run.completed") + ? current + : undefined; + }); + assert.equal(continued.runs.selectedRunId, "run-history-1"); + + const resumedSendTurn = await waitFor("logged resumed sendTurn with runId", async () => + logEntry( + (entry) => + entry.method === "agent.sendTurn" && + entry.params?.message === "integration resume follow up" && + entry.params?.runId === "run-history-1", + ), + ); + assert.equal(resumedSendTurn.params?.mode, "edit"); + + await chatProbe({ type: "click", selector: "#show-runs" }); + const runsProbe = await waitFor("runs visible after conversation back", async () => { + const snapshot = await chatProbeSnapshot(); + return snapshot.conversationActive === false && snapshot.runIds.includes("run-history-1") + ? snapshot + : undefined; + }); + assert.equal(runsProbe.runsHidden, false); + + await chatProbe({ type: "click", selector: '.run-entry-row[data-run-id="run-history-1"] .run-delete' }); + const deleteProbe = await chatProbeSnapshot(); + assert.equal(deleteProbe.runDeleteConfirmVisible, true); + + await chatProbe({ type: "click", selector: '.run-entry-row[data-run-id="run-history-1"] .confirm-delete' }); + + const deleted = await waitFor("run deleted through inline webview confirmation", async () => { + const current = await chatState(); + return current.runs.status === "ready" && current.runs.runs.every((run) => run.runId !== "run-history-1") + ? current + : undefined; + }); + assert.equal(deleted.runs.selectedRunId, undefined); + + await waitFor("logged delete run request", async () => + logEntry((entry) => entry.method === "agent.deleteRun" && entry.params?.runId === "run-history-1"), + ); +} + +async function exerciseChatKeyboardSubmit(): Promise { + await postChatMessage({ + type: "showRuns", + }); + await chatProbe({ type: "setPrompt", value: "keyboard integration turn" }); + + const shifted = await chatProbe({ type: "keydown", key: "Enter", shiftKey: true }); + assert.equal(shifted.defaultPrevented, false); + + const composing = await chatProbe({ type: "keydown", key: "Enter", isComposing: true }); + assert.equal(composing.defaultPrevented, true); + assert.equal(composing.snapshot.promptValue, "keyboard integration turn"); + + const submitted = await chatProbe({ type: "keydown", key: "Enter" }); + assert.equal(submitted.defaultPrevented, true); + assert.equal(submitted.snapshot.sendLabel, STOP_LABEL); + assert.deepEqual(submitted.snapshot.sendVisibleIcons, ["stop"]); + assert.equal(submitted.snapshot.sendDisabled, false); + assert.equal(submitted.snapshot.modeHidden, true); + + const sentTurn = await waitFor("logged keyboard sendTurn", async () => + logEntry((entry) => entry.method === "agent.sendTurn" && entry.params?.message === "keyboard integration turn"), + ); + assert.equal(sentTurn.params?.message, "keyboard integration turn"); + const turnStarted = await waitFor("logged keyboard turn start", async () => + logEntry( + (entry) => + entry.kind === "event" && + entry.event?.type === "turn.started" && + entry.event.payload?.userTask === "keyboard integration turn", + ), + ); + const sentRunId = turnStarted.event?.runId; + const sentTurnId = turnStarted.event?.turnId; + assert.ok(typeof sentRunId === "string"); + assert.ok(typeof sentTurnId === "string"); + const approvalRequired = await waitFor("logged keyboard approval request", async () => + logEntry( + (entry) => + entry.kind === "event" && + entry.event?.type === "tool.approvalRequired" && + entry.event.runId === sentRunId && + entry.event.turnId === sentTurnId && + typeof entry.event.payload?.approvalId === "string", + ), + ); + const expectedApprovalId = approvalRequired.event?.payload?.approvalId; + assert.ok(typeof expectedApprovalId === "string"); + + await waitFor("keyboard-submitted turn waiting for approval", async () => { + const current = await chatState(); + return current.approval?.approvalId === expectedApprovalId ? current : undefined; + }); + await chatProbe({ type: "click", selector: ".approval-action.approve" }); + + await waitFor("keyboard-submitted turn completed", async () => { + const current = await chatState(); + return current.submission.status === "completed" && + current.timeline.items.some((item) => item.type === "run.completed") + ? current + : undefined; + }); + + await waitFor("logged keyboard turn approval", async () => + logEntry((entry) => entry.method === "agent.approve" && entry.params?.approvalId === expectedApprovalId), + ); + + const editable = await chatProbeSnapshot(); + assert.equal(editable.sendLabel, SEND_LABEL); + assert.equal(editable.sendTitle, SEND_LABEL); + assert.equal(editable.sendIsStop, false); + assert.deepEqual(editable.sendVisibleIcons, ["submit"]); + assert.equal(editable.modeHidden, true); + assert.ok(editable.messageEditLabels.includes("Edit and resend message")); + assert.ok(editable.messageEditDisabled.every((disabled) => disabled === false)); + + const editProbe = await chatProbe({ type: "click", selector: ".item.user .message-edit" }); + assert.equal(editProbe.snapshot.promptValue, "keyboard integration turn"); + + await chatProbe({ type: "setPrompt", value: "keyboard integration turn edited" }); + await chatProbe({ type: "click", selector: "#send" }); + + const editedSendTurn = await waitFor("logged edited resend", async () => + logEntry((entry) => entry.method === "agent.sendTurn" && entry.params?.message === "keyboard integration turn edited"), + ); + assert.equal(editedSendTurn.params?.runId, sentRunId); + assert.equal(editedSendTurn.params?.supersedes?.turnId, sentTurnId); + assert.match(String(editedSendTurn.params?.supersedes?.messageId), new RegExp(`^${sentRunId}:`)); + + const editedApproval = await waitFor("inline approval for edited resend", async () => { + const current = await chatState(); + return current.approval?.approvalId !== undefined && current.approval.approvalId !== expectedApprovalId + ? current + : undefined; + }); + const editedApprovalId = editedApproval.approval?.approvalId; + assert.ok(typeof editedApprovalId === "string"); + + await chatProbe({ type: "click", selector: ".approval-action.approve" }); + + await waitFor("edited resend completed", async () => { + const current = await chatState(); + return current.submission.status === "completed" && + current.timeline.items.some((item) => item.type === "run.completed" && item.turnId !== sentTurnId) + ? current + : undefined; + }); + + await waitFor("logged edited turn approval", async () => + logEntry((entry) => entry.method === "agent.approve" && entry.params?.approvalId === editedApprovalId), + ); + const editedProbe = await chatProbeSnapshot(); + assert.ok(editedProbe.userMessages.includes("keyboard integration turn edited")); + assert.ok(!editedProbe.userMessages.includes("keyboard integration turn")); + assert.ok(editedProbe.supersededUserItemIds.some((id) => id.includes(sentRunId))); } async function postChatMessage(message: unknown): Promise { @@ -164,6 +401,15 @@ async function chatState(): Promise { return await vscode.commands.executeCommand(TEST_CHAT_STATE_COMMAND); } +async function chatProbe(action: Record): Promise { + return await vscode.commands.executeCommand(TEST_CHAT_PROBE_COMMAND, action); +} + +async function chatProbeSnapshot(): Promise { + const result = await chatProbe({ type: "snapshot" }); + return result.snapshot; +} + async function waitFor( label: string, predicate: () => Promise, @@ -248,6 +494,7 @@ interface ChatState { readonly latestRunId?: string; readonly items: ReadonlyArray<{ readonly type: string; + readonly turnId?: string; }>; }; readonly submission: { @@ -265,9 +512,13 @@ interface ChatState { readonly context: { readonly status: string; }; + readonly approval?: { + readonly approvalId: string; + }; } interface RpcFixtureLogEntry { + readonly kind?: string; readonly method?: string; readonly params?: { readonly message?: string; @@ -277,5 +528,66 @@ interface RpcFixtureLogEntry { readonly runId?: string; readonly reason?: string; readonly attachments?: ReadonlyArray>; + readonly supersedes?: { + readonly messageId?: string; + readonly turnId?: string; + }; }; + readonly event?: { + readonly type?: string; + readonly runId?: string; + readonly turnId?: string; + readonly payload?: { + readonly approvalId?: string; + readonly userTask?: string; + }; + }; +} + +interface WebviewProbeSnapshotResult { + readonly clicked?: boolean; + readonly defaultPrevented?: boolean; + readonly value?: string; + readonly snapshot: WebviewProbeSnapshot; +} + +interface WebviewProbeSnapshot { + readonly conversationActive: boolean; + readonly statusHidden: boolean; + readonly runsHidden: boolean; + readonly contextHidden: boolean; + readonly approvalVisible: boolean; + readonly approvalTitle: string; + readonly approvalActionLabels: readonly string[]; + readonly runDeleteConfirmVisible: boolean; + readonly runIds: readonly string[]; + readonly visibleItemTitles: readonly string[]; + readonly visibleItemTypes: readonly string[]; + readonly userMessages: readonly string[]; + readonly markdownBlockCount: number; + readonly markdownCodeBlocks: readonly string[]; + readonly markdownInlineCodes: readonly string[]; + readonly markdownLinks: readonly string[]; + readonly markdownTables: number; + readonly markdownHorizontalRules: number; + readonly markdownText: string; + readonly workLogVisible: boolean; + readonly workLogOpen: boolean; + readonly workLogTitle: string; + readonly workLogTypes: readonly string[]; + readonly workLogSegmentSummaries?: readonly string[]; + readonly promptValue: string; + readonly sendLabel: string; + readonly sendTitle: string; + readonly sendIsStop: boolean; + readonly sendVisibleIcons: readonly string[]; + readonly sendDisabled: boolean; + readonly modeHidden: boolean; + readonly cancelDisabled: boolean; + readonly messageEditButtons: readonly string[]; + readonly messageEditLabels: readonly string[]; + readonly messageEditDisabled: readonly boolean[]; + readonly supersededUserItemIds: readonly string[]; + readonly providerActions: readonly string[]; + readonly settingsActionVisible: boolean; } diff --git a/vscode/extension/test/fixtures/rpcFixtureServer.mjs b/vscode/extension/test/fixtures/rpcFixtureServer.mjs index 5e2d992..84ab2f5 100644 --- a/vscode/extension/test/fixtures/rpcFixtureServer.mjs +++ b/vscode/extension/test/fixtures/rpcFixtureServer.mjs @@ -14,6 +14,31 @@ fs.writeFileSync(logPath, ""); let seq = 1; const pendingApprovals = new Map(); const runs = new Map(); +const runTurnCounters = new Map(); + +const ledgerHistorySummary = [ + "## 代码库检查总结", + "", + "---", + "", + "### 项目概览", + "**Ledger Lite** — 个人财务辅助工具,位于 `src/ledger.js`,由 `test/ledger.test.js` 提供测试覆盖。", + "", + "### 发现的 5 个缺陷", + "", + "| # | 函数 | 问题 |", + "|---|---|---|", + "| 1 | `parseAmount` | 未处理逗号分隔符(如 `\"$1,234.50\"`) |", + "| 2 | `parseAmount` | 未处理会计括号负号(如 `\"($42.10)\"` → `-42.10`) |", + "| 3 | `parseAmount` | 无效输入应抛出 `TypeError`,当前静默返回 `NaN` |", + "| 4 | `summarizeByCategory` | 未忽略 voided 交易、未归一化类别大小写/空格、金额未四舍五入到分 |", + "| 5 | `topCategories` | 排序应按绝对值降序 + 类别名升序,当前仅按原始值降序 |", + "", + "---", + "", + "### 现状", + "尚未修改任何文件。测试当前**无法通过**。所有修改将集中在 `src/ledger.js`,API 签名保持不变。准备好修复时请告知。", +].join("\n"); const historyRun = { runId: "run-history-1", @@ -25,7 +50,7 @@ const historyRun = { lastSeq: 2, eventCount: 2, mode: "edit", - summary: "Historical fixture summary", + summary: ledgerHistorySummary, changedFiles: ["fixture/history.txt"], verificationStatus: "passed", }; @@ -84,6 +109,9 @@ function handleRequest(request) { case "agent.resume": handleResume(request); return; + case "agent.deleteRun": + handleDeleteRun(request); + return; default: respondError(request.id, -32601, `Unknown method: ${request.method}`); } @@ -92,14 +120,19 @@ function handleRequest(request) { function handleSendTurn(request) { const params = record(request.params); const message = typeof params?.message === "string" ? params.message : ""; - const runId = message.includes("cancel") ? "run-cancel-1" : "run-approval-1"; - const turnId = message.includes("cancel") ? "turn-cancel-1" : "turn-approval-1"; + const runId = typeof params?.runId === "string" && params.runId.length > 0 + ? params.runId + : message.includes("cancel") ? "run-cancel-1" : "run-approval-1"; + const existing = runs.get(runId); + const turnIndex = nextTurnIndex(runId, existing); + const turnId = message.includes("cancel") ? "turn-cancel-1" : `turn-${turnIndex}`; const now = new Date().toISOString(); runs.set(runId, { + ...(existing ?? {}), runId, title: message || "Untitled fixture run", status: "running", - startedAt: now, + startedAt: existing?.startedAt ?? now, updatedAt: now, lastSeq: seq, eventCount: 0, @@ -119,6 +152,7 @@ function handleSendTurn(request) { }); emitEvent(runId, turnId, "turn.started", { userTask: message, + ...(record(params?.supersedes) === undefined ? {} : { supersedes: params.supersedes }), }); emitContextBuilt(runId, turnId); @@ -127,8 +161,8 @@ function handleSendTurn(request) { return; } - const approvalId = "approval-approval-1"; - const toolCallId = "tool-approval-1"; + const approvalId = `approval-${encodeIdSegment(runId)}-${encodeIdSegment(turnId)}`; + const toolCallId = `tool-${encodeIdSegment(runId)}-${encodeIdSegment(turnId)}`; pendingApprovals.set(approvalId, { runId, turnId, toolCallId }); emitEvent(runId, turnId, "tool.approvalRequired", { approvalId, @@ -148,6 +182,30 @@ function handleSendTurn(request) { }, 10); } +function nextTurnIndex(runId, existing) { + const current = runTurnCounters.get(runId); + const baseline = Number.isInteger(current) + ? current + : Number.isInteger(existing?.eventCount) ? existing.eventCount : 0; + const next = baseline + 1; + runTurnCounters.set(runId, next); + return next; +} + +function handleDeleteRun(request) { + const runId = request.params?.runId; + if (!runs.has(runId)) { + respondError(request.id, -32003, `Run not found: ${runId}`); + return; + } + + runs.delete(runId); + respond(request.id, { + runId, + deleted: true, + }); +} + function handleApprove(request) { const approvalId = request.params?.approvalId; respond(request.id, { @@ -180,7 +238,22 @@ function handleApprove(request) { }, }); emitEvent(pending.runId, pending.turnId, "assistant.delta", { - text: "Fixture approval flow completed.", + text: [ + "## Fixture approval flow completed", + "", + "- Rendered **strong** text", + "- Preserved `inline code`", + "", + "```txt", + "ok", + "```", + "", + "| Check | Result |", + "| --- | --- |", + "| Markdown | passed |", + "", + "[Fixture link](https://example.com/docs)", + ].join("\n"), stream: true, }); emitEvent(pending.runId, pending.turnId, "run.completed", { @@ -236,18 +309,28 @@ function handleResume(request) { replayStarted: true, }); setTimeout(() => { - emitEvent(runId, "turn-history-1", "assistant.delta", { - text: "Historical replayed response.", - stream: false, - }); + for (const text of chunkText(ledgerHistorySummary, 24)) { + emitEvent(runId, "turn-history-1", "assistant.delta", { + text, + stream: true, + }); + } emitEvent(runId, "turn-history-1", "run.completed", { - summary: "Historical fixture summary", + summary: ledgerHistorySummary, changedFiles: ["fixture/history.txt"], verificationStatus: "passed", }); }, 10); } +function chunkText(text, chunkSize) { + const chunks = []; + for (let index = 0; index < text.length; index += chunkSize) { + chunks.push(text.slice(index, index + chunkSize)); + } + return chunks; +} + function emitContextBuilt(runId, turnId) { emitEvent(runId, turnId, "context.built", { inputTokens: 120, @@ -393,3 +476,7 @@ function log(entry) { function record(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined; } + +function encodeIdSegment(value) { + return encodeURIComponent(String(value)); +} diff --git a/vscode/extension/test/gitWorkflow.test.ts b/vscode/extension/test/gitWorkflow.test.ts new file mode 100644 index 0000000..463e9d8 --- /dev/null +++ b/vscode/extension/test/gitWorkflow.test.ts @@ -0,0 +1,315 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { SendTurnParams, SendTurnResult } from "@prole-coder/protocol" with { + "resolution-mode": "import", +}; + +import { + generateCommitMessage, + generatePrDescription, + type GitWorkflowAgent, + type GitWorkflowRepository, + type GitWorkflowRepositoryProvider, +} from "../src/gitWorkflow.js"; +import type { AgentEventEnvelope, DisposableLike } from "../src/rpcServer.js"; + +test("generateCommitMessage sends staged diff to the agent and writes the SCM input box", async () => { + const repository = new FakeRepository({ + stagedDiff: "diff --git a/src/lib.rs b/src/lib.rs\n+new code\n", + }); + const agent = new FakeAgent("feat: update library"); + const window = new FakeGitWindow(); + + await generateCommitMessage({ + repositories: repositoryProvider(repository), + window, + agent, + }); + + assert.equal(repository.inputBox.value, "feat: update library"); + assert.equal(agent.sendTurns[0]?.mode, "ask"); + assert.ok(agent.sendTurns[0]?.message.includes("Conventional Commit")); + assert.ok(agent.sendTurns[0]?.attachments?.[0]?.text?.includes("diff --git")); + assert.equal(window.infos.at(-1), "Commit message generated in Source Control."); +}); + +test("generateCommitMessage asks before using unstaged changes", async () => { + const repository = new FakeRepository({ + stagedDiff: "", + unstagedDiff: "diff --git a/README.md b/README.md\n+docs\n", + }); + const agent = new FakeAgent("docs: update readme"); + const window = new FakeGitWindow(["Use Unstaged"]); + + await generateCommitMessage({ + repositories: repositoryProvider(repository), + window, + agent, + }); + + assert.equal(repository.inputBox.value, "docs: update readme"); + assert.ok(agent.sendTurns[0]?.attachments?.[0]?.text?.includes("README.md")); +}); + +test("generateCommitMessage redacts generation failures", async () => { + const repository = new FakeRepository({ + stagedDiff: "diff --git a/src/lib.rs b/src/lib.rs\n+new code\n", + }); + const window = new FakeGitWindow(); + + await generateCommitMessage({ + repositories: repositoryProvider(repository), + window, + agent: new FailingAgent(new Error("provider failed with fixture-secret")), + redactor: { + redact(message) { + return message.replaceAll("fixture-secret", "[redacted]"); + }, + }, + }); + + assert.equal(window.warnings.at(-1), "Failed to generate commit message: provider failed with [redacted]"); +}); + +test("generateCommitMessage keeps the first terminal agent event", async () => { + const repository = new FakeRepository({ + stagedDiff: "diff --git a/src/lib.rs b/src/lib.rs\n+new code\n", + }); + const window = new FakeGitWindow(); + + await generateCommitMessage({ + repositories: repositoryProvider(repository), + window, + agent: new DuplicateTerminalAgent(), + }); + + assert.equal(repository.inputBox.value, "feat: first result"); + assert.deepEqual(window.warnings, []); +}); + +test("generatePrDescription uses upstream branch context and opens markdown without creating a PR", async () => { + const repository = new FakeRepository({ + branch: "feature/api-key", + upstream: "origin/main", + diffStat: " README.md | 2 ++", + branchDiff: "diff --git a/README.md b/README.md\n+summary\n", + commitSummary: "abc123 Add API key UX", + }); + const agent = new FakeAgent("# Add API key UX\n\n## Summary\n- Adds configuration"); + const sink = new FakeMarkdownSink(); + + await generatePrDescription({ + repositories: repositoryProvider(repository), + window: new FakeGitWindow(), + agent, + markdownSink: sink, + }); + + assert.ok(agent.sendTurns[0]?.attachments?.[0]?.text?.includes("Base: origin/main")); + assert.ok(agent.sendTurns[0]?.attachments?.[0]?.text?.includes("abc123 Add API key UX")); + assert.equal(sink.markdowns[0]?.title, "ProleCoder PR Description"); + assert.ok(sink.copied.at(-1)?.includes("## Summary")); +}); + +test("generatePrDescription falls back to main when no upstream exists", async () => { + const repository = new FakeRepository({ + branch: "feature/no-upstream", + refs: new Set(["main"]), + }); + const agent = new FakeAgent("# PR\n"); + const sink = new FakeMarkdownSink(); + + await generatePrDescription({ + repositories: repositoryProvider(repository), + window: new FakeGitWindow(), + agent, + markdownSink: sink, + }); + + assert.ok(agent.sendTurns[0]?.attachments?.[0]?.text?.includes("Base: main")); +}); + +class FakeRepository implements GitWorkflowRepository { + readonly label = "repo"; + readonly rootPath = "C:/workspace/repo"; + readonly inputBox = { value: "" }; + readonly branch?: string | undefined; + readonly upstream?: string | undefined; + private readonly stagedDiffValue: string; + private readonly unstagedDiffValue: string; + private readonly diffStatValue: string; + private readonly branchDiffValue: string; + private readonly commitSummaryValue: string; + private readonly refs: Set; + + constructor(options: { + readonly branch?: string; + readonly upstream?: string; + readonly stagedDiff?: string; + readonly unstagedDiff?: string; + readonly diffStat?: string; + readonly branchDiff?: string; + readonly commitSummary?: string; + readonly refs?: Set; + } = {}) { + this.branch = options.branch; + this.upstream = options.upstream; + this.stagedDiffValue = options.stagedDiff ?? ""; + this.unstagedDiffValue = options.unstagedDiff ?? ""; + this.diffStatValue = options.diffStat ?? ""; + this.branchDiffValue = options.branchDiff ?? ""; + this.commitSummaryValue = options.commitSummary ?? ""; + this.refs = options.refs ?? new Set(); + } + + async stagedDiff(): Promise { + return this.stagedDiffValue; + } + + async unstagedDiff(): Promise { + return this.unstagedDiffValue; + } + + async diffStat(): Promise { + return this.diffStatValue; + } + + async branchDiff(): Promise { + return this.branchDiffValue; + } + + async commitSummary(): Promise { + return this.commitSummaryValue; + } + + async refExists(ref: string): Promise { + return this.refs.has(ref); + } +} + +class FakeGitWindow { + readonly infos: string[] = []; + readonly warnings: string[] = []; + + constructor(private readonly warningSelections: string[] = []) {} + + showInformationMessage(message: string): void { + this.infos.push(message); + } + + showWarningMessage(message: string): string | undefined { + this.warnings.push(message); + return this.warningSelections.shift(); + } +} + +class FakeAgent implements GitWorkflowAgent { + readonly sendTurns: SendTurnParams[] = []; + private readonly handlers = new Set<(event: AgentEventEnvelope) => void>(); + + constructor(private readonly responseText: string) {} + + onEvent(handler: (event: AgentEventEnvelope) => void): DisposableLike { + this.handlers.add(handler); + return { + dispose: () => { + this.handlers.delete(handler); + }, + }; + } + + async sendTurn(params: SendTurnParams): Promise { + this.sendTurns.push(params); + queueMicrotask(() => { + this.emit(agentEvent(1, "assistant.delta", { text: this.responseText })); + this.emit(agentEvent(2, "run.completed", { summary: "done" })); + }); + return { + runId: "run_git_1", + turnId: "turn_git_1", + accepted: true, + }; + } + + private emit(event: AgentEventEnvelope): void { + for (const handler of this.handlers) { + handler(event); + } + } +} + +class FailingAgent implements GitWorkflowAgent { + constructor(private readonly error: Error) {} + + onEvent(): DisposableLike { + return { dispose: () => undefined }; + } + + async sendTurn(): Promise { + throw this.error; + } +} + +class DuplicateTerminalAgent implements GitWorkflowAgent { + private readonly handlers = new Set<(event: AgentEventEnvelope) => void>(); + + onEvent(handler: (event: AgentEventEnvelope) => void): DisposableLike { + this.handlers.add(handler); + return { + dispose: () => { + this.handlers.delete(handler); + }, + }; + } + + async sendTurn(): Promise { + this.emit(agentEvent(1, "assistant.delta", { text: "feat: first result" })); + this.emit(agentEvent(2, "run.completed", { summary: "done" })); + this.emit(agentEvent(3, "assistant.delta", { text: "\nshould be ignored" })); + this.emit(agentEvent(4, "run.failed", { message: "late failure" })); + return { + runId: "run_git_1", + turnId: "turn_git_1", + accepted: true, + }; + } + + private emit(event: AgentEventEnvelope): void { + for (const handler of this.handlers) { + handler(event); + } + } +} + +class FakeMarkdownSink { + readonly markdowns: Array<{ readonly content: string; readonly title: string }> = []; + readonly copied: string[] = []; + + showMarkdown(content: string, title: string): void { + this.markdowns.push({ content, title }); + } + + copyToClipboard(content: string): void { + this.copied.push(content); + } +} + +function repositoryProvider(repository: GitWorkflowRepository): GitWorkflowRepositoryProvider { + return { + async repositories() { + return [repository]; + }, + }; +} + +function agentEvent(seq: number, type: string, payload: unknown): AgentEventEnvelope { + return { + seq, + time: "1970-01-01T00:00:00.000Z", + type, + runId: "run_git_1", + turnId: "turn_git_1", + payload, + }; +} diff --git a/vscode/extension/test/logging.test.ts b/vscode/extension/test/logging.test.ts new file mode 100644 index 0000000..3bfbd15 --- /dev/null +++ b/vscode/extension/test/logging.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createOutputLogger, formatLogLine } from "../src/logging.js"; + +test("formatLogLine includes timestamp, level, and full message", () => { + assert.equal( + formatLogLine( + "error", + "Failed to send turn: DeepSeek provider configuration failed.", + new Date("2026-05-31T00:00:00.000Z"), + ), + "[2026-05-31T00:00:00.000Z] ERROR Failed to send turn: DeepSeek provider configuration failed.", + ); +}); + +test("createOutputLogger appends all log levels to the output sink", () => { + const lines: string[] = []; + const logger = createOutputLogger( + { + appendLine(value) { + lines.push(value); + }, + }, + () => new Date("2026-05-31T00:00:00.000Z"), + ); + + logger.info("RPC initialized."); + logger.warn("RPC stderr preview."); + logger.error("Failed to load runs."); + + assert.deepEqual(lines, [ + "[2026-05-31T00:00:00.000Z] INFO RPC initialized.", + "[2026-05-31T00:00:00.000Z] WARN RPC stderr preview.", + "[2026-05-31T00:00:00.000Z] ERROR Failed to load runs.", + ]); +}); diff --git a/vscode/extension/test/notifier.test.ts b/vscode/extension/test/notifier.test.ts new file mode 100644 index 0000000..204d925 --- /dev/null +++ b/vscode/extension/test/notifier.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createExtensionNotifier } from "../src/notifier.js"; +import { MutableSecretRedactor } from "../src/redaction.js"; + +test("extension notifier redacts secret values before logging and showing toasts", () => { + const logger = new FakeLogger(); + const window = new FakeWindow(); + const redactor = new MutableSecretRedactor(); + redactor.update(["fixture-secret"]); + + const notifier = createExtensionNotifier(logger, window, redactor); + notifier.info("using fixture-secret"); + notifier.warn("warn fixture-secret"); + notifier.error("error fixture-secret"); + + assert.deepEqual(logger.infos, ["using [redacted]"]); + assert.deepEqual(logger.warnings, ["warn [redacted]"]); + assert.deepEqual(logger.errors, ["error [redacted]"]); + assert.deepEqual(window.infos, ["using [redacted]"]); + assert.deepEqual(window.warnings, ["warn [redacted]", "error [redacted]"]); +}); + +class FakeLogger { + readonly infos: string[] = []; + readonly warnings: string[] = []; + readonly errors: string[] = []; + + info(message: string): void { + this.infos.push(message); + } + + warn(message: string): void { + this.warnings.push(message); + } + + error(message: string): void { + this.errors.push(message); + } +} + +class FakeWindow { + readonly infos: string[] = []; + readonly warnings: string[] = []; + + showInformationMessage(message: string): void { + this.infos.push(message); + } + + showWarningMessage(message: string): void { + this.warnings.push(message); + } +} diff --git a/vscode/extension/test/patchPreview.test.ts b/vscode/extension/test/patchPreview.test.ts index b3289ea..603f2a8 100644 --- a/vscode/extension/test/patchPreview.test.ts +++ b/vscode/extension/test/patchPreview.test.ts @@ -36,6 +36,67 @@ test("applyParsedPatchFile builds patched preview content without writing files" assert.equal(after, "one\nnew\nthree\n\nkeep\ninsert\nremove\n"); }); +test("parseUnifiedDiff tolerates hunk headers with underdeclared line counts", () => { + const parsed = parseUnifiedDiff( + [ + "--- a/README.md", + "+++ b/README.md", + "@@ -1,1 +1,1 @@", + " one", + "-old", + "+new", + " three", + "", + ].join("\n"), + ); + const file = parsed.files[0]; + assert.ok(file); + const hunk = file.hunks[0]; + assert.ok(hunk); + assert.equal(hunk.oldCount, 3); + assert.equal(hunk.newCount, 3); + assert.equal(applyParsedPatchFile(file, "one\nold\nthree\n"), "one\nnew\nthree\n"); +}); + +test("parseUnifiedDiff keeps deleted content that resembles a file header", () => { + const parsed = parseUnifiedDiff( + [ + "--- a/README.md", + "+++ b/README.md", + "@@ -1,2 +1,2 @@", + " keep", + "--- heading", + "+renamed heading", + "", + ].join("\n"), + ); + const file = parsed.files[0]; + assert.ok(file); + assert.equal(applyParsedPatchFile(file, "keep\n-- heading\n"), "keep\nrenamed heading\n"); +}); + +test("parseUnifiedDiff rejects severely underdeclared hunk headers", () => { + assert.throws( + () => + parseUnifiedDiff( + [ + "--- a/README.md", + "+++ b/README.md", + "@@ -1,1 +1,1 @@", + " one", + " two", + " three", + " four", + " five", + " six", + " seven", + "", + ].join("\n"), + ), + /too many more lines than declared/, + ); +}); + test("PatchDiffPreviewController opens native diff requests before approval prompting", async () => { const rpc = new FakePatchEventSource(); const host = new FakePatchDiffPreviewHost({ @@ -58,6 +119,25 @@ test("PatchDiffPreviewController opens native diff requests before approval prom assert.deepEqual(host.warnings, []); }); +test("PatchDiffPreviewController ignores replayed patch approval events", async () => { + const rpc = new FakePatchEventSource(); + const host = new FakePatchDiffPreviewHost({ + "README.md": "one\nold\nthree\n\nkeep\nremove\n", + }); + const controller = new PatchDiffPreviewController(rpc, host); + + rpc.emit({ ...toolRequestedEvent(), replay: true }); + await controller.prepareApproval({ ...approvalEvent(), replay: true }, { + approvalId: "approval_1", + toolCallId: "tool_call_1", + toolName: "apply_patch", + }); + + assert.equal(host.opened.length, 0); + assert.equal(controller.approvalBoundary("approval_1"), undefined); + assert.deepEqual(host.warnings, []); +}); + test("PatchDiffPreviewController warns without breaking on malformed patch previews", () => { const rpc = new FakePatchEventSource(); const host = new FakePatchDiffPreviewHost({}); diff --git a/vscode/extension/test/providerConfigurationUx.test.ts b/vscode/extension/test/providerConfigurationUx.test.ts new file mode 100644 index 0000000..347709b --- /dev/null +++ b/vscode/extension/test/providerConfigurationUx.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CONFIGURE_DEEPSEEK_API_KEY_ACTION_LABEL, + providerConfigurationActionFromError, + providerConfigurationActionFromMetadata, + providerConfigurationActionFromPayload, +} from "../src/providerConfigurationUx.js"; + +test("providerConfigurationActionFromError reads structured RPC recovery data", () => { + const error = { + data: { + provider: "deepseek", + configurationError: "missingApiKey", + recoverableAction: { + kind: "configureDeepSeekApiKey", + label: CONFIGURE_DEEPSEEK_API_KEY_ACTION_LABEL, + }, + }, + }; + + assert.deepEqual(providerConfigurationActionFromError(error), { + type: "configureDeepSeekApiKey", + label: CONFIGURE_DEEPSEEK_API_KEY_ACTION_LABEL, + }); + assert.equal( + providerConfigurationActionFromError( + new Error("DeepSeek provider configuration failed: DEEPSEEK_API_KEY is required"), + ), + undefined, + ); +}); + +test("providerConfigurationActionFromPayload reads run failed recovery data", () => { + assert.deepEqual( + providerConfigurationActionFromPayload({ + code: "E_PROVIDER_ERROR", + recoverableAction: { + kind: "configureDeepSeekApiKey", + label: CONFIGURE_DEEPSEEK_API_KEY_ACTION_LABEL, + }, + }), + { + type: "configureDeepSeekApiKey", + label: CONFIGURE_DEEPSEEK_API_KEY_ACTION_LABEL, + }, + ); +}); + +test("providerConfigurationActionFromMetadata reads chat participant actions", () => { + assert.deepEqual( + providerConfigurationActionFromMetadata({ + providerConfigurationAction: { + type: "configureDeepSeekApiKey", + label: CONFIGURE_DEEPSEEK_API_KEY_ACTION_LABEL, + }, + }), + { + type: "configureDeepSeekApiKey", + label: CONFIGURE_DEEPSEEK_API_KEY_ACTION_LABEL, + }, + ); +}); diff --git a/vscode/extension/test/providerSecretCommands.test.ts b/vscode/extension/test/providerSecretCommands.test.ts new file mode 100644 index 0000000..b050397 --- /dev/null +++ b/vscode/extension/test/providerSecretCommands.test.ts @@ -0,0 +1,477 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CLEAR_DEEPSEEK_API_KEY_COMMAND, + CONFIGURE_DEEPSEEK_API_KEY_COMMAND, + SELECT_DEEPSEEK_MODEL_COMMAND, + SHOW_PROVIDER_STATUS_COMMAND, + registerProviderSecretCommands, + type SecretQuickInputButton, + type SecretQuickPickItemButtonEvent, +} from "../src/providerSecretCommands.js"; +import { + DEEPSEEK_API_KEY_ENV, + DEEPSEEK_API_KEY_STORE_SECRET_ID, + DEEPSEEK_API_KEY_SECRET_ID, + DEEPSEEK_MODEL_ENV, + parseDeepSeekApiKeyStore, + serializeDeepSeekApiKeyStore, +} from "../src/providerSecrets.js"; +import { MutableSecretRedactor } from "../src/redaction.js"; + +test("configure DeepSeek API key stores SecretStorage value, updates env, and restarts idle RPC", async () => { + const commands = new FakeCommands(); + const window = new FakeSecretWindow(["Work", " fixture-secret-value "], [], [ + { label: "+ Add DeepSeek API Key" }, + ]); + const secrets = new FakeSecrets(); + const redactor = new MutableSecretRedactor(); + const rpc = new FakeRpcServer("ready"); + + registerProviderSecretCommands({ + commands, + window, + secrets, + processEnv: { + [DEEPSEEK_API_KEY_ENV]: "env-secret-value", + }, + redactor, + rpcServer: rpc, + isRpcIdle: () => true, + renameAliasButton: FakeSecretWindow.renameButton, + }); + + await commands.run(CONFIGURE_DEEPSEEK_API_KEY_COMMAND); + + const keyStore = parseDeepSeekApiKeyStore(await secrets.get(DEEPSEEK_API_KEY_STORE_SECRET_ID)); + assert.equal(keyStore.entries.length, 1); + assert.equal(keyStore.entries[0]?.alias, "Work"); + assert.equal(keyStore.entries[0]?.apiKey, "fixture-secret-value"); + assert.equal(keyStore.selectedKeyId, keyStore.entries[0]?.id); + assert.equal(await secrets.get(DEEPSEEK_API_KEY_SECRET_ID), undefined); + assert.deepEqual(rpc.processEnv, { + [DEEPSEEK_API_KEY_ENV]: "fixture-secret-value", + }); + assert.equal(rpc.stopCount, 1); + assert.equal(rpc.startCount, 1); + assert.deepEqual(window.infos.at(-1), "DeepSeek API key: VS Code SecretStorage (Work)"); + assert.equal(redactor.redact("key fixture-secret-value"), "key [redacted]"); +}); + +test("configure DeepSeek API key selects an existing stored key", async () => { + const commands = new FakeCommands(); + const window = new FakeSecretWindow([], [], [{ label: "Personal" }]); + const secrets = new FakeSecrets({ + [DEEPSEEK_API_KEY_STORE_SECRET_ID]: serializeDeepSeekApiKeyStore({ + selectedKeyId: "work", + entries: [ + { + id: "work", + alias: "Work", + apiKey: "work-secret-value", + }, + { + id: "personal", + alias: "Personal", + apiKey: "personal-secret-value", + }, + ], + }), + }); + const rpc = new FakeRpcServer("ready"); + + registerProviderSecretCommands({ + commands, + window, + secrets, + rpcServer: rpc, + isRpcIdle: () => true, + renameAliasButton: FakeSecretWindow.renameButton, + }); + + await commands.run(CONFIGURE_DEEPSEEK_API_KEY_COMMAND); + + const keyStore = parseDeepSeekApiKeyStore(await secrets.get(DEEPSEEK_API_KEY_STORE_SECRET_ID)); + assert.equal(keyStore.selectedKeyId, "personal"); + assert.deepEqual(rpc.processEnv, { + [DEEPSEEK_API_KEY_ENV]: "personal-secret-value", + }); + assert.equal(rpc.stopCount, 1); + assert.equal(rpc.startCount, 1); + assert.deepEqual(window.infos.at(-1), "DeepSeek API key: VS Code SecretStorage (Personal)"); +}); + +test("configure DeepSeek API key renames an existing key alias without restarting RPC", async () => { + const commands = new FakeCommands(); + const window = new FakeSecretWindow(["Team"], [], [{ label: "Work", button: true }]); + const secrets = new FakeSecrets({ + [DEEPSEEK_API_KEY_STORE_SECRET_ID]: serializeDeepSeekApiKeyStore({ + selectedKeyId: "work", + entries: [ + { + id: "work", + alias: "Work", + apiKey: "work-secret-value", + }, + ], + }), + }); + const rpc = new FakeRpcServer("ready"); + + registerProviderSecretCommands({ + commands, + window, + secrets, + rpcServer: rpc, + isRpcIdle: () => true, + renameAliasButton: FakeSecretWindow.renameButton, + }); + + await commands.run(CONFIGURE_DEEPSEEK_API_KEY_COMMAND); + + const keyStore = parseDeepSeekApiKeyStore(await secrets.get(DEEPSEEK_API_KEY_STORE_SECRET_ID)); + assert.equal(keyStore.entries[0]?.alias, "Team"); + assert.deepEqual(rpc.processEnv, { + [DEEPSEEK_API_KEY_ENV]: "work-secret-value", + }); + assert.equal(rpc.stopCount, 0); + assert.equal(rpc.startCount, 0); + assert.deepEqual(window.infos.at(-1), "DeepSeek API key alias updated: Team"); +}); + +test("configure DeepSeek API key deletes a non-selected key without restarting RPC", async () => { + const commands = new FakeCommands(); + const window = new FakeSecretWindow([], [], [{ label: "Personal", button: 1 }], ["Delete"]); + const secrets = new FakeSecrets({ + [DEEPSEEK_API_KEY_STORE_SECRET_ID]: serializeDeepSeekApiKeyStore({ + selectedKeyId: "work", + entries: [ + { + id: "work", + alias: "Work", + apiKey: "work-secret-value", + }, + { + id: "personal", + alias: "Personal", + apiKey: "personal-secret-value", + }, + ], + }), + }); + const rpc = new FakeRpcServer("ready"); + + registerProviderSecretCommands({ + commands, + window, + secrets, + rpcServer: rpc, + isRpcIdle: () => true, + renameAliasButton: FakeSecretWindow.renameButton, + deleteKeyButton: FakeSecretWindow.deleteButton, + }); + + await commands.run(CONFIGURE_DEEPSEEK_API_KEY_COMMAND); + + const keyStore = parseDeepSeekApiKeyStore(await secrets.get(DEEPSEEK_API_KEY_STORE_SECRET_ID)); + assert.equal(keyStore.selectedKeyId, "work"); + assert.deepEqual( + keyStore.entries.map((entry) => entry.id), + ["work"], + ); + assert.deepEqual(rpc.processEnv, { + [DEEPSEEK_API_KEY_ENV]: "work-secret-value", + }); + assert.equal(rpc.stopCount, 0); + assert.equal(rpc.startCount, 0); + assert.deepEqual(window.warnings.at(-1), 'Delete DeepSeek API key "Personal"?'); + assert.deepEqual(window.infos.at(-1), "DeepSeek API key deleted: Personal"); +}); + +test("clear DeepSeek API key falls back to process env without restarting active RPC", async () => { + const commands = new FakeCommands(); + const window = new FakeSecretWindow([]); + const secrets = new FakeSecrets({ + [DEEPSEEK_API_KEY_SECRET_ID]: "fixture-secret-value", + }); + const rpc = new FakeRpcServer("ready"); + + registerProviderSecretCommands({ + commands, + window, + secrets, + processEnv: { + [DEEPSEEK_API_KEY_ENV]: "env-secret-value", + }, + rpcServer: rpc, + isRpcIdle: () => false, + }); + + await commands.run(CLEAR_DEEPSEEK_API_KEY_COMMAND); + + assert.equal(await secrets.get(DEEPSEEK_API_KEY_SECRET_ID), undefined); + assert.deepEqual(rpc.processEnv, { + [DEEPSEEK_API_KEY_ENV]: "env-secret-value", + }); + assert.equal(rpc.stopCount, 0); + assert.equal(rpc.startCount, 0); + assert.ok(window.infos.some((message) => message.includes("current turn finishes"))); + assert.deepEqual(window.infos.at(-1), "DeepSeek API key: process env"); +}); + +test("show provider status reports missing configuration without exposing keys", async () => { + const commands = new FakeCommands(); + const window = new FakeSecretWindow([]); + + registerProviderSecretCommands({ + commands, + window, + secrets: new FakeSecrets(), + processEnv: {}, + }); + + await commands.run(SHOW_PROVIDER_STATUS_COMMAND); + + assert.deepEqual(window.infos.at(-1), "DeepSeek API key: missing; DeepSeek model: provider default"); +}); + +test("select DeepSeek model stores configuration, updates env, and restarts idle RPC", async () => { + const commands = new FakeCommands(); + const window = new FakeSecretWindow([], ["DeepSeek V4 Flash"]); + const secrets = new FakeSecrets({ + [DEEPSEEK_API_KEY_SECRET_ID]: "fixture-secret-value", + }); + const configuration = new FakeProviderConfiguration({ + model: "deepseek-v4-pro", + }); + const rpc = new FakeRpcServer("ready"); + + registerProviderSecretCommands({ + commands, + window, + secrets, + providerConfiguration: configuration, + rpcServer: rpc, + isRpcIdle: () => true, + }); + + await commands.run(SELECT_DEEPSEEK_MODEL_COMMAND); + + assert.equal(configuration.values["model"], "deepseek-v4-flash"); + assert.deepEqual(rpc.processEnv, { + [DEEPSEEK_API_KEY_ENV]: "fixture-secret-value", + [DEEPSEEK_MODEL_ENV]: "deepseek-v4-flash", + }); + assert.equal(rpc.stopCount, 1); + assert.equal(rpc.startCount, 1); + assert.deepEqual(window.infos.at(-1), "DeepSeek model: DeepSeek V4 Flash (deepseek-v4-flash)"); +}); + +test("configure DeepSeek API key redacts restart failures", async () => { + const commands = new FakeCommands(); + const window = new FakeSecretWindow(["Key", "fixture-secret-value"], [], [ + { label: "+ Add DeepSeek API Key" }, + ]); + const redactor = new MutableSecretRedactor(); + const rpc = new FakeRpcServer("ready", new Error("failed with fixture-secret-value")); + + registerProviderSecretCommands({ + commands, + window, + secrets: new FakeSecrets(), + redactor, + rpcServer: rpc, + isRpcIdle: () => true, + renameAliasButton: FakeSecretWindow.renameButton, + }); + + await commands.run(CONFIGURE_DEEPSEEK_API_KEY_COMMAND); + + assert.equal(window.warnings.at(-1), "DeepSeek API key updated, but RPC restart failed: failed with [redacted]"); +}); + +class FakeCommands { + private readonly callbacks = new Map unknown>(); + + registerCommand(command: string, callback: () => unknown) { + this.callbacks.set(command, callback); + return { dispose: () => undefined }; + } + + async run(command: string): Promise { + const callback = this.callbacks.get(command); + assert.ok(callback, `${command} should be registered`); + await callback(); + } +} + +class FakeSecretWindow { + static readonly renameButton = { tooltip: "Rename alias" }; + static readonly deleteButton = { tooltip: "Delete key" }; + + readonly infos: string[] = []; + readonly warnings: string[] = []; + + constructor( + private readonly inputValues: string[], + private readonly quickPickLabels: string[] = [], + private readonly quickPickInteractions: FakeQuickPickInteraction[] = [], + private readonly warningSelections: string[] = [], + ) {} + + showInputBox(): string | undefined { + return this.inputValues.shift(); + } + + showQuickPick(items: readonly T[]): T | undefined { + const label = this.quickPickLabels.shift(); + if (label === undefined) { + return undefined; + } + return items.find((item) => item.label === label); + } + + createQuickPick(): FakeQuickPick { + return new FakeQuickPick(this.quickPickInteractions); + } + + showInformationMessage(message: string): void { + this.infos.push(message); + } + + showWarningMessage(message: string, ...items: readonly string[]): string | undefined { + this.warnings.push(message); + if (items.length === 0) { + return undefined; + } + return this.warningSelections.shift(); + } +} + +interface FakeQuickPickInteraction { + readonly label: string; + readonly button?: boolean | number; +} + +class FakeQuickPick { + title: string | undefined; + placeholder: string | undefined; + ignoreFocusOut = false; + matchOnDescription = false; + items: readonly T[] = []; + selectedItems: readonly T[] = []; + + private readonly acceptCallbacks: Array<() => unknown> = []; + private readonly hideCallbacks: Array<() => unknown> = []; + private readonly buttonCallbacks: Array<(event: SecretQuickPickItemButtonEvent) => unknown> = []; + + constructor(private readonly interactions: FakeQuickPickInteraction[]) {} + + onDidAccept(callback: () => unknown): { dispose(): void } { + this.acceptCallbacks.push(callback); + return { dispose: () => undefined }; + } + + onDidHide(callback: () => unknown): { dispose(): void } { + this.hideCallbacks.push(callback); + return { dispose: () => undefined }; + } + + onDidTriggerItemButton(callback: (event: SecretQuickPickItemButtonEvent) => unknown): { dispose(): void } { + this.buttonCallbacks.push(callback); + return { dispose: () => undefined }; + } + + show(): void { + const interaction = this.interactions.shift(); + queueMicrotask(() => { + if (interaction === undefined) { + this.hide(); + return; + } + + const item = this.items.find((candidate) => candidate.label === interaction.label); + assert.ok(item, `${interaction.label} should exist in quick pick`); + if (interaction.button !== undefined && interaction.button !== false) { + const buttonIndex = interaction.button === true ? 0 : interaction.button; + const button = item.buttons?.[buttonIndex]; + assert.ok(button, `${interaction.label} should have a button`); + for (const callback of this.buttonCallbacks) { + callback({ button, item }); + } + return; + } + + this.selectedItems = [item]; + for (const callback of this.acceptCallbacks) { + callback(); + } + }); + } + + hide(): void { + for (const callback of this.hideCallbacks) { + callback(); + } + } + + dispose(): void { + return undefined; + } +} + +class FakeSecrets { + constructor(private readonly values: Record = {}) {} + + async get(key: string): Promise { + return this.values[key]; + } + + async store(key: string, value: string): Promise { + this.values[key] = value; + } + + async delete(key: string): Promise { + delete this.values[key]; + } +} + +class FakeProviderConfiguration { + constructor(readonly values: Record = {}) {} + + get(section: string, defaultValue: T): T { + const value = this.values[section]; + return value === undefined ? defaultValue : (value as T); + } + + update(section: string, value: unknown): void { + this.values[section] = value; + } +} + +class FakeRpcServer { + processEnv: Record = {}; + startCount = 0; + stopCount = 0; + + constructor( + readonly status: string, + private readonly startError?: Error, + ) {} + + setProcessEnv(env: Record): void { + this.processEnv = env; + } + + async start(): Promise { + this.startCount += 1; + if (this.startError !== undefined) { + throw this.startError; + } + } + + stop(): void { + this.stopCount += 1; + } +} diff --git a/vscode/extension/test/providerSecrets.test.ts b/vscode/extension/test/providerSecrets.test.ts new file mode 100644 index 0000000..6a61dcc --- /dev/null +++ b/vscode/extension/test/providerSecrets.test.ts @@ -0,0 +1,230 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEEPSEEK_API_KEY_ENV, + DEEPSEEK_API_KEY_STORE_SECRET_ID, + DEEPSEEK_MODEL_ENV, + deepSeekEnvOverride, + formatDeepSeekModelStatus, + formatProviderSecretStatus, + maskDeepSeekApiKey, + parseDeepSeekApiKeyStore, + providerSecretRedactionValues, + resolveDeepSeekApiKey, + resolveDeepSeekModel, + serializeDeepSeekApiKeyStore, +} from "../src/providerSecrets.js"; + +test("resolveDeepSeekApiKey prefers SecretStorage over process env", () => { + const resolution = resolveDeepSeekApiKey({ + secretValue: " secret-storage-key ", + processEnv: { + [DEEPSEEK_API_KEY_ENV]: "env-key", + }, + }); + + assert.equal(resolution.source, "secret-storage"); + assert.equal(resolution.apiKey, "secret-storage-key"); + assert.deepEqual(deepSeekEnvOverride(resolution), { + [DEEPSEEK_API_KEY_ENV]: "secret-storage-key", + }); + assert.deepEqual(deepSeekEnvOverride(resolution, " deepseek-v4-flash "), { + [DEEPSEEK_API_KEY_ENV]: "secret-storage-key", + [DEEPSEEK_MODEL_ENV]: "deepseek-v4-flash", + }); + assert.deepEqual(providerSecretRedactionValues(resolution), ["secret-storage-key"]); + assert.equal(formatProviderSecretStatus(resolution), "DeepSeek API key: VS Code SecretStorage"); +}); + +test("resolveDeepSeekApiKey selects stored key aliases and redacts every stored key", () => { + const keyStoreValue = serializeDeepSeekApiKeyStore({ + selectedKeyId: "work", + entries: [ + { + id: "personal", + alias: "Personal", + apiKey: "personal-secret-value", + }, + { + id: "work", + alias: "Work", + apiKey: "work-secret-value", + }, + ], + }); + + const store = parseDeepSeekApiKeyStore(keyStoreValue); + assert.equal(store.entries.length, 2); + assert.equal(maskDeepSeekApiKey("work-secret-value"), "work********alue"); + + const resolution = resolveDeepSeekApiKey({ + keyStoreValue, + secretValue: "legacy-secret-value", + processEnv: { + [DEEPSEEK_API_KEY_ENV]: "env-secret-value", + }, + }); + + assert.equal(resolution.source, "secret-storage"); + assert.equal(resolution.apiKey, "work-secret-value"); + assert.equal(resolution.keyAlias, "Work"); + assert.equal(formatProviderSecretStatus(resolution), "DeepSeek API key: VS Code SecretStorage (Work)"); + assert.deepEqual(providerSecretRedactionValues(resolution), [ + "personal-secret-value", + "work-secret-value", + "legacy-secret-value", + ]); + assert.deepEqual(deepSeekEnvOverride(resolution), { + [DEEPSEEK_API_KEY_ENV]: "work-secret-value", + }); + assert.ok(keyStoreValue.includes(DEEPSEEK_API_KEY_STORE_SECRET_ID) === false); +}); + +test("serializeDeepSeekApiKeyStore validates entries before writing", () => { + assert.throws( + () => + serializeDeepSeekApiKeyStore({ + entries: [ + { + id: "empty", + alias: "Empty", + apiKey: " ", + }, + ], + }), + /non-empty apiKey/, + ); + assert.throws( + () => + serializeDeepSeekApiKeyStore({ + entries: [ + { + id: "duplicate", + alias: "First", + apiKey: "first-secret-value", + }, + { + id: "duplicate", + alias: "Second", + apiKey: "second-secret-value", + }, + ], + }), + /must be unique/, + ); + + const serialized = serializeDeepSeekApiKeyStore({ + selectedKeyId: " work ", + entries: [ + { + id: " work ", + alias: " Work Key ", + apiKey: " work-secret-value ", + }, + ], + }); + + assert.deepEqual(parseDeepSeekApiKeyStore(serialized), { + selectedKeyId: "work", + entries: [ + { + id: "work", + alias: "Work Key", + apiKey: "work-secret-value", + }, + ], + }); +}); + +test("parseDeepSeekApiKeyStore skips malformed entries defensively", () => { + const store = parseDeepSeekApiKeyStore( + JSON.stringify({ + version: 1, + selectedKeyId: " duplicate ", + keys: [ + "not an entry", + { + id: " duplicate ", + alias: " First Key ", + apiKey: " first-secret-value ", + }, + { + id: "duplicate", + alias: "Duplicate", + apiKey: "second-secret-value", + }, + { + id: "empty-key", + alias: "Empty", + apiKey: " ", + }, + { + id: "missing-key", + alias: "Missing", + }, + ], + }), + ); + + assert.deepEqual(store, { + selectedKeyId: "duplicate", + entries: [ + { + id: "duplicate", + alias: "First Key", + apiKey: "first-secret-value", + }, + ], + }); +}); + +test("resolveDeepSeekApiKey falls back to process env and reports missing", () => { + const envResolution = resolveDeepSeekApiKey({ + processEnv: { + [DEEPSEEK_API_KEY_ENV]: " env-key ", + }, + }); + + assert.equal(envResolution.source, "process-env"); + assert.equal(envResolution.apiKey, "env-key"); + assert.equal(formatProviderSecretStatus(envResolution), "DeepSeek API key: process env"); + + const missingResolution = resolveDeepSeekApiKey({ + secretValue: " ", + processEnv: { + [DEEPSEEK_API_KEY_ENV]: "", + }, + }); + + assert.equal(missingResolution.source, "missing"); + assert.deepEqual(deepSeekEnvOverride(missingResolution), {}); + assert.deepEqual(providerSecretRedactionValues(missingResolution), []); + assert.equal(formatProviderSecretStatus(missingResolution), "DeepSeek API key: missing"); +}); + +test("resolveDeepSeekModel prefers configured model over process env", () => { + assert.equal( + resolveDeepSeekModel({ + configuredModel: " deepseek-v4-pro ", + processEnv: { + [DEEPSEEK_MODEL_ENV]: "deepseek-v4-flash", + }, + }), + "deepseek-v4-pro", + ); + assert.equal( + resolveDeepSeekModel({ + configuredModel: "", + processEnv: { + [DEEPSEEK_MODEL_ENV]: " deepseek-v4-flash ", + }, + }), + "deepseek-v4-flash", + ); + assert.equal(resolveDeepSeekModel({ configuredModel: " ", processEnv: {} }), undefined); + assert.equal( + formatDeepSeekModelStatus("deepseek-v4-pro"), + "DeepSeek model: DeepSeek V4 Pro (deepseek-v4-pro)", + ); +}); diff --git a/vscode/extension/test/rpcServer.test.ts b/vscode/extension/test/rpcServer.test.ts index 0c1b50e..440512e 100644 --- a/vscode/extension/test/rpcServer.test.ts +++ b/vscode/extension/test/rpcServer.test.ts @@ -6,14 +6,17 @@ import { DEFAULT_RPC_COMMAND, RPC_APPROVE_METHOD, RPC_CANCEL_METHOD, + RPC_DELETE_RUN_METHOD, RPC_EVENT_BATCH_METHOD, RPC_INITIALIZE_METHOD, RPC_LIST_RUNS_METHOD, + RPC_LOAD_RUN_EVENTS_METHOD, RPC_PREVIEW_FIM_METHOD, RPC_PROTOCOL_VERSION, RPC_REJECT_METHOD, RPC_RESUME_METHOD, RPC_SEND_TURN_METHOD, + RPC_STEER_METHOD, RpcRequestError, RpcServerManager, type RpcChildProcess, @@ -63,6 +66,50 @@ test("RPC server manager spawns the configured command and initializes the works assert.equal(ready.capabilities.supportsEventBatching, true); }); +test("RPC server manager forwards configured process environment overrides", async () => { + const factory = new FakeProcessFactory(); + const manager = new RpcServerManager({ + launch: { + command: "prole", + args: ["rpc"], + autoStart: true, + }, + workspace: { + root: "C:/workspace/project", + trusted: true, + }, + extensionVersion: "0.1.0", + processFactory: factory, + processEnv: { + DEEPSEEK_API_KEY: "stored-key", + }, + }); + + const readyPromise = manager.start(); + const child = factory.lastChild(); + child.stdout.pushJson(initializeResponse(child.initializeRequest().id)); + await readyPromise; + + assert.deepEqual(factory.lastOptions?.env, { + DEEPSEEK_API_KEY: "stored-key", + }); + + manager.stop(); + manager.setProcessEnv({ DEEPSEEK_API_KEY: "rotated-key" }); + + const restarted = manager.start(); + const restartedChild = factory.lastChild(); + child.exit(null, "SIGTERM"); + restartedChild.stdout.pushJson(initializeResponse(restartedChild.initializeRequest().id)); + await restarted; + + assert.deepEqual(factory.lastOptions?.env, { + DEEPSEEK_API_KEY: "rotated-key", + }); + assert.equal(restartedChild.killed, false); + assert.equal(manager.status, "ready"); +}); + test("RPC server manager forwards agent.event notifications", async () => { const factory = new FakeProcessFactory(); const manager = rpcManagerWithFactory(factory); @@ -274,7 +321,7 @@ test("RPC server manager sends typed agent.sendTurn requests and resolves matchi }); }); -test("RPC server manager sends typed run list and resume requests", async () => { +test("RPC server manager sends typed run list, resume, and delete requests", async () => { const factory = new FakeProcessFactory(); const manager = rpcManagerWithFactory(factory); const readyPromise = manager.start(); @@ -344,6 +391,92 @@ test("RPC server manager sends typed run list and resume requests", async () => nextSeq: 9, replayStarted: true, }); + + const eventsPromise = manager.loadRunEvents({ runId: "run_1", beforeSeq: 3, limit: 2 }); + await flushMicrotasks(); + const eventsRequest = child.requestAt(3); + assert.equal(eventsRequest.method, RPC_LOAD_RUN_EVENTS_METHOD); + assert.deepEqual(eventsRequest.params, { runId: "run_1", beforeSeq: 3, limit: 2 }); + child.stdout.pushJson({ + jsonrpc: "2.0", + id: eventsRequest.id, + result: { + runId: "run_1", + events: [agentEvent({ seq: 1, type: "run.started" })], + firstSeq: 1, + lastSeq: 1, + hasMoreBefore: false, + }, + }); + + assert.deepEqual(await eventsPromise, { + runId: "run_1", + events: [agentEvent({ seq: 1, type: "run.started" })], + firstSeq: 1, + lastSeq: 1, + hasMoreBefore: false, + }); + + const deletePromise = manager.deleteRun({ runId: "run_1" }); + await flushMicrotasks(); + const deleteRequest = child.requestAt(4); + assert.equal(deleteRequest.method, RPC_DELETE_RUN_METHOD); + assert.deepEqual(deleteRequest.params, { runId: "run_1" }); + child.stdout.pushJson({ + jsonrpc: "2.0", + id: deleteRequest.id, + result: { + runId: "run_1", + deleted: true, + }, + }); + + assert.deepEqual(await deletePromise, { + runId: "run_1", + deleted: true, + }); +}); + +test("RPC server manager marks resume replay events without marking later live events", async () => { + const factory = new FakeProcessFactory(); + const manager = rpcManagerWithFactory(factory); + const received: unknown[] = []; + manager.onEvent((event) => received.push(event)); + const readyPromise = manager.start(); + const child = factory.lastChild(); + child.stdout.pushJson(initializeResponse(child.initializeRequest().id)); + await readyPromise; + + const resumePromise = manager.resume({ runId: "run_1" }); + await flushMicrotasks(); + const resumeRequest = child.requestAt(1); + assert.equal(resumeRequest.method, RPC_RESUME_METHOD); + child.stdout.pushJson({ + jsonrpc: "2.0", + id: resumeRequest.id, + result: { + runId: "run_1", + nextSeq: 4, + replayStarted: true, + }, + }); + await resumePromise; + + child.stdout.pushJson(agentEventNotification({ seq: 1, type: "run.started" })); + child.stdout.pushJson(agentEventNotification({ seq: 3, type: "tool.approvalRequired" })); + child.stdout.pushJson(agentEventNotification({ seq: 4, type: "turn.started" })); + + assert.deepEqual( + received.map((event) => ({ + seq: (event as { seq: number }).seq, + replay: (event as { replay?: boolean }).replay, + })), + [ + { seq: 1, replay: true }, + { seq: 3, replay: true }, + { seq: 4, replay: undefined }, + ], + ); }); test("RPC server manager sends typed approval requests", async () => { @@ -455,6 +588,42 @@ test("RPC server manager sends typed cancel requests", async () => { }); }); +test("RPC server manager sends typed steer requests", async () => { + const factory = new FakeProcessFactory(); + const manager = rpcManagerWithFactory(factory); + const readyPromise = manager.start(); + const child = factory.lastChild(); + child.stdout.pushJson(initializeResponse(child.initializeRequest().id)); + await readyPromise; + + const steerPromise = manager.steer({ + runId: "run_1", + message: "focus on the failing test", + }); + await flushMicrotasks(); + const steerRequest = child.requestAt(1); + assert.equal(steerRequest.method, RPC_STEER_METHOD); + assert.deepEqual(steerRequest.params, { + runId: "run_1", + message: "focus on the failing test", + }); + child.stdout.pushJson({ + jsonrpc: "2.0", + id: steerRequest.id, + result: { + runId: "run_1", + steerId: "steer_1", + accepted: true, + }, + }); + + assert.deepEqual(await steerPromise, { + runId: "run_1", + steerId: "steer_1", + accepted: true, + }); +}); + test("RPC server manager sends typed FIM preview requests", async () => { const factory = new FakeProcessFactory(); const manager = rpcManagerWithFactory(factory); @@ -842,17 +1011,21 @@ function initializeResponse(id: unknown): unknown { }; } -function agentEventNotification(): unknown { +function agentEventNotification(options: { readonly seq?: number; readonly type?: string } = {}): unknown { return { jsonrpc: "2.0", method: "agent.event", - params: { - seq: 1, - time: "1970-01-01T00:00:00.000Z", - type: "run.started", - runId: "run_1", - payload: { mode: "ask" }, - }, + params: agentEvent(options), + }; +} + +function agentEvent(options: { readonly seq?: number; readonly type?: string } = {}): unknown { + return { + seq: options.seq ?? 1, + time: "1970-01-01T00:00:00.000Z", + type: options.type ?? "run.started", + runId: "run_1", + payload: { mode: "ask" }, }; } diff --git a/vscode/extension/test/runHistory.test.ts b/vscode/extension/test/runHistory.test.ts index 5387127..9f41179 100644 --- a/vscode/extension/test/runHistory.test.ts +++ b/vscode/extension/test/runHistory.test.ts @@ -3,9 +3,11 @@ import test from "node:test"; import { RUN_LIST_LIMIT, + deletedRunList, failedRunList, idleRunList, isRefreshRunsMessage, + deleteRunIdFromMessage, loadingRunList, readyRunList, resumeRunIdFromMessage, @@ -40,13 +42,32 @@ test("run history ready snapshots only keep a selected run that still exists", ( }); }); +test("run history delete snapshots remove runs and keep valid selections", () => { + const ready = readyRunList( + { runs: [runSummary("run_1"), runSummary("run_2"), runSummary("run_3")] }, + "run_2", + ); + + assert.deepEqual(deletedRunList(ready, "run_1", "Deleted run_1."), { + status: "ready", + runs: [runSummary("run_2"), runSummary("run_3")], + selectedRunId: "run_2", + message: "Deleted run_1.", + }); + assert.deepEqual(deletedRunList(ready, "run_2", "Deleted run_2."), { + status: "ready", + runs: [runSummary("run_1"), runSummary("run_3")], + message: "Deleted run_2.", + }); +}); + test("run history leaves result limiting to the RPC server", () => { const runs = Array.from({ length: RUN_LIST_LIMIT + 1 }, (_, index) => runSummary(`run_${index}`)); assert.equal(readyRunList({ runs }).runs.length, RUN_LIST_LIMIT + 1); }); -test("run history parses refresh and resume webview messages defensively", () => { +test("run history parses refresh, resume, and delete webview messages defensively", () => { assert.equal(RUN_LIST_LIMIT, 20); assert.equal(isRefreshRunsMessage({ type: "refreshRuns" }), true); assert.equal(isRefreshRunsMessage({ type: "refreshRuns", runId: "run_1" }), true); @@ -54,7 +75,11 @@ test("run history parses refresh and resume webview messages defensively", () => assert.equal(resumeRunIdFromMessage({ type: "resumeRun", runId: " run_1 " }), "run_1"); assert.equal(resumeRunIdFromMessage({ type: "resumeRun", runId: " " }), undefined); assert.equal(resumeRunIdFromMessage({ type: "resumeRun", runId: 1 }), undefined); + assert.equal(deleteRunIdFromMessage({ type: "deleteRun", runId: " run_1 " }), "run_1"); + assert.equal(deleteRunIdFromMessage({ type: "deleteRun", runId: " " }), undefined); + assert.equal(deleteRunIdFromMessage({ type: "deleteRun", runId: 1 }), undefined); assert.equal(resumeRunIdFromMessage(idleRunList()), undefined); + assert.equal(deleteRunIdFromMessage(idleRunList()), undefined); }); function runSummary(runId: string) { diff --git a/vscode/extension/test/webviewHtml.test.ts b/vscode/extension/test/webviewHtml.test.ts new file mode 100644 index 0000000..f209a5a --- /dev/null +++ b/vscode/extension/test/webviewHtml.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import test from "node:test"; +import { Script, runInNewContext } from "node:vm"; + +import { WEBVIEW_MARKDOWN_RENDERER_SCRIPT } from "../src/webviewMarkdown.js"; + +test("webview markdown renderer script export exposes expected entrypoint", () => { + assert.ok(WEBVIEW_MARKDOWN_RENDERER_SCRIPT.trim().length > 0); + assert.match(WEBVIEW_MARKDOWN_RENDERER_SCRIPT, /function appendMarkdownBlocks\(/); + assert.match(WEBVIEW_MARKDOWN_RENDERER_SCRIPT, /function safeMarkdownHref\(/); +}); + +test("generated chat webview inline script parses as JavaScript", () => { + const chatViewPath = path.resolve(__dirname, "../src/chatView.js"); + const moduleObject: { exports: Record } = { exports: {} }; + const baseRequire = createRequire(chatViewPath); + const source = `${readFileSync(chatViewPath, "utf8")}\nexports.__renderChatViewHtml = renderChatViewHtml;`; + assert.match( + source, + /if \(postSubmission\) \{\s*this\.postSubmission\(\);\s*\}\s*if \(postSnapshot\) \{\s*this\.postSnapshot\(\);/s, + ); + + runInNewContext( + source, + { + Buffer, + URL, + __dirname: path.dirname(chatViewPath), + __filename: chatViewPath, + clearTimeout, + console, + exports: moduleObject.exports, + module: moduleObject, + process, + require: (id: string) => (id === "vscode" ? {} : baseRequire(id)), + setTimeout, + }, + { filename: chatViewPath }, + ); + + const renderChatViewHtml = moduleObject.exports["__renderChatViewHtml"] as + | ((...args: readonly unknown[]) => unknown) + | undefined; + if (renderChatViewHtml === undefined) { + assert.fail("renderChatViewHtml was not found in compiled chatView.js"); + } + + const html = renderChatViewHtml( + { cspSource: "vscode-webview://test" }, + { + eventCount: 1, + items: [ + { + body: "## Summary\n\n---\n\n- Item\n\n```text\nhello\n```", + id: "assistant-1", + kind: "assistant", + lastSeq: 1, + runId: "run_1", + seq: 1, + time: "2026-06-05T00:00:00.000Z", + title: "DeepSeek", + tone: "neutral", + type: "assistant.delta", + }, + ], + latestStatus: "Assistant", + }, + { busy: false }, + { runs: [], status: "idle" }, + { omitted: [], segments: [], sources: [], totalTokens: 0 }, + { status: "idle" }, + ) as string; + + assert.match(html, /aria-label="Send message"/); + assert.match(html, /message-edit/); + assert.match(html, /\.composer-row\s*\{[^}]*flex-wrap: nowrap/s); + assert.match(html, /\.send,\s*\n\s*\.cancel\s*\{[^}]*width: 28px/s); + assert.match(html, /\.send-icon\[hidden\]\s*\{[^}]*display: none !important/s); + assert.match(html, /\.mode\[hidden\]\s*\{[^}]*display: none !important/s); + assert.match(html, /\.approval-card\s*\{[^}]*max-height: min\(70vh, 32rem\)/s); + assert.match(html, /\.approval-section\s*\{/); + assert.match(html, /Long shell commands intentionally wrap vertically/); + assert.match(html, /\.approval-command \.approval-section-body\s*\{/); + assert.match(html, /\.steer-confirm\s*\{/); + assert.match(html, /id="steer-confirmation" class="steer-confirmation-host"/); + assert.match(html, /\.active-work-status\s*\{/); + assert.match(html, /\.work-log-segment-summary\s*\{/); + assert.match(html, /\.item-children\s*\{/); + assert.match(html, /details\.item:not\(\[open\]\) > \.item-children\s*\{/); + assert.match(html, /