diff --git a/.gitignore b/.gitignore index b3007af..338c0a7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ # Node / pnpm / TypeScript node_modules/ .pnpm-store/ +.vscode-test/ dist/ out/ dist-test/ diff --git a/README.md b/README.md index f00f3f6..d847d9d 100644 --- a/README.md +++ b/README.md @@ -362,7 +362,9 @@ TypeScript workspace,需要先运行 `pnpm install`: pnpm -r typecheck pnpm -r lint pnpm -r test -pnpm -C vscode/extension package +pnpm run vscode:test-electron +pnpm run vsix:smoke +pnpm run vsix:alpha ``` 全量检查: @@ -566,7 +568,7 @@ extension.ts ## 开发计划 -当前进度:Phase 1 Agent Core MVP 功能闭环、Phase 2 的 1M Context Capsule 核心收敛和 Phase 3 的 VS Code 插件核心与共享 RPC 交互管线均已完成。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 可视化和命令子进程树清理均已完成。下一步进入 Phase 4 的 VS Code 深度集成。 +当前进度: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 与生态扩展。 阶段完成口径:README 中某个 Phase 只有在 `docs/phase-tasks.md` 对应 Phase 下的所有任务都标记为 `[x]` 后,才能在高层开发计划中表述为“全部完成”。如果某阶段核心功能已完成但仍有 P1/P2 增强或发布/文档验收项未完成,README 必须继续把该阶段表述为进行中,并列出剩余任务。 @@ -666,21 +668,36 @@ extension.ts ### Phase 4:VS Code 深度集成 -- [ ] Problems 面板 diagnostics 进入 Context Builder。 -- [ ] Terminal command approval。 -- [ ] provider、model、预算、审批策略和 RPC 命令配置界面。 -- [ ] RPC 高频事件输出节流与批量发送策略。 -- [ ] 事件 payload schema 与协议 fixture 对齐。 -- [ ] 审批持久化存储。 -- [ ] 真实 hunk 级 patch 审批。 -- [ ] FIM completion preview。 -- [ ] Provider capability model:显式表达 thinking、tool choice、FIM、stream usage、cache usage、上下文和输出限制。 -- [ ] VSIX alpha / pre-release 打包与插件安装说明。 +- [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 缓冲回归测试。 验收标准: - 插件不需要用户手动打开终端即可完成一次“诊断 -> 修改 -> 测试 -> 报告”。 - 插件和 CLI 对同一任务产生一致的 run log。 +- VS Code 插件可通过 VSIX 安装到 clean 环境。 +- fixture provider 下 Chat sendTurn、Cancel、Problems diagnostics、审批和 Run List / resume 至少有一条 extension-host 或可重复手动验收路径。 +- 配置界面不保存 API Key,只管理非敏感配置。 +- `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 表述为整阶段完成。 ### Phase 5:TUI 与生态扩展 diff --git a/crates/agent-core/src/provider/deepseek_api.rs b/crates/agent-core/src/provider/deepseek_api.rs index beac4bd..347645d 100644 --- a/crates/agent-core/src/provider/deepseek_api.rs +++ b/crates/agent-core/src/provider/deepseek_api.rs @@ -8,9 +8,12 @@ use thiserror::Error; use url::Url; pub const DEFAULT_API_BASE_URL: &str = "https://api.deepseek.com"; +pub const DEFAULT_FIM_API_BASE_URL: &str = "https://api.deepseek.com/beta"; 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 FIM_MAX_TOKENS_LIMIT: u32 = 4096; #[derive(Debug, Error)] pub enum DeepSeekApiError { @@ -27,6 +30,10 @@ pub enum DeepSeekApiError { UnsupportedBaseUrlScheme { scheme: String }, #[error("chat completion request must include at least one message")] EmptyMessages, + #[error("FIM completion request prefix must not be empty")] + EmptyFimPrefix, + #[error("FIM completion max_tokens must be between 1 and {FIM_MAX_TOKENS_LIMIT}")] + InvalidFimMaxTokens, #[error("reasoning_effort requires thinking.type = enabled")] ReasoningEffortRequiresEnabledThinking, #[error("tool_choice is not supported while DeepSeek thinking mode is enabled")] @@ -142,6 +149,15 @@ impl DeepSeekApiConfig { Self::new(api_key, base_url, model) } + pub fn from_env_for_fim() -> Result { + // DeepSeek chat and beta FIM endpoints currently share the same API key. + let api_key = env::var("DEEPSEEK_API_KEY").map_err(|_| DeepSeekApiError::MissingApiKey)?; + let base_url = + env::var("DEEPSEEK_BASE_URL").unwrap_or_else(|_| DEFAULT_FIM_API_BASE_URL.to_owned()); + let model = env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_owned()); + Self::new(api_key, base_url, model) + } + pub fn with_timeout(mut self, timeout: Duration) -> Self { self.timeout = timeout; self @@ -251,6 +267,22 @@ impl DeepSeekApiAdapter { decode_chat_completion_stream(response).await } + + pub async fn create_fim_completion( + &self, + request: FimCompletionRequest, + ) -> 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() + .await?; + + decode_fim_completion_response(response).await + } } async fn decode_chat_completion_response( @@ -266,6 +298,18 @@ async fn decode_chat_completion_response( serde_json::from_str(&body).map_err(|source| DeepSeekApiError::InvalidJson { source, body }) } +async fn decode_fim_completion_response( + response: reqwest::Response, +) -> Result { + let status = response.status(); + let body = response.text().await?; + if !status.is_success() { + return Err(DeepSeekApiError::Api { status, body }); + } + + serde_json::from_str(&body).map_err(|source| DeepSeekApiError::InvalidJson { source, body }) +} + async fn decode_chat_completion_stream( response: reqwest::Response, ) -> Result { @@ -411,6 +455,60 @@ impl ChatCompletionRequest { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct FimCompletionRequest { + pub model: DeepSeekModelId, + pub prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub suffix: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, +} + +impl FimCompletionRequest { + pub fn new( + model: DeepSeekModelId, + prompt: impl Into, + ) -> Result { + let prompt = prompt.into(); + if prompt.is_empty() { + return Err(DeepSeekApiError::EmptyFimPrefix); + } + + Ok(Self { + model, + prompt, + suffix: None, + max_tokens: None, + stream: Some(false), + }) + } + + pub fn with_suffix(mut self, suffix: impl Into) -> Self { + self.suffix = Some(suffix.into()); + self + } + + pub fn with_max_tokens(mut self, max_tokens: u32) -> Self { + self.max_tokens = Some(max_tokens); + self + } + + pub fn validate_for_deepseek(&self) -> Result<(), DeepSeekApiError> { + if self.prompt.is_empty() { + return Err(DeepSeekApiError::EmptyFimPrefix); + } + if let Some(max_tokens) = self.max_tokens + && !(1..=FIM_MAX_TOKENS_LIMIT).contains(&max_tokens) + { + return Err(DeepSeekApiError::InvalidFimMaxTokens); + } + Ok(()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ThinkingConfig { #[serde(rename = "type")] @@ -831,6 +929,23 @@ pub struct ChatCompletionMessage { pub tool_calls: Option>, } +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct FimCompletionResponse { + pub id: String, + pub choices: Vec, + pub created: u64, + pub model: String, + pub object: String, + pub usage: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct FimCompletionChoice { + pub index: u32, + pub text: String, + pub finish_reason: Option, +} + #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct ChatCompletionChunk { pub id: String, @@ -1003,8 +1118,9 @@ mod tests { use super::{ ChatCompletionRequest, ChatFunctionCallDelta, ChatMessage, ChatTool, ChatToolCall, ChatToolCallAccumulator, ChatToolCallAccumulatorError, ChatToolCallDelta, ChatToolType, - DeepSeekApiConfig, DeepSeekApiError, DeepSeekModelId, ReasoningEffort, SseEventParser, - StreamEvent, StreamOptions, ThinkingConfig, ToolChoice, parse_stream_event_block, + DeepSeekApiConfig, DeepSeekApiError, DeepSeekModelId, FimCompletionRequest, + ReasoningEffort, SseEventParser, StreamEvent, StreamOptions, ThinkingConfig, ToolChoice, + parse_stream_event_block, }; #[test] @@ -1055,6 +1171,68 @@ mod tests { ); } + #[test] + fn fim_request_serializes_prefix_suffix_and_non_streaming_default() { + let request = FimCompletionRequest::new( + DeepSeekModelId::new(DeepSeekModelId::V4_PRO).expect("model should be valid"), + "fn main() {", + ) + .expect("FIM request should be valid") + .with_suffix("}") + .with_max_tokens(32); + + let json = serde_json::to_value(request).expect("request should serialize"); + + assert_eq!(json["model"], "deepseek-v4-pro"); + assert_eq!(json["prompt"], "fn main() {"); + assert_eq!(json["suffix"], "}"); + assert_eq!(json["max_tokens"], 32); + assert_eq!(json["stream"], false); + } + + #[test] + fn fim_request_rejects_invalid_max_tokens() { + let request = FimCompletionRequest::new( + DeepSeekModelId::new(DeepSeekModelId::V4_PRO).expect("model should be valid"), + "prefix", + ) + .expect("FIM request should be valid") + .with_max_tokens(0); + + let error = request + .validate_for_deepseek() + .expect_err("zero FIM max_tokens should fail"); + + assert!(matches!(error, DeepSeekApiError::InvalidFimMaxTokens)); + } + + #[test] + fn fim_response_deserializes_text_choice() { + let response = r#"{ + "id": "fim-test", + "object": "text_completion", + "created": 1710000000, + "model": "deepseek-v4-pro", + "choices": [ + { "index": 0, "text": " println!(\"hi\"); ", "finish_reason": "stop" } + ], + "usage": { + "prompt_tokens": 3, + "completion_tokens": 2, + "total_tokens": 5 + } + }"#; + + let response: super::FimCompletionResponse = + serde_json::from_str(response).expect("FIM response should parse"); + + assert_eq!(response.choices[0].text, " println!(\"hi\"); "); + assert_eq!( + response.choices[0].finish_reason, + Some(super::FinishReason::Stop) + ); + } + #[test] fn request_serializes_thinking_payload() { let request = ChatCompletionRequest::new( diff --git a/crates/agent-core/src/tool_execution.rs b/crates/agent-core/src/tool_execution.rs index 45e1758..6cf6b5f 100644 --- a/crates/agent-core/src/tool_execution.rs +++ b/crates/agent-core/src/tool_execution.rs @@ -603,7 +603,7 @@ pub struct WorkspaceManifestArgs { pub max_entries: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceManifestResult { pub status: ToolStatus, @@ -675,6 +675,21 @@ pub struct ApplyPatchArgs { pub expected_files: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PatchApprovalHunk { + pub id: String, + pub file_path: String, + pub file_index: usize, + pub hunk_index: usize, + pub old_start: usize, + pub old_count: usize, + pub new_start: usize, + pub new_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub section: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct ApplyPatchResult { @@ -1130,6 +1145,72 @@ struct ParsedPatch { } impl ParsedPatch { + fn to_unified_diff(&self) -> String { + let mut output = String::new(); + for file in &self.files { + output.push_str(&format!( + "--- {}\n+++ {}\n", + file.format_old_path(), + file.format_new_path() + )); + for hunk in &file.hunks { + output.push_str(&format!( + "@@ -{}{} +{}{} @@{}\n", + hunk.old_start, + format_count(hunk.old_count), + hunk.new_start, + format_count(hunk.new_count), + hunk.section + )); + for line in &hunk.lines { + match line { + PatchLine::Context(text) => { + output.push(' '); + output.push_str(text); + output.push('\n'); + } + PatchLine::Remove(text) => { + output.push('-'); + output.push_str(text); + output.push('\n'); + } + PatchLine::Add(text) => { + output.push('+'); + output.push_str(text); + output.push('\n'); + } + } + } + } + } + output + } + + fn approval_hunks(&self) -> Result, ToolExecutionError> { + let mut hunks = Vec::new(); + for (file_index, file) in self.files.iter().enumerate() { + let file_path = file.target_path()?; + for (hunk_index, hunk) in file.hunks.iter().enumerate() { + hunks.push(PatchApprovalHunk { + id: patch_hunk_id(&file_path, hunk_index, hunk), + file_path: file_path.clone(), + file_index, + hunk_index, + old_start: hunk.old_start, + old_count: hunk.old_count, + new_start: hunk.new_start, + new_count: hunk.new_count, + section: if hunk.section.trim().is_empty() { + None + } else { + Some(hunk.section.trim().to_owned()) + }, + }); + } + } + Ok(hunks) + } + fn reverse_patch(&self) -> String { let mut output = String::new(); for file in &self.files { @@ -1315,6 +1396,94 @@ fn parse_unified_diff(diff: &str) -> Result { Ok(ParsedPatch { files }) } +pub fn patch_approval_hunks(diff: &str) -> Result, ToolExecutionError> { + let parsed = parse_unified_diff(diff)?; + parsed.approval_hunks() +} + +pub fn filter_apply_patch_hunks( + args: ApplyPatchArgs, + approved_hunk_ids: &[String], +) -> Result { + if approved_hunk_ids.is_empty() { + return Err(ToolExecutionError::InvalidPatch( + "hunk approval must include at least one hunk id".to_owned(), + )); + } + + let approved: BTreeSet<&str> = approved_hunk_ids.iter().map(String::as_str).collect(); + let parsed = parse_unified_diff(&args.unified_diff)?; + let mut seen = BTreeSet::new(); + let mut selected_files = Vec::new(); + let mut expected_files = BTreeSet::new(); + + for file in parsed.files { + let mut selected_hunks = Vec::new(); + let hunk_count = file.hunks.len(); + let file_path = file.target_path()?; + let mut all_delta: isize = 0; + let mut selected_delta: isize = 0; + for (hunk_index, hunk) in file.hunks.into_iter().enumerate() { + let id = patch_hunk_id(&file_path, hunk_index, &hunk); + if approved.contains(id.as_str()) { + seen.insert(id); + let omitted_delta = all_delta - selected_delta; + let mut selected_hunk = hunk.clone(); + selected_hunk.new_start = + adjusted_hunk_start(selected_hunk.new_start, omitted_delta)?; + selected_delta += hunk.new_count as isize - hunk.old_count as isize; + selected_hunks.push(selected_hunk); + } + all_delta += hunk.new_count as isize - hunk.old_count as isize; + } + + if selected_hunks.is_empty() { + continue; + } + + if selected_hunks.len() != hunk_count + && (file.old_path.is_none() || file.new_path.is_none()) + { + return Err(ToolExecutionError::InvalidPatch(format!( + "hunk approval for file creation or deletion must include every hunk in `{file_path}`" + ))); + } + + expected_files.insert(file_path); + selected_files.push(FilePatch { + old_path: file.old_path, + new_path: file.new_path, + hunks: selected_hunks, + }); + } + + let missing: Vec = approved + .into_iter() + .filter(|id| !seen.contains(*id)) + .map(str::to_owned) + .collect(); + if !missing.is_empty() { + return Err(ToolExecutionError::InvalidPatch(format!( + "approved hunk id(s) were not present in the patch: {}", + missing.join(", ") + ))); + } + + if selected_files.is_empty() { + return Err(ToolExecutionError::InvalidPatch( + "hunk approval did not select any patch hunks".to_owned(), + )); + } + + let filtered = ParsedPatch { + files: selected_files, + }; + Ok(ApplyPatchArgs { + unified_diff: filtered.to_unified_diff(), + expected_files: expected_files.into_iter().collect(), + }) +} + fn parse_patch_path(path: &str) -> Result, ToolExecutionError> { let path = path.split('\t').next().unwrap_or(path); if path == "/dev/null" { @@ -1380,6 +1549,28 @@ fn format_count(count: usize) -> String { } } +fn patch_hunk_id(file_path: &str, hunk_index: usize, hunk: &PatchHunk) -> String { + format!( + "{}#{}:old{}+{}:new{}+{}", + file_path, + hunk_index + 1, + hunk.old_start, + hunk.old_count, + hunk.new_start, + hunk.new_count + ) +} + +fn adjusted_hunk_start(start: usize, omitted_delta: isize) -> Result { + let adjusted = start as isize - omitted_delta; + if adjusted < 0 { + return Err(ToolExecutionError::InvalidPatch( + "selected patch hunk line numbers underflow after filtering".to_owned(), + )); + } + Ok(adjusted as usize) +} + fn apply_file_patch(original: &str, patch: &FilePatch) -> Result { let original_had_trailing_newline = original.ends_with('\n'); let original_lines: Vec<&str> = original.lines().collect(); @@ -1625,6 +1816,46 @@ mod tests { assert!(result.reverse_patch.contains("+old")); } + #[test] + fn filter_apply_patch_hunks_keeps_only_selected_hunks() { + let args = ApplyPatchArgs { + unified_diff: concat!( + "--- a/README.md\n", + "+++ b/README.md\n", + "@@ -1,3 +1,4 @@\n", + " one\n", + "+intro\n", + "-old\n", + "+new\n", + " three\n", + "@@ -5,2 +6,3 @@\n", + " keep\n", + "+insert\n", + " remove\n", + ) + .to_owned(), + expected_files: vec!["README.md".to_owned()], + }; + + let hunks = super::patch_approval_hunks(&args.unified_diff) + .expect("patch should expose approval hunks"); + let filtered = super::filter_apply_patch_hunks(args, &[hunks[1].id.clone()]) + .expect("selected hunk should filter patch"); + + assert_eq!( + filtered.unified_diff, + concat!( + "--- a/README.md\n", + "+++ b/README.md\n", + "@@ -5,2 +5,3 @@\n", + " keep\n", + "+insert\n", + " remove\n", + ) + ); + assert_eq!(filtered.expected_files, vec!["README.md"]); + } + #[test] fn apply_patch_rejects_unexpected_files() { let workspace = TestWorkspace::new("tool-execution"); diff --git a/crates/agent-core/src/turn_loop.rs b/crates/agent-core/src/turn_loop.rs index e803d69..595fa0d 100644 --- a/crates/agent-core/src/turn_loop.rs +++ b/crates/agent-core/src/turn_loop.rs @@ -18,19 +18,22 @@ use crate::{ ReasoningContentError, ReasoningContentMode, ReasoningContentState, ReasoningContentStateMachine, }, - run_log::{RunLogError, RunLogEvent, RunLogWriter}, + run_log::{RunLogError, RunLogEvent, RunLogWriter, redact_text}, tool::{ ToolArgumentSchemaError, ToolDefinition, ToolName, find_builtin_tool, validate_tool_arguments, }, tool_execution::{ - ApplyPatchArgs, ReadFileArgs, ShellArgs, ToolExecutionError, ToolStatus, - WorkspaceManifestArgs, WorkspaceToolExecutor, redacted_tool_result_value, + ApplyPatchArgs, PatchApprovalHunk, ReadFileArgs, ShellArgs, ShellResult, + ToolExecutionError, ToolStatus, WorkspaceManifestArgs, WorkspaceToolExecutor, + filter_apply_patch_hunks, patch_approval_hunks, redacted_tool_result_value, }, }; const DEFAULT_MAX_ATTACHMENTS: usize = 32; const DEFAULT_MAX_ATTACHMENT_BYTES: u64 = 256 * 1024; +const SHELL_APPROVAL_OUTPUT_SUMMARY_MAX_LINES: usize = 8; +const SHELL_APPROVAL_OUTPUT_SUMMARY_MAX_BYTES: usize = 2 * 1024; #[derive(Debug)] pub struct AgentTurnLoop { @@ -178,6 +181,7 @@ where let mut messages = vec![ChatMessage::user(context.content)]; let mut tool_results = Vec::new(); let mut changed_files = Vec::new(); + let mut last_shell_output_summary = None; for iteration in 1..=self.config.max_model_turns { let prepared = self.reasoning.prepare_messages(&messages)?; @@ -270,10 +274,12 @@ where 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(), @@ -285,6 +291,9 @@ where status: executed.status, result: executed.log_result, }); + if let Some(output_summary) = follow_up_output_summary { + last_shell_output_summary = Some(output_summary); + } } } @@ -667,7 +676,8 @@ where } ToolName::ApplyPatch => { let args: ApplyPatchArgs = parse_tool_arguments(tool_call, &arguments_preview)?; - self.ensure_approval( + let approval_hunks = patch_approval_hunks(&args.unified_diff)?; + let approval_scope = self.ensure_approval( definition, &risk_assessment, tool_call, @@ -676,9 +686,16 @@ where context.tool_index, Some(args.expected_files.clone()), None, + None, + None, + Some(approval_hunks), run_log, event_sink, )?; + let args = match approval_scope { + ApprovalScope::All => args, + ApprovalScope::Hunks { hunk_ids } => filter_apply_patch_hunks(args, &hunk_ids)?, + }; self.execute_without_approval( tool_call, context, @@ -708,6 +725,9 @@ where context.tool_index, args.cwd.clone().map(|cwd| vec![cwd]), Some(args.command.clone()), + Some(args.cwd.clone().unwrap_or_else(|| ".".to_owned())), + context.previous_shell_output_summary.map(str::to_owned), + None, run_log, event_sink, )?; @@ -719,7 +739,7 @@ where event_sink, |tools, args, cancellation_token| { let result = tools.shell_with_cancellation(args, cancellation_token)?; - tool_record(result.status, result.summary.clone(), Vec::new(), &result) + shell_tool_record(result) }, ) } @@ -821,12 +841,15 @@ where tool_index: usize, paths: Option>, command: Option, + cwd: Option, + output_summary: Option, + hunks: Option>, run_log: &mut (impl RunLogWriter + ?Sized), event_sink: &mut (impl TurnEventSink + ?Sized), - ) -> Result<(), AgentTurnLoopError> { + ) -> Result { let approval = effective_approval_requirement(definition.approval, risk_assessment.risk); if approval == ApprovalRequirement::None { - return Ok(()); + return Ok(ApprovalScope::All); } let request = TurnApprovalRequest { @@ -842,7 +865,10 @@ where &risk_assessment.risk_reasons, ), command, + cwd, + output_summary, paths, + hunks, risk_reasons: risk_assessment.risk_reasons.clone(), persistable: approval_persistable(approval, risk_assessment.risk), }; @@ -862,9 +888,25 @@ where event_sink, "tool.approvalResolved", Some(turn_id.to_owned()), - approval_resolved_payload(&request, "approved", None), + approval_resolved_payload( + &request, + "approved", + None, + Some(&ApprovalScope::All), + ), )?; - Ok(()) + Ok(ApprovalScope::All) + } + ApprovalDecision::ApprovedHunks { hunk_ids } => { + let approval_scope = ApprovalScope::Hunks { hunk_ids }; + append_turn_event( + run_log, + event_sink, + "tool.approvalResolved", + Some(turn_id.to_owned()), + approval_resolved_payload(&request, "approved", None, Some(&approval_scope)), + )?; + Ok(approval_scope) } ApprovalDecision::Rejected { reason } => { append_turn_event( @@ -872,7 +914,7 @@ where event_sink, "tool.approvalResolved", Some(turn_id.to_owned()), - approval_resolved_payload(&request, "rejected", Some(reason.as_str())), + approval_resolved_payload(&request, "rejected", Some(reason.as_str()), None), )?; Err(AgentTurnLoopError::ApprovalRejected { approval_id: request.approval_id, @@ -886,7 +928,7 @@ where event_sink, "tool.approvalResolved", Some(turn_id.to_owned()), - approval_resolved_payload(&request, "canceled", Some(reason.as_str())), + approval_resolved_payload(&request, "canceled", Some(reason.as_str()), None), )?; Err(AgentTurnLoopError::ApprovalCanceled { approval_id: request.approval_id, @@ -900,7 +942,7 @@ where event_sink, "tool.approvalResolved", Some(turn_id.to_owned()), - approval_resolved_payload(&request, "expired", Some(reason.as_str())), + approval_resolved_payload(&request, "expired", Some(reason.as_str()), None), )?; Err(AgentTurnLoopError::ApprovalExpired { approval_id: request.approval_id, @@ -1467,7 +1509,10 @@ pub struct TurnApprovalRequest { pub title: String, pub detail: String, pub command: Option, + pub cwd: Option, + pub output_summary: Option, pub paths: Option>, + pub hunks: Option>, pub risk_reasons: Vec, pub persistable: bool, } @@ -1475,11 +1520,18 @@ pub struct TurnApprovalRequest { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ApprovalDecision { Approved, + ApprovedHunks { hunk_ids: Vec }, Rejected { reason: String }, Canceled { reason: String }, Expired { reason: String }, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApprovalScope { + All, + Hunks { hunk_ids: Vec }, +} + pub trait ApprovalPolicy { fn decide( &mut self, @@ -1673,6 +1725,7 @@ struct ExecutedToolCall { message_content: String, log_result: Value, changed_files: Vec, + follow_up_output_summary: Option, } struct CollectedProviderTurn { @@ -1689,6 +1742,7 @@ struct ToolCallContext<'a> { turn_id: &'a str, iteration: usize, tool_index: usize, + previous_shell_output_summary: Option<&'a str>, cancellation_token: &'a CancellationToken, } @@ -1743,9 +1797,79 @@ fn tool_record( message_content, log_result, changed_files, + follow_up_output_summary: None, }) } +fn shell_tool_record(result: ShellResult) -> Result { + let output_summary = shell_approval_output_summary(&result); + let log_result = redacted_tool_result_value(&result)?; + let message_content = serde_json::to_string(&log_result)?; + Ok(ExecutedToolCall { + status: result.status, + summary: result.summary, + message_content, + log_result, + changed_files: Vec::new(), + follow_up_output_summary: output_summary, + }) +} + +fn shell_approval_output_summary(result: &ShellResult) -> Option { + let mut sections = Vec::new(); + if let Some(stdout) = shell_output_tail_section("stdout", &result.stdout) { + sections.push(stdout); + } + if let Some(stderr) = shell_output_tail_section("stderr", &result.stderr) { + sections.push(stderr); + } + if sections.is_empty() { + return None; + } + + let exit_code = result + .exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "unknown".to_owned()); + let summary = format!("exitCode: {exit_code}\n{}", sections.join("\n")); + Some(truncate_shell_output_summary(&summary)) +} + +fn shell_output_tail_section(label: &str, output: &str) -> Option { + let redacted = redact_text(output); + let trimmed = redacted.trim_end(); + if trimmed.is_empty() { + return None; + } + + let line_count = trimmed.lines().count(); + let omitted = line_count.saturating_sub(SHELL_APPROVAL_OUTPUT_SUMMARY_MAX_LINES); + let shown = line_count - omitted; + let mut section = if omitted == 0 { + format!("{label}:") + } else { + format!("{label} (last {shown} of {line_count} lines):") + }; + for line in trimmed.lines().skip(omitted) { + section.push('\n'); + section.push_str(line); + } + Some(section) +} + +fn truncate_shell_output_summary(text: &str) -> String { + const TRUNCATED_MARKER: &str = "\n[output summary truncated]"; + if text.len() <= SHELL_APPROVAL_OUTPUT_SUMMARY_MAX_BYTES { + return text.to_owned(); + } + + let mut end = SHELL_APPROVAL_OUTPUT_SUMMARY_MAX_BYTES - TRUNCATED_MARKER.len(); + while !text.is_char_boundary(end) { + end -= 1; + } + format!("{}{}", &text[..end], TRUNCATED_MARKER) +} + fn reasoning_state_payload(state: ReasoningContentState) -> Value { match state { ReasoningContentState::NoReplayRequired => { @@ -1896,12 +2020,27 @@ fn approval_payload(request: &TurnApprovalRequest) -> Value { if let Some(command) = &request.command { payload.insert("command".to_owned(), Value::String(command.clone())); } + if let Some(cwd) = &request.cwd { + payload.insert("cwd".to_owned(), Value::String(cwd.clone())); + } + if let Some(output_summary) = &request.output_summary { + payload.insert( + "outputSummary".to_owned(), + Value::String(output_summary.clone()), + ); + } if let Some(paths) = &request.paths { payload.insert( "paths".to_owned(), Value::Array(paths.iter().cloned().map(Value::String).collect()), ); } + if let Some(hunks) = &request.hunks { + payload.insert( + "hunks".to_owned(), + serde_json::to_value(hunks).expect("patch approval hunks must serialize"), + ); + } if !request.risk_reasons.is_empty() { payload.insert( "riskReasons".to_owned(), @@ -1923,6 +2062,7 @@ fn approval_resolved_payload( request: &TurnApprovalRequest, decision: &'static str, reason: Option<&str>, + scope: Option<&ApprovalScope>, ) -> Value { let mut payload = Map::new(); payload.insert( @@ -1941,6 +2081,22 @@ fn approval_resolved_payload( if let Some(reason) = reason { payload.insert("reason".to_owned(), Value::String(reason.to_owned())); } + if let Some(scope) = scope { + let mut scope_payload = Map::new(); + match scope { + ApprovalScope::All => { + scope_payload.insert("scope".to_owned(), Value::String("all".to_owned())); + } + ApprovalScope::Hunks { hunk_ids } => { + scope_payload.insert("scope".to_owned(), Value::String("selected".to_owned())); + scope_payload.insert( + "approved".to_owned(), + Value::Array(hunk_ids.iter().cloned().map(Value::String).collect()), + ); + } + } + payload.insert("hunks".to_owned(), Value::Object(scope_payload)); + } Value::Object(payload) } @@ -2014,7 +2170,8 @@ mod tests { use super::{ AgentRunMode, AgentTurnInput, AgentTurnLoop, AgentTurnLoopConfig, AgentTurnLoopError, - AutoApprovePolicy, CancellationToken, TextRange, TurnAttachment, TurnEventSink, + ApprovalDecision, ApprovalPolicy, ApprovalPolicyError, AutoApprovePolicy, + CancellationToken, TextRange, TurnApprovalRequest, TurnAttachment, TurnEventSink, TurnEventSinkError, TurnProvider, TurnProviderCompletion, TurnProviderDelta, TurnProviderError, TurnProviderEvent, TurnProviderFinishReason, TurnProviderFuture, TurnProviderRequest, TurnProviderResponse, TurnProviderStream, @@ -2421,6 +2578,8 @@ mod tests { .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"]) @@ -2476,6 +2635,74 @@ mod tests { ); } + #[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_rejects_tool_arguments_before_typed_deserialization() { let workspace = TestWorkspace::new("turn-loop"); @@ -2575,6 +2802,76 @@ mod tests { assert_eq!(completed.payload["changedFiles"], json!(["README.md"])); } + #[tokio::test] + async fn turn_loop_applies_only_approved_patch_hunks() { + 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"); + let mut run = store + .create_run("run_turn_patch_hunks") + .expect("run should be created"); + let patch = concat!( + "--- a/README.md\n", + "+++ b/README.md\n", + "@@ -1,3 +1,3 @@\n", + " one\n", + "-old\n", + "+new\n", + " three\n", + "@@ -5,2 +5,3 @@\n", + " keep\n", + "+insert\n", + " remove\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::with_approval_policy(workspace.path(), provider, HunkApprovePolicy) + .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"); + + assert_eq!( + workspace.read("README.md"), + "one\nold\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"]) + ); + } + #[tokio::test] async fn turn_loop_allows_tool_calls_without_reasoning_when_thinking_is_disabled() { let workspace = TestWorkspace::new("turn-loop"); @@ -2806,6 +3103,21 @@ 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 { diff --git a/crates/agent-rpc/src/lib.rs b/crates/agent-rpc/src/lib.rs index ef5aae4..f0271df 100644 --- a/crates/agent-rpc/src/lib.rs +++ b/crates/agent-rpc/src/lib.rs @@ -1,9 +1,10 @@ #![forbid(unsafe_code)] use std::{ - collections::HashMap, + collections::{HashMap, HashSet, VecDeque}, + fs, io::{self, BufRead, Write}, - path::PathBuf, + path::{Path, PathBuf}, sync::{Arc, Condvar, Mutex, mpsc}, thread, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, @@ -55,7 +56,9 @@ pub const REJECT_METHOD: RpcMethod = RpcMethod::new("reject"); pub const CANCEL_METHOD: RpcMethod = RpcMethod::new("cancel"); pub const RESUME_METHOD: RpcMethod = RpcMethod::new("resume"); pub const LIST_RUNS_METHOD: RpcMethod = RpcMethod::new("listRuns"); +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"); pub const JSON_RPC_PARSE_ERROR: i64 = -32700; pub const JSON_RPC_INVALID_REQUEST: i64 = -32600; @@ -79,6 +82,9 @@ pub const RPC_INTERNAL_INVARIANT: i64 = -32060; 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 APPROVAL_PERSISTENCE_FILE: &str = "approvals.v1.json"; +const APPROVAL_PERSISTENCE_VERSION: u32 = 1; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct JsonRpcRequest { @@ -178,6 +184,65 @@ pub struct ServerInfo { pub version: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderModelCapabilities { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + pub context_window_tokens: u64, + pub max_output_tokens: u64, + pub supports_thinking: bool, + pub supports_tool_calls: bool, + pub supports_tool_choice: bool, + pub supports_fim: bool, + pub supports_streaming: bool, + pub reports_cache_usage: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderCapabilities { + pub provider: String, + pub default_model: String, + pub models: Vec, +} + +impl Default for ProviderCapabilities { + fn default() -> Self { + Self { + provider: "deepseek".to_owned(), + default_model: "deepseek-v4-pro".to_owned(), + models: vec![ + ProviderModelCapabilities { + id: "deepseek-v4-flash".to_owned(), + display_name: Some("DeepSeek V4 Flash".to_owned()), + context_window_tokens: 1_048_576, + max_output_tokens: 393_216, + supports_thinking: true, + supports_tool_calls: true, + supports_tool_choice: false, + supports_fim: true, + supports_streaming: true, + reports_cache_usage: true, + }, + ProviderModelCapabilities { + id: "deepseek-v4-pro".to_owned(), + display_name: Some("DeepSeek V4 Pro".to_owned()), + context_window_tokens: 1_048_576, + max_output_tokens: 393_216, + supports_thinking: true, + supports_tool_calls: true, + supports_tool_choice: false, + supports_fim: true, + supports_streaming: true, + reports_cache_usage: true, + }, + ], + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ServerCapabilities { @@ -185,7 +250,9 @@ pub struct ServerCapabilities { pub supports_run_resume: bool, pub supports_patch_approval: bool, pub supports_persistent_approvals: bool, + pub supports_event_batching: bool, pub supported_risk_levels: Vec, + pub provider: ProviderCapabilities, } impl Default for ServerCapabilities { @@ -194,11 +261,13 @@ impl Default for ServerCapabilities { protocol_version: PROTOCOL_VERSION.to_owned(), supports_run_resume: true, supports_patch_approval: true, - supports_persistent_approvals: false, + supports_persistent_approvals: true, + supports_event_batching: true, supported_risk_levels: ALL_RISK_LEVELS .iter() .map(|risk| risk.as_str().to_owned()) .collect(), + provider: ProviderCapabilities::default(), } } } @@ -403,6 +472,14 @@ pub struct ApproveParams { pub approval_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub persist: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hunks: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcApprovedHunks { + pub approved: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -411,6 +488,8 @@ pub struct ApproveResult { pub approval_id: String, pub state: RpcApprovalState, pub persist: RpcApprovalPersistence, + #[serde(skip_serializing_if = "Option::is_none")] + pub hunks: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -447,6 +526,31 @@ pub struct CancelResult { pub reason: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FimPreviewParams { + pub prefix: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub suffix: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub language_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FimPreviewResult { + pub text: String, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub finish_reason: Option, +} + #[derive(Debug, Clone, PartialEq)] pub struct AgentRpcHandlerOutput { pub result: TResult, @@ -541,6 +645,11 @@ pub trait AgentRpcRequestHandler { params: ListRunsParams, ) -> Result, AgentRpcHandlerError>; + fn preview_fim( + &mut self, + params: FimPreviewParams, + ) -> Result, AgentRpcHandlerError>; + fn shutdown(&mut self) -> Result, AgentRpcHandlerError> { Ok(Vec::new()) } @@ -553,6 +662,16 @@ pub trait RpcTurnProviderFactory { &mut self, params: &SendTurnParams, ) -> Result; + + fn preview_fim( + &mut self, + _params: &FimPreviewParams, + ) -> Result { + Err(AgentRpcHandlerError::new( + RPC_PROVIDER_ERROR, + "FIM preview is not supported by this RPC provider", + )) + } } impl RpcTurnProviderFactory for F @@ -644,6 +763,8 @@ where } let store = RunLogStore::new(¶ms.workspace_root).map_err(map_run_log_error)?; + self.approval_queue + .configure_workspace_persistence(store.workspace_root())?; let result = AgentInitializeResult::default(); self.workspace = Some(RpcWorkspace { store }); Ok(result) @@ -716,7 +837,11 @@ where params: ApproveParams, ) -> Result, AgentRpcHandlerError> { let persist = params.persist.unwrap_or(RpcApprovalPersistence::Never); - if let Err(error) = self.approval_queue.approve(¶ms.approval_id, persist) { + let hunks = params.hunks.clone(); + if let Err(error) = self + .approval_queue + .approve(¶ms.approval_id, persist, params.hunks) + { self.drain_ready_active_run_events()?; return Err(error); } @@ -730,6 +855,7 @@ where approval_id: params.approval_id, state: RpcApprovalState::Approved, persist, + hunks, }) .with_events(events)) } @@ -867,6 +993,14 @@ where Ok(AgentRpcHandlerOutput::new(ListRunsResult { runs })) } + fn preview_fim( + &mut self, + params: FimPreviewParams, + ) -> Result, AgentRpcHandlerError> { + let result = self.provider_factory.preview_fim(¶ms)?; + Ok(AgentRpcHandlerOutput::new(result)) + } + fn shutdown(&mut self) -> Result, AgentRpcHandlerError> { let mut events = self.drain_ready_active_run_events()?; let Some(active_run) = self.active_run.as_ref() else { @@ -1107,9 +1241,24 @@ impl Default for RpcApprovalQueue { struct RpcApprovalQueueInner { approval_timeout: Duration, pending: Mutex>, + persistence: Mutex, changed: Condvar, } +#[derive(Debug, Default)] +struct RpcApprovalPersistenceState { + session_keys: HashSet, + workspace_keys: HashSet, + workspace_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RpcApprovalPersistenceFile { + version: u32, + approvals: Vec, +} + #[derive(Debug, Clone)] struct PendingApproval { run_id: String, @@ -1124,11 +1273,27 @@ impl RpcApprovalQueue { inner: Arc::new(RpcApprovalQueueInner { approval_timeout, pending: Mutex::new(HashMap::new()), + persistence: Mutex::new(RpcApprovalPersistenceState::default()), changed: Condvar::new(), }), } } + fn configure_workspace_persistence( + &self, + workspace_root: &Path, + ) -> Result<(), AgentRpcHandlerError> { + let path = workspace_root + .join(AGENT_METADATA.state_dir) + .join(APPROVAL_PERSISTENCE_FILE); + let workspace_keys = load_workspace_approvals(&path)?; + let mut persistence = self.lock_persistence()?; + persistence.session_keys.clear(); + persistence.workspace_path = Some(path); + persistence.workspace_keys = workspace_keys; + Ok(()) + } + fn register( &self, run_id: String, @@ -1166,8 +1331,15 @@ impl RpcApprovalQueue { &self, approval_id: &str, persist: RpcApprovalPersistence, + hunks: Option, ) -> Result<(), AgentRpcHandlerError> { - self.resolve(approval_id, ApprovalDecision::Approved, Some(persist)) + let decision = match hunks { + Some(hunks) => ApprovalDecision::ApprovedHunks { + hunk_ids: hunks.approved, + }, + None => ApprovalDecision::Approved, + }; + self.resolve(approval_id, decision, Some(persist)) } fn reject(&self, approval_id: &str, reason: String) -> Result<(), AgentRpcHandlerError> { @@ -1207,6 +1379,12 @@ impl RpcApprovalQueue { return Ok(decision); } + if self.is_persistently_approved(&entry.request)? { + pending.remove(&request.approval_id); + self.inner.changed.notify_all(); + return Ok(ApprovalDecision::Approved); + } + if Instant::now() >= entry.expires_at { pending.remove(&request.approval_id); self.inner.changed.notify_all(); @@ -1249,6 +1427,65 @@ impl RpcApprovalQueue { decision: ApprovalDecision, persist: Option, ) -> Result<(), AgentRpcHandlerError> { + let request_to_persist = { + let pending = self.lock_pending()?; + let entry = pending.get(approval_id).ok_or_else(|| { + AgentRpcHandlerError::new( + RPC_APPROVAL_NOT_FOUND, + format!("approval `{approval_id}` is not pending in the current RPC handler"), + ) + })?; + + if entry.decision.is_some() { + return Err(AgentRpcHandlerError::new( + RPC_APPROVAL_NOT_FOUND, + format!("approval `{approval_id}` has already been resolved"), + )); + } + + if Instant::now() >= entry.expires_at { + return Err(AgentRpcHandlerError::new( + RPC_APPROVAL_NOT_FOUND, + format!("approval `{approval_id}` expired before it could be resolved"), + )); + } + + validate_approval_decision(&entry.request, &decision, persist)?; + + if matches!( + persist, + Some(RpcApprovalPersistence::Session | RpcApprovalPersistence::Workspace) + ) && !approval_request_allows_persistence(&entry.request) + { + return Err(AgentRpcHandlerError::new( + RPC_APPROVAL_DENIED, + format!("approval `{approval_id}` does not allow persistent decisions"), + )); + } + + if matches!(decision, ApprovalDecision::Approved) + && matches!( + persist, + Some(RpcApprovalPersistence::Session | RpcApprovalPersistence::Workspace) + ) + { + Some(entry.request.clone()) + } else { + None + } + }; + + let persistence_key = request_to_persist + .as_ref() + .map(approval_persistence_key) + .transpose()? + .flatten(); + if let (Some(key), Some(RpcApprovalPersistence::Workspace)) = + (persistence_key.as_ref(), persist) + { + self.write_workspace_approval_key(key)?; + } + let mut pending = self.lock_pending()?; let entry = pending.get_mut(approval_id).ok_or_else(|| { AgentRpcHandlerError::new( @@ -1275,22 +1512,63 @@ impl RpcApprovalQueue { )); } - if matches!( - persist, - Some(RpcApprovalPersistence::Session | RpcApprovalPersistence::Workspace) - ) && !entry.request.persistable - { - return Err(AgentRpcHandlerError::new( - RPC_APPROVAL_DENIED, - format!("approval `{approval_id}` does not allow persistent decisions"), - )); - } - entry.decision = Some(decision); self.inner.changed.notify_all(); + drop(pending); + if let (Some(key), Some(persist)) = (persistence_key, persist) { + self.remember_persistent_key(key, persist)?; + } Ok(()) } + fn is_persistently_approved( + &self, + request: &TurnApprovalRequest, + ) -> Result { + let Some(key) = approval_persistence_key(request)? else { + return Ok(false); + }; + let persistence = self.lock_persistence()?; + Ok(persistence.session_keys.contains(&key) || persistence.workspace_keys.contains(&key)) + } + + fn write_workspace_approval_key(&self, key: &str) -> Result<(), AgentRpcHandlerError> { + let (path, keys) = { + let persistence = self.lock_persistence()?; + let mut keys = persistence.workspace_keys.clone(); + keys.insert(key.to_owned()); + let path = persistence.workspace_path.clone().ok_or_else(|| { + AgentRpcHandlerError::new( + RPC_INTERNAL_INVARIANT, + "workspace approval persistence was not configured", + ) + })?; + (path, keys) + }; + + write_workspace_approvals(&path, &keys) + } + + fn remember_persistent_key( + &self, + key: String, + persist: RpcApprovalPersistence, + ) -> Result<(), AgentRpcHandlerError> { + match persist { + RpcApprovalPersistence::Never => Ok(()), + RpcApprovalPersistence::Session => { + let mut persistence = self.lock_persistence()?; + persistence.session_keys.insert(key); + Ok(()) + } + RpcApprovalPersistence::Workspace => { + let mut persistence = self.lock_persistence()?; + persistence.workspace_keys.insert(key); + Ok(()) + } + } + } + fn lock_pending( &self, ) -> Result>, AgentRpcHandlerError> @@ -1299,6 +1577,17 @@ impl RpcApprovalQueue { AgentRpcHandlerError::new(RPC_INTERNAL_INVARIANT, "approval queue lock was poisoned") }) } + + fn lock_persistence( + &self, + ) -> Result, AgentRpcHandlerError> { + self.inner.persistence.lock().map_err(|_| { + AgentRpcHandlerError::new( + RPC_INTERNAL_INVARIANT, + "approval persistence lock was poisoned", + ) + }) + } } fn approval_expires_at(timeout: Duration) -> Instant { @@ -1310,6 +1599,160 @@ fn approval_expired_reason(approval_id: &str) -> String { format!("approval `{approval_id}` expired before a decision was received") } +fn validate_approval_decision( + request: &TurnApprovalRequest, + decision: &ApprovalDecision, + persist: Option, +) -> Result<(), AgentRpcHandlerError> { + let ApprovalDecision::ApprovedHunks { hunk_ids } = decision else { + return Ok(()); + }; + + if matches!( + persist, + Some(RpcApprovalPersistence::Session | RpcApprovalPersistence::Workspace) + ) { + return Err(AgentRpcHandlerError::new( + RPC_APPROVAL_DENIED, + format!( + "approval `{}` cannot persist a hunk-level patch decision", + request.approval_id + ), + )); + } + + if hunk_ids.is_empty() { + return Err(AgentRpcHandlerError::new( + RPC_APPROVAL_DENIED, + format!( + "approval `{}` must include at least one approved hunk", + request.approval_id + ), + )); + } + + let Some(available_hunks) = &request.hunks else { + return Err(AgentRpcHandlerError::new( + RPC_APPROVAL_DENIED, + format!( + "approval `{}` does not support hunk-level decisions", + request.approval_id + ), + )); + }; + + let mut available = HashSet::new(); + for hunk in available_hunks { + available.insert(hunk.id.as_str()); + } + + let mut seen = HashSet::new(); + for hunk_id in hunk_ids { + if !seen.insert(hunk_id.as_str()) { + return Err(AgentRpcHandlerError::new( + RPC_APPROVAL_DENIED, + format!( + "approval `{}` contains duplicate hunk id `{hunk_id}`", + request.approval_id + ), + )); + } + if !available.contains(hunk_id.as_str()) { + return Err(AgentRpcHandlerError::new( + RPC_APPROVAL_DENIED, + format!( + "approval `{}` does not contain hunk id `{hunk_id}`", + request.approval_id + ), + )); + } + } + + Ok(()) +} + +fn approval_request_allows_persistence(request: &TurnApprovalRequest) -> bool { + request.persistable && !matches!(request.risk, RiskLevel::Network | RiskLevel::Destructive) +} + +fn approval_persistence_key( + request: &TurnApprovalRequest, +) -> Result, AgentRpcHandlerError> { + if !approval_request_allows_persistence(request) { + return Ok(None); + } + + let mut paths = request.paths.clone().unwrap_or_default(); + paths.sort_unstable(); + paths.dedup(); + serde_json::to_string(&json!({ + "toolName": request.tool_name.as_str(), + "risk": request.risk.as_str(), + "command": request.command.as_deref(), + "cwd": request.cwd.as_deref(), + "paths": paths, + })) + .map(Some) + .map_err(|source| { + AgentRpcHandlerError::new( + RPC_INTERNAL_INVARIANT, + format!("approval persistence key could not be serialized: {source}"), + ) + }) +} + +fn load_workspace_approvals(path: &Path) -> Result, AgentRpcHandlerError> { + if !path.exists() { + return Ok(HashSet::new()); + } + + let bytes = fs::read(path).map_err(map_io_error)?; + let file: RpcApprovalPersistenceFile = serde_json::from_slice(&bytes).map_err(|source| { + AgentRpcHandlerError::new( + RPC_INTERNAL_INVARIANT, + format!("approval persistence file is malformed: {source}"), + ) + })?; + if file.version != APPROVAL_PERSISTENCE_VERSION { + return Err(AgentRpcHandlerError::new( + RPC_INTERNAL_INVARIANT, + format!( + "unsupported approval persistence version {}, expected {}", + file.version, APPROVAL_PERSISTENCE_VERSION + ), + )); + } + + Ok(file.approvals.into_iter().collect()) +} + +fn write_workspace_approvals( + path: &Path, + keys: &HashSet, +) -> Result<(), AgentRpcHandlerError> { + let parent = path.parent().ok_or_else(|| { + AgentRpcHandlerError::new( + RPC_INTERNAL_INVARIANT, + "approval persistence path has no parent directory", + ) + })?; + fs::create_dir_all(parent).map_err(map_io_error)?; + + let mut approvals = keys.iter().cloned().collect::>(); + approvals.sort_unstable(); + let file = RpcApprovalPersistenceFile { + version: APPROVAL_PERSISTENCE_VERSION, + approvals, + }; + let bytes = serde_json::to_vec_pretty(&file).map_err(|source| { + AgentRpcHandlerError::new( + RPC_INTERNAL_INVARIANT, + format!("approval persistence file could not be serialized: {source}"), + ) + })?; + fs::write(path, bytes).map_err(map_io_error) +} + #[derive(Debug, Clone)] struct RpcApprovalPolicy { queue: RpcApprovalQueue, @@ -1524,7 +1967,10 @@ fn approval_request_from_event( title: payload.title, detail: payload.detail, command: payload.command, + cwd: payload.cwd, + output_summary: payload.output_summary, paths: payload.paths, + hunks: payload.hunks, risk_reasons: payload.risk_reasons, persistable: payload.persistable, }) @@ -1540,7 +1986,10 @@ struct ApprovalRequiredPayload { title: String, detail: String, command: Option, + cwd: Option, + output_summary: Option, paths: Option>, + hunks: Option>, #[serde(default)] risk_reasons: Vec, persistable: bool, @@ -1653,6 +2102,7 @@ impl JsonRpcNotification { } pub type AgentEventNotification = JsonRpcNotification; +pub type AgentEventBatchNotification = JsonRpcNotification; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1667,6 +2117,15 @@ pub struct AgentEventEnvelope { pub payload: Value, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentEventBatchParams { + pub events: Vec, + pub first_seq: u64, + pub last_seq: u64, + pub count: usize, +} + pub fn run_log_event_to_envelope(event: &RunLogEvent) -> Result { Ok(AgentEventEnvelope { seq: event.seq, @@ -1687,6 +2146,28 @@ pub fn run_log_event_to_notification( )) } +pub fn run_log_events_to_batch_notification( + events: &[RunLogEvent], +) -> Result { + let envelopes = events + .iter() + .map(run_log_event_to_envelope) + .collect::, _>>()?; + let first_seq = envelopes.first().map_or(0, |event| event.seq); + let last_seq = envelopes.last().map_or(0, |event| event.seq); + let count = envelopes.len(); + + Ok(JsonRpcNotification::new( + EVENT_BATCH_METHOD, + AgentEventBatchParams { + events: envelopes, + first_seq, + last_seq, + count, + }, + )) +} + #[derive(Debug)] pub struct StdioEventBridge { writer: W, @@ -1822,6 +2303,9 @@ where method if method == LIST_RUNS_METHOD.qualified_name() => { self.handle_list_runs(id, message.params, writer) } + method if method == FIM_PREVIEW_METHOD.qualified_name() => { + self.handle_preview_fim(id, message.params, writer) + } method => write_error( writer, id, @@ -2047,6 +2531,32 @@ where Err(error) => write_error(writer, id, error.into_error_object()), } } + + fn handle_preview_fim( + &mut self, + id: Value, + params: Option, + writer: &mut W, + ) -> Result<(), AgentRpcError> + where + W: Write, + { + let params = match parse_params::( + params, + FIM_PREVIEW_METHOD.qualified_name().as_str(), + ) { + Ok(params) => params, + Err(error) => return write_error(writer, id, error), + }; + + match self.handler.preview_fim(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()), + } + } } #[derive(Debug)] @@ -2098,7 +2608,16 @@ where let mut shutdown_started = false; let mut pending_error = None; - while let Ok(message) = loop_rx.recv() { + let mut pending_messages = VecDeque::new(); + loop { + let message = match pending_messages.pop_front() { + Some(message) => message, + None => match loop_rx.recv() { + Ok(message) => message, + Err(_) => break, + }, + }; + match message { RpcLoopMessage::InputLine(Ok(line)) if !shutdown_started => { let result = server.handle_line(&line, writer); @@ -2118,7 +2637,20 @@ where detach_stdio_loop_handler(&mut server); } RpcLoopMessage::RunEvent(event) => { - let result = emit_run_log_events(writer, std::slice::from_ref(&event)); + let mut events = vec![event]; + while events.len() < RPC_LIVE_EVENT_BATCH_MAX { + match loop_rx.try_recv() { + Ok(RpcLoopMessage::RunEvent(event)) => events.push(event), + Ok(other) => { + pending_messages.push_back(other); + break; + } + Err(mpsc::TryRecvError::Empty | mpsc::TryRecvError::Disconnected) => { + break; + } + } + } + let result = emit_live_run_log_events(writer, &events); handle_stdio_write_result(&mut server, result, &disconnect_handle)?; } RpcLoopMessage::InputLine(_) | RpcLoopMessage::ReaderEof => {} @@ -2204,6 +2736,18 @@ where Ok(()) } +fn emit_live_run_log_events(writer: &mut W, events: &[RunLogEvent]) -> Result<(), AgentRpcError> +where + W: Write, +{ + if events.len() <= 1 { + return emit_run_log_events(writer, events); + } + + let notification = run_log_events_to_batch_notification(events)?; + write_json_line(writer, ¬ification) +} + #[derive(Debug, Clone, PartialEq)] struct IncomingMessage { jsonrpc: String, @@ -2352,18 +2896,20 @@ mod tests { const RPC_TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); use prole_coder_agent_core::{ + approval::RiskLevel, provider::deepseek_api::ChatToolCall, run_log::{RunLogEvent, RunLogStore}, test_helpers::TestWorkspace, turn_loop::{ - AgentTurnInput, AgentTurnLoopConfig, TurnEventSink, TurnProvider, TurnProviderError, - TurnProviderFuture, TurnProviderRequest, TurnProviderResponse, - turn_provider_response_stream, + AgentTurnInput, AgentTurnLoopConfig, ApprovalDecision, TurnApprovalRequest, + TurnEventSink, TurnProvider, TurnProviderError, TurnProviderFuture, + TurnProviderRequest, TurnProviderResponse, turn_provider_response_stream, }, }; + use serde::Deserialize; use serde_json::{Value, json}; use std::{ - collections::VecDeque, + collections::{HashMap, VecDeque}, io::{self, Cursor, Read, Write}, sync::{Arc, Condvar, Mutex, mpsc}, thread, @@ -2374,17 +2920,19 @@ mod tests { APPROVE_METHOD, ActiveRunSpawn, AgentInitializeParams, AgentInitializeResult, AgentRpcError, AgentRpcHandlerError, AgentRpcHandlerOutput, AgentRpcRequestHandler, AgentTurnLoopRpcHandler, ApproveParams, ApproveResult, CANCEL_METHOD, CancelParams, - CancelResult, EVENT_METHOD, 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, RpcRunState, RpcRunSummary, RpcRunSummaryStatus, RpcWorkspace, - SEND_TURN_METHOD, SendTurnParams, SendTurnResult, StdioEventBridge, format_unix_millis, - run_log_event_to_notification, run_stdio_request_loop, spawn_active_run, + 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, }; #[test] @@ -2396,7 +2944,9 @@ mod tests { assert_eq!(CANCEL_METHOD.qualified_name(), "agent.cancel"); assert_eq!(RESUME_METHOD.qualified_name(), "agent.resume"); assert_eq!(LIST_RUNS_METHOD.qualified_name(), "agent.listRuns"); + 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"); } #[test] @@ -2454,6 +3004,210 @@ mod tests { assert_eq!(notification.params.payload["text"], "hello"); } + #[test] + fn default_initialize_result_exposes_provider_capabilities() { + let result = AgentInitializeResult::default(); + let value = serde_json::to_value(&result).expect("initialize result should serialize"); + + assert_eq!(value["capabilities"]["supportsEventBatching"], true); + assert_eq!( + value["capabilities"]["provider"]["defaultModel"], + "deepseek-v4-pro" + ); + assert_eq!( + value["capabilities"]["provider"]["models"][0]["contextWindowTokens"], + 1_048_576 + ); + assert_eq!( + value["capabilities"]["provider"]["models"][1]["maxOutputTokens"], + 393_216 + ); + } + + #[test] + fn run_log_events_convert_to_agent_event_batch_notification() { + let events = vec![ + run_log_event( + 10, + "assistant.delta", + "run_01", + Some("turn_01"), + json!({ "text": "hello" }), + ), + run_log_event( + 11, + "assistant.delta", + "run_01", + Some("turn_01"), + json!({ "text": " world" }), + ), + ]; + + let notification = run_log_events_to_batch_notification(&events) + .expect("batch notification should convert"); + + assert_eq!(notification.jsonrpc, "2.0"); + assert_eq!(notification.method, "agent.eventBatch"); + assert_eq!(notification.params.first_seq, 10); + assert_eq!(notification.params.last_seq, 11); + assert_eq!(notification.params.count, 2); + assert_eq!(notification.params.events[0].seq, 10); + assert_eq!(notification.params.events[1].payload["text"], " world"); + } + + #[test] + fn live_event_emission_batches_multiple_queued_events() { + let events = vec![ + run_log_event( + 1, + "assistant.delta", + "run_01", + Some("turn_01"), + json!({ "text": "a" }), + ), + run_log_event( + 2, + "assistant.delta", + "run_01", + Some("turn_01"), + json!({ "text": "b" }), + ), + ]; + let mut output = Vec::new(); + + emit_live_run_log_events(&mut output, &events) + .expect("live events should be emitted as one batch"); + + let lines = output_lines(output); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["method"], "agent.eventBatch"); + assert_eq!(lines[0]["params"]["firstSeq"], 1); + assert_eq!(lines[0]["params"]["lastSeq"], 2); + assert_eq!(lines[0]["params"]["count"], 2); + } + + #[test] + fn live_event_emission_keeps_single_event_wire_compatible() { + let event = run_log_event( + 1, + "assistant.delta", + "run_01", + Some("turn_01"), + json!({ "text": "hello" }), + ); + let mut output = Vec::new(); + + emit_live_run_log_events(&mut output, &[event]).expect("single live event should emit"); + + let lines = output_lines(output); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0]["method"], "agent.event"); + assert_eq!(lines[0]["params"]["seq"], 1); + } + + #[test] + fn event_payload_fixture_matches_rust_payload_samples() { + #[derive(Deserialize)] + struct EventPayloadFixture { + version: String, + events: Vec, + } + + #[derive(Deserialize)] + struct EventPayloadFixtureEntry { + #[serde(rename = "type")] + event_type: String, + required: Vec, + } + + let fixture: EventPayloadFixture = serde_json::from_str(include_str!( + "../../../docs/protocol/event-payloads.v1.json" + )) + .expect("event payload fixture should parse"); + let samples = HashMap::from([ + ( + "provider.requested", + json!({ + "iteration": 1, + "messageCount": 4, + "reasoningState": { "status": "active" } + }), + ), + ( + "tool.completed", + json!({ + "toolCallId": "call_1", + "name": "shell", + "status": "ok", + "summary": "Command completed.", + "result": { "exitCode": 0 } + }), + ), + ( + "run.completed", + json!({ + "summary": "Updated the workspace.", + "changedFiles": ["README.md"], + "verificationStatus": "passed" + }), + ), + ( + "tool.approvalRequired", + json!({ + "approvalId": "approval_1", + "toolCallId": "call_patch", + "toolName": "apply_patch", + "risk": "write", + "title": "Apply patch", + "detail": "Modify README.md", + "paths": ["README.md"], + "hunks": [{ + "id": "README.md#1:old1+3:new1+3", + "filePath": "README.md", + "fileIndex": 0, + "hunkIndex": 0, + "oldStart": 1, + "oldCount": 3, + "newStart": 1, + "newCount": 3 + }], + "persistable": true + }), + ), + ( + "tool.approvalResolved", + json!({ + "approvalId": "approval_1", + "toolCallId": "call_patch", + "toolName": "apply_patch", + "decision": "approved", + "hunks": { + "scope": "selected", + "approved": ["README.md#1:old1+3:new1+3"] + } + }), + ), + ]); + + assert_eq!(fixture.version, PROTOCOL_VERSION); + for event in fixture.events { + let sample = samples + .get(event.event_type.as_str()) + .unwrap_or_else(|| panic!("missing sample for {}", event.event_type)); + let object = sample + .as_object() + .expect("sample payload must be an object"); + for field in event.required { + assert!( + object.contains_key(&field), + "{} must include required field {}", + event.event_type, + field + ); + } + } + } + #[test] fn stdio_bridge_writes_newline_delimited_notifications() { let events = vec![ @@ -2723,6 +3477,48 @@ mod tests { ); } + #[test] + fn request_loop_handles_fim_preview_requests() { + let input = [ + json!({ + "jsonrpc": "2.0", + "id": "init_1", + "method": "agent.initialize", + "params": initialize_params() + }) + .to_string(), + json!({ + "jsonrpc": "2.0", + "id": "fim_1", + "method": "agent.previewFim", + "params": { + "prefix": "fn main() {", + "suffix": "}", + "path": "src/main.rs", + "languageId": "rust", + "model": "fixture-fim", + "maxTokens": 32 + } + }) + .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.fim_previews.len(), 1); + assert_eq!(handler.fim_previews[0].language_id.as_deref(), Some("rust")); + assert_eq!(handler.fim_previews[0].max_tokens, Some(32)); + let lines = output_lines(output); + assert_eq!(lines.len(), 2); + assert_eq!(lines[1]["id"], "fim_1"); + assert_eq!(lines[1]["result"]["text"], "fixture completion"); + assert_eq!(lines[1]["result"]["model"], "fixture-fim"); + } + #[test] fn request_loop_dispatches_approval_decisions() { let input = [ @@ -3271,25 +4067,20 @@ mod tests { assert_eq!(workspace.read("README.md"), "old\n"); let lines = output.lines(); + let events = agent_event_values(&lines); let cancel_response_index = line_index(&lines, |line| line["id"] == "cancel_1"); assert_eq!(lines[cancel_response_index]["result"]["state"], "canceled"); assert_eq!( lines[cancel_response_index]["result"]["reason"], "user changed their mind" ); - assert!(lines.iter().any(|line| { - line["method"] == "agent.event" - && line["params"]["type"] == "tool.approvalResolved" - && line["params"]["payload"]["decision"] == "canceled" - })); - assert!(lines.iter().any(|line| { - line["method"] == "agent.event" - && line["params"]["type"] == "run.canceled" - && line["params"]["payload"]["code"] == "E_APPROVAL_CANCELED" + assert!(events.iter().any(|event| { + event["type"] == "tool.approvalResolved" && event["payload"]["decision"] == "canceled" })); - assert!(!lines.iter().any(|line| { - line["method"] == "agent.event" && line["params"]["type"] == "tool.started" + assert!(events.iter().any(|event| { + event["type"] == "run.canceled" && event["payload"]["code"] == "E_APPROVAL_CANCELED" })); + assert!(!events.iter().any(|event| event["type"] == "tool.started")); } #[test] @@ -3399,6 +4190,7 @@ mod tests { "EOF shutdown should finish the active run" ); let lines = output.lines(); + let events = agent_event_values(&lines); let first_turn_response_index = line_index(&lines, |line| line["id"] == "turn_1"); assert_eq!( lines[first_turn_response_index]["result"]["runId"], @@ -3410,18 +4202,16 @@ mod tests { lines[second_turn_error_index]["error"]["code"], RPC_RUN_ALREADY_ACTIVE ); - assert!(lines.iter().any(|line| { - line["method"] == "agent.event" - && line["params"]["type"] == "tool.approvalResolved" - && line["params"]["runId"] == "run_rpc_active_disconnect" - && line["params"]["payload"]["decision"] == "canceled" - && line["params"]["payload"]["reason"] == "RPC client disconnected" + assert!(events.iter().any(|event| { + event["type"] == "tool.approvalResolved" + && event["runId"] == "run_rpc_active_disconnect" + && event["payload"]["decision"] == "canceled" + && event["payload"]["reason"] == "RPC client disconnected" })); - assert!(lines.iter().any(|line| { - line["method"] == "agent.event" - && line["params"]["type"] == "run.canceled" - && line["params"]["runId"] == "run_rpc_active_disconnect" - && line["params"]["payload"]["code"] == "E_APPROVAL_CANCELED" + assert!(events.iter().any(|event| { + event["type"] == "run.canceled" + && event["runId"] == "run_rpc_active_disconnect" + && event["payload"]["code"] == "E_APPROVAL_CANCELED" })); } @@ -3533,11 +4323,142 @@ mod tests { .approve(ApproveParams { approval_id: "approval_missing".to_owned(), persist: None, + hunks: None, }) .expect_err("missing approval should be rejected"); assert_eq!(approval_error.code, RPC_APPROVAL_NOT_FOUND); } + #[test] + fn approval_queue_reuses_session_persistent_approval() { + let queue = RpcApprovalQueue::new(Duration::from_secs(60)); + let first = sample_turn_approval_request("approval_1", RiskLevel::Exec, true); + queue + .register("run_1".to_owned(), first.clone()) + .expect("approval should register"); + queue + .approve("approval_1", RpcApprovalPersistence::Session, None) + .expect("session approval should resolve"); + assert_eq!( + queue + .wait_for_decision(&first) + .expect("first approval should resolve"), + ApprovalDecision::Approved + ); + + let second = sample_turn_approval_request("approval_2", RiskLevel::Exec, true); + queue + .register("run_2".to_owned(), second.clone()) + .expect("second approval should register"); + assert_eq!( + queue + .wait_for_decision(&second) + .expect("second approval should be auto-approved"), + ApprovalDecision::Approved + ); + } + + #[test] + fn approval_queue_reuses_workspace_persistent_approval_after_reload() { + let workspace = TestWorkspace::new("rpc-approval-persistence"); + let queue = RpcApprovalQueue::new(Duration::from_secs(60)); + queue + .configure_workspace_persistence(workspace.path()) + .expect("workspace persistence should configure"); + let first = sample_turn_approval_request("approval_1", RiskLevel::Exec, true); + queue + .register("run_1".to_owned(), first.clone()) + .expect("approval should register"); + queue + .approve("approval_1", RpcApprovalPersistence::Workspace, None) + .expect("workspace approval should resolve"); + assert_eq!( + queue + .wait_for_decision(&first) + .expect("first approval should resolve"), + ApprovalDecision::Approved + ); + + let reloaded = RpcApprovalQueue::new(Duration::from_secs(60)); + reloaded + .configure_workspace_persistence(workspace.path()) + .expect("workspace persistence should reload"); + let second = sample_turn_approval_request("approval_2", RiskLevel::Exec, true); + reloaded + .register("run_2".to_owned(), second.clone()) + .expect("second approval should register"); + assert_eq!( + reloaded + .wait_for_decision(&second) + .expect("workspace approval should be auto-approved"), + ApprovalDecision::Approved + ); + } + + #[test] + fn approval_queue_rejects_persistence_for_network_and_destructive_risks() { + for risk in [RiskLevel::Network, RiskLevel::Destructive] { + let queue = RpcApprovalQueue::new(Duration::from_secs(60)); + let request = sample_turn_approval_request("approval_1", risk, true); + queue + .register("run_1".to_owned(), request) + .expect("approval should register"); + let error = queue + .approve("approval_1", RpcApprovalPersistence::Session, None) + .expect_err("high-risk approvals must not be persisted"); + + assert_eq!(error.code, RPC_APPROVAL_DENIED); + } + } + + #[test] + fn approval_queue_resolves_hunk_level_patch_decisions() { + let queue = RpcApprovalQueue::new(Duration::from_secs(60)); + let request = sample_patch_turn_approval_request("approval_1"); + queue + .register("run_1".to_owned(), request.clone()) + .expect("approval should register"); + queue + .approve( + "approval_1", + RpcApprovalPersistence::Never, + Some(RpcApprovedHunks { + approved: vec!["README.md#2:old5+2:new5+3".to_owned()], + }), + ) + .expect("hunk approval should resolve"); + + assert_eq!( + queue + .wait_for_decision(&request) + .expect("hunk approval should be returned"), + ApprovalDecision::ApprovedHunks { + hunk_ids: vec!["README.md#2:old5+2:new5+3".to_owned()] + } + ); + } + + #[test] + fn approval_queue_rejects_hunk_decisions_for_non_patch_approvals() { + let queue = RpcApprovalQueue::new(Duration::from_secs(60)); + let request = sample_turn_approval_request("approval_1", RiskLevel::Exec, true); + queue + .register("run_1".to_owned(), request) + .expect("approval should register"); + + let error = queue + .approve( + "approval_1", + RpcApprovalPersistence::Never, + Some(RpcApprovedHunks { + approved: vec!["README.md#1:old1+1:new1+1".to_owned()], + }), + ) + .expect_err("non-patch hunk approvals should be rejected"); + + assert_eq!(error.code, RPC_APPROVAL_DENIED); + } + #[test] fn approval_request_from_event_uses_typed_payload_schema() { let event = run_log_event( @@ -3552,6 +4473,8 @@ mod tests { "risk": "write", "title": "Apply patch", "detail": "Modify README.md", + "cwd": ".", + "outputSummary": "previous command output summary", "paths": ["README.md"], "riskReasons": ["file deletion"], "persistable": true @@ -3569,6 +4492,11 @@ mod tests { prole_coder_agent_core::approval::RiskLevel::Write ); assert_eq!(request.paths, Some(vec!["README.md".to_owned()])); + assert_eq!(request.cwd, Some(".".to_owned())); + assert_eq!( + request.output_summary, + Some("previous command output summary".to_owned()) + ); assert_eq!(request.risk_reasons, vec!["file deletion".to_owned()]); assert!(request.persistable); } @@ -3596,6 +4524,69 @@ mod tests { assert_eq!(error.code, RPC_INTERNAL_INVARIANT); } + fn sample_turn_approval_request( + approval_id: &str, + risk: RiskLevel, + persistable: bool, + ) -> TurnApprovalRequest { + TurnApprovalRequest { + approval_id: approval_id.to_owned(), + tool_call_id: "call_shell".to_owned(), + tool_name: "shell".to_owned(), + risk, + title: "Run shell command".to_owned(), + detail: "Execute cargo test".to_owned(), + command: Some("cargo test".to_owned()), + cwd: Some(".".to_owned()), + output_summary: None, + paths: None, + hunks: None, + risk_reasons: Vec::new(), + persistable, + } + } + + fn sample_patch_turn_approval_request(approval_id: &str) -> TurnApprovalRequest { + TurnApprovalRequest { + approval_id: approval_id.to_owned(), + tool_call_id: "call_patch".to_owned(), + tool_name: "apply_patch".to_owned(), + risk: RiskLevel::Write, + title: "Apply patch".to_owned(), + detail: "Modify README.md".to_owned(), + command: None, + cwd: None, + output_summary: None, + paths: Some(vec!["README.md".to_owned()]), + hunks: Some(vec![ + prole_coder_agent_core::tool_execution::PatchApprovalHunk { + id: "README.md#1:old1+3:new1+3".to_owned(), + file_path: "README.md".to_owned(), + file_index: 0, + hunk_index: 0, + old_start: 1, + old_count: 3, + new_start: 1, + new_count: 3, + section: None, + }, + prole_coder_agent_core::tool_execution::PatchApprovalHunk { + id: "README.md#2:old5+2:new5+3".to_owned(), + file_path: "README.md".to_owned(), + file_index: 0, + hunk_index: 1, + old_start: 5, + old_count: 2, + new_start: 5, + new_count: 3, + section: Some("next block".to_owned()), + }, + ]), + risk_reasons: Vec::new(), + persistable: true, + } + } + fn initialize_params() -> Value { initialize_params_for("C:/workspace/project") } @@ -3621,6 +4612,22 @@ mod tests { .collect() } + // Extracts event envelopes from single-event and batch wire formats for assertions. + // Batch metadata is validated by dedicated batch tests, not by this helper. + fn agent_event_values(lines: &[Value]) -> Vec { + let mut events = Vec::new(); + for line in lines { + if line["method"] == "agent.event" { + events.push(line["params"].clone()); + } else if line["method"] == "agent.eventBatch" + && let Some(batch_events) = line["params"]["events"].as_array() + { + events.extend(batch_events.iter().cloned()); + } + } + events + } + fn line_index(lines: &[Value], predicate: impl Fn(&Value) -> bool) -> usize { lines .iter() @@ -3767,6 +4774,7 @@ mod tests { cancellations: Vec, resumes: Vec, list_runs: Vec, + fim_previews: Vec, } impl AgentRpcRequestHandler for TestHandler { @@ -3810,6 +4818,7 @@ mod tests { approval_id: params.approval_id, state: RpcApprovalState::Approved, persist: params.persist.unwrap_or(RpcApprovalPersistence::Never), + hunks: params.hunks, })) } @@ -3879,6 +4888,18 @@ mod tests { }], })) } + + fn preview_fim( + &mut self, + params: FimPreviewParams, + ) -> Result, AgentRpcHandlerError> { + self.fim_previews.push(params); + Ok(AgentRpcHandlerOutput::new(FimPreviewResult { + text: "fixture completion".to_owned(), + model: "fixture-fim".to_owned(), + finish_reason: Some("stop".to_owned()), + })) + } } fn run_log_event( diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index c431bb8..008b0a9 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, FinishReason, StreamEvent, - ThinkingConfig, Usage, + ChatToolCallAccumulator, DeepSeekApiAdapter, DeepSeekApiConfig, DeepSeekModelId, + FimCompletionRequest, FinishReason, StreamEvent, ThinkingConfig, Usage, }, reasoning::ReasoningContentMode, run_log::{RunLog, RunLogError, RunLogStore}, @@ -36,12 +36,12 @@ use prole_coder_agent_core::{ }, }; use prole_coder_agent_rpc::{ - AgentRpcError, AgentRpcHandlerError, AgentTurnLoopRpcHandler, JSON_RPC_INTERNAL_ERROR, - JSON_RPC_INVALID_PARAMS, JsonRpcErrorObject, JsonRpcErrorResponse, RPC_APPROVAL_DENIED, - 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, RpcTurnProviderFactory, SendTurnParams, StdioEventBridge, - run_stdio_request_loop, + AgentRpcError, AgentRpcHandlerError, AgentTurnLoopRpcHandler, FimPreviewParams, + FimPreviewResult, JSON_RPC_INTERNAL_ERROR, JSON_RPC_INVALID_PARAMS, JsonRpcErrorObject, + JsonRpcErrorResponse, RPC_APPROVAL_DENIED, 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, RpcTurnProviderFactory, SendTurnParams, + StdioEventBridge, run_stdio_request_loop, }; use serde_json::{Value, json}; use thiserror::Error; @@ -988,6 +988,75 @@ impl RpcTurnProviderFactory for CliRpcProviderFactory { ) .map_err(|error| AgentRpcHandlerError::new(RPC_INTERNAL_INVARIANT, error.to_string())) } + + fn preview_fim( + &mut self, + params: &FimPreviewParams, + ) -> Result { + match self.provider { + ProviderKind::Fixture => Ok(FimPreviewResult { + text: fixture_fim_preview_text(params), + model: params + .model + .clone() + .unwrap_or_else(|| "fixture-fim".to_owned()), + finish_reason: Some("stop".to_owned()), + }), + ProviderKind::DeepSeek => deepseek_fim_preview(params), + } + } +} + +fn fixture_fim_preview_text(params: &FimPreviewParams) -> String { + if params.language_id.as_deref() == Some("rust") { + " println!(\"prole fixture\");".to_owned() + } else { + " prole fixture completion".to_owned() + } +} + +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 adapter = DeepSeekApiAdapter::new(config) + .map_err(|error| AgentRpcHandlerError::new(RPC_PROVIDER_ERROR, error.to_string()))?; + let model = params + .model + .clone() + .unwrap_or_else(|| adapter.config().model().to_string()); + let model_id = DeepSeekModelId::new(model.clone()) + .map_err(|error| AgentRpcHandlerError::new(RPC_PROVIDER_ERROR, error.to_string()))?; + let mut request = FimCompletionRequest::new(model_id, params.prefix.clone()) + .map_err(|error| AgentRpcHandlerError::new(RPC_PROVIDER_ERROR, error.to_string()))?; + if let Some(suffix) = ¶ms.suffix { + request = request.with_suffix(suffix.clone()); + } + if let Some(max_tokens) = params.max_tokens { + request = request.with_max_tokens(max_tokens); + } + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .thread_name("prole-coder-fim") + .build() + .map_err(|error| AgentRpcHandlerError::new(RPC_INTERNAL_INVARIANT, error.to_string()))?; + let response = runtime + .block_on(adapter.create_fim_completion(request)) + .map_err(|error| AgentRpcHandlerError::new(RPC_PROVIDER_ERROR, error.to_string()))?; + let first_choice = response.choices.first().ok_or_else(|| { + AgentRpcHandlerError::new( + RPC_PROVIDER_ERROR, + "DeepSeek FIM response did not include any completion choices", + ) + })?; + + Ok(FimPreviewResult { + text: first_choice.text.clone(), + model: response.model, + finish_reason: first_choice.finish_reason.clone().map(finish_reason_label), + }) } #[derive(Debug)] @@ -1170,6 +1239,17 @@ fn turn_provider_finish_reason_from_deepseek(reason: FinishReason) -> TurnProvid } } +fn finish_reason_label(reason: FinishReason) -> String { + match reason { + FinishReason::Stop => "stop", + FinishReason::Length => "length", + FinishReason::ToolCalls => "tool_calls", + FinishReason::ContentFilter => "content_filter", + FinishReason::InsufficientSystemResource => "insufficient_system_resource", + } + .to_owned() +} + fn turn_provider_usage_from_deepseek(usage: Usage) -> TurnProviderUsage { TurnProviderUsage { prompt_tokens: Some(usage.prompt_tokens), @@ -1465,7 +1545,10 @@ mod tests { test_helpers::TestWorkspace, turn_loop::TurnProviderEvent, }; - use prole_coder_agent_rpc::{PROTOCOL_VERSION, RPC_APPROVAL_DENIED, RPC_TOOL_EXECUTION_FAILED}; + use prole_coder_agent_rpc::{ + FimPreviewParams, PROTOCOL_VERSION, RPC_APPROVAL_DENIED, RPC_TOOL_EXECUTION_FAILED, + RpcTurnProviderFactory, + }; use serde_json::{Value, json}; use std::{ io::{self, Read, Write}, @@ -1475,8 +1558,8 @@ mod tests { }; use super::{ - CliCommand, ProviderKind, RunCommand, deepseek_chat_stream_to_turn_provider_stream, - run_cli, run_cli_with_input, + CliCommand, CliRpcProviderFactory, FixtureKind, ProviderKind, RunCommand, ThinkingKind, + deepseek_chat_stream_to_turn_provider_stream, run_cli, run_cli_with_input, }; #[test] @@ -1959,6 +2042,30 @@ mod tests { ); } + #[test] + fn fixture_rpc_provider_factory_returns_fim_preview() { + let mut factory = CliRpcProviderFactory { + provider: ProviderKind::Fixture, + fixture: FixtureKind::Final, + max_output_tokens: 32, + thinking: ThinkingKind::Enabled, + }; + + let result = factory + .preview_fim(&FimPreviewParams { + prefix: "fn main() {".to_owned(), + suffix: Some("}".to_owned()), + path: Some("src/main.rs".to_owned()), + language_id: Some("rust".to_owned()), + model: None, + max_tokens: Some(32), + }) + .expect("fixture FIM preview should be available"); + + assert_eq!(result.model, "fixture-fim"); + assert!(result.text.contains("prole fixture")); + } + type InteractiveCliRpc = ( mpsc::Sender, SharedOutput, diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index bbbd142..f195448 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -193,7 +193,10 @@ mod tests { title: "Execute shell command".to_owned(), detail: "Run verification".to_owned(), command: Some("cargo test".to_owned()), + cwd: Some(".".to_owned()), + output_summary: None, paths: Some(vec!["crates/cli/src/lib.rs".to_owned()]), + hunks: None, risk_reasons: Vec::new(), persistable, } diff --git a/docs/adr/0006-provider-capability-data-contract.md b/docs/adr/0006-provider-capability-data-contract.md new file mode 100644 index 0000000..d9a8130 --- /dev/null +++ b/docs/adr/0006-provider-capability-data-contract.md @@ -0,0 +1,23 @@ +# ADR 0006: Provider capability data contract + +状态:Accepted + +## 背景 + +Phase 4 需要让 VS Code 侧根据后端模型能力调整展示和后续交互,例如 thinking、tool calls、FIM、streaming/cache usage,以及上下文和输出上限。当前只有 DeepSeek V4 适配器,但协议应避免把这些能力散落在 UI 文案或配置推断中。 + +## 决策 + +在 `agent.initialize` 的 `capabilities.provider` 中返回 provider/model capability 数据契约: + +- `provider` 与 `defaultModel` 描述当前默认 provider 和模型。 +- `models[]` 描述每个模型的 `contextWindowTokens`、`maxOutputTokens`。 +- `supportsThinking`、`supportsToolCalls`、`supportsToolChoice`、`supportsFim`、`supportsStreaming`、`reportsCacheUsage` 描述前端可见能力。 + +该契约先作为静态协议数据返回,不引入新的 provider trait。后续新增 provider 或模型时,再把静态默认值下沉到 provider registry。 + +## 影响 + +- VS Code 可以在初始化后读取稳定字段,而不需要解析模型名或依赖文档常量。 +- Rust/TypeScript 协议类型同步维护该结构。 +- `supportsToolChoice` 表示显式 `tool_choice` 参数能力,不等同于 tool calls;DeepSeek V4 当前返回 `false`,但 `supportsToolCalls` 为 `true`。 diff --git a/docs/agent-core.md b/docs/agent-core.md index 068d79f..d654d7d 100644 --- a/docs/agent-core.md +++ b/docs/agent-core.md @@ -68,7 +68,7 @@ Phase 2a/2b/2c 已把基础 builder 升级为结构化 `ContextCapsule`:先构 Phase 1 已实现 `WorkspaceToolExecutor`,作为 workspace_manifest/read/search/apply_patch/shell/git 工具的基础执行层。它负责 workspace 路径解析、敏感路径拒绝、命令超时和结构化工具结果。详细设计见 `docs/tool-system.md`。 -当前执行层已接入基础 Agent Turn Loop,可以跑通“模型请求工具 -> 请求审批 -> 记录审批决定 -> 执行工具 -> 写入 run log -> 继续下一轮模型调用”的 fake provider 集成测试。RPC handler 已能在 `tool.approvalRequired` 处等待 `agent.approve` / `agent.reject` / `agent.cancel` 或审批超时,并能通过 `CancellationToken` 协作式取消 provider request 和命令类工具。Phase 2d 已加入 tool call JSON Schema 预校验:模型 arguments 会先解析为 `serde_json::Value` 并按工具注册表 schema 校验,再进入 Rust typed deserialization、审批和执行。Phase 3 已加入 shell 命令风险分类器,在审批前识别依赖安装、网络访问、远程 git、删除、reset 和发布等高风险操作并升级风险;命令类工具取消或超时时也会清理子进程树。尚未完成的是更强 sandbox。 +当前执行层已接入基础 Agent Turn Loop,可以跑通“模型请求工具 -> 请求审批 -> 记录审批决定 -> 执行工具 -> 写入 run log -> 继续下一轮模型调用”的 fake provider 集成测试。RPC handler 已能在 `tool.approvalRequired` 处复用 session/workspace 持久批准,或等待 `agent.approve` / `agent.reject` / `agent.cancel` / 审批超时,并能通过 `CancellationToken` 协作式取消 provider request 和命令类工具。Phase 2d 已加入 tool call JSON Schema 预校验:模型 arguments 会先解析为 `serde_json::Value` 并按工具注册表 schema 校验,再进入 Rust typed deserialization、审批和执行。Phase 3 已加入 shell 命令风险分类器,在审批前识别依赖安装、网络访问、远程 git、删除、reset 和发布等高风险操作并升级风险;Phase 4 已让 shell 审批 payload 携带命令、cwd 和上一条 shell 输出摘要。命令类工具取消或超时时会清理子进程树。尚未完成的是更强 sandbox。 ## Run Log diff --git a/docs/approval-model.md b/docs/approval-model.md index 7813cf5..500a924 100644 --- a/docs/approval-model.md +++ b/docs/approval-model.md @@ -1,6 +1,6 @@ # 审批模型 -状态:`0.1.0` 设计已确定,基础类型、Turn Loop 审批编排、RPC pending approval 等待队列、审批超时、取消语义和 shell 动态风险升级已实现。 +状态:`0.1.0` 设计已确定,基础类型、Turn Loop 审批编排、RPC pending approval 等待队列、审批超时、取消语义、shell 动态风险升级和 `apply_patch` hunk 级审批已实现。 审批模型用于保护工作区,避免未经审阅的写入、命令执行、网络访问和破坏性操作。审批是 Agent Core 的核心安全边界,不由前端单独实现。 @@ -49,6 +49,7 @@ destructive -> always_required - 详细说明 - 工作目录 - 精确命令或文件路径 +- 对 `apply_patch`,可批准 hunk 的稳定 id、文件路径和 hunk 行号范围 - 是否允许持久化 前端只能显示和提交用户决定。Agent Core 负责判断请求是否有效、是否过期、是否可持久化。 @@ -94,12 +95,12 @@ pending 协议 `0.1.0` 中: - `read` 不需要持久审批。 -- `write` 可以在未来支持 session/workspace 持久化,但默认不启用。 -- `exec` 默认不持久化。 +- `write` 可以支持 session/workspace 持久化,但必须由审批请求显式标记 `persistable: true`。 +- `exec` 可以支持 session/workspace 持久化,但必须由审批请求显式标记 `persistable: true`,并由用户选择持久范围。 - `network` 不允许持久化。 - `destructive` 永远不允许持久化。 -当前 RPC 仍只保存单次审批结果;session/workspace 持久批准存储尚未实现。动态升级为 `network` 或 `destructive` 的请求会通过 `persistable: false` 防止前端发送持久化批准。 +当前 RPC pending approval queue 已支持 session/workspace 持久批准:session 批准保存在当前 RPC 进程内存中;workspace 批准保存在 workspace 内被忽略的 `.prole-coder/approvals.v1.json`。持久批准 key 首版由工具名、风险等级、命令、cwd 和路径集合组成,不包含 runId/approvalId。动态升级为 `network` 或 `destructive` 的请求会通过 `persistable: false` 防止前端发送持久化批准;RPC 层也会再次拒绝这两类风险的持久化请求。hunk 级 patch 批准是一次性决定,不允许 session/workspace 持久化。 ## 实现位置 @@ -107,12 +108,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 队列。Agent Core 已在 shell 工具审批前执行命令风险分类:依赖安装、网络访问、远程 git 和发布命令会升级到 `network`,删除、强制 push、git reset/clean 等会升级到 `destructive`,并在 `tool.requested` / `tool.approvalRequired` 中写入 `riskReasons`。VS Code 插件已有 modal approval adapter 并接入真实 RPC pending queue;`apply_patch` 审批会先打开 VS Code 原生 diff 预览,并保留 hunk boundary 供后续细粒度审批;TUI 已有可测试的 prompt 状态机。 +当前 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 状态机。 ## 后续增强 - 扩展 RPC 审批队列到多 active run、跨进程恢复和前端断连后的自动取消;当前实现只支持单 active run 的内存等待队列。 -- 扩展 patch 的动态风险升级;`shell` 的动态风险升级已覆盖下载依赖、访问网络、删除文件、发布和远程 git 操作。 -- 增加 session/workspace 持久批准,但只允许明确可持久化的低风险操作使用;`network` 和 `destructive` 不允许持久化。 +- 扩展 patch 的动态风险升级;`shell` 的动态风险升级已覆盖下载依赖、访问网络、删除文件、发布和远程 git 操作,`apply_patch` 已支持 hunk 级批准。 +- 扩展持久批准的可审计 metadata、清理入口和高级 UI 管理;`network` 和 `destructive` 仍不允许持久化,VS Code 主审批弹窗不重新暴露 session/workspace 选项。 - 继续增强 TUI 的真实 RPC pending 队列接入;VS Code 已能消费 `tool.approvalRequired` 并发送 `agent.approve` / `agent.reject`。 -- 增加跨前端一致性测试,确保同一工具请求在 CLI、TUI 和 VS Code 中展示的风险、路径、命令、风险原因和持久化选项一致。 +- 增加跨前端一致性测试,确保同一工具请求在 CLI、TUI 和 VS Code 中展示的风险、路径、命令、风险原因和持久化能力语义一致;VS Code 主审批弹窗保持简化的 Approve / Reject。 diff --git a/docs/architecture.md b/docs/architecture.md index 01529be..e74538b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,7 +62,7 @@ TypeScript workspace: ## 后续增强 -- 深化 VS Code 插件能力,补齐 alpha VSIX 打包、FIM/diagnostics 等 Phase 4 集成;TUI 后续复用同一 RPC 管线。 +- Phase 4 已完成 VS Code 深度集成、alpha VSIX 打包、FIM/diagnostics 和 extension-host E2E;TUI 后续复用同一 RPC 管线。 - 扩展 `crates/agent-rpc`,支持多 active run、输出节流和更细的事件 payload schema。 - 明确 `.prole-coder/` 本地状态的目录结构、版本迁移策略和脱敏规则。 - 增加端到端测试,覆盖 CLI/TUI/VS Code 对同一任务产生一致 run log 的能力。 diff --git a/docs/context-capsule.md b/docs/context-capsule.md index fe68914..6bcf2c3 100644 --- a/docs/context-capsule.md +++ b/docs/context-capsule.md @@ -1,6 +1,6 @@ # 上下文胶囊(Context Capsule) -状态:草案,Phase 1 基础 Context Builder 已实现;Phase 2a/2b/2c/2d 已完成;Phase 3 的 VS Code/RPC 事件消费与 Context Capsule 可视化已完成首版。 +状态:草案,Phase 1 基础 Context Builder 已实现;Phase 2a/2b/2c/2d 已完成;Phase 3 的 VS Code/RPC 事件消费与 Context Capsule 可视化已完成首版;Phase 4 已补齐 VS Code Sidebar Chat 与原生 `@prole` Chat Participant 的自动上下文压缩 attachment。 Context Capsule 是一次模型回合的结构化输入包。它面向 DeepSeek 的长上下文和上下文缓存能力设计。 @@ -196,7 +196,7 @@ Context Capsule 不直接扫描工作区,而是消费工具系统和 run log - `workspace_manifest` 提供稳定文件骨架、摘要、manifest hash、git state / object id、风险标记和 `max_entries_exceeded` 截断原因。 - `git_status` / `git_diff` 提供当前工作区变化摘要。 - `read_file` 和 `search` 提供已审计的文件片段与来源路径;`read_file` 已返回完整文件的 `sha256` 和 `sizeBytes`,供 manifest 和工具结果一致性校验使用。 -- `agent.sendTurn.attachments` 现在直接进入 Context Builder:file attachment 由 Core 读取并继承 `read_file` 安全边界;selection、explicit_content 和 diagnostic 使用前端提供文本,但会做数量、大小、重复来源、路径和 range 校验。 +- `agent.sendTurn.attachments` 现在直接进入 Context Builder:file attachment 由 Core 读取并继承 `read_file` 安全边界;selection、explicit_content 和 diagnostic 使用前端提供文本,但会做数量、大小、重复来源、路径和 range 校验。VS Code 插件已在发送 turn 时把 Problems 面板快照转换为 diagnostic attachments,并按协议 attachment 上限优先保留 error;Sidebar Chat 与原生 `@prole` Chat Participant 还会把历史对话/事件摘要压缩为受限长度的 `explicit_content` attachment,且 Sidebar timeline 单条消息在压缩前先限长,让连续对话自然承接上下文。 - `lsp_diagnostics` 提供编辑器或语言服务器诊断。 - 工具结果进入上下文前必须经过脱敏、大小限制和来源标注;Run Log 会对超长字符串和数组写入 `runLogTruncation`,让前端区分空输出、缺失字段和被截断输出。 diff --git a/docs/deepseek-api-adapter.md b/docs/deepseek-api-adapter.md index 524a5b4..7122842 100644 --- a/docs/deepseek-api-adapter.md +++ b/docs/deepseek-api-adapter.md @@ -1,12 +1,13 @@ # DeepSeek API Adapter -状态:草案,Phase 1 基础实现已完成。 +状态:草案,Phase 1 基础实现和 Phase 4 FIM preview 边界已完成。 本文档定义 `ProleCoder` 访问 DeepSeek API 的 Rust adapter。它属于 Agent Core 的 provider 边界,不直接处理 UI、审批、工具执行或 run log。 ## 目标 - 使用 DeepSeek API 调用 `chat/completions`。 +- 使用 DeepSeek beta FIM completion endpoint 支持编辑器 completion preview。 - 默认支持 `deepseek-v4-pro`,同时保留 `deepseek-v4-flash`。 - 显式表示 `thinking`、`reasoning_effort`、`reasoning_content`、tool calls、usage 和 cache token 字段。 - 保持请求和响应类型的表达性,使未来适配不同 provider、私有部署或兼容协议时不丢失语义。 @@ -36,6 +37,20 @@ Chat Completions endpoint: POST /chat/completions ``` +FIM Completion 默认 base URL: + +```text +https://api.deepseek.com/beta +``` + +FIM Completion endpoint: + +```text +POST /completions +``` + +FIM 请求使用 `prompt` 作为 cursor 前缀,`suffix` 作为 cursor 后缀,`stream` 固定为 `false`。VS Code inline completion 会通过 RPC `agent.previewFim` 请求该路径;前端只在 server capability 明确标记模型 `supportsFim` 时发送请求。当前 DeepSeek chat 与 beta FIM endpoint 使用同一个 `DEEPSEEK_API_KEY`,只在默认 base URL 上区分 `https://api.deepseek.com` 与 `https://api.deepseek.com/beta`。 + 当前主要模型: - `deepseek-v4-flash` @@ -81,6 +96,8 @@ crates/agent-core/src/provider/deepseek_api.rs - `ChatCompletionResponse`:非流式响应。 - `ChatCompletionChunk`:流式 chunk。 - `ChatCompletionStream`:HTTP streaming 响应转换后的事件流。 +- `FimCompletionRequest`:DeepSeek FIM 请求体,包含 `model`、`prompt`、可选 `suffix`、可选 `max_tokens`,并默认 `stream = false`。 +- `FimCompletionResponse` / `FimCompletionChoice`:FIM 非流式响应和 text choice。 - `SseEventParser`:增量解析 SSE byte chunks。 - `StreamEvent`:`Chunk` 或 `Done`。 - `parse_stream_event_block`:解析单个 SSE event block。 @@ -119,7 +136,7 @@ DEEPSEEK_BASE_URL=https://api.deepseek.com DEEPSEEK_MODEL=deepseek-v4-pro ``` -运行时 adapter 仍然从 `DEEPSEEK_API_KEY` 读取密钥。真实联网测试额外支持测试专用的 `PROLE_CODER_DEEPSEEK_API_KEY` 和 `.secrets/deepseek-api-key` 本地密钥文件;这个文件只放 API Key,不放 base URL 或模型名。测试侧读取优先级为 `PROLE_CODER_DEEPSEEK_API_KEY`、`DEEPSEEK_API_KEY`、`.secrets/deepseek-api-key`。`DEEPSEEK_BASE_URL` 和 `DEEPSEEK_MODEL` 有项目默认值,也可以在外部测试配置或当前 shell 环境变量中选择。 +运行时 adapter 仍然从 `DEEPSEEK_API_KEY` 读取密钥,chat completion 与 FIM preview 共用这个密钥。真实联网测试额外支持测试专用的 `PROLE_CODER_DEEPSEEK_API_KEY` 和 `.secrets/deepseek-api-key` 本地密钥文件;这个文件只放 API Key,不放 base URL 或模型名。测试侧读取优先级为 `PROLE_CODER_DEEPSEEK_API_KEY`、`DEEPSEEK_API_KEY`、`.secrets/deepseek-api-key`。`DEEPSEEK_BASE_URL` 和 `DEEPSEEK_MODEL` 有项目默认值,也可以在外部测试配置或当前 shell 环境变量中选择。 ## 错误处理 @@ -129,6 +146,8 @@ adapter 使用显式错误枚举: - base URL 无效。 - 模型 ID 为空。 - 请求消息为空。 +- FIM prefix 为空。 +- FIM `max_tokens` 超出 1 到 4096 的边界。 - 非流式调用收到 streaming request。 - HTTP 发送失败。 - DeepSeek 返回非 2xx 状态。 @@ -201,7 +220,7 @@ cargo test -p prole-coder-agent-core --test deepseek_api_live -- --ignored --noc ## 后续增强 -- 抽象 provider capability model,显式表达 thinking、tool_choice、FIM、stream usage、cache usage、最大上下文和最大输出长度等能力,而不是把规则散落在调用处。 +- Provider capability model 已通过 Phase 4 P4-3 显式表达 thinking、tool_choice、FIM、stream usage、cache usage、最大上下文和最大输出长度;后续如模型真实能力变化,应先更新 capability data contract,再更新 UI 行为。 - 增加更细的错误分类,用于区分认证失败、限速、无效参数、服务端错误、网络中断和被截断的 stream;分类只用于明确提示和重试决策,不做静默兜底。 - 继续收集不同模型、不同工具 schema 和 thinking/tool-call 组合下的 streaming delta 形态,必要时补更细的兼容性测试。 - Phase 2c 已增加针对 cache usage 字段的离线测试、ignored live cache usage 实验入口,以及 `provider.completed` 上下文缓存统计记录。 @@ -214,6 +233,7 @@ cargo test -p prole-coder-agent-core --test deepseek_api_live -- --ignored --noc - DeepSeek API 首次调用:https://api-docs.deepseek.com/zh-cn/ - DeepSeek Chat Completions:https://api-docs.deepseek.com/api/create-chat-completion +- DeepSeek FIM Completion:https://api-docs.deepseek.com/zh-cn/guides/fim_completion - DeepSeek 思考模式:https://api-docs.deepseek.com/zh-cn/guides/thinking_mode - DeepSeek 模型与价格:https://api-docs.deepseek.com/quick_start/pricing/ - DeepSeek 限速说明:https://api-docs.deepseek.com/quick_start/rate_limit/ diff --git a/docs/json-rpc-protocol.md b/docs/json-rpc-protocol.md index 17d49bc..2ba7847 100644 --- a/docs/json-rpc-protocol.md +++ b/docs/json-rpc-protocol.md @@ -45,6 +45,29 @@ Agent Core } ``` +实时高频事件可以使用 `agent.eventBatch` notification 批量发送;Run Log 本身仍以单个 `seq` 事件作为事实来源,`agent.resume` replay 仍按 `agent.event` 重放。 +```json +{ + "jsonrpc": "2.0", + "method": "agent.eventBatch", + "params": { + "events": [ + { + "seq": 2, + "time": "2026-05-20T14:00:00.001Z", + "type": "assistant.delta", + "runId": "run_01", + "turnId": "turn_01", + "payload": { "text": "hello" } + } + ], + "firstSeq": 2, + "lastSeq": 2, + "count": 1 + } +} +``` + ## 版本 协议版本为 `0.1.0`。 @@ -99,7 +122,28 @@ interface ServerCapabilities { supportsRunResume: boolean; supportsPatchApproval: boolean; supportsPersistentApprovals: boolean; + supportsEventBatching: boolean; supportedRiskLevels: RiskLevel[]; + provider: ProviderCapabilities; +} + +interface ProviderCapabilities { + provider: string; + defaultModel: string; + models: ProviderModelCapabilities[]; +} + +interface ProviderModelCapabilities { + id: string; + displayName?: string; + contextWindowTokens: number; + maxOutputTokens: number; + supportsThinking: boolean; + supportsToolCalls: boolean; + supportsToolChoice: boolean; + supportsFim: boolean; + supportsStreaming: boolean; + reportsCacheUsage: boolean; } ``` @@ -122,6 +166,22 @@ interface PlanStep { } ``` +### PatchApprovalHunk + +```ts +interface PatchApprovalHunk { + id: string; + filePath: string; + fileIndex: number; + hunkIndex: number; + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; + section?: string; +} +``` + ## 方法 ### `agent.initialize` @@ -161,8 +221,27 @@ Result: "protocolVersion": "0.1.0", "supportsRunResume": true, "supportsPatchApproval": true, - "supportsPersistentApprovals": false, - "supportedRiskLevels": ["read", "write", "exec", "network", "destructive"] + "supportsPersistentApprovals": true, + "supportsEventBatching": true, + "supportedRiskLevels": ["read", "write", "exec", "network", "destructive"], + "provider": { + "provider": "deepseek", + "defaultModel": "deepseek-v4-pro", + "models": [ + { + "id": "deepseek-v4-pro", + "displayName": "DeepSeek V4 Pro", + "contextWindowTokens": 1048576, + "maxOutputTokens": 393216, + "supportsThinking": true, + "supportsToolCalls": true, + "supportsToolChoice": false, + "supportsFim": true, + "supportsStreaming": true, + "reportsCacheUsage": true + } + ] + } }, "stateDir": ".prole-coder" } @@ -215,9 +294,9 @@ 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 在 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`,并在超时时写入取消事件。如果 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 等前端传入结构化诊断文本。当前默认限制是单 turn 最多 32 个 attachment,单个 attachment 文本最多 256 KiB;超过限制会让该 run 以 `run.failed` / `E_INVALID_ATTACHMENT` 结束。 +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` 结束。 ### `agent.approve` @@ -227,21 +306,29 @@ Phase 2c 起,Rust handler 会消费 `attachments` 并转换为 Context Capsule interface ApproveParams { approvalId: string; persist?: "never" | "session" | "workspace"; + hunks?: { + approved: string[]; + }; } interface ApproveResult { approvalId: string; state: "approved"; persist: "never" | "session" | "workspace"; + hunks?: { + approved: string[]; + }; } ``` 规则: - `persist` 默认是 `never`。 -- 只有明确标记为 persistable 的审批类型才能使用 `workspace` 持久化。 +- 只有明确标记为 `persistable: true` 的审批类型才能使用 `session` / `workspace` 持久化。 +- `network` 和 `destructive` 风险不可持久化,即使客户端发送持久化参数也会被 server 拒绝。 +- `hunks.approved` 只用于 `apply_patch` 的 hunk 级批准;必须引用 `tool.approvalRequired.hunks` 中存在的 id,且不能与 `session` / `workspace` 持久化同时使用。 - 批准已过期或未知审批时返回 `E_APPROVAL_NOT_FOUND`。 -- 当前 Rust request loop 已能解析 `agent.approve` 并分发给 `AgentRpcRequestHandler`;`AgentTurnLoopRpcHandler` 已能批准当前 active run 的 pending approval,并继续输出 `tool.approvalResolved`、后续工具事件和 run 结束事件。未知、已使用或已过期的 approval 会返回 `E_APPROVAL_NOT_FOUND`。 +- 当前 Rust request loop 已能解析 `agent.approve` 并分发给 `AgentRpcRequestHandler`;`AgentTurnLoopRpcHandler` 已能批准当前 active run 的 pending approval,并按需写入 session/workspace 持久批准。后续相同 key 的审批会自动通过并继续输出 `tool.approvalResolved`、后续工具事件和 run 结束事件。hunk 级批准会在 Core 层过滤 `apply_patch` 后只应用已批准 hunks。未知、已使用或已过期的 approval 会返回 `E_APPROVAL_NOT_FOUND`。 ### `agent.reject` @@ -356,6 +443,34 @@ interface ListRunsResult { - `limit` 省略时返回全部已知 run;传入时只返回前 N 条。 - 当前 Rust request loop 已能解析 `agent.listRuns` 并分发给 handler;`AgentTurnLoopRpcHandler` 已能从 Run Log summary metadata 返回列表。 +### `agent.previewFim` + +请求一次 fill-in-the-middle completion preview。该方法用于编辑器 inline completion,不创建 run,也不写入 run log。 + +```ts +interface FimPreviewParams { + prefix: string; + suffix?: string; + path?: string; + languageId?: string; + model?: string; + maxTokens?: number; +} + +interface FimPreviewResult { + text: string; + model: string; + finishReason?: string; +} +``` + +规则: + +- `prefix` 必须非空。 +- `model` 只能由 client 在 server capability 明确支持 FIM 时传入;前端不得靠模型名称推断能力。 +- `maxTokens` 是 provider 请求上限,不改变 server 侧上下文预算。 +- 当前 Rust request loop 已能解析 `agent.previewFim` 并分发给 handler;CLI fixture provider 返回可测试预览,DeepSeek provider 通过 beta FIM completion endpoint 获取结果。 + ## 事件封装 所有 server event 使用该 envelope: @@ -622,7 +737,9 @@ interface ToolApprovalRequired { detail: string; cwd?: string; command?: string; + outputSummary?: string; paths?: string[]; + hunks?: PatchApprovalHunk[]; riskReasons?: string[]; persistable: boolean; } @@ -637,10 +754,16 @@ interface ToolApprovalResolved { toolName: ToolName; decision: "approved" | "rejected" | "canceled" | "expired"; reason?: string; + hunks?: + | { scope: "all" } + | { + scope: "selected"; + approved: string[]; + }; } ``` -该事件记录用户、策略或 RPC 队列对审批请求的决定。`decision: "approved"` 后续应进入 `tool.started`;`decision: "rejected"` 后当前工具调用不得执行,run 可以失败、继续只读工作或让模型请求不同操作;`decision: "canceled"` 和 `decision: "expired"` 表示 active run 被用户取消或审批超时,后续必须写入 `run.canceled`,对应工具不得执行。CLI 当前会把 prompt 的批准/拒绝写入该事件;RPC handler 的 pending approval 队列会在 `agent.approve` / `agent.reject` / `agent.cancel` 或超时后写入同等事件。 +该事件记录用户、策略或 RPC 队列对审批请求的决定。`decision: "approved"` 后续应进入 `tool.started`;`hunks.scope: "selected"` 表示后续只会应用指定 hunk id。`decision: "rejected"` 后当前工具调用不得执行,run 可以失败、继续只读工作或让模型请求不同操作;`decision: "canceled"` 和 `decision: "expired"` 表示 active run 被用户取消或审批超时,后续必须写入 `run.canceled`,对应工具不得执行。CLI 当前会把 prompt 的批准/拒绝写入该事件;RPC handler 的 pending approval 队列会在 `agent.approve` / `agent.reject` / `agent.cancel` 或超时后写入同等事件。 ### `tool.started` @@ -679,7 +802,7 @@ interface PatchProposed { } ``` -Patch 通过其中的 `approvalId` 使用 `agent.approve` 批准。 +Patch 通过其中的 `approvalId` 使用 `agent.approve` 批准。`apply_patch` 首版支持在 `tool.approvalRequired.hunks` 中暴露可批准 hunk;client 可以发送 `agent.approve` 的 `hunks.approved` 只批准其中一部分。 ### `patch.applied` diff --git a/docs/phase-tasks.md b/docs/phase-tasks.md index cd8ffdc..cc30038 100644 --- a/docs/phase-tasks.md +++ b/docs/phase-tasks.md @@ -1,6 +1,6 @@ # 详细任务索引 -状态:Phase 1 审计完成;后续任务已归入阶段。 +状态:Phase 1、Phase 2、Phase 3、Phase 4 已完成;Phase 4 包含原 14 项 VS Code 深度集成能力以及 P4-15 到 P4-18 的 Codex-like UX 收敛。 本文档是详细设计文档里的任务账本。README 保留高层开发计划;这里把各模块文档中出现的“已实现、尚未实现、后续增强、下一步”收敛为可勾选任务,避免后续工作只散落在说明文字里。 @@ -93,16 +93,24 @@ | 状态 | 任务 | 来源 | 说明 | | --- | --- | --- | --- | -| [ ] | Problems 面板诊断进入 Context Builder | `README.md`、`docs/vscode-extension.md`、`docs/context-capsule.md` | 依赖 Phase 2 attachment/context 输入稳定。 | -| [ ] | Terminal command approval | `README.md`、`docs/vscode-extension.md`、`docs/approval-model.md` | 展示命令、cwd、风险等级、输出摘要和持久化选项。 | -| [ ] | provider、model、预算、审批策略和 RPC 命令配置界面 | `README.md`、`docs/vscode-extension.md` | 避免保存 API Key,配置只管理非敏感选项。 | -| [ ] | RPC 高频事件输出节流与批量发送策略 | `docs/rpc-server.md`、`docs/vscode-extension.md` | 面向 `assistant.delta` 等高频事件,减少 stdio/webview 卡顿;需保持 Run Log `seq` 与 live notification 顺序一致。 | -| [ ] | 事件 payload schema 与协议 fixture 对齐 | `docs/json-rpc-protocol.md`、`docs/turn-loop.md`、`packages/protocol` | 将 `provider.requested`、`tool.completed`、`run.completed` 等事件 payload 纳入 Rust/TypeScript 兼容性测试,避免协议漂移。 | -| [ ] | 审批持久化存储 | `docs/approval-model.md`、`docs/tool-system.md`、`docs/vscode-extension.md` | 实现 session/workspace 持久批准存储;继续禁止 network/destructive 风险持久化。 | -| [ ] | 真实 hunk 级 patch 审批 | `docs/tool-system.md`、`docs/vscode-extension.md` | 复用 Phase 3 的 hunk boundary,将 `apply_patch` 从 whole-patch approve/reject 扩展到 hunk 级决策、冲突诊断和回放记录。 | -| [ ] | FIM completion preview | `README.md`、`docs/deepseek-api-adapter.md`、`docs/vscode-extension.md` | 需要 provider capability model 与编辑器 UI。 | -| [ ] | Provider capability model | `README.md`、`docs/roadmap.md`、`docs/deepseek-api-adapter.md` | 显式表达 thinking、tool choice、FIM、stream usage、cache usage、上下文和输出限制。 | -| [ ] | VSIX alpha / pre-release 打包与安装说明 | `docs/release.md`、`docs/vscode-extension.md` | Marketplace 上架不阻塞 Phase 4 完成,但需要可安装产物和文档。 | +| [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 与生态扩展 diff --git a/docs/protocol/event-payloads.v1.json b/docs/protocol/event-payloads.v1.json new file mode 100644 index 0000000..71525d1 --- /dev/null +++ b/docs/protocol/event-payloads.v1.json @@ -0,0 +1,74 @@ +{ + "version": "0.1.0", + "scope": "Phase 4 fixture alignment covers stable event payload protocol surfaces that Rust and TypeScript both validate.", + "events": [ + { + "type": "provider.requested", + "payloadName": "ProviderRequestedPayload", + "required": ["iteration", "messageCount", "reasoningState"], + "properties": { + "iteration": "number", + "messageCount": "number", + "reasoningState": "object" + } + }, + { + "type": "tool.completed", + "payloadName": "ToolCompletedPayload", + "required": ["toolCallId", "name", "status", "summary", "result"], + "properties": { + "toolCallId": "string", + "name": "string", + "status": "ok|failed", + "summary": "string", + "result": "object" + } + }, + { + "type": "run.completed", + "payloadName": "RunCompletedPayload", + "required": ["summary", "changedFiles", "verificationStatus"], + "properties": { + "summary": "string", + "changedFiles": "array", + "verificationStatus": "passed|failed|skipped" + } + }, + { + "type": "tool.approvalRequired", + "payloadName": "ToolApprovalRequiredPayload", + "required": [ + "approvalId", + "toolCallId", + "toolName", + "risk", + "title", + "detail", + "persistable" + ], + "properties": { + "approvalId": "string", + "toolCallId": "string", + "toolName": "ToolName", + "risk": "RiskLevel", + "title": "string", + "detail": "string", + "paths": "array", + "hunks": "array", + "persistable": "boolean" + } + }, + { + "type": "tool.approvalResolved", + "payloadName": "ToolApprovalResolvedPayload", + "required": ["approvalId", "toolCallId", "toolName", "decision"], + "properties": { + "approvalId": "string", + "toolCallId": "string", + "toolName": "ToolName", + "decision": "approved|rejected|canceled|expired", + "hunks": "all|selected" + } + } + ] +} diff --git a/docs/release.md b/docs/release.md index 27b3ffd..f3bfcab 100644 --- a/docs/release.md +++ b/docs/release.md @@ -32,10 +32,70 @@ - 用于安装 wrapper 的 npm package - VS Code Marketplace 或 Open VSX +## VSIX dry-run packaging smoke + +Phase 4 P4-1 已提供 VSIX 打包烟测入口: + +```powershell +pnpm run vsix:smoke +``` + +该命令会构建 `@prole-coder/protocol` 与 `prole-coder-vscode`,在 `target/` 下临时生成 VSIX,检查 `.vscodeignore`、`workspace:*` 依赖是否只停留在开发期、`media/prole-coder-view.svg`、compiled `out/`、activationEvents、`@prole` Chat Participant 贡献点和包内排除规则,然后清理临时产物。 + +此 smoke 使用 dry-run 口径,允许缺少 repository 和发布许可证文件,并禁用依赖探测以避免把 workspace 开发依赖写入运行时包。它只验证打包基础设施,不代表 P4-13 的 alpha / pre-release VSIX 安装交付已经完成。 + +## VSIX alpha / pre-release 打包 + +Phase 4 P4-13 提供保留产物的 alpha / pre-release VSIX 打包入口: + +```powershell +pnpm run vsix:alpha +``` + +该命令会构建 `@prole-coder/protocol` 与 `prole-coder-vscode`,然后生成: + +```text +target/vsix/prole-coder-vscode-0.1.0-alpha.vsix +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 和可复现构建说明。 + +## VSIX clean 环境安装验收 + +在新的 PowerShell 中从仓库根目录执行: + +```powershell +$userDataDir = Join-Path (Get-Location) "target\vscode-clean-user-data" +$extensionsDir = Join-Path (Get-Location) "target\vscode-clean-extensions" +New-Item -ItemType Directory -Force -Path $userDataDir, $extensionsDir | Out-Null +code --user-data-dir $userDataDir --extensions-dir $extensionsDir --install-extension .\target\vsix\prole-coder-vscode-0.1.0-alpha.vsix --force +code --user-data-dir $userDataDir --extensions-dir $extensionsDir . +``` + +如果本机没有全局 `prole` 命令,可在这个 clean VS Code 环境中把扩展设置为开发期 RPC 命令: + +```json +{ + "prole-coder.rpc.command": "cargo", + "prole-coder.rpc.args": ["run", "-p", "prole-coder-cli", "--", "rpc"] +} +``` + +验收时确认: + +- `code --user-data-dir $userDataDir --extensions-dir $extensionsDir --list-extensions` 能看到 `prole-coder.prole-coder-vscode`。 +- 打开仓库后 ProleCoder Activity Bar 高级面板可见,VS Code 原生 Chat 中可用 `@prole` Chat Participant。 +- `ProleCoder: Open Chat` 优先打开 VS Code 原生 Chat 侧栏并填入 `@prole`,同时在受信任 workspace 中按配置静默启动或复用 RPC server。 +- `ProleCoder: Open Settings` 能打开扩展设置;API Key 不写入 VS Code settings,只通过 RPC server 的环境变量或被忽略的本地密钥文件读取。 +- 完成验收后可删除 `target/vscode-clean-user-data` 和 `target/vscode-clean-extensions`。 + ## 后续增强 - 添加 `LICENSE` 文件,并在发布包中包含 AGPL-3.0-or-later 许可证文本和源码获取说明。 - 设计可复现构建流程,记录 Rust、Node.js、pnpm、VSIX 打包工具和平台目标版本。 - 增加发布前检查:格式、lint、测试、敏感信息扫描、依赖审计、产物校验和变更日志生成。 -- 为 CLI/TUI 二进制、npm wrapper 和 VSIX 生成校验和,并在 GitHub Release 中发布。 +- 为 CLI/TUI 二进制、npm wrapper 生成校验和,并把 VSIX alpha 已生成的校验和纳入正式 GitHub Release 发布流程。 - 明确网络服务部署场景下的源码提供方式,避免 AGPL 合规说明留到发布后补救。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 383df55..9bd58a5 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 1 Agent Core MVP、合并主线前离线最终验收、Phase 2 的 1M Context Capsule 核心收敛、Phase 2e 展示型 demo 扩展、Phase 3 VS Code 插件核心与共享 RPC 事件队列、Phase 4 VS Code 深度集成与 Codex-like UX 收敛均已完成。 本文档把 README 中的大阶段拆成更可执行的优先级。README 保留项目入口和高层计划;这里记录跨模块的落地顺序、取舍和验收重点。具体任务的阶段、状态和来源统一登记在 `docs/phase-tasks.md`,阶段条目标记完成前应同步检查并更新该索引。 @@ -105,22 +105,37 @@ P0 不追求: 目标:让 VS Code 插件成为 Agent Core 的薄前端,而不是第二套 Agent。 -Phase 3 优先交付 VS Code 插件核心体验;Phase 4 再做 VS Code 深度集成;TUI 进入 Phase 5,与生态扩展一起推进。Marketplace 发布不阻塞 Phase 3 完成,先保证本地可安装、可运行、可审计。Phase 2e 展示型 demo 已经给 VS Code Context Viz / Approval / Run Log UI 提供可观察样本。 +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 提供可观察样本。 优先事项: -- 在已完成的 RPC server 管理和 request client 基础上,接入真实 Chat / Approval / Diff / Run List / Context Viz UI。 -- Sidebar Chat 渲染 run events。 +- 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。 +- 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。 +- 审批持久化存储已支持 session/workspace,继续禁止 network/destructive 风险持久化。 +- 配置界面依赖 Provider capability model;provider、model、预算、审批策略和 RPC 命令配置都不得保存 API Key。 +- 真实 hunk 级 patch 审批首版限定 `apply_patch`,再扩展 Core/RPC 审批决策和 Run Log 记录。 +- 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。 - 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 摘要。 -- Problems 面板诊断进入 Context Builder。 -- Terminal command approval 展示命令、cwd、风险等级和输出摘要。 - Phase 3 命令风险分类器已完成:识别网络访问、依赖安装、远程 git、发布和破坏性命令,并在审批前升级风险。 -- Phase 4 Provider capability model:显式表达 thinking、tool choice、FIM、stream usage、cache usage、最大上下文和最大输出长度等能力。 -验收重点: +已完成的验收重点: +- Phase 4 的 18 个条目已全部在 `docs/phase-tasks.md` 标记 `[x]`,README 可以写 Phase 4 全部完成。 +- 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 顺序一致。 +- 配置界面不保存 API Key,只管理非敏感配置。 - Phase 3 RPC 管线中,`agent.sendTurn` 创建 run 后返回 accepted,不等待 `assistant.delta`、审批或 terminal event。 - 同一 run 的 live `agent.event` notification 和 `agent.resume` replay 使用同一 Run Log `seq` 与 envelope。 - `context.built` 在插件侧只作为 Run Log metadata 渲染,插件不重新实现 context builder。 @@ -162,11 +177,11 @@ Phase 2 的 1M Context Capsule 按 4 个增量轮次推进: - [x] `demo-attachment`:展示 file、selection、explicit_content、diagnostic attachments 如何进入 Context Builder。 - [x] `demo-live` provider summary 增强:展示模型、duration、usage、cache hit/miss 和 stream 摘要。 -后续 DeepSeek 差异化事项: +与 Phase 4 关联的 DeepSeek 差异化事项: - `reasoning_content` replay 状态摘要。 -- FIM completion preview。 -- 高频 JSON-RPC streaming 性能基准和必要的 delta 合并策略。 +- FIM completion preview 已纳入 Phase 4 `P4-12`,依赖 Provider capability model。 +- 高频 JSON-RPC streaming 性能基准和必要的 delta 合并策略已纳入 Phase 4 `P4-5`。 验收重点: diff --git a/docs/rpc-server.md b/docs/rpc-server.md index ef9b4a3..11a8b06 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 断连取消。 +状态:`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 层批量发送。 Agent RPC Server 是 CLI、TUI、VS Code 插件和 Rust Agent Core 之间的协议边界。它不重新实现工具执行、上下文构建或 turn loop;它负责把前端 request 转换为 Core 调用,把 Core / Run Log 事件转换为 JSON-RPC notification。 @@ -21,7 +21,8 @@ Agent RPC Server 是 CLI、TUI、VS Code 插件和 Rust Agent Core 之间的协 - `JsonRpcRequest` / `JsonRpcResponse` / `JsonRpcErrorResponse`:JSON-RPC 2.0 request/response/error 基础结构。 - `JsonRpcNotification`:JSON-RPC 2.0 notification 基础结构。 - `run_log_event_to_envelope`:把 `RunLogEvent` 转换为前端事件 envelope。 -- `run_log_event_to_notification`:把 `RunLogEvent` 转换为 `agent.event` notification。 +- `run_log_event_to_notification`:把单个 `RunLogEvent` 转换为 `agent.event` notification。 +- `run_log_events_to_batch_notification`:把实时队列中同一轮可立即取出的多个 `RunLogEvent` 转换为 `agent.eventBatch` notification。 - `StdioEventBridge`:把一个或多个 Run Log 事件写为 newline-delimited JSON notification,并实现 `TurnEventSink`,可直接接到 `AgentTurnLoop::run_turn_with_event_sink`。 - `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 持续输出。 @@ -30,7 +31,7 @@ Agent RPC Server 是 CLI、TUI、VS Code 插件和 Rust Agent Core 之间的协 - `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` notification 都经同一个 writer 串行输出。stdin EOF 或 writer failure 会取消 active run 并 flush 收尾事件。 +- `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。 @@ -42,7 +43,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` 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`、`listRuns` 和 `resume` helper;Sidebar Chat 会用 `agent.listRuns` 填充最近 run 列表,并用 `agent.resume` 回放历史事件。 ## 数据流 @@ -108,13 +109,13 @@ Run Log 是事实来源: RPC 层不负责重新脱敏 payload。当前 Run Log 写入时已经调用基础脱敏和截断规则,并用 `runLogTruncation` 记录边界;后续可继续补充输出节流和更完整的密钥形态识别。 -`agent.sendTurn` 创建 run 后返回 accepted;后续 live events 由后台 worker 通过有界队列持续投递给 request loop。`agent.resume` 等 replay 型方法仍可随 response 返回一组历史 `RunLogEvent`,request loop 会先写 JSON-RPC response,再按顺序写 replay notification。这样保持“request 已被接受”和“事件开始抵达”的边界清晰,同时允许长 provider request 期间继续向前端推送事件。 +`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`。 同一个 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。后续增强重点转向多 active run、输出节流和更强 sandbox。 +这意味着当前 RPC server 已具备真实审批等待、取消、超时、EOF / writer failure 断连取消、协作式 provider/tool 停止语义、命令子进程树清理,以及 `agent.sendTurn` 早返回后的后台 live event streaming/batching。后续增强重点转向多 active run 和更强 sandbox。 ## Request Loop 规则 diff --git a/docs/testing.md b/docs/testing.md index 522340d..2afc635 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -48,6 +48,35 @@ 默认 CI 当前通过 `pnpm run check` 执行。`search` 工具测试会执行 `rg`,因此本机和 CI 都需要安装 ripgrep。 +## Phase 4 VS Code extension-host E2E + +Phase 4 P4-14 的确定性 VS Code 端到端入口: + +```powershell +pnpm run vscode:test-electron +``` + +该命令会构建 protocol 与 extension,编译 `test/electron/index.ts`,再用 `@vscode/test-electron` 启动隔离的 VS Code test host。`vscode/extension/scripts/runVscodeIntegrationTests.mjs` 会为每轮测试创建独立 `target/vscode-test-user-data-*` 和 `target/vscode-test-extensions-*` profile,避免本机 VS Code 状态或上一轮测试 mutex 影响结果。 + +测试工作区使用本地 JSON-RPC fixture server,不联网、不读取 API key,也不依赖全局 `prole` 命令。覆盖范围: + +- extension activation、trusted workspace、Chat view focus 和命令注册。 +- Chat submit turn 通过真实 `RpcServerManager.sendTurn()` 进入 fixture RPC server。 +- 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。 + +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 入口,并补齐以下确定性覆盖: + +- `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`。 +- `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 贡献点。 + ## 新增测试的协作要求 - PR 或提交说明中标明测试类型:unit、integration、regression、live、demo 或 stress。 diff --git a/docs/tool-system.md b/docs/tool-system.md index c19a126..2afdc3b 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 预览和命令子进程树清理已实现。 +状态:`0.1.0` 设计已确定,Phase 1 基础执行层、审批前 shell 动态风险升级、Phase 3 RPC/VS Code 审批接入、VS Code Native diff patch 预览、`apply_patch` hunk 级审批和命令子进程树清理已实现。 工具系统通过显式 schema 和类型化结果向 Agent Core 暴露工作区操作。模型不得直接执行文件写入、shell 命令或网络访问;它只能请求工具,工具请求必须经过 schema 校验和审批策略。 @@ -276,7 +276,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 预览,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 预览和 `apply_patch` selected hunk 审批,TUI 真实队列接入仍在后续阶段。 ## 协议一致性测试 @@ -303,7 +303,7 @@ 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。VS Code 前端会在审批前用原生 diff editor 展示 patch 预览,并生成稳定 hunk boundary,但当前 approve/reject 仍是 whole-patch 粒度。 +- `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 和耗时。 - `git_status`:读取 `git status --short --branch` 或普通 `git status`。 - `git_diff`:读取 unstaged 或 staged diff,支持限定 workspace-relative 路径。 @@ -316,7 +316,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` 处等待 `agent.approve` / `agent.reject` / `agent.cancel` 或审批超时。`shell` 会在审批前分类命令并动态升级风险;`shell`、`search`、`git_status` 和 `git_diff` 会在子进程轮询循环中检查 `CancellationToken`,取消或超时时清理整棵命令子进程树并让 Turn Loop 写入 `run.canceled`。VS Code 已接入真实 RPC 审批队列;TUI 真实队列接入仍需要后续实现。 +当前执行层已接入基础 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 真实队列接入仍需要后续实现。 ## 后续增强 @@ -325,7 +325,7 @@ fixture 中的 `tools` 被当作无序集合校验;测试会按工具名规整 - 为 Rust 和 TypeScript 的每个工具补齐具体 `resultSchema`,替换当前通用 `statusResultSchema`。 - 将当前 `docs/protocol/tool-registry.v1.json` 扩展为更完整的 schema fixture 或代码生成入口,避免协议文档、Rust 类型和 `packages/protocol` 分叉。 - 如果 fixture 或代码生成入口继续扩展,再引入 workspace 级路径元数据或 build script,避免多个 crate 复制相对路径。 -- 在 RPC pending 审批队列上继续补充多 active run 关联、持久审批存储和更细的重放语义。 +- 在 RPC pending 审批队列上继续补充多 active run 关联、持久审批管理 UI、清理入口和更细的重放语义。 ### 路径与敏感信息 @@ -357,7 +357,7 @@ Schema 校验不能只作为 typed deserialization 失败后的补救,因为 R ### `apply_patch` - 当前实现只支持受限 unified diff;后续需要支持更完整的 git patch 语法,包括 rename、copy、mode change 和更严格的 no-newline 语义。 -- 已增加 VS Code patch 预览和 hunk boundary;后续继续实现真实 hunk 级审批、冲突诊断和失败时的精确 hunk mismatch 信息。 +- 已增加 VS Code patch 预览和 `apply_patch` hunk 级审批;后续继续增强冲突诊断、失败时的精确 hunk mismatch 信息,以及 rename/copy/mode change 等更完整 patch 语法下的审批边界。 - 用修改前快照生成 reverse patch,并在 run log 中保存 patch id、审批 id 和可审计回滚信息。 - 如果需要抵抗磁盘写入中途失败,应进一步引入临时文件、原子替换或备份恢复机制;当前 staging 主要保证解析和 hunk 校验失败不会产生半应用 patch。 - 明确二进制文件和生成文件策略,避免文本 patch 意外改写不可审计内容。 @@ -377,5 +377,5 @@ Schema 校验不能只作为 typed deserialization 失败后的补救,因为 R ### 尚未实现的内置工具 -- `lsp_diagnostics`:应能从 VS Code 或独立语言服务器读取 Problems/diagnostics,并保留来源、范围和严重级别。 +- `lsp_diagnostics`:独立工具执行逻辑尚未实现;VS Code 当前已在发送 turn 时把 Problems 快照作为 diagnostic attachments 注入 Context Builder。 - `plan_update`:应由 Agent Core 写入 run log,并通过 JSON-RPC 事件同步给 CLI/TUI/VS Code。 diff --git a/docs/turn-loop.md b/docs/turn-loop.md index 5757fdf..8ab84f8 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 接入和命令子进程树清理。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 协作式取消信号、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 仍在后续阶段。 Agent Turn Loop 是 Agent Core 的回合编排层。它负责把已经实现的 Context Builder、`reasoning_content` 状态机、provider 边界、工具执行、审批和 Run Log 串成同一条可复现事件流。 diff --git a/docs/vscode-extension.md b/docs/vscode-extension.md index 0c0b7aa..e6cb73f 100644 --- a/docs/vscode-extension.md +++ b/docs/vscode-extension.md @@ -1,6 +1,6 @@ # 编辑器插件(VS Code Extension) -状态:Phase 3 优先开发项。基础命令、审批弹窗 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 可视化已实现。 +状态: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 和自动上下文压缩均已实现。 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()`、`approve()` 和 `reject()` helper,避免 UI 层直接拼常用 JSON-RPC method string。 +- 提供 typed `sendTurn()`、`cancel()`、`approve()`、`reject()`、`listRuns()` 和 `resume()` helper,避免 UI 层直接拼常用 JSON-RPC method string。 - 把 JSON-RPC error response 转换为 `RpcRequestError`,保留 `code` 和 `data`。 - server 停止、退出或出错时,会拒绝尚未完成的 pending request。 - 记录 stderr 尾部,供后续错误提示和诊断使用。 @@ -39,7 +39,7 @@ VS Code 插件是 `ProleCoder` 的一等前端。它必须通过 JSON-RPC server `vscode/extension/src/commands.ts` 当前注册 `prole-coder.openChat`: - 如果没有 workspace,则提示先打开 trusted workspace。 -- 如果有 RPC manager,则聚焦 ProleCoder Chat view,尝试启动或复用 RPC server,并提示 server ready 或启动失败。 +- 如果有 RPC manager,则优先打开 VS Code 原生 Chat 侧栏并填入 `@prole`,同时静默启动或复用 RPC server;启动失败时提示原因。 `vscode/extension/src/chatView.ts` 当前注册 `prole-coder.chat` Webview view: @@ -47,17 +47,18 @@ VS Code 插件是 `ProleCoder` 的一等前端。它必须通过 JSON-RPC server - 通过 `RpcServerManager.onEvent()` 订阅 live `agent.event`。 - 使用 `ChatEventTimeline` 把 `assistant.delta`、tool lifecycle、approval、context/provider 和 terminal event 转换为 timeline item。 - 同一 run/turn 的连续 `assistant.delta` 会合并为一条 assistant 消息,避免流式输出刷屏。 -- 提供 prompt 输入和 mode 选择,通过 Webview `submitTurn` 消息调用 typed `RpcServerManager.sendTurn()`,accepted 后等待同一 run 的 terminal event 收口输入状态。 +- 提供 prompt 输入、mode 选择和运行中 Cancel 按钮;通过 Webview `submitTurn` 消息调用 typed `RpcServerManager.sendTurn()`,发送时把 Problems 快照转换为 diagnostic attachments,并按协议 attachment 上限优先保留 error;accepted 后等待同一 run 的 terminal event 收口输入状态,Cancel 会调用 typed `RpcServerManager.cancel()`。 `vscode/extension/src/commands.ts` 还提供 `requestApproval`: - 使用 VS Code modal warning 展示审批摘要。 -- 将 `Approve` / `Approve Once` / `Approve For Session` / `Reject` / 关闭弹窗映射为稳定的批准或拒绝决定。 +- 主审批弹窗只展示 `Approve` / `Reject`;`Approve` 映射为一次性批准,关闭弹窗映射为拒绝。 +- `apply_patch` 多 hunk 审批继续提供 `Select Hunks` quick pick,持久化批准能力保留在 Core/RPC 策略与队列中,不在主弹窗暴露复杂选项。 `vscode/extension/src/approvalFlow.ts` 当前接入真实 RPC pending approval: - 订阅 `RpcServerManager.onEvent()`,只处理 `tool.approvalRequired`。 -- 校验 approval payload 的 `approvalId`、`toolCallId`、`toolName`、`risk`、`title`、`detail`、`persistable`、`command` 和 `paths`。 +- 校验 approval payload 的 `approvalId`、`toolCallId`、`toolName`、`risk`、`title`、`detail`、`persistable`、`command`、`cwd`、`outputSummary` 和 `paths`。 - 复用 `requestApproval` 打开 VS Code modal,并把 approve/reject 结果发送为 typed `RpcServerManager.approve()` / `reject()`。 - 记录已处理的 approvalId,避免重复事件触发重复弹窗。 @@ -109,27 +110,38 @@ 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 接入。 +- `tool.approvalRequired` 触发 VS Code modal,approve/reject 能回传到 `agent.approve` / `agent.reject`。已完成真实 RPC pending queue 接入;`apply_patch` 首版支持 selected hunk quick pick 并通过 `agent.approve.hunks` 回传。 - Sidebar Chat 能通过 `agent.listRuns` 展示最近 run,并用 `agent.resume` 回放历史事件。已完成首版 Run List / resume 接入。 - Sidebar Chat 能把 `context.built` 渲染为 Context Capsule 面板,展示 token 分段、来源和 manifest/cache/estimator metadata。已完成首版 Context Capsule 可视化。 - -Phase 4 P1/P2 深度集成: - -1. 读取 Problems 面板诊断并交给 Agent Core。 -2. Terminal command approval 展示命令、cwd、风险等级、输出摘要和持久化选项。 -3. provider、model、预算、审批策略和 RPC 命令配置界面。 -4. FIM completion preview。 -5. VSIX alpha / pre-release 打包与安装说明。 +- `ProleCoder: Open Settings` 能打开 VS Code 设置,并显示 server capability、模型预算、审批策略、RPC command/state;扩展配置不保存 API Key。 +- 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`。 在这些能力稳定前,不在插件侧重复实现 context builder、tool execution 或 provider 调用。 ## 后续增强 -- `agent.sendTurn`、`agent.approve` 和 `agent.reject` 类型化 helper 已完成;继续为 `agent.cancel` 等常用方法增加类型化 helper,避免 UI 层直接拼 method string。 -- 支持 `agent.cancel`,在用户关闭 run 或插件停用时取消 pending run。 -- 处理协议版本不匹配:显示 server/client protocol version,并引导用户升级对应组件。 - 支持多 workspace folder:每个 workspace root 对应一个 RPC server 或明确选择 active workspace。 -- 渲染 `agent.event` 流,包括 assistant delta、计划、工具调用、审批请求、patch 和验证结果。 -- 扩展 Native diff editor 当前的 hunk boundary,支持真实 hunk 级选择、部分批准和对应 RPC/Core 协议扩展。 -- 从 Problems 面板读取 diagnostics,通过协议传给 Agent Core,而不是在插件内自行生成修复逻辑。 -- 增加 `@vscode/test-electron` 集成测试,覆盖真实 extension activation、配置读取、启动失败提示和基础事件渲染。 +- 支持多 active run 与多个前端订阅同一 run 的事件流。 +- 扩展 Native diff editor hunk 审批到更复杂的编辑器 diff 场景。 +- 增加更细的 replay 标记与历史事件筛选语义。 diff --git a/package.json b/package.json index 3e32401..90f9188 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "format": "cargo fmt --all", "lint": "cargo clippy --workspace --all-targets -- -D warnings && pnpm -r lint", "test": "cargo test --workspace && pnpm -r test", - "typecheck": "pnpm -r typecheck" + "typecheck": "pnpm -r typecheck", + "vscode:test-electron": "pnpm --filter prole-coder-vscode test:electron", + "vsix:alpha": "pnpm --filter prole-coder-vscode vsix:alpha", + "vsix:smoke": "pnpm -r --if-present vsix:smoke" } } diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 3fc4913..578eda9 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -1,6 +1,7 @@ export const protocolVersion = "0.1.0" as const; export const jsonRpcVersion = "2.0" as const; export const agentEventMethod = "agent.event" as const; +export const agentEventBatchMethod = "agent.eventBatch" as const; export const agentInitializeMethod = "agent.initialize" as const; export const agentSendTurnMethod = "agent.sendTurn" as const; export const agentResumeMethod = "agent.resume" as const; @@ -8,6 +9,7 @@ export const agentApproveMethod = "agent.approve" as const; export const agentRejectMethod = "agent.reject" as const; export const agentCancelMethod = "agent.cancel" as const; export const agentListRunsMethod = "agent.listRuns" as const; +export const agentPreviewFimMethod = "agent.previewFim" as const; export interface ProtocolErrorDefinition { readonly code: number; @@ -61,6 +63,35 @@ export const riskLevels = ["read", "write", "exec", "network", "destructive"] as export type RiskLevel = (typeof riskLevels)[number]; export type ApprovalRisk = RiskLevel; +export const providerCapabilityFeatures = [ + "thinking", + "toolCalls", + "toolChoice", + "fim", + "streaming", + "cacheUsage", +] as const; +export type ProviderCapabilityFeature = (typeof providerCapabilityFeatures)[number]; + +export interface ProviderModelCapabilities { + readonly id: string; + readonly displayName?: string; + readonly contextWindowTokens: number; + readonly maxOutputTokens: number; + readonly supportsThinking: boolean; + readonly supportsToolCalls: boolean; + readonly supportsToolChoice: boolean; + readonly supportsFim: boolean; + readonly supportsStreaming: boolean; + readonly reportsCacheUsage: boolean; +} + +export interface ProviderCapabilities { + readonly provider: string; + readonly defaultModel: string; + readonly models: readonly ProviderModelCapabilities[]; +} + export const approvalRequirements = ["none", "required", "always_required"] as const; export type ApprovalRequirement = (typeof approvalRequirements)[number]; @@ -402,7 +433,9 @@ export interface ServerCapabilities { readonly supportsRunResume: boolean; readonly supportsPatchApproval: boolean; readonly supportsPersistentApprovals: boolean; + readonly supportsEventBatching: boolean; readonly supportedRiskLevels: readonly RiskLevel[]; + readonly provider: ProviderCapabilities; } export interface AgentInitializeResult { @@ -480,12 +513,18 @@ export interface ListRunsResult { export interface ApproveParams { readonly approvalId: string; readonly persist?: ApprovalPersistence; + readonly hunks?: { + readonly approved: readonly string[]; + }; } export interface ApproveResult { readonly approvalId: string; readonly state: "approved"; readonly persist: ApprovalPersistence; + readonly hunks?: { + readonly approved: readonly string[]; + }; } export interface RejectParams { @@ -510,6 +549,21 @@ export interface CancelResult { readonly reason?: string; } +export interface FimPreviewParams { + readonly prefix: string; + readonly suffix?: string; + readonly path?: string; + readonly languageId?: string; + readonly model?: string; + readonly maxTokens?: number; +} + +export interface FimPreviewResult { + readonly text: string; + readonly model: string; + readonly finishReason?: string; +} + export interface ApprovalRequest { readonly approvalId: string; readonly risk: RiskLevel; @@ -518,11 +572,27 @@ export interface ApprovalRequest { readonly toolCallId: string; readonly toolName: ToolName; readonly command?: string; + readonly cwd?: string; + readonly outputSummary?: string; readonly paths?: readonly string[]; + readonly hunks?: readonly PatchApprovalHunk[]; readonly riskReasons?: readonly string[]; readonly persistable: boolean; } +// Keep vscode/extension/src/approvalFlow.ts optionalApprovalHunks in sync with this wire shape. +export interface PatchApprovalHunk { + readonly id: string; + readonly filePath: string; + readonly fileIndex: number; + readonly hunkIndex: number; + readonly oldStart: number; + readonly oldCount: number; + readonly newStart: number; + readonly newCount: number; + readonly section?: string; +} + export type JsonRpcId = string | number | null; export interface JsonRpcRequest { @@ -605,6 +675,30 @@ export interface ProviderCompletedPayload extends RunLogPayloadMetadata { readonly streaming?: ProviderStreamingPayload; } +export interface ProviderRequestedPayload { + readonly iteration: number; + readonly messageCount: number; + readonly reasoningState: Readonly>; +} + +export type ToolExecutionStatus = "ok" | "failed"; + +export interface ToolCompletedPayload extends RunLogPayloadMetadata { + readonly toolCallId: string; + readonly name: ToolName; + readonly status: ToolExecutionStatus; + readonly summary: string; + readonly result: Readonly>; +} + +export type VerificationStatus = "passed" | "failed" | "skipped"; + +export interface RunCompletedPayload { + readonly summary: string; + readonly changedFiles: readonly string[]; + readonly verificationStatus: VerificationStatus; +} + export interface ToolApprovalRequiredPayload { readonly approvalId: string; readonly toolCallId: string; @@ -613,7 +707,10 @@ export interface ToolApprovalRequiredPayload { readonly title: string; readonly detail: string; readonly command?: string; + readonly cwd?: string; + readonly outputSummary?: string; readonly paths?: readonly string[]; + readonly hunks?: readonly PatchApprovalHunk[]; readonly riskReasons?: readonly string[]; readonly persistable: boolean; } @@ -624,6 +721,14 @@ export interface ToolApprovalResolvedPayload { readonly toolName: ToolName; readonly decision: "approved" | "rejected" | "canceled" | "expired"; readonly reason?: string; + readonly hunks?: + | { + readonly scope: "all"; + } + | { + readonly scope: "selected"; + readonly approved: readonly string[]; + }; } export type AgentEventNotification = JsonRpcNotification< @@ -632,6 +737,19 @@ export type AgentEventNotification = JsonRpcNotification< readonly method: typeof agentEventMethod; }; +export interface AgentEventBatchParams { + readonly events: readonly AgentEventEnvelope[]; + readonly firstSeq: number; + readonly lastSeq: number; + readonly count: number; +} + +export type AgentEventBatchNotification = JsonRpcNotification< + AgentEventBatchParams +> & { + readonly method: typeof agentEventBatchMethod; +}; + export type AgentEvent = | { readonly type: "delta"; diff --git a/packages/protocol/test/index.test.ts b/packages/protocol/test/index.test.ts index b761a82..f887785 100644 --- a/packages/protocol/test/index.test.ts +++ b/packages/protocol/test/index.test.ts @@ -11,18 +11,28 @@ import { agentRejectMethod, agentCancelMethod, agentListRunsMethod, + agentEventBatchMethod, + agentPreviewFimMethod, type ApprovalRequest, type ApproveParams, type ApproveResult, type CancelParams, type CancelResult, + type AgentEventBatchParams, + type FimPreviewParams, + type FimPreviewResult, type ListRunsParams, type ListRunsResult, + type ProviderCapabilities, type ProviderCompletedPayload, + type ProviderRequestedPayload, + type RunCompletedPayload, type RunLogPayloadMetadata, type RunSummary, type RejectParams, type RejectResult, + type ServerCapabilities, + type ToolCompletedPayload, type ToolApprovalRequiredPayload, type ToolApprovalResolvedPayload, type TurnAttachment, @@ -53,12 +63,30 @@ interface ToolRegistryTool { readonly status: string; } +interface EventPayloadFixture { + readonly version: string; + readonly events: readonly EventPayloadFixtureEntry[]; +} + +interface EventPayloadFixtureEntry { + readonly type: string; + readonly payloadName: string; + readonly required: readonly string[]; + readonly properties: Readonly>; +} + const toolRegistryFixture = JSON.parse( readFileSync( new URL("../../../../docs/protocol/tool-registry.v1.json", import.meta.url), "utf8", ), ) as ToolRegistryFixture; +const eventPayloadFixture = JSON.parse( + readFileSync( + new URL("../../../../docs/protocol/event-payloads.v1.json", import.meta.url), + "utf8", + ), +) as EventPayloadFixture; const jsonRpcProtocolDocument = readFileSync( new URL("../../../../docs/json-rpc-protocol.md", import.meta.url), "utf8", @@ -102,7 +130,43 @@ test("JSON-RPC method constants match protocol document", () => { assert.equal(agentRejectMethod, "agent.reject"); assert.equal(agentCancelMethod, "agent.cancel"); assert.equal(agentListRunsMethod, "agent.listRuns"); + assert.equal(agentPreviewFimMethod, "agent.previewFim"); assert.equal(agentEventMethod, "agent.event"); + assert.equal(agentEventBatchMethod, "agent.eventBatch"); +}); + +test("server capabilities expose provider model capabilities", () => { + const provider = { + provider: "deepseek", + defaultModel: "deepseek-v4-pro", + models: [ + { + id: "deepseek-v4-pro", + displayName: "DeepSeek V4 Pro", + contextWindowTokens: 1_048_576, + maxOutputTokens: 393_216, + supportsThinking: true, + supportsToolCalls: true, + supportsToolChoice: false, + supportsFim: true, + supportsStreaming: true, + reportsCacheUsage: true, + }, + ], + } satisfies ProviderCapabilities; + const capabilities = { + protocolVersion, + supportsRunResume: true, + supportsPatchApproval: true, + supportsPersistentApprovals: true, + supportsEventBatching: true, + supportedRiskLevels: riskLevels, + provider, + } satisfies ServerCapabilities; + + assert.equal(capabilities.provider.defaultModel, "deepseek-v4-pro"); + assert.equal(capabilities.provider.models[0]?.supportsFim, true); + assert.equal(capabilities.supportsEventBatching, true); }); test("protocol error code registry matches protocol document", () => { @@ -131,11 +195,25 @@ test("approval request and decision params use stable protocol fields", () => { const request = { approvalId: "approval_1", toolCallId: "call_1", - toolName: "shell", - risk: "exec", - title: "Run shell command", - detail: "Execute cargo test", - command: "cargo test", + toolName: "apply_patch", + risk: "write", + title: "Apply patch", + detail: "Modify README.md", + cwd: ".", + outputSummary: "previous command output was truncated", + paths: ["README.md"], + hunks: [ + { + id: "README.md#1:old1+3:new1+3", + filePath: "README.md", + fileIndex: 0, + hunkIndex: 0, + oldStart: 1, + oldCount: 3, + newStart: 1, + newCount: 3, + }, + ], riskReasons: ["dependency install/update"], persistable: false, } satisfies ApprovalRequest; @@ -146,7 +224,10 @@ test("approval request and decision params use stable protocol fields", () => { risk: request.risk, title: request.title, detail: request.detail, - command: request.command, + cwd: request.cwd, + outputSummary: request.outputSummary, + paths: request.paths, + hunks: request.hunks, riskReasons: request.riskReasons, persistable: request.persistable, } satisfies ToolApprovalRequiredPayload; @@ -155,15 +236,23 @@ test("approval request and decision params use stable protocol fields", () => { toolCallId: request.toolCallId, toolName: request.toolName, decision: "approved", + hunks: { + scope: "selected", + approved: ["README.md#1:old1+3:new1+3"], + }, } satisfies ToolApprovalResolvedPayload; const approve = { approvalId: request.approvalId, - persist: "session", + persist: "never", + hunks: { + approved: ["README.md#1:old1+3:new1+3"], + }, } satisfies ApproveParams; const approveResult = { approvalId: request.approvalId, state: "approved", - persist: "session", + persist: "never", + hunks: approve.hunks, } satisfies ApproveResult; const reject = { approvalId: request.approvalId, @@ -183,6 +272,19 @@ test("approval request and decision params use stable protocol fields", () => { state: "canceled", reason: cancel.reason, } satisfies CancelResult; + const fimPreview = { + prefix: "fn main() {", + suffix: "}", + path: "src/main.rs", + languageId: "rust", + model: "deepseek-v4-pro", + maxTokens: 32, + } satisfies FimPreviewParams; + const fimPreviewResult = { + text: " println!(\"hi\");", + model: "deepseek-v4-pro", + finishReason: "stop", + } satisfies FimPreviewResult; const expiredPayload = { approvalId: request.approvalId, toolCallId: request.toolCallId, @@ -192,11 +294,15 @@ test("approval request and decision params use stable protocol fields", () => { } satisfies ToolApprovalResolvedPayload; assert.equal(approve.approvalId, "approval_1"); - assert.equal(requiredPayload.toolName, "shell"); + assert.equal(requiredPayload.toolName, "apply_patch"); + assert.equal(requiredPayload.hunks?.[0]?.id, "README.md#1:old1+3:new1+3"); assert.equal(resolvedPayload.decision, "approved"); + assert.equal(approve.hunks?.approved[0], "README.md#1:old1+3:new1+3"); assert.equal(approveResult.state, "approved"); assert.equal(reject.reason, rejectResult.reason); assert.equal(cancelResult.state, "canceled"); + assert.equal(fimPreview.languageId, "rust"); + assert.equal(fimPreviewResult.finishReason, "stop"); assert.equal(expiredPayload.decision, "expired"); }); @@ -297,6 +403,116 @@ test("attachments and provider completed payload use phase 2c and 2d fields", () assert.equal(metadata.runLogTruncation?.[0]?.reason, "max_string_bytes"); }); +test("event payload fixture stays aligned with shared protocol types", () => { + const providerRequested = { + iteration: 1, + messageCount: 4, + reasoningState: { + status: "active", + }, + } satisfies ProviderRequestedPayload; + const toolCompleted = { + toolCallId: "call_1", + name: "shell", + status: "ok", + summary: "Command completed.", + result: { + exitCode: 0, + }, + } satisfies ToolCompletedPayload; + const runCompleted = { + summary: "Updated the workspace.", + changedFiles: ["README.md"], + verificationStatus: "passed", + } satisfies RunCompletedPayload; + const toolApprovalRequired = { + approvalId: "approval_1", + toolCallId: "call_patch", + toolName: "apply_patch", + risk: "write", + title: "Apply patch", + detail: "Modify README.md", + paths: ["README.md"], + hunks: [ + { + id: "README.md#1:old1+3:new1+3", + filePath: "README.md", + fileIndex: 0, + hunkIndex: 0, + oldStart: 1, + oldCount: 3, + newStart: 1, + newCount: 3, + }, + ], + persistable: true, + } satisfies ToolApprovalRequiredPayload; + const toolApprovalResolved = { + approvalId: "approval_1", + toolCallId: "call_patch", + toolName: "apply_patch", + decision: "approved", + hunks: { + scope: "selected", + approved: ["README.md#1:old1+3:new1+3"], + }, + } satisfies ToolApprovalResolvedPayload; + const samples = { + "provider.requested": providerRequested, + "tool.completed": toolCompleted, + "run.completed": runCompleted, + "tool.approvalRequired": toolApprovalRequired, + "tool.approvalResolved": toolApprovalResolved, + } as const; + + assert.equal(eventPayloadFixture.version, protocolVersion); + assert.deepEqual(eventPayloadFixture.events.map((event) => event.type), [ + "provider.requested", + "tool.completed", + "run.completed", + "tool.approvalRequired", + "tool.approvalResolved", + ]); + + for (const event of eventPayloadFixture.events) { + const sample = samples[event.type as keyof typeof samples] as Record; + assert.ok(sample, `missing sample for ${event.type}`); + for (const field of event.required) { + assert.equal(field in sample, true, `${event.type} must include ${field}`); + } + } +}); + +test("event batch notification payload preserves event ordering metadata", () => { + const batch = { + events: [ + { + seq: 10, + time: "1970-01-01T00:00:00.000Z", + type: "assistant.delta", + runId: "run_1", + turnId: "turn_1", + payload: { text: "hello" }, + }, + { + seq: 11, + time: "1970-01-01T00:00:00.001Z", + type: "assistant.delta", + runId: "run_1", + turnId: "turn_1", + payload: { text: " world" }, + }, + ], + firstSeq: 10, + lastSeq: 11, + count: 2, + } satisfies AgentEventBatchParams; + + assert.equal(batch.events[0]?.seq, batch.firstSeq); + assert.equal(batch.events[1]?.seq, batch.lastSeq); + assert.equal(batch.events.length, batch.count); +}); + test("tool registry contains every declared tool exactly once", () => { const registeredNames = toolDefinitions.map((tool) => tool.name); diff --git a/vscode/extension/.vscodeignore b/vscode/extension/.vscodeignore index bd2ca4b..8415472 100644 --- a/vscode/extension/.vscodeignore +++ b/vscode/extension/.vscodeignore @@ -1,5 +1,6 @@ src/** test/** +scripts/** out-test/** out/**/*.test.js out/**/*.test.js.map diff --git a/vscode/extension/README.md b/vscode/extension/README.md new file mode 100644 index 0000000..c226383 --- /dev/null +++ b/vscode/extension/README.md @@ -0,0 +1,7 @@ +# ProleCoder VS Code Extension + +This package contains the VS Code frontend for ProleCoder. + +The extension starts and supervises the Rust JSON-RPC agent server, renders agent events in the sidebar chat, and forwards user decisions such as approvals back to the shared core. + +For repository setup, development plans, and release status, see the workspace root README. diff --git a/vscode/extension/package.json b/vscode/extension/package.json index 4e2a27f..4540210 100644 --- a/vscode/extension/package.json +++ b/vscode/extension/package.json @@ -15,7 +15,9 @@ "activationEvents": [ "onStartupFinished", "onCommand:prole-coder.openChat", - "onView:prole-coder.chat" + "onCommand:prole-coder.openSettings", + "onView:prole-coder.chat", + "onChatParticipant:prole-coder.chatParticipant" ], "main": "./out/extension.js", "contributes": { @@ -23,6 +25,37 @@ { "command": "prole-coder.openChat", "title": "ProleCoder: Open Chat" + }, + { + "command": "prole-coder.openSettings", + "title": "ProleCoder: Open Settings" + } + ], + "chatParticipants": [ + { + "id": "prole-coder.chatParticipant", + "name": "prole", + "fullName": "ProleCoder", + "description": "Use ProleCoder from the native VS Code Chat sidebar.", + "isSticky": true, + "commands": [ + { + "name": "edit", + "description": "Ask ProleCoder to make workspace changes." + }, + { + "name": "ask", + "description": "Ask about the workspace without editing." + }, + { + "name": "plan", + "description": "Plan a change before editing." + }, + { + "name": "review", + "description": "Review the current code." + } + ] } ], "viewsContainers": { @@ -63,6 +96,30 @@ "type": "string" }, "description": "Arguments passed to the Rust Agent RPC server command." + }, + "prole-coder.fim.enabled": { + "type": "boolean", + "default": true, + "description": "Enable ProleCoder inline FIM completion previews when the connected RPC server reports FIM-capable provider data." + }, + "prole-coder.fim.model": { + "type": "string", + "default": "", + "description": "Optional model id for FIM previews. Leave empty to use the RPC server default model from provider capabilities." + }, + "prole-coder.fim.maxTokens": { + "type": "integer", + "default": 128, + "minimum": 1, + "maximum": 4096, + "description": "Maximum completion tokens requested for FIM previews." + }, + "prole-coder.fim.maxContextChars": { + "type": "integer", + "default": 12000, + "minimum": 1000, + "maximum": 100000, + "description": "Maximum prefix and suffix characters sent to the RPC server for a FIM preview." } } } @@ -71,6 +128,9 @@ "build": "pnpm --filter @prole-coder/protocol build && tsc -p tsconfig.json", "lint": "pnpm --filter @prole-coder/protocol build && tsc -p tsconfig.test.json --noEmit", "package": "vsce package", + "test:electron": "pnpm run build && tsc -p tsconfig.test.json && node scripts/runVscodeIntegrationTests.mjs", + "vsix:alpha": "pnpm run build && node scripts/packageAlphaVsix.mjs", + "vsix:smoke": "pnpm run build && node scripts/vsixDryRunSmoke.mjs", "test": "pnpm --filter @prole-coder/protocol build && tsc -p tsconfig.test.json && node --test \"out-test/**/*.test.js\"", "typecheck": "pnpm --filter @prole-coder/protocol build && tsc -p tsconfig.json --noEmit" }, diff --git a/vscode/extension/scripts/packageAlphaVsix.mjs b/vscode/extension/scripts/packageAlphaVsix.mjs new file mode 100644 index 0000000..cf89395 --- /dev/null +++ b/vscode/extension/scripts/packageAlphaVsix.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const { createVSIX } = require("@vscode/vsce"); +const { readVSIXPackage } = require("@vscode/vsce/out/zip"); + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const extensionRoot = path.resolve(scriptDir, ".."); +const workspaceRoot = path.resolve(extensionRoot, "..", ".."); +const outputDir = path.join(workspaceRoot, "target", "vsix"); +const releaseChannel = "alpha"; + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); + +async function main() { + const manifestPath = path.join(extensionRoot, "package.json"); + const manifest = await readJson(manifestPath); + assertAlphaManifestShape(manifest); + + await fs.mkdir(outputDir, { recursive: true }); + const packagePath = path.join(outputDir, `${manifest.name}-${manifest.version}-${releaseChannel}.vsix`); + assertPathInside(outputDir, packagePath); + + await createVSIX({ + cwd: extensionRoot, + packagePath, + readmePath: "README.md", + dependencies: false, + allowMissingRepository: true, + rewriteRelativeLinks: false, + skipLicense: true, + preRelease: true, + }); + + const packageInfo = await readVSIXPackage(packagePath); + assertPackagedManifest(packageInfo, manifest); + + const checksum = await sha256File(packagePath); + const checksumPath = `${packagePath}.sha256`; + await fs.writeFile(checksumPath, `${checksum} ${path.basename(packagePath)}\n`, "utf8"); + + const stats = await fs.stat(packagePath); + const relativePackagePath = path.relative(workspaceRoot, packagePath); + const relativeChecksumPath = path.relative(workspaceRoot, checksumPath); + console.log(`VSIX ${releaseChannel} package created: ${relativePackagePath}`); + console.log(`SHA-256 checksum written: ${relativeChecksumPath}`); + console.log(`Package size: ${stats.size} bytes`); +} + +async function readJson(filePath) { + const raw = await fs.readFile(filePath, "utf8"); + return JSON.parse(raw); +} + +function assertAlphaManifestShape(manifest) { + assert(typeof manifest.name === "string" && manifest.name.length > 0, "package.json must define name."); + assert(typeof manifest.version === "string" && manifest.version.length > 0, "package.json must define version."); + assert(typeof manifest.publisher === "string" && manifest.publisher.length > 0, "package.json must define publisher."); + assert(typeof manifest.engines?.vscode === "string", "package.json must define engines.vscode."); + assert( + typeof manifest.main === "string" && normalizeManifestPath(manifest.main).startsWith("out/"), + 'package.json main must point at compiled "out/" JavaScript.', + ); + assert( + Array.isArray(manifest.activationEvents) && manifest.activationEvents.includes("onView:prole-coder.chat"), + "activationEvents must include onView:prole-coder.chat.", + ); + assert( + manifest.activationEvents.includes("onChatParticipant:prole-coder.chatParticipant"), + "activationEvents must include onChatParticipant:prole-coder.chatParticipant.", + ); + const chatParticipant = manifest.contributes?.chatParticipants?.find( + (participant) => participant?.id === "prole-coder.chatParticipant", + ); + assert(chatParticipant?.name === "prole", "native chat participant @prole must be declared."); +} + +function assertPackagedManifest(packageInfo, sourceManifest) { + const packagedManifest = packageInfo?.manifest; + assert(packagedManifest?.name === sourceManifest.name, "VSIX package name does not match package.json."); + assert(packagedManifest?.version === sourceManifest.version, "VSIX package version does not match package.json."); + assert(packagedManifest?.publisher === sourceManifest.publisher, "VSIX publisher does not match package.json."); + + const properties = packageInfo?.xmlManifest?.PackageManifest?.Metadata?.[0]?.Properties?.[0]?.Property ?? []; + const hasPreReleaseFlag = properties.some( + (property) => + property?.$?.Id === "Microsoft.VisualStudio.Code.PreRelease" && property?.$?.Value === "true", + ); + assert(hasPreReleaseFlag, "VSIX manifest must be marked as a VS Code pre-release package."); +} + +async function sha256File(filePath) { + const buffer = await fs.readFile(filePath); + return crypto.createHash("sha256").update(buffer).digest("hex"); +} + +function normalizeManifestPath(manifestPath) { + return manifestPath.replace(/^\.\//u, "").replaceAll("\\", "/"); +} + +function assertPathInside(parent, child) { + const relative = path.relative(parent, child); + assert(relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative), `${child} is outside ${parent}.`); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} diff --git a/vscode/extension/scripts/runVscodeIntegrationTests.mjs b/vscode/extension/scripts/runVscodeIntegrationTests.mjs new file mode 100644 index 0000000..edac8c5 --- /dev/null +++ b/vscode/extension/scripts/runVscodeIntegrationTests.mjs @@ -0,0 +1,49 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { runTests } from "@vscode/test-electron"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const extensionDevelopmentPath = resolve(scriptDir, ".."); +const repoRoot = resolve(extensionDevelopmentPath, "../.."); +const extensionTestsPath = resolve(extensionDevelopmentPath, "out-test", "test", "electron", "index.js"); +const rpcFixtureServerPath = resolve(extensionDevelopmentPath, "test", "fixtures", "rpcFixtureServer.mjs"); +const workspacePath = resolve(repoRoot, "target", "vscode-test-workspace"); +const settingsPath = resolve(workspacePath, ".vscode", "settings.json"); +const rpcFixtureLogPath = resolve(workspacePath, ".prole-coder-test", "rpc-log.jsonl"); +const profileSuffix = `${process.pid}-${Date.now()}`; +const userDataPath = resolve(repoRoot, "target", `vscode-test-user-data-${profileSuffix}`); +const extensionsPath = resolve(repoRoot, "target", `vscode-test-extensions-${profileSuffix}`); + +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_RPC_LOG = rpcFixtureLogPath; + +mkdirSync(dirname(settingsPath), { recursive: true }); +rmSync(rpcFixtureLogPath, { force: true }); +writeFileSync( + settingsPath, + `${JSON.stringify( + { + "prole-coder.rpc.autoStart": false, + "prole-coder.rpc.command": process.execPath, + "prole-coder.rpc.args": [rpcFixtureServerPath, rpcFixtureLogPath], + }, + null, + 2, + )}\n`, +); + +await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: [ + `--folder-uri=${pathToFileURL(workspacePath).href}`, + `--user-data-dir=${userDataPath}`, + `--extensions-dir=${extensionsPath}`, + "--disable-extensions", + "--new-window", + ], +}); diff --git a/vscode/extension/scripts/vsixDryRunSmoke.mjs b/vscode/extension/scripts/vsixDryRunSmoke.mjs new file mode 100644 index 0000000..0ac7dfa --- /dev/null +++ b/vscode/extension/scripts/vsixDryRunSmoke.mjs @@ -0,0 +1,248 @@ +#!/usr/bin/env node +import fs from "node:fs/promises"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const { createVSIX } = require("@vscode/vsce"); + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const extensionRoot = path.resolve(scriptDir, ".."); +const workspaceRoot = path.resolve(extensionRoot, "..", ".."); +const targetRoot = path.join(workspaceRoot, "target"); + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); + +async function main() { + const manifestPath = path.join(extensionRoot, "package.json"); + const manifest = await readJson(manifestPath); + + await assertManifestPackagingShape(manifest); + await assertVscodeIgnore(); + await assertWorkspaceDependencyBoundary(manifest); + + await fs.mkdir(targetRoot, { recursive: true }); + const tempDir = await fs.mkdtemp(path.join(targetRoot, "vsix-dry-run-")); + const packagePath = path.join(tempDir, `${manifest.name}-${manifest.version}.vsix`); + + try { + await createVSIX({ + cwd: extensionRoot, + packagePath, + readmePath: "README.md", + dependencies: false, + allowMissingRepository: true, + rewriteRelativeLinks: false, + skipLicense: true, + }); + + const entries = await readZipEntries(packagePath); + assertPackageEntries(entries, manifest); + + console.log(`VSIX dry-run packaging smoke passed (${entries.size} entries inspected).`); + } finally { + await removeTempDir(tempDir); + } +} + +async function readJson(filePath) { + const raw = await fs.readFile(filePath, "utf8"); + return JSON.parse(raw); +} + +async function assertManifestPackagingShape(manifest) { + assert(typeof manifest.name === "string" && manifest.name.length > 0, "package.json must define name."); + assert( + typeof manifest.version === "string" && manifest.version.length > 0, + "package.json must define version.", + ); + assert( + typeof manifest.main === "string" && normalizeManifestPath(manifest.main).startsWith("out/"), + 'package.json main must point at compiled "out/" JavaScript.', + ); + await assertFileExists(path.join(extensionRoot, manifest.main), `compiled entry ${manifest.main}`); + + const activationEvents = manifest.activationEvents; + assert(Array.isArray(activationEvents) && activationEvents.length > 0, "activationEvents must not be empty."); + assert(!activationEvents.includes("*"), "activationEvents must be explicit for the smoke package."); + for (const event of ["onCommand:prole-coder.openChat", "onView:prole-coder.chat"]) { + assert(activationEvents.includes(event), `activationEvents must include ${event}.`); + } + assert( + activationEvents.includes("onChatParticipant:prole-coder.chatParticipant"), + "activationEvents must include onChatParticipant:prole-coder.chatParticipant.", + ); + + const chatParticipant = manifest.contributes?.chatParticipants?.find( + (participant) => participant?.id === "prole-coder.chatParticipant", + ); + assert(chatParticipant?.name === "prole", "native chat participant @prole must be declared."); + assert(chatParticipant?.isSticky === true, "native chat participant must be sticky."); + + const iconPath = manifest.contributes?.viewsContainers?.activitybar?.find( + (container) => container?.id === "prole-coder", + )?.icon; + assert(typeof iconPath === "string" && iconPath.length > 0, "activity bar icon must be declared."); + await assertFileExists(path.join(extensionRoot, iconPath), `activity bar icon ${iconPath}`); +} + +async function assertVscodeIgnore() { + const ignorePath = path.join(extensionRoot, ".vscodeignore"); + await assertFileExists(ignorePath, ".vscodeignore"); + const ignoreLines = new Set( + (await fs.readFile(ignorePath, "utf8")) + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")), + ); + + for (const pattern of ["src/**", "test/**", "scripts/**", "out-test/**", "node_modules/**", "**/*.map"]) { + assert(ignoreLines.has(pattern), `.vscodeignore must contain ${pattern}.`); + } +} + +async function assertWorkspaceDependencyBoundary(manifest) { + const runtimeDependencySections = ["dependencies", "optionalDependencies"]; + for (const section of runtimeDependencySections) { + const dependencies = manifest[section] ?? {}; + for (const [name, version] of Object.entries(dependencies)) { + assert( + typeof version !== "string" || !version.startsWith("workspace:"), + `${section}.${name} must not use workspace:* in the VSIX runtime package.`, + ); + } + } + + const workspaceDevDependencies = Object.entries(manifest.devDependencies ?? {}).filter( + ([, version]) => typeof version === "string" && version.startsWith("workspace:"), + ); + assert( + workspaceDevDependencies.some(([name]) => name === "@prole-coder/protocol"), + "expected @prole-coder/protocol to remain an explicit workspace:* devDependency.", + ); + + const compiledFiles = await collectFiles(path.join(extensionRoot, "out")); + const jsFiles = compiledFiles.filter((filePath) => /\.(?:cjs|js|mjs)$/u.test(filePath)); + for (const filePath of jsFiles) { + const content = await fs.readFile(filePath, "utf8"); + for (const [packageName] of workspaceDevDependencies) { + assert( + !content.includes(packageName), + `compiled runtime file ${path.relative(extensionRoot, filePath)} still references ${packageName}.`, + ); + } + } +} + +function assertPackageEntries(entries, manifest) { + const requiredEntries = [ + "extension/package.json", + `extension/${normalizeManifestPath(manifest.main)}`, + "extension/media/prole-coder-view.svg", + "extension/readme.md", + ]; + + for (const entry of requiredEntries) { + assert(entries.has(entry), `VSIX package must include ${entry}.`); + } + + const forbiddenPrefixes = [ + "extension/src/", + "extension/test/", + "extension/scripts/", + "extension/out-test/", + "extension/node_modules/", + "extension/.vscode/", + ]; + for (const entry of entries) { + for (const prefix of forbiddenPrefixes) { + assert(!entry.startsWith(prefix), `VSIX package must not include ignored path ${entry}.`); + } + assert(!entry.endsWith(".map"), `VSIX package must not include source map ${entry}.`); + assert(!entry.endsWith(".tsbuildinfo"), `VSIX package must not include TypeScript build info ${entry}.`); + } +} + +async function assertFileExists(filePath, label) { + try { + const stat = await fs.stat(filePath); + assert(stat.isFile(), `${label} must be a file.`); + } catch (error) { + if (error?.code === "ENOENT") { + throw new Error(`Missing ${label}.`); + } + throw error; + } +} + +async function collectFiles(root) { + const files = []; + const entries = await fs.readdir(root, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectFiles(entryPath))); + } else if (entry.isFile()) { + files.push(entryPath); + } + } + return files; +} + +async function readZipEntries(zipPath) { + const buffer = await fs.readFile(zipPath); + const eocdOffset = findEndOfCentralDirectory(buffer); + const centralDirectorySize = buffer.readUInt32LE(eocdOffset + 12); + const centralDirectoryOffset = buffer.readUInt32LE(eocdOffset + 16); + const centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize; + const entries = new Set(); + + let offset = centralDirectoryOffset; + while (offset < centralDirectoryEnd) { + assert(buffer.readUInt32LE(offset) === 0x02014b50, "Invalid VSIX central directory."); + const fileNameLength = buffer.readUInt16LE(offset + 28); + const extraFieldLength = buffer.readUInt16LE(offset + 30); + const commentLength = buffer.readUInt16LE(offset + 32); + const fileNameStart = offset + 46; + const fileNameEnd = fileNameStart + fileNameLength; + entries.add(buffer.toString("utf8", fileNameStart, fileNameEnd)); + offset = fileNameEnd + extraFieldLength + commentLength; + } + + return entries; +} + +function findEndOfCentralDirectory(buffer) { + const signature = 0x06054b50; + const minimumOffset = Math.max(0, buffer.length - 0xffff - 22); + for (let offset = buffer.length - 22; offset >= minimumOffset; offset -= 1) { + if (buffer.readUInt32LE(offset) === signature) { + return offset; + } + } + throw new Error("Invalid VSIX: missing ZIP end of central directory."); +} + +function normalizeManifestPath(manifestPath) { + return manifestPath.replace(/^\.\//u, "").replaceAll("\\", "/"); +} + +async function removeTempDir(tempDir) { + assertPathInside(targetRoot, tempDir); + await fs.rm(tempDir, { recursive: true, force: true }); +} + +function assertPathInside(parent, child) { + const relative = path.relative(parent, child); + assert(relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative), `${child} is outside ${parent}.`); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} diff --git a/vscode/extension/src/approvalFlow.ts b/vscode/extension/src/approvalFlow.ts index 8ddd37b..5970c52 100644 --- a/vscode/extension/src/approvalFlow.ts +++ b/vscode/extension/src/approvalFlow.ts @@ -136,6 +136,7 @@ export class ApprovalEventController implements DisposableLike { return this.rpcClient.approve({ approvalId: decision.approvalId, persist: approvalPersist(decision.persist), + ...(decision.hunks === undefined ? {} : { hunks: decision.hunks }), }); } @@ -177,7 +178,25 @@ export function approvalPromptRequestFromEvent( detail: event.payload.detail, persistable: event.payload.persistable, ...(event.payload.command === undefined ? {} : { command: event.payload.command }), + ...(event.payload.cwd === undefined ? {} : { cwd: event.payload.cwd }), + ...(event.payload.outputSummary === undefined + ? {} + : { outputSummary: event.payload.outputSummary }), ...(event.payload.paths === undefined ? {} : { paths: event.payload.paths }), + ...(event.payload.hunks === undefined + ? {} + : { + hunks: event.payload.hunks.map((hunk) => ({ + id: hunk.id, + filePath: hunk.filePath, + hunkIndex: hunk.hunkIndex, + oldStart: hunk.oldStart, + oldCount: hunk.oldCount, + newStart: hunk.newStart, + newCount: hunk.newCount, + ...(hunk.section === undefined ? {} : { section: hunk.section }), + })), + }), ...(event.payload.riskReasons === undefined ? {} : { riskReasons: event.payload.riskReasons }), }; } @@ -196,11 +215,38 @@ function isApprovalPayload(value: unknown): value is ToolApprovalRequiredPayload isNonEmptyString(value["detail"]) && typeof value["persistable"] === "boolean" && optionalString(value["command"]) && + optionalString(value["cwd"]) && + optionalString(value["outputSummary"]) && optionalStringArray(value["paths"]) && + optionalApprovalHunks(value["hunks"]) && optionalStringArray(value["riskReasons"]) ); } +function optionalApprovalHunks(value: unknown): boolean { + // Mirrors the shared protocol PatchApprovalHunk shape; update both sides when the wire shape changes. + if (value === undefined) { + return true; + } + if (!Array.isArray(value)) { + return false; + } + + return value.every( + (hunk) => + isRecord(hunk) && + isNonEmptyString(hunk["id"]) && + isNonEmptyString(hunk["filePath"]) && + Number.isInteger(hunk["fileIndex"]) && + Number.isInteger(hunk["hunkIndex"]) && + Number.isInteger(hunk["oldStart"]) && + Number.isInteger(hunk["oldCount"]) && + Number.isInteger(hunk["newStart"]) && + Number.isInteger(hunk["newCount"]) && + optionalString(hunk["section"]), + ); +} + function isRiskLevel(value: unknown): value is RiskLevel { return typeof value === "string" && Object.prototype.hasOwnProperty.call(RISK_LOOKUP, value); } diff --git a/vscode/extension/src/automaticContext.ts b/vscode/extension/src/automaticContext.ts new file mode 100644 index 0000000..0fd737a --- /dev/null +++ b/vscode/extension/src/automaticContext.ts @@ -0,0 +1,171 @@ +import type { TurnAttachment } from "@prole-coder/protocol" with { + "resolution-mode": "import", +}; + +import type { ChatTimelineItem, ChatTimelineSnapshot } from "./chatEvents"; + +export const MAX_TURN_ATTACHMENTS = 32; +const DEFAULT_MAX_CONTEXT_CHARS = 24_000; +const DEFAULT_RECENT_MESSAGES = 8; +const DEFAULT_COMPACTED_LINE_CHARS = 240; +const DEFAULT_TIMELINE_MESSAGE_CHARS = 8_000; +const AUTOMATIC_CONTEXT_PREFIX = "ProleCoder automatic conversation context"; + +export interface ConversationContextMessage { + readonly role: "assistant" | "system" | "user"; + readonly text: string; +} + +export interface AutomaticContextOptions { + readonly maxChars?: number; + readonly recentMessages?: number; + readonly compactedLineChars?: number; +} + +export function automaticContextAttachmentFromMessages( + messages: readonly ConversationContextMessage[], + options: AutomaticContextOptions = {}, +): TurnAttachment | undefined { + const cleaned = messages + .map((message) => ({ + role: message.role, + text: normalizeText(message.text), + })) + .filter((message) => message.text.length > 0); + + if (cleaned.length === 0) { + return undefined; + } + + const maxChars = positiveInteger(options.maxChars, DEFAULT_MAX_CONTEXT_CHARS); + const recentMessages = positiveInteger(options.recentMessages, DEFAULT_RECENT_MESSAGES); + const compactedLineChars = positiveInteger( + options.compactedLineChars, + DEFAULT_COMPACTED_LINE_CHARS, + ); + const compactedCount = Math.max(0, cleaned.length - recentMessages); + const compacted = cleaned.slice(0, compactedCount); + const recent = cleaned.slice(compactedCount); + + const sections = [ + AUTOMATIC_CONTEXT_PREFIX, + `Total entries: ${cleaned.length}`, + `Compacted entries: ${compacted.length}`, + `Recent entries: ${recent.length}`, + ]; + + if (compacted.length > 0) { + sections.push( + "", + "[Compacted Earlier Entries]", + ...compacted.map( + (message, index) => + `${index + 1}. ${roleLabel(message.role)}: ${firstLine(message.text, compactedLineChars)}`, + ), + ); + } + + sections.push("", "[Recent Entries]"); + for (const message of recent) { + sections.push(`${roleLabel(message.role)}:`, message.text, ""); + } + + const text = clampText(sections.join("\n"), maxChars); + if (text.length === 0) { + return undefined; + } + + return { + kind: "explicit_content", + text, + }; +} + +export function automaticContextAttachmentFromTimeline( + snapshot: ChatTimelineSnapshot, + options: AutomaticContextOptions = {}, +): TurnAttachment | undefined { + return automaticContextAttachmentFromMessages(messagesFromTimeline(snapshot.items), options); +} + +export function mergeTurnAttachments( + automaticContext: TurnAttachment | undefined, + attachments: readonly TurnAttachment[], +): readonly TurnAttachment[] { + if (automaticContext === undefined) { + return attachments.slice(0, MAX_TURN_ATTACHMENTS); + } + + return [automaticContext, ...attachments.slice(0, MAX_TURN_ATTACHMENTS - 1)]; +} + +export function isAutomaticContextAttachment(attachment: TurnAttachment): boolean { + return ( + attachment.kind === "explicit_content" && + typeof attachment.text === "string" && + attachment.text.startsWith(AUTOMATIC_CONTEXT_PREFIX) + ); +} + +function messagesFromTimeline(items: readonly ChatTimelineItem[]): ConversationContextMessage[] { + const messages: ConversationContextMessage[] = []; + for (const item of items) { + const text = timelineMessageText(item); + if (text.length === 0) { + continue; + } + + if (item.type === "turn.started") { + messages.push({ role: "user", text }); + } else if (item.type === "assistant.delta") { + messages.push({ role: "assistant", text }); + } else if (item.kind === "terminal" || item.kind === "approval" || item.kind === "tool") { + messages.push({ role: "system", text: `${item.title}\n${text}` }); + } + } + return messages; +} + +function timelineMessageText(item: ChatTimelineItem): string { + return clampText(item.body ?? item.title, DEFAULT_TIMELINE_MESSAGE_CHARS); +} + +function normalizeText(value: string): string { + return value.replace(/\r\n?/gu, "\n").trim(); +} + +function firstLine(value: string, maxChars: number): string { + const line = value + .split("\n") + .map((part) => part.trim()) + .find((part) => part.length > 0) ?? ""; + return clampText(line, maxChars).replace(/\n/gu, " "); +} + +function clampText(value: string, maxChars: number): string { + if (value.length <= maxChars) { + return value; + } + + const marker = "\n[automatic context clipped]\n"; + if (maxChars <= marker.length) { + return value.slice(0, maxChars); + } + + return `${value.slice(0, maxChars - marker.length)}${marker}`; +} + +function roleLabel(role: ConversationContextMessage["role"]): string { + switch (role) { + case "assistant": + return "Assistant"; + case "system": + return "System"; + case "user": + return "User"; + } +} + +function positiveInteger(value: number | undefined, fallback: number): number { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback; +} diff --git a/vscode/extension/src/chatInput.ts b/vscode/extension/src/chatInput.ts index a3030ed..40b8ac7 100644 --- a/vscode/extension/src/chatInput.ts +++ b/vscode/extension/src/chatInput.ts @@ -1,4 +1,4 @@ -import type { RpcRunMode, SendTurnParams } from "@prole-coder/protocol" with { +import type { RpcRunMode, SendTurnParams, TurnAttachment } from "@prole-coder/protocol" with { "resolution-mode": "import", }; @@ -51,10 +51,14 @@ export function parseChatTurnSubmission(value: unknown): ChatTurnSubmissionParse }; } -export function sendTurnParams(submission: ChatTurnSubmission): SendTurnParams { +export function sendTurnParams( + submission: ChatTurnSubmission, + attachments: readonly TurnAttachment[] = [], +): SendTurnParams { return { message: submission.message, mode: submission.mode, + ...(attachments.length === 0 ? {} : { attachments }), }; } diff --git a/vscode/extension/src/chatParticipant.ts b/vscode/extension/src/chatParticipant.ts new file mode 100644 index 0000000..fc7e562 --- /dev/null +++ b/vscode/extension/src/chatParticipant.ts @@ -0,0 +1,116 @@ +import * as vscode from "vscode"; + +import type { TurnAttachment } from "@prole-coder/protocol" with { + "resolution-mode": "import", +}; + +import { + automaticContextAttachmentFromMessages, + mergeTurnAttachments, + type ConversationContextMessage, +} from "./automaticContext"; +import { + CHAT_PARTICIPANT_ID, + runChatParticipantTurn, + type ChatParticipantRpcClient, +} from "./chatParticipantCore"; +import { diagnosticAttachmentsFromProblems } from "./diagnostics"; + +export function registerProleChatParticipant( + context: vscode.ExtensionContext, + rpcClient: ChatParticipantRpcClient | undefined, + workspaceRoot: string | undefined, +): vscode.Disposable { + const participant = vscode.chat.createChatParticipant( + CHAT_PARTICIPANT_ID, + (request, chatContext, response, token) => { + const automaticContext = automaticContextAttachmentFromMessages( + messagesFromChatHistory(chatContext.history), + ); + const diagnostics = collectDiagnosticAttachments(workspaceRoot); + const attachments = mergeTurnAttachments(automaticContext, diagnostics); + return runChatParticipantTurn({ + ...(rpcClient === undefined ? {} : { rpcClient }), + request: { + prompt: request.prompt, + ...(request.command === undefined ? {} : { command: request.command }), + ...(attachments.length === 0 ? {} : { attachments }), + }, + response, + token, + }); + }, + ); + participant.iconPath = vscode.Uri.joinPath(context.extensionUri, "media", "prole-coder-view.svg"); + return participant; +} + +function collectDiagnosticAttachments(workspaceRoot: string | undefined): readonly TurnAttachment[] { + if (workspaceRoot === undefined) { + return []; + } + + const problems = vscode.languages.getDiagnostics().map(([uri, diagnostics]) => ({ + uri: { + fsPath: uri.fsPath, + }, + diagnostics, + })); + + return diagnosticAttachmentsFromProblems(problems, workspaceRoot); +} + +function messagesFromChatHistory( + history: readonly (vscode.ChatRequestTurn | vscode.ChatResponseTurn)[], +): readonly ConversationContextMessage[] { + const messages: ConversationContextMessage[] = []; + for (const turn of history) { + if (isChatRequestTurn(turn)) { + messages.push({ role: "user", text: turn.prompt }); + } else { + const text = markdownTextFromResponse(turn.response); + if (text.length > 0) { + messages.push({ role: "assistant", text }); + } + } + } + return messages; +} + +function isChatRequestTurn( + turn: vscode.ChatRequestTurn | vscode.ChatResponseTurn, +): turn is vscode.ChatRequestTurn { + return "prompt" in turn; +} + +function markdownTextFromResponse( + response: ReadonlyArray< + | vscode.ChatResponseMarkdownPart + | vscode.ChatResponseFileTreePart + | vscode.ChatResponseAnchorPart + | vscode.ChatResponseCommandButtonPart + >, +): string { + return response + .map((part) => { + const value = part.value; + if (value instanceof vscode.MarkdownString) { + return value.value; + } + if (isMarkdownStringLike(value)) { + return value.value; + } + return ""; + }) + .filter((text) => text.length > 0) + .join("\n"); +} + +function isMarkdownStringLike(value: unknown): value is { readonly value: string } { + return ( + typeof value === "object" && + value !== null && + "value" in value && + typeof (value as { readonly value?: unknown }).value === "string" + ); +} diff --git a/vscode/extension/src/chatParticipantCore.ts b/vscode/extension/src/chatParticipantCore.ts new file mode 100644 index 0000000..3b17845 --- /dev/null +++ b/vscode/extension/src/chatParticipantCore.ts @@ -0,0 +1,305 @@ +import type { + CancelResult, + RpcRunMode, + SendTurnParams, + SendTurnResult, + TurnAttachment, +} from "@prole-coder/protocol" with { + "resolution-mode": "import", +}; + +import { + isAutomaticContextAttachment, + type ConversationContextMessage, +} from "./automaticContext"; +import { DEFAULT_CHAT_MODE, sendTurnParams } from "./chatInput"; +import type { AgentEventEnvelope, DisposableLike } from "./rpcServer"; + +export const CHAT_PARTICIPANT_ID = "prole-coder.chatParticipant"; +export const CHAT_PARTICIPANT_NAME = "prole"; + +export interface ChatParticipantRpcClient { + onEvent(handler: (event: AgentEventEnvelope) => void): DisposableLike; + sendTurn(params: SendTurnParams): Promise; + cancel(params: { readonly runId: string; readonly reason?: string }): Promise; +} + +export interface ChatParticipantResponseStream { + markdown(value: string): void; + progress(value: string): void; +} + +export interface CancellationTokenLike { + readonly isCancellationRequested: boolean; + onCancellationRequested(listener: () => unknown): DisposableLike; +} + +export interface ChatParticipantResult { + readonly errorDetails?: { + readonly message: string; + }; + readonly metadata?: { + readonly [key: string]: unknown; + }; +} + +export interface ChatParticipantTurnRequest { + readonly prompt: string; + readonly command?: string; + readonly attachments?: readonly TurnAttachment[]; +} + +export interface ChatParticipantTurnOptions { + readonly rpcClient?: ChatParticipantRpcClient; + readonly request: ChatParticipantTurnRequest; + readonly response: ChatParticipantResponseStream; + readonly token?: CancellationTokenLike; +} + +export function modeFromChatCommand(command: string | undefined): RpcRunMode { + switch (command) { + case "ask": + case "edit": + case "plan": + case "review": + return command; + default: + return DEFAULT_CHAT_MODE; + } +} + +export async function runChatParticipantTurn( + options: ChatParticipantTurnOptions, +): Promise { + const prompt = options.request.prompt.trim(); + if (prompt.length === 0) { + return errorResult("Enter a message before sending."); + } + + if (options.rpcClient === undefined) { + return errorResult("Open a trusted workspace before chatting with ProleCoder."); + } + + if (options.token?.isCancellationRequested === true) { + return canceledResult(); + } + + const params = sendTurnParams( + { + message: prompt, + mode: modeFromChatCommand(options.request.command), + }, + options.request.attachments ?? [], + ); + if (params.attachments?.some(isAutomaticContextAttachment) === true) { + options.response.progress("Conversation context compacted."); + } + + let runId: string | undefined; + let terminalResult: ChatParticipantResult | undefined; + let resolveTerminal: ((result: ChatParticipantResult) => void) | undefined; + const cancellationState = { requested: false }; + const bufferedEvents: AgentEventEnvelope[] = []; + const terminalPromise = new Promise((resolve) => { + resolveTerminal = resolve; + }); + + const eventSubscription = options.rpcClient.onEvent((event) => { + if (runId === undefined) { + bufferedEvents.push(event); + return; + } + if (event.runId !== runId) { + return; + } + + handleParticipantEvent(event, options.response, finish); + }); + const cancellationSubscription = options.token?.onCancellationRequested(() => { + cancellationState.requested = true; + if (runId !== undefined) { + void options.rpcClient?.cancel({ + runId, + reason: "canceled in VS Code Chat", + }); + finish(canceledResult(runId)); + } + }); + + try { + const result = await options.rpcClient.sendTurn(params); + runId = result.runId; + options.response.progress(`Run ${shortId(runId)} started.`); + + for (const event of bufferedEvents) { + if (event.runId === runId) { + handleParticipantEvent(event, options.response, finish); + } + } + + if (terminalResult !== undefined) { + return terminalResult; + } + + if (cancellationState.requested) { + await options.rpcClient.cancel({ + runId, + reason: "canceled in VS Code Chat", + }); + return canceledResult(runId); + } + + return await terminalPromise; + } catch (error) { + return errorResult(`ProleCoder turn failed: ${errorMessage(error)}`, runId); + } finally { + eventSubscription.dispose(); + cancellationSubscription?.dispose(); + } + + function finish(result: ChatParticipantResult): void { + if (terminalResult !== undefined) { + return; + } + terminalResult = result; + resolveTerminal?.(result); + } +} + +export function conversationMessagesFromPlainHistory( + history: readonly { readonly role: ConversationContextMessage["role"]; readonly text: string }[], +): readonly ConversationContextMessage[] { + return history.map((entry) => ({ + role: entry.role, + text: entry.text, + })); +} + +function handleParticipantEvent( + event: AgentEventEnvelope, + response: ChatParticipantResponseStream, + finish: (result: ChatParticipantResult) => void, +): void { + const payload = record(event.payload); + switch (event.type) { + case "assistant.delta": { + const text = textField(payload, "text") ?? textField(payload, "delta"); + if (text !== undefined) { + response.markdown(text); + } + return; + } + case "context.built": + case "tool.approvalRequired": + case "tool.approvalResolved": + case "tool.started": + case "tool.completed": { + const message = eventProgressMessage(event, payload); + if (message !== undefined) { + response.progress(message); + } + return; + } + case "run.completed": + finish({ + metadata: { + runId: event.runId, + status: "completed", + }, + }); + return; + case "run.failed": { + const message = terminalMessage(payload, "Run failed."); + response.markdown(`\n\n${message}`); + finish(errorResult(message, event.runId)); + return; + } + case "run.canceled": { + const message = terminalMessage(payload, "Run canceled."); + response.progress(message); + finish(canceledResult(event.runId)); + return; + } + } +} + +function eventProgressMessage( + event: AgentEventEnvelope, + payload: Record | undefined, +): string | undefined { + switch (event.type) { + case "context.built": + return contextBuiltMessage(payload); + case "tool.approvalRequired": + return `Waiting for approval: ${textField(payload, "title") ?? textField(payload, "toolName") ?? "tool"}.`; + case "tool.approvalResolved": + return `Approval ${textField(payload, "decision") ?? "resolved"}.`; + case "tool.started": + return `Running ${textField(payload, "name") ?? textField(payload, "toolName") ?? "tool"}...`; + case "tool.completed": + return `${textField(payload, "name") ?? textField(payload, "toolName") ?? "Tool"} completed.`; + default: + return undefined; + } +} + +function contextBuiltMessage(payload: Record | undefined): string { + const inputTokens = numberField(payload, "inputTokens"); + const maxInputTokens = numberField(payload, "maxInputTokens"); + if (inputTokens !== undefined && maxInputTokens !== undefined) { + return `Context ready: ${inputTokens.toLocaleString()} / ${maxInputTokens.toLocaleString()} tokens.`; + } + return "Context ready."; +} + +function terminalMessage(payload: Record | undefined, fallback: string): string { + return ( + textField(payload, "message") ?? + textField(payload, "reason") ?? + textField(payload, "summary") ?? + fallback + ); +} + +function errorResult(message: string, runId?: string): ChatParticipantResult { + return { + errorDetails: { message }, + metadata: { + status: "failed", + ...(runId === undefined ? {} : { runId }), + }, + }; +} + +function canceledResult(runId?: string): ChatParticipantResult { + return { + metadata: { + status: "canceled", + ...(runId === undefined ? {} : { runId }), + }, + }; +} + +function shortId(value: string): string { + return value.length <= 12 ? value : `${value.slice(0, 12)}...`; +} + +function textField(payload: Record | undefined, key: string): string | undefined { + const value = payload?.[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function numberField(payload: Record | undefined, key: string): number | undefined { + const value = payload?.[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function record(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/vscode/extension/src/chatView.ts b/vscode/extension/src/chatView.ts index 1de3933..fdb51cd 100644 --- a/vscode/extension/src/chatView.ts +++ b/vscode/extension/src/chatView.ts @@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto"; import * as vscode from "vscode"; import type { + CancelParams, + CancelResult, ListRunsParams, ListRunsResult, ResumeParams, @@ -12,6 +14,10 @@ import type { "resolution-mode": "import", }; +import { + automaticContextAttachmentFromTimeline, + mergeTurnAttachments, +} from "./automaticContext"; import { CHAT_RUN_MODES, DEFAULT_CHAT_MODE, parseChatTurnSubmission, sendTurnParams } from "./chatInput"; import { ChatEventTimeline, type ChatTimelineSnapshot } from "./chatEvents"; import { @@ -19,6 +25,7 @@ import { emptyContextViz, type ContextVizSnapshot, } from "./contextViz"; +import { diagnosticAttachmentsFromProblems } from "./diagnostics"; import type { AgentEventEnvelope, DisposableLike } from "./rpcServer"; import { RUN_LIST_LIMIT, @@ -41,12 +48,16 @@ export interface ChatTurnSender { sendTurn(params: SendTurnParams): Promise; } +export interface ChatCancelClient { + cancel(params: CancelParams): Promise; +} + export interface ChatRunHistoryClient { listRuns(params?: ListRunsParams): Promise; resume(params: ResumeParams): Promise; } -export type ChatRpcClient = ChatRpcEventSource & ChatTurnSender & ChatRunHistoryClient; +export type ChatRpcClient = ChatRpcEventSource & ChatTurnSender & ChatCancelClient & ChatRunHistoryClient; interface SnapshotWebviewMessage { readonly type: "snapshot"; @@ -77,13 +88,14 @@ type ExtensionToWebviewMessage = type ChatSubmissionStatus = "idle" | "sending" | "running" | "completed" | "failed" | "canceled"; type TerminalSubmissionStatus = Extract; -interface ChatSubmissionSnapshot { +export interface ChatSubmissionSnapshot { readonly busy: boolean; readonly status: ChatSubmissionStatus; readonly message: string; readonly runId?: string; readonly turnId?: string; readonly error?: string; + readonly canceling?: boolean; } interface TerminalRunState { @@ -92,6 +104,13 @@ interface TerminalRunState { readonly error?: string; } +export interface ChatViewTestState { + readonly timeline: ChatTimelineSnapshot; + readonly submission: ChatSubmissionSnapshot; + readonly runs: RunListSnapshot; + readonly context: ContextVizSnapshot; +} + export class ProleChatViewProvider implements vscode.WebviewViewProvider, DisposableLike { private readonly timeline = new ChatEventTimeline(); private readonly terminalRuns = new Map(); @@ -106,6 +125,7 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos constructor( private readonly extensionUri: vscode.Uri, rpcClient?: ChatRpcClient, + private readonly workspaceRoot?: string, ) { this.rpcClient = rpcClient; this.rpcSubscription = rpcClient?.onEvent((event) => { @@ -154,6 +174,19 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos return vscode.commands.executeCommand(`${CHAT_VIEW_ID}.focus`); } + testHandleWebviewMessage(message: unknown): Promise { + return this.handleWebviewMessage(message); + } + + testState(): ChatViewTestState { + return { + timeline: this.timeline.snapshot(), + submission: this.submission, + runs: this.runList, + context: this.contextViz, + }; + } + dispose(): void { this.rpcSubscription?.dispose(); this.viewMessageSubscription?.dispose(); @@ -173,6 +206,12 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos return; } + const cancelRunId = cancelRunIdFromMessage(message); + if (cancelRunId !== undefined) { + await this.cancelTurn(cancelRunId); + return; + } + if (!isRecord(message) || message["type"] !== "submitTurn") { return; } @@ -214,7 +253,14 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos this.setContextViz(emptyContextViz()); try { - const result = await this.rpcClient.sendTurn(sendTurnParams(parsed.value)); + const automaticContext = automaticContextAttachmentFromTimeline(this.timeline.snapshot()); + const attachments = mergeTurnAttachments( + automaticContext, + this.collectDiagnosticAttachments(), + ); + const result = await this.rpcClient.sendTurn( + sendTurnParams(parsed.value, attachments), + ); void this.refreshRuns("Refreshing runs..."); const terminal = this.terminalRuns.get(result.runId); this.setSubmission( @@ -332,6 +378,67 @@ export class ProleChatViewProvider implements vscode.WebviewViewProvider, Dispos } } + private async cancelTurn(runId: string): Promise { + if (this.rpcClient === undefined) { + this.setSubmission({ + ...this.submission, + busy: false, + status: "failed", + message: "Open a trusted workspace before canceling a turn.", + error: "No trusted workspace is available.", + }); + return; + } + + if (!this.submission.busy || this.submission.runId !== runId || this.submission.canceling) { + return; + } + + this.setSubmission({ + ...this.submission, + message: "Cancel requested...", + canceling: true, + }); + + try { + const result = await this.rpcClient.cancel({ + runId, + reason: "canceled in VS Code", + }); + void this.refreshRuns("Refreshing runs..."); + this.setSubmission({ + busy: false, + status: "canceled", + message: result.reason ?? "Run canceled.", + runId: result.runId, + ...(this.submission.turnId === undefined ? {} : { turnId: this.submission.turnId }), + }); + } catch (error) { + const messageText = `Failed to cancel turn: ${errorMessage(error)}`; + this.setSubmission({ + ...this.submission, + message: messageText, + error: messageText, + canceling: false, + }); + } + } + + private collectDiagnosticAttachments(): NonNullable { + if (this.workspaceRoot === undefined) { + return []; + } + + const problems = vscode.languages.getDiagnostics().map(([uri, diagnostics]) => ({ + uri: { + fsPath: uri.fsPath, + }, + diagnostics, + })); + + return diagnosticAttachmentsFromProblems(problems, this.workspaceRoot); + } + private updateSubmissionForEvent(event: AgentEventEnvelope): boolean { const terminal = terminalRunState(event); if (terminal === undefined) { @@ -783,6 +890,7 @@ function renderChatViewHtml( .prompt:focus, .mode:focus, .send:focus, + .cancel:focus, .refresh-runs:focus, .run-entry:focus, .context-tab:focus { @@ -806,7 +914,8 @@ function renderChatViewHtml( font: var(--vscode-font-size) var(--vscode-font-family); } - .send { + .send, + .cancel { flex: 0 0 auto; min-width: 64px; height: 28px; @@ -822,7 +931,18 @@ function renderChatViewHtml( background: var(--vscode-button-hoverBackground); } + .cancel { + color: var(--vscode-button-secondaryForeground); + background: var(--vscode-button-secondaryBackground); + font-weight: 500; + } + + .cancel:hover:enabled { + background: var(--vscode-button-secondaryHoverBackground); + } + .send:disabled, + .cancel:disabled, .prompt:disabled, .mode:disabled { opacity: 0.65; @@ -878,6 +998,7 @@ function renderChatViewHtml(
+
@@ -902,6 +1023,7 @@ function renderChatViewHtml( const promptInput = document.getElementById("prompt"); const modeInput = document.getElementById("mode"); const sendButton = document.getElementById("send"); + const cancelButton = document.getElementById("cancel"); const submissionRoot = document.getElementById("submission"); let currentContext = initialContext; let contextSourceTab = "included"; @@ -934,6 +1056,13 @@ function renderChatViewHtml( vscodeApi.postMessage({ type: "refreshRuns" }); }); + cancelButton.addEventListener("click", () => { + const runId = cancelButton.dataset.runId; + if (typeof runId === "string" && runId.length > 0) { + vscodeApi.postMessage({ type: "cancelTurn", runId }); + } + }); + composer.addEventListener("submit", (event) => { event.preventDefault(); const message = promptInput.value.trim(); @@ -947,7 +1076,7 @@ function renderChatViewHtml( return; } - setComposerBusy(true); + setComposerBusy(true, false); submissionRoot.className = "submission sending"; submissionRoot.textContent = "Sending turn..."; vscodeApi.postMessage({ @@ -1289,7 +1418,10 @@ function renderChatViewHtml( const state = submission && typeof submission === "object" ? submission : initialSubmission; const status = typeof state.status === "string" ? state.status : "idle"; const busy = state.busy === true; - setComposerBusy(busy); + const runId = typeof state.runId === "string" ? state.runId : ""; + const cancelable = busy && runId.length > 0 && state.canceling !== true; + setComposerBusy(busy, cancelable); + cancelButton.dataset.runId = runId; submissionRoot.className = "submission " + status; submissionRoot.textContent = typeof state.message === "string" ? state.message : ""; if (status === "running") { @@ -1297,10 +1429,11 @@ function renderChatViewHtml( } } - function setComposerBusy(busy) { + function setComposerBusy(busy, cancelable) { promptInput.disabled = busy; modeInput.disabled = busy; sendButton.disabled = busy; + cancelButton.disabled = cancelable !== true; } function renderItem(item) { @@ -1400,6 +1533,15 @@ function terminalMessage(event: AgentEventEnvelope, fallback: string): string { return typeof message === "string" && message.length > 0 ? message : fallback; } +function cancelRunIdFromMessage(message: unknown): string | undefined { + if (!isRecord(message) || message["type"] !== "cancelTurn") { + return undefined; + } + + const runId = message["runId"]; + return typeof runId === "string" && runId.length > 0 ? runId : undefined; +} + 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 2c6882f..9830916 100644 --- a/vscode/extension/src/commands.ts +++ b/vscode/extension/src/commands.ts @@ -1,9 +1,9 @@ export const OPEN_CHAT_COMMAND = "prole-coder.openChat"; +export const OPEN_SETTINGS_COMMAND = "prole-coder.openSettings"; export const OPEN_CHAT_NO_WORKSPACE_MESSAGE = "Open a trusted workspace before starting the prole-coder RPC server."; export const APPROVAL_APPROVE_LABEL = "Approve"; -export const APPROVAL_APPROVE_ONCE_LABEL = "Approve Once"; -export const APPROVAL_APPROVE_SESSION_LABEL = "Approve For Session"; +export const APPROVAL_APPROVE_SELECTED_HUNKS_LABEL = "Select Hunks"; export const APPROVAL_REJECT_LABEL = "Reject"; export const APPROVAL_DISMISSED_REASON = "approval prompt dismissed"; export const APPROVAL_REJECTED_REASON = "rejected in VS Code"; @@ -27,6 +27,45 @@ export interface RpcServerStarter { }>; } +export interface SettingsRpcServer { + readonly status: string; + readonly launchConfig: { + readonly command: string; + readonly args: readonly string[]; + readonly autoStart: boolean; + }; + start(): Promise<{ + readonly server: { + readonly name: string; + readonly version: string; + }; + readonly capabilities: { + readonly supportsPersistentApprovals: boolean; + readonly provider: { + readonly provider: string; + readonly defaultModel: string; + readonly models: ReadonlyArray<{ + readonly id: string; + readonly displayName?: string; + readonly contextWindowTokens: number; + readonly maxOutputTokens: number; + readonly supportsThinking: boolean; + readonly supportsToolCalls: boolean; + readonly supportsToolChoice: boolean; + readonly supportsFim: boolean; + readonly supportsStreaming: boolean; + readonly reportsCacheUsage: boolean; + }>; + }; + }; + readonly stateDir: string; + }>; +} + +export interface SettingsWindowMessenger extends WindowMessenger { + openSettings?(query: string): unknown; +} + export interface ChatViewOpener { openChatView(): unknown; } @@ -37,6 +76,13 @@ export interface ApprovalWindowMessenger { options: { modal: true }, ...items: string[] ): string | undefined | PromiseLike; + showQuickPick?( + items: readonly ApprovalHunkQuickPickItem[], + options: { canPickMany: true; placeHolder: string }, + ): + | readonly ApprovalHunkQuickPickItem[] + | undefined + | PromiseLike; } export interface DisposableLike { @@ -54,15 +100,39 @@ export interface ApprovalPromptRequest { readonly detail: string; readonly persistable: boolean; readonly command?: string; + readonly cwd?: string; + readonly outputSummary?: string; readonly paths?: readonly string[]; + readonly hunks?: readonly ApprovalPromptHunk[]; readonly riskReasons?: readonly string[]; } +export interface ApprovalPromptHunk { + readonly id: string; + readonly filePath: string; + readonly hunkIndex: number; + readonly oldStart: number; + readonly oldCount: number; + readonly newStart: number; + readonly newCount: number; + readonly section?: string; +} + +export interface ApprovalHunkQuickPickItem { + readonly label: string; + readonly description: string; + readonly detail?: string; + readonly hunkId: string; +} + export type ApprovalPromptDecision = | { readonly kind: "approve"; readonly approvalId: string; readonly persist: ApprovalPersistence; + readonly hunks?: { + readonly approved: readonly string[]; + }; } | { readonly kind: "reject"; @@ -85,11 +155,7 @@ export function registerOpenChatCommand( return rpcServer .start() - .then((ready) => - window.showInformationMessage( - `prole-coder RPC server ready: ${ready.server.name} ${ready.server.version}`, - ), - ) + .then(() => undefined) .catch((error: unknown) => { const message = `prole-coder RPC server failed to start: ${errorMessage(error)}`; if (window.showWarningMessage !== undefined) { @@ -101,20 +167,85 @@ export function registerOpenChatCommand( }); } +export function registerOpenSettingsCommand( + commands: CommandRegistry, + window: SettingsWindowMessenger, + rpcServer?: SettingsRpcServer, +): DisposableLike { + return commands.registerCommand(OPEN_SETTINGS_COMMAND, async () => { + window.openSettings?.("@ext:prole-coder.prole-coder-vscode"); + + if (rpcServer === undefined) { + return window.showInformationMessage(OPEN_CHAT_NO_WORKSPACE_MESSAGE); + } + + try { + const ready = await rpcServer.start(); + return window.showInformationMessage(formatSettingsSummary(rpcServer.launchConfig, ready)); + } catch (error) { + const message = `prole-coder settings opened, but RPC capabilities are unavailable: ${errorMessage(error)}`; + if (window.showWarningMessage !== undefined) { + return window.showWarningMessage(message); + } + return window.showInformationMessage(message); + } + }); +} + +export function formatSettingsSummary( + launch: SettingsRpcServer["launchConfig"], + ready: Awaited>, +): string { + const provider = ready.capabilities.provider; + const defaultModel = + provider.models.find((model) => model.id === provider.defaultModel) ?? provider.models[0]; + const modelLabel = + defaultModel === undefined + ? provider.defaultModel + : `${defaultModel.displayName ?? defaultModel.id} (${defaultModel.id})`; + const featureSummary = + defaultModel === undefined + ? "capability data unavailable" + : [ + defaultModel.supportsThinking ? "thinking" : "no-thinking", + defaultModel.supportsToolCalls ? "tool-calls" : "no-tool-calls", + defaultModel.supportsToolChoice ? "tool-choice" : "no-tool-choice", + defaultModel.supportsFim ? "fim" : "no-fim", + defaultModel.supportsStreaming ? "streaming" : "no-streaming", + defaultModel.reportsCacheUsage ? "cache-usage" : "no-cache-usage", + ].join(", "); + const budgetSummary = + defaultModel === undefined + ? "capability data unavailable" + : `${defaultModel.contextWindowTokens} context tokens, ${defaultModel.maxOutputTokens} max output tokens`; + const approvalSummary = ready.capabilities.supportsPersistentApprovals + ? "one-shot, session, and workspace approvals available" + : "one-shot approvals only"; + + return [ + `Provider: ${provider.provider}`, + `Model: ${modelLabel}`, + `Budget: ${budgetSummary}`, + `Capabilities: ${featureSummary}`, + `Approvals: ${approvalSummary}`, + `RPC: ${launch.command} ${launch.args.join(" ")} (autoStart: ${launch.autoStart})`, + `State: ${ready.stateDir}`, + "API keys are read by the RPC server environment and are not stored in VS Code settings.", + ].join("\n"); +} + export async function requestApproval( window: ApprovalWindowMessenger, request: ApprovalPromptRequest, ): Promise { - const choices = request.persistable - ? [APPROVAL_APPROVE_ONCE_LABEL, APPROVAL_APPROVE_SESSION_LABEL, APPROVAL_REJECT_LABEL] - : [APPROVAL_APPROVE_LABEL, APPROVAL_REJECT_LABEL]; + const choices = approvalChoices(request); const selected = await window.showWarningMessage( formatApprovalMessage(request), { modal: true }, ...choices, ); - if (selected === APPROVAL_APPROVE_LABEL || selected === APPROVAL_APPROVE_ONCE_LABEL) { + if (selected === APPROVAL_APPROVE_LABEL) { return { kind: "approve", approvalId: request.approvalId, @@ -122,11 +253,23 @@ export async function requestApproval( }; } - if (selected === APPROVAL_APPROVE_SESSION_LABEL) { + if (selected === APPROVAL_APPROVE_SELECTED_HUNKS_LABEL) { + const hunks = await requestSelectedHunks(window, request); + if (hunks.length > 0) { + return { + kind: "approve", + approvalId: request.approvalId, + persist: "never", + hunks: { + approved: hunks, + }, + }; + } + return { - kind: "approve", + kind: "reject", approvalId: request.approvalId, - persist: "session", + reason: APPROVAL_DISMISSED_REASON, }; } @@ -145,6 +288,40 @@ export async function requestApproval( }; } +function approvalChoices(request: ApprovalPromptRequest): string[] { + const hunkChoices = + request.toolName === "apply_patch" && request.hunks !== undefined && request.hunks.length > 1 + ? [APPROVAL_APPROVE_SELECTED_HUNKS_LABEL] + : []; + + return [APPROVAL_APPROVE_LABEL, ...hunkChoices, APPROVAL_REJECT_LABEL]; +} + +async function requestSelectedHunks( + window: ApprovalWindowMessenger, + request: ApprovalPromptRequest, +): Promise { + const hunks = request.hunks ?? []; + if (window.showQuickPick === undefined || hunks.length === 0) { + return []; + } + + const selected = await window.showQuickPick( + hunks.map((hunk) => ({ + label: `${hunk.filePath} hunk ${hunk.hunkIndex + 1}`, + description: `-${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount}`, + ...(hunk.section === undefined ? {} : { detail: hunk.section }), + hunkId: hunk.id, + })), + { + canPickMany: true, + placeHolder: "Select patch hunks to approve", + }, + ); + + return selected?.map((item) => item.hunkId) ?? []; +} + function formatApprovalMessage(request: ApprovalPromptRequest): string { const detail = [ request.title, @@ -161,6 +338,14 @@ function formatApprovalMessage(request: ApprovalPromptRequest): string { detail.push(`Command: ${request.command}`); } + if (request.cwd !== undefined) { + 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/diagnostics.ts b/vscode/extension/src/diagnostics.ts new file mode 100644 index 0000000..cf0559b --- /dev/null +++ b/vscode/extension/src/diagnostics.ts @@ -0,0 +1,170 @@ +import * as path from "node:path"; + +import type { TextRange, TurnAttachment } from "@prole-coder/protocol" with { + "resolution-mode": "import", +}; + +const MAX_DIAGNOSTIC_ATTACHMENTS = 32; + +export interface ProblemDiagnosticSnapshot { + readonly uri: { + readonly fsPath: string; + }; + readonly diagnostics: readonly ProblemDiagnostic[]; +} + +export interface ProblemDiagnostic { + readonly severity: number; + readonly message: string; + readonly source?: string; + readonly code?: string | number | { readonly value?: string | number }; + readonly range: { + readonly start: { + readonly line: number; + readonly character: number; + }; + readonly end: { + readonly line: number; + readonly character: number; + }; + }; +} + +export function diagnosticAttachmentsFromProblems( + problems: readonly ProblemDiagnosticSnapshot[], + workspaceRoot: string, +): TurnAttachment[] { + const candidates: DiagnosticAttachmentCandidate[] = []; + let sequence = 0; + + for (const entry of problems) { + const relativePath = workspaceRelativePath(workspaceRoot, entry.uri.fsPath); + if (relativePath === undefined) { + continue; + } + + for (const diagnostic of entry.diagnostics) { + candidates.push({ + relativePath, + diagnostic, + sequence, + }); + sequence += 1; + } + } + + candidates.sort(compareDiagnosticCandidates); + + return candidates.slice(0, MAX_DIAGNOSTIC_ATTACHMENTS).map((candidate) => ({ + kind: "diagnostic", + path: candidate.relativePath, + range: protocolRangeFromDiagnostic(candidate.diagnostic), + text: diagnosticText(candidate.diagnostic), + })); +} + +interface DiagnosticAttachmentCandidate { + readonly relativePath: string; + readonly diagnostic: ProblemDiagnostic; + readonly sequence: number; +} + +function compareDiagnosticCandidates( + left: DiagnosticAttachmentCandidate, + right: DiagnosticAttachmentCandidate, +): number { + return ( + severityRank(left.diagnostic.severity) - severityRank(right.diagnostic.severity) || + left.relativePath.localeCompare(right.relativePath) || + compareRanges(left.diagnostic.range, right.diagnostic.range) || + left.sequence - right.sequence + ); +} + +function compareRanges( + left: ProblemDiagnostic["range"], + right: ProblemDiagnostic["range"], +): number { + return ( + left.start.line - right.start.line || + left.start.character - right.start.character || + left.end.line - right.end.line || + left.end.character - right.end.character + ); +} + +function severityRank(severity: number): number { + switch (severity) { + case 0: + return 0; + case 1: + return 1; + case 2: + return 2; + case 3: + return 3; + default: + return 4; + } +} + +function workspaceRelativePath(workspaceRoot: string, filePath: string): string | undefined { + const relative = path.relative(workspaceRoot, filePath); + if (relative.length === 0 || relative.startsWith("..") || path.isAbsolute(relative)) { + return undefined; + } + + return relative.replaceAll(path.sep, "/"); +} + +function protocolRangeFromDiagnostic(diagnostic: ProblemDiagnostic): TextRange { + return { + startLine: diagnostic.range.start.line + 1, + startColumn: diagnostic.range.start.character + 1, + endLine: diagnostic.range.end.line + 1, + endColumn: diagnostic.range.end.character + 1, + }; +} + +function diagnosticText(diagnostic: ProblemDiagnostic): string { + const lines = [ + `Severity: ${severityLabel(diagnostic.severity)}`, + `Message: ${diagnostic.message}`, + ]; + if (diagnostic.source !== undefined && diagnostic.source.length > 0) { + lines.push(`Source: ${diagnostic.source}`); + } + const code = diagnosticCode(diagnostic.code); + if (code !== undefined) { + lines.push(`Code: ${code}`); + } + + return lines.join("\n"); +} + +function severityLabel(severity: number): string { + switch (severity) { + case 0: + return "error"; + case 1: + return "warning"; + case 2: + return "information"; + case 3: + return "hint"; + default: + return "unknown"; + } +} + +function diagnosticCode( + code: string | number | { readonly value?: string | number } | undefined, +): string | undefined { + if (typeof code === "string" || typeof code === "number") { + return String(code); + } + if (code !== undefined && (typeof code.value === "string" || typeof code.value === "number")) { + return String(code.value); + } + return undefined; +} diff --git a/vscode/extension/src/extension.ts b/vscode/extension/src/extension.ts index b7569f9..03696a1 100644 --- a/vscode/extension/src/extension.ts +++ b/vscode/extension/src/extension.ts @@ -1,23 +1,51 @@ import * as vscode from "vscode"; -import { ApprovalEventController } from "./approvalFlow"; +import { ApprovalEventController, type ApprovalRequester } from "./approvalFlow"; +import { registerProleChatParticipant } from "./chatParticipant"; import { CHAT_VIEW_ID, ProleChatViewProvider } from "./chatView"; -import { registerOpenChatCommand } from "./commands"; +import { + type ChatViewOpener, + registerOpenChatCommand, + registerOpenSettingsCommand, +} from "./commands"; import { createPatchDiffPreviewController } from "./diffPreview"; +import { registerFimInlineCompletionProvider } from "./fimPreviewVscode"; import { RpcServerManager, readRpcServerLaunchConfig } from "./rpcServer"; export function activate(context: vscode.ExtensionContext): void { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; const rpcServer = createRpcServerManager(context); - const chatView = new ProleChatViewProvider(context.extensionUri, rpcServer); - const openChat = registerOpenChatCommand(vscode.commands, vscode.window, rpcServer, chatView); + const chatView = new ProleChatViewProvider(context.extensionUri, rpcServer, workspaceRoot); + const chatParticipant = registerProleChatParticipant(context, rpcServer, workspaceRoot); + const openChat = registerOpenChatCommand( + vscode.commands, + vscode.window, + rpcServer, + nativeChatOpener(chatView), + ); + const openSettings = registerOpenSettingsCommand( + vscode.commands, + { + showInformationMessage(message) { + return vscode.window.showInformationMessage(message); + }, + showWarningMessage(message) { + return vscode.window.showWarningMessage(message); + }, + openSettings(query) { + return vscode.commands.executeCommand("workbench.action.openSettings", query); + }, + }, + rpcServer, + ); const chatViewRegistration = vscode.window.registerWebviewViewProvider(CHAT_VIEW_ID, chatView, { webviewOptions: { retainContextWhenHidden: true, }, }); - context.subscriptions.push(openChat, chatView, chatViewRegistration); + context.subscriptions.push(openChat, openSettings, chatView, chatViewRegistration, chatParticipant); + registerTestCommands(context, chatView); if (rpcServer !== undefined && workspaceRoot !== undefined) { const patchDiffPreviewController = createPatchDiffPreviewController(context, rpcServer, workspaceRoot); const approvalController = new ApprovalEventController( @@ -28,10 +56,11 @@ export function activate(context: vscode.ExtensionContext): void { return vscode.window.showWarningMessage(message); }, }, - undefined, + testApprovalRequester(context), patchDiffPreviewController, ); context.subscriptions.push(patchDiffPreviewController, approvalController); + context.subscriptions.push(registerFimInlineCompletionProvider(rpcServer)); context.subscriptions.push(rpcServer); if (rpcServer.autoStart) { void rpcServer.start().catch((error: unknown) => { @@ -79,3 +108,46 @@ function extensionVersion(context: vscode.ExtensionContext): string { function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } + +function nativeChatOpener(fallback: ChatViewOpener): ChatViewOpener { + return { + async openChatView() { + try { + return await vscode.commands.executeCommand("workbench.action.chat.open", { + query: "@prole ", + isPartialQuery: true, + }); + } catch { + return fallback.openChatView(); + } + }, + }; +} + +function registerTestCommands(context: vscode.ExtensionContext, chatView: ProleChatViewProvider): void { + if (context.extensionMode !== vscode.ExtensionMode.Test || process.env["PROLE_CODER_VSCODE_TEST"] !== "1") { + return; + } + + context.subscriptions.push( + vscode.commands.registerCommand("prole-coder.test.chatMessage", (message: unknown) => + chatView.testHandleWebviewMessage(message), + ), + vscode.commands.registerCommand("prole-coder.test.chatState", () => chatView.testState()), + ); +} + +function testApprovalRequester(context: vscode.ExtensionContext): ApprovalRequester | undefined { + if ( + context.extensionMode !== vscode.ExtensionMode.Test || + process.env["PROLE_CODER_VSCODE_TEST_AUTO_APPROVE"] !== "1" + ) { + return undefined; + } + + return async (_window, request) => ({ + kind: "approve", + approvalId: request.approvalId, + persist: "never", + }); +} diff --git a/vscode/extension/src/fimPreview.ts b/vscode/extension/src/fimPreview.ts new file mode 100644 index 0000000..e56b965 --- /dev/null +++ b/vscode/extension/src/fimPreview.ts @@ -0,0 +1,58 @@ +import type { FimPreviewParams, ServerCapabilities } from "@prole-coder/protocol" with { + "resolution-mode": "import", +}; + +export interface FimPreviewRequestInput { + readonly text: string; + readonly offset: number; + readonly path?: string; + readonly languageId?: string; + readonly configuredModel?: string; + readonly maxTokens?: number; + readonly maxContextChars: number; + readonly capabilities: ServerCapabilities; +} + +export function buildFimPreviewParams(input: FimPreviewRequestInput): FimPreviewParams | undefined { + if (input.offset < 0 || input.offset > input.text.length || input.maxContextChars < 1) { + return undefined; + } + + const model = selectFimModel(input.capabilities, input.configuredModel); + if (model === undefined) { + return undefined; + } + + const prefixStart = Math.max(0, input.offset - input.maxContextChars); + const suffixEnd = Math.min(input.text.length, input.offset + input.maxContextChars); + const prefix = input.text.slice(prefixStart, input.offset); + if (prefix.trim().length === 0) { + return undefined; + } + + const suffix = input.text.slice(input.offset, suffixEnd); + return { + prefix, + ...(suffix.length === 0 ? {} : { suffix }), + ...(input.path === undefined ? {} : { path: input.path }), + ...(input.languageId === undefined ? {} : { languageId: input.languageId }), + model, + ...(input.maxTokens === undefined ? {} : { maxTokens: input.maxTokens }), + }; +} + +export function selectFimModel( + capabilities: ServerCapabilities, + configuredModel?: string, +): string | undefined { + if (configuredModel !== undefined && configuredModel.trim().length > 0) { + const configured = capabilities.provider.models.find((model) => model.id === configuredModel.trim()); + return configured?.supportsFim === true ? configured.id : undefined; + } + + const defaultModel = capabilities.provider.models.find( + (model) => model.id === capabilities.provider.defaultModel, + ); + return defaultModel?.supportsFim === true ? defaultModel.id : undefined; +} + diff --git a/vscode/extension/src/fimPreviewVscode.ts b/vscode/extension/src/fimPreviewVscode.ts new file mode 100644 index 0000000..8541d45 --- /dev/null +++ b/vscode/extension/src/fimPreviewVscode.ts @@ -0,0 +1,59 @@ +import * as vscode from "vscode"; + +import { buildFimPreviewParams } from "./fimPreview"; +import type { RpcServerManager } from "./rpcServer"; + +const DEFAULT_FIM_MAX_TOKENS = 128; +const DEFAULT_FIM_MAX_CONTEXT_CHARS = 12_000; + +export function registerFimInlineCompletionProvider(rpcServer: RpcServerManager): vscode.Disposable { + return vscode.languages.registerInlineCompletionItemProvider( + { pattern: "**" }, + new ProleFimInlineCompletionProvider(rpcServer), + ); +} + +class ProleFimInlineCompletionProvider implements vscode.InlineCompletionItemProvider { + constructor(private readonly rpcServer: RpcServerManager) {} + + async provideInlineCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + _context: vscode.InlineCompletionContext, + token: vscode.CancellationToken, + ): Promise { + const config = vscode.workspace.getConfiguration("prole-coder.fim"); + if (!config.get("enabled", true) || this.rpcServer.status !== "ready") { + return []; + } + + const ready = await this.rpcServer.start(); + if (token.isCancellationRequested) { + return []; + } + + const params = buildFimPreviewParams({ + text: document.getText(), + offset: document.offsetAt(position), + languageId: document.languageId, + configuredModel: config.get("model", ""), + maxTokens: config.get("maxTokens", DEFAULT_FIM_MAX_TOKENS), + maxContextChars: config.get("maxContextChars", DEFAULT_FIM_MAX_CONTEXT_CHARS), + capabilities: ready.capabilities, + ...(document.uri.scheme === "file" ? { path: document.uri.fsPath } : {}), + }); + if (params === undefined) { + return []; + } + + const result = await this.rpcServer.previewFim(params).catch(() => undefined); + if (result === undefined) { + return []; + } + if (token.isCancellationRequested || result.text.length === 0) { + return []; + } + + return [new vscode.InlineCompletionItem(result.text)]; + } +} diff --git a/vscode/extension/src/rpcServer.ts b/vscode/extension/src/rpcServer.ts index 0e7feda..1a6a86c 100644 --- a/vscode/extension/src/rpcServer.ts +++ b/vscode/extension/src/rpcServer.ts @@ -4,6 +4,10 @@ import type { AgentEventEnvelope as ProtocolAgentEventEnvelope, ApproveParams, ApproveResult, + CancelParams, + CancelResult, + FimPreviewParams, + FimPreviewResult, ListRunsParams, ListRunsResult, RejectParams, @@ -12,6 +16,7 @@ import type { ResumeResult, SendTurnParams, SendTurnResult, + ServerCapabilities, } from "@prole-coder/protocol" with { "resolution-mode": "import", }; @@ -19,13 +24,17 @@ import type { export const RPC_PROTOCOL_VERSION = "0.1.0"; export const RPC_INITIALIZE_METHOD = "agent.initialize"; 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_LIST_RUNS_METHOD = "agent.listRuns"; export const RPC_APPROVE_METHOD = "agent.approve"; export const RPC_REJECT_METHOD = "agent.reject"; +export const RPC_CANCEL_METHOD = "agent.cancel"; +export const RPC_PREVIEW_FIM_METHOD = "agent.previewFim"; export const DEFAULT_RPC_COMMAND = "prole"; export const DEFAULT_RPC_ARGS = ["rpc"] as const; +const RPC_UNSUPPORTED_PROTOCOL_CODE = -32001; export type RpcServerStatus = "stopped" | "starting" | "ready" | "failed"; @@ -46,6 +55,7 @@ export interface RpcServerReadyState { readonly name: string; readonly version: string; }; + readonly capabilities: ServerCapabilities; readonly stateDir: string; } @@ -206,6 +216,10 @@ export class RpcServerManager implements DisposableLike { return this.launch.autoStart; } + get launchConfig(): RpcServerLaunchConfig { + return this.launch; + } + start(): Promise { if (this.readyState !== undefined && this.currentStatus === "ready") { return Promise.resolve(this.readyState); @@ -326,6 +340,14 @@ export class RpcServerManager implements DisposableLike { return this.sendRequest(RPC_REJECT_METHOD, params); } + cancel(params: CancelParams): Promise { + return this.sendRequest(RPC_CANCEL_METHOD, params); + } + + previewFim(params: FimPreviewParams): Promise { + return this.sendRequest(RPC_PREVIEW_FIM_METHOD, params); + } + stop(): void { this.intentionalStop = true; this.readyState = undefined; @@ -402,8 +424,15 @@ export class RpcServerManager implements DisposableLike { if (isJsonRpcNotification(message) && message.method === RPC_EVENT_METHOD) { if (isAgentEventEnvelope(message.params)) { - for (const handler of this.eventHandlers) { - handler(message.params); + this.dispatchAgentEvent(message.params); + } + return; + } + + if (isJsonRpcNotification(message) && message.method === RPC_EVENT_BATCH_METHOD) { + if (isAgentEventBatchParams(message.params)) { + for (const event of message.params.events) { + this.dispatchAgentEvent(event); } } } @@ -411,6 +440,13 @@ export class RpcServerManager implements DisposableLike { private handleInitializeResponse(message: JsonRpcResponse): void { if (message.error !== undefined) { + const protocolMismatch = formatProtocolMismatch(message.error); + if (protocolMismatch !== undefined) { + this.notifier?.warn(protocolMismatch); + this.failStarting(new Error(protocolMismatch)); + return; + } + this.failStarting( new Error(`RPC initialize failed: ${message.error.message} (${message.error.code})`), ); @@ -431,6 +467,12 @@ export class RpcServerManager implements DisposableLike { resolve?.(message.result); } + private dispatchAgentEvent(event: AgentEventEnvelope): void { + for (const handler of this.eventHandlers) { + handler(event); + } + } + private handleRequestResponse(message: JsonRpcResponse): void { const id = String(message.id); const pending = this.pendingRequests.get(id); @@ -545,10 +587,51 @@ function isRpcServerReadyState(value: unknown): value is RpcServerReadyState { isRecord(value["server"]) && typeof value["server"]["name"] === "string" && typeof value["server"]["version"] === "string" && + isServerCapabilities(value["capabilities"]) && typeof value["stateDir"] === "string" ); } +function isServerCapabilities(value: unknown): value is ServerCapabilities { + return ( + isRecord(value) && + typeof value["protocolVersion"] === "string" && + typeof value["supportsRunResume"] === "boolean" && + typeof value["supportsPatchApproval"] === "boolean" && + typeof value["supportsPersistentApprovals"] === "boolean" && + typeof value["supportsEventBatching"] === "boolean" && + Array.isArray(value["supportedRiskLevels"]) && + value["supportedRiskLevels"].every((risk) => typeof risk === "string") && + isProviderCapabilities(value["provider"]) + ); +} + +function isProviderCapabilities(value: unknown): boolean { + return ( + isRecord(value) && + typeof value["provider"] === "string" && + typeof value["defaultModel"] === "string" && + Array.isArray(value["models"]) && + value["models"].every(isProviderModelCapabilities) + ); +} + +function isProviderModelCapabilities(value: unknown): boolean { + return ( + isRecord(value) && + typeof value["id"] === "string" && + (value["displayName"] === undefined || typeof value["displayName"] === "string") && + typeof value["contextWindowTokens"] === "number" && + typeof value["maxOutputTokens"] === "number" && + typeof value["supportsThinking"] === "boolean" && + typeof value["supportsToolCalls"] === "boolean" && + typeof value["supportsToolChoice"] === "boolean" && + typeof value["supportsFim"] === "boolean" && + typeof value["supportsStreaming"] === "boolean" && + typeof value["reportsCacheUsage"] === "boolean" + ); +} + function isAgentEventEnvelope(value: unknown): value is AgentEventEnvelope { return ( isRecord(value) && @@ -560,10 +643,50 @@ function isAgentEventEnvelope(value: unknown): value is AgentEventEnvelope { ); } +function isAgentEventBatchParams(value: unknown): value is { + readonly events: readonly AgentEventEnvelope[]; + readonly firstSeq: number; + readonly lastSeq: number; + readonly count: number; +} { + return ( + isRecord(value) && + Array.isArray(value["events"]) && + value["events"].every(isAgentEventEnvelope) && + typeof value["firstSeq"] === "number" && + typeof value["lastSeq"] === "number" && + typeof value["count"] === "number" && + value["events"].length === value["count"] + ); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function formatProtocolMismatch(error: JsonRpcErrorObject): string | undefined { + if (error.code !== RPC_UNSUPPORTED_PROTOCOL_CODE) { + return undefined; + } + + const data = isRecord(error.data) ? error.data : {}; + const clientProtocol = + stringField(data, "clientProtocolVersion") ?? + stringField(data, "actualProtocolVersion") ?? + RPC_PROTOCOL_VERSION; + const serverProtocol = + stringField(data, "serverProtocolVersion") ?? + stringField(data, "expectedProtocolVersion") ?? + "unknown"; + + return `RPC protocol mismatch: VS Code extension requested ${clientProtocol}, but the ProleCoder RPC server supports ${serverProtocol}. Update the CLI/server and extension so their protocol versions match.`; +} + +function stringField(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" ? value : undefined; +} + function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } diff --git a/vscode/extension/test/approvalFlow.test.ts b/vscode/extension/test/approvalFlow.test.ts index 40ca6f7..980caae 100644 --- a/vscode/extension/test/approvalFlow.test.ts +++ b/vscode/extension/test/approvalFlow.test.ts @@ -34,6 +34,34 @@ test("approval controller sends approve decisions to the RPC pending queue", asy assert.deepEqual(rpc.rejections, []); }); +test("approval controller sends hunk approvals to the RPC pending queue", async () => { + const rpc = new FakeApprovalRpcClient(); + const controller = new ApprovalEventController(rpc, fakeWindow, fakeNotifier(), async (_window, request) => { + assert.equal(request.hunks?.length, 2); + return { + kind: "approve", + approvalId: request.approvalId, + persist: "never", + hunks: { + approved: ["README.md#2:old5+2:new5+3"], + }, + }; + }); + + rpc.emit(approvalEvent({ toolName: "apply_patch", hunks: true })); + await controller.whenIdle(); + + assert.deepEqual(rpc.approvals, [ + { + approvalId: "approval_1", + persist: "never", + hunks: { + approved: ["README.md#2:old5+2:new5+3"], + }, + }, + ]); +}); + test("approval controller sends reject decisions to the RPC pending queue", async () => { const rpc = new FakeApprovalRpcClient(); const controller = new ApprovalEventController(rpc, fakeWindow, fakeNotifier(), async (_window, request) => ({ @@ -205,7 +233,7 @@ test("approval controller reports malformed approval events without prompting", }); test("approvalPromptRequestFromEvent maps protocol payloads to modal requests", () => { - const request = approvalPromptRequestFromEvent(approvalEvent()); + const request = approvalPromptRequestFromEvent(approvalEvent({ hunks: true })); assert.deepEqual(request, { approvalId: "approval_1", @@ -216,7 +244,30 @@ test("approvalPromptRequestFromEvent maps protocol payloads to modal requests", detail: "Run verification", persistable: true, command: "cargo test", + cwd: ".", + outputSummary: "last output summary", paths: ["crates/cli/src/lib.rs"], + hunks: [ + { + id: "README.md#1:old1+3:new1+3", + filePath: "README.md", + hunkIndex: 0, + oldStart: 1, + oldCount: 3, + newStart: 1, + newCount: 3, + }, + { + id: "README.md#2:old5+2:new5+3", + filePath: "README.md", + hunkIndex: 1, + oldStart: 5, + oldCount: 2, + newStart: 5, + newCount: 3, + section: "next block", + }, + ], riskReasons: ["dependency install/update"], }); }); @@ -233,7 +284,9 @@ function fakeNotifier(): { warn(message: string): unknown } { }; } -function approvalEvent(options: { readonly runId?: string; readonly toolName?: string } = {}): AgentEventEnvelope { +function approvalEvent( + options: { readonly runId?: string; readonly toolName?: string; readonly hunks?: boolean } = {}, +): AgentEventEnvelope { return { seq: 1, time: "1970-01-01T00:00:00.000Z", @@ -248,7 +301,36 @@ function approvalEvent(options: { readonly runId?: string; readonly toolName?: s title: "Execute shell command", detail: "Run verification", command: "cargo test", + cwd: ".", + outputSummary: "last output summary", paths: ["crates/cli/src/lib.rs"], + ...(options.hunks === true + ? { + hunks: [ + { + id: "README.md#1:old1+3:new1+3", + filePath: "README.md", + fileIndex: 0, + hunkIndex: 0, + oldStart: 1, + oldCount: 3, + newStart: 1, + newCount: 3, + }, + { + id: "README.md#2:old5+2:new5+3", + filePath: "README.md", + fileIndex: 0, + hunkIndex: 1, + oldStart: 5, + oldCount: 2, + newStart: 5, + newCount: 3, + section: "next block", + }, + ], + } + : {}), riskReasons: ["dependency install/update"], persistable: true, }, @@ -256,7 +338,11 @@ function approvalEvent(options: { readonly runId?: string; readonly toolName?: s } class FakeApprovalRpcClient implements ApprovalRpcClient { - readonly approvals: Array<{ readonly approvalId: string; readonly persist?: string }> = []; + readonly approvals: Array<{ + readonly approvalId: string; + readonly persist?: string; + readonly hunks?: { readonly approved: readonly string[] }; + }> = []; readonly rejections: Array<{ readonly approvalId: string; readonly reason?: string }> = []; private readonly handlers = new Set<(event: AgentEventEnvelope) => void>(); @@ -275,16 +361,22 @@ class FakeApprovalRpcClient implements ApprovalRpcClient { } } - async approve(params: { readonly approvalId: string; readonly persist?: string }): Promise<{ + async approve(params: { + readonly approvalId: string; + readonly persist?: string; + readonly hunks?: { readonly approved: readonly string[] }; + }): Promise<{ readonly approvalId: string; readonly state: "approved"; readonly persist: "never" | "session" | "workspace"; + readonly hunks?: { readonly approved: readonly string[] }; }> { this.approvals.push(params); return { approvalId: params.approvalId, state: "approved", persist: params.persist === "session" || params.persist === "workspace" ? params.persist : "never", + ...(params.hunks === undefined ? {} : { hunks: params.hunks }), }; } diff --git a/vscode/extension/test/automaticContext.test.ts b/vscode/extension/test/automaticContext.test.ts new file mode 100644 index 0000000..382558a --- /dev/null +++ b/vscode/extension/test/automaticContext.test.ts @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + automaticContextAttachmentFromMessages, + automaticContextAttachmentFromTimeline, + isAutomaticContextAttachment, + mergeTurnAttachments, +} from "../src/automaticContext.js"; + +test("automatic context attachment compacts older messages and preserves recent entries", () => { + const attachment = automaticContextAttachmentFromMessages( + [ + { role: "user", text: "first user request\nwith detail" }, + { role: "assistant", text: "first assistant response" }, + { role: "user", text: "latest request" }, + { role: "assistant", text: "latest response" }, + ], + { recentMessages: 2, maxChars: 2000 }, + ); + + assert.ok(attachment); + assert.equal(attachment.kind, "explicit_content"); + assert.ok(isAutomaticContextAttachment(attachment)); + assert.ok(attachment.text?.includes("Compacted entries: 2")); + assert.ok(attachment.text?.includes("User:\nlatest request")); + assert.ok(attachment.text?.includes("Assistant:\nlatest response")); +}); + +test("automatic context attachment respects its character budget", () => { + const attachment = automaticContextAttachmentFromMessages( + [ + { role: "user", text: "x".repeat(200) }, + { role: "assistant", text: "y".repeat(200) }, + ], + { maxChars: 120, recentMessages: 2 }, + ); + + assert.ok(attachment?.text); + assert.equal(attachment.text.length <= 120, true); + assert.ok(attachment.text.includes("automatic context clipped")); +}); + +test("automatic context attachment returns undefined for empty history", () => { + assert.equal(automaticContextAttachmentFromMessages([]), undefined); + assert.equal( + automaticContextAttachmentFromMessages([{ role: "user", text: " " }]), + undefined, + ); +}); + +test("automatic context can be built from sidebar timeline snapshots", () => { + const attachment = automaticContextAttachmentFromTimeline({ + eventCount: 3, + latestRunId: "run_1", + items: [ + { + id: "1", + seq: 1, + lastSeq: 1, + time: "1970-01-01T00:00:00.000Z", + type: "turn.started", + runId: "run_1", + kind: "turn", + tone: "running", + title: "Turn started", + body: "Fix README", + }, + { + id: "2", + seq: 2, + lastSeq: 2, + time: "1970-01-01T00:00:01.000Z", + type: "assistant.delta", + runId: "run_1", + kind: "assistant", + tone: "neutral", + title: "Assistant", + body: "README updated.", + }, + ], + }); + + assert.ok(attachment?.text?.includes("Fix README")); + assert.ok(attachment?.text?.includes("README updated.")); +}); + +test("automatic context caps oversized sidebar timeline messages before compression", () => { + const attachment = automaticContextAttachmentFromTimeline( + { + eventCount: 1, + latestRunId: "run_1", + items: [ + { + id: "1", + seq: 1, + lastSeq: 1, + time: "1970-01-01T00:00:00.000Z", + type: "assistant.delta", + runId: "run_1", + kind: "assistant", + tone: "neutral", + title: "Assistant", + body: "a".repeat(12_000), + }, + ], + }, + { maxChars: 20_000, recentMessages: 1 }, + ); + + assert.ok(attachment?.text); + assert.equal(attachment.text.length < 9_000, true); + assert.ok(attachment.text.includes("automatic context clipped")); +}); + +test("mergeTurnAttachments reserves one slot for automatic context", () => { + const automaticContext = automaticContextAttachmentFromMessages([ + { role: "user", text: "previous" }, + ]); + const attachments = Array.from({ length: 32 }, (_, index) => ({ + kind: "explicit_content" as const, + text: `manual ${index}`, + })); + + const merged = mergeTurnAttachments(automaticContext, attachments); + + assert.equal(merged.length, 32); + assert.equal(merged[0], automaticContext); + assert.equal(merged[31]?.text, "manual 30"); +}); diff --git a/vscode/extension/test/chatInput.test.ts b/vscode/extension/test/chatInput.test.ts index dd4476b..c51a0b1 100644 --- a/vscode/extension/test/chatInput.test.ts +++ b/vscode/extension/test/chatInput.test.ts @@ -39,6 +39,35 @@ test("chat input rejects empty messages and invalid modes", () => { }); }); +test("chat input forwards diagnostic attachments into sendTurn params", () => { + assert.deepEqual( + sendTurnParams( + { + message: "fix diagnostics", + mode: "edit", + }, + [ + { + kind: "diagnostic", + path: "src/lib.rs", + text: "Severity: error\nMessage: unused import", + }, + ], + ), + { + message: "fix diagnostics", + mode: "edit", + attachments: [ + { + kind: "diagnostic", + path: "src/lib.rs", + text: "Severity: error\nMessage: unused import", + }, + ], + }, + ); +}); + test("chat input exposes protocol run modes and default mode", () => { assert.equal(DEFAULT_CHAT_MODE, "edit"); assert.deepEqual([...CHAT_RUN_MODES], ["edit", "ask", "plan", "review"]); diff --git a/vscode/extension/test/chatParticipantCore.test.ts b/vscode/extension/test/chatParticipantCore.test.ts new file mode 100644 index 0000000..9d78d9b --- /dev/null +++ b/vscode/extension/test/chatParticipantCore.test.ts @@ -0,0 +1,219 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { SendTurnParams } from "@prole-coder/protocol" with { + "resolution-mode": "import", +}; + +import { automaticContextAttachmentFromMessages } from "../src/automaticContext.js"; +import { + modeFromChatCommand, + runChatParticipantTurn, + type ChatParticipantRpcClient, + type ChatParticipantResponseStream, +} from "../src/chatParticipantCore.js"; +import type { AgentEventEnvelope, DisposableLike } from "../src/rpcServer.js"; + +test("modeFromChatCommand maps known commands and defaults to edit", () => { + assert.equal(modeFromChatCommand("ask"), "ask"); + assert.equal(modeFromChatCommand("plan"), "plan"); + assert.equal(modeFromChatCommand("review"), "review"); + assert.equal(modeFromChatCommand("missing"), "edit"); + assert.equal(modeFromChatCommand(undefined), "edit"); +}); + +test("runChatParticipantTurn sends a real turn and streams matching run events", async () => { + const rpc = new FakeChatParticipantRpcClient(); + const response = new FakeChatResponseStream(); + const result = await runChatParticipantTurn({ + rpcClient: rpc, + request: { + prompt: "please edit", + command: "review", + attachments: [ + { + kind: "diagnostic", + path: "src/lib.rs", + range: { + startLine: 1, + startColumn: 1, + endLine: 1, + endColumn: 5, + }, + text: "error", + }, + ], + }, + response, + }); + + assert.deepEqual(rpc.sendTurns[0], { + message: "please edit", + mode: "review", + attachments: [ + { + kind: "diagnostic", + path: "src/lib.rs", + range: { + startLine: 1, + startColumn: 1, + endLine: 1, + endColumn: 5, + }, + text: "error", + }, + ], + } satisfies SendTurnParams); + assert.equal(response.markdownParts.join(""), "hello"); + assert.ok(response.progressParts.some((part) => part.includes("Context ready"))); + assert.deepEqual(result.metadata, { + runId: "run_chat_1", + status: "completed", + }); +}); + +test("runChatParticipantTurn reports automatic context compaction", async () => { + const rpc = new FakeChatParticipantRpcClient(); + const response = new FakeChatResponseStream(); + const automaticContext = automaticContextAttachmentFromMessages([ + { role: "user", text: "previous request" }, + ]); + assert.ok(automaticContext); + + await runChatParticipantTurn({ + rpcClient: rpc, + request: { + prompt: "continue", + attachments: [automaticContext], + }, + response, + }); + + assert.ok(response.progressParts.some((part) => part.includes("compacted"))); + assert.equal(rpc.sendTurns[0]?.attachments?.[0], automaticContext); +}); + +test("runChatParticipantTurn handles terminal events buffered before sendTurn returns", async () => { + const rpc = new EarlyTerminalChatParticipantRpcClient(); + const response = new FakeChatResponseStream(); + const result = await withTimeout( + runChatParticipantTurn({ + rpcClient: rpc, + request: { + prompt: "finish quickly", + }, + response, + }), + ); + + assert.equal(response.markdownParts.join(""), "early"); + assert.deepEqual(result.metadata, { + runId: "run_chat_1", + status: "completed", + }); +}); + +test("runChatParticipantTurn returns chat errors without an RPC client", async () => { + const response = new FakeChatResponseStream(); + const result = await runChatParticipantTurn({ + request: { + prompt: "hello", + }, + response, + }); + + assert.ok(result.errorDetails?.message.includes("trusted workspace")); +}); + +class FakeChatParticipantRpcClient implements ChatParticipantRpcClient { + readonly sendTurns: SendTurnParams[] = []; + 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(params: SendTurnParams) { + this.sendTurns.push(params); + queueMicrotask(() => { + this.emit(agentEvent(1, "context.built", { inputTokens: 12, maxInputTokens: 100 })); + this.emit(agentEvent(2, "assistant.delta", { text: "hello" })); + this.emit(agentEvent(3, "run.completed", { summary: "done" })); + }); + return { + runId: "run_chat_1", + turnId: "turn_chat_1", + accepted: true as const, + }; + } + + async cancel(params: { readonly runId: string; readonly reason?: string }) { + return { + runId: params.runId, + state: "canceled" as const, + ...(params.reason === undefined ? {} : { reason: params.reason }), + }; + } + + protected emit(event: AgentEventEnvelope): void { + for (const handler of this.handlers) { + handler(event); + } + } +} + +class EarlyTerminalChatParticipantRpcClient extends FakeChatParticipantRpcClient { + override async sendTurn(params: SendTurnParams) { + this.sendTurns.push(params); + this.emit(agentEvent(1, "assistant.delta", { text: "early" })); + this.emit(agentEvent(2, "run.completed", { summary: "done" })); + return { + runId: "run_chat_1", + turnId: "turn_chat_1", + accepted: true as const, + }; + } +} + +class FakeChatResponseStream implements ChatParticipantResponseStream { + readonly markdownParts: string[] = []; + readonly progressParts: string[] = []; + + markdown(value: string): void { + this.markdownParts.push(value); + } + + progress(value: string): void { + this.progressParts.push(value); + } +} + +function agentEvent(seq: number, type: string, payload: unknown): AgentEventEnvelope { + return { + seq, + time: "1970-01-01T00:00:00.000Z", + type, + runId: "run_chat_1", + turnId: "turn_chat_1", + payload, + }; +} + +async function withTimeout(value: Promise): Promise { + let timeout: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error("timed out waiting for chat turn")), 1_000); + }); + try { + return await Promise.race([value, timeoutPromise]); + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } +} diff --git a/vscode/extension/test/commands.test.ts b/vscode/extension/test/commands.test.ts index 28ad9b1..e3d89d5 100644 --- a/vscode/extension/test/commands.test.ts +++ b/vscode/extension/test/commands.test.ts @@ -3,19 +3,21 @@ import test from "node:test"; import { APPROVAL_APPROVE_LABEL, - APPROVAL_APPROVE_ONCE_LABEL, - APPROVAL_APPROVE_SESSION_LABEL, + APPROVAL_APPROVE_SELECTED_HUNKS_LABEL, APPROVAL_DISMISSED_REASON, APPROVAL_REJECTED_REASON, APPROVAL_REJECT_LABEL, OPEN_CHAT_COMMAND, OPEN_CHAT_NO_WORKSPACE_MESSAGE, + OPEN_SETTINGS_COMMAND, type ApprovalPromptRequest, type ApprovalWindowMessenger, type CommandRegistry, type DisposableLike, + formatSettingsSummary, type WindowMessenger, registerOpenChatCommand, + registerOpenSettingsCommand, requestApproval, } from "../src/commands.js"; @@ -66,10 +68,11 @@ test("open chat command asks for a workspace when RPC server is unavailable", () assert.ok(message?.includes("trusted workspace")); }); -test("open chat command starts the RPC server and reports readiness", async () => { +test("open chat command opens chat and starts the RPC server without a success toast", async () => { let callback: (() => unknown) | undefined; let message: string | undefined; let chatOpened = false; + let started = false; const commands: CommandRegistry = { registerCommand(_command, registeredCallback) { @@ -90,6 +93,7 @@ test("open chat command starts the RPC server and reports readiness", async () = { status: "stopped", async start() { + started = true; return { server: { name: "prole-coder-agent-rpc", @@ -109,8 +113,8 @@ test("open chat command starts the RPC server and reports readiness", async () = await callback(); assert.equal(chatOpened, true); - assert.ok(message?.includes("RPC server ready")); - assert.ok(message?.includes("prole-coder-agent-rpc")); + assert.equal(started, true); + assert.equal(message, undefined); }); test("open chat command reports RPC startup failures with warning messages", async () => { @@ -145,6 +149,89 @@ test("open chat command reports RPC startup failures with warning messages", asy assert.ok(warning?.includes("spawn denied")); }); +test("registerOpenSettingsCommand opens VS Code settings and reports server capabilities", async () => { + const disposable: DisposableLike = { dispose: () => undefined }; + let registeredCommand: string | undefined; + let callback: (() => unknown) | undefined; + let settingsQuery: string | undefined; + let message: string | undefined; + + const commands: CommandRegistry = { + registerCommand(command, registeredCallback) { + registeredCommand = command; + callback = registeredCallback; + return disposable; + }, + }; + + registerOpenSettingsCommand( + commands, + { + showInformationMessage(value) { + message = value; + }, + openSettings(query) { + settingsQuery = query; + }, + }, + sampleSettingsRpcServer(), + ); + assert.equal(registeredCommand, OPEN_SETTINGS_COMMAND); + assert.ok(callback); + + await callback(); + + assert.equal(settingsQuery, "@ext:prole-coder.prole-coder-vscode"); + assert.ok(message?.includes("Provider: deepseek")); + assert.ok(message?.includes("Model: DeepSeek V4 Pro")); + assert.ok(message?.includes("Budget: 1048576 context tokens")); + assert.ok(message?.includes("RPC: prole rpc")); + assert.ok(message?.includes("not stored in VS Code settings")); + assert.equal(message?.toLowerCase().includes("api key:"), false); +}); + +test("formatSettingsSummary uses server capability data without model name inference", () => { + const summary = formatSettingsSummary( + { + command: "prole", + args: ["rpc"], + autoStart: true, + }, + { + server: { + name: "prole-coder-agent-rpc", + version: "0.1.0", + }, + stateDir: ".prole-coder", + capabilities: { + supportsPersistentApprovals: true, + provider: { + provider: "deepseek", + defaultModel: "custom-model", + models: [ + { + id: "custom-model", + contextWindowTokens: 123, + maxOutputTokens: 45, + supportsThinking: false, + supportsToolCalls: true, + supportsToolChoice: true, + supportsFim: false, + supportsStreaming: true, + reportsCacheUsage: false, + }, + ], + }, + }, + }, + ); + + assert.ok(summary.includes("Model: custom-model (custom-model)")); + assert.ok(summary.includes("no-thinking")); + assert.ok(summary.includes("no-fim")); + assert.ok(summary.includes("123 context tokens, 45 max output tokens")); +}); + test("open chat command falls back to information messages for non-Error startup failures", async () => { let callback: (() => unknown) | undefined; let info: string | undefined; @@ -176,33 +263,50 @@ test("open chat command falls back to information messages for non-Error startup assert.ok(info?.includes("plain failure")); }); -test("requestApproval maps VS Code approve choices to approval params", async () => { - const approvals = [APPROVAL_APPROVE_ONCE_LABEL, APPROVAL_APPROVE_SESSION_LABEL] as const; +test("requestApproval maps the simple approve choice to one-shot approval", async () => { + let message: string | undefined; + let modal: boolean | undefined; + let items: readonly string[] = []; + const window: ApprovalWindowMessenger = { + showWarningMessage(value, options, ...choices) { + message = value; + modal = options.modal; + items = choices; + return APPROVAL_APPROVE_LABEL; + }, + }; + + const decision = await requestApproval(window, sampleApprovalRequest(true)); + + assert.deepEqual(decision, { + kind: "approve", + approvalId: "approval_1", + persist: "never", + }); + 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.deepEqual(items, [APPROVAL_APPROVE_LABEL, APPROVAL_REJECT_LABEL]); +}); - for (const selected of approvals) { - let message: string | undefined; - let modal: boolean | undefined; +test("requestApproval keeps the modal choices simple for all risks", async () => { + for (const risk of ["exec", "network", "destructive"] as const) { let items: readonly string[] = []; const window: ApprovalWindowMessenger = { - showWarningMessage(value, options, ...choices) { - message = value; - modal = options.modal; + showWarningMessage(_message, _options, ...choices) { items = choices; - return selected; + return APPROVAL_APPROVE_LABEL; }, }; - const decision = await requestApproval(window, sampleApprovalRequest(true)); + const decision = await requestApproval(window, { + ...sampleApprovalRequest(true), + risk, + }); assert.equal(decision.kind, "approve"); - assert.equal(decision.approvalId, "approval_1"); - assert.equal( - decision.persist, - selected === APPROVAL_APPROVE_SESSION_LABEL ? "session" : "never", - ); - assert.equal(modal, true); - assert.ok(message?.includes("Command: cargo test")); - assert.ok(items.includes(APPROVAL_REJECT_LABEL)); + assert.deepEqual(items, [APPROVAL_APPROVE_LABEL, APPROVAL_REJECT_LABEL]); } }); @@ -225,6 +329,58 @@ test("requestApproval maps non-persistable approve to one-shot approval", async assert.deepEqual(items, [APPROVAL_APPROVE_LABEL, APPROVAL_REJECT_LABEL]); }); +test("requestApproval maps selected patch hunks to one-shot approve params", async () => { + let warningChoices: readonly string[] = []; + let quickPickItems: readonly { readonly hunkId: string }[] = []; + const window: ApprovalWindowMessenger = { + showWarningMessage(_message, _options, ...choices) { + warningChoices = choices; + return APPROVAL_APPROVE_SELECTED_HUNKS_LABEL; + }, + showQuickPick(items) { + quickPickItems = items; + return [items[1]].filter((item): item is (typeof items)[number] => item !== undefined); + }, + }; + + const decision = await requestApproval(window, { + ...sampleApprovalRequest(true), + toolName: "apply_patch", + hunks: [ + { + id: "README.md#1:old1+3:new1+3", + filePath: "README.md", + hunkIndex: 0, + oldStart: 1, + oldCount: 3, + newStart: 1, + newCount: 3, + }, + { + id: "README.md#2:old5+2:new5+3", + filePath: "README.md", + hunkIndex: 1, + oldStart: 5, + oldCount: 2, + newStart: 5, + newCount: 3, + section: "next block", + }, + ], + }); + + assert.ok(warningChoices.includes(APPROVAL_APPROVE_SELECTED_HUNKS_LABEL)); + assert.equal(quickPickItems.length, 2); + assert.deepEqual(decision, { + kind: "approve", + approvalId: "approval_1", + persist: "never", + hunks: { + approved: ["README.md#2:old5+2:new5+3"], + }, + }); +}); + test("requestApproval includes command and joined paths in the modal message", async () => { let message = ""; const window: ApprovalWindowMessenger = { @@ -286,6 +442,49 @@ function sampleApprovalRequest(persistable: boolean): ApprovalPromptRequest { detail: "Run verification", persistable, command: "cargo test", + cwd: "crates/cli", + outputSummary: "last run passed", paths: ["crates/cli/src/lib.rs"], }; } + +function sampleSettingsRpcServer() { + return { + status: "ready", + launchConfig: { + command: "prole", + args: ["rpc"], + autoStart: true, + }, + async start() { + return { + server: { + name: "prole-coder-agent-rpc", + version: "0.1.0", + }, + stateDir: ".prole-coder", + capabilities: { + supportsPersistentApprovals: true, + provider: { + provider: "deepseek", + defaultModel: "deepseek-v4-pro", + models: [ + { + id: "deepseek-v4-pro", + displayName: "DeepSeek V4 Pro", + contextWindowTokens: 1_048_576, + maxOutputTokens: 393_216, + supportsThinking: true, + supportsToolCalls: true, + supportsToolChoice: false, + supportsFim: true, + supportsStreaming: true, + reportsCacheUsage: true, + }, + ], + }, + }, + }; + }, + }; +} diff --git a/vscode/extension/test/diagnostics.test.ts b/vscode/extension/test/diagnostics.test.ts new file mode 100644 index 0000000..676d3d9 --- /dev/null +++ b/vscode/extension/test/diagnostics.test.ts @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import * as path from "node:path"; +import test from "node:test"; + +import { diagnosticAttachmentsFromProblems } from "../src/diagnostics.js"; + +test("diagnostic attachments snapshot workspace Problems as diagnostic attachments", () => { + const workspaceRoot = path.join("C:", "workspace", "project"); + const attachments = diagnosticAttachmentsFromProblems( + [ + { + uri: { + fsPath: path.join(workspaceRoot, "src", "lib.rs"), + }, + diagnostics: [ + { + severity: 0, + message: "unused import", + source: "rust-analyzer", + code: { + value: "unused_imports", + }, + range: { + start: { + line: 2, + character: 4, + }, + end: { + line: 2, + character: 10, + }, + }, + }, + ], + }, + ], + workspaceRoot, + ); + + assert.deepEqual(attachments, [ + { + kind: "diagnostic", + path: "src/lib.rs", + range: { + startLine: 3, + startColumn: 5, + endLine: 3, + endColumn: 11, + }, + text: [ + "Severity: error", + "Message: unused import", + "Source: rust-analyzer", + "Code: unused_imports", + ].join("\n"), + }, + ]); +}); + +test("diagnostic attachments skip Problems outside the workspace", () => { + const workspaceRoot = path.join("C:", "workspace", "project"); + const attachments = diagnosticAttachmentsFromProblems( + [ + { + uri: { + fsPath: path.join("C:", "workspace", "other", "src", "lib.rs"), + }, + diagnostics: [ + { + severity: 1, + message: "outside workspace", + range: { + start: { + line: 0, + character: 0, + }, + end: { + line: 0, + character: 1, + }, + }, + }, + ], + }, + ], + workspaceRoot, + ); + + assert.deepEqual(attachments, []); +}); + +test("diagnostic attachments cap Problems with errors first", () => { + const workspaceRoot = path.join("C:", "workspace", "project"); + const warningDiagnostics = Array.from({ length: 40 }, (_, index) => ({ + severity: 1, + message: `warning ${index}`, + range: { + start: { + line: index + 1, + character: 0, + }, + end: { + line: index + 1, + character: 1, + }, + }, + })); + const attachments = diagnosticAttachmentsFromProblems( + [ + { + uri: { + fsPath: path.join(workspaceRoot, "src", "lib.rs"), + }, + diagnostics: [ + ...warningDiagnostics, + { + severity: 0, + message: "compile error", + range: { + start: { + line: 99, + character: 2, + }, + end: { + line: 99, + character: 8, + }, + }, + }, + ], + }, + ], + workspaceRoot, + ); + + assert.equal(attachments.length, 32); + const firstText = attachments[0]?.text; + if (firstText === undefined) { + assert.fail("first diagnostic attachment should include text"); + } + assert.match(firstText, /Severity: error/); + assert.match(firstText, /Message: compile error/); + assert.equal( + attachments.some((attachment) => (attachment.text ?? "").includes("warning 39")), + false, + ); +}); diff --git a/vscode/extension/test/electron/index.ts b/vscode/extension/test/electron/index.ts new file mode 100644 index 0000000..a394f66 --- /dev/null +++ b/vscode/extension/test/electron/index.ts @@ -0,0 +1,281 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +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"; + +export async function run(): Promise { + const extension = vscode.extensions.getExtension(extensionId); + + assert.ok(extension, `${extensionId} should be installed in the test host`); + await extension.activate(); + assert.equal(extension.isActive, true); + assert.equal(contributesProleChatParticipant(extension.packageJSON), true); + assert.equal(vscode.workspace.isTrusted, true); + assert.equal(vscode.workspace.getConfiguration("prole-coder.rpc").get("autoStart"), false); + + await vscode.commands.executeCommand("workbench.view.extension.prole-coder"); + await vscode.commands.executeCommand("prole-coder.chat.focus"); + + const commands = await vscode.commands.getCommands(true); + assert.equal(commands.includes("prole-coder.openChat"), true); + assert.equal(commands.includes(TEST_CHAT_MESSAGE_COMMAND), true); + assert.equal(commands.includes(TEST_CHAT_STATE_COMMAND), true); + await vscode.commands.executeCommand("prole-coder.openChat"); + + await exerciseChatSendTurnDiagnosticsAndApproval(); + await exerciseChatCancel(); + await exerciseRunListAndResume(); +} + +async function exerciseChatSendTurnDiagnosticsAndApproval(): Promise { + const diagnostics = vscode.languages.createDiagnosticCollection("prole-coder-e2e"); + try { + const workspace = workspaceFolder(); + const fileUri = vscode.Uri.joinPath(workspace.uri, "src", "broken.ts"); + await vscode.workspace.fs.createDirectory(vscode.Uri.joinPath(workspace.uri, "src")); + await vscode.workspace.fs.writeFile(fileUri, Buffer.from("const broken = true;\n", "utf8")); + diagnostics.set(fileUri, [ + new vscode.Diagnostic( + new vscode.Range(0, 0, 0, 5), + "fixture diagnostic from extension-host test", + vscode.DiagnosticSeverity.Error, + ), + ]); + + await postChatMessage({ + type: "submitTurn", + message: "integration approval flow", + mode: "edit", + }); + + 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 + : 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 sendTurn = await waitFor("logged sendTurn with diagnostics", async () => + logEntry((entry) => entry.method === "agent.sendTurn" && entry.params?.message === "integration approval flow"), + ); + assert.equal(sendTurn.params?.mode, "edit"); + assert.ok( + Array.isArray(sendTurn.params?.attachments) && + sendTurn.params.attachments.some( + (attachment: Record) => + attachment["kind"] === "diagnostic" && + attachment["path"] === "src/broken.ts" && + String(attachment["text"]).includes("fixture diagnostic"), + ), + ); + + const approve = await waitFor("logged approval response", async () => + logEntry((entry) => entry.method === "agent.approve" && entry.params?.approvalId === "approval-approval-1"), + ); + assert.equal(approve.params?.persist, "never"); + } finally { + diagnostics.dispose(); + } +} + +async function exerciseChatCancel(): Promise { + await postChatMessage({ + type: "submitTurn", + message: "integration cancel flow", + mode: "edit", + }); + + 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", + }); + + const canceled = await waitFor("canceled chat turn", async () => { + const current = await chatState(); + return current.submission.status === "canceled" && current.submission.runId === "run-cancel-1" + ? current + : undefined; + }); + assert.equal(canceled.submission.busy, false); + assert.ok(canceled.timeline.items.some((item) => item.type === "run.canceled")); + + const cancel = await waitFor("logged cancel request", async () => + logEntry((entry) => entry.method === "agent.cancel" && entry.params?.runId === "run-cancel-1"), + ); + assert.equal(cancel.params?.reason, "canceled in VS Code"); +} + +async function exerciseRunListAndResume(): Promise { + await postChatMessage({ + type: "refreshRuns", + }); + + const listed = await waitFor("run list containing historical fixture run", async () => { + const current = await chatState(); + return current.runs.status === "ready" && + current.runs.runs.some((run) => run.runId === "run-history-1") + ? current + : undefined; + }); + assert.ok(listed.runs.runs.length >= 1); + + await postChatMessage({ + type: "resumeRun", + runId: "run-history-1", + }); + + const resumed = await waitFor("resumed historical fixture run", async () => { + const current = await chatState(); + return current.runs.selectedRunId === "run-history-1" && + current.timeline.latestRunId === "run-history-1" && + current.timeline.items.some((item) => item.type === "run.completed") + ? current + : undefined; + }); + assert.equal(resumed.submission.status, "idle"); + + await waitFor("logged resume request", async () => + logEntry((entry) => entry.method === "agent.resume" && entry.params?.runId === "run-history-1"), + ); +} + +async function postChatMessage(message: unknown): Promise { + await vscode.commands.executeCommand(TEST_CHAT_MESSAGE_COMMAND, message); +} + +async function chatState(): Promise { + return await vscode.commands.executeCommand(TEST_CHAT_STATE_COMMAND); +} + +async function waitFor( + label: string, + predicate: () => Promise, + timeoutMs = 5000, +): Promise { + const started = Date.now(); + for (;;) { + const result = await predicate(); + if (result !== undefined) { + return result; + } + if (Date.now() - started > timeoutMs) { + throw new Error(`Timed out waiting for ${label}`); + } + await delay(25); + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function logEntry(predicate: (entry: RpcFixtureLogEntry) => boolean): Promise { + const logPath = process.env["PROLE_CODER_VSCODE_TEST_RPC_LOG"]; + assert.ok(logPath, "PROLE_CODER_VSCODE_TEST_RPC_LOG must be set"); + const raw = await readFile(logPath, "utf8").catch((error: unknown) => { + if (isNodeError(error) && error.code === "ENOENT") { + return ""; + } + throw error; + }); + for (const line of raw.split(/\r?\n/u)) { + if (line.length === 0) { + continue; + } + const entry = JSON.parse(line) as RpcFixtureLogEntry; + if (predicate(entry)) { + return entry; + } + } + return undefined; +} + +function workspaceFolder(): vscode.WorkspaceFolder { + const workspace = vscode.workspace.workspaceFolders?.[0]; + assert.ok(workspace, "integration test requires a workspace folder"); + return workspace; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +function contributesProleChatParticipant(packageJson: unknown): boolean { + if (!isRecord(packageJson)) { + return false; + } + const contributes = packageJson["contributes"]; + if (!isRecord(contributes)) { + return false; + } + const chatParticipants = contributes["chatParticipants"]; + return ( + Array.isArray(chatParticipants) && + chatParticipants.some( + (participant) => + isRecord(participant) && + participant["id"] === "prole-coder.chatParticipant" && + participant["name"] === "prole", + ) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +interface ChatState { + readonly timeline: { + readonly latestRunId?: string; + readonly items: ReadonlyArray<{ + readonly type: string; + }>; + }; + readonly submission: { + readonly busy: boolean; + readonly status: string; + readonly runId?: string; + }; + readonly runs: { + readonly status: string; + readonly selectedRunId?: string; + readonly runs: ReadonlyArray<{ + readonly runId: string; + }>; + }; + readonly context: { + readonly status: string; + }; +} + +interface RpcFixtureLogEntry { + readonly method?: string; + readonly params?: { + readonly message?: string; + readonly mode?: string; + readonly approvalId?: string; + readonly persist?: string; + readonly runId?: string; + readonly reason?: string; + readonly attachments?: ReadonlyArray>; + }; +} diff --git a/vscode/extension/test/fimPreview.test.ts b/vscode/extension/test/fimPreview.test.ts new file mode 100644 index 0000000..b619e3b --- /dev/null +++ b/vscode/extension/test/fimPreview.test.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildFimPreviewParams, selectFimModel } from "../src/fimPreview.js"; + +test("buildFimPreviewParams trims context by cursor offset and capability model", () => { + const params = buildFimPreviewParams({ + text: "0123456789CURSORsuffix", + offset: 10, + path: "src/main.rs", + languageId: "rust", + configuredModel: "", + maxTokens: 64, + maxContextChars: 5, + capabilities: capabilities(), + }); + + assert.deepEqual(params, { + prefix: "56789", + suffix: "CURSO", + path: "src/main.rs", + languageId: "rust", + model: "deepseek-v4-pro", + maxTokens: 64, + }); +}); + +test("buildFimPreviewParams rejects unsupported configured FIM model", () => { + const params = buildFimPreviewParams({ + text: "prefix", + offset: 6, + configuredModel: "no-fim-model", + maxContextChars: 100, + capabilities: capabilities(), + }); + + assert.equal(params, undefined); +}); + +test("selectFimModel uses server capability flags without name inference", () => { + assert.equal(selectFimModel(capabilities(), "deepseek-v4-pro"), "deepseek-v4-pro"); + assert.equal(selectFimModel(capabilities(), "deepseek-v4-flash"), undefined); +}); + +function capabilities() { + return { + protocolVersion: "0.1.0", + supportsRunResume: true, + supportsPatchApproval: true, + supportsPersistentApprovals: true, + supportsEventBatching: true, + supportedRiskLevels: ["read", "write", "exec", "network", "destructive"], + provider: { + provider: "deepseek", + defaultModel: "deepseek-v4-pro", + models: [ + { + id: "deepseek-v4-pro", + contextWindowTokens: 1_048_576, + maxOutputTokens: 393_216, + supportsThinking: true, + supportsToolCalls: true, + supportsToolChoice: false, + supportsFim: true, + supportsStreaming: true, + reportsCacheUsage: true, + }, + { + id: "deepseek-v4-flash", + displayName: "No FIM fixture", + contextWindowTokens: 1_048_576, + maxOutputTokens: 393_216, + supportsThinking: true, + supportsToolCalls: true, + supportsToolChoice: false, + supportsFim: false, + supportsStreaming: true, + reportsCacheUsage: true, + }, + { + id: "no-fim-model", + contextWindowTokens: 128, + maxOutputTokens: 64, + supportsThinking: false, + supportsToolCalls: false, + supportsToolChoice: false, + supportsFim: false, + supportsStreaming: false, + reportsCacheUsage: false, + }, + ], + }, + } as const; +} + diff --git a/vscode/extension/test/fixtures/rpcFixtureServer.mjs b/vscode/extension/test/fixtures/rpcFixtureServer.mjs new file mode 100644 index 0000000..5e2d992 --- /dev/null +++ b/vscode/extension/test/fixtures/rpcFixtureServer.mjs @@ -0,0 +1,395 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import readline from "node:readline"; + +const logPath = process.argv[2]; +if (typeof logPath !== "string" || logPath.length === 0) { + throw new Error("Usage: node rpcFixtureServer.mjs "); +} + +fs.mkdirSync(path.dirname(logPath), { recursive: true }); +fs.writeFileSync(logPath, ""); + +let seq = 1; +const pendingApprovals = new Map(); +const runs = new Map(); + +const historyRun = { + runId: "run-history-1", + title: "Historical fixture run", + status: "completed", + startedAt: "2026-05-30T00:00:00.000Z", + updatedAt: "2026-05-30T00:00:01.000Z", + completedAt: "2026-05-30T00:00:01.000Z", + lastSeq: 2, + eventCount: 2, + mode: "edit", + summary: "Historical fixture summary", + changedFiles: ["fixture/history.txt"], + verificationStatus: "passed", +}; +runs.set(historyRun.runId, historyRun); + +const reader = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity, +}); + +reader.on("line", (line) => { + if (line.trim().length === 0) { + return; + } + + let request; + try { + request = JSON.parse(line); + } catch (error) { + log({ kind: "parse-error", message: String(error) }); + return; + } + + log({ + kind: "request", + id: request.id, + method: request.method, + params: request.params, + }); + handleRequest(request); +}); + +reader.on("close", () => { + process.exit(0); +}); + +function handleRequest(request) { + switch (request.method) { + case "agent.initialize": + respond(request.id, initializeResult()); + return; + case "agent.listRuns": + respond(request.id, { + runs: listRuns(request.params?.limit), + }); + return; + case "agent.sendTurn": + handleSendTurn(request); + return; + case "agent.approve": + handleApprove(request); + return; + case "agent.cancel": + handleCancel(request); + return; + case "agent.resume": + handleResume(request); + return; + default: + respondError(request.id, -32601, `Unknown method: ${request.method}`); + } +} + +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 now = new Date().toISOString(); + runs.set(runId, { + runId, + title: message || "Untitled fixture run", + status: "running", + startedAt: now, + updatedAt: now, + lastSeq: seq, + eventCount: 0, + mode: params?.mode === "ask" || params?.mode === "plan" || params?.mode === "review" ? params.mode : "edit", + }); + + respond(request.id, { + runId, + turnId, + accepted: true, + }); + + setTimeout(() => { + emitEvent(runId, turnId, "run.started", { + mode: params?.mode ?? "edit", + workspaceRoot: process.cwd(), + }); + emitEvent(runId, turnId, "turn.started", { + userTask: message, + }); + emitContextBuilt(runId, turnId); + + if (message.includes("cancel")) { + updateRun(runId, { lastSeq: seq - 1, eventCount: 3 }); + return; + } + + const approvalId = "approval-approval-1"; + const toolCallId = "tool-approval-1"; + pendingApprovals.set(approvalId, { runId, turnId, toolCallId }); + emitEvent(runId, turnId, "tool.approvalRequired", { + approvalId, + toolCallId, + toolName: "shell", + risk: "exec", + title: "Run fixture verification", + detail: "Fixture approval used by VS Code integration tests.", + command: "echo fixture", + cwd: process.cwd(), + outputSummary: "no previous output", + paths: ["fixture.txt"], + riskReasons: ["executes a command"], + persistable: true, + }); + updateRun(runId, { lastSeq: seq - 1, eventCount: 4 }); + }, 10); +} + +function handleApprove(request) { + const approvalId = request.params?.approvalId; + respond(request.id, { + approvalId, + state: "approved", + persist: request.params?.persist ?? "never", + }); + + const pending = pendingApprovals.get(approvalId); + if (pending === undefined) { + return; + } + pendingApprovals.delete(approvalId); + + setTimeout(() => { + emitEvent(pending.runId, pending.turnId, "tool.approvalResolved", { + approvalId, + toolCallId: pending.toolCallId, + toolName: "shell", + decision: "approved", + }); + emitEvent(pending.runId, pending.turnId, "tool.completed", { + toolCallId: pending.toolCallId, + name: "shell", + status: "ok", + summary: "Fixture command approved.", + result: { + status: "ok", + summary: "Fixture command approved.", + }, + }); + emitEvent(pending.runId, pending.turnId, "assistant.delta", { + text: "Fixture approval flow completed.", + stream: true, + }); + emitEvent(pending.runId, pending.turnId, "run.completed", { + summary: "Fixture run completed.", + changedFiles: [], + verificationStatus: "passed", + }); + updateRun(pending.runId, { + status: "completed", + updatedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + lastSeq: seq - 1, + eventCount: 8, + summary: "Fixture run completed.", + changedFiles: [], + verificationStatus: "passed", + }); + }, 10); +} + +function handleCancel(request) { + const runId = request.params?.runId; + const reason = request.params?.reason ?? "canceled by test"; + respond(request.id, { + runId, + state: "canceled", + reason, + }); + emitEvent(runId, "turn-cancel-1", "run.canceled", { + code: "E_RUN_CANCELED", + reason, + }); + updateRun(runId, { + status: "canceled", + updatedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + lastSeq: seq - 1, + eventCount: 4, + summary: reason, + }); +} + +function handleResume(request) { + const runId = request.params?.runId; + if (!runs.has(runId)) { + respondError(request.id, -32003, `Run not found: ${runId}`); + return; + } + + respond(request.id, { + runId, + nextSeq: 3, + replayStarted: true, + }); + setTimeout(() => { + emitEvent(runId, "turn-history-1", "assistant.delta", { + text: "Historical replayed response.", + stream: false, + }); + emitEvent(runId, "turn-history-1", "run.completed", { + summary: "Historical fixture summary", + changedFiles: ["fixture/history.txt"], + verificationStatus: "passed", + }); + }, 10); +} + +function emitContextBuilt(runId, turnId) { + emitEvent(runId, turnId, "context.built", { + inputTokens: 120, + maxInputTokens: 1000, + stablePrefixTokens: 40, + dynamicPreludeTokens: 30, + turnSuffixTokens: 50, + stablePrefixBudgetTokens: 300, + stablePrefixBudgetRatioPpm: 300000, + stablePrefixHash: "sha256:fixturestableprefix", + cacheHitTokens: 0, + cacheMissTokens: 120, + sections: [ + { placement: "stable_prefix", itemCount: 1 }, + { placement: "dynamic_prelude", itemCount: 1 }, + { placement: "turn_suffix", itemCount: 2 }, + ], + includedSources: [ + { + kind: "diagnostic", + path: "src/broken.ts", + tokens: 20, + required: false, + reason: "workspace diagnostics", + }, + ], + omittedSources: [], + estimator: { + name: "fixture", + exact: false, + description: "Fixture estimator", + }, + manifest: { + manifestHash: "sha256:fixturemanifest", + maxEntries: 500, + totalDiscoveredFiles: 1, + includedFiles: 1, + omitted: [], + }, + }); +} + +function emitEvent(runId, turnId, type, payload) { + const event = { + seq, + time: new Date().toISOString(), + type, + runId, + turnId, + payload, + }; + seq += 1; + send({ + jsonrpc: "2.0", + method: "agent.event", + params: event, + }); + log({ kind: "event", event }); +} + +function initializeResult() { + return { + protocolVersion: "0.1.0", + server: { + name: "prole-coder-vscode-fixture", + version: "0.1.0", + }, + capabilities: { + protocolVersion: "0.1.0", + supportsRunResume: true, + supportsPatchApproval: true, + supportsPersistentApprovals: true, + supportsEventBatching: true, + supportedRiskLevels: ["read", "write", "exec", "network", "destructive"], + provider: { + provider: "fixture", + defaultModel: "fixture-model", + models: [ + { + id: "fixture-model", + displayName: "Fixture Model", + contextWindowTokens: 1000, + maxOutputTokens: 128, + supportsThinking: false, + supportsToolCalls: true, + supportsToolChoice: true, + supportsFim: true, + supportsStreaming: true, + reportsCacheUsage: true, + }, + ], + }, + }, + stateDir: path.join(process.cwd(), ".prole-coder-fixture"), + }; +} + +function listRuns(limit) { + const max = Number.isInteger(limit) && limit > 0 ? limit : 20; + return [...runs.values()] + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + .slice(0, max); +} + +function updateRun(runId, patch) { + const current = runs.get(runId); + if (current === undefined) { + return; + } + runs.set(runId, { + ...current, + ...patch, + }); +} + +function respond(id, result) { + send({ + jsonrpc: "2.0", + id, + result, + }); +} + +function respondError(id, code, message) { + send({ + jsonrpc: "2.0", + id, + error: { + code, + message, + }, + }); +} + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function log(entry) { + fs.appendFileSync(logPath, `${JSON.stringify(entry)}\n`, "utf8"); +} + +function record(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined; +} diff --git a/vscode/extension/test/rpcServer.test.ts b/vscode/extension/test/rpcServer.test.ts index bd01b77..0c1b50e 100644 --- a/vscode/extension/test/rpcServer.test.ts +++ b/vscode/extension/test/rpcServer.test.ts @@ -5,8 +5,11 @@ import { DEFAULT_RPC_ARGS, DEFAULT_RPC_COMMAND, RPC_APPROVE_METHOD, + RPC_CANCEL_METHOD, + RPC_EVENT_BATCH_METHOD, RPC_INITIALIZE_METHOD, RPC_LIST_RUNS_METHOD, + RPC_PREVIEW_FIM_METHOD, RPC_PROTOCOL_VERSION, RPC_REJECT_METHOD, RPC_RESUME_METHOD, @@ -56,6 +59,8 @@ test("RPC server manager spawns the configured command and initializes the works assert.equal(manager.status, "ready"); assert.equal(ready.server.name, "prole-coder-agent-rpc"); + assert.equal(ready.capabilities.provider.defaultModel, "deepseek-v4-pro"); + assert.equal(ready.capabilities.supportsEventBatching, true); }); test("RPC server manager forwards agent.event notifications", async () => { @@ -117,6 +122,84 @@ test("RPC server manager ignores malformed agent.event notifications", async () assert.deepEqual(received, []); }); +test("RPC server manager forwards agent.eventBatch notifications in order", 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; + + child.stdout.pushJson({ + jsonrpc: "2.0", + method: RPC_EVENT_BATCH_METHOD, + params: { + events: [ + { + seq: 2, + time: "1970-01-01T00:00:00.001Z", + type: "assistant.delta", + runId: "run_1", + turnId: "turn_1", + payload: { text: "hello" }, + }, + { + seq: 3, + time: "1970-01-01T00:00:00.002Z", + type: "assistant.delta", + runId: "run_1", + turnId: "turn_1", + payload: { text: " world" }, + }, + ], + firstSeq: 2, + lastSeq: 3, + count: 2, + }, + }); + + assert.deepEqual( + received.map((event) => (event as { seq: number }).seq), + [2, 3], + ); +}); + +test("RPC server manager ignores malformed agent.eventBatch notifications", 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; + + child.stdout.pushJson({ + jsonrpc: "2.0", + method: RPC_EVENT_BATCH_METHOD, + params: { + events: [ + { + seq: 2, + time: "1970-01-01T00:00:00.001Z", + type: "assistant.delta", + runId: "run_1", + payload: { text: "hello" }, + }, + ], + firstSeq: 2, + lastSeq: 2, + count: 2, + }, + }); + + assert.deepEqual(received, []); +}); + test("RPC server manager ignores non-agent.event notifications", async () => { const factory = new FakeProcessFactory(); const manager = rpcManagerWithFactory(factory); @@ -273,14 +356,20 @@ test("RPC server manager sends typed approval requests", async () => { const approvePromise = manager.approve({ approvalId: "approval_1", - persist: "session", + persist: "never", + hunks: { + approved: ["README.md#1:old1+3:new1+3"], + }, }); await flushMicrotasks(); const approveRequest = child.requestAt(1); assert.equal(approveRequest.method, RPC_APPROVE_METHOD); assert.deepEqual(approveRequest.params, { approvalId: "approval_1", - persist: "session", + persist: "never", + hunks: { + approved: ["README.md#1:old1+3:new1+3"], + }, }); child.stdout.pushJson({ jsonrpc: "2.0", @@ -288,13 +377,19 @@ test("RPC server manager sends typed approval requests", async () => { result: { approvalId: "approval_1", state: "approved", - persist: "session", + persist: "never", + hunks: { + approved: ["README.md#1:old1+3:new1+3"], + }, }, }); assert.deepEqual(await approvePromise, { approvalId: "approval_1", state: "approved", - persist: "session", + persist: "never", + hunks: { + approved: ["README.md#1:old1+3:new1+3"], + }, }); const rejectPromise = manager.reject({ @@ -324,6 +419,86 @@ test("RPC server manager sends typed approval requests", async () => { }); }); +test("RPC server manager sends typed cancel 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 cancelPromise = manager.cancel({ + runId: "run_1", + reason: "user canceled", + }); + await flushMicrotasks(); + const cancelRequest = child.requestAt(1); + assert.equal(cancelRequest.method, RPC_CANCEL_METHOD); + assert.deepEqual(cancelRequest.params, { + runId: "run_1", + reason: "user canceled", + }); + child.stdout.pushJson({ + jsonrpc: "2.0", + id: cancelRequest.id, + result: { + runId: "run_1", + state: "canceled", + reason: "user canceled", + }, + }); + + assert.deepEqual(await cancelPromise, { + runId: "run_1", + state: "canceled", + reason: "user canceled", + }); +}); + +test("RPC server manager sends typed FIM preview 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 previewPromise = manager.previewFim({ + prefix: "fn main() {", + suffix: "}", + path: "src/main.rs", + languageId: "rust", + model: "deepseek-v4-pro", + maxTokens: 32, + }); + await flushMicrotasks(); + const previewRequest = child.requestAt(1); + assert.equal(previewRequest.method, RPC_PREVIEW_FIM_METHOD); + assert.deepEqual(previewRequest.params, { + prefix: "fn main() {", + suffix: "}", + path: "src/main.rs", + languageId: "rust", + model: "deepseek-v4-pro", + maxTokens: 32, + }); + child.stdout.pushJson({ + jsonrpc: "2.0", + id: previewRequest.id, + result: { + text: " println!(\"hi\");", + model: "deepseek-v4-pro", + finishReason: "stop", + }, + }); + + assert.deepEqual(await previewPromise, { + text: " println!(\"hi\");", + model: "deepseek-v4-pro", + finishReason: "stop", + }); +}); + test("RPC server manager rejects sendRequest when stdin write fails", async () => { const factory = new FakeProcessFactory(); const manager = rpcManagerWithFactory(factory); @@ -439,6 +614,53 @@ test("RPC server manager fails startup on process error", async () => { assert.equal(manager.status, "failed"); }); +test("RPC server manager warns clearly on protocol mismatch startup errors", async () => { + const factory = new FakeProcessFactory(); + const warnings: string[] = []; + const manager = new RpcServerManager({ + launch: { + command: "prole", + args: ["rpc"], + autoStart: true, + }, + workspace: { + root: "C:/workspace/project", + trusted: true, + }, + extensionVersion: "0.1.0", + processFactory: factory, + notifier: { + info: () => undefined, + warn(message) { + warnings.push(message); + }, + }, + }); + + const readyPromise = manager.start(); + const child = factory.lastChild(); + const request = child.initializeRequest(); + child.stdout.pushJson({ + jsonrpc: "2.0", + id: request.id, + error: { + code: -32001, + message: "unsupported protocol version `9.9.9`, expected `0.1.0`", + data: { + clientProtocolVersion: "9.9.9", + serverProtocolVersion: RPC_PROTOCOL_VERSION, + }, + }, + }); + + await assert.rejects(readyPromise, /protocol mismatch/i); + assert.equal(manager.status, "failed"); + assert.equal(child.killed, true); + assert.equal(warnings.length, 1); + assert.ok(warnings[0]?.includes("9.9.9")); + assert.ok(warnings[0]?.includes(RPC_PROTOCOL_VERSION)); +}); + test("RPC server manager stop rejects pending startup", async () => { const factory = new FakeProcessFactory(); const manager = rpcManagerWithFactory(factory); @@ -593,8 +815,27 @@ function initializeResponse(id: unknown): unknown { protocolVersion: RPC_PROTOCOL_VERSION, supportsRunResume: true, supportsPatchApproval: true, - supportsPersistentApprovals: false, + supportsPersistentApprovals: true, + supportsEventBatching: true, supportedRiskLevels: ["read", "write", "exec", "network", "destructive"], + provider: { + provider: "deepseek", + defaultModel: "deepseek-v4-pro", + models: [ + { + id: "deepseek-v4-pro", + displayName: "DeepSeek V4 Pro", + contextWindowTokens: 1_048_576, + maxOutputTokens: 393_216, + supportsThinking: true, + supportsToolCalls: true, + supportsToolChoice: false, + supportsFim: true, + supportsStreaming: true, + reportsCacheUsage: true, + }, + ], + }, }, stateDir: ".prole-coder", },