903e4537f4
Rename brand-bearing string literals across the TUI source and the system-prompt templates that ship inside the binary. The DeepSeek provider integration is again left intact: the `ApiProvider::Deepseek` enum variant, the `"deepseek"` provider name string returned by `ApiProvider`-to-string mappings, model IDs, the `~/.deepseek/` config directory and `DEEPSEEK_CONFIG_PATH` env var, the OS keyring key `"deepseek"`, the Ollama `deepseek-coder*` model defaults, the China preset alias `deepseek-china`, and the various provider list error messages all keep the legacy spelling. Touchpoints: - `crates/tui/src/prompts/*.md` and `*.txt`: brand language flipped to `codewhale`; the internal `<deepseek:subagent.done>`, `<deepseek:subagent_context>`, `<deepseek:fork_state>`, `<deepseek:tool_call>` XML-ish event tags rename in lockstep to `<codewhale:…>` so the model-facing format stays consistent. - `crates/tui/src/tools/subagent/mod.rs`: emits the new event tag. - `crates/tui/src/core/tool_parser.rs`: parses the new event tag. - `crates/tui/src/tools/subagent/tests.rs`, `crates/tui/tests/protocol_recovery.rs`, `crates/tui/src/prompts.rs`: test expectations updated to match the new tag and the new prompt text. - Status / display strings flipped to `codewhale`: `acp_server.rs`'s agent name + title, `config_ui.rs`'s config schema title, `share.rs`'s export title, `welcome.rs`'s onboarding banner, `commands/status.rs`, `core/engine*`, `tui/notifications.rs`, `tui/sidebar.rs`, `tui/widgets/header.rs`, `tui/widgets/mod.rs`, `tui/ui.rs`'s resume-hint, `main.rs`'s clap header and `Doctor` prose, `tui/ui/tests.rs` and other test assertions. - `crates/tui/src/logging.rs` test fixture: `deepseek_cli=debug` -> `codewhale_cli=debug` so the log-filter test references the renamed crate. - Tracing targets that were namespaced under the brand (`target: "deepseek::config"`) move to `target: "codewhale::config"`. - Test-fixture tempdir prefixes (`deepseek-tui-…` / `deepseek-…`) rename for consistency. Local gates green: `cargo check --workspace --all-targets --locked`, `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings`, `cargo test --workspace --all-features --locked` (3226+ pass, 0 fail). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
501 lines
15 KiB
Rust
501 lines
15 KiB
Rust
//! Minimal Agent Client Protocol stdio adapter.
|
|
//!
|
|
//! This intentionally starts with the ACP baseline: initialize, new session,
|
|
//! prompt, and cancel. It keeps stdout protocol-clean for editor clients and
|
|
//! routes prompts through the same configured DeepSeek client as one-shot CLI
|
|
//! mode.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
use anyhow::{Result, anyhow};
|
|
use serde_json::{Value, json};
|
|
use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
|
|
|
|
use crate::client::DeepSeekClient;
|
|
use crate::config::Config;
|
|
use crate::llm_client::LlmClient;
|
|
use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
|
|
|
|
const ACP_PROTOCOL_VERSION: u64 = 1;
|
|
|
|
pub async fn run_acp_server(config: Config, model: String, default_cwd: PathBuf) -> Result<()> {
|
|
let stdin = tokio::io::stdin();
|
|
let stdout = tokio::io::stdout();
|
|
let mut reader = BufReader::new(stdin).lines();
|
|
let mut writer = tokio::io::BufWriter::new(stdout);
|
|
let mut server = AcpServer::new(config, model, default_cwd);
|
|
|
|
while let Some(line) = reader.next_line().await? {
|
|
if line.trim().is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let message: Value = match serde_json::from_str(&line) {
|
|
Ok(value) => value,
|
|
Err(err) => {
|
|
write_jsonrpc_error(&mut writer, None, -32700, format!("invalid json: {err}"))
|
|
.await?;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
if message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
|
|
write_jsonrpc_error(
|
|
&mut writer,
|
|
message.get("id").cloned(),
|
|
-32600,
|
|
"jsonrpc version must be 2.0",
|
|
)
|
|
.await?;
|
|
continue;
|
|
}
|
|
|
|
let id = message.get("id").cloned();
|
|
let method = match message.get("method").and_then(Value::as_str) {
|
|
Some(method) => method,
|
|
None => {
|
|
write_jsonrpc_error(&mut writer, id, -32600, "missing method").await?;
|
|
continue;
|
|
}
|
|
};
|
|
let params = message.get("params").cloned().unwrap_or_else(|| json!({}));
|
|
|
|
match server.handle_request(method, params, &mut writer).await {
|
|
Ok(AcpDispatch::Response(result)) => {
|
|
if let Some(id) = id {
|
|
write_jsonrpc_result(&mut writer, id, result).await?;
|
|
}
|
|
}
|
|
Ok(AcpDispatch::Shutdown) => {
|
|
if let Some(id) = id {
|
|
write_jsonrpc_result(&mut writer, id, json!(null)).await?;
|
|
}
|
|
break;
|
|
}
|
|
Err(err) => {
|
|
write_jsonrpc_error(&mut writer, id, err.code, err.message).await?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
struct AcpServer {
|
|
config: Config,
|
|
model: String,
|
|
default_cwd: PathBuf,
|
|
sessions: HashMap<String, AcpSession>,
|
|
}
|
|
|
|
struct AcpSession {
|
|
cwd: PathBuf,
|
|
}
|
|
|
|
enum AcpDispatch {
|
|
Response(Value),
|
|
Shutdown,
|
|
}
|
|
|
|
struct AcpError {
|
|
code: i32,
|
|
message: String,
|
|
}
|
|
|
|
impl AcpServer {
|
|
fn new(config: Config, model: String, default_cwd: PathBuf) -> Self {
|
|
Self {
|
|
config,
|
|
model,
|
|
default_cwd,
|
|
sessions: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
async fn handle_request<W>(
|
|
&mut self,
|
|
method: &str,
|
|
params: Value,
|
|
writer: &mut W,
|
|
) -> std::result::Result<AcpDispatch, AcpError>
|
|
where
|
|
W: AsyncWrite + Unpin,
|
|
{
|
|
match method {
|
|
"initialize" => Ok(AcpDispatch::Response(initialize_result(
|
|
params.get("protocolVersion").and_then(Value::as_u64),
|
|
))),
|
|
"session/new" => Ok(AcpDispatch::Response(self.new_session(params)?)),
|
|
"session/prompt" => {
|
|
self.prompt(params, writer).await?;
|
|
Ok(AcpDispatch::Response(json!({ "stopReason": "end_turn" })))
|
|
}
|
|
"session/cancel" => Ok(AcpDispatch::Response(json!(null))),
|
|
"shutdown" => Ok(AcpDispatch::Shutdown),
|
|
_ => Err(AcpError::method_not_found(method)),
|
|
}
|
|
}
|
|
|
|
fn new_session(&mut self, params: Value) -> std::result::Result<Value, AcpError> {
|
|
let cwd = params
|
|
.get("cwd")
|
|
.and_then(Value::as_str)
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| self.default_cwd.clone());
|
|
let session_id = format!("codewhale-{}", uuid::Uuid::new_v4());
|
|
self.sessions.insert(session_id.clone(), AcpSession { cwd });
|
|
Ok(json!({ "sessionId": session_id }))
|
|
}
|
|
|
|
async fn prompt<W>(&self, params: Value, writer: &mut W) -> std::result::Result<(), AcpError>
|
|
where
|
|
W: AsyncWrite + Unpin,
|
|
{
|
|
let session_id = params
|
|
.get("sessionId")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| AcpError::invalid_params("sessionId is required"))?;
|
|
let session = self
|
|
.sessions
|
|
.get(session_id)
|
|
.ok_or_else(|| AcpError::invalid_params("unknown sessionId"))?;
|
|
let prompt = extract_prompt_text(params.get("prompt"))
|
|
.filter(|text| !text.trim().is_empty())
|
|
.ok_or_else(|| AcpError::invalid_params("prompt must include text content"))?;
|
|
|
|
let output = self
|
|
.run_prompt(&prompt, &session.cwd)
|
|
.await
|
|
.map_err(|err| AcpError::internal(err.to_string()))?;
|
|
|
|
if !output.is_empty() {
|
|
write_session_update(writer, session_id, output)
|
|
.await
|
|
.map_err(|err| AcpError::internal(err.to_string()))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_prompt(&self, prompt: &str, cwd: &PathBuf) -> Result<String> {
|
|
let _cwd_guard = ScopedCurrentDir::new(cwd)?;
|
|
let client = DeepSeekClient::new(&self.config)?;
|
|
let route = crate::resolve_cli_auto_route(&self.config, &self.model, prompt).await;
|
|
let reasoning_effort = route
|
|
.reasoning_effort
|
|
.map(|effort| effort.as_setting().to_string());
|
|
|
|
let request = MessageRequest {
|
|
model: route.model,
|
|
messages: vec![Message {
|
|
role: "user".to_string(),
|
|
content: vec![ContentBlock::Text {
|
|
text: prompt.to_string(),
|
|
cache_control: None,
|
|
}],
|
|
}],
|
|
max_tokens: 4096,
|
|
system: Some(SystemPrompt::Text(
|
|
"You are a coding assistant inside an ACP-compatible editor. Give concise, actionable responses.".to_string(),
|
|
)),
|
|
tools: None,
|
|
tool_choice: None,
|
|
metadata: None,
|
|
thinking: None,
|
|
reasoning_effort,
|
|
stream: Some(false),
|
|
temperature: Some(0.2),
|
|
top_p: Some(0.9),
|
|
};
|
|
|
|
let response = client.create_message(request).await?;
|
|
let mut output = String::new();
|
|
for block in response.content {
|
|
if let ContentBlock::Text { text, .. } = block {
|
|
output.push_str(&text);
|
|
}
|
|
}
|
|
Ok(output)
|
|
}
|
|
}
|
|
|
|
struct ScopedCurrentDir {
|
|
prior: PathBuf,
|
|
}
|
|
|
|
impl ScopedCurrentDir {
|
|
fn new(cwd: &PathBuf) -> Result<Self> {
|
|
let prior = std::env::current_dir()?;
|
|
if cwd.as_os_str().is_empty() {
|
|
return Ok(Self { prior });
|
|
}
|
|
std::env::set_current_dir(cwd)
|
|
.map_err(|err| anyhow!("failed to enter ACP session cwd {}: {err}", cwd.display()))?;
|
|
Ok(Self { prior })
|
|
}
|
|
}
|
|
|
|
impl Drop for ScopedCurrentDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::env::set_current_dir(&self.prior);
|
|
}
|
|
}
|
|
|
|
impl AcpError {
|
|
fn invalid_params(message: impl Into<String>) -> Self {
|
|
Self {
|
|
code: -32602,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
fn method_not_found(method: &str) -> Self {
|
|
Self {
|
|
code: -32601,
|
|
message: format!("method not found: {method}"),
|
|
}
|
|
}
|
|
|
|
fn internal(message: impl Into<String>) -> Self {
|
|
Self {
|
|
code: -32603,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn initialize_result(client_protocol_version: Option<u64>) -> Value {
|
|
json!({
|
|
"protocolVersion": client_protocol_version
|
|
.map(|version| version.min(ACP_PROTOCOL_VERSION))
|
|
.unwrap_or(ACP_PROTOCOL_VERSION),
|
|
"agentCapabilities": {
|
|
"loadSession": false,
|
|
"promptCapabilities": {
|
|
"image": false,
|
|
"audio": false,
|
|
"embeddedContext": true
|
|
},
|
|
"mcpCapabilities": {
|
|
"http": false,
|
|
"sse": false
|
|
},
|
|
"sessionCapabilities": {}
|
|
},
|
|
"agentInfo": {
|
|
"name": "codewhale",
|
|
"title": "codewhale",
|
|
"version": env!("CARGO_PKG_VERSION")
|
|
},
|
|
"authMethods": []
|
|
})
|
|
}
|
|
|
|
fn extract_prompt_text(prompt: Option<&Value>) -> Option<String> {
|
|
match prompt? {
|
|
Value::String(text) => Some(text.clone()),
|
|
Value::Array(blocks) => {
|
|
let parts = blocks
|
|
.iter()
|
|
.filter_map(content_block_text)
|
|
.collect::<Vec<_>>();
|
|
(!parts.is_empty()).then(|| parts.join("\n\n"))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn content_block_text(block: &Value) -> Option<String> {
|
|
match block.get("type").and_then(Value::as_str)? {
|
|
"text" => block
|
|
.get("text")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string),
|
|
"resource" => resource_text(block),
|
|
"resource_link" | "resourceLink" => resource_link_text(block),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn resource_text(block: &Value) -> Option<String> {
|
|
let resource = block.get("resource").unwrap_or(block);
|
|
if let Some(text) = resource.get("text").and_then(Value::as_str) {
|
|
return Some(text.to_string());
|
|
}
|
|
resource_link_text(resource)
|
|
}
|
|
|
|
fn resource_link_text(block: &Value) -> Option<String> {
|
|
let uri = block
|
|
.get("uri")
|
|
.or_else(|| block.pointer("/resource/uri"))
|
|
.and_then(Value::as_str)?;
|
|
Some(format!("@{uri}"))
|
|
}
|
|
|
|
async fn write_session_update<W>(writer: &mut W, session_id: &str, text: String) -> Result<()>
|
|
where
|
|
W: AsyncWrite + Unpin,
|
|
{
|
|
let notification = json!({
|
|
"jsonrpc": "2.0",
|
|
"method": "session/update",
|
|
"params": {
|
|
"sessionId": session_id,
|
|
"update": {
|
|
"sessionUpdate": "agent_message_chunk",
|
|
"content": {
|
|
"type": "text",
|
|
"text": text
|
|
}
|
|
}
|
|
}
|
|
});
|
|
write_json_line(writer, notification).await
|
|
}
|
|
|
|
async fn write_jsonrpc_result<W>(writer: &mut W, id: Value, result: Value) -> Result<()>
|
|
where
|
|
W: AsyncWrite + Unpin,
|
|
{
|
|
let id = jsonrpc_response_id(id);
|
|
write_json_line(
|
|
writer,
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"result": result
|
|
}),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn write_jsonrpc_error<W>(
|
|
writer: &mut W,
|
|
id: Option<Value>,
|
|
code: i32,
|
|
message: impl Into<String>,
|
|
) -> Result<()>
|
|
where
|
|
W: AsyncWrite + Unpin,
|
|
{
|
|
let id = id.map(jsonrpc_response_id);
|
|
write_json_line(
|
|
writer,
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"error": {
|
|
"code": code,
|
|
"message": message.into()
|
|
}
|
|
}),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn write_json_line<W>(writer: &mut W, value: Value) -> Result<()>
|
|
where
|
|
W: AsyncWrite + Unpin,
|
|
{
|
|
writer.write_all(value.to_string().as_bytes()).await?;
|
|
writer.write_all(b"\n").await?;
|
|
writer.flush().await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn jsonrpc_response_id(id: Value) -> Value {
|
|
match id {
|
|
Value::Null => Value::Null,
|
|
Value::String(_) => id,
|
|
Value::Number(number) => Value::String(number.to_string()),
|
|
other => Value::String(other.to_string()),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn initialize_advertises_baseline_acp_agent() {
|
|
let result = initialize_result(Some(1));
|
|
|
|
assert_eq!(result["protocolVersion"], 1);
|
|
assert_eq!(result["agentInfo"]["name"], "codewhale");
|
|
assert_eq!(result["agentCapabilities"]["loadSession"], false);
|
|
assert_eq!(
|
|
result["agentCapabilities"]["promptCapabilities"]["embeddedContext"],
|
|
true
|
|
);
|
|
assert_eq!(result["authMethods"], json!([]));
|
|
}
|
|
|
|
#[test]
|
|
fn extract_prompt_text_accepts_text_and_resource_blocks() {
|
|
let prompt = json!([
|
|
{ "type": "text", "text": "Review this file" },
|
|
{
|
|
"type": "resource",
|
|
"resource": {
|
|
"uri": "file:///tmp/app.rs",
|
|
"mimeType": "text/rust",
|
|
"text": "fn main() {}"
|
|
}
|
|
},
|
|
{ "type": "resource_link", "uri": "file:///tmp/lib.rs" }
|
|
]);
|
|
|
|
let text = extract_prompt_text(Some(&prompt)).expect("prompt text");
|
|
|
|
assert!(text.contains("Review this file"));
|
|
assert!(text.contains("fn main() {}"));
|
|
assert!(text.contains("@file:///tmp/lib.rs"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_update_is_protocol_clean_single_line_json() {
|
|
let mut out = Vec::new();
|
|
|
|
write_session_update(&mut out, "sess_1", "hello\nworld".to_string())
|
|
.await
|
|
.expect("write update");
|
|
|
|
let line = String::from_utf8(out).expect("utf8");
|
|
assert_eq!(line.lines().count(), 1);
|
|
let value: Value = serde_json::from_str(line.trim()).expect("json");
|
|
assert_eq!(value["method"], "session/update");
|
|
assert_eq!(value["params"]["sessionId"], "sess_1");
|
|
assert_eq!(value["params"]["update"]["content"]["text"], "hello\nworld");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn jsonrpc_result_stringifies_numeric_ids_for_zed_acp() {
|
|
let mut out = Vec::new();
|
|
|
|
write_jsonrpc_result(&mut out, json!(1), json!({"ok": true}))
|
|
.await
|
|
.expect("write result");
|
|
|
|
let line = String::from_utf8(out).expect("utf8");
|
|
let value: Value = serde_json::from_str(line.trim()).expect("json");
|
|
assert_eq!(value["id"], "1");
|
|
assert_eq!(value["result"], json!({"ok": true}));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn jsonrpc_error_keeps_absent_id_null() {
|
|
let mut out = Vec::new();
|
|
|
|
write_jsonrpc_error(&mut out, None, -32700, "invalid json")
|
|
.await
|
|
.expect("write error");
|
|
|
|
let line = String::from_utf8(out).expect("utf8");
|
|
let value: Value = serde_json::from_str(line.trim()).expect("json");
|
|
assert_eq!(value["id"], Value::Null);
|
|
assert_eq!(value["error"]["code"], -32700);
|
|
}
|
|
}
|