fix(tui): actionable CLI error messages (doctor, exec, stream, review)

Message-only slices plus one presence-only credential check:

- doctor: plain value/profile validation errors are shown (without the
  rejected value, which may be a pasted secret) with
  `fix: codewhale config set <key> <valid>`; parse and credential errors
  stay suppressed. Text and --json paths share SafeConfigDiagnostic.
- doctor verdict: a key saved with `codewhale auth set` (secret store,
  config, or env; read-only presence) clears "no model provider"; the
  hint is /provider or `codewhale auth set --provider <p>` when headless.
- exec preflight: headless next steps name CLI commands only, no
  duplicate auth instruction, no "..", multi-line guidance keeps its
  indentation; DeepSeek zsh caveat on its own line and the DeepSeek
  Harness bullet only when dsh is on PATH; the final exec failure line
  no longer repeats an error the stream already printed.
- `config set provider <typo>` reuses "Invalid provider 'x': expected ...".
- SSE H1 fallback: full error chain, FORCE_HTTP1 hint only after
  protocol/TLS errors, connect failures read "Cannot reach host:port".
- 400s classify as ContextLengthError only on context-specific phrases.
- cloud sanitize_error: controls/whitespace collapse to one space,
  redact before cut, keep head and tail.
- exec --resume shows the typed id (redacted only for stream-json) plus
  `Run \`codewhale sessions\` to list ids.`; review outside a repo says
  "Not inside a git repository (cwd: ...)", git diff errors keep one line.

Tests: targeted codewhale-tui lib tests, 63 passed / 0 failed; touched
modules (config::tests, llm_client, cloud_dispatch, exec_agent,
route_preferences, doctor) 724 passed / 0 failed / 1 ignored.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
This commit is contained in:
Hunter BownandClaude Opus 5.5 committed 2026-09-28 23:27:51 -07:00
1 parent 44b547dace
commit 2562f29971
12 files changed
+824 -130

No files matched your search

+101 -5
View File
@@ -229,11 +229,55 @@ 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, &detail) {
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 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.
fn connect_failure_summary(err: &anyhow::Error, detail: &str) -> Option<String> {
let reqwest_error = err
.chain()
.find_map(|cause| cause.downcast_ref::<reqwest::Error>())
.filter(|error| error.is_connect())?;
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 lower = detail.to_ascii_lowercase();
let why = if lower.contains("refused") {
"connection refused"
} else if lower.contains("dns") || lower.contains("lookup") {
"DNS lookup failed"
} else {
"connection failed"
};
Some(format!("Cannot reach {target} ({why})"))
}
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 +549,58 @@ 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} (connection refused)"
)),
"{text}"
);
assert!(!text.contains("CODEWHALE_FORCE_HTTP1"), "{text}");
assert!(
err.downcast_ref::<LlmError>()
.is_some_and(LlmError::is_retryable),
"{err:#}"
);
}
#[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));
+49 -10
View File
@@ -2107,17 +2107,29 @@ 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()
let flat = message
.split(|ch: char| ch.is_control() || ch.is_whitespace())
.filter(|word| !word.is_empty())
.collect::<Vec<_>>()
.join(" ");
// Redact before cutting so a secret can never be split past the redactor.
let redacted = redact_machine_tokens(&redact_url_userinfo(&flat));
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 +2588,33 @@ mod tests {
);
}
#[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"));
+176 -34
View File
@@ -5132,10 +5132,13 @@ impl Config {
.and_then(|providers| providers.custom_provider_config(provider))
.is_none()
{
anyhow::bail!(
"Invalid provider '{provider}': expected {}.",
ApiProvider::names_hint()
);
return Err(SafeConfigDiagnostic::invalid_value(
"provider",
provider,
&ApiProvider::names_hint(),
ApiProvider::Deepseek.as_str(),
)
.into());
}
let active_provider = self.api_provider();
match validate_kimi_code_api_model_id(
@@ -5191,10 +5194,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()
),
"auto",
)
.into());
}
if let Some(policy) = self.approval_policy.as_deref() {
let normalized = policy.trim().to_ascii_lowercase();
@@ -5202,15 +5211,25 @@ 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",
"on-request",
)
.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",
"normal",
)
.into());
}
}
if let Some(mode) = self.sandbox_mode.as_deref() {
@@ -5219,9 +5238,13 @@ 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",
"workspace-write",
)
.into());
}
}
if let Some(tui) = &self.tui
@@ -5229,9 +5252,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",
"auto",
)
.into());
}
}
if let Some(transcript) = &self.transcript
@@ -5546,6 +5573,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 +7314,11 @@ 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(
crate::integrations::dsh::detect::dsh_on_path()
))
}
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,31 @@ pub(crate) fn is_kimi_code_membership_model(model: &str) -> bool {
.any(|id| model.eq_ignore_ascii_case(id))
}
/// Missing-key guidance for DeepSeek. Built from lines so indentation
/// survives (a `\` string continuation strips it). The DeepSeek Harness
/// bullet appears only when `dsh` is installed.
fn deepseek_missing_key_message(dsh_detected: bool) -> String {
let mut lines = vec![
"DeepSeek API key not found.",
"",
"1. Get a key: https://platform.deepseek.com/api_keys",
"2. Save it (works in every folder, no OS prompts):",
" codewhale auth set --provider deepseek",
"",
"Alternatives:",
" • export DEEPSEEK_API_KEY=<your-key> (current shell only)",
" zsh: exports in ~/.zshrc reach only interactive shells; use ~/.zshenv.",
" • api_key = \"<your-key>\" in ~/.codewhale/config.toml",
];
if dsh_detected {
lines.extend([
" • already configured DeepSeek Harness? grant read-only access:",
" codewhale auth external-consent --provider deepseek --mode read-only",
]);
}
lines.join("\n")
}
/// 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 +10757,68 @@ 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, valid: &str) -> Self {
Self::InvalidValue {
message: format!("Invalid {key} '{value}': expected {expected}."),
shareable: format!("Invalid {key} (value not shown): expected {expected}."),
fix: Some(format!("codewhale config set {key} {valid}")),
}
}
/// 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>())
}
}
/// 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 {
SafeConfigDiagnostic::invalid_value(
"provider",
provider,
&ApiProvider::names_hint(),
ApiProvider::Deepseek.as_str(),
)
.to_string()
}
/// Fail closed on known-bad model/endpoint pairings (#4687).
@@ -11072,7 +11195,14 @@ 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}")
let message =
format!("Profile '{profile_name}' not found. Available profiles: {available}");
Err(SafeConfigDiagnostic::InvalidValue {
shareable: message.clone(),
message,
fix: None,
}
.into())
}
}
} else {
@@ -12317,6 +12447,18 @@ pub fn active_provider_has_env_api_key(config: &Config) -> bool {
&& provider_env_api_key(provider).is_some())
}
/// Whether the active provider has a credential in the environment, the
/// durable secret store, or the user-global config. Presence only: the store
/// is opened read-only and every value is dropped unread by the caller, so
/// diagnostics can answer "is a key saved?" without probing or showing it.
#[must_use]
pub(crate) fn active_provider_credential_present(config: &Config) -> bool {
let provider = config.api_provider();
active_provider_has_env_api_key(config)
|| provider_secret_store_api_key_with_mode(config, provider, true).is_some()
|| user_global_config_api_key(provider).is_some()
}
#[must_use]
pub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {
active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)
+48
View File
@@ -15521,3 +15521,51 @@ 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_indentation_and_gates_the_harness_bullet() {
let plain = deepseek_missing_key_message(false);
assert!(
plain.contains("\n codewhale auth set --provider deepseek\n"),
"{plain}"
);
assert!(
plain.contains(
"\n zsh: exports in ~/.zshrc reach only interactive shells; use ~/.zshenv.\n"
),
"{plain}"
);
assert!(!plain.contains("DeepSeek Harness"), "{plain}");
let with_dsh = deepseek_missing_key_message(true);
assert!(
with_dsh.ends_with(
"\n codewhale auth external-consent --provider deepseek --mode read-only"
),
"{with_dsh}"
);
}
+35 -3
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();
@@ -796,6 +796,9 @@ pub(crate) async fn run_exec_agent(
let mut approval_required = false;
let mut tool_error_seen = false;
let mut last_error_category = None;
// The error text already printed to stderr in text mode, so the final
// failure line does not print the same message twice.
let mut printed_error: Option<String> = None;
let mut reported_sandbox_contract = false;
let mut should_persist_session =
@@ -1107,6 +1110,7 @@ pub(crate) async fn run_exec_agent(
})?;
} else if !json_output {
eprintln!("error: {}", envelope.message);
printed_error = Some(envelope.message);
}
}
Event::TurnUsage {
@@ -1487,12 +1491,13 @@ 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());
let failure = exec_failure_line(error, printed_error.as_deref());
if exit_code != 1 {
eprintln!("Error: exec turn failed: {error}");
eprintln!("Error: {failure}");
let _ = io::stdout().flush();
std::process::exit(exit_code);
}
bail!("exec turn failed: {error}");
bail!("{failure}");
}
if matches!(
@@ -1506,9 +1511,36 @@ pub(crate) async fn run_exec_agent(
Ok(())
}
/// The final exec failure line. When the stream already printed this exact
/// error, repeat only the verdict, not the message.
fn exec_failure_line(error: &str, already_printed: Option<&str>) -> String {
if already_printed.is_some_and(|printed| printed.trim() == error.trim()) {
"exec turn failed (error above)".to_string()
} else {
format!("exec turn failed: {error}")
}
}
#[cfg(test)]
mod tests {
use super::{ExecAgentEvents, exec_automation_services, exec_disallowed_tools};
#[test]
fn exec_failure_line_does_not_repeat_a_streamed_error() {
let error = "Provider returned 401: invalid api key";
assert_eq!(
super::exec_failure_line(error, Some(error)),
"exec turn failed (error above)"
);
assert_eq!(
super::exec_failure_line(error, None),
format!("exec turn failed: {error}")
);
assert_eq!(
super::exec_failure_line(error, Some("a different warning")),
format!("exec turn failed: {error}")
);
}
use crate::core::engine::mock_engine_handle;
use crate::core::engine::tool_catalog::REQUEST_USER_INPUT_NAME;
use crate::core::events::{Event, TurnOutcomeStatus};
@@ -229,6 +229,12 @@ pub(crate) fn resolve_dsh_home(env: &DetectEnv) -> (PathBuf, bool) {
(home.join(".dsh"), false)
}
/// Whether a DeepSeek Harness launcher is on this process's `PATH`. A cheap
/// `PATH` scan only: nothing is run and `$DSH_HOME` is not touched.
pub(crate) fn dsh_on_path() -> bool {
find_on_path(std::env::var_os("PATH").as_ref()).is_some()
}
fn find_on_path(path: Option<&OsString>) -> Option<PathBuf> {
let path = path?;
for dir in std::env::split_paths(path) {
+177 -34
View File
@@ -1099,13 +1099,26 @@ 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);
fn load_exec_resume_session(
session_id: &str,
output_format: ExecOutputFormat,
) -> Result<session_manager::SavedSession> {
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, output_format))
}
/// The person who typed `--resume <id>` sees that id; only the
/// machine-readable stream-json run keeps the redacted breadcrumb.
fn exec_resume_load_error(session_id: &str, output_format: ExecOutputFormat) -> String {
let session_ref = if output_format == ExecOutputFormat::StreamJson {
exec_stream_session_ref(session_id)
} else {
session_id.to_string()
};
format!("could not load session {session_ref}. Run `codewhale sessions` to list ids.")
}
/// Select the route for `exec --resume` before any engine/client is built.
@@ -2381,11 +2394,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 {
@@ -2559,7 +2568,7 @@ async fn run_async_main_dispatch(
)?;
let resume_session = resume_session_id
.as_deref()
.map(load_exec_resume_session)
.map(|id| load_exec_resume_session(id, args.output_format))
.transpose()?;
let explicit_model = args
.model
@@ -4679,7 +4688,11 @@ 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,
doctor_has_credentials_or_local_runtime(config),
config.api_provider().as_str(),
);
println!("{}", verdict.truecolor(aqua_r, aqua_g, aqua_b).bold());
println!();
@@ -5668,25 +5681,33 @@ async fn run_doctor(
println!("{}", verdict.truecolor(aqua_r, aqua_g, aqua_b).bold());
}
/// 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 {
/// Doctor's one-line answer: ready, or the single next step (U7).
/// `credential_present` is the resolved credential's presence (secret store,
/// config, or env); doctor never displays or probes the value to decide it.
fn doctor_verdict(
state: &codewhale_config::SetupState,
credential_present: bool,
provider: &str,
) -> String {
// NeedsAction means a named route needs repair (missing credentials or a
// failed check). 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
);
// failed check). Configured routes can be used without a prior probe, and
// a credential saved since setup ran (`codewhale auth set`) configures
// the route even though the persisted step predates it. `first_run_ready`
// accepts NeedsAction (a failed key still reaches the wizard's ready
// screen), so check the provider first.
let provider_configured = credential_present
|| 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`."
format!(
"Not ready: no model provider set up → run /provider in Codewhale, or `codewhale auth set --provider {provider}` when headless."
)
} else if state.first_run_ready() {
"Ready: setup is complete."
"Ready: setup is complete.".to_string()
} else {
"Not ready: first-run setup is unfinished → run `codewhale setup`."
"Not ready: first-run setup is unfinished → run `codewhale setup`.".to_string()
}
}
@@ -5694,9 +5715,24 @@ 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(), false, "deepseek");
assert!(verdict.starts_with("Not ready"), "{verdict}");
assert!(verdict.contains("/provider"), "{verdict}");
assert!(
verdict.contains("`codewhale auth set --provider deepseek` when headless"),
"{verdict}"
);
assert!(!verdict.contains("codewhale setup"), "{verdict}");
}
#[test]
fn a_saved_credential_configures_the_provider_step() {
// `codewhale auth set` on a fresh home writes no setup state; the
// resolved credential alone must clear the "no model provider" step.
let verdict =
super::doctor_verdict(&codewhale_config::SetupState::default(), true, "deepseek");
assert!(!verdict.contains("no model provider"), "{verdict}");
}
#[test]
@@ -5716,7 +5752,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, false, "deepseek");
assert!(verdict.starts_with("Not ready"), "{verdict}");
assert!(verdict.contains("/provider"), "{verdict}");
@@ -5724,7 +5760,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, false, "deepseek"),
"Ready: setup is complete."
);
}
}
@@ -6384,10 +6423,13 @@ fn doctor_inherited_setup_facts(
}
}
/// Presence only: a key in the secret store, config, or env configures the
/// route; doctor keeps the yes/no and never reads the value into output.
fn doctor_has_credentials_or_local_runtime(config: &Config) -> bool {
resolve_credential_diagnostic(config)
.availability
.certifies_ready()
|| crate::config::active_provider_credential_present(config)
}
fn print_doctor_setup_report(
@@ -7294,15 +7336,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)?);
@@ -10361,12 +10423,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()
};
@@ -10376,6 +10441,28 @@ fn collect_diff(
Ok(diff)
}
/// Outside a work tree `git diff` prints its whole `--no-index` usage; say
/// what is actually wrong instead.
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() || String::from_utf8_lossy(&output.stdout).trim() != "true" {
bail!("Not inside a git repository (cwd: {})", workspace.display());
}
Ok(())
}
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 {
@@ -17408,6 +17495,62 @@ api_key = "test-only-key"
}
}
#[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_shows_the_typed_id_except_in_stream_json() {
let id = "0123456789abcdef-session";
let human = exec_resume_load_error(id, ExecOutputFormat::Text);
assert_eq!(
human,
format!("could not load session {id}. Run `codewhale sessions` to list ids.")
);
let stream = exec_resume_load_error(id, ExecOutputFormat::StreamJson);
assert!(!stream.contains(id), "{stream}");
assert!(
stream.ends_with("Run `codewhale sessions` to list ids."),
"{stream}"
);
}
#[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"
);
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());
+19 -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")
@@ -730,6 +726,24 @@ fn looks_like_authentication_failure(body: &str) -> bool {
/// 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.
/// A 400 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. `lower` must already be lowercase.
fn is_context_length_message(lower: &str) -> bool {
[
"context_length",
"context length",
"context window",
"prompt is too long",
"maximum context",
"tokens exceed",
"exceeds the maximum number of tokens",
]
.iter()
.any(|phrase| lower.contains(phrase))
}
fn has_explicit_quota_evidence(body: &str) -> bool {
explicit_quota_code(body).is_some()
|| has_explicit_quota_code_marker(body)
+27
View File
@@ -158,6 +158,33 @@ 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).",
] {
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,
+175 -35
View File
@@ -249,24 +249,50 @@ impl std::fmt::Debug for ValidatedRuntimeRoute {
}
}
/// Who reads a route preflight failure. The interactive app can run slash
/// commands; `codewhale exec` and other headless callers only have the CLI.
#[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 +315,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 +378,26 @@ 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 `codewhale auth set` command
// already; repeating it would make two instructions out of one.
if lower.contains("codewhale auth set") {
return None;
}
return Some(
match ApiProvider::parse(identity_key).filter(|p| *p != ApiProvider::Custom) {
Some(provider) => {
format!("Run `codewhale auth set --provider {}`.", provider.as_str())
}
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 +406,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 +419,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 +433,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 {
@@ -1158,7 +1235,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."));
@@ -1167,11 +1249,60 @@ 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`."
);
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."
));
@@ -1179,8 +1310,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."
));
@@ -1191,7 +1326,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
@@ -138,9 +138,16 @@ 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");
// 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}"
);
assert_eq!(
report["error"]["message"],
"configuration validation failed; details omitted because configuration errors may contain credential material"
report["error"]["fix"],
"codewhale config set provider deepseek"
);
let all_output = format!(
"{}\n{}",