mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
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:
1 parent
2562f29971
commit
a4740de765
12 files changed
+375
-190
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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{}",
|
||||
|
||||
Reference in new issue
Block a user