wip(tui): preserve interrupted L2 review repairs before takeover

Checkpoint the 12-file working tree left by the original Claude repair:L2
worker for PR #6725 after its session limit interrupted the repair. Preserve
that work before the DSH helper ownership move; this commit is local only
and is not a green landing claim.

Provenance: original lane commit 2562f29971be6fb3cb069b9699463314d727e23a;
Claude session_014ZwqatxgVFxHvovngywnks; repair workflow receipt
wg9a2bnyw.output reports the quota stop. No original edits were discarded.

Verification at checkpoint:
- git diff --check: pass.
- scripts/split/module_graph.py --check: FAIL, four new integrations-to-UI
  references (two crate-root references and two ratatui imports).
- Recovered repair log b0yfkj7tx.output: test result: FAILED. 332 passed;
  1 failed (tls_failure_during_connect_keeps_the_http1_hint).
- Hosted Ubuntu at the preceding PR head failed the 80x24 keyless error
  visibility test; the recovered tree has no repair of that rendering yet.
- No Rust build or npm/check:web gate was run for this checkpoint.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: Hunter B <[email protected]>
This commit is contained in:
Hunter BandClaude Opus 5.5 committed 2026-09-29 00:22:45 -07:00
1 parent 2562f29971
commit a4740de765
12 files changed
+375 -190

No files matched your search

+69 -9
View File
@@ -230,7 +230,7 @@ fn h1_fallback_error(err: anyhow::Error) -> anyhow::Error {
}
let detail = format!("{err:#}");
if let Some(unreachable) = connect_failure_summary(&err, &detail) {
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}.");
@@ -246,11 +246,25 @@ fn h1_fallback_error(err: anyhow::Error) -> anyhow::Error {
}
/// `Cannot reach host:port (<why>)` for a failure to open the connection.
fn connect_failure_summary(err: &anyhow::Error, detail: &str) -> Option<String> {
/// 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 is_protocol_or_tls_failure(&causes) {
return None;
}
let target = reqwest_error
.url()
.and_then(|url| {
@@ -261,15 +275,29 @@ fn connect_failure_summary(err: &anyhow::Error, detail: &str) -> Option<String>
))
})
.unwrap_or_else(|| "the provider host".to_string());
let lower = detail.to_ascii_lowercase();
let why = if lower.contains("refused") {
let why = if causes.contains("refused") {
"connection refused"
} else if lower.contains("dns") || lower.contains("lookup") {
} else if causes.contains("dns") || causes.contains("lookup") {
"DNS lookup failed"
} else {
"connection failed"
};
Some(format!("Cannot reach {target} ({why})"))
// 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 is_protocol_or_tls_failure(detail: &str) -> bool {
@@ -568,9 +596,8 @@ mod tests {
.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.contains(&format!("Cannot reach 127.0.0.1:{port}"))
&& text.contains("(connection refused)"),
"{text}"
);
assert!(!text.contains("CODEWHALE_FORCE_HTTP1"), "{text}");
@@ -581,6 +608,39 @@ mod tests {
);
}
#[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;
}
});
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(
+32 -6
View File
@@ -2114,13 +2114,28 @@ const SANITIZED_ERROR_MAX_CHARS: usize = 240;
/// 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 {
let flat = message
.split(|ch: char| ch.is_control() || ch.is_whitespace())
.filter(|word| !word.is_empty())
.collect::<Vec<_>>()
.join(" ");
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 = redact_machine_tokens(&redact_url_userinfo(&flat));
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;
@@ -2588,6 +2603,17 @@ 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!(
+57 -37
View File
@@ -5132,13 +5132,7 @@ impl Config {
.and_then(|providers| providers.custom_provider_config(provider))
.is_none()
{
return Err(SafeConfigDiagnostic::invalid_value(
"provider",
provider,
&ApiProvider::names_hint(),
ApiProvider::Deepseek.as_str(),
)
.into());
return Err(invalid_provider_diagnostic(provider).into());
}
let active_provider = self.api_provider();
match validate_kimi_code_api_model_id(
@@ -5201,7 +5195,7 @@ impl Config {
"auto or a model ID provider '{}' serves{hint}",
provider.as_str()
),
"auto",
user_config_fix("model", "auto", Some("a *_MODEL environment variable")),
)
.into());
}
@@ -5215,7 +5209,11 @@ impl Config {
"approval_policy",
policy,
"on-request, untrusted, never, auto, or suggest",
"on-request",
user_config_fix(
"approval_policy",
"on-request",
Some("CODEWHALE_APPROVAL_POLICY"),
),
)
.into());
}
@@ -5227,7 +5225,7 @@ impl Config {
"verbosity",
v,
"normal or concise",
"normal",
user_config_fix("verbosity", "normal", None),
)
.into());
}
@@ -5242,7 +5240,11 @@ impl Config {
"sandbox_mode",
mode,
"read-only, workspace-write, danger-full-access, or external-sandbox",
"workspace-write",
user_config_fix(
"sandbox_mode",
"workspace-write",
Some("CODEWHALE_SANDBOX_MODE"),
),
)
.into());
}
@@ -5256,7 +5258,7 @@ impl Config {
"tui.alternate_screen",
&mode,
"auto, always, or never",
"auto",
user_config_fix("tui.alternate_screen", "auto", None),
)
.into());
}
@@ -7316,7 +7318,7 @@ impl Config {
),
ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
anyhow::bail!(deepseek_missing_key_message(
crate::integrations::dsh::detect::dsh_on_path()
crate::integrations::dsh::detect::dsh_present()
))
}
ApiProvider::SiliconflowCn => anyhow::bail!(
@@ -10724,7 +10726,7 @@ pub(crate) fn is_kimi_code_membership_model(model: &str) -> bool {
/// 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.
/// bullet appears only when `dsh` is on PATH or its credentials file exists.
fn deepseek_missing_key_message(dsh_detected: bool) -> String {
let mut lines = vec![
"DeepSeek API key not found.",
@@ -10777,11 +10779,11 @@ pub(crate) enum SafeConfigDiagnostic {
}
impl SafeConfigDiagnostic {
fn invalid_value(key: &str, value: &str, expected: &str, valid: &str) -> Self {
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(format!("codewhale config set {key} {valid}")),
fix: Some(fix),
}
}
@@ -10809,16 +10811,41 @@ impl SafeConfigDiagnostic {
}
}
/// 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 {
/// 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(),
ApiProvider::Deepseek.as_str(),
user_config_fix(
"provider",
ApiProvider::Deepseek.as_str(),
Some("CODEWHALE_PROVIDER"),
),
)
.to_string()
}
/// 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).
@@ -11195,11 +11222,16 @@ fn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result<Config> {
}
})
.unwrap_or_else(|| "none".to_string());
let message =
format!("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 {
shareable: message.clone(),
message,
message: format!(
"Profile '{profile_name}' not found. Available profiles: {available}"
),
shareable: format!(
"Profile not found (name not shown). Available profiles: {available}"
),
fix: None,
}
.into())
@@ -12447,18 +12479,6 @@ 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)
+55
View File
@@ -15569,3 +15569,58 @@ fn deepseek_missing_key_message_keeps_indentation_and_gates_the_harness_bullet()
"{with_dsh}"
);
}
#[test]
fn invalid_value_fixes_are_commands_that_work_and_name_overriding_layers() {
let error = Config {
tui: Some(TuiConfig {
alternate_screen: Some("sometimes".to_string()),
..TuiConfig::default()
}),
..Config::default()
}
.validate()
.expect_err("unknown alternate_screen");
let diagnostic = SafeConfigDiagnostic::find_in(&error).expect("safe diagnostic");
// `config set` refuses dotted keys, so the fix edits the table instead.
assert_eq!(
diagnostic.fix(),
Some(
"set alternate_screen = \"auto\" in the [tui] table of config.toml (if a profile or managed config sets it, correct it there)"
)
);
let error = Config {
sandbox_mode: Some("bogus".to_string()),
..Config::default()
}
.validate()
.expect_err("unknown sandbox_mode");
let fix = SafeConfigDiagnostic::find_in(&error)
.and_then(SafeConfigDiagnostic::fix)
.expect("fix");
assert!(
fix.starts_with("codewhale config set sandbox_mode workspace-write (if CODEWHALE_SANDBOX_MODE, a profile, or managed config sets it"),
"{fix}"
);
}
#[test]
fn missing_profile_diagnostic_does_not_show_the_requested_name() {
let mut profiles = HashMap::new();
profiles.insert("work".to_string(), Config::default());
let config = ConfigFile {
base: Box::default(),
profiles: Some(profiles),
legacy_root: Default::default(),
};
let error = apply_profile(config, Some("sk-pasted-token")).expect_err("no such profile");
let diagnostic = SafeConfigDiagnostic::find_in(&error).expect("safe diagnostic");
let shown = diagnostic.display_message();
assert_eq!(
shown,
"Profile not found (name not shown). Available profiles: work"
);
// The local error keeps the typed name for the person at the terminal.
assert!(error.to_string().contains("sk-pasted-token"), "{error}");
}
+1 -7
View File
@@ -759,13 +759,7 @@ pub(super) fn is_context_length_error_message(message: &str) -> bool {
let lower = message.to_lowercase();
lower.contains("model output truncated")
|| lower.contains("model response incomplete")
|| lower.contains("maximum context length")
|| lower.contains("context length")
|| lower.contains("context_length")
|| lower.contains("prompt is too long")
|| lower.contains("context window")
// llama.cpp: "the request exceeds the available context size".
|| lower.contains("available context size")
|| crate::llm_client::is_context_length_message(&lower)
|| (lower.contains("requested") && lower.contains("tokens") && lower.contains("maximum"))
}
+5 -33
View File
@@ -796,9 +796,6 @@ 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 =
@@ -1110,7 +1107,6 @@ pub(crate) async fn run_exec_agent(
})?;
} else if !json_output {
eprintln!("error: {}", envelope.message);
printed_error = Some(envelope.message);
}
}
Event::TurnUsage {
@@ -1491,13 +1487,15 @@ 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());
// 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: {failure}");
eprintln!("Error: exec turn failed: {error}");
let _ = io::stdout().flush();
std::process::exit(exit_code);
}
bail!("{failure}");
bail!("exec turn failed: {error}");
}
if matches!(
@@ -1511,36 +1509,10 @@ 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};
+8 -4
View File
@@ -229,10 +229,14 @@ 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()
/// Whether DeepSeek Harness looks set up here: a launcher on `PATH`, or a
/// `$DSH_HOME/.credentials.yaml` (a GUI-launched shell may lack the launcher
/// on `PATH` while the saved key still exists). A `PATH` scan and one
/// existence check only: nothing is run and no file is read.
pub(crate) fn dsh_present() -> bool {
let env = DetectEnv::from_process();
find_on_path(env.path.as_ref()).is_some()
|| resolve_dsh_home(&env).0.join(".credentials.yaml").exists()
}
fn find_on_path(path: Option<&OsString>) -> Option<PathBuf> {
+96 -78
View File
@@ -1099,26 +1099,21 @@ fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result<O
)
}
fn load_exec_resume_session(
session_id: &str,
output_format: ExecOutputFormat,
) -> Result<session_manager::SavedSession> {
fn load_exec_resume_session(session_id: &str) -> 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(|| exec_resume_load_error(session_id, output_format))
.with_context(|| exec_resume_load_error(session_id))
}
/// 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 {
/// 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)
} 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.
@@ -2568,7 +2563,7 @@ async fn run_async_main_dispatch(
)?;
let resume_session = resume_session_id
.as_deref()
.map(|id| load_exec_resume_session(id, args.output_format))
.map(load_exec_resume_session)
.transpose()?;
let explicit_model = args
.model
@@ -4688,11 +4683,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,
doctor_has_credentials_or_local_runtime(config),
config.api_provider().as_str(),
);
let verdict = doctor_verdict(&verdict_state, config.api_provider().as_str());
println!("{}", verdict.truecolor(aqua_r, aqua_g, aqua_b).bold());
println!();
@@ -5681,33 +5672,29 @@ 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).
/// `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, 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 {
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.".to_string()
} else {
"Not ready: first-run setup is unfinished → run `codewhale setup`.".to_string()
/// Doctor's one-line answer: ready, or the single next step (U7). Readiness
/// 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.
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(),
}
}
@@ -5715,24 +5702,30 @@ fn doctor_verdict(
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(), false, "deepseek");
let verdict = super::doctor_verdict(&codewhale_config::SetupState::default(), "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");
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]
@@ -5752,7 +5745,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, false, "deepseek");
let verdict = super::doctor_verdict(&state, "deepseek");
assert!(verdict.starts_with("Not ready"), "{verdict}");
assert!(verdict.contains("/provider"), "{verdict}");
@@ -5761,7 +5754,7 @@ mod doctor_verdict_tests {
StepEntry::new(StepStatus::Verified, true, "0.10.1"),
);
assert_eq!(
super::doctor_verdict(&state, false, "deepseek"),
super::doctor_verdict(&state, "deepseek"),
"Ready: setup is complete."
);
}
@@ -6423,13 +6416,10 @@ 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(
@@ -10442,17 +10432,34 @@ fn collect_diff(
}
/// Outside a work tree `git diff` prints its whole `--no-index` usage; say
/// what is actually wrong instead.
/// 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() || String::from_utf8_lossy(&output.stdout).trim() != "true" {
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());
}
Ok(())
bail!(
"git could not read the repository at {}: {}",
workspace.display(),
stderr.trim()
);
}
fn first_stderr_line(stderr: &str) -> &str {
@@ -17495,6 +17502,21 @@ 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");
@@ -17514,18 +17536,14 @@ api_key = "test-only-key"
}
#[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}");
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!(
stream.ends_with("Run `codewhale sessions` to list ids."),
"{stream}"
text.ends_with("Run `codewhale sessions` to list ids."),
"{text}"
);
}
@@ -17541,7 +17559,7 @@ api_key = "test-only-key"
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"
"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}");
+17 -9
View File
@@ -723,27 +723,35 @@ fn looks_like_authentication_failure(body: &str) -> bool {
|| lower.contains("missing token")
}
/// 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 {
/// 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",
"prompt is too long",
"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.
fn has_explicit_quota_evidence(body: &str) -> bool {
explicit_quota_code(body).is_some()
|| has_explicit_quota_code_marker(body)
+5
View File
@@ -163,6 +163,11 @@ fn http_response_boundary_classifies_status_contract() {
"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!(
+26 -4
View File
@@ -250,7 +250,9 @@ 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.
/// 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,
@@ -383,16 +385,25 @@ fn classify_provider_route_preflight_next_step(
"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") {
// 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."
),
@@ -1277,6 +1288,17 @@ mod tests {
"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"
);
@@ -145,9 +145,10 @@ api_key = "doctor-json-arbitrary-secret"
message.starts_with("Invalid provider (value not shown): expected deepseek"),
"{message}"
);
assert_eq!(
report["error"]["fix"],
"codewhale config set provider deepseek"
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{}",