Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Gemini tool-call replays rejected by compatible gateways for a missing
`thought_signature` now explain how to recover: use the built-in `google`
provider or a gateway that preserves signatures, then start a new session.
Gateways that manage signatures themselves continue to work. Reported by
@Hmbown (#6048).
Comment thread
nightt5879 marked this conversation as resolved.
Outdated

## [0.9.13] - 2026-09-10

Codewhale v0.9.13 source candidate addresses integrity issues in 0.9.12:
Expand Down
81 changes: 81 additions & 0 deletions crates/tui/src/client/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7211,6 +7211,87 @@ mod google_thought_signature_tests {
request_from(signed_history(signature, true))
}

#[tokio::test]
async fn gateway_thought_signature_rejection_explains_recovery_after_transport() {
use crate::llm_client::LlmClient;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

let _ = rustls::crypto::ring::default_provider().install_default();
// An unsigned replay must reach the gateway: it may manage Google's
// signatures itself. Only an actual rejection warrants recovery advice.
for streaming in [false, true] {
for status in [200, 400] {
let server = MockServer::start().await;
let response = if status == 400 {
ResponseTemplate::new(status).set_body_json(json!({
"error": {
"code": 400,
"message": "Function call is missing a thought_signature in functionCall parts."
}
}))
} else if streaming {
ResponseTemplate::new(status)
.insert_header("content-type", "text/event-stream")
.set_body_string("data: [DONE]\n\n")
} else {
ResponseTemplate::new(status).set_body_json(json!({
"id": "gateway-replay",
"model": "gemini-3.1-pro-preview",
"choices": [{
"message": {"role": "assistant", "content": "Done."},
"finish_reason": "stop"
}]
}))
};
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(response)
.expect(1)
.mount(&server)
.await;

let mut client = DeepSeekClient::new(&crate::config::Config {
provider: Some("openai".to_string()),
providers: Some(crate::config::ProvidersConfig {
openai: crate::config::ProviderConfig {
api_key: Some("gateway-test-key".to_string()),
base_url: Some(format!("{}/v1", server.uri())),
model: Some("gemini-3.1-pro-preview".to_string()),
..crate::config::ProviderConfig::default()
},
..crate::config::ProvidersConfig::default()
}),
..crate::config::Config::default()
})
.expect("gateway client");
client.isolated_request_state = true;
let request = google_request_with_signed_tool(None);
let result = if streaming {
client.create_message_stream(request).await.map(|_| ())
} else {
client
.create_message_without_response_cache(request)
.await
.map(|_| ())
};
if status == 400 {
let error = result.expect_err("gateway rejects unsigned replay");
let message = error.to_string();
assert!(message.contains("built-in `google` provider"), "{message}");
assert!(message.contains("start a new session"), "{message}");
assert!(matches!(
error.downcast_ref::<crate::llm_client::LlmError>(),
Some(crate::llm_client::LlmError::InvalidRequest { status: 400, .. })
));
} else {
result.expect("gateway-managed signatures must still work");
}
server.verify().await;
}
}
}

#[test]
fn google_route_round_trips_thought_signatures_on_replayed_tool_calls() {
let request = google_request_with_signed_tool(Some("SIG-abc123"));
Expand Down
26 changes: 25 additions & 1 deletion crates/tui/src/llm_client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,31 @@ pub(crate) fn sanitize_http_error_body(
status: u16,
body: &str,
) -> String {
if let Some(message) = extract_json_error_message(body) {
let json_message = extract_json_error_message(body);
let message = json_message.as_deref().unwrap_or(body);
// Gate on Google's actual rejection, not the selected provider or model:
// compatible gateways may manage signatures themselves (#6048). This
// shared boundary covers both streaming and non-streaming HTTP failures.
const SIGNATURE_HINT: &str = "Gemini rejected tool-call replay because a thought signature is missing. \
Use the built-in `google` provider with its default endpoint, or a gateway that preserves \
Google thought signatures, then start a new session before using tools. \
Changing reasoning settings will not restore missing signatures.";
if status == 400
&& !is_probably_html(message)
&& explicit_quota_code(body).is_none()
&& !message.contains(SIGNATURE_HINT)
{
let lower = collapse_whitespace(message).to_ascii_lowercase();
if lower.contains("missing a thought_signature")
|| lower.contains("missing thought_signature")
|| lower.contains("thought_signature is missing")
{
let detail = truncate_for_error(&collapse_whitespace(message), 900);
return format!("{SIGNATURE_HINT} Provider error: {detail}");
}
}

if let Some(message) = json_message {
let message = truncate_for_error(&collapse_whitespace(&message), 2_000);
if let Some(code) = explicit_quota_code(body) {
return format!("{message} (provider error code: {code})");
Expand Down
84 changes: 84 additions & 0 deletions crates/tui/src/llm_client/tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,89 @@
use super::*;

#[test]
fn missing_google_thought_signature_errors_explain_recovery() {
for detail in [
"Function call is missing a thought_signature in functionCall parts.",
"Function call is missing thought_signature.",
"The thought_signature is missing from the function call.",
] {
for body in [
detail.to_string(),
serde_json::json!({"error": {"message": detail, "code": 400}}).to_string(),
serde_json::json!({"error": "Bad Request", "message": detail}).to_string(),
] {
let message = sanitize_http_error_body(Some("Custom"), 400, &body);
assert!(message.contains("built-in `google` provider"), "{message}");
assert!(message.contains("start a new session"), "{message}");
assert!(message.contains(detail), "provider detail must survive");
assert_eq!(sanitize_http_error_body(None, 400, &message), message);
let error = LlmError::from_http_response(400, &message);
assert!(matches!(
error,
LlmError::InvalidRequest { status: 400, .. }
));
assert!(!error.is_retryable());
}
}
}

#[test]
fn google_thought_signature_hint_requires_a_missing_signature_400() {
for (status, detail) in [
(400, "Invalid model name"),
(400, "Invalid thought_signature in functionCall parts"),
(400, "Unsupported parameter: thought_signature"),
(
401,
"Function call is missing a thought_signature in functionCall parts.",
),
(
429,
"Function call is missing a thought_signature in functionCall parts.",
),
(
500,
"Function call is missing a thought_signature in functionCall parts.",
),
] {
let body = serde_json::json!({"error": {"message": detail}}).to_string();
assert_eq!(
sanitize_http_error_body(Some("Custom"), status, &body),
detail
);
}
}

#[test]
fn google_thought_signature_hint_keeps_large_provider_errors_bounded() {
let body = format!(
"Function call is missing a thought_signature in functionCall parts. {}",
"界".repeat(3_000)
);
let message = sanitize_http_error_body(None, 400, &body);
assert!(message.contains("start a new session"));
assert!(message.chars().count() < 2_000);
assert_eq!(sanitize_http_error_body(None, 400, &message), message);
}

#[test]
fn google_thought_signature_hint_preserves_quota_and_html_handling() {
let detail = "Function call is missing a thought_signature in functionCall parts.";
let body = serde_json::json!({
"error": {"message": detail, "code": "insufficient_quota"}
})
.to_string();
let message = sanitize_http_error_body(None, 400, &body);
assert!(matches!(
LlmError::from_http_response(400, &message),
LlmError::QuotaExhausted(_)
));
let html = format!("<!doctype html><html><body>{detail}</body></html>");
let message = sanitize_http_error_body(None, 400, &html);
assert!(message.contains("HTML error page"));
assert!(!message.contains("<html>"));
}

#[test]
fn retryability_distinguishes_transient_failures_from_durable_failures() {
for error in [
Expand Down
1 change: 1 addition & 0 deletions docs/CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ notes, and relevant issue/PR comments.

**Reports and reproductions**

- **[Hmbown](https://github.com/Hmbown)** — identified missing recovery guidance when a compatible gateway rejects Gemini tool-call replay without thought signatures ([#6048](https://github.com/Hmbown/Codewhale/issues/6048)).
- **[7jrxt42BxFZo4iAnN4CX](https://github.com/7jrxt42BxFZo4iAnN4CX)** — proposed global usage and tool diagnostics and independent goal verification ([#6011](https://github.com/Hmbown/Codewhale/issues/6011), [#6013](https://github.com/Hmbown/Codewhale/issues/6013)); these broader requests remain open.
- **[nsfoxer](https://github.com/nsfoxer)** — reported the multiline-paste regression and incomplete provider model lists ([#5981](https://github.com/Hmbown/Codewhale/issues/5981), [#6009](https://github.com/Hmbown/Codewhale/issues/6009)).
- **[Nefelibata1024](https://github.com/Nefelibata1024)** — confirmed the multiline-paste regression's impact ([#5981](https://github.com/Hmbown/Codewhale/issues/5981)).
Expand Down
10 changes: 9 additions & 1 deletion web/lib/changelog.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,15 @@ export const CHANGELOG: ChangelogRelease[] = [
"date": null,
"unreleased": true,
"compareUrl": "https://github.com/Hmbown/CodeWhale/compare/v0.9.12...HEAD",
"sections": []
"sections": [
{
"heading": "Fixed",
"items": [
"Gemini tool-call replays rejected by compatible gateways for a missing thought_signature now explain how to recover: use the built-in google provider or a gateway that preserves signatures, then start a new session. Gateways that manage signatures themselves continue to work. Reported by @Hmbown (#6048)."
],
"itemCount": 1
}
]
},
{
"version": "0.9.13",
Expand Down
Loading