Merge pull request #6725 from Hmbown/fix/l2-tui-cli-messages

fix(tui): actionable CLI error messages (doctor, exec, stream, review)
This commit is contained in:
Hunter Bown authored and GitHub committed 2026-09-29 02:58:36 -07:00
commit ee1d8657ab
14 files changed
+1035 -137

No files matched your search

+179 -5
View File
@@ -229,11 +229,97 @@ fn h1_fallback_error(err: anyhow::Error) -> anyhow::Error {
return err;
}
anyhow::Error::new(LlmError::NetworkError(format!(
"SSE stream request failed after HTTP/1.1 fallback: {err}. \
`codewhale doctor` can still pass when non-streaming requests work; \
on Windows or proxy networks, try `CODEWHALE_FORCE_HTTP1=1` and rerun `codewhale`."
)))
let detail = format!("{err:#}");
if let Some(unreachable) = connect_failure_summary(&err) {
return anyhow::Error::new(LlmError::NetworkError(format!("{unreachable}: {detail}")));
}
let mut message = format!("SSE stream request failed after HTTP/1.1 fallback: {detail}.");
// The HTTP/1.1 hint only helps when the protocol or TLS layer failed; it
// is noise for a refused connection or an unknown host.
if has_tls_cause(&err) || is_protocol_or_tls_failure(&detail) {
message.push_str(
" `codewhale doctor` can still pass when non-streaming requests work; \
on Windows or proxy networks, try `CODEWHALE_FORCE_HTTP1=1` and rerun `codewhale`.",
);
}
anyhow::Error::new(LlmError::NetworkError(message))
}
/// `Cannot reach host:port (<why>)` for a failure to open the connection.
/// reqwest also reports TLS handshake and certificate failures as connect
/// errors; those return `None` so they keep the HTTP/1.1 and proxy hint.
fn connect_failure_summary(err: &anyhow::Error) -> Option<String> {
let reqwest_error = err
.chain()
.find_map(|cause| cause.downcast_ref::<reqwest::Error>())
.filter(|error| error.is_connect())?;
// Classify on the underlying causes only: reqwest's own message embeds
// the request URL, whose host or path could contain "dns" or "tls".
let mut causes = String::new();
let mut source = std::error::Error::source(reqwest_error);
while let Some(cause) = source {
causes.push_str(&cause.to_string().to_ascii_lowercase());
causes.push('\n');
source = cause.source();
}
if has_tls_cause(err) || is_protocol_or_tls_failure(&causes) {
return None;
}
let target = reqwest_error
.url()
.and_then(|url| {
Some(format!(
"{}:{}",
url.host_str()?,
url.port_or_known_default()?
))
})
.unwrap_or_else(|| "the provider host".to_string());
let why = if causes.contains("refused") {
"connection refused"
} else if causes.contains("dns") || causes.contains("lookup") {
"DNS lookup failed"
} else {
"connection failed"
};
// With a proxy configured, the host that refused may be the proxy.
let via_proxy = [
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
"HTTP_PROXY",
"http_proxy",
]
.iter()
.any(|name| std::env::var_os(name).is_some_and(|value| !value.is_empty()));
Some(if via_proxy {
format!("Cannot reach {target} or the configured proxy ({why})")
} else {
format!("Cannot reach {target} ({why})")
})
}
fn has_tls_cause(err: &anyhow::Error) -> bool {
err.chain().any(|mut cause| {
// hyper-rustls wraps tokio-rustls's IO error in another IO error;
// Error::source skips those inners, so inspect get_ref explicitly.
while let Some(inner) = cause
.downcast_ref::<std::io::Error>()
.and_then(std::io::Error::get_ref)
{
cause = inner;
}
cause.is::<rustls::Error>()
})
}
fn is_protocol_or_tls_failure(detail: &str) -> bool {
let lower = detail.to_ascii_lowercase();
should_retry_with_h1(StreamHttpPolicy::DualWithH1Fallback, &lower)
|| ["tls", "ssl", "certificate", "handshake", "alpn"]
.iter()
.any(|needle| lower.contains(needle))
}
/// Open an SSE response through the shared transport policy.
@@ -505,6 +591,94 @@ mod tests {
assert!(err.to_string().contains("connection reset"), "{err}");
}
#[tokio::test]
async fn refused_connection_names_the_host_and_skips_the_http1_hint() {
let port = {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
listener.local_addr().expect("addr").port()
};
let client = crate::tls::reqwest_client();
let url = format!("http://127.0.0.1:{port}/v1/chat");
let err = open_sse_response(
&open_req(StreamHttpPolicy::DualWithH1Fallback, Duration::from_secs(5)),
|_| {
let request = client.post(&url);
async move { Ok::<_, anyhow::Error>(request.send().await?) }
},
)
.await
.expect_err("nothing listens on the port");
let text = err.to_string();
assert!(
text.contains(&format!("Cannot reach 127.0.0.1:{port}"))
&& text.contains("(connection refused)"),
"{text}"
);
assert!(!text.contains("CODEWHALE_FORCE_HTTP1"), "{text}");
assert!(
err.downcast_ref::<LlmError>()
.is_some_and(LlmError::is_retryable),
"{err:#}"
);
}
#[tokio::test]
async fn tls_failure_during_connect_keeps_the_http1_hint() {
// A plain-TCP peer on an https URL fails the TLS handshake, which
// reqwest reports as a connect error; it must not read as
// "Cannot reach" and must keep the FORCE_HTTP1/proxy hint.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let port = listener.local_addr().expect("addr").port();
let server = tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
while let Ok((mut socket, _)) = listener.accept().await {
let _ = socket.write_all(b"HTTP/1.1 400 Not TLS\r\n\r\n").await;
// Keep reading until the client closes. Dropping a socket with
// unread ClientHello bytes can send a TCP reset on Windows,
// hiding the TLS protocol error this fixture is meant to test.
let _ = tokio::io::copy(&mut socket, &mut tokio::io::sink()).await;
}
});
crate::tls::ensure_rustls_crypto_provider();
let client = crate::tls::reqwest_client();
let url = format!("https://127.0.0.1:{port}/v1/chat");
let err = open_sse_response(
&open_req(StreamHttpPolicy::DualWithH1Fallback, Duration::from_secs(5)),
|_| {
let request = client.post(&url);
async move { Ok::<_, anyhow::Error>(request.send().await?) }
},
)
.await
.expect_err("the peer does not speak TLS");
server.abort();
let text = err.to_string();
assert!(!text.contains("Cannot reach"), "{text}");
assert!(text.contains("CODEWHALE_FORCE_HTTP1=1"), "{text}");
}
#[test]
fn h1_fallback_hint_follows_protocol_errors_with_the_full_chain() {
let protocol = h1_fallback_error(
anyhow::Error::new(LlmError::NetworkError("http2 protocol error".to_string()))
.context("sending stream request"),
);
let text = protocol.to_string();
assert!(text.contains("sending stream request: "), "{text}");
assert!(text.contains("http2 protocol error"), "{text}");
assert!(text.contains("CODEWHALE_FORCE_HTTP1=1"), "{text}");
let other = h1_fallback_error(anyhow::Error::new(LlmError::NetworkError(
"unexpected end of body".to_string(),
)));
assert!(
!other.to_string().contains("CODEWHALE_FORCE_HTTP1"),
"{other}"
);
}
#[tokio::test]
async fn provider_error_from_h1_fallback_keeps_its_semantic_type() {
let attempts = Arc::new(AtomicUsize::new(0));
+75 -10
View File
@@ -2107,17 +2107,44 @@ fn one_line(value: &str, max: usize) -> String {
}
}
/// Sanitized (control-character-free, bounded) error text for job notes.
const SANITIZED_ERROR_MAX_CHARS: usize = 240;
/// Sanitized (single-line, secret-redacted, bounded) error text for job
/// notes. Controls, newlines and tabs become one space so words never fuse;
/// long text keeps its head and its tail, because harness and command output
/// put the actual failure last.
pub fn sanitize_error(message: &str) -> String {
redact_machine_tokens(&redact_url_userinfo(
&message
.chars()
.filter(|ch| !ch.is_control())
.collect::<String>(),
))
.chars()
.take(240)
.collect()
fn redact(text: &str) -> String {
redact_machine_tokens(&redact_url_userinfo(text))
}
fn flatten(text: &str) -> String {
text.split(|ch: char| ch.is_control() || ch.is_whitespace())
.filter(|word| !word.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
// A secret split by a control character (a newline, BEL, an ANSI
// fragment) must still be caught, so redaction first sees the text with
// every control removed, which rejoins it. Only when that finds nothing
// do controls become spaces for readability; flattening only inserts
// separators, so it cannot expose a token the rejoined text did not.
let fused: String = message.chars().filter(|ch| !ch.is_control()).collect();
let fused_redacted = redact(&fused);
// Redact before cutting so a secret can never be split past the redactor.
let redacted = if fused_redacted == fused {
redact(&flatten(message))
} else {
flatten(&fused_redacted)
};
let count = redacted.chars().count();
if count <= SANITIZED_ERROR_MAX_CHARS {
return redacted;
}
let head_chars = SANITIZED_ERROR_MAX_CHARS / 3;
let tail_chars = SANITIZED_ERROR_MAX_CHARS - head_chars - 1;
let head: String = redacted.chars().take(head_chars).collect();
let tail: String = redacted.chars().skip(count - tail_chars).collect();
format!("{}…{}", head.trim_end(), tail.trim_start())
}
/// Replace anything shaped like a Codewhale account machine token with its
@@ -2576,6 +2603,44 @@ mod tests {
);
}
#[test]
fn sanitize_error_redacts_a_token_split_by_a_control_character() {
let head = "0123456789abcdef01234567";
for split in ['\u{7}', '\n', '\u{1b}'] {
let message = format!("push failed: cwc_key_{head}{split}tailsecret0123456789 done");
let sanitized = sanitize_error(&message);
assert!(!sanitized.contains("tailsecret"), "{sanitized}");
assert!(sanitized.contains("[redacted]"), "{sanitized}");
}
}
#[test]
fn sanitize_error_flattens_whitespace_and_keeps_the_tail() {
assert_eq!(
sanitize_error("npm ERR!\tcode 1\n\n npm ERR!\u{7} missing\r\nscript"),
"npm ERR! code 1 npm ERR! missing script"
);
let long = format!(
"{} error: the real failure is here",
"progress line\n".repeat(60)
);
let sanitized = sanitize_error(&long);
assert!(
sanitized.chars().count() <= SANITIZED_ERROR_MAX_CHARS,
"{sanitized}"
);
assert!(
sanitized.starts_with("progress line progress line"),
"{sanitized}"
);
assert!(sanitized.contains('…'), "{sanitized}");
assert!(
sanitized.ends_with("error: the real failure is here"),
"{sanitized}"
);
assert!(!sanitized.contains('\n'), "{sanitized}");
}
#[test]
fn snapshot_names_are_slug_charset_and_bounded() {
assert!(valid_snapshot_name("codewhale-cloud-agent"));
+186 -34
View File
@@ -5132,10 +5132,7 @@ impl Config {
.and_then(|providers| providers.custom_provider_config(provider))
.is_none()
{
anyhow::bail!(
"Invalid provider '{provider}': expected {}.",
ApiProvider::names_hint()
);
return Err(invalid_provider_diagnostic(provider).into());
}
let active_provider = self.api_provider();
match validate_kimi_code_api_model_id(
@@ -5191,10 +5188,16 @@ impl Config {
} else {
format!(" (for example: {})", known.join(", "))
};
anyhow::bail!(
"Invalid configured model '{model}' for provider '{}': expected auto or a model ID this provider serves{hint}.",
provider.as_str()
);
return Err(SafeConfigDiagnostic::invalid_value(
"model",
model,
&format!(
"auto or a model ID provider '{}' serves{hint}",
provider.as_str()
),
user_config_fix("model", "auto", Some("a *_MODEL environment variable")),
)
.into());
}
if let Some(policy) = self.approval_policy.as_deref() {
let normalized = policy.trim().to_ascii_lowercase();
@@ -5202,15 +5205,29 @@ impl Config {
normalized.as_str(),
"on-request" | "untrusted" | "never" | "auto" | "suggest"
) {
anyhow::bail!(
"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest."
);
return Err(SafeConfigDiagnostic::invalid_value(
"approval_policy",
policy,
"on-request, untrusted, never, auto, or suggest",
user_config_fix(
"approval_policy",
"on-request",
Some("CODEWHALE_APPROVAL_POLICY"),
),
)
.into());
}
}
if let Some(v) = self.verbosity.as_deref() {
let normalized = v.trim().to_ascii_lowercase();
if !matches!(normalized.as_str(), "normal" | "concise") {
anyhow::bail!("Invalid verbosity '{v}': expected normal or concise.");
return Err(SafeConfigDiagnostic::invalid_value(
"verbosity",
v,
"normal or concise",
user_config_fix("verbosity", "normal", None),
)
.into());
}
}
if let Some(mode) = self.sandbox_mode.as_deref() {
@@ -5219,9 +5236,17 @@ impl Config {
normalized.as_str(),
"read-only" | "workspace-write" | "danger-full-access" | "external-sandbox"
) {
anyhow::bail!(
"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox."
);
return Err(SafeConfigDiagnostic::invalid_value(
"sandbox_mode",
mode,
"read-only, workspace-write, danger-full-access, or external-sandbox",
user_config_fix(
"sandbox_mode",
"workspace-write",
Some("CODEWHALE_SANDBOX_MODE"),
),
)
.into());
}
}
if let Some(tui) = &self.tui
@@ -5229,9 +5254,13 @@ impl Config {
{
let mode = mode.to_ascii_lowercase();
if !matches!(mode.as_str(), "auto" | "always" | "never") {
anyhow::bail!(
"Invalid tui.alternate_screen '{mode}': expected auto, always, or never."
);
return Err(SafeConfigDiagnostic::invalid_value(
"tui.alternate_screen",
&mode,
"auto, always, or never",
user_config_fix("tui.alternate_screen", "auto", None),
)
.into());
}
}
if let Some(transcript) = &self.transcript
@@ -5546,6 +5575,29 @@ impl Config {
Ok(identity)
}
/// Resolve a provider a user is selecting now (`codewhale config set
/// provider <name>`). A name that is neither a built-in provider nor a
/// configured table is a typo, not a saved session missing its route, so
/// it gets config validation's wording instead of the resume wording.
pub(crate) fn resolve_provider_selection_identity(
&self,
provider_id: &str,
) -> std::result::Result<ProviderIdentity, String> {
let requested = provider_id.trim();
if !requested.is_empty()
&& ApiProvider::parse(requested).is_none()
&& !requested.eq_ignore_ascii_case(ApiProvider::Custom.as_str())
&& self
.providers
.as_ref()
.and_then(|providers| providers.custom_provider_config(requested))
.is_none()
{
return Err(invalid_provider_message(requested));
}
self.resolve_provider_pin_identity(provider_id)
}
/// Resolve an additive exact provider id. Unlike raw selector resolution,
/// an id means the record requires that exact `[providers.<id>]` table.
fn resolve_exact_provider_identity(
@@ -7264,21 +7316,9 @@ impl Config {
.credential_url()
.unwrap_or("https://app.codewhale.net/settings?section=api")
),
ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(
"DeepSeek API key not found.\n\
\n\
1. Get a key: https://platform.deepseek.com/api_keys\n\
2. Save it (works in every folder, no OS prompts):\n\
codewhale auth set --provider deepseek\n\
\n\
Alternatives:\n\
• export DEEPSEEK_API_KEY=<your-key> (current shell only;\n\
also note: zsh users — exports in ~/.zshrc only reach interactive\n\
shells, prefer ~/.zshenv for everything)\n\
• api_key = \"<your-key>\" in ~/.codewhale/config.toml\n\
• already configured DeepSeek Harness? grant read-only access:\n\
codewhale auth external-consent --provider deepseek --mode read-only"
),
ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
anyhow::bail!(deepseek_missing_key_message())
}
ApiProvider::SiliconflowCn => anyhow::bail!(
"SiliconFlow China API key not found. Get a key: {}. Run 'codewhale auth set --provider siliconflow-CN', \
set {}, or add [{}] api_key in ~/.codewhale/config.toml. \
@@ -10682,6 +10722,23 @@ pub(crate) fn is_kimi_code_membership_model(model: &str) -> bool {
.any(|id| model.eq_ignore_ascii_case(id))
}
/// Keep the recovery command visible in small terminals. The optional DSH
/// advice is conditional prose: producing an error must not inspect PATH or
/// another application's credential file on the runtime thread.
fn deepseek_missing_key_message() -> &'static str {
concat!(
"DeepSeek API key not found.\n",
"Save a key for every folder:\n",
" codewhale auth set --provider deepseek\n",
"Get a key: https://platform.deepseek.com/api_keys\n",
"Or export DEEPSEEK_API_KEY=<your-key> (this shell only).\n",
"zsh: ~/.zshrc is interactive only; use ~/.zshenv.\n",
"Or set api_key in ~/.codewhale/config.toml.\n",
"If you already use DeepSeek Harness, grant read-only access:\n",
" codewhale auth external-consent --provider deepseek --mode read-only"
)
}
/// The Moonshot direct-platform roster, as one fact. Mirror of
/// [`KIMI_CODE_MEMBERSHIP_MODELS`] for the pay-as-you-go product.
pub(crate) const MOONSHOT_DIRECT_PLATFORM_MODELS: [&str; 3] = [
@@ -10692,10 +10749,93 @@ pub(crate) const MOONSHOT_DIRECT_PLATFORM_MODELS: [&str; 3] = [
pub(crate) const KIMI_CODE_CLAUDE_ALIAS_GUIDANCE: &str = "Kimi Code model `k3[1m]` is a Claude Code environment convention, not an API model id. Use model = \"k3\". If your Kimi Code plan includes 1M context, also set context_window = 1048576; otherwise keep the 262144 safe default.";
/// Configuration errors whose text is safe to show in diagnostics such as
/// `codewhale doctor`. Everything else stays suppressed there because parse
/// errors and credential fields can echo secret material.
#[derive(Debug, thiserror::Error)]
pub(crate) enum SafeConfigDiagnostic {
#[error("{}", KIMI_CODE_CLAUDE_ALIAS_GUIDANCE)]
KimiCodeClaudeAlias,
/// A plain enum/value validation failure on a non-credential key.
/// `message` quotes the rejected value for the local error; `shareable`
/// omits it, because a mistyped value can still be a pasted secret.
#[error("{message}")]
InvalidValue {
message: String,
shareable: String,
/// A `codewhale config set <key> <valid>` command, when one fixes it.
fix: Option<String>,
},
}
impl SafeConfigDiagnostic {
fn invalid_value(key: &str, value: &str, expected: &str, fix: String) -> Self {
Self::InvalidValue {
message: format!("Invalid {key} '{value}': expected {expected}."),
shareable: format!("Invalid {key} (value not shown): expected {expected}."),
fix: Some(fix),
}
}
/// The diagnostic text for reports such as `codewhale doctor`, without
/// the rejected value and redacted defensively.
pub(crate) fn display_message(&self) -> String {
let text = match self {
Self::KimiCodeClaudeAlias => self.to_string(),
Self::InvalidValue { shareable, .. } => shareable.clone(),
};
codewhale_secrets::redact::redact_secrets(&text)
}
pub(crate) fn fix(&self) -> Option<&str> {
match self {
Self::KimiCodeClaudeAlias => None,
Self::InvalidValue { fix, .. } => fix.as_deref(),
}
}
/// Find a safe diagnostic anywhere in an error chain (loaders wrap
/// validation errors in file-path context).
pub(crate) fn find_in(error: &anyhow::Error) -> Option<&Self> {
error.chain().find_map(|cause| cause.downcast_ref::<Self>())
}
}
/// How to correct a rejected value in the user config. `config set` does not
/// take dotted keys, so a table field names the table to edit. Validation runs
/// after the environment, profile and managed layers are applied, and any of
/// them outranks the user config, so the fix names those layers too.
fn user_config_fix(key: &str, valid: &str, env_var: Option<&str>) -> String {
let edit = match key.split_once('.') {
Some((table, field)) => {
format!("set {field} = \"{valid}\" in the [{table}] table of config.toml")
}
None => format!("codewhale config set {key} {valid}"),
};
let layers = match env_var {
Some(env_var) => format!("{env_var}, a profile, or managed config"),
None => "a profile or managed config".to_string(),
};
format!("{edit} (if {layers} sets it, correct it there)")
}
fn invalid_provider_diagnostic(provider: &str) -> SafeConfigDiagnostic {
SafeConfigDiagnostic::invalid_value(
"provider",
provider,
&ApiProvider::names_hint(),
user_config_fix(
"provider",
ApiProvider::Deepseek.as_str(),
Some("CODEWHALE_PROVIDER"),
),
)
}
/// The one wording for an unknown provider name, shared by config validation
/// and `config set provider`.
pub(crate) fn invalid_provider_message(provider: &str) -> String {
invalid_provider_diagnostic(provider).to_string()
}
/// Fail closed on known-bad model/endpoint pairings (#4687).
@@ -11072,7 +11212,19 @@ fn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result<Config> {
}
})
.unwrap_or_else(|| "none".to_string());
anyhow::bail!("Profile '{profile_name}' not found. Available profiles: {available}")
// Profile names are user-typed (`--profile`), so the shareable
// text omits the requested one like every other InvalidValue;
// the available names are config table keys, not values.
Err(SafeConfigDiagnostic::InvalidValue {
message: format!(
"Profile '{profile_name}' not found. Available profiles: {available}"
),
shareable: format!(
"Profile not found (name not shown). Available profiles: {available}"
),
fix: None,
}
.into())
}
}
} else {
+97
View File
@@ -15521,3 +15521,100 @@ fn no_parse_leaves_a_top_level_key_behind() -> Result<()> {
assert!(!codewhale_config::legacy_root::has_legacy_root_keys(&table));
Ok(())
}
#[test]
fn config_set_provider_typo_reuses_the_invalid_provider_wording() {
let config = Config::default();
let error = config
.resolve_provider_selection_identity("deepsek")
.expect_err("a typo is not a provider");
assert!(
error.starts_with("Invalid provider 'deepsek': expected deepseek"),
"{error}"
);
assert!(!error.contains("saved session"), "{error}");
// The resume path keeps its own saved-session wording.
let resume = config
.resolve_provider_pin_identity("deepsek")
.expect_err("missing custom route");
assert!(resume.starts_with("saved session requires"), "{resume}");
assert_eq!(
config
.resolve_provider_selection_identity("deepseek")
.expect("built-in provider")
.provider,
ApiProvider::Deepseek
);
}
#[test]
fn deepseek_missing_key_message_keeps_commands_copyable_and_harness_advice_conditional() {
let plain = deepseek_missing_key_message();
assert!(
plain.contains("\n codewhale auth set --provider deepseek\n"),
"{plain}"
);
assert!(
plain.contains("If you already use DeepSeek Harness, grant read-only access:"),
"{plain}"
);
assert!(
plain.ends_with("\n codewhale auth external-consent --provider deepseek --mode read-only"),
"{plain}"
);
}
#[test]
fn invalid_value_fixes_are_commands_that_work_and_name_overriding_layers() {
let error = Config {
tui: Some(TuiConfig {
alternate_screen: Some("sometimes".to_string()),
..TuiConfig::default()
}),
..Config::default()
}
.validate()
.expect_err("unknown alternate_screen");
let diagnostic = SafeConfigDiagnostic::find_in(&error).expect("safe diagnostic");
// `config set` refuses dotted keys, so the fix edits the table instead.
assert_eq!(
diagnostic.fix(),
Some(
"set alternate_screen = \"auto\" in the [tui] table of config.toml (if a profile or managed config sets it, correct it there)"
)
);
let error = Config {
sandbox_mode: Some("bogus".to_string()),
..Config::default()
}
.validate()
.expect_err("unknown sandbox_mode");
let fix = SafeConfigDiagnostic::find_in(&error)
.and_then(SafeConfigDiagnostic::fix)
.expect("fix");
assert!(
fix.starts_with("codewhale config set sandbox_mode workspace-write (if CODEWHALE_SANDBOX_MODE, a profile, or managed config sets it"),
"{fix}"
);
}
#[test]
fn missing_profile_diagnostic_does_not_show_the_requested_name() {
let mut profiles = HashMap::new();
profiles.insert("work".to_string(), Config::default());
let config = ConfigFile {
base: Box::default(),
profiles: Some(profiles),
legacy_root: Default::default(),
};
let error = apply_profile(config, Some("sk-pasted-token")).expect_err("no such profile");
let diagnostic = SafeConfigDiagnostic::find_in(&error).expect("safe diagnostic");
let shown = diagnostic.display_message();
assert_eq!(
shown,
"Profile not found (name not shown). Available profiles: work"
);
// The local error keeps the typed name for the person at the terminal.
assert!(error.to_string().contains("sk-pasted-token"), "{error}");
}
+1 -7
View File
@@ -759,13 +759,7 @@ pub(super) fn is_context_length_error_message(message: &str) -> bool {
let lower = message.to_lowercase();
lower.contains("model output truncated")
|| lower.contains("model response incomplete")
|| lower.contains("maximum context length")
|| lower.contains("context length")
|| lower.contains("context_length")
|| lower.contains("prompt is too long")
|| lower.contains("context window")
// llama.cpp: "the request exceeds the available context size".
|| lower.contains("available context size")
|| crate::llm_client::is_context_length_message(&lower)
|| (lower.contains("requested") && lower.contains("tokens") && lower.contains("maximum"))
}
+5 -1
View File
@@ -388,7 +388,7 @@ pub(crate) async fn run_exec_agent(
Some(&effective_model),
)
.map_err(anyhow::Error::msg)?
.validate()
.validate_for(crate::route_runtime::RouteErrorSurface::Headless)
.map_err(anyhow::Error::msg)?;
let effective_provider_name = validated_route.identity.key.clone();
let effective_provider_id = validated_route.identity.exact_id.clone();
@@ -1487,6 +1487,9 @@ pub(crate) async fn run_exec_agent(
// the process level without parsing the stream. Genuine failures
// keep the historical `bail!` → exit 1 path.
let exit_code = exec_failure_exit_code(summary.error_category.as_deref());
// The final line always carries the message: automation greps it and
// a caller may keep only the last stderr line, even when the stream
// already printed the same error above.
if exit_code != 1 {
eprintln!("Error: exec turn failed: {error}");
let _ = io::stdout().flush();
@@ -1509,6 +1512,7 @@ pub(crate) async fn run_exec_agent(
#[cfg(test)]
mod tests {
use super::{ExecAgentEvents, exec_automation_services, exec_disallowed_tools};
use crate::core::engine::mock_engine_handle;
use crate::core::engine::tool_catalog::REQUEST_USER_INPUT_NAME;
use crate::core::events::{Event, TurnOutcomeStatus};
+195 -34
View File
@@ -1100,12 +1100,20 @@ fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result<O
}
fn load_exec_resume_session(session_id: &str) -> Result<session_manager::SavedSession> {
let session_ref = exec_stream_session_ref(session_id);
SessionManager::default_location()
.context("could not open session manager for resume")?
.resume_session_by_prefix(session_id)
.map(|recovery| recovery.session)
.with_context(|| format!("could not load session {session_ref}"))
.with_context(|| exec_resume_load_error(session_id))
}
/// The typed `--resume` value stays redacted in every output mode: exec runs
/// in CI logs, and a mistyped or pasted value can be a secret.
fn exec_resume_load_error(session_id: &str) -> String {
format!(
"could not load session {}. Run `codewhale sessions` to list ids.",
exec_stream_session_ref(session_id)
)
}
/// Select the route for `exec --resume` before any engine/client is built.
@@ -2381,11 +2389,7 @@ async fn run_async_main_dispatch(
let config = match load_doctor_config_from_cli(&cli, &args) {
Ok(config) => config,
Err(error) if args.json => return run_doctor_json_config_error(&error),
Err(_) => {
bail!(
"doctor configuration validation failed; details omitted because configuration errors may contain credential material"
)
}
Err(error) => bail!(doctor_config_error_text(&error)),
};
let workspace = resolve_workspace(&cli);
if args.repair_sessions {
@@ -4698,7 +4702,7 @@ async fn run_doctor(
println!("{}", "==================".truecolor(sky_r, sky_g, sky_b));
// Verdict first (U7): the answer and the next step, before the detail.
let (verdict_state, _) = doctor_setup_state(config, workspace);
let verdict = doctor_verdict(&verdict_state);
let verdict = doctor_verdict(&verdict_state, config.api_provider().as_str());
println!("{}", verdict.truecolor(aqua_r, aqua_g, aqua_b).bold());
println!();
@@ -5688,24 +5692,28 @@ async fn run_doctor(
}
/// Doctor's one-line answer: ready, or the single next step (U7). Readiness
/// is the setup lane's own verdict; doctor never probes credential values to
/// decide it.
fn doctor_verdict(state: &codewhale_config::SetupState) -> &'static str {
// NeedsAction means a named route needs repair (missing credentials or a
// failed check). Configured routes can be used without a prior probe.
/// is the setup lane's own verdict; doctor never reads the environment or the
/// secret store to decide it, so a key saved outside setup shows as an
/// unverified route (`credential: availability=not_probed`), not a missing one.
fn doctor_verdict(state: &codewhale_config::SetupState, provider: &str) -> String {
use codewhale_config::StepStatus;
// NeedsAction means a named route exists but its key is missing, unchecked
// or failed. Configured routes can be used without a prior probe.
// `first_run_ready` accepts NeedsAction (a failed key still reaches the
// wizard's ready screen), so check the provider first: finished setup
// with a route needing repair is not "Ready".
let provider_configured = matches!(
state.status(codewhale_config::SetupStep::ProviderModel),
codewhale_config::StepStatus::Configured | codewhale_config::StepStatus::Verified
);
if !provider_configured {
"Not ready: no model provider set up → run /provider in Codewhale, or `codewhale setup`."
} else if state.first_run_ready() {
"Ready: setup is complete."
} else {
"Not ready: first-run setup is unfinished → run `codewhale setup`."
// wizard's ready screen), so check the provider first.
match state.status(codewhale_config::SetupStep::ProviderModel) {
StepStatus::Configured | StepStatus::Verified => {
if state.first_run_ready() {
"Ready: setup is complete.".to_string()
} else {
"Not ready: first-run setup is unfinished → run `codewhale setup`.".to_string()
}
}
StepStatus::NeedsAction => format!(
"Not ready: the {provider} route has no verified key → save one with /provider in Codewhale or `codewhale auth set --provider {provider}`; `codewhale doctor --probe-api` checks a key already saved."
),
_ => "Not ready: no model provider set up → run /provider in Codewhale, or `codewhale setup`."
.to_string(),
}
}
@@ -5713,11 +5721,32 @@ fn doctor_verdict(state: &codewhale_config::SetupState) -> &'static str {
mod doctor_verdict_tests {
#[test]
fn a_fresh_home_is_not_ready_and_names_the_provider_step() {
let verdict = super::doctor_verdict(&codewhale_config::SetupState::default());
let verdict = super::doctor_verdict(&codewhale_config::SetupState::default(), "deepseek");
assert!(verdict.starts_with("Not ready"), "{verdict}");
assert!(verdict.contains("/provider"), "{verdict}");
}
#[test]
fn a_route_without_a_verified_key_is_not_called_missing() {
// A fresh home derives NeedsAction for the default route because
// doctor does not read saved keys; the verdict must not claim there is
// no provider, and names both the headless fix and the probe.
use codewhale_config::{SetupState, SetupStep, StepEntry, StepStatus};
let mut state = SetupState::default();
state.set_step(
SetupStep::ProviderModel,
StepEntry::new(StepStatus::NeedsAction, true, "inherited"),
);
let verdict = super::doctor_verdict(&state, "deepseek");
assert!(verdict.starts_with("Not ready"), "{verdict}");
assert!(!verdict.contains("no model provider"), "{verdict}");
assert!(
verdict.contains("`codewhale auth set --provider deepseek`"),
"{verdict}"
);
assert!(verdict.contains("--probe-api"), "{verdict}");
}
#[test]
fn finished_setup_with_an_unconfirmed_key_is_not_ready() {
use codewhale_config::{
@@ -5735,7 +5764,7 @@ mod doctor_verdict_tests {
state.runtime_posture_source = RuntimePostureSource::Confirmed;
state.constitution_choice = ConstitutionChoice::Bundled;
assert!(state.first_run_ready(), "fixture must be wizard-ready");
let verdict = super::doctor_verdict(&state);
let verdict = super::doctor_verdict(&state, "deepseek");
assert!(verdict.starts_with("Not ready"), "{verdict}");
assert!(verdict.contains("/provider"), "{verdict}");
@@ -5743,7 +5772,10 @@ mod doctor_verdict_tests {
SetupStep::ProviderModel,
StepEntry::new(StepStatus::Verified, true, "0.10.1"),
);
assert_eq!(super::doctor_verdict(&state), "Ready: setup is complete.");
assert_eq!(
super::doctor_verdict(&state, "deepseek"),
"Ready: setup is complete."
);
}
}
@@ -7313,15 +7345,35 @@ fn run_doctor_repair_sessions(dry_run: bool) -> Result<()> {
Ok(())
}
const DOCTOR_CONFIG_ERROR_OMITTED: &str = "configuration validation failed; details omitted because configuration errors may contain credential material";
/// Human doctor text for a config load failure. Plain value/profile
/// validation errors are shown with their fix; anything else (parse errors,
/// credential fields) stays suppressed because it may echo secret material.
fn doctor_config_error_text(error: &anyhow::Error) -> String {
let Some(diagnostic) = crate::config::SafeConfigDiagnostic::find_in(error) else {
return format!("doctor {DOCTOR_CONFIG_ERROR_OMITTED}");
};
let mut text = format!(
"doctor configuration validation failed: {}",
diagnostic.display_message()
);
if let Some(fix) = diagnostic.fix() {
text.push_str("\nfix: ");
text.push_str(fix);
}
text
}
fn run_doctor_json_config_error(error: &anyhow::Error) -> Result<()> {
let safe_message = error
.downcast_ref::<crate::config::SafeConfigDiagnostic>()
.map(ToString::to_string);
let diagnostic = crate::config::SafeConfigDiagnostic::find_in(error);
let safe_message = diagnostic.map(crate::config::SafeConfigDiagnostic::display_message);
let report = serde_json::json!({
"status": "error",
"error": {
"kind": "config_validation",
"message": safe_message.as_deref().unwrap_or("configuration validation failed; details omitted because configuration errors may contain credential material"),
"message": safe_message.as_deref().unwrap_or(DOCTOR_CONFIG_ERROR_OMITTED),
"fix": diagnostic.and_then(crate::config::SafeConfigDiagnostic::fix),
},
});
println!("{}", serde_json::to_string_pretty(&report)?);
@@ -10380,12 +10432,15 @@ fn collect_diff(
cmd.arg("--").arg(path);
}
ensure_review_workspace_is_git_repo(workspace)?;
let output = cmd
.output()
.map_err(|e| anyhow::anyhow!("Failed to run git diff. Is git installed? ({e})"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git diff failed: {}", stderr.trim());
bail!(
"git diff failed: {}",
first_stderr_line(&String::from_utf8_lossy(&output.stderr))
);
}
String::from_utf8_lossy(&output.stdout).to_string()
};
@@ -10395,6 +10450,45 @@ fn collect_diff(
Ok(diff)
}
/// Outside a work tree `git diff` prints its whole `--no-index` usage; say
/// what is actually wrong instead. Only git's own "not a git repository"
/// becomes that one line; any other failure (dubious ownership, permissions,
/// a corrupt repository) keeps git's stderr, which names the fix.
fn ensure_review_workspace_is_git_repo(workspace: &std::path::Path) -> Result<()> {
let output = crate::dependencies::Git::review_command(workspace)?
.current_dir(workspace)
.args(["rev-parse", "--is-inside-work-tree"])
.output()
.map_err(|e| anyhow::anyhow!("Failed to run git. Is git installed? ({e})"))?;
if output.status.success() {
if String::from_utf8_lossy(&output.stdout).trim() == "true" {
return Ok(());
}
// Inside `.git` or a bare repository: a repository, but no work tree.
bail!(
"Not inside a git work tree (cwd: {}); run review from a checkout, not a bare repository or .git directory",
workspace.display()
);
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.to_ascii_lowercase().contains("not a git repository") {
bail!("Not inside a git repository (cwd: {})", workspace.display());
}
bail!(
"git could not read the repository at {}: {}",
workspace.display(),
stderr.trim()
);
}
fn first_stderr_line(stderr: &str) -> &str {
stderr
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("(no error output)")
}
fn ensure_local_review_diff_fits(diff: &str, max_chars: usize) -> Result<()> {
let chars = diff.chars().count();
if chars > max_chars {
@@ -17427,6 +17521,73 @@ api_key = "test-only-key"
}
}
#[test]
fn review_in_a_bare_repository_is_not_called_outside_git() {
let bare = tempfile::tempdir().expect("tempdir");
let init = std::process::Command::new("git")
.args(["init", "--bare", "-q"])
.current_dir(bare.path())
.status()
.expect("git init --bare");
assert!(init.success());
let error = ensure_review_workspace_is_git_repo(bare.path())
.expect_err("a bare repository has no work tree");
let text = error.to_string();
assert!(text.starts_with("Not inside a git work tree"), "{text}");
}
#[test]
fn review_outside_a_git_repository_says_so_in_one_line() {
let outside = tempfile::tempdir().expect("tempdir");
let error = ensure_review_workspace_is_git_repo(outside.path())
.expect_err("a plain directory is not a work tree");
assert_eq!(
error.to_string(),
format!(
"Not inside a git repository (cwd: {})",
outside.path().display()
)
);
assert_eq!(
first_stderr_line("\nfatal: bad revision 'nope...HEAD'\nusage: git diff\n --stat\n"),
"fatal: bad revision 'nope...HEAD'"
);
}
#[test]
fn exec_resume_error_redacts_the_typed_id_and_names_the_list_command() {
let id = "sk-live-pasted-by-mistake";
let text = exec_resume_load_error(id);
assert!(!text.contains(id), "{text}");
assert!(text.contains("<redacted:"), "{text}");
assert!(
text.ends_with("Run `codewhale sessions` to list ids."),
"{text}"
);
}
#[test]
fn doctor_shows_plain_value_errors_with_a_fix_and_hides_the_rest() {
let invalid = crate::config::Config {
verbosity: Some("chatty".to_string()),
..Default::default()
}
.validate()
.expect_err("unknown verbosity");
let wrapped = invalid.context("Failed to load config file /tmp/config.toml");
let text = doctor_config_error_text(&wrapped);
assert_eq!(
text,
"doctor configuration validation failed: Invalid verbosity (value not shown): expected normal or concise.\nfix: codewhale config set verbosity normal (if a profile or managed config sets it, correct it there)"
);
assert!(!text.contains("chatty"), "{text}");
let opaque = anyhow::anyhow!("TOML parse error near api_key = \"sk-live-secret\"");
let text = doctor_config_error_text(&opaque);
assert!(text.contains("details omitted"), "{text}");
assert!(!text.contains("sk-live-secret"), "{text}");
}
#[test]
fn local_review_budget_counts_unicode_characters_without_cutting_input() {
let diff = format!("{}+鲸鱼\n", review_test_diff());
+27 -5
View File
@@ -497,11 +497,7 @@ impl LlmError {
status,
message: body.to_string(),
}
} else if body_lower.contains("context_length")
|| body_lower.contains("token")
|| body_lower.contains("too long")
|| body_lower.contains("maximum")
{
} else if is_context_length_message(&body_lower) {
LlmError::ContextLengthError(body.to_string())
} else if body_lower.contains("content_policy")
|| body_lower.contains("safety")
@@ -727,6 +723,32 @@ fn looks_like_authentication_failure(body: &str) -> bool {
|| lower.contains("missing token")
}
/// A provider error is a context overflow only when it says so. Bare
/// "token", "too long" or "maximum" also appear in ordinary invalid-request
/// errors (`max_tokens must be ...`, a field value too long), which compaction
/// or a bigger window cannot fix. This is the one phrase list: the typed 400
/// classification here and the engine's string classifier both read it.
/// `lower` must already be lowercase.
pub(crate) fn is_context_length_message(lower: &str) -> bool {
[
"context_length",
"context length",
"context window",
"context limit",
"maximum context",
"prompt is too long",
"input is too long",
"maximum prompt length",
"exceeded model token limit",
"tokens exceed",
"exceeds the maximum number of tokens",
// llama.cpp: "the request exceeds the available context size".
"available context size",
]
.iter()
.any(|phrase| lower.contains(phrase))
}
/// Quota exhaustion is a durable account state, not a generic rate-limit
/// synonym. Accept only explicit provider evidence at the HTTP/parser boundary;
/// callers holding a stringified error must never promote it to this type.
+32
View File
@@ -158,6 +158,38 @@ fn http_response_boundary_classifies_status_contract() {
LlmError::from_http_response(400, "invalid json"),
LlmError::InvalidRequest { status: 400, .. }
));
for context in [
"This model's maximum context length is 131072 tokens.",
"prompt is too long: 250000 tokens > 200000 maximum",
"input tokens exceed the configured limit",
"The input token count (2000000) exceeds the maximum number of tokens allowed (1048576).",
// xAI, Moonshot, Anthropic and Bedrock wording.
"This model's maximum prompt length is 131072 but the request contains 200000 tokens.",
"Invalid request: Your request exceeded model token limit: 262144",
"input length and `max_tokens` exceed context limit: 187254 + 20000 > 204798",
"Input is too long for requested model.",
] {
assert!(
matches!(
LlmError::from_http_response(400, context),
LlmError::ContextLengthError(_)
),
"{context}"
);
}
for invalid in [
"max_tokens must be less than or equal to 8192",
"Invalid 'messages[1].name': string too long. Expected a maximum length of 64.",
"invalid token in JSON body",
] {
assert!(
matches!(
LlmError::from_http_response(400, invalid),
LlmError::InvalidRequest { status: 400, .. }
),
"{invalid}"
);
}
// "Unsupported parameter: max_output_tokens" names a *token* field, which
// the generic keyword rules misread as a context-window overflow. It is a
// request-shape error, and retrying or compacting cannot fix it.
+2 -2
View File
@@ -58,7 +58,7 @@ fn model_identity(config: &Config, key: &str) -> Result<ProviderIdentity> {
})
.map_or(id, |provider| provider.as_str())
};
config.resolve_provider_pin_identity(selector)
config.resolve_provider_selection_identity(selector)
} else {
config.active_provider_identity(config.api_provider())
}
@@ -369,7 +369,7 @@ pub fn set_document(
let config = parse_config(&doc.to_string())?;
if key == "provider" {
let identity = config
.resolve_provider_pin_identity(value)
.resolve_provider_selection_identity(value)
.map_err(anyhow::Error::msg)?;
persistence::set_document_value(
doc,
+197 -35
View File
@@ -249,24 +249,52 @@ impl std::fmt::Debug for ValidatedRuntimeRoute {
}
}
/// Who reads a route preflight failure. The interactive app can run slash
/// commands; a headless caller only has the CLI. Only `codewhale exec` asks
/// for [`Self::Headless`] today (through [`ResolvedRuntimeRoute::validate_for`]);
/// `preflight` and other callers keep the interactive wording.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RouteErrorSurface {
Interactive,
Headless,
}
impl ResolvedRuntimeRoute {
pub(crate) fn preflight(mut self) -> Result<Self, String> {
if self.preflighted_client.is_none() {
self.preflighted_client = Some(
CodewhaleClient::from_candidate(&self.config, &self.candidate).map_err(|err| {
format_provider_route_preflight_error(&self.identity.key, &self.model, &err)
format_provider_route_preflight_error(
&self.identity.key,
&self.model,
&err,
RouteErrorSurface::Interactive,
)
})?,
);
}
Ok(self)
}
pub(crate) fn validate(mut self) -> Result<ValidatedRuntimeRoute, String> {
pub(crate) fn validate(self) -> Result<ValidatedRuntimeRoute, String> {
self.validate_for(RouteErrorSurface::Interactive)
}
/// [`Self::validate`] with next steps worded for `surface`.
pub(crate) fn validate_for(
mut self,
surface: RouteErrorSurface,
) -> Result<ValidatedRuntimeRoute, String> {
let client = match self.preflighted_client.take() {
Some(client) => client,
None => {
CodewhaleClient::from_candidate(&self.config, &self.candidate).map_err(|err| {
format_provider_route_preflight_error(&self.identity.key, &self.model, &err)
format_provider_route_preflight_error(
&self.identity.key,
&self.model,
&err,
surface,
)
})?
}
};
@@ -289,34 +317,61 @@ fn format_provider_route_preflight_error(
identity_key: &str,
model: &str,
err: &anyhow::Error,
surface: RouteErrorSurface,
) -> String {
let reason = err.to_string().trim().to_string();
let mut message = format!(
"{}. Failed to configure provider route {} / {}.",
reason, identity_key, model
);
if let Some(next_step) = classify_provider_route_preflight_next_step(identity_key, &reason) {
message.push_str(" Next step: ");
let reason = err.to_string();
let reason = reason.trim();
// Multi-line guidance ends in a command to copy; a period appended to it
// would break the paste, so it gets a line break instead.
let multi_line = reason.contains('\n');
let mut message = if multi_line {
format!("{reason}\nFailed to configure provider route {identity_key} / {model}.")
} else {
format!(
"{}. Failed to configure provider route {identity_key} / {model}.",
reason.trim_end_matches('.')
)
};
if let Some(next_step) =
classify_provider_route_preflight_next_step(identity_key, reason, surface)
{
message.push_str(if multi_line { "\n" } else { " " });
message.push_str("Next step: ");
message.push_str(&next_step);
}
message
}
fn classify_provider_route_preflight_next_step(identity_key: &str, reason: &str) -> Option<String> {
fn classify_provider_route_preflight_next_step(
identity_key: &str,
reason: &str,
surface: RouteErrorSurface,
) -> Option<String> {
let headless = surface == RouteErrorSurface::Headless;
let lower = reason.to_ascii_lowercase();
if lower
.contains("codex oauth credentials are only available on the official openai codex route")
{
return Some(format!(
"Run /provider setup {identity_key} and remove its custom base URL; Codex OAuth only works on the official route."
));
return Some(if headless {
format!(
"Remove the custom base_url from [providers.{identity_key}]; Codex OAuth only works on the official route."
)
} else {
format!(
"Run /provider setup {identity_key} and remove its custom base URL; Codex OAuth only works on the official route."
)
});
}
if lower.contains("openai codex oauth credentials are unavailable")
|| lower.contains("codex access token")
{
return Some(format!(
"Run `codewhale auth chatgpt` or /provider setup {identity_key} to Sign in with ChatGPT; Codex CLI import remains an explicit alternative."
));
return Some(if headless {
"Run `codewhale auth chatgpt` to Sign in with ChatGPT; Codex CLI import remains an explicit alternative.".to_string()
} else {
format!(
"Run `codewhale auth chatgpt` or /provider setup {identity_key} to Sign in with ChatGPT; Codex CLI import remains an explicit alternative."
)
});
}
if lower.contains("api key not found")
|| lower.contains("access token")
@@ -325,9 +380,35 @@ fn classify_provider_route_preflight_next_step(identity_key: &str, reason: &str)
|| lower.contains("missing")
|| lower.contains("unsupported")))
{
return Some(format!(
"Run /auth or /provider setup {identity_key} to configure credentials."
));
if !headless {
return Some(format!(
"Run /auth or /provider setup {identity_key} to configure credentials."
));
}
// The reason usually names its own command (`codewhale auth set`,
// `codewhale auth chatgpt`, `codewhale auth xai-device`, ...);
// repeating one would make two instructions out of one.
if lower.contains("codewhale auth") {
return None;
}
// `auth set` stores an API key. An access-token or other credential
// failure may belong to an OAuth route, where it is the wrong fix.
if !lower.contains("api key") {
return Some(
"Run `codewhale doctor` to see which credential this route needs.".to_string(),
);
}
return Some(
match ApiProvider::parse(identity_key).filter(|p| *p != ApiProvider::Custom) {
Some(provider) => {
format!("Run `codewhale auth set --provider {}`.", provider.as_str())
}
// A custom identity's key is its `[providers.<name>]` table.
None => format!(
"Add api_key or api_key_env to [providers.{identity_key}] in config.toml."
),
},
);
}
if lower.contains("tls certificate")
|| lower.contains("ssl_cert_file")
@@ -336,9 +417,11 @@ fn classify_provider_route_preflight_next_step(identity_key: &str, reason: &str)
|| lower.contains("base url")
|| lower.contains("invalid url")
{
return Some(format!(
"Run /provider setup {identity_key} to fix base URL/TLS settings."
));
return Some(if headless {
format!("Fix base_url/TLS settings in [providers.{identity_key}] of config.toml.")
} else {
format!("Run /provider setup {identity_key} to fix base URL/TLS settings.")
});
}
if lower.contains("provider")
&& lower.contains("model")
@@ -347,10 +430,13 @@ fn classify_provider_route_preflight_next_step(identity_key: &str, reason: &str)
|| lower.contains("unknown")
|| lower.contains("not found"))
{
return Some(
return Some(if headless {
"Pass a model this provider serves with --model (`codewhale models` lists them)."
.to_string()
} else {
"Run /models (or open the model picker) and choose a model valid for this provider."
.to_string(),
);
.to_string()
});
}
if lower.contains("fleet") || lower.contains("profile") || lower.contains("partial route") {
return Some(
@@ -358,9 +444,11 @@ fn classify_provider_route_preflight_next_step(identity_key: &str, reason: &str)
.to_string(),
);
}
Some(format!(
"Run /provider setup {identity_key} to review this route configuration."
))
Some(if headless {
"Run `codewhale doctor` to review this route configuration.".to_string()
} else {
format!("Run /provider setup {identity_key} to review this route configuration.")
})
}
impl ValidatedRuntimeRoute {
@@ -1186,7 +1274,12 @@ mod tests {
let err = anyhow::anyhow!(
"Custom provider 'lm-studio' API key not found. Run 'codewhale auth set --provider custom'."
);
let formatted = format_provider_route_preflight_error("lm-studio", "local-model", &err);
let formatted = format_provider_route_preflight_error(
"lm-studio",
"local-model",
&err,
RouteErrorSurface::Interactive,
);
assert!(formatted.starts_with("Custom provider 'lm-studio' API key not found."));
assert!(formatted.contains("Failed to configure provider route lm-studio / local-model."));
@@ -1195,11 +1288,71 @@ mod tests {
));
}
#[test]
fn provider_route_preflight_headless_missing_key_is_one_cli_message() {
let single = anyhow::anyhow!(
"Custom provider 'lm-studio' API key not found. Run 'codewhale auth set --provider custom'."
);
let formatted = format_provider_route_preflight_error(
"lm-studio",
"local-model",
&single,
RouteErrorSurface::Headless,
);
assert!(!formatted.contains(".."), "{formatted}");
assert!(!formatted.contains("/auth"), "{formatted}");
assert!(!formatted.contains("/provider"), "{formatted}");
assert!(!formatted.contains("Next step"), "{formatted}");
let bare = anyhow::anyhow!("DeepSeek API key not found.");
let formatted = format_provider_route_preflight_error(
"deepseek",
"deepseek-v4-flash",
&bare,
RouteErrorSurface::Headless,
);
assert_eq!(
formatted,
"DeepSeek API key not found. Failed to configure provider route deepseek / deepseek-v4-flash. Next step: Run `codewhale auth set --provider deepseek`."
);
// An OAuth access-token failure is not an `auth set` (API key) fix.
let oauth = anyhow::anyhow!("OAuth access token is empty");
let formatted = format_provider_route_preflight_error(
"xai",
"grok-4",
&oauth,
RouteErrorSurface::Headless,
);
assert!(!formatted.contains("auth set"), "{formatted}");
assert!(formatted.contains("codewhale doctor"), "{formatted}");
let multi = anyhow::anyhow!(
"DeepSeek API key not found.\n\n codewhale auth set --provider deepseek"
);
let formatted = format_provider_route_preflight_error(
"deepseek",
"deepseek-v4-flash",
&multi,
RouteErrorSurface::Headless,
);
assert!(
formatted.contains(
"\n codewhale auth set --provider deepseek\nFailed to configure provider route"
),
"{formatted}"
);
}
#[test]
fn provider_route_preflight_codex_oauth_errors_surface_the_right_next_step() {
let missing = anyhow::anyhow!("OpenAI Codex OAuth credentials are unavailable.");
let missing_formatted =
format_provider_route_preflight_error("openai-codex", "gpt-5.6-sol", &missing);
let missing_formatted = format_provider_route_preflight_error(
"openai-codex",
"gpt-5.6-sol",
&missing,
RouteErrorSurface::Interactive,
);
assert!(missing_formatted.contains(
"Next step: Run `codewhale auth chatgpt` or /provider setup openai-codex to Sign in with ChatGPT; Codex CLI import remains an explicit alternative."
));
@@ -1207,8 +1360,12 @@ mod tests {
let custom = anyhow::anyhow!(
"Codex OAuth credentials are only available on the official OpenAI Codex route"
);
let custom_formatted =
format_provider_route_preflight_error("openai-codex", "gpt-5.6-sol", &custom);
let custom_formatted = format_provider_route_preflight_error(
"openai-codex",
"gpt-5.6-sol",
&custom,
RouteErrorSurface::Interactive,
);
assert!(custom_formatted.contains(
"Next step: Run /provider setup openai-codex and remove its custom base URL; Codex OAuth only works on the official route."
));
@@ -1219,7 +1376,12 @@ mod tests {
let err = anyhow::anyhow!(
"TLS certificate verification cannot be disabled for provider custom; configure SSL_CERT_FILE with a trusted custom CA bundle instead"
);
let formatted = format_provider_route_preflight_error("lm-studio", "local-model", &err);
let formatted = format_provider_route_preflight_error(
"lm-studio",
"local-model",
&err,
RouteErrorSurface::Interactive,
);
assert!(
formatted
+4 -1
View File
@@ -2646,7 +2646,10 @@ fn render_error_message(
let body_style = error_body_style(severity);
let prefix_width = UnicodeWidthStr::width(label);
let content_width = width.saturating_sub(2 + prefix_width as u16).max(1);
let mut lines = wrap_plain_line(message, body_style, content_width);
let mut lines: Vec<_> = message
.split('\n')
.flat_map(|line| wrap_plain_line(line, body_style, content_width))
.collect();
if let Some(first) = lines.get_mut(0) {
first.spans.insert(0, Span::raw(" "));
first.spans.insert(0, Span::styled(label, label_style));
+24
View File
@@ -1530,6 +1530,30 @@ fn error_severity_ranks_stay_visually_distinguishable() {
assert_eq!(body_fg, error_fg);
}
#[test]
fn error_guidance_keeps_recovery_commands_on_their_own_line() {
let cell = HistoryCell::Error {
message: "DeepSeek API key not found.\nSave it:\n codewhale auth set --provider deepseek"
.to_string(),
severity: crate::error_taxonomy::ErrorSeverity::Error,
};
for width in [80, 140] {
let lines = cell.lines(width);
let command_line = lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.find(|line| line.contains("codewhale auth set --provider deepseek"))
.expect("recovery command stays intact");
assert!(!command_line.contains('\n'), "{command_line:?}");
assert!(!command_line.contains("Save it:"), "{command_line:?}");
}
}
/// A multiline failure can run past the bottom of the terminal while its full
/// text stays in history. The live cell advertises the pager; the pager and the
/// transcript must carry the recovery instruction verbatim and must not
@@ -138,9 +138,17 @@ api_key = "doctor-json-arbitrary-secret"
let report: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("machine-readable doctor error");
assert_eq!(report["error"]["kind"], "config_validation");
assert_eq!(
report["error"]["message"],
"configuration validation failed; details omitted because configuration errors may contain credential material"
// A plain value error names the key and the valid values, never the
// rejected value itself (it may be a pasted secret).
let message = report["error"]["message"].as_str().expect("message");
assert!(
message.starts_with("Invalid provider (value not shown): expected deepseek"),
"{message}"
);
let fix = report["error"]["fix"].as_str().expect("fix");
assert!(
fix.starts_with("codewhale config set provider deepseek (if CODEWHALE_PROVIDER"),
"{fix}"
);
let all_output = format!(
"{}\n{}",