mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-19 00:00:08 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28fb5c171b | ||
|
|
4c2a09c868 |
No files matched your search
@@ -57,7 +57,7 @@ jobs:
|
||||
mkdir -p dist
|
||||
cp "target/release/${{ matrix.bin }}" "dist/${{ matrix.artifact_name }}"
|
||||
chmod +x "dist/${{ matrix.artifact_name }}"
|
||||
(cd dist && (sha256sum "${{ matrix.artifact_name }}" 2>/dev/null || shasum -a 256 "${{ matrix.artifact_name }}") > "${{ matrix.artifact_name }}.sha256")
|
||||
(cd dist && sha256sum "${{ matrix.artifact_name }}" > "${{ matrix.artifact_name }}.sha256")
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
|
||||
@@ -13,5 +13,3 @@ archive/
|
||||
status-help.txt
|
||||
# Legacy Python port session scratch artifacts
|
||||
.port_sessions/
|
||||
# Local agent session state
|
||||
.gjc/
|
||||
@@ -0,0 +1 @@
|
||||
{"schema_version":1,"session_id":"5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299","state":"running","ready_for_input":false,"updated_at":"2026-10-12T22:09:30.659Z","current_turn_id":null,"last_turn_id":null,"live":true,"reason":null,"source":"agent_session_event","event":"turn_start","cwd":"/home/bellman/Data/Workspace/gc-task-cc-issue-3257-lane","workdir":"/home/bellman/Data/Workspace/gc-task-cc-issue-3257-lane","branch":null,"session_file":"/home/bellman/.gajaeway/broker/agent/sessions/v2-6fji37qf7ld523ivvoiohlcpmz3nu45m2apxyrkusn5nxczjmouq/2026-10-12T22-04-33-362Z_5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299.jsonl","activity":{"seq":115,"last_activity_at":"2026-10-12T22:09:34.144Z","tool":"bash","phase":"started","outcome":null,"elapsed_ms":null,"active_tool_count":1,"active_tools":[{"tool":"bash","started_at":"2026-10-12T22:09:34.144Z"}],"in_flight":[{"digest":"e603a70efb03b6b2b78c94b704197d26fc8a35113543291a21f68b3b08ca11a9","tool":"bash","started_at":"2026-10-12T22:09:34.144Z"}]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"pid":1,"start_time":"unknown","token":"1a2e8a33-76f2-4175-ac23-1521458f12a2","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"pid":1,"start_time":"unknown","token":"a1342032-af80-4a49-a783-291bf73bf027","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
|
||||
Whitespace-only changes.
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sessionId": "5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299",
|
||||
"pid": 487360,
|
||||
"host": "127.0.0.1",
|
||||
"port": 37541,
|
||||
"url": "ws://127.0.0.1:37541",
|
||||
"token": "LlIfu_aHH57jfys2Ii0ORMrnu56NLAa1",
|
||||
"startedAt": 1791842673967,
|
||||
"updatedAt": 1791842673967,
|
||||
"stale": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"effectMarker":"f441857a-f591-474b-b34a-6d89b2bf9feb","incarnation":"linux:159099652","pid":487360}
|
||||
@@ -0,0 +1 @@
|
||||
{"effectMarker":"f441857a-f591-474b-b34a-6d89b2bf9feb","incarnation":"linux:159099652","pid":487360}
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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,25 +130,6 @@ 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/`.
|
||||
|
||||
+24
-51
@@ -383,17 +383,6 @@ 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"
|
||||
@@ -1013,24 +1002,12 @@ 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 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -1904,15 +1881,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.19"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -1953,12 +1929,6 @@ 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"
|
||||
@@ -1976,19 +1946,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2001,6 +1970,16 @@ 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"
|
||||
@@ -2012,17 +1991,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -13,130 +13,7 @@ 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)
|
||||
}
|
||||
@@ -157,7 +34,7 @@ impl ProviderClient {
|
||||
ProviderKind::OpenAi => {
|
||||
// OLLAMA_HOST takes priority: local Ollama needs no API key
|
||||
// and ignores DashScope/OpenAI env-based dispatch.
|
||||
if read_env_non_empty("OLLAMA_HOST").is_some() {
|
||||
if std::env::var_os("OLLAMA_HOST").is_some() {
|
||||
Ok(Self::OpenAi(
|
||||
openai_compat::OpenAiCompatClient::from_ollama_env()
|
||||
.expect("from_ollama_env always returns Some"),
|
||||
@@ -278,7 +155,7 @@ pub fn read_xai_base_url() -> String {
|
||||
mod tests {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::{ProviderClient, ProviderConfig, ProviderConfigKind};
|
||||
use super::ProviderClient;
|
||||
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
|
||||
|
||||
/// Serializes every test in this module that mutates process-wide
|
||||
@@ -336,73 +213,6 @@ 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,5 +1,4 @@
|
||||
mod client;
|
||||
|
||||
mod error;
|
||||
mod http_client;
|
||||
mod prompt_cache;
|
||||
@@ -9,8 +8,7 @@ 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, ProviderConfig,
|
||||
ProviderConfigKind,
|
||||
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
|
||||
};
|
||||
pub use error::ApiError;
|
||||
pub use http_client::{
|
||||
|
||||
@@ -198,13 +198,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||
argument_hint: Some("[task]"),
|
||||
resume_supported: false,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "ultragoal",
|
||||
aliases: &[],
|
||||
summary: "Start or manage a durable goal ledger with ultragoal workflow",
|
||||
argument_hint: Some("[op|args]"),
|
||||
resume_supported: false,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "teleport",
|
||||
aliases: &[],
|
||||
@@ -219,20 +212,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||
argument_hint: None,
|
||||
resume_supported: false,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "dump",
|
||||
aliases: &[],
|
||||
summary: "Dump debugging information for the current session",
|
||||
argument_hint: Some("[scope]"),
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "trace",
|
||||
aliases: &[],
|
||||
summary: "Enable or disable execution tracing",
|
||||
argument_hint: Some("[on|off]"),
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "export",
|
||||
aliases: &[],
|
||||
@@ -1084,19 +1063,10 @@ pub enum SlashCommand {
|
||||
Ultraplan {
|
||||
task: Option<String>,
|
||||
},
|
||||
Ultragoal {
|
||||
args: Option<String>,
|
||||
},
|
||||
Teleport {
|
||||
target: Option<String>,
|
||||
},
|
||||
DebugToolCall,
|
||||
Dump {
|
||||
scope: Option<String>,
|
||||
},
|
||||
Trace {
|
||||
mode: Option<String>,
|
||||
},
|
||||
Model {
|
||||
model: Option<String>,
|
||||
},
|
||||
@@ -1161,9 +1131,6 @@ pub enum SlashCommand {
|
||||
SecurityReview,
|
||||
Keybindings,
|
||||
PrivacySettings,
|
||||
Workspace {
|
||||
path: Option<String>,
|
||||
},
|
||||
Plan {
|
||||
mode: Option<String>,
|
||||
},
|
||||
@@ -1278,11 +1245,8 @@ impl SlashCommand {
|
||||
Self::Init => "/init",
|
||||
Self::Bughunter { .. } => "/bughunter",
|
||||
Self::Ultraplan { .. } => "/ultraplan",
|
||||
Self::Ultragoal { .. } => "/ultragoal",
|
||||
Self::Teleport { .. } => "/teleport",
|
||||
Self::DebugToolCall { .. } => "/debug-tool-call",
|
||||
Self::Dump { .. } => "/dump",
|
||||
Self::Trace { .. } => "/trace",
|
||||
Self::Resume { .. } => "/resume",
|
||||
Self::Model { .. } => "/model",
|
||||
Self::Permissions { .. } => "/permissions",
|
||||
@@ -1308,7 +1272,6 @@ 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",
|
||||
@@ -1382,7 +1345,6 @@ pub fn validate_slash_command_input(
|
||||
"pr" => SlashCommand::Pr { context: remainder },
|
||||
"issue" => SlashCommand::Issue { context: remainder },
|
||||
"ultraplan" => SlashCommand::Ultraplan { task: remainder },
|
||||
"ultragoal" => SlashCommand::Ultragoal { args: remainder },
|
||||
"teleport" => SlashCommand::Teleport {
|
||||
target: Some(require_remainder(command, remainder, "<symbol-or-path>")?),
|
||||
},
|
||||
@@ -1390,12 +1352,6 @@ pub fn validate_slash_command_input(
|
||||
validate_no_args(command, &args)?;
|
||||
SlashCommand::DebugToolCall
|
||||
}
|
||||
"dump" => SlashCommand::Dump {
|
||||
scope: optional_single_arg(command, &args, "[scope]")?,
|
||||
},
|
||||
"trace" => SlashCommand::Trace {
|
||||
mode: optional_single_arg(command, &args, "[on|off]")?,
|
||||
},
|
||||
"model" => SlashCommand::Model {
|
||||
model: optional_single_arg(command, &args, "[model]")?,
|
||||
},
|
||||
@@ -1449,9 +1405,6 @@ 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.",
|
||||
@@ -5380,11 +5333,8 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::Pr { .. }
|
||||
| SlashCommand::Issue { .. }
|
||||
| SlashCommand::Ultraplan { .. }
|
||||
| SlashCommand::Ultragoal { .. }
|
||||
| SlashCommand::Teleport { .. }
|
||||
| SlashCommand::DebugToolCall
|
||||
| SlashCommand::Dump { .. }
|
||||
| SlashCommand::Trace { .. }
|
||||
| SlashCommand::Sandbox
|
||||
| SlashCommand::Model { .. }
|
||||
| SlashCommand::Permissions { .. }
|
||||
@@ -5392,7 +5342,6 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::Cost
|
||||
| SlashCommand::Resume { .. }
|
||||
| SlashCommand::Config { .. }
|
||||
| SlashCommand::Workspace { .. }
|
||||
| SlashCommand::Mcp { .. }
|
||||
| SlashCommand::Memory
|
||||
| SlashCommand::Init
|
||||
@@ -5622,36 +5571,6 @@ mod tests {
|
||||
SlashCommand::parse("/debug-tool-call"),
|
||||
Ok(Some(SlashCommand::DebugToolCall))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/dump"),
|
||||
Ok(Some(SlashCommand::Dump { scope: None }))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/dump session"),
|
||||
Ok(Some(SlashCommand::Dump {
|
||||
scope: Some("session".to_string())
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/trace"),
|
||||
Ok(Some(SlashCommand::Trace { mode: None }))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/trace on"),
|
||||
Ok(Some(SlashCommand::Trace {
|
||||
mode: Some("on".to_string())
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/ultragoal"),
|
||||
Ok(Some(SlashCommand::Ultragoal { args: None }))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/ultragoal create my-goal"),
|
||||
Ok(Some(SlashCommand::Ultragoal {
|
||||
args: Some("create my-goal".to_string())
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/bughunter runtime"),
|
||||
Ok(Some(SlashCommand::Bughunter {
|
||||
@@ -6093,10 +6012,7 @@ mod tests {
|
||||
assert!(!help.contains("/login"));
|
||||
assert!(!help.contains("/logout"));
|
||||
assert!(help.contains("/setup"));
|
||||
assert!(help.contains("/ultragoal [op|args]"));
|
||||
assert!(help.contains("/dump [scope]"));
|
||||
assert!(help.contains("/trace [on|off]"));
|
||||
assert_eq!(slash_command_specs().len(), 143);
|
||||
assert_eq!(slash_command_specs().len(), 140);
|
||||
assert!(resume_supported_slash_commands().len() >= 39);
|
||||
}
|
||||
|
||||
@@ -6203,24 +6119,6 @@ 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();
|
||||
|
||||
@@ -13,16 +13,6 @@ use tokio::task::JoinHandle;
|
||||
pub const SCENARIO_PREFIX: &str = "PARITY_SCENARIO:";
|
||||
pub const DEFAULT_MODEL: &str = "claude-sonnet-4-6";
|
||||
|
||||
/// The assistant text the `context_window_retry` scenario finally streams, after
|
||||
/// its first request was refused with a context-overflow error. Exposed so a
|
||||
/// test can assert on the exact body instead of restating the literal.
|
||||
pub const CONTEXT_WINDOW_RETRY_TEXT: &str = "auto compact retry parity complete.";
|
||||
|
||||
/// The partial assistant text the `stream_then_error` scenario streams before it
|
||||
/// fails the response mid-stream. Exposed so a test can assert on the exact body
|
||||
/// instead of restating the literal.
|
||||
pub const STREAM_THEN_ERROR_TEXT: &str = "partial response before the stream failed";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CapturedRequest {
|
||||
pub method: String,
|
||||
@@ -109,8 +99,6 @@ enum Scenario {
|
||||
BashPermissionPromptDenied,
|
||||
PluginToolRoundtrip,
|
||||
AutoCompactTriggered,
|
||||
ContextWindowRetry,
|
||||
StreamThenError,
|
||||
TokenCostReporting,
|
||||
}
|
||||
|
||||
@@ -128,8 +116,6 @@ impl Scenario {
|
||||
"bash_permission_prompt_denied" => Some(Self::BashPermissionPromptDenied),
|
||||
"plugin_tool_roundtrip" => Some(Self::PluginToolRoundtrip),
|
||||
"auto_compact_triggered" => Some(Self::AutoCompactTriggered),
|
||||
"context_window_retry" => Some(Self::ContextWindowRetry),
|
||||
"stream_then_error" => Some(Self::StreamThenError),
|
||||
"token_cost_reporting" => Some(Self::TokenCostReporting),
|
||||
_ => None,
|
||||
}
|
||||
@@ -148,8 +134,6 @@ impl Scenario {
|
||||
Self::BashPermissionPromptDenied => "bash_permission_prompt_denied",
|
||||
Self::PluginToolRoundtrip => "plugin_tool_roundtrip",
|
||||
Self::AutoCompactTriggered => "auto_compact_triggered",
|
||||
Self::ContextWindowRetry => "context_window_retry",
|
||||
Self::StreamThenError => "stream_then_error",
|
||||
Self::TokenCostReporting => "token_cost_reporting",
|
||||
}
|
||||
}
|
||||
@@ -165,18 +149,6 @@ async fn handle_connection(
|
||||
let scenario = detect_scenario(&request)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing parity scenario"))?;
|
||||
|
||||
// How many times this scenario has already been served *on this endpoint*.
|
||||
// Scenarios that need to behave differently on a retry (reject once, then
|
||||
// succeed) key off this; counting per endpoint keeps the token-counting
|
||||
// preflight on `/v1/messages/count_tokens` from consuming the first attempt.
|
||||
let attempt = {
|
||||
let served = requests.lock().await;
|
||||
served
|
||||
.iter()
|
||||
.filter(|captured| captured.scenario == scenario.name() && captured.path == path)
|
||||
.count()
|
||||
};
|
||||
|
||||
requests.lock().await.push(CapturedRequest {
|
||||
method,
|
||||
path,
|
||||
@@ -186,7 +158,7 @@ async fn handle_connection(
|
||||
raw_body,
|
||||
});
|
||||
|
||||
let response = build_http_response(&request, scenario, attempt);
|
||||
let response = build_http_response(&request, scenario);
|
||||
socket.write_all(response.as_bytes()).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -336,11 +308,7 @@ fn flatten_tool_result_content(content: &[api::ToolResultContentBlock]) -> Strin
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn build_http_response(request: &MessageRequest, scenario: Scenario, attempt: usize) -> String {
|
||||
if let Some(rejection) = context_window_rejection(scenario, attempt) {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
fn build_http_response(request: &MessageRequest, scenario: Scenario) -> String {
|
||||
let response = if request.stream {
|
||||
let body = build_stream_body(request, scenario);
|
||||
return http_response(
|
||||
@@ -361,37 +329,6 @@ fn build_http_response(request: &MessageRequest, scenario: Scenario, attempt: us
|
||||
)
|
||||
}
|
||||
|
||||
/// The first request of [`Scenario::ContextWindowRetry`] is refused with a
|
||||
/// context-overflow error instead of a streamed answer, so the client's
|
||||
/// auto-compact recovery path runs and the *retry* is what produces the
|
||||
/// response text. Every later request against the same endpoint succeeds.
|
||||
///
|
||||
/// HTTP 400 is deliberately chosen: it is not in the client's retryable status
|
||||
/// set, so the client surfaces the error rather than silently re-sending it —
|
||||
/// which is exactly the situation a real context-overflow rejection creates.
|
||||
/// The body carries the `context_window` marker the client greps for.
|
||||
fn context_window_rejection(scenario: Scenario, attempt: usize) -> Option<String> {
|
||||
if scenario != Scenario::ContextWindowRetry || attempt > 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let body = json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"message": "prompt is too long: context_window exceeded for this model",
|
||||
},
|
||||
})
|
||||
.to_string();
|
||||
|
||||
Some(http_response(
|
||||
"400 Bad Request",
|
||||
"application/json",
|
||||
&body,
|
||||
&[("request-id", request_id_for(scenario))],
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn build_stream_body(request: &MessageRequest, scenario: Scenario) -> String {
|
||||
match scenario {
|
||||
@@ -524,8 +461,6 @@ fn build_stream_body(request: &MessageRequest, scenario: Scenario) -> String {
|
||||
Scenario::AutoCompactTriggered => {
|
||||
final_text_sse_with_usage("auto compact parity complete.", 50_000, 200)
|
||||
}
|
||||
Scenario::ContextWindowRetry => final_text_sse(CONTEXT_WINDOW_RETRY_TEXT),
|
||||
Scenario::StreamThenError => stream_then_error_sse(STREAM_THEN_ERROR_TEXT),
|
||||
Scenario::TokenCostReporting => {
|
||||
final_text_sse_with_usage("token cost reporting parity complete.", 1_000, 500)
|
||||
}
|
||||
@@ -693,12 +628,6 @@ fn build_message_response(request: &MessageRequest, scenario: Scenario) -> Messa
|
||||
50_000,
|
||||
200,
|
||||
),
|
||||
Scenario::ContextWindowRetry => {
|
||||
text_message_response("msg_context_window_retry", CONTEXT_WINDOW_RETRY_TEXT)
|
||||
}
|
||||
Scenario::StreamThenError => {
|
||||
text_message_response("msg_stream_then_error", STREAM_THEN_ERROR_TEXT)
|
||||
}
|
||||
Scenario::TokenCostReporting => text_message_response_with_usage(
|
||||
"msg_token_cost_reporting",
|
||||
"token cost reporting parity complete.",
|
||||
@@ -721,8 +650,6 @@ fn request_id_for(scenario: Scenario) -> &'static str {
|
||||
Scenario::BashPermissionPromptDenied => "req_bash_permission_prompt_denied",
|
||||
Scenario::PluginToolRoundtrip => "req_plugin_tool_roundtrip",
|
||||
Scenario::AutoCompactTriggered => "req_auto_compact_triggered",
|
||||
Scenario::ContextWindowRetry => "req_context_window_retry",
|
||||
Scenario::StreamThenError => "req_stream_then_error",
|
||||
Scenario::TokenCostReporting => "req_token_cost_reporting",
|
||||
}
|
||||
}
|
||||
@@ -1044,84 +971,6 @@ fn final_text_sse(text: &str) -> String {
|
||||
body
|
||||
}
|
||||
|
||||
/// Streams a text block to completion and then fails the response, which is
|
||||
/// what a provider does when it dies partway through an answer.
|
||||
///
|
||||
/// The block is closed before the failure on purpose: the client only flushes
|
||||
/// its buffered markdown to the terminal on `content_block_stop`, so without
|
||||
/// that frame the partial text would still be sitting in the renderer and the
|
||||
/// scenario would not reproduce a turn that streamed text and *then* errored.
|
||||
///
|
||||
/// The response is cut off in the middle of the failing frame rather than
|
||||
/// carrying a complete one. Two things fall out of that, both deliberate.
|
||||
///
|
||||
/// It is what a provider that dies on the socket actually looks like, and it
|
||||
/// keeps the failure in a *later* parser call than the answer text. The whole
|
||||
/// response is one `write_all` on the mock's side, so a complete trailing frame
|
||||
/// would land in the same read chunk as the deltas — and `SseParser::push`
|
||||
/// returns `Err` from inside its frame loop, which discards the events it had
|
||||
/// already parsed from that same chunk. The client would then never render the
|
||||
/// text at all, and the scenario would be exercising that parser behaviour
|
||||
/// instead of the terminal state it is meant to reproduce. A truncated tail
|
||||
/// stays in the parser's buffer, so `push` returns the deltas normally and the
|
||||
/// failure only surfaces from `finish()`, once the client has consumed every
|
||||
/// event ahead of it.
|
||||
///
|
||||
/// The failure is a frame that does not deserialize — `api::types::StreamEvent`
|
||||
/// is `#[serde(tag = "type")]` with no fallback arm, so an unknown frame type or
|
||||
/// a truncated payload is an `ApiError::Json`. That is non-retryable, and it is
|
||||
/// raised while reading the body rather than while acquiring the response, so the
|
||||
/// client reports it on the first attempt rather than retrying.
|
||||
fn stream_then_error_sse(text: &str) -> String {
|
||||
let mut body = String::new();
|
||||
append_sse(
|
||||
&mut body,
|
||||
"message_start",
|
||||
json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": unique_message_id(),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [],
|
||||
"model": DEFAULT_MODEL,
|
||||
"stop_reason": null,
|
||||
"stop_sequence": null,
|
||||
"usage": usage_json(14, 0)
|
||||
}
|
||||
}),
|
||||
);
|
||||
append_sse(
|
||||
&mut body,
|
||||
"content_block_start",
|
||||
json!({
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": ""}
|
||||
}),
|
||||
);
|
||||
append_sse(
|
||||
&mut body,
|
||||
"content_block_delta",
|
||||
json!({
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": text}
|
||||
}),
|
||||
);
|
||||
append_sse(
|
||||
&mut body,
|
||||
"content_block_stop",
|
||||
json!({"type": "content_block_stop", "index": 0}),
|
||||
);
|
||||
// No terminating blank line: the frame is still arriving when the provider
|
||||
// drops the connection, so it stays in the client's parser buffer until the
|
||||
// body ends. See the note above for why the failure has to trail the deltas
|
||||
// by a parser call rather than sharing their chunk.
|
||||
body.push_str("event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_");
|
||||
body
|
||||
}
|
||||
|
||||
fn final_text_sse_with_usage(text: &str, input_tokens: u32, output_tokens: u32) -> String {
|
||||
let mut body = String::new();
|
||||
append_sse(
|
||||
|
||||
@@ -205,11 +205,6 @@ 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()
|
||||
@@ -2663,42 +2658,6 @@ 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();
|
||||
|
||||
@@ -27,7 +27,6 @@ use std::net::TcpListener;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread::{self, JoinHandle};
|
||||
@@ -6715,49 +6714,6 @@ 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.
|
||||
@@ -6989,11 +6945,8 @@ fn run_resume_command(
|
||||
| SlashCommand::Pr { .. }
|
||||
| SlashCommand::Issue { .. }
|
||||
| SlashCommand::Ultraplan { .. }
|
||||
| SlashCommand::Ultragoal { .. }
|
||||
| SlashCommand::Teleport { .. }
|
||||
| SlashCommand::DebugToolCall { .. }
|
||||
| SlashCommand::Dump { .. }
|
||||
| SlashCommand::Trace { .. }
|
||||
| SlashCommand::Resume { .. }
|
||||
| SlashCommand::Permissions { .. }
|
||||
| SlashCommand::Login
|
||||
@@ -7749,7 +7702,6 @@ impl LiveCli {
|
||||
system_prompt.clone(),
|
||||
enable_tools,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
allowed_tools.clone(),
|
||||
permission_mode,
|
||||
None,
|
||||
@@ -7831,7 +7783,6 @@ impl LiveCli {
|
||||
fn prepare_turn_runtime(
|
||||
&self,
|
||||
emit_output: bool,
|
||||
terminal_tail: TerminalTail,
|
||||
) -> Result<(BuiltRuntime, HookAbortMonitor), Box<dyn std::error::Error>> {
|
||||
let hook_abort_signal = runtime::HookAbortSignal::new();
|
||||
let runtime = build_runtime(
|
||||
@@ -7841,7 +7792,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
emit_output,
|
||||
terminal_tail,
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -7859,39 +7809,27 @@ impl LiveCli {
|
||||
}
|
||||
|
||||
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let terminal_tail = TerminalTail::new();
|
||||
let (mut runtime, hook_abort_monitor) =
|
||||
self.prepare_turn_runtime(true, terminal_tail.clone())?;
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
|
||||
let mut spinner = Spinner::new();
|
||||
let mut stdout = io::stdout();
|
||||
// The animated frame is cursor control: it saves the cursor, rewrites the
|
||||
// current line, and restores the cursor. None of that describes anything
|
||||
// when stdout is a pipe — the escapes survive as literal bytes and the
|
||||
// banner ends up glued to whatever the emitter writes next. Only animate
|
||||
// on a terminal; the one-shot completion line below is plain text and is
|
||||
// emitted either way.
|
||||
if io::stdout().is_terminal() {
|
||||
spinner.tick(
|
||||
"🦀 Thinking...",
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
)?;
|
||||
}
|
||||
spinner.tick(
|
||||
"🦀 Thinking...",
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
match result {
|
||||
Ok(summary) => {
|
||||
self.replace_runtime(runtime)?;
|
||||
// The emitter owns the terminal during a turn: `emit_output = true`
|
||||
// streams text deltas and echoes tool results live, so the response
|
||||
// must not be printed again here — that was the duplicate output
|
||||
// this PR's predecessor fixed.
|
||||
//
|
||||
// What the emitter may have left behind is a partial line, so ask it
|
||||
// rather than guessing from `final_assistant_text(&summary)`: that
|
||||
// guess is empty for tool-only turns, which still render.
|
||||
if !terminal_tail.at_line_start() {
|
||||
// The assistant response was already streamed to the terminal live
|
||||
// during the turn (emit_output = true), so reprinting the full text
|
||||
// here would duplicate the entire message. When text was streamed,
|
||||
// emit a newline first so `spinner.finish`'s line-clear lands on a
|
||||
// fresh line instead of erasing the last streamed line.
|
||||
let final_text = final_assistant_text(&summary);
|
||||
if !final_text.is_empty() {
|
||||
println!();
|
||||
}
|
||||
spinner.finish(
|
||||
@@ -7911,13 +7849,6 @@ impl LiveCli {
|
||||
}
|
||||
Err(error) => {
|
||||
runtime.shutdown_plugins()?;
|
||||
// Same ownership rule as the success arm: `fail` clears the current
|
||||
// line, so it may only do so when the emitter stopped on a line
|
||||
// boundary. A turn that streamed text and then failed would
|
||||
// otherwise lose its last partial line to the clear.
|
||||
if !terminal_tail.at_line_start() {
|
||||
println!();
|
||||
}
|
||||
spinner.fail(
|
||||
"❌ Request failed",
|
||||
TerminalRenderer::new().color_theme(),
|
||||
@@ -8030,14 +7961,14 @@ impl LiveCli {
|
||||
|
||||
// Build a new runtime with the compacted session and retry
|
||||
let (mut new_runtime, hook_abort_monitor) =
|
||||
self.prepare_turn_runtime(true, terminal_tail.clone())?;
|
||||
self.prepare_turn_runtime(true)?;
|
||||
drop(hook_abort_monitor);
|
||||
|
||||
let mut rp = CliPermissionPrompter::new(self.permission_mode);
|
||||
match new_runtime.run_turn(input, Some(&mut rp)) {
|
||||
Ok(summary) => {
|
||||
self.replace_runtime(new_runtime)?;
|
||||
if !terminal_tail.at_line_start() {
|
||||
if !final_assistant_text(&summary).is_empty() {
|
||||
println!();
|
||||
}
|
||||
spinner.finish(
|
||||
@@ -8122,8 +8053,7 @@ impl LiveCli {
|
||||
}
|
||||
|
||||
fn run_prompt_compact(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) =
|
||||
self.prepare_turn_runtime(false, TerminalTail::new())?;
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
@@ -8136,8 +8066,7 @@ impl LiveCli {
|
||||
}
|
||||
|
||||
fn run_prompt_compact_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) =
|
||||
self.prepare_turn_runtime(false, TerminalTail::new())?;
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
@@ -8162,8 +8091,7 @@ impl LiveCli {
|
||||
}
|
||||
|
||||
fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) =
|
||||
self.prepare_turn_runtime(false, TerminalTail::new())?;
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
@@ -8234,10 +8162,6 @@ impl LiveCli {
|
||||
self.run_ultraplan(task.as_deref())?;
|
||||
false
|
||||
}
|
||||
SlashCommand::Ultragoal { args } => {
|
||||
self.run_ultragoal(args.as_deref())?;
|
||||
false
|
||||
}
|
||||
SlashCommand::Teleport { target } => {
|
||||
Self::run_teleport(target.as_deref())?;
|
||||
false
|
||||
@@ -8246,14 +8170,6 @@ impl LiveCli {
|
||||
self.run_debug_tool_call(None)?;
|
||||
false
|
||||
}
|
||||
SlashCommand::Dump { scope } => {
|
||||
self.run_dump(scope.as_deref())?;
|
||||
false
|
||||
}
|
||||
SlashCommand::Trace { mode } => {
|
||||
self.run_trace(mode.as_deref())?;
|
||||
false
|
||||
}
|
||||
SlashCommand::Sandbox => {
|
||||
Self::print_sandbox_status();
|
||||
false
|
||||
@@ -8274,7 +8190,6 @@ 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,
|
||||
@@ -8433,51 +8348,6 @@ 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)
|
||||
@@ -8577,7 +8447,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -8624,7 +8493,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -8655,7 +8523,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -8698,7 +8565,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -9052,7 +8918,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -9088,7 +8953,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -9183,7 +9047,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -9204,7 +9067,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
@@ -9229,7 +9091,6 @@ impl LiveCli {
|
||||
self.system_prompt.clone(),
|
||||
enable_tools,
|
||||
false,
|
||||
TerminalTail::new(),
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
progress,
|
||||
@@ -9275,21 +9136,6 @@ impl LiveCli {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_ultragoal(&self, args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("{}", format_ultragoal_report(args));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_dump(&self, scope: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("{}", format_dump_report(scope));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_trace(&self, mode: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("{}", format_trace_report(mode));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_commit(&mut self, args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
validate_no_args("/commit", args)?;
|
||||
let status = git_output(&["status", "--short", "--branch"])?;
|
||||
@@ -9333,41 +9179,6 @@ 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>> {
|
||||
@@ -11650,36 +11461,6 @@ fn format_ultraplan_report(task: Option<&str>) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn format_ultragoal_report(args: Option<&str>) -> String {
|
||||
format!(
|
||||
"Ultragoal
|
||||
Args {}
|
||||
Action start or manage a durable goal ledger with ultragoal workflow
|
||||
Output goal ledger state and operations on the goal tracking system",
|
||||
args.unwrap_or("none")
|
||||
)
|
||||
}
|
||||
|
||||
fn format_dump_report(scope: Option<&str>) -> String {
|
||||
format!(
|
||||
"Dump
|
||||
Scope {}
|
||||
Action dump debugging information for the current session
|
||||
Output session state, messages, and internal debugging data",
|
||||
scope.unwrap_or("session")
|
||||
)
|
||||
}
|
||||
|
||||
fn format_trace_report(mode: Option<&str>) -> String {
|
||||
format!(
|
||||
"Trace
|
||||
Mode {}
|
||||
Action enable or disable execution tracing
|
||||
Output execution trace information or confirmation of trace state",
|
||||
mode.unwrap_or("current state")
|
||||
)
|
||||
}
|
||||
|
||||
fn format_pr_report(branch: &str, context: Option<&str>) -> String {
|
||||
format!(
|
||||
"PR
|
||||
@@ -12719,7 +12500,6 @@ fn build_runtime(
|
||||
system_prompt: Vec<String>,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
terminal_tail: TerminalTail,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
permission_mode: PermissionMode,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
@@ -12732,7 +12512,6 @@ fn build_runtime(
|
||||
system_prompt,
|
||||
enable_tools,
|
||||
emit_output,
|
||||
terminal_tail,
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
progress_reporter,
|
||||
@@ -12749,7 +12528,6 @@ fn build_runtime_with_plugin_state(
|
||||
system_prompt: Vec<String>,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
terminal_tail: TerminalTail,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
permission_mode: PermissionMode,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
@@ -12768,7 +12546,6 @@ 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(
|
||||
@@ -12776,16 +12553,13 @@ fn build_runtime_with_plugin_state(
|
||||
model,
|
||||
enable_tools,
|
||||
emit_output,
|
||||
terminal_tail.clone(),
|
||||
allowed_tools.clone(),
|
||||
tool_registry.clone(),
|
||||
progress_reporter,
|
||||
provider_config,
|
||||
)?,
|
||||
CliToolExecutor::new(
|
||||
allowed_tools.clone(),
|
||||
emit_output,
|
||||
terminal_tail,
|
||||
tool_registry.clone(),
|
||||
mcp_state.clone(),
|
||||
),
|
||||
@@ -12881,157 +12655,12 @@ impl runtime::PermissionPrompter for CliPermissionPrompter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks whether the shared terminal stream currently sits at the start of a
|
||||
/// fresh line.
|
||||
///
|
||||
/// A turn's streamed assistant text has no trailing newline, and
|
||||
/// `Spinner::finish` moves to column 0 and clears the current line — so without
|
||||
/// a guard it erases the last thing the emitter rendered. Both writers that
|
||||
/// render live during a turn (the assistant/reasoning deltas in
|
||||
/// `AnthropicRuntimeClient::consume_stream` and the tool-result blocks in
|
||||
/// `CliToolExecutor::execute`) push their bytes
|
||||
/// through a `TailTrackingWriter` sharing one of these, which lets the caller
|
||||
/// that ends the turn ask the emitter what it actually left on the terminal.
|
||||
///
|
||||
/// The previous approach inferred this from `final_assistant_text(&summary)`,
|
||||
/// which agrees today only because every *other* writer terminates its output
|
||||
/// with a newline — tool results go through `stream_markdown` (which appends
|
||||
/// one), thinking summaries are written with a trailing newline, and tool-call
|
||||
/// headers use `writeln!`. That is a property of the current renderers rather
|
||||
/// than of the streaming protocol, and the text path already violates it, so
|
||||
/// recording the fact is cheaper than depending on it. The heuristic also left
|
||||
/// the failure arm unguarded: a turn that streamed text and then errored still
|
||||
/// had that line cleared.
|
||||
#[derive(Clone, Default)]
|
||||
struct TerminalTail {
|
||||
at_line_start: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl TerminalTail {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
at_line_start: Arc::new(AtomicBool::new(true)),
|
||||
}
|
||||
}
|
||||
|
||||
/// True when nothing has been written since the last newline, so clearing
|
||||
/// the current terminal line cannot destroy rendered output.
|
||||
fn at_line_start(&self) -> bool {
|
||||
self.at_line_start.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn record(&self, written: &[u8]) {
|
||||
if let Some(last) = written.last() {
|
||||
self.at_line_start.store(*last == b'\n', Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forwards writes to `inner` while reporting the tail byte to a `TerminalTail`.
|
||||
struct TailTrackingWriter<'a, W: Write> {
|
||||
inner: &'a mut W,
|
||||
tail: TerminalTail,
|
||||
}
|
||||
|
||||
impl<'a, W: Write> TailTrackingWriter<'a, W> {
|
||||
fn new(inner: &'a mut W, tail: TerminalTail) -> Self {
|
||||
Self { inner, tail }
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Write> Write for TailTrackingWriter<'_, W> {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let written = self.inner.write(buf)?;
|
||||
self.tail.record(&buf[..written]);
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
|
||||
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
struct AnthropicRuntimeClient {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
client: ApiProviderClient,
|
||||
@@ -13039,7 +12668,6 @@ struct AnthropicRuntimeClient {
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
terminal_tail: TerminalTail,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
@@ -13052,69 +12680,50 @@ impl AnthropicRuntimeClient {
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
terminal_tail: TerminalTail,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
provider_config: Option<api::ProviderConfig>,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
// Explicit persisted provider configuration takes precedence.
|
||||
// When absent, preserve the existing model-based provider detection.
|
||||
// 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.
|
||||
let resolved_model = api::resolve_model_alias(&model);
|
||||
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
|
||||
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 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)?
|
||||
}
|
||||
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)?
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
@@ -13124,7 +12733,6 @@ impl AnthropicRuntimeClient {
|
||||
model,
|
||||
enable_tools,
|
||||
emit_output,
|
||||
terminal_tail,
|
||||
allowed_tools,
|
||||
tool_registry,
|
||||
progress_reporter,
|
||||
@@ -13224,9 +12832,8 @@ impl AnthropicRuntimeClient {
|
||||
})?;
|
||||
let mut stdout = io::stdout();
|
||||
let mut sink = io::sink();
|
||||
let mut tracked = TailTrackingWriter::new(&mut stdout, self.terminal_tail.clone());
|
||||
let out: &mut dyn Write = if self.emit_output {
|
||||
&mut tracked
|
||||
&mut stdout
|
||||
} else {
|
||||
&mut sink
|
||||
};
|
||||
@@ -14390,7 +13997,6 @@ fn prompt_cache_record_to_runtime_event(
|
||||
struct CliToolExecutor {
|
||||
renderer: TerminalRenderer,
|
||||
emit_output: bool,
|
||||
terminal_tail: TerminalTail,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
@@ -14400,14 +14006,12 @@ impl CliToolExecutor {
|
||||
fn new(
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
emit_output: bool,
|
||||
terminal_tail: TerminalTail,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
renderer: TerminalRenderer::new(),
|
||||
emit_output,
|
||||
terminal_tail,
|
||||
allowed_tools,
|
||||
tool_registry,
|
||||
mcp_state,
|
||||
@@ -14501,11 +14105,8 @@ impl ToolExecutor for CliToolExecutor {
|
||||
Ok(output) => {
|
||||
if self.emit_output {
|
||||
let markdown = format_tool_result(tool_name, &output, false);
|
||||
let mut stdout = io::stdout();
|
||||
let mut tracked =
|
||||
TailTrackingWriter::new(&mut stdout, self.terminal_tail.clone());
|
||||
self.renderer
|
||||
.stream_markdown(&markdown, &mut tracked)
|
||||
.stream_markdown(&markdown, &mut io::stdout())
|
||||
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||
}
|
||||
Ok(output)
|
||||
@@ -14513,11 +14114,8 @@ impl ToolExecutor for CliToolExecutor {
|
||||
Err(error) => {
|
||||
if self.emit_output {
|
||||
let markdown = format_tool_result(tool_name, &error.to_string(), true);
|
||||
let mut stdout = io::stdout();
|
||||
let mut tracked =
|
||||
TailTrackingWriter::new(&mut stdout, self.terminal_tail.clone());
|
||||
self.renderer
|
||||
.stream_markdown(&markdown, &mut tracked)
|
||||
.stream_markdown(&markdown, &mut io::stdout())
|
||||
.map_err(|stream_error| ToolError::new(stream_error.to_string()))?;
|
||||
}
|
||||
Err(error)
|
||||
@@ -14832,8 +14430,8 @@ mod tests {
|
||||
CliOutputFormat, CliToolExecutor, GitOperation, GitWorkspaceSummary,
|
||||
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
|
||||
PermissionModeProvenance, PromptHistoryEntry, SessionLifecycleKind,
|
||||
SessionLifecycleSummary, SlashCommand, StatusUsage, TerminalTail, TmuxPaneSnapshot,
|
||||
DEFAULT_MODEL, LATEST_SESSION_REFERENCE, STUB_COMMANDS,
|
||||
SessionLifecycleSummary, SlashCommand, StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL,
|
||||
LATEST_SESSION_REFERENCE, STUB_COMMANDS,
|
||||
};
|
||||
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
|
||||
use plugins::{
|
||||
@@ -19711,119 +19309,6 @@ 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() {
|
||||
@@ -19869,7 +19354,6 @@ UU conflicted.rs",
|
||||
let mut executor = CliToolExecutor::new(
|
||||
None,
|
||||
false,
|
||||
TerminalTail::new(),
|
||||
state.tool_registry.clone(),
|
||||
state.mcp_state.clone(),
|
||||
);
|
||||
@@ -19968,7 +19452,6 @@ UU conflicted.rs",
|
||||
let mut executor = CliToolExecutor::new(
|
||||
None,
|
||||
false,
|
||||
TerminalTail::new(),
|
||||
state.tool_registry.clone(),
|
||||
state.mcp_state.clone(),
|
||||
);
|
||||
@@ -20029,7 +19512,6 @@ UU conflicted.rs",
|
||||
vec!["test system prompt".to_string()],
|
||||
true,
|
||||
false,
|
||||
TerminalTail::new(),
|
||||
None,
|
||||
PermissionMode::DangerFullAccess,
|
||||
None,
|
||||
|
||||
@@ -7,9 +7,7 @@ use std::process::{Command, Output, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use mock_anthropic_service::{
|
||||
MockAnthropicService, CONTEXT_WINDOW_RETRY_TEXT, SCENARIO_PREFIX, STREAM_THEN_ERROR_TEXT,
|
||||
};
|
||||
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
|
||||
use serde_json::Value;
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -191,199 +189,6 @@ fn text_prompt_mode_streams_assistant_text_once_before_spinner_completion() {
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_prompt_mode_emits_the_response_body_exactly_once() {
|
||||
// given a workspace pointed at the mock Anthropic service running the
|
||||
// streaming_text scenario, which emits a single assistant text block
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let workspace = unique_temp_dir("text-prompt-once");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
// when we invoke claw in normal text prompt mode
|
||||
let prompt = format!("{SCENARIO_PREFIX}streaming_text");
|
||||
let output = run_claw(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&base_url,
|
||||
&[
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
&prompt,
|
||||
],
|
||||
);
|
||||
|
||||
// then the response body reaches stdout exactly once: the emitter owns the
|
||||
// terminal while the turn runs, so the caller must not print the assistant
|
||||
// text a second time
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"text prompt run should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let plain_stdout = strip_ansi_codes(&stdout);
|
||||
assert_eq!(
|
||||
plain_stdout
|
||||
.matches("Mock streaming says hello from the parity harness.")
|
||||
.count(),
|
||||
1,
|
||||
"text prompt stdout should carry the response body exactly once ({stdout:?})"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_prompt_mode_emits_retried_body_once_after_auto_compact() {
|
||||
// given a mock service whose first answer to this scenario is a context
|
||||
// window rejection and whose answer to the retry is a streamed response
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let workspace = unique_temp_dir("text-prompt-auto-compact-retry");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
// when claw is rejected for context window overflow and recovers by
|
||||
// compacting the session and retrying the same prompt
|
||||
let prompt = format!("{SCENARIO_PREFIX}context_window_retry");
|
||||
let output = run_claw(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&base_url,
|
||||
&[
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
&prompt,
|
||||
],
|
||||
);
|
||||
|
||||
// then the retry succeeds and its response body is on stdout exactly once.
|
||||
// The recovery path builds a fresh runtime with its own terminal tail, so
|
||||
// this is where a tail that only tracked the first attempt would leave the
|
||||
// streamed line unterminated and let `spinner.finish` clear it.
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"auto-compact retry should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let plain_stdout = strip_ansi_codes(&stdout);
|
||||
assert_eq!(
|
||||
plain_stdout.matches(CONTEXT_WINDOW_RETRY_TEXT).count(),
|
||||
1,
|
||||
"auto-compact retry stdout should carry the response body exactly once ({stdout:?})"
|
||||
);
|
||||
assert!(
|
||||
plain_stdout
|
||||
.lines()
|
||||
.any(|line| line == CONTEXT_WINDOW_RETRY_TEXT),
|
||||
"the retried response body should land on its own line ({stdout:?})"
|
||||
);
|
||||
assert!(
|
||||
plain_stdout.contains("✨ Done (after auto-compact)"),
|
||||
"the retry should report the auto-compact completion ({stdout:?})"
|
||||
);
|
||||
|
||||
// and the scenario really was requested twice, so the retry path ran
|
||||
let captured = runtime.block_on(server.captured_requests());
|
||||
let messages: Vec<_> = captured
|
||||
.iter()
|
||||
.filter(|request| request.path == "/v1/messages")
|
||||
.collect();
|
||||
assert!(
|
||||
messages.len() >= 2,
|
||||
"the scenario should be requested twice so the retry is exercised ({captured:?})"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_prompt_mode_keeps_streamed_text_when_the_turn_fails() {
|
||||
// given a mock service that streams part of an answer and then fails the
|
||||
// response, so the turn ends with text already rendered on the terminal
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let workspace = unique_temp_dir("text-prompt-stream-then-error");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
// when the turn fails after the emitter already streamed a partial line
|
||||
let prompt = format!("{SCENARIO_PREFIX}stream_then_error");
|
||||
let output = run_claw(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&base_url,
|
||||
&[
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
&prompt,
|
||||
],
|
||||
);
|
||||
|
||||
// then the failed turn is reported...
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"a mid-stream failure should fail the run\nstdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let plain_stdout = strip_ansi_codes(&stdout);
|
||||
|
||||
// ...and the text the emitter already streamed survives the failure banner.
|
||||
// `spinner.fail` moves to column 0 and clears the current line, so a caller
|
||||
// that does not know a partial line is pending erases that text instead of
|
||||
// printing the banner underneath it. This is the failure arm of the same
|
||||
// ownership rule the success arm follows.
|
||||
assert!(
|
||||
plain_stdout
|
||||
.lines()
|
||||
.any(|line| line == STREAM_THEN_ERROR_TEXT),
|
||||
"the streamed text should keep its own line despite the failure ({stdout:?})"
|
||||
);
|
||||
assert!(
|
||||
plain_stdout.contains("Request failed"),
|
||||
"the failure banner should still be reported ({stdout:?})"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_flag_with_json_output_emits_structured_json() {
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
|
||||
Reference in new issue
Block a user