mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 09:59:45 -10:00
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:
14 files changed
+1035
-137
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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{}",
|
||||
|
||||
Reference in new issue
Block a user