mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-21 00:00:00 +08:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d8fd5f917 | ||
|
|
2d920ce915 | ||
|
|
80bfe94b77 | ||
|
|
5303de65ad | ||
|
|
50ff600466 | ||
|
|
4c3b10f087 | ||
|
|
e97ddc767f | ||
|
|
687c6b03a4 | ||
|
|
ad65ea5a47 | ||
|
|
a6c32edcf6 |
No files matched your search
@@ -0,0 +1,44 @@
|
||||
# Issue #3283 Triage Summary
|
||||
|
||||
**Issue:** docs: document OmniRoute OpenAI-compatible setup
|
||||
**Opened by:** @diegosouzapw
|
||||
**Status:** BLOCKED (waiting for clarification)
|
||||
**Date:** 2026-10-13
|
||||
|
||||
## Findings
|
||||
|
||||
### Investigation
|
||||
1. Searched codebase for "OmniRoute" — **no matches found**
|
||||
2. Searched documentation (docs/local-openai-compatible-providers.md, MODEL_COMPATIBILITY.md) — **no mentions**
|
||||
3. Searched git history (commits, branches, PRs) — **no references**
|
||||
4. Checked GitHub API (rate limited, but issue title confirmed)
|
||||
|
||||
### Current State
|
||||
- Issue #3283 is OPEN
|
||||
- No labels assigned
|
||||
- Title: "docs: document OmniRoute OpenAI-compatible setup"
|
||||
- This is a documentation request for setting up an OpenAI-compatible provider
|
||||
|
||||
### Existing Documentation
|
||||
The repository already has comprehensive documentation for OpenAI-compatible providers:
|
||||
- `docs/local-openai-compatible-providers.md` — documents Ollama, llama.cpp, vLLM, and OpenRouter
|
||||
- `docs/MODEL_COMPATIBILITY.md` — model-specific provider routing details
|
||||
- `rust/crates/api/src/providers/openai_compat.rs` — provider implementation
|
||||
|
||||
## Next Steps
|
||||
|
||||
**Action taken:** Comment added to issue asking for:
|
||||
1. OmniRoute documentation/website link
|
||||
2. OpenAI-compatible endpoint example (base URL, API key format)
|
||||
3. Model ID examples
|
||||
4. Any special configuration or known quirks
|
||||
|
||||
**Comment posted to:** https://github.com/ultraworkers/claw-code/issues/3283#issuecomment-6133484114
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Cannot proceed without user input.** Once the user provides the necessary information about OmniRoute, the fix will involve:
|
||||
1. Adding a new section to `docs/local-openai-compatible-providers.md`
|
||||
2. Including setup example (environment variables, model names, basic smoke test)
|
||||
3. Following the existing format used for Ollama, llama.cpp, vLLM sections
|
||||
4. Creating a PR for review
|
||||
@@ -130,6 +130,25 @@ claw --model "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit" prompt "Reply exactl
|
||||
|
||||
mlx-lm serves models under their full Hugging Face repo ID. Use the exact `id` field from `curl $OPENAI_BASE_URL/models` for `--model`. A bare name like `qwen2.5-coder-7b-instruct` will fail model resolution before the request ever reaches the server.
|
||||
|
||||
## OmniRoute and other remote OpenAI-compatible gateways
|
||||
|
||||
[OmniRoute](https://github.com/diegosouzapw/OmniRoute) is a third-party OpenAI-compatible AI gateway that can route requests to multiple providers. To use any remote OpenAI-compatible gateway with Claw, set `OPENAI_BASE_URL` to the gateway's `/v1` endpoint and provide your API key:
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL="https://api.omniroute.ai/v1"
|
||||
export OPENAI_API_KEY="your-omniroute-api-key"
|
||||
claw --model "gpt-4" prompt "Reply exactly HELLO_WORLD_123"
|
||||
```
|
||||
|
||||
To find the available model IDs on your gateway, use the `/models` endpoint:
|
||||
|
||||
```bash
|
||||
curl -sS "$OPENAI_BASE_URL/models" \
|
||||
-H "Authorization: Bearer $OPENAI_API_KEY" | jq '.data[].id'
|
||||
```
|
||||
|
||||
For local configuration, see your gateway's documentation for the correct base URL, API key format, and available model IDs. The same OPENAI_BASE_URL + OPENAI_API_KEY pattern applies to any OpenAI-compatible remote gateway.
|
||||
|
||||
## Local skills install from disk
|
||||
|
||||
Skills are discovered from Claw skill roots such as `.claw/skills/` in a workspace and `~/.claw/skills/` for user-level installs. Legacy `.codex/skills/` roots may also be scanned for compatibility, but new local Claw projects should prefer `.claw/skills/`.
|
||||
|
||||
+51
-24
@@ -383,6 +383,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
@@ -1002,12 +1013,24 @@ name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -1881,14 +1904,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -1929,6 +1953,12 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "radix_trie"
|
||||
version = "0.2.1"
|
||||
@@ -1946,18 +1976,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_chacha",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1970,16 +2001,6 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
@@ -1991,11 +2012,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -13,7 +13,130 @@ pub enum ProviderClient {
|
||||
OpenAi(OpenAiCompatClient),
|
||||
}
|
||||
|
||||
/// Provider selected explicitly by persisted/runtime configuration.
|
||||
///
|
||||
/// This is intentionally separate from `ProviderKind`: DashScope speaks the
|
||||
/// OpenAI-compatible wire protocol but remains a distinct configured provider.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderConfigKind {
|
||||
Anthropic,
|
||||
Xai,
|
||||
OpenAi,
|
||||
DashScope,
|
||||
}
|
||||
|
||||
/// Explicit provider configuration supplied by the runtime layer.
|
||||
///
|
||||
/// This keeps the API crate independent of runtime configuration types.
|
||||
/// Credentials are redacted from Debug output.
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderConfig {
|
||||
pub kind: ProviderConfigKind,
|
||||
pub model: String,
|
||||
pub api_key: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProviderConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ProviderConfig")
|
||||
.field("kind", &self.kind)
|
||||
.field("model", &self.model)
|
||||
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
||||
.field("base_url", &self.base_url)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn read_env_non_empty(key: &str) -> Option<String> {
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
impl ProviderClient {
|
||||
/// Construct a client from an explicit provider configuration.
|
||||
///
|
||||
/// The persisted provider kind is authoritative for provider selection,
|
||||
/// while environment credentials and base URLs retain precedence over
|
||||
/// persisted values.
|
||||
pub fn from_config(config: &ProviderConfig) -> Result<Self, ApiError> {
|
||||
match config.kind {
|
||||
ProviderConfigKind::Anthropic => {
|
||||
let api_key = read_env_non_empty("ANTHROPIC_API_KEY")
|
||||
.or_else(|| config.api_key.clone())
|
||||
.ok_or_else(|| {
|
||||
ApiError::missing_credentials(
|
||||
"Anthropic",
|
||||
&["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut client = AnthropicClient::new(api_key);
|
||||
let base_url = read_env_non_empty("ANTHROPIC_BASE_URL")
|
||||
.or_else(|| config.base_url.clone())
|
||||
.unwrap_or_else(anthropic::read_base_url);
|
||||
client = client.with_base_url(base_url);
|
||||
Ok(Self::Anthropic(client))
|
||||
}
|
||||
ProviderConfigKind::Xai => {
|
||||
let compat = OpenAiCompatConfig::xai();
|
||||
let api_key =
|
||||
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
|
||||
|
||||
let mut client = match api_key {
|
||||
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
|
||||
None => OpenAiCompatClient::from_env(compat)?,
|
||||
};
|
||||
|
||||
let base_url = read_env_non_empty(compat.base_url_env)
|
||||
.or_else(|| config.base_url.clone())
|
||||
.unwrap_or_else(|| compat.default_base_url.to_string());
|
||||
client = client.with_base_url(base_url);
|
||||
|
||||
Ok(Self::Xai(client))
|
||||
}
|
||||
ProviderConfigKind::OpenAi | ProviderConfigKind::DashScope => {
|
||||
let compat = match config.kind {
|
||||
ProviderConfigKind::OpenAi => OpenAiCompatConfig::openai(),
|
||||
ProviderConfigKind::DashScope => OpenAiCompatConfig::dashscope(),
|
||||
_ => unreachable!("non-OpenAI provider reached compatibility path"),
|
||||
};
|
||||
|
||||
if config.kind == ProviderConfigKind::OpenAi
|
||||
&& read_env_non_empty("OLLAMA_HOST").is_some()
|
||||
&& config.api_key.is_none()
|
||||
&& config.base_url.is_none()
|
||||
{
|
||||
return Ok(Self::OpenAi(
|
||||
openai_compat::OpenAiCompatClient::from_ollama_env()
|
||||
.expect("from_ollama_env always returns Some"),
|
||||
));
|
||||
}
|
||||
|
||||
let api_key =
|
||||
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
|
||||
let persisted_base_url = config.base_url.clone();
|
||||
let mut client = match api_key {
|
||||
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
|
||||
None if config.kind == ProviderConfigKind::OpenAi
|
||||
&& persisted_base_url
|
||||
.as_deref()
|
||||
.is_some_and(openai_compat::is_local_openai_compatible_base_url) =>
|
||||
{
|
||||
OpenAiCompatClient::new("local-dev-token", compat)
|
||||
}
|
||||
None => OpenAiCompatClient::from_env(compat)?,
|
||||
};
|
||||
let base_url = read_env_non_empty(compat.base_url_env)
|
||||
.or(persisted_base_url)
|
||||
.unwrap_or_else(|| compat.default_base_url.to_string());
|
||||
client = client.with_base_url(base_url);
|
||||
|
||||
Ok(Self::OpenAi(client))
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn from_model(model: &str) -> Result<Self, ApiError> {
|
||||
Self::from_model_with_anthropic_auth(model, None)
|
||||
}
|
||||
@@ -34,7 +157,7 @@ impl ProviderClient {
|
||||
ProviderKind::OpenAi => {
|
||||
// OLLAMA_HOST takes priority: local Ollama needs no API key
|
||||
// and ignores DashScope/OpenAI env-based dispatch.
|
||||
if std::env::var_os("OLLAMA_HOST").is_some() {
|
||||
if read_env_non_empty("OLLAMA_HOST").is_some() {
|
||||
Ok(Self::OpenAi(
|
||||
openai_compat::OpenAiCompatClient::from_ollama_env()
|
||||
.expect("from_ollama_env always returns Some"),
|
||||
@@ -155,7 +278,7 @@ pub fn read_xai_base_url() -> String {
|
||||
mod tests {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::ProviderClient;
|
||||
use super::{ProviderClient, ProviderConfig, ProviderConfigKind};
|
||||
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
|
||||
|
||||
/// Serializes every test in this module that mutates process-wide
|
||||
@@ -213,6 +336,73 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_provider_kind_overrides_model_provider_detection() {
|
||||
let _lock = env_lock();
|
||||
let _anthropic = EnvVarGuard::set("ANTHROPIC_API_KEY", Some("test-anthropic-key"));
|
||||
let _xai = EnvVarGuard::set("XAI_API_KEY", Some("test-xai-key"));
|
||||
|
||||
let config = ProviderConfig {
|
||||
kind: ProviderConfigKind::Xai,
|
||||
model: "claude-sonnet-4-6".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
match ProviderClient::from_config(&config).expect("explicit xAI config should succeed") {
|
||||
ProviderClient::Xai(client) => {
|
||||
assert!(client.base_url().contains("api.x.ai"));
|
||||
}
|
||||
other => panic!("Expected explicit xAI provider, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_dashscope_kind_overrides_qwen_model_routing() {
|
||||
let _lock = env_lock();
|
||||
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
|
||||
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
|
||||
|
||||
let config = ProviderConfig {
|
||||
kind: ProviderConfigKind::DashScope,
|
||||
model: "gpt-5".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
match ProviderClient::from_config(&config)
|
||||
.expect("explicit DashScope config should succeed")
|
||||
{
|
||||
ProviderClient::OpenAi(client) => {
|
||||
assert!(client.base_url().contains("dashscope.aliyuncs.com"));
|
||||
}
|
||||
other => panic!("Expected explicit DashScope provider, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_openai_kind_does_not_become_dashscope_from_model() {
|
||||
let _lock = env_lock();
|
||||
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
|
||||
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
|
||||
let _openai_base_url = EnvVarGuard::set("OPENAI_BASE_URL", None);
|
||||
|
||||
let config = ProviderConfig {
|
||||
kind: ProviderConfigKind::OpenAi,
|
||||
model: "qwen-plus".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
match ProviderClient::from_config(&config).expect("explicit OpenAI config should succeed") {
|
||||
ProviderClient::OpenAi(client) => {
|
||||
assert!(client.base_url().contains("api.openai.com"));
|
||||
assert!(!client.base_url().contains("dashscope.aliyuncs.com"));
|
||||
}
|
||||
other => panic!("Expected explicit OpenAI provider, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashscope_model_uses_dashscope_config_not_openai() {
|
||||
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod client;
|
||||
|
||||
mod error;
|
||||
mod http_client;
|
||||
mod prompt_cache;
|
||||
@@ -8,7 +9,8 @@ mod types;
|
||||
|
||||
pub use client::{
|
||||
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
|
||||
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
|
||||
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient, ProviderConfig,
|
||||
ProviderConfigKind,
|
||||
};
|
||||
pub use error::{ApiError, ApiErrorDetails};
|
||||
pub use http_client::{
|
||||
|
||||
@@ -1131,6 +1131,9 @@ pub enum SlashCommand {
|
||||
SecurityReview,
|
||||
Keybindings,
|
||||
PrivacySettings,
|
||||
Workspace {
|
||||
path: Option<String>,
|
||||
},
|
||||
Plan {
|
||||
mode: Option<String>,
|
||||
},
|
||||
@@ -1272,6 +1275,7 @@ impl SlashCommand {
|
||||
Self::SecurityReview => "/security-review",
|
||||
Self::Keybindings => "/keybindings",
|
||||
Self::PrivacySettings => "/privacy-settings",
|
||||
Self::Workspace { .. } => "/workspace",
|
||||
Self::Plan { .. } => "/plan",
|
||||
Self::Review { .. } => "/review",
|
||||
Self::Tasks { .. } => "/tasks",
|
||||
@@ -1405,6 +1409,9 @@ pub fn validate_slash_command_input(
|
||||
validate_no_args(command, &args)?;
|
||||
SlashCommand::Setup
|
||||
}
|
||||
"workspace" | "cwd" => SlashCommand::Workspace {
|
||||
path: optional_single_arg(command, &args, "[path]")?,
|
||||
},
|
||||
"login" | "logout" => {
|
||||
return Err(command_error(
|
||||
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
|
||||
@@ -5342,6 +5349,7 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::Cost
|
||||
| SlashCommand::Resume { .. }
|
||||
| SlashCommand::Config { .. }
|
||||
| SlashCommand::Workspace { .. }
|
||||
| SlashCommand::Mcp { .. }
|
||||
| SlashCommand::Memory
|
||||
| SlashCommand::Init
|
||||
@@ -6119,6 +6127,24 @@ mod tests {
|
||||
assert_eq!(suggest_slash_commands("zzz", 3), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_workspace_slash_command_and_alias() {
|
||||
let workspace = validate_slash_command_input("/workspace")
|
||||
.expect("workspace should parse")
|
||||
.expect("workspace should be a slash command");
|
||||
assert_eq!(workspace, SlashCommand::Workspace { path: None });
|
||||
|
||||
let cwd = validate_slash_command_input("/cwd src")
|
||||
.expect("cwd alias should parse")
|
||||
.expect("cwd alias should be a slash command");
|
||||
assert_eq!(
|
||||
cwd,
|
||||
SlashCommand::Workspace {
|
||||
path: Some("src".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacts_sessions_via_slash_command() {
|
||||
let mut session = Session::new();
|
||||
|
||||
@@ -205,6 +205,11 @@ pub struct RuntimeProviderConfig {
|
||||
}
|
||||
|
||||
impl RuntimeProviderConfig {
|
||||
#[must_use]
|
||||
pub fn is_configured(&self) -> bool {
|
||||
self.kind.is_some()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> Option<&str> {
|
||||
self.kind.as_deref()
|
||||
@@ -2658,6 +2663,42 @@ mod tests {
|
||||
std::env::temp_dir().join(format!("runtime-config-{pid}-{nanos}-{seq}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_persisted_provider_configuration_from_settings() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::create_dir_all(&cwd).expect("project dir");
|
||||
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{
|
||||
"provider": {
|
||||
"kind": "dashscope",
|
||||
"apiKey": "persisted-test-key",
|
||||
"baseUrl": "https://dashscope.example/v1",
|
||||
"model": "qwen-plus"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("provider configuration should load");
|
||||
|
||||
let provider = loaded.provider();
|
||||
|
||||
assert_eq!(provider.kind(), Some("dashscope"));
|
||||
assert_eq!(provider.api_key(), Some("persisted-test-key"));
|
||||
assert_eq!(provider.base_url(), Some("https://dashscope.example/v1"));
|
||||
assert_eq!(provider.model(), Some("qwen-plus"));
|
||||
assert!(provider.is_configured());
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_object_settings_files() {
|
||||
let root = temp_dir();
|
||||
|
||||
@@ -6722,6 +6722,49 @@ fn run_resume_command(
|
||||
message: Some(render_memory_report()?),
|
||||
json: Some(render_memory_json()?),
|
||||
}),
|
||||
SlashCommand::Workspace { path } => {
|
||||
let cwd_before = env::current_dir()?;
|
||||
let workspace_root = session
|
||||
.workspace_root()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| cwd_before.clone());
|
||||
let workspace_root = canonicalize_or_clone(&workspace_root);
|
||||
let changed = if let Some(path) = path.as_deref() {
|
||||
let requested_path = Path::new(path);
|
||||
let resolved_path = if requested_path.is_absolute() {
|
||||
requested_path.to_path_buf()
|
||||
} else {
|
||||
cwd_before.join(requested_path)
|
||||
};
|
||||
let resolved_path = canonicalize_or_clone(&resolved_path);
|
||||
if !resolved_path.starts_with(&workspace_root) {
|
||||
return Err(format!(
|
||||
"workspace_change_outside_root: `{}` is outside the current workspace root `{}`.\nUse `claw --cwd {}` to start a new session there.",
|
||||
resolved_path.display(),
|
||||
workspace_root.display(),
|
||||
resolved_path.display(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
env::set_current_dir(&resolved_path)?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let cwd_after = env::current_dir()?;
|
||||
let message =
|
||||
render_workspace_report(&cwd_after, &workspace_root, &session.session_id, changed);
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(message),
|
||||
json: Some(workspace_report_json(
|
||||
&cwd_after,
|
||||
&workspace_root,
|
||||
&session.session_id,
|
||||
changed,
|
||||
)),
|
||||
})
|
||||
}
|
||||
SlashCommand::Init => {
|
||||
// #142: run the init once, then render both text + structured JSON
|
||||
// from the same InitReport so both surfaces stay in sync.
|
||||
@@ -8201,6 +8244,7 @@ impl LiveCli {
|
||||
Self::print_config(section.as_deref())?;
|
||||
false
|
||||
}
|
||||
SlashCommand::Workspace { path } => self.handle_workspace_command(path.as_deref())?,
|
||||
SlashCommand::Mcp { action, target } => {
|
||||
let args = match (action.as_deref(), target.as_deref()) {
|
||||
(None, None) => None,
|
||||
@@ -8359,6 +8403,51 @@ impl LiveCli {
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_workspace_command(
|
||||
&mut self,
|
||||
target: Option<&str>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let current_dir = env::current_dir()?;
|
||||
let workspace_root = self
|
||||
.runtime
|
||||
.session()
|
||||
.workspace_root()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| current_dir.clone());
|
||||
let workspace_root = canonicalize_or_clone(&workspace_root);
|
||||
|
||||
if let Some(target) = target {
|
||||
let requested_path = Path::new(target);
|
||||
let resolved_path = if requested_path.is_absolute() {
|
||||
requested_path.to_path_buf()
|
||||
} else {
|
||||
current_dir.join(requested_path)
|
||||
};
|
||||
let resolved_path = canonicalize_or_clone(&resolved_path);
|
||||
if !resolved_path.starts_with(&workspace_root) {
|
||||
return Err(format!(
|
||||
"workspace_change_outside_root: `{}` is outside the current workspace root `{}`.\nUse `claw --cwd {}` to start a new session there.",
|
||||
resolved_path.display(),
|
||||
workspace_root.display(),
|
||||
resolved_path.display(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
env::set_current_dir(&resolved_path)?;
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
render_workspace_report(
|
||||
&env::current_dir()?,
|
||||
&workspace_root,
|
||||
&self.session.id,
|
||||
target.is_some(),
|
||||
)
|
||||
);
|
||||
Ok(target.is_some())
|
||||
}
|
||||
|
||||
fn record_prompt_history(&mut self, prompt: &str) {
|
||||
let timestamp_ms = std::time::SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -9199,6 +9288,41 @@ fn new_cli_session() -> Result<Session, Box<dyn std::error::Error>> {
|
||||
Ok(Session::new().with_workspace_root(env::current_dir()?))
|
||||
}
|
||||
|
||||
fn canonicalize_or_clone(path: &Path) -> PathBuf {
|
||||
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
fn render_workspace_report(
|
||||
cwd: &Path,
|
||||
workspace_root: &Path,
|
||||
session_id: &str,
|
||||
changed: bool,
|
||||
) -> String {
|
||||
let action = if changed { "change" } else { "show" };
|
||||
format!(
|
||||
"Workspace\n Action {action}\n Session {session_id}\n Workspace root {}\n Current directory {}",
|
||||
workspace_root.display(),
|
||||
cwd.display(),
|
||||
)
|
||||
}
|
||||
|
||||
fn workspace_report_json(
|
||||
cwd: &Path,
|
||||
workspace_root: &Path,
|
||||
session_id: &str,
|
||||
changed: bool,
|
||||
) -> Value {
|
||||
serde_json::json!({
|
||||
"kind": "workspace",
|
||||
"action": if changed { "change" } else { "show" },
|
||||
"status": "ok",
|
||||
"session_id": session_id,
|
||||
"workspace_root": workspace_root.display().to_string(),
|
||||
"current_directory": cwd.display().to_string(),
|
||||
"changed": changed,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_managed_session_handle(
|
||||
session_id: &str,
|
||||
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
||||
@@ -12587,6 +12711,7 @@ fn build_runtime_with_plugin_state(
|
||||
plugin_registry.initialize()?;
|
||||
let policy = permission_policy(permission_mode, &feature_config, &tool_registry)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let provider_config = provider_config_from_runtime(feature_config.provider())?;
|
||||
let mut runtime = ConversationRuntime::new_with_features(
|
||||
session,
|
||||
AnthropicRuntimeClient::new(
|
||||
@@ -12598,6 +12723,7 @@ fn build_runtime_with_plugin_state(
|
||||
allowed_tools.clone(),
|
||||
tool_registry.clone(),
|
||||
progress_reporter,
|
||||
provider_config,
|
||||
)?,
|
||||
CliToolExecutor::new(
|
||||
allowed_tools.clone(),
|
||||
@@ -12771,10 +12897,85 @@ impl<W: Write> Write for TailTrackingWriter<'_, W> {
|
||||
|
||||
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
|
||||
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
|
||||
// OpenAI, or DashScope at construction time based on
|
||||
// `detect_provider_kind(&model)`. The struct name is kept to avoid
|
||||
// churning `BuiltRuntime` and every Deref/DerefMut site that references
|
||||
// it. See ROADMAP #29 for the provider-dispatch routing fix.
|
||||
// OpenAI, or DashScope at construction time. Explicit persisted provider
|
||||
// configuration takes precedence; model-based provider detection remains
|
||||
// the fallback when no provider configuration is present. The struct name
|
||||
// is kept to avoid churning `BuiltRuntime` and every Deref/DerefMut site
|
||||
// that references it.
|
||||
|
||||
fn provider_config_from_runtime(
|
||||
provider: &runtime::RuntimeProviderConfig,
|
||||
) -> Result<Option<api::ProviderConfig>, Box<dyn std::error::Error>> {
|
||||
let Some(kind) = provider.kind() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let kind = match kind.trim().to_ascii_lowercase().as_str() {
|
||||
"anthropic" => api::ProviderConfigKind::Anthropic,
|
||||
"xai" => api::ProviderConfigKind::Xai,
|
||||
"openai" => api::ProviderConfigKind::OpenAi,
|
||||
"dashscope" => api::ProviderConfigKind::DashScope,
|
||||
other => {
|
||||
return Err(format!(
|
||||
"invalid provider configuration: unsupported provider kind `{other}`"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(api::ProviderConfig {
|
||||
kind,
|
||||
model: provider.model().unwrap_or_default().to_string(),
|
||||
api_key: provider.api_key().map(str::to_string),
|
||||
base_url: provider.base_url().map(str::to_string),
|
||||
}))
|
||||
}
|
||||
|
||||
fn explicit_provider_kind_for_model(
|
||||
model: &str,
|
||||
persisted_model: &str,
|
||||
) -> Option<api::ProviderConfigKind> {
|
||||
let resolved_model = api::resolve_model_alias(model);
|
||||
let persisted_resolved = api::resolve_model_alias(persisted_model);
|
||||
|
||||
if resolved_model.eq_ignore_ascii_case(&persisted_resolved) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let namespace = model
|
||||
.trim()
|
||||
.split_once('/')
|
||||
.map(|(namespace, _)| namespace.to_ascii_lowercase());
|
||||
|
||||
match namespace.as_deref() {
|
||||
Some("anthropic") => Some(api::ProviderConfigKind::Anthropic),
|
||||
Some("xai") => Some(api::ProviderConfigKind::Xai),
|
||||
Some("openai") | Some("local") => Some(api::ProviderConfigKind::OpenAi),
|
||||
Some("dashscope") | Some("qwen") | Some("kimi") => Some(api::ProviderConfigKind::DashScope),
|
||||
_ => {
|
||||
let lower = resolved_model.to_ascii_lowercase();
|
||||
if lower.starts_with("qwen/")
|
||||
|| lower.starts_with("qwen-")
|
||||
|| lower.starts_with("kimi/")
|
||||
|| lower.starts_with("kimi-")
|
||||
{
|
||||
Some(api::ProviderConfigKind::DashScope)
|
||||
} else if lower.starts_with("grok/") || lower.starts_with("grok-") {
|
||||
Some(api::ProviderConfigKind::Xai)
|
||||
} else if lower.starts_with("claude") || lower.starts_with("anthropic/") {
|
||||
Some(api::ProviderConfigKind::Anthropic)
|
||||
} else if lower.starts_with("openai/")
|
||||
|| lower.starts_with("gpt-")
|
||||
|| lower.starts_with("local/")
|
||||
{
|
||||
Some(api::ProviderConfigKind::OpenAi)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AnthropicRuntimeClient {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
client: ApiProviderClient,
|
||||
@@ -12799,47 +13000,65 @@ impl AnthropicRuntimeClient {
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
provider_config: Option<api::ProviderConfig>,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
// Dispatch to the correct provider at construction time.
|
||||
// `ApiProviderClient` (exposed by the api crate as
|
||||
// `ProviderClient`) is an enum over Anthropic / xAI / OpenAI
|
||||
// variants, where xAI and OpenAI both use the OpenAI-compat
|
||||
// wire format under the hood. We consult
|
||||
// `detect_provider_kind(&resolved_model)` so model-name prefix
|
||||
// routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over
|
||||
// env-var presence.
|
||||
//
|
||||
// For Anthropic we build the client directly instead of going
|
||||
// through `ApiProviderClient::from_model_with_anthropic_auth`
|
||||
// so we can explicitly apply `api::read_base_url()` — that
|
||||
// reads `ANTHROPIC_BASE_URL` and is required for the local
|
||||
// mock-server test harness
|
||||
// (`crates/rusty-claude-cli/tests/compact_output.rs`) to point
|
||||
// claw at its fake Anthropic endpoint. We also attach a
|
||||
// session-scoped prompt cache on the Anthropic path; the
|
||||
// prompt cache is Anthropic-only so non-Anthropic variants
|
||||
// skip it.
|
||||
// Explicit persisted provider configuration takes precedence.
|
||||
// When absent, preserve the existing model-based provider detection.
|
||||
let resolved_model = api::resolve_model_alias(&model);
|
||||
let client = match detect_provider_kind(&resolved_model) {
|
||||
ProviderKind::Anthropic => {
|
||||
let auth = resolve_cli_auth_source()?;
|
||||
let inner = AnthropicClient::from_auth(auth)
|
||||
.with_base_url(api::read_base_url())
|
||||
.with_prompt_cache(PromptCache::new(session_id));
|
||||
ApiProviderClient::Anthropic(inner)
|
||||
let provider_config = provider_config.map(|config| {
|
||||
if let Some(kind) = explicit_provider_kind_for_model(&model, config.model.as_str()) {
|
||||
api::ProviderConfig {
|
||||
kind,
|
||||
model: resolved_model.clone(),
|
||||
..config
|
||||
}
|
||||
} else {
|
||||
config
|
||||
}
|
||||
ProviderKind::Xai | ProviderKind::OpenAi => {
|
||||
// The api crate's `ProviderClient::from_model_with_anthropic_auth`
|
||||
// with `None` for the anthropic auth routes via
|
||||
// `detect_provider_kind` and builds an
|
||||
// `OpenAiCompatClient::from_env` with the matching
|
||||
// `OpenAiCompatConfig` (openai / xai / dashscope).
|
||||
// That reads the correct API-key env var and BASE_URL
|
||||
// override internally, so this one call covers OpenAI,
|
||||
// OpenRouter, xAI, DashScope, Ollama, and any other
|
||||
// OpenAI-compat endpoint users configure via
|
||||
// `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`.
|
||||
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
|
||||
});
|
||||
let client = if let Some(config) = provider_config {
|
||||
match config.kind {
|
||||
api::ProviderConfigKind::Anthropic => {
|
||||
let auth = match resolve_cli_auth_source() {
|
||||
Ok(auth) => auth,
|
||||
Err(_) => {
|
||||
config
|
||||
.api_key
|
||||
.clone()
|
||||
.map(AuthSource::ApiKey)
|
||||
.ok_or_else(|| {
|
||||
api::ApiError::missing_credentials(
|
||||
"Anthropic",
|
||||
&["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
let mut inner = AnthropicClient::from_auth(auth);
|
||||
let base_url = std::env::var("ANTHROPIC_BASE_URL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| config.base_url.clone())
|
||||
.unwrap_or_else(api::read_base_url);
|
||||
inner = inner.with_base_url(base_url);
|
||||
ApiProviderClient::Anthropic(
|
||||
inner.with_prompt_cache(PromptCache::new(session_id)),
|
||||
)
|
||||
}
|
||||
_ => ApiProviderClient::from_config(&config)?,
|
||||
}
|
||||
} else {
|
||||
match detect_provider_kind(&resolved_model) {
|
||||
ProviderKind::Anthropic => {
|
||||
let auth = resolve_cli_auth_source()?;
|
||||
let inner = AnthropicClient::from_auth(auth)
|
||||
.with_base_url(api::read_base_url())
|
||||
.with_prompt_cache(PromptCache::new(session_id));
|
||||
ApiProviderClient::Anthropic(inner)
|
||||
}
|
||||
ProviderKind::Xai | ProviderKind::OpenAi => {
|
||||
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
@@ -19508,6 +19727,119 @@ UU conflicted.rs",
|
||||
let _ = fs::remove_dir_all(source_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_model_provider_hint_overrides_persisted_provider_kind() {
|
||||
let config = api::ProviderConfig {
|
||||
kind: api::ProviderConfigKind::DashScope,
|
||||
model: "qwen-plus".to_string(),
|
||||
api_key: Some("persisted-test-key".to_string()),
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
super::explicit_provider_kind_for_model("openai/gpt-5", &config.model),
|
||||
Some(api::ProviderConfigKind::OpenAi)
|
||||
);
|
||||
assert_eq!(
|
||||
super::explicit_provider_kind_for_model("qwen-plus", &config.model),
|
||||
None,
|
||||
"matching persisted model must keep the persisted provider kind"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_model_provider_hint_controls_runtime_provider_dispatch() {
|
||||
let config = api::ProviderConfig {
|
||||
kind: api::ProviderConfigKind::DashScope,
|
||||
model: "qwen-plus".to_string(),
|
||||
api_key: Some("persisted-test-key".to_string()),
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
let runtime = super::AnthropicRuntimeClient::new(
|
||||
"provider-resolution-explicit-model-test",
|
||||
"openai/gpt-5".to_string(),
|
||||
false,
|
||||
false,
|
||||
TerminalTail::new(),
|
||||
None,
|
||||
GlobalToolRegistry::builtin(),
|
||||
None,
|
||||
Some(config),
|
||||
)
|
||||
.expect("runtime client should construct from explicit model provider hint");
|
||||
|
||||
match runtime.client {
|
||||
api::ProviderClient::OpenAi(client) => {
|
||||
assert!(client.base_url().contains("api.openai.com"));
|
||||
assert!(!client.base_url().contains("dashscope.aliyuncs.com"));
|
||||
}
|
||||
other => panic!(
|
||||
"explicit OpenAI model prefix must override persisted DashScope configuration, got {other:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_provider_configuration_controls_runtime_provider_dispatch() {
|
||||
let config_home = temp_dir();
|
||||
let workspace = temp_dir();
|
||||
fs::create_dir_all(&config_home).expect("config home");
|
||||
fs::create_dir_all(&workspace).expect("workspace");
|
||||
|
||||
fs::write(
|
||||
config_home.join("settings.json"),
|
||||
r#"{
|
||||
"provider": {
|
||||
"kind": "dashscope",
|
||||
"apiKey": "persisted-test-key",
|
||||
"baseUrl": "https://dashscope.example/v1",
|
||||
"model": "qwen-plus"
|
||||
},
|
||||
"model": "qwen-plus"
|
||||
}"#,
|
||||
)
|
||||
.expect("write provider settings");
|
||||
|
||||
let loader = ConfigLoader::new(&workspace, &config_home);
|
||||
let runtime_config = loader.load().expect("runtime config should load");
|
||||
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
|
||||
.expect("runtime plugin state should load");
|
||||
|
||||
let provider_config = super::provider_config_from_runtime(state.feature_config.provider())
|
||||
.expect("provider configuration should adapt")
|
||||
.expect("persisted provider configuration should be present");
|
||||
|
||||
assert_eq!(
|
||||
provider_config.kind,
|
||||
api::ProviderConfigKind::DashScope,
|
||||
"persisted provider kind must survive the runtime configuration boundary"
|
||||
);
|
||||
|
||||
let runtime = super::AnthropicRuntimeClient::new(
|
||||
"provider-resolution-test",
|
||||
"qwen-plus".to_string(),
|
||||
false,
|
||||
false,
|
||||
TerminalTail::new(),
|
||||
None,
|
||||
GlobalToolRegistry::builtin(),
|
||||
None,
|
||||
Some(provider_config),
|
||||
)
|
||||
.expect("runtime client should construct from persisted provider configuration");
|
||||
|
||||
match runtime.client {
|
||||
api::ProviderClient::OpenAi(_) => {}
|
||||
other => panic!(
|
||||
"persisted DashScope configuration must dispatch to the OpenAI-compatible client, got {other:?}"
|
||||
),
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() {
|
||||
|
||||
@@ -249,16 +249,6 @@ impl TerminalRenderer {
|
||||
|
||||
#[must_use]
|
||||
pub fn render_markdown(&self, markdown: &str) -> String {
|
||||
self.render_markdown_raw(markdown).trim_end().to_string()
|
||||
}
|
||||
|
||||
/// Render markdown to ANSI without trimming trailing whitespace.
|
||||
///
|
||||
/// Block-level events (paragraphs, headings, lists, code blocks) emit their
|
||||
/// trailing newline separators here. `render_markdown` trims them for
|
||||
/// one-shot output, while streaming keeps them so that successive chunks
|
||||
/// stay on their own lines instead of running together.
|
||||
fn render_markdown_raw(&self, markdown: &str) -> String {
|
||||
let normalized = normalize_nested_fences(markdown);
|
||||
let mut output = String::new();
|
||||
let mut state = RenderState::default();
|
||||
@@ -277,7 +267,30 @@ impl TerminalRenderer {
|
||||
);
|
||||
}
|
||||
|
||||
output
|
||||
output.trim_end().to_string()
|
||||
}
|
||||
|
||||
/// Ensures the output ends with at least `required` newlines.
|
||||
/// This is idempotent: calling it multiple times with the same output
|
||||
/// will not add extra newlines beyond what is needed.
|
||||
/// The function is crucial for streaming rendering where chunks may
|
||||
/// split at block boundaries, causing the parser to lose newlines
|
||||
/// that normally separate blocks.
|
||||
fn ensure_newlines(output: &mut String, required: usize) {
|
||||
if output.is_empty() {
|
||||
// Don't start with blank lines
|
||||
return;
|
||||
}
|
||||
|
||||
// Count trailing newlines
|
||||
let trailing_newlines = output.chars().rev().take_while(|&c| c == '\n').count();
|
||||
|
||||
// Add newlines if we don't have enough
|
||||
if trailing_newlines < required {
|
||||
for _ in 0..(required - trailing_newlines) {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -285,18 +298,6 @@ impl TerminalRenderer {
|
||||
self.render_markdown(markdown)
|
||||
}
|
||||
|
||||
/// Stream-friendly variant of `markdown_to_ansi` that preserves trailing
|
||||
/// block separators (such as a paragraph's closing newlines).
|
||||
///
|
||||
/// `MarkdownStreamState::push` writes each rendered chunk directly to the
|
||||
/// terminal with no separator of its own, so the trailing newlines must
|
||||
/// survive here to keep a paragraph from sticking to the block that follows
|
||||
/// it. The final `flush` still uses `markdown_to_ansi` to trim the tail.
|
||||
#[must_use]
|
||||
pub fn markdown_to_ansi_stream(&self, markdown: &str) -> String {
|
||||
self.render_markdown_raw(markdown)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn render_event(
|
||||
&self,
|
||||
@@ -309,10 +310,14 @@ impl TerminalRenderer {
|
||||
) {
|
||||
match event {
|
||||
Event::Start(Tag::Heading { level, .. }) => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
Self::start_heading(state, level as u8, output);
|
||||
}
|
||||
Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
|
||||
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
|
||||
Event::Start(Tag::BlockQuote(..)) => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
self.start_quote(state, output);
|
||||
}
|
||||
Event::End(TagEnd::BlockQuote(..)) => {
|
||||
state.quote = state.quote.saturating_sub(1);
|
||||
output.push('\n');
|
||||
@@ -325,6 +330,7 @@ impl TerminalRenderer {
|
||||
state.append_raw(output, "\n");
|
||||
}
|
||||
Event::Start(Tag::List(first_item)) => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
let kind = match first_item {
|
||||
Some(index) => ListKind::Ordered { next_index: index },
|
||||
None => ListKind::Unordered,
|
||||
@@ -337,6 +343,7 @@ impl TerminalRenderer {
|
||||
}
|
||||
Event::Start(Tag::Item) => Self::start_item(state, output),
|
||||
Event::Start(Tag::CodeBlock(kind)) => {
|
||||
Self::ensure_newlines(output, 2);
|
||||
*in_code_block = true;
|
||||
*code_language = match kind {
|
||||
CodeBlockKind::Indented => String::from("text"),
|
||||
@@ -360,7 +367,10 @@ impl TerminalRenderer {
|
||||
format!("{}", format!("`{code}`").with(self.color_theme.inline_code));
|
||||
state.append_raw(output, &rendered);
|
||||
}
|
||||
Event::Rule => output.push_str("---\n"),
|
||||
Event::Rule => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
output.push_str("---\n");
|
||||
}
|
||||
Event::Text(text) => {
|
||||
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
|
||||
}
|
||||
@@ -405,7 +415,10 @@ impl TerminalRenderer {
|
||||
);
|
||||
state.append_raw(output, &rendered);
|
||||
}
|
||||
Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()),
|
||||
Event::Start(Tag::Table(..)) => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
state.table = Some(TableState::default());
|
||||
}
|
||||
Event::End(TagEnd::Table) => {
|
||||
if let Some(table) = state.table.take() {
|
||||
output.push_str(&self.render_table(&table));
|
||||
@@ -619,9 +632,21 @@ impl TerminalRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarkdownStreamState {
|
||||
pending: String,
|
||||
/// Track whether the last emitted output ended with a newline.
|
||||
/// Used to ensure proper spacing when resuming from a buffer.
|
||||
last_output_ends_with_newline: bool,
|
||||
}
|
||||
|
||||
impl Default for MarkdownStreamState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pending: String::new(),
|
||||
last_output_ends_with_newline: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MarkdownStreamState {
|
||||
@@ -631,7 +656,40 @@ impl MarkdownStreamState {
|
||||
let split = find_stream_safe_boundary(&self.pending)?;
|
||||
let ready = self.pending[..split].to_string();
|
||||
self.pending.drain(..split);
|
||||
Some(renderer.markdown_to_ansi_stream(&ready))
|
||||
|
||||
let mut rendered = renderer.markdown_to_ansi(&ready);
|
||||
let stripped = strip_ansi(&rendered);
|
||||
|
||||
// If the previous output didn't end with a newline and this chunk starts
|
||||
// with a block-level element, ensure proper spacing before it.
|
||||
if !self.last_output_ends_with_newline && !rendered.is_empty() {
|
||||
if stripped.starts_with("\u{256d}\u{2500}")
|
||||
|| stripped.starts_with("```")
|
||||
|| stripped.starts_with("|")
|
||||
|| stripped.starts_with("• ")
|
||||
|| stripped.starts_with("1. ")
|
||||
|| stripped.starts_with("-")
|
||||
{
|
||||
rendered = format!("\n\n{}", rendered);
|
||||
}
|
||||
}
|
||||
|
||||
// When markdown ends with a code block closing marker or other block-level element,
|
||||
// render_markdown() trims the trailing newlines that finish_code_block() added.
|
||||
// For streaming, we need to restore these newlines so proper spacing is preserved
|
||||
// when chunks are combined. This is critical for idempotence across re-renders.
|
||||
if stripped.ends_with("\u{2570}\u{2500}")
|
||||
|| stripped.ends_with("---")
|
||||
|| stripped.ends_with("|")
|
||||
{
|
||||
// Block element that should have trailing spacing
|
||||
if !rendered.ends_with('\n') {
|
||||
rendered.push_str("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
self.last_output_ends_with_newline = rendered.ends_with('\n');
|
||||
Some(rendered)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -641,7 +699,26 @@ impl MarkdownStreamState {
|
||||
None
|
||||
} else {
|
||||
let pending = std::mem::take(&mut self.pending);
|
||||
Some(renderer.markdown_to_ansi(&pending))
|
||||
let mut rendered = renderer.markdown_to_ansi(&pending);
|
||||
|
||||
// If the previous output didn't end with a newline and this output
|
||||
// starts with a block-level element, ensure proper spacing.
|
||||
if !self.last_output_ends_with_newline && !rendered.is_empty() {
|
||||
// Check if output starts with a block element by looking for common markers
|
||||
let stripped = strip_ansi(&rendered);
|
||||
if stripped.starts_with("\u{256d}\u{2500}")
|
||||
|| stripped.starts_with("```")
|
||||
|| stripped.starts_with("|")
|
||||
|| stripped.starts_with("• ")
|
||||
|| stripped.starts_with("1. ")
|
||||
|| stripped.starts_with("-")
|
||||
{
|
||||
rendered = format!("\n\n{}", rendered);
|
||||
}
|
||||
}
|
||||
|
||||
self.last_output_ends_with_newline = rendered.ends_with('\n');
|
||||
Some(rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -835,6 +912,11 @@ fn normalize_nested_fences(markdown: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Find a safe boundary for streaming markdown rendering.
|
||||
/// Safe boundaries are:
|
||||
/// - After a closing fence line (when fence is closed)
|
||||
/// - After an empty line
|
||||
/// This function returns the position of the last safe boundary found.
|
||||
fn find_stream_safe_boundary(markdown: &str) -> Option<usize> {
|
||||
let mut open_fence: Option<FenceMarker> = None;
|
||||
let mut last_boundary = None;
|
||||
@@ -1074,34 +1156,6 @@ mod tests {
|
||||
assert!(plain_text.contains("fn nested()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_rendering_preserves_block_separators() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
|
||||
// The stream variant keeps the paragraph-ending newlines that the
|
||||
// one-shot variant trims, so consecutive streamed chunks do not run
|
||||
// into each other when written back-to-back to the terminal.
|
||||
let streamed = renderer.markdown_to_ansi_stream("Intro paragraph.\n\n");
|
||||
assert!(streamed.ends_with('\n'));
|
||||
assert!(!renderer
|
||||
.markdown_to_ansi("Intro paragraph.\n\n")
|
||||
.ends_with('\n'));
|
||||
|
||||
let mut state = MarkdownStreamState::default();
|
||||
let first = state
|
||||
.push(&renderer, "Intro paragraph.\n\n")
|
||||
.expect("blank line closes the paragraph block");
|
||||
let second = state
|
||||
.push(&renderer, "1. first item\n\n")
|
||||
.expect("blank line closes the list block");
|
||||
|
||||
let combined = strip_ansi(&format!("{first}{second}"));
|
||||
assert!(combined.contains("Intro paragraph."));
|
||||
assert!(combined.contains("1. first item"));
|
||||
// The two blocks stay on separate lines instead of sticking together.
|
||||
assert!(!combined.contains("Intro paragraph.1."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_advances_frames() {
|
||||
let terminal_renderer = TerminalRenderer::new();
|
||||
@@ -1117,4 +1171,189 @@ mod tests {
|
||||
let output = String::from_utf8_lossy(&out);
|
||||
assert!(output.contains("Working"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_text_before_code_block_preserves_newline_boundary() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let mut state = MarkdownStreamState::default();
|
||||
|
||||
// Simulate streaming: paragraph text arrives in one chunk
|
||||
assert_eq!(
|
||||
state.push(&renderer, "This is some text."),
|
||||
None,
|
||||
"incomplete paragraph should not flush"
|
||||
);
|
||||
|
||||
// Then code block arrives in the next chunk
|
||||
let rendered = state
|
||||
.push(&renderer, "\n\n```rust\nfn main() {}\n```\n")
|
||||
.expect("fenced block should trigger flush");
|
||||
let plain_text = strip_ansi(&rendered);
|
||||
|
||||
// Verify the paragraph text and code block are present
|
||||
assert!(plain_text.contains("This is some text."));
|
||||
assert!(plain_text.contains("fn main()"));
|
||||
|
||||
// The critical check: there should be a blank line between the paragraph and code block
|
||||
// The paragraph ends, then there should be newlines before the code block marker
|
||||
let lines: Vec<&str> = plain_text.lines().collect();
|
||||
let text_line_idx = lines
|
||||
.iter()
|
||||
.position(|line| line.contains("This is some text."))
|
||||
.expect("find text line");
|
||||
// Skip past the text line and find the code block
|
||||
let code_marker_idx = lines[text_line_idx + 1..]
|
||||
.iter()
|
||||
.position(|line| line.contains("╭─ rust"))
|
||||
.map(|i| i + text_line_idx + 1);
|
||||
|
||||
// There should be at least one empty line between the text and the code block marker
|
||||
if let Some(code_idx) = code_marker_idx {
|
||||
assert!(
|
||||
code_idx > text_line_idx + 1,
|
||||
"there should be spacing between paragraph and code block"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_paragraph_immediately_before_code_block_has_blank_line() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let markdown = "This is a paragraph.\n\n```rust\nfn main() {}\n```";
|
||||
let output = renderer.markdown_to_ansi(markdown);
|
||||
let plain_text = strip_ansi(&output);
|
||||
let lines: Vec<&str> = plain_text.lines().collect();
|
||||
|
||||
// Find the paragraph and code block
|
||||
let text_idx = lines
|
||||
.iter()
|
||||
.position(|l| l.contains("This is a paragraph."))
|
||||
.expect("find paragraph");
|
||||
let code_idx = lines[text_idx + 1..]
|
||||
.iter()
|
||||
.position(|l| l.contains("╭─ rust"))
|
||||
.map(|i| i + text_idx + 1)
|
||||
.expect("find code block");
|
||||
|
||||
// There should be at least one blank line between them
|
||||
assert!(
|
||||
code_idx > text_idx + 1,
|
||||
"code block should not immediately follow paragraph"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_block_ending_with_trailing_text_preserves_spacing() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let markdown = "```rust\nfn main() {}\n```\n\nText after.";
|
||||
let output = renderer.markdown_to_ansi(markdown);
|
||||
let plain_text = strip_ansi(&output);
|
||||
|
||||
// The code block should end with "╰─" followed by blank lines, then text
|
||||
assert!(
|
||||
plain_text.contains("\u{256d}\u{2500}"),
|
||||
"should contain code block close marker"
|
||||
);
|
||||
assert!(
|
||||
plain_text.contains("\n\nText"),
|
||||
"should have blank line before text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_code_block_preserves_trailing_newlines() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let mut state = MarkdownStreamState::default();
|
||||
let markdown = "Text before.\n\n```rust\nfn test() {}\n```\n\nText after.";
|
||||
|
||||
// Debug: let's check what the code block part alone renders to
|
||||
let code_part = "```rust\nfn test() {}\n```\n\nText after.";
|
||||
let code_rendered = strip_ansi(&renderer.markdown_to_ansi(code_part));
|
||||
|
||||
// Simulate the streaming at split position 1
|
||||
let r1 = state.push(&renderer, "T").map(|s| strip_ansi(&s));
|
||||
let r2 = state
|
||||
.push(
|
||||
&renderer,
|
||||
"ext before.\n\n```rust\nfn test() {}\n```\n\nText after.",
|
||||
)
|
||||
.map(|s| strip_ansi(&s));
|
||||
|
||||
// After r2, the pending should contain the code block part
|
||||
let pending_before_r3 = state.pending.clone();
|
||||
|
||||
let r3 = state.flush(&renderer).map(|s| strip_ansi(&s));
|
||||
|
||||
// Combine the results
|
||||
let mut full_streamed = String::new();
|
||||
if let Some(s) = &r1 {
|
||||
full_streamed.push_str(s);
|
||||
}
|
||||
if let Some(s) = &r2 {
|
||||
full_streamed.push_str(s);
|
||||
}
|
||||
if let Some(s) = &r3 {
|
||||
full_streamed.push_str(s);
|
||||
}
|
||||
|
||||
let full_rendered = strip_ansi(&renderer.markdown_to_ansi(markdown));
|
||||
|
||||
assert_eq!(full_streamed, full_rendered,
|
||||
"Streaming mismatch.\n\nCode part alone renders to: {:?}\nPending before r3: {:?}\n\nBreakdown:\n r1: {:?}\n r2: {:?}\n r3: {:?}\n\nExpected:\n{:?}\n\nGot:\n{:?}",
|
||||
code_rendered, pending_before_r3, r1, r2, r3, full_rendered, full_streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_with_split_at_every_byte_position_produces_consistent_output() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let markdown = "Text before.\n\n```rust\nfn test() {}\n```\n\nText after.";
|
||||
|
||||
// First, render the full document
|
||||
let full_output = renderer.markdown_to_ansi(markdown);
|
||||
let full_plain = strip_ansi(&full_output);
|
||||
|
||||
// Now simulate streaming by splitting at different positions
|
||||
for split_pos in 1..markdown.len() {
|
||||
let mut state = MarkdownStreamState::default();
|
||||
let part1 = &markdown[..split_pos];
|
||||
let part2 = &markdown[split_pos..];
|
||||
|
||||
// Try to push both parts
|
||||
let r1 = state.push(&renderer, part1);
|
||||
let r2 = state.push(&renderer, part2);
|
||||
let final_r = state.flush(&renderer);
|
||||
|
||||
// Reconstruct the streamed output
|
||||
let mut streamed_output = String::new();
|
||||
if let Some(out) = r1 {
|
||||
streamed_output.push_str(&out);
|
||||
}
|
||||
if let Some(out) = r2 {
|
||||
streamed_output.push_str(&out);
|
||||
}
|
||||
if let Some(out) = final_r {
|
||||
streamed_output.push_str(&out);
|
||||
}
|
||||
|
||||
let streamed_plain = strip_ansi(&streamed_output);
|
||||
|
||||
if streamed_plain != full_plain {
|
||||
eprintln!("\n=== MISMATCH at split position {} ===", split_pos);
|
||||
eprintln!("Part 1: {:?}", part1);
|
||||
eprintln!("Part 2: {:?}", part2);
|
||||
eprintln!("\nExpected (full):");
|
||||
eprintln!("{:?}", full_plain);
|
||||
eprintln!("\nGot (streamed):");
|
||||
eprintln!("{:?}", streamed_plain);
|
||||
eprintln!("\nStreamed rendered output:");
|
||||
eprintln!("{:?}", streamed_output);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
streamed_plain, full_plain,
|
||||
"streaming with split at position {} produces different output",
|
||||
split_pos
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user