mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-14 00:00:19 +08:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a797de094 | ||
|
|
7c38d78759 | ||
|
|
cb572811e1 | ||
|
|
fa0d53a969 | ||
|
|
910ac4e326 | ||
|
|
a25717aded | ||
|
|
7a1391fbc1 | ||
|
|
2944e02a09 | ||
|
|
cd01ffc5f3 | ||
|
|
4361e98998 | ||
|
|
95f797f5e2 | ||
|
|
ff515c9aeb | ||
|
|
0f1a69659c | ||
|
|
990cc33c19 | ||
|
|
8d2c7b78b2 |
@@ -57,7 +57,7 @@ jobs:
|
||||
mkdir -p dist
|
||||
cp "target/release/${{ matrix.bin }}" "dist/${{ matrix.artifact_name }}"
|
||||
chmod +x "dist/${{ matrix.artifact_name }}"
|
||||
(cd dist && sha256sum "${{ matrix.artifact_name }}" > "${{ matrix.artifact_name }}.sha256")
|
||||
(cd dist && (sha256sum "${{ matrix.artifact_name }}" 2>/dev/null || shasum -a 256 "${{ matrix.artifact_name }}") > "${{ matrix.artifact_name }}.sha256")
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
|
||||
@@ -13,3 +13,5 @@ archive/
|
||||
status-help.txt
|
||||
# Legacy Python port session scratch artifacts
|
||||
.port_sessions/
|
||||
# Local agent session state
|
||||
.gjc/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"schema_version":1,"session_id":"5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299","state":"running","ready_for_input":false,"updated_at":"2026-10-12T22:09:30.659Z","current_turn_id":null,"last_turn_id":null,"live":true,"reason":null,"source":"agent_session_event","event":"turn_start","cwd":"/home/bellman/Data/Workspace/gc-task-cc-issue-3257-lane","workdir":"/home/bellman/Data/Workspace/gc-task-cc-issue-3257-lane","branch":null,"session_file":"/home/bellman/.gajaeway/broker/agent/sessions/v2-6fji37qf7ld523ivvoiohlcpmz3nu45m2apxyrkusn5nxczjmouq/2026-10-12T22-04-33-362Z_5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299.jsonl","activity":{"seq":115,"last_activity_at":"2026-10-12T22:09:34.144Z","tool":"bash","phase":"started","outcome":null,"elapsed_ms":null,"active_tool_count":1,"active_tools":[{"tool":"bash","started_at":"2026-10-12T22:09:34.144Z"}],"in_flight":[{"digest":"e603a70efb03b6b2b78c94b704197d26fc8a35113543291a21f68b3b08ca11a9","tool":"bash","started_at":"2026-10-12T22:09:34.144Z"}]}}
|
||||
@@ -1 +0,0 @@
|
||||
{"pid":1,"start_time":"unknown","token":"1a2e8a33-76f2-4175-ac23-1521458f12a2","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"pid":1,"start_time":"unknown","token":"a1342032-af80-4a49-a783-291bf73bf027","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sessionId": "5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299",
|
||||
"pid": 487360,
|
||||
"host": "127.0.0.1",
|
||||
"port": 37541,
|
||||
"url": "ws://127.0.0.1:37541",
|
||||
"token": "LlIfu_aHH57jfys2Ii0ORMrnu56NLAa1",
|
||||
"startedAt": 1791842673967,
|
||||
"updatedAt": 1791842673967,
|
||||
"stale": false
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"effectMarker":"f441857a-f591-474b-b34a-6d89b2bf9feb","incarnation":"linux:159099652","pid":487360}
|
||||
@@ -1 +0,0 @@
|
||||
{"effectMarker":"f441857a-f591-474b-b34a-6d89b2bf9feb","incarnation":"linux:159099652","pid":487360}
|
||||
@@ -52,19 +52,13 @@ pub enum ApiError {
|
||||
body_snippet: String,
|
||||
source: serde_json::Error,
|
||||
},
|
||||
Api {
|
||||
status: reqwest::StatusCode,
|
||||
error_type: Option<String>,
|
||||
message: Option<String>,
|
||||
request_id: Option<String>,
|
||||
body: String,
|
||||
retryable: bool,
|
||||
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413)
|
||||
suggested_action: Option<String>,
|
||||
/// Parsed Retry-After header value (seconds) for 429 responses.
|
||||
/// When present, overrides the exponential backoff delay.
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
/// A provider returned an error response.
|
||||
///
|
||||
/// Boxed: the payload is ~139 bytes, and `ApiError` is the `Err` half of nearly every
|
||||
/// `Result` in this crate, so inlining it inflates the size of every one of those returns
|
||||
/// (clippy::result_large_err). Boxing keeps `ApiError` small and means adding a field here
|
||||
/// cannot silently re-inflate every signature in the crate.
|
||||
Api(Box<ApiErrorDetails>),
|
||||
RetriesExhausted {
|
||||
attempts: u32,
|
||||
last_error: Box<ApiError>,
|
||||
@@ -81,7 +75,38 @@ pub enum ApiError {
|
||||
},
|
||||
}
|
||||
|
||||
/// Payload of [`ApiError::Api`], held behind a `Box` so the enum stays small.
|
||||
#[derive(Debug)]
|
||||
pub struct ApiErrorDetails {
|
||||
pub status: reqwest::StatusCode,
|
||||
pub error_type: Option<String>,
|
||||
pub message: Option<String>,
|
||||
pub request_id: Option<String>,
|
||||
pub body: String,
|
||||
pub retryable: bool,
|
||||
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413)
|
||||
pub suggested_action: Option<String>,
|
||||
/// Parsed Retry-After header value (seconds) for 429 responses.
|
||||
/// When present, overrides the exponential backoff delay.
|
||||
pub retry_after: Option<Duration>,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
/// Convenience constructor so callers do not have to spell out the `Box` at every site.
|
||||
#[must_use]
|
||||
pub fn api(details: ApiErrorDetails) -> Self {
|
||||
Self::Api(Box::new(details))
|
||||
}
|
||||
|
||||
/// Borrow the provider-error payload, if this is one.
|
||||
#[must_use]
|
||||
pub fn api_details(&self) -> Option<&ApiErrorDetails> {
|
||||
match self {
|
||||
Self::Api(details) => Some(details),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn missing_credentials(
|
||||
provider: &'static str,
|
||||
@@ -137,7 +162,7 @@ impl ApiError {
|
||||
/// over the computed backoff delay when it exists.
|
||||
pub fn retry_after(&self) -> Option<Duration> {
|
||||
match self {
|
||||
Self::Api { retry_after, .. } => *retry_after,
|
||||
Self::Api(details) => details.retry_after,
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.retry_after(),
|
||||
_ => None,
|
||||
}
|
||||
@@ -146,7 +171,7 @@ impl ApiError {
|
||||
pub fn is_retryable(&self) -> bool {
|
||||
match self {
|
||||
Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(),
|
||||
Self::Api { retryable, .. } => *retryable,
|
||||
Self::Api(details) => details.retryable,
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
|
||||
Self::MissingCredentials { .. }
|
||||
| Self::ContextWindowExceeded { .. }
|
||||
@@ -164,7 +189,7 @@ impl ApiError {
|
||||
#[must_use]
|
||||
pub fn request_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Api { request_id, .. } => request_id.as_deref(),
|
||||
Self::Api(details) => details.request_id.as_deref(),
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.request_id(),
|
||||
Self::MissingCredentials { .. }
|
||||
| Self::ContextWindowExceeded { .. }
|
||||
@@ -191,12 +216,12 @@ impl ApiError {
|
||||
Self::MissingCredentials { .. } | Self::ExpiredOAuthToken | Self::Auth(_) => {
|
||||
"provider_auth"
|
||||
}
|
||||
Self::Api { status, .. } if matches!(status.as_u16(), 401 | 403) => "provider_auth",
|
||||
Self::Api(details) if matches!(details.status.as_u16(), 401 | 403) => "provider_auth",
|
||||
Self::ContextWindowExceeded { .. } => "context_window",
|
||||
Self::Api { .. } if self.is_context_window_failure() => "context_window",
|
||||
Self::Api { status, .. } if status.as_u16() == 429 => "provider_rate_limit",
|
||||
Self::Api { .. } if self.is_generic_fatal_wrapper() => "provider_internal",
|
||||
Self::Api { .. } => "provider_error",
|
||||
Self::Api(_) if self.is_context_window_failure() => "context_window",
|
||||
Self::Api(details) if details.status.as_u16() == 429 => "provider_rate_limit",
|
||||
Self::Api(_) if self.is_generic_fatal_wrapper() => "provider_internal",
|
||||
Self::Api(_) => "provider_error",
|
||||
Self::Http(_) | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => {
|
||||
"provider_transport"
|
||||
}
|
||||
@@ -208,11 +233,12 @@ impl ApiError {
|
||||
#[must_use]
|
||||
pub fn is_generic_fatal_wrapper(&self) -> bool {
|
||||
match self {
|
||||
Self::Api { message, body, .. } => {
|
||||
message
|
||||
Self::Api(details) => {
|
||||
details
|
||||
.message
|
||||
.as_deref()
|
||||
.is_some_and(looks_like_generic_fatal_wrapper)
|
||||
|| looks_like_generic_fatal_wrapper(body)
|
||||
|| looks_like_generic_fatal_wrapper(&details.body)
|
||||
}
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.is_generic_fatal_wrapper(),
|
||||
Self::MissingCredentials { .. }
|
||||
@@ -229,21 +255,36 @@ impl ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
/// True for failures whose own text cannot say what went wrong: transport, decode, and frame
|
||||
/// errors.
|
||||
///
|
||||
/// These matter because a server that answers an oversized request with a non-SSE HTTP 500
|
||||
/// body lands here rather than in [`Self::is_context_window_failure`] — reqwest reports
|
||||
/// "error decoding response body" and the real cause is lost. Callers that know how large the
|
||||
/// request was can combine the two signals; callers that do not must treat this as unknown,
|
||||
/// never as an overflow.
|
||||
#[must_use]
|
||||
pub fn is_ambiguous_transport_failure(&self) -> bool {
|
||||
match self {
|
||||
Self::Http(_) | Self::Json { .. } | Self::InvalidSseFrame(_) => true,
|
||||
Self::RetriesExhausted { last_error, .. } => {
|
||||
last_error.is_ambiguous_transport_failure()
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_context_window_failure(&self) -> bool {
|
||||
match self {
|
||||
Self::ContextWindowExceeded { .. } => true,
|
||||
Self::Api {
|
||||
status,
|
||||
message,
|
||||
body,
|
||||
..
|
||||
} => {
|
||||
matches!(status.as_u16(), 400 | 413 | 422)
|
||||
&& (message
|
||||
Self::Api(details) => {
|
||||
matches!(details.status.as_u16(), 400 | 413 | 422)
|
||||
&& (details
|
||||
.message
|
||||
.as_deref()
|
||||
.is_some_and(looks_like_context_window_error)
|
||||
|| looks_like_context_window_error(body))
|
||||
|| looks_like_context_window_error(&details.body))
|
||||
}
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.is_context_window_failure(),
|
||||
Self::MissingCredentials { .. }
|
||||
@@ -327,14 +368,14 @@ impl Display for ApiError {
|
||||
"failed to parse {provider} response for model {model}: {source}; first 200 chars of body: {body_snippet}"
|
||||
),
|
||||
// #28: enhance 401/403 errors with actionable auth guidance
|
||||
Self::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
..
|
||||
} if matches!(status.as_u16(), 401 | 403) => {
|
||||
Self::Api(details) if matches!(details.status.as_u16(), 401 | 403) => {
|
||||
let (status, error_type, message, request_id, body) = (
|
||||
&details.status,
|
||||
&details.error_type,
|
||||
&details.message,
|
||||
&details.request_id,
|
||||
&details.body,
|
||||
);
|
||||
if let (Some(error_type), Some(message)) = (error_type, message) {
|
||||
write!(f, "api returned {status} ({error_type})")?;
|
||||
if let Some(request_id) = request_id {
|
||||
@@ -356,14 +397,14 @@ impl Display for ApiError {
|
||||
Run `claw doctor` to verify your credential configuration."
|
||||
)
|
||||
}
|
||||
Self::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
..
|
||||
} => {
|
||||
Self::Api(details) => {
|
||||
let (status, error_type, message, request_id, body) = (
|
||||
&details.status,
|
||||
&details.error_type,
|
||||
&details.message,
|
||||
&details.request_id,
|
||||
&details.body,
|
||||
);
|
||||
if let (Some(error_type), Some(message)) = (error_type, message) {
|
||||
write!(f, "api returned {status} ({error_type})")?;
|
||||
if let Some(request_id) = request_id {
|
||||
@@ -469,6 +510,7 @@ fn truncate_body_snippet(body: &str, max_chars: usize) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ApiErrorDetails;
|
||||
use super::{truncate_body_snippet, ApiError};
|
||||
|
||||
#[test]
|
||||
@@ -533,7 +575,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() {
|
||||
let error = ApiError::Api {
|
||||
let error = ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
error_type: Some("api_error".to_string()),
|
||||
message: Some(
|
||||
@@ -545,7 +587,7 @@ mod tests {
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
}));
|
||||
|
||||
assert!(error.is_generic_fatal_wrapper());
|
||||
assert_eq!(error.safe_failure_class(), "provider_internal");
|
||||
@@ -557,7 +599,7 @@ mod tests {
|
||||
fn retries_exhausted_preserves_nested_request_id_and_failure_class() {
|
||||
let error = ApiError::RetriesExhausted {
|
||||
attempts: 3,
|
||||
last_error: Box::new(ApiError::Api {
|
||||
last_error: Box::new(ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::BAD_GATEWAY,
|
||||
error_type: Some("api_error".to_string()),
|
||||
message: Some(
|
||||
@@ -569,7 +611,7 @@ mod tests {
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
}),
|
||||
}))),
|
||||
};
|
||||
|
||||
assert!(error.is_generic_fatal_wrapper());
|
||||
@@ -579,7 +621,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn classifies_provider_context_window_errors() {
|
||||
let error = ApiError::Api {
|
||||
let error = ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::BAD_REQUEST,
|
||||
error_type: Some("invalid_request_error".to_string()),
|
||||
message: Some(
|
||||
@@ -591,7 +633,7 @@ mod tests {
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
}));
|
||||
|
||||
assert!(error.is_context_window_failure());
|
||||
assert_eq!(error.safe_failure_class(), "context_window");
|
||||
@@ -600,7 +642,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn classifies_openai_configured_limit_errors_as_context_window_failures() {
|
||||
let error = ApiError::Api {
|
||||
let error = ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::BAD_REQUEST,
|
||||
error_type: Some("invalid_request_error".to_string()),
|
||||
message: Some(
|
||||
@@ -612,7 +654,7 @@ mod tests {
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
}));
|
||||
|
||||
assert!(error.is_context_window_failure());
|
||||
assert_eq!(error.safe_failure_class(), "context_window");
|
||||
|
||||
@@ -10,7 +10,7 @@ pub use client::{
|
||||
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
|
||||
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
|
||||
};
|
||||
pub use error::ApiError;
|
||||
pub use error::{ApiError, ApiErrorDetails};
|
||||
pub use http_client::{
|
||||
build_http_client, build_http_client_or_default, build_http_client_with,
|
||||
build_http_client_with_opts, ProxyConfig, TimeoutConfig,
|
||||
@@ -27,9 +27,10 @@ pub use providers::openai_compat::{
|
||||
OpenAiCompatClient, OpenAiCompatConfig,
|
||||
};
|
||||
pub use providers::{
|
||||
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
|
||||
model_family_identity_for, model_family_identity_for_kind, provider_diagnostics_for_model,
|
||||
resolve_model_alias, ProviderDiagnostics, ProviderKind,
|
||||
context_window_override, detect_provider_kind, estimate_message_request_input_tokens,
|
||||
max_tokens_for_model, max_tokens_for_model_with_override, model_family_identity_for,
|
||||
model_family_identity_for_kind, model_token_limit, provider_diagnostics_for_model,
|
||||
resolve_model_alias, ModelTokenLimit, ProviderDiagnostics, ProviderKind,
|
||||
};
|
||||
pub use sse::{parse_frame, SseParser};
|
||||
pub use types::{
|
||||
|
||||
@@ -12,7 +12,7 @@ use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, SessionTracer};
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::error::{ApiError, ApiErrorDetails};
|
||||
use crate::http_client::build_http_client_or_default;
|
||||
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
|
||||
|
||||
@@ -892,7 +892,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
let retryable = is_retryable_status(status);
|
||||
let retry_after = parse_retry_after(&headers, status);
|
||||
|
||||
Err(ApiError::Api {
|
||||
Err(ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status,
|
||||
error_type: parsed_error
|
||||
.as_ref()
|
||||
@@ -905,7 +905,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
retryable,
|
||||
suggested_action: None,
|
||||
retry_after,
|
||||
})
|
||||
})))
|
||||
}
|
||||
|
||||
fn parse_retry_after(
|
||||
@@ -952,86 +952,30 @@ fn is_retryable_400(status: reqwest::StatusCode, body: &str) -> bool {
|
||||
const SK_ANT_BEARER_HINT: &str = "sk-ant-* keys go in ANTHROPIC_API_KEY (x-api-key header), not ANTHROPIC_AUTH_TOKEN (Bearer header). Move your key to ANTHROPIC_API_KEY.";
|
||||
|
||||
fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
let ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
..
|
||||
} = error
|
||||
else {
|
||||
let ApiError::Api(mut details) = error else {
|
||||
return error;
|
||||
};
|
||||
if status.as_u16() != 401 {
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
if details.status.as_u16() != 401 {
|
||||
return ApiError::Api(details);
|
||||
}
|
||||
let Some(bearer_token) = auth.bearer_token() else {
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
return ApiError::Api(details);
|
||||
};
|
||||
if !bearer_token.starts_with("sk-ant-") {
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
return ApiError::Api(details);
|
||||
}
|
||||
// Only append the hint when the AuthSource is pure BearerToken. If both
|
||||
// api_key and bearer_token are present (`ApiKeyAndBearer`), the x-api-key
|
||||
// header is already being sent alongside the Bearer header and the 401
|
||||
// is coming from a different cause — adding the hint would be misleading.
|
||||
if auth.api_key().is_some() {
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
}
|
||||
let enriched_message = match message {
|
||||
Some(existing) => Some(format!("{existing} — hint: {SK_ANT_BEARER_HINT}")),
|
||||
None => Some(format!("hint: {SK_ANT_BEARER_HINT}")),
|
||||
};
|
||||
ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message: enriched_message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
return ApiError::Api(details);
|
||||
}
|
||||
details.message = Some(match details.message.take() {
|
||||
Some(existing) => format!("{existing} — hint: {SK_ANT_BEARER_HINT}"),
|
||||
None => format!("hint: {SK_ANT_BEARER_HINT}"),
|
||||
});
|
||||
ApiError::Api(details)
|
||||
}
|
||||
|
||||
fn anthropic_wire_model(model: &str) -> &str {
|
||||
@@ -1083,6 +1027,7 @@ struct AnthropicErrorBody {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
|
||||
use crate::error::ApiErrorDetails;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
@@ -1647,7 +1592,7 @@ mod tests {
|
||||
fn enrich_bearer_auth_error_appends_sk_ant_hint_on_401_with_pure_bearer_token() {
|
||||
// given
|
||||
let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string());
|
||||
let error = crate::error::ApiError::Api {
|
||||
let error = crate::error::ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid bearer token".to_string()),
|
||||
@@ -1656,7 +1601,7 @@ mod tests {
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
}));
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
@@ -1678,8 +1623,8 @@ mod tests {
|
||||
"request id should still flow through the enriched error: {rendered}"
|
||||
);
|
||||
match enriched {
|
||||
crate::error::ApiError::Api { status, .. } => {
|
||||
assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED);
|
||||
crate::error::ApiError::Api(details) => {
|
||||
assert_eq!(details.status, reqwest::StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
other => panic!("expected Api variant, got {other:?}"),
|
||||
}
|
||||
@@ -1689,7 +1634,7 @@ mod tests {
|
||||
fn enrich_bearer_auth_error_leaves_non_401_errors_unchanged() {
|
||||
// given
|
||||
let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string());
|
||||
let error = crate::error::ApiError::Api {
|
||||
let error = crate::error::ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
error_type: Some("api_error".to_string()),
|
||||
message: Some("internal server error".to_string()),
|
||||
@@ -1698,7 +1643,7 @@ mod tests {
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
}));
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
@@ -1719,7 +1664,7 @@ mod tests {
|
||||
fn enrich_bearer_auth_error_ignores_401_when_bearer_token_is_not_sk_ant() {
|
||||
// given
|
||||
let auth = AuthSource::BearerToken("oauth-access-token-opaque".to_string());
|
||||
let error = crate::error::ApiError::Api {
|
||||
let error = crate::error::ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid bearer token".to_string()),
|
||||
@@ -1728,7 +1673,7 @@ mod tests {
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
}));
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
@@ -1748,7 +1693,7 @@ mod tests {
|
||||
api_key: "sk-ant-api03-legitimate".to_string(),
|
||||
bearer_token: "sk-ant-api03-deadbeef".to_string(),
|
||||
};
|
||||
let error = crate::error::ApiError::Api {
|
||||
let error = crate::error::ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid bearer token".to_string()),
|
||||
@@ -1757,7 +1702,7 @@ mod tests {
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
}));
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
@@ -1774,7 +1719,7 @@ mod tests {
|
||||
fn enrich_bearer_auth_error_ignores_401_when_auth_source_has_no_bearer() {
|
||||
// given
|
||||
let auth = AuthSource::ApiKey("sk-ant-api03-legitimate".to_string());
|
||||
let error = crate::error::ApiError::Api {
|
||||
let error = crate::error::ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid x-api-key".to_string()),
|
||||
@@ -1783,7 +1728,7 @@ mod tests {
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
}));
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
|
||||
@@ -619,16 +619,43 @@ fn web_passthrough_diagnostic(
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides the context window every sizing guard is computed against.
|
||||
///
|
||||
/// The model *name* is not a reliable statement of the window when the endpoint is a local
|
||||
/// server: the harness sends its compiled-in default name (a Claude alias) while the backend
|
||||
/// serves whatever `-c` it was started with. Every guard downstream of [`model_token_limit`]
|
||||
/// then sizes itself against the name's window instead of the real one, which makes the
|
||||
/// preflights unable to fire rather than merely inaccurate. This variable is the operator's
|
||||
/// statement of the truth, and it wins over the table.
|
||||
const CONTEXT_WINDOW_ENV_VAR: &str = "CLAW_CONTEXT_WINDOW";
|
||||
|
||||
/// Fraction of the window a single response may be allowed to claim when the window is
|
||||
/// declared. Output and input share one budget on a local server, so reserving a quarter for
|
||||
/// the reply leaves three quarters for prompt plus transcript.
|
||||
const MAX_OUTPUT_SHARE_OF_WINDOW: u32 = 4;
|
||||
|
||||
/// The operator-declared context window, when set and parseable as a positive token count.
|
||||
#[must_use]
|
||||
pub fn context_window_override() -> Option<u32> {
|
||||
parse_context_window_override(std::env::var(CONTEXT_WINDOW_ENV_VAR).ok().as_deref())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn parse_context_window_override(value: Option<&str>) -> Option<u32> {
|
||||
value
|
||||
.and_then(|raw| raw.trim().parse::<u32>().ok())
|
||||
.filter(|window| *window > 0)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn max_tokens_for_model(model: &str) -> u32 {
|
||||
let canonical = resolve_model_alias(model);
|
||||
let heuristic = if canonical.contains("opus") {
|
||||
32_000
|
||||
} else {
|
||||
64_000
|
||||
};
|
||||
|
||||
model_token_limit(model).map_or(heuristic, |limit| heuristic.min(limit.max_output_tokens))
|
||||
// A declared window caps the reply too. Asking a 65k server for 32k of output reserves half
|
||||
// the window for a single message, which the server then silently clamps — leaving the
|
||||
// client's own accounting wrong by the difference.
|
||||
model_token_limit(model).map_or_else(
|
||||
|| max_tokens_for_model_by_name(model),
|
||||
|limit| max_tokens_for_model_by_name(model).min(limit.max_output_tokens),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the effective max output tokens for a model, preferring a plugin
|
||||
@@ -641,6 +668,33 @@ pub fn max_tokens_for_model_with_override(model: &str, plugin_override: Option<u
|
||||
|
||||
#[must_use]
|
||||
pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
||||
// A declared window replaces the table's, and supplies one for models the table has never
|
||||
// heard of. Returning `Some` for an unknown model is the point: `None` makes both preflights
|
||||
// return `Ok` without looking at anything, so an unrecognised local model gets no guard at all.
|
||||
if let Some(context_window_tokens) = context_window_override() {
|
||||
return Some(ModelTokenLimit {
|
||||
max_output_tokens: max_tokens_for_model_by_name(model)
|
||||
.min(context_window_tokens / MAX_OUTPUT_SHARE_OF_WINDOW),
|
||||
context_window_tokens,
|
||||
});
|
||||
}
|
||||
model_token_limit_by_name(model)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn max_tokens_for_model_by_name(model: &str) -> u32 {
|
||||
let canonical = resolve_model_alias(model);
|
||||
let heuristic = if canonical.contains("opus") {
|
||||
32_000
|
||||
} else {
|
||||
64_000
|
||||
};
|
||||
model_token_limit_by_name(model)
|
||||
.map_or(heuristic, |limit| heuristic.min(limit.max_output_tokens))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn model_token_limit_by_name(model: &str) -> Option<ModelTokenLimit> {
|
||||
let canonical = resolve_model_alias(model);
|
||||
let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str());
|
||||
match base_model {
|
||||
@@ -723,7 +777,7 @@ pub fn preflight_message_request(request: &MessageRequest) -> Result<(), ApiErro
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn estimate_message_request_input_tokens(request: &MessageRequest) -> u32 {
|
||||
pub fn estimate_message_request_input_tokens(request: &MessageRequest) -> u32 {
|
||||
let mut estimate = estimate_serialized_tokens(&request.messages);
|
||||
estimate = estimate.saturating_add(estimate_serialized_tokens(&request.system));
|
||||
estimate = estimate.saturating_add(estimate_serialized_tokens(&request.tools));
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::error::{ApiError, ApiErrorDetails};
|
||||
use crate::http_client::build_http_client_or_default;
|
||||
use crate::types::{
|
||||
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
|
||||
@@ -239,7 +239,7 @@ impl OpenAiCompatClient {
|
||||
.get("code")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.map(|c| c as u16);
|
||||
return Err(ApiError::Api {
|
||||
return Err(ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::from_u16(code.unwrap_or(400))
|
||||
.unwrap_or(reqwest::StatusCode::BAD_REQUEST),
|
||||
error_type: err_obj
|
||||
@@ -255,7 +255,7 @@ impl OpenAiCompatClient {
|
||||
.unwrap_or(reqwest::StatusCode::BAD_REQUEST),
|
||||
),
|
||||
retry_after: None,
|
||||
});
|
||||
})));
|
||||
}
|
||||
}
|
||||
let payload = serde_json::from_str::<ChatCompletionResponse>(&body).map_err(|error| {
|
||||
@@ -1613,7 +1613,7 @@ fn parse_sse_frame(
|
||||
.map(|c| c as u16);
|
||||
let status = reqwest::StatusCode::from_u16(code.unwrap_or(500))
|
||||
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
return Err(ApiError::Api {
|
||||
return Err(ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status,
|
||||
error_type: err_obj
|
||||
.get("type")
|
||||
@@ -1625,12 +1625,12 @@ fn parse_sse_frame(
|
||||
retryable: false,
|
||||
suggested_action: suggested_action_for_status(status),
|
||||
retry_after: None,
|
||||
});
|
||||
})));
|
||||
}
|
||||
}
|
||||
// Detect HTML responses
|
||||
if trimmed.starts_with('<') || trimmed.starts_with("<!") {
|
||||
return Err(ApiError::Api {
|
||||
return Err(ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::BAD_REQUEST,
|
||||
error_type: Some("invalid_response".to_string()),
|
||||
message: Some(
|
||||
@@ -1641,7 +1641,7 @@ fn parse_sse_frame(
|
||||
retryable: false,
|
||||
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
|
||||
retry_after: None,
|
||||
});
|
||||
})));
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -1665,7 +1665,7 @@ fn parse_sse_frame(
|
||||
.map(|c| c as u16);
|
||||
let status = reqwest::StatusCode::from_u16(code.unwrap_or(400))
|
||||
.unwrap_or(reqwest::StatusCode::BAD_REQUEST);
|
||||
return Err(ApiError::Api {
|
||||
return Err(ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status,
|
||||
error_type: err_obj
|
||||
.get("type")
|
||||
@@ -1677,13 +1677,13 @@ fn parse_sse_frame(
|
||||
retryable: false,
|
||||
suggested_action: suggested_action_for_status(status),
|
||||
retry_after: None,
|
||||
});
|
||||
})));
|
||||
}
|
||||
}
|
||||
// Detect HTML or other non-JSON responses early for better error messages
|
||||
let trimmed_payload = payload.trim();
|
||||
if trimmed_payload.starts_with('<') || trimmed_payload.starts_with("<!") {
|
||||
return Err(ApiError::Api {
|
||||
return Err(ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::BAD_REQUEST,
|
||||
error_type: Some("invalid_response".to_string()),
|
||||
message: Some(
|
||||
@@ -1694,7 +1694,7 @@ fn parse_sse_frame(
|
||||
retryable: false,
|
||||
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
|
||||
retry_after: None,
|
||||
});
|
||||
})));
|
||||
}
|
||||
serde_json::from_str::<ChatCompletionChunk>(&payload)
|
||||
.map(Some)
|
||||
@@ -1754,7 +1754,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
|
||||
let suggested_action = suggested_action_for_status(status);
|
||||
|
||||
Err(ApiError::Api {
|
||||
Err(ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status,
|
||||
error_type: parsed_error
|
||||
.as_ref()
|
||||
@@ -1767,7 +1767,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
})
|
||||
})))
|
||||
}
|
||||
|
||||
fn parse_retry_after(
|
||||
|
||||
@@ -585,14 +585,11 @@ async fn surfaces_retry_exhaustion_for_persistent_retryable_errors() {
|
||||
last_error,
|
||||
} => {
|
||||
assert_eq!(attempts, 2);
|
||||
assert!(matches!(
|
||||
*last_error,
|
||||
ApiError::Api {
|
||||
status: reqwest::StatusCode::SERVICE_UNAVAILABLE,
|
||||
retryable: true,
|
||||
..
|
||||
}
|
||||
));
|
||||
let details = last_error
|
||||
.api_details()
|
||||
.expect("last error should be a provider Api error");
|
||||
assert_eq!(details.status, reqwest::StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert!(details.retryable);
|
||||
}
|
||||
other => panic!("expected retries exhausted, got {other:?}"),
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ async fn flush_path_batch(
|
||||
#[cfg(feature = "qdrant-index")]
|
||||
let mut qdrant_points: Vec<ChunkPoint> = Vec::with_capacity(batch.len());
|
||||
|
||||
for ((ord, t), vec) in batch.drain(..).zip(vecs.into_iter()) {
|
||||
for ((ord, t), vec) in batch.drain(..).zip(vecs) {
|
||||
let dim = vec.len();
|
||||
let cid = insert_chunk(conn, path, ord, &t)?;
|
||||
insert_embedding(conn, cid, dim, &vec)?;
|
||||
|
||||
@@ -13,6 +13,16 @@ use tokio::task::JoinHandle;
|
||||
pub const SCENARIO_PREFIX: &str = "PARITY_SCENARIO:";
|
||||
pub const DEFAULT_MODEL: &str = "claude-sonnet-4-6";
|
||||
|
||||
/// The assistant text the `context_window_retry` scenario finally streams, after
|
||||
/// its first request was refused with a context-overflow error. Exposed so a
|
||||
/// test can assert on the exact body instead of restating the literal.
|
||||
pub const CONTEXT_WINDOW_RETRY_TEXT: &str = "auto compact retry parity complete.";
|
||||
|
||||
/// The partial assistant text the `stream_then_error` scenario streams before it
|
||||
/// fails the response mid-stream. Exposed so a test can assert on the exact body
|
||||
/// instead of restating the literal.
|
||||
pub const STREAM_THEN_ERROR_TEXT: &str = "partial response before the stream failed";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CapturedRequest {
|
||||
pub method: String,
|
||||
@@ -99,6 +109,8 @@ enum Scenario {
|
||||
BashPermissionPromptDenied,
|
||||
PluginToolRoundtrip,
|
||||
AutoCompactTriggered,
|
||||
ContextWindowRetry,
|
||||
StreamThenError,
|
||||
TokenCostReporting,
|
||||
}
|
||||
|
||||
@@ -116,6 +128,8 @@ impl Scenario {
|
||||
"bash_permission_prompt_denied" => Some(Self::BashPermissionPromptDenied),
|
||||
"plugin_tool_roundtrip" => Some(Self::PluginToolRoundtrip),
|
||||
"auto_compact_triggered" => Some(Self::AutoCompactTriggered),
|
||||
"context_window_retry" => Some(Self::ContextWindowRetry),
|
||||
"stream_then_error" => Some(Self::StreamThenError),
|
||||
"token_cost_reporting" => Some(Self::TokenCostReporting),
|
||||
_ => None,
|
||||
}
|
||||
@@ -134,6 +148,8 @@ impl Scenario {
|
||||
Self::BashPermissionPromptDenied => "bash_permission_prompt_denied",
|
||||
Self::PluginToolRoundtrip => "plugin_tool_roundtrip",
|
||||
Self::AutoCompactTriggered => "auto_compact_triggered",
|
||||
Self::ContextWindowRetry => "context_window_retry",
|
||||
Self::StreamThenError => "stream_then_error",
|
||||
Self::TokenCostReporting => "token_cost_reporting",
|
||||
}
|
||||
}
|
||||
@@ -149,6 +165,18 @@ async fn handle_connection(
|
||||
let scenario = detect_scenario(&request)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing parity scenario"))?;
|
||||
|
||||
// How many times this scenario has already been served *on this endpoint*.
|
||||
// Scenarios that need to behave differently on a retry (reject once, then
|
||||
// succeed) key off this; counting per endpoint keeps the token-counting
|
||||
// preflight on `/v1/messages/count_tokens` from consuming the first attempt.
|
||||
let attempt = {
|
||||
let served = requests.lock().await;
|
||||
served
|
||||
.iter()
|
||||
.filter(|captured| captured.scenario == scenario.name() && captured.path == path)
|
||||
.count()
|
||||
};
|
||||
|
||||
requests.lock().await.push(CapturedRequest {
|
||||
method,
|
||||
path,
|
||||
@@ -158,7 +186,7 @@ async fn handle_connection(
|
||||
raw_body,
|
||||
});
|
||||
|
||||
let response = build_http_response(&request, scenario);
|
||||
let response = build_http_response(&request, scenario, attempt);
|
||||
socket.write_all(response.as_bytes()).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -308,7 +336,11 @@ fn flatten_tool_result_content(content: &[api::ToolResultContentBlock]) -> Strin
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn build_http_response(request: &MessageRequest, scenario: Scenario) -> String {
|
||||
fn build_http_response(request: &MessageRequest, scenario: Scenario, attempt: usize) -> String {
|
||||
if let Some(rejection) = context_window_rejection(scenario, attempt) {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
let response = if request.stream {
|
||||
let body = build_stream_body(request, scenario);
|
||||
return http_response(
|
||||
@@ -329,6 +361,37 @@ fn build_http_response(request: &MessageRequest, scenario: Scenario) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// The first request of [`Scenario::ContextWindowRetry`] is refused with a
|
||||
/// context-overflow error instead of a streamed answer, so the client's
|
||||
/// auto-compact recovery path runs and the *retry* is what produces the
|
||||
/// response text. Every later request against the same endpoint succeeds.
|
||||
///
|
||||
/// HTTP 400 is deliberately chosen: it is not in the client's retryable status
|
||||
/// set, so the client surfaces the error rather than silently re-sending it —
|
||||
/// which is exactly the situation a real context-overflow rejection creates.
|
||||
/// The body carries the `context_window` marker the client greps for.
|
||||
fn context_window_rejection(scenario: Scenario, attempt: usize) -> Option<String> {
|
||||
if scenario != Scenario::ContextWindowRetry || attempt > 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let body = json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"message": "prompt is too long: context_window exceeded for this model",
|
||||
},
|
||||
})
|
||||
.to_string();
|
||||
|
||||
Some(http_response(
|
||||
"400 Bad Request",
|
||||
"application/json",
|
||||
&body,
|
||||
&[("request-id", request_id_for(scenario))],
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn build_stream_body(request: &MessageRequest, scenario: Scenario) -> String {
|
||||
match scenario {
|
||||
@@ -461,6 +524,8 @@ fn build_stream_body(request: &MessageRequest, scenario: Scenario) -> String {
|
||||
Scenario::AutoCompactTriggered => {
|
||||
final_text_sse_with_usage("auto compact parity complete.", 50_000, 200)
|
||||
}
|
||||
Scenario::ContextWindowRetry => final_text_sse(CONTEXT_WINDOW_RETRY_TEXT),
|
||||
Scenario::StreamThenError => stream_then_error_sse(STREAM_THEN_ERROR_TEXT),
|
||||
Scenario::TokenCostReporting => {
|
||||
final_text_sse_with_usage("token cost reporting parity complete.", 1_000, 500)
|
||||
}
|
||||
@@ -628,6 +693,12 @@ fn build_message_response(request: &MessageRequest, scenario: Scenario) -> Messa
|
||||
50_000,
|
||||
200,
|
||||
),
|
||||
Scenario::ContextWindowRetry => {
|
||||
text_message_response("msg_context_window_retry", CONTEXT_WINDOW_RETRY_TEXT)
|
||||
}
|
||||
Scenario::StreamThenError => {
|
||||
text_message_response("msg_stream_then_error", STREAM_THEN_ERROR_TEXT)
|
||||
}
|
||||
Scenario::TokenCostReporting => text_message_response_with_usage(
|
||||
"msg_token_cost_reporting",
|
||||
"token cost reporting parity complete.",
|
||||
@@ -650,6 +721,8 @@ fn request_id_for(scenario: Scenario) -> &'static str {
|
||||
Scenario::BashPermissionPromptDenied => "req_bash_permission_prompt_denied",
|
||||
Scenario::PluginToolRoundtrip => "req_plugin_tool_roundtrip",
|
||||
Scenario::AutoCompactTriggered => "req_auto_compact_triggered",
|
||||
Scenario::ContextWindowRetry => "req_context_window_retry",
|
||||
Scenario::StreamThenError => "req_stream_then_error",
|
||||
Scenario::TokenCostReporting => "req_token_cost_reporting",
|
||||
}
|
||||
}
|
||||
@@ -971,6 +1044,84 @@ fn final_text_sse(text: &str) -> String {
|
||||
body
|
||||
}
|
||||
|
||||
/// Streams a text block to completion and then fails the response, which is
|
||||
/// what a provider does when it dies partway through an answer.
|
||||
///
|
||||
/// The block is closed before the failure on purpose: the client only flushes
|
||||
/// its buffered markdown to the terminal on `content_block_stop`, so without
|
||||
/// that frame the partial text would still be sitting in the renderer and the
|
||||
/// scenario would not reproduce a turn that streamed text and *then* errored.
|
||||
///
|
||||
/// The response is cut off in the middle of the failing frame rather than
|
||||
/// carrying a complete one. Two things fall out of that, both deliberate.
|
||||
///
|
||||
/// It is what a provider that dies on the socket actually looks like, and it
|
||||
/// keeps the failure in a *later* parser call than the answer text. The whole
|
||||
/// response is one `write_all` on the mock's side, so a complete trailing frame
|
||||
/// would land in the same read chunk as the deltas — and `SseParser::push`
|
||||
/// returns `Err` from inside its frame loop, which discards the events it had
|
||||
/// already parsed from that same chunk. The client would then never render the
|
||||
/// text at all, and the scenario would be exercising that parser behaviour
|
||||
/// instead of the terminal state it is meant to reproduce. A truncated tail
|
||||
/// stays in the parser's buffer, so `push` returns the deltas normally and the
|
||||
/// failure only surfaces from `finish()`, once the client has consumed every
|
||||
/// event ahead of it.
|
||||
///
|
||||
/// The failure is a frame that does not deserialize — `api::types::StreamEvent`
|
||||
/// is `#[serde(tag = "type")]` with no fallback arm, so an unknown frame type or
|
||||
/// a truncated payload is an `ApiError::Json`. That is non-retryable, and it is
|
||||
/// raised while reading the body rather than while acquiring the response, so the
|
||||
/// client reports it on the first attempt rather than retrying.
|
||||
fn stream_then_error_sse(text: &str) -> String {
|
||||
let mut body = String::new();
|
||||
append_sse(
|
||||
&mut body,
|
||||
"message_start",
|
||||
json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": unique_message_id(),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [],
|
||||
"model": DEFAULT_MODEL,
|
||||
"stop_reason": null,
|
||||
"stop_sequence": null,
|
||||
"usage": usage_json(14, 0)
|
||||
}
|
||||
}),
|
||||
);
|
||||
append_sse(
|
||||
&mut body,
|
||||
"content_block_start",
|
||||
json!({
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": ""}
|
||||
}),
|
||||
);
|
||||
append_sse(
|
||||
&mut body,
|
||||
"content_block_delta",
|
||||
json!({
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": text}
|
||||
}),
|
||||
);
|
||||
append_sse(
|
||||
&mut body,
|
||||
"content_block_stop",
|
||||
json!({"type": "content_block_stop", "index": 0}),
|
||||
);
|
||||
// No terminating blank line: the frame is still arriving when the provider
|
||||
// drops the connection, so it stays in the client's parser buffer until the
|
||||
// body ends. See the note above for why the failure has to trail the deltas
|
||||
// by a parser call rather than sharing their chunk.
|
||||
body.push_str("event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_");
|
||||
body
|
||||
}
|
||||
|
||||
fn final_text_sse_with_usage(text: &str, input_tokens: u32, output_tokens: u32) -> String {
|
||||
let mut body = String::new();
|
||||
append_sse(
|
||||
|
||||
@@ -95,10 +95,21 @@ pub fn get_compact_continuation_message(
|
||||
#[must_use]
|
||||
pub fn compact_session(session: &Session, config: CompactionConfig) -> CompactionResult {
|
||||
if !should_compact(session, config) {
|
||||
// "Nothing to drop" is not the same as "nothing to do". A session can exceed the window
|
||||
// while holding fewer messages than `preserve_recent_messages` — a single read of a large
|
||||
// file does it — and message-granular compaction has no move to make there. Trim the
|
||||
// payloads anyway, so the one lever that still works is not gated behind the one that
|
||||
// does not.
|
||||
let mut trimmed_session = session.clone();
|
||||
trimmed_session.messages = session
|
||||
.messages
|
||||
.iter()
|
||||
.map(truncate_oversized_tool_results)
|
||||
.collect();
|
||||
return CompactionResult {
|
||||
summary: String::new(),
|
||||
formatted_summary: String::new(),
|
||||
compacted_session: session.clone(),
|
||||
compacted_session: trimmed_session,
|
||||
removed_message_count: 0,
|
||||
};
|
||||
}
|
||||
@@ -165,7 +176,14 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
||||
k
|
||||
};
|
||||
let removed = &session.messages[compacted_prefix_len..keep_from];
|
||||
let preserved = session.messages[keep_from..].to_vec();
|
||||
// Dropping whole messages is not enough on its own. An agentic turn is five messages
|
||||
// (user / assistant+tool_use / tool_result / assistant+tool_use / tool_result), so the
|
||||
// preserved tail IS the pair of tool results that overflowed the window, and the only
|
||||
// message compaction may drop is the short user prompt. Trim the payloads that must stay.
|
||||
let preserved = session.messages[keep_from..]
|
||||
.iter()
|
||||
.map(truncate_oversized_tool_results)
|
||||
.collect::<Vec<_>>();
|
||||
let summary =
|
||||
merge_compact_summaries(existing_summary.as_deref(), &summarize_messages(removed));
|
||||
let formatted_summary = format_compact_summary(&summary);
|
||||
@@ -190,6 +208,78 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest tool-result payload compaction will preserve verbatim, in characters.
|
||||
///
|
||||
/// ~16 KB is ~4 k tokens: big enough that ordinary tool output is never touched, small enough
|
||||
/// that several preserved results still fit a small window.
|
||||
const MAX_PRESERVED_TOOL_RESULT_CHARS: usize = 16 * 1024;
|
||||
|
||||
/// Characters kept from the tail of a trimmed payload. The head carries the structure a model
|
||||
/// needs to recognise what it read; the tail is where a file's most recent edits usually are.
|
||||
const PRESERVED_TOOL_RESULT_TAIL_CHARS: usize = 2 * 1024;
|
||||
|
||||
/// Returns `message` with any oversized `ToolResult` payload replaced by a head/tail excerpt.
|
||||
///
|
||||
/// Clones only when something is actually trimmed, so the common path stays cheap.
|
||||
fn truncate_oversized_tool_results(message: &ConversationMessage) -> ConversationMessage {
|
||||
let needs_trim = message.blocks.iter().any(|block| match block {
|
||||
ContentBlock::ToolResult { output, .. } => {
|
||||
output.chars().count() > MAX_PRESERVED_TOOL_RESULT_CHARS
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
if !needs_trim {
|
||||
return message.clone();
|
||||
}
|
||||
|
||||
let blocks = message
|
||||
.blocks
|
||||
.iter()
|
||||
.map(|block| match block {
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
output,
|
||||
is_error,
|
||||
} => ContentBlock::ToolResult {
|
||||
tool_use_id: tool_use_id.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
output: excerpt_tool_result(output),
|
||||
is_error: *is_error,
|
||||
},
|
||||
other => other.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
ConversationMessage {
|
||||
role: message.role,
|
||||
blocks,
|
||||
usage: message.usage,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a head + marker + tail excerpt of an oversized tool-result payload.
|
||||
///
|
||||
/// Operates on chars, not bytes, so a multi-byte boundary can never be split.
|
||||
fn excerpt_tool_result(output: &str) -> String {
|
||||
let chars: Vec<char> = output.chars().collect();
|
||||
let total = chars.len();
|
||||
if total <= MAX_PRESERVED_TOOL_RESULT_CHARS {
|
||||
return output.to_string();
|
||||
}
|
||||
|
||||
let tail_len = PRESERVED_TOOL_RESULT_TAIL_CHARS.min(total);
|
||||
let head_len = MAX_PRESERVED_TOOL_RESULT_CHARS.saturating_sub(tail_len);
|
||||
let elided = total.saturating_sub(head_len + tail_len);
|
||||
if elided == 0 {
|
||||
return output.to_string();
|
||||
}
|
||||
|
||||
let head: String = chars[..head_len].iter().collect();
|
||||
let tail: String = chars[total - tail_len..].iter().collect();
|
||||
format!("{head}\n\n[... {elided} characters elided by compaction; re-read this file if you need the omitted portion ...]\n\n{tail}")
|
||||
}
|
||||
|
||||
fn compacted_summary_prefix_len(session: &Session) -> usize {
|
||||
usize::from(
|
||||
session
|
||||
@@ -573,7 +663,7 @@ fn extract_summary_timeline(summary: &str) -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
collect_key_files, compact_session, format_compact_summary,
|
||||
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
|
||||
get_compact_continuation_message, infer_pending_work, should_compact, CompactionConfig,
|
||||
};
|
||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
@@ -763,6 +853,185 @@ mod tests {
|
||||
/// user(ToolResult) pair at the boundary. An orphaned tool-result message
|
||||
/// without the preceding assistant `tool_calls` causes a 400 on the
|
||||
/// OpenAI-compat path (gaebal-gajae repro 2026-04-09).
|
||||
/// The failure this exists for: an agentic turn is 5 messages (user / assistant+tool_use /
|
||||
/// tool_result / assistant+tool_use / tool_result), so `preserve_recent_messages: 4` protects
|
||||
/// exactly the two giant tool results that overflow the window. Message-granular compaction
|
||||
/// can only drop the tiny user prompt, so the payloads themselves must be trimmed.
|
||||
#[test]
|
||||
fn compaction_truncates_oversized_tool_results_it_must_preserve() {
|
||||
let huge = "z".repeat(80_000);
|
||||
let mut session = Session::new();
|
||||
session
|
||||
.push_message(ConversationMessage::user_text("read two files"))
|
||||
.expect("user message");
|
||||
session
|
||||
.push_message(ConversationMessage::assistant(vec![
|
||||
ContentBlock::ToolUse {
|
||||
id: "call-1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: "{\"path\":\"a.rs\"}".to_string(),
|
||||
},
|
||||
]))
|
||||
.expect("assistant tool use");
|
||||
session
|
||||
.push_message(ConversationMessage::tool_result(
|
||||
"call-1",
|
||||
"read_file",
|
||||
huge.clone(),
|
||||
false,
|
||||
))
|
||||
.expect("tool result");
|
||||
session
|
||||
.push_message(ConversationMessage::assistant(vec![
|
||||
ContentBlock::ToolUse {
|
||||
id: "call-2".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: "{\"path\":\"b.rs\"}".to_string(),
|
||||
},
|
||||
]))
|
||||
.expect("second assistant tool use");
|
||||
session
|
||||
.push_message(ConversationMessage::tool_result(
|
||||
"call-2",
|
||||
"read_file",
|
||||
huge.clone(),
|
||||
false,
|
||||
))
|
||||
.expect("second tool result");
|
||||
|
||||
let before = estimate_session_tokens(&session);
|
||||
let result = compact_session(
|
||||
&session,
|
||||
CompactionConfig {
|
||||
preserve_recent_messages: 4,
|
||||
max_estimated_tokens: 0,
|
||||
},
|
||||
);
|
||||
let after = estimate_session_tokens(&result.compacted_session);
|
||||
|
||||
assert!(
|
||||
after < before / 2,
|
||||
"compaction must shrink an oversized preserved tool result: {before} -> {after}"
|
||||
);
|
||||
|
||||
let preserved_output = result
|
||||
.compacted_session
|
||||
.messages
|
||||
.iter()
|
||||
.flat_map(|message| &message.blocks)
|
||||
.find_map(|block| match block {
|
||||
ContentBlock::ToolResult { output, .. } => Some(output.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the tool result must still be present, not deleted");
|
||||
|
||||
assert!(
|
||||
preserved_output.len() < huge.len(),
|
||||
"the payload must actually be trimmed"
|
||||
);
|
||||
assert!(
|
||||
preserved_output.contains("elided"),
|
||||
"the model must be told the view is partial, got: {}",
|
||||
&preserved_output[..preserved_output.len().min(200)]
|
||||
);
|
||||
assert!(
|
||||
preserved_output.starts_with('z'),
|
||||
"the head of the output must be kept"
|
||||
);
|
||||
assert!(
|
||||
preserved_output.ends_with('z'),
|
||||
"the tail of the output must be kept"
|
||||
);
|
||||
}
|
||||
|
||||
/// A session can exceed the window while holding FEWER messages than
|
||||
/// `preserve_recent_messages` — one read of a large file does it. `should_compact` returns
|
||||
/// false there (nothing is droppable), so trimming must not be gated behind it.
|
||||
#[test]
|
||||
fn oversized_tool_results_are_trimmed_even_when_no_message_can_be_dropped() {
|
||||
let huge = "q".repeat(80_000);
|
||||
let mut session = Session::new();
|
||||
session
|
||||
.push_message(ConversationMessage::user_text("read one enormous file"))
|
||||
.expect("user message");
|
||||
session
|
||||
.push_message(ConversationMessage::assistant(vec![
|
||||
ContentBlock::ToolUse {
|
||||
id: "call-1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: "{\"path\":\"huge.rs\"}".to_string(),
|
||||
},
|
||||
]))
|
||||
.expect("assistant tool use");
|
||||
session
|
||||
.push_message(ConversationMessage::tool_result(
|
||||
"call-1",
|
||||
"read_file",
|
||||
huge,
|
||||
false,
|
||||
))
|
||||
.expect("tool result");
|
||||
|
||||
// 3 messages, preserve 4: there is nothing to remove, yet the session is far too big.
|
||||
let before = estimate_session_tokens(&session);
|
||||
let result = compact_session(
|
||||
&session,
|
||||
CompactionConfig {
|
||||
preserve_recent_messages: 4,
|
||||
max_estimated_tokens: 0,
|
||||
},
|
||||
);
|
||||
let after = estimate_session_tokens(&result.compacted_session);
|
||||
|
||||
assert!(
|
||||
after < before / 2,
|
||||
"an untrimmable session must still have its payloads trimmed: {before} -> {after}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Trimming must not touch payloads that already fit — otherwise every ordinary tool result
|
||||
/// would grow an elision marker it does not need.
|
||||
#[test]
|
||||
fn compaction_leaves_small_tool_results_untouched() {
|
||||
let small = "ok".repeat(50);
|
||||
let mut session = Session::new();
|
||||
for index in 0..6 {
|
||||
session
|
||||
.push_message(ConversationMessage::user_text(format!("turn {index}")))
|
||||
.expect("user message");
|
||||
session
|
||||
.push_message(ConversationMessage::tool_result(
|
||||
format!("call-{index}"),
|
||||
"read_file",
|
||||
small.clone(),
|
||||
false,
|
||||
))
|
||||
.expect("tool result");
|
||||
}
|
||||
|
||||
let result = compact_session(
|
||||
&session,
|
||||
CompactionConfig {
|
||||
preserve_recent_messages: 4,
|
||||
max_estimated_tokens: 0,
|
||||
},
|
||||
);
|
||||
|
||||
for block in result
|
||||
.compacted_session
|
||||
.messages
|
||||
.iter()
|
||||
.flat_map(|message| &message.blocks)
|
||||
{
|
||||
if let ContentBlock::ToolResult { output, .. } = block {
|
||||
assert_eq!(
|
||||
output, &small,
|
||||
"a small tool result must pass through as-is"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_does_not_split_tool_use_tool_result_pair() {
|
||||
use crate::session::{ContentBlock, Session};
|
||||
|
||||
@@ -18,6 +18,47 @@ use crate::usage::{TokenUsage, UsageTracker};
|
||||
const DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD: u32 = 100_000;
|
||||
const AUTO_COMPACTION_THRESHOLD_ENV_VAR: &str = "CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS";
|
||||
|
||||
const DEFAULT_MAX_STALL_NUDGES: usize = 3;
|
||||
const MAX_STALL_NUDGES_ENV_VAR: &str = "CLAW_MAX_STALL_NUDGES";
|
||||
/// Placeholder recorded in the trace when a turn is resumed rather than started, so the trace
|
||||
/// does not attribute a second user message to a turn that only had one.
|
||||
const RESUMED_TURN_MARKER: &str = "[resumed turn]";
|
||||
/// Operator-declared context window. Kept as a literal rather than imported because `runtime`
|
||||
/// sits below `api` in the dependency graph; `api::context_window_override` reads the same name.
|
||||
const CONTEXT_WINDOW_ENV_VAR: &str = "CLAW_CONTEXT_WINDOW";
|
||||
/// Share of a declared context window at which compaction is triggered, leaving the remainder
|
||||
/// as headroom for the reply plus whatever the next tool result carries.
|
||||
const AUTO_COMPACTION_WINDOW_PERCENT: u32 = 70;
|
||||
|
||||
/// Injected when the model narrates its next step (or asks for a go-ahead mid-task)
|
||||
/// instead of emitting the tool call that would perform it.
|
||||
const STALL_NUDGE_PROMPT: &str = "[auto-continue] You ended the turn without calling a tool, \
|
||||
but the work is not finished. Do not describe the next step and do not ask for \
|
||||
confirmation — issue the tool call now and keep going until the task is done. If \
|
||||
everything asked for is genuinely complete, reply with a one-line summary containing \
|
||||
no forward-looking sentence.";
|
||||
|
||||
/// Phrases that mark text as an announcement of work the model has not performed yet.
|
||||
/// Matched against the final non-empty line of the assistant's reply.
|
||||
const PENDING_ACTION_MARKERS: [&str; 16] = [
|
||||
"let me ",
|
||||
"let's ",
|
||||
"lets ",
|
||||
"i'll ",
|
||||
"i will ",
|
||||
"i'm going to",
|
||||
"i am going to",
|
||||
"going to run",
|
||||
"about to ",
|
||||
"now running",
|
||||
"now checking",
|
||||
"now i",
|
||||
"next i",
|
||||
"next, i",
|
||||
"proceeding to",
|
||||
"moving on to",
|
||||
];
|
||||
|
||||
/// Fully assembled request payload sent to the upstream model client.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ApiRequest {
|
||||
@@ -90,6 +131,7 @@ impl std::error::Error for ToolError {}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeError {
|
||||
message: String,
|
||||
context_window: bool,
|
||||
}
|
||||
|
||||
impl RuntimeError {
|
||||
@@ -97,8 +139,28 @@ impl RuntimeError {
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
context_window: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A failure the caller may be able to clear by shrinking the session.
|
||||
///
|
||||
/// This is a decision the API layer makes and hands up, not one the caller reconstructs by
|
||||
/// matching substrings against the rendered message. Recovering from context exhaustion means
|
||||
/// discarding conversation history, so a transport hiccup misread as an overflow destroys the
|
||||
/// session for no reason — the classification has to come from whoever saw the real error.
|
||||
#[must_use]
|
||||
pub fn context_window(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
context_window: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn is_context_window_failure(&self) -> bool {
|
||||
self.context_window
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for RuntimeError {
|
||||
@@ -140,6 +202,7 @@ pub struct ConversationRuntime<C, T> {
|
||||
hook_abort_signal: HookAbortSignal,
|
||||
hook_progress_reporter: Option<Box<dyn HookProgressReporter>>,
|
||||
session_tracer: Option<SessionTracer>,
|
||||
max_stall_nudges: usize,
|
||||
}
|
||||
|
||||
impl<C, T> ConversationRuntime<C, T>
|
||||
@@ -194,9 +257,17 @@ where
|
||||
hook_abort_signal: HookAbortSignal::default(),
|
||||
hook_progress_reporter: None,
|
||||
session_tracer: None,
|
||||
max_stall_nudges: max_stall_nudges_from_env(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Override how many auto-continue nudges a stalled turn may receive. `0` disables them.
|
||||
#[must_use]
|
||||
pub fn with_max_stall_nudges(mut self, max_stall_nudges: usize) -> Self {
|
||||
self.max_stall_nudges = max_stall_nudges;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
|
||||
self.max_iterations = max_iterations;
|
||||
@@ -326,14 +397,33 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn run_turn(
|
||||
&mut self,
|
||||
user_input: impl Into<String>,
|
||||
prompter: Option<&mut dyn PermissionPrompter>,
|
||||
) -> Result<TurnSummary, RuntimeError> {
|
||||
self.drive_turn(Some(user_input.into()), prompter)
|
||||
}
|
||||
|
||||
/// Continues the turn already recorded in the session, without adding a user message.
|
||||
///
|
||||
/// Recovery paths retry a turn that failed part-way through. Calling [`Self::run_turn`] again
|
||||
/// with the original input would append that input a second time and invite the model to redo
|
||||
/// tool calls whose side effects have already landed. Resuming reads the transcript as it
|
||||
/// stands — including the tool results that did complete — and carries on from there.
|
||||
pub fn resume_turn(
|
||||
&mut self,
|
||||
prompter: Option<&mut dyn PermissionPrompter>,
|
||||
) -> Result<TurnSummary, RuntimeError> {
|
||||
self.drive_turn(None, prompter)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn drive_turn(
|
||||
&mut self,
|
||||
user_input: Option<String>,
|
||||
mut prompter: Option<&mut dyn PermissionPrompter>,
|
||||
) -> Result<TurnSummary, RuntimeError> {
|
||||
let user_input = user_input.into();
|
||||
|
||||
// ROADMAP #38: Session-health canary - probe if context was compacted
|
||||
if self.session.compaction.is_some() {
|
||||
if let Err(error) = self.run_session_health_probe() {
|
||||
@@ -345,16 +435,24 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
self.record_turn_started(&user_input);
|
||||
self.session
|
||||
.push_user_text(user_input)
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
match user_input {
|
||||
Some(user_input) => {
|
||||
self.record_turn_started(&user_input);
|
||||
self.session
|
||||
.push_user_text(user_input)
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
}
|
||||
None => self.record_turn_started(RESUMED_TURN_MARKER),
|
||||
}
|
||||
|
||||
let mut assistant_messages = Vec::new();
|
||||
let mut tool_results = Vec::new();
|
||||
let mut prompt_cache_events = Vec::new();
|
||||
let mut iterations = 0;
|
||||
let mut auto_compaction = None;
|
||||
let max_stall_nudges = self.max_stall_nudges;
|
||||
let mut stall_nudges = 0;
|
||||
let mut tools_ran = false;
|
||||
|
||||
loop {
|
||||
iterations += 1;
|
||||
@@ -366,6 +464,23 @@ where
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
// Compact BEFORE the request goes out, not only after a reply lands.
|
||||
//
|
||||
// The usage-based check below is post-hoc: it can only see the context once a call
|
||||
// has returned and reported its counters. Between that measurement and the next
|
||||
// request the session can grow enormously — a reasoning model emitting a long
|
||||
// <think> block, then a tool result carrying a large file. Observed live: a call
|
||||
// measured at 17,858 prompt tokens generated 10,842 more, a read_file added ~19k on
|
||||
// top, and the next request hit the provider at 47,755 tokens and was rejected
|
||||
// outright. No amount of tuning the post-hoc threshold prevents that, because
|
||||
// nothing measures the session in between.
|
||||
//
|
||||
// `estimate_session_tokens` is a local heuristic over the transcript, so this costs
|
||||
// no API call and works before any usage has ever been recorded.
|
||||
if let Some(compaction) = self.maybe_auto_compact_before_request() {
|
||||
auto_compaction = Some(compaction);
|
||||
}
|
||||
|
||||
let request = ApiRequest {
|
||||
system_prompt: self.system_prompt.clone(),
|
||||
messages: self.session.messages.clone(),
|
||||
@@ -381,6 +496,7 @@ where
|
||||
match build_assistant_message(events) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
let error = self.classify_empty_reply(error);
|
||||
self.record_turn_failed(iterations, &error);
|
||||
return Err(error);
|
||||
}
|
||||
@@ -417,8 +533,35 @@ where
|
||||
}
|
||||
|
||||
if pending_tool_uses.is_empty() {
|
||||
// A reply with no tool call usually means the turn is done. On small local
|
||||
// models it just as often means the model narrated the next step — or asked
|
||||
// for a go-ahead mid-task — and then emitted EOS, leaving the announced work
|
||||
// undone. Nudge it back into the loop instead of dropping the user at the
|
||||
// prompt, bounded so a model that insists it is finished still gets to stop.
|
||||
let stalled = stall_nudges < max_stall_nudges
|
||||
&& is_stalled_reply(
|
||||
&assistant_text(
|
||||
assistant_messages
|
||||
.last()
|
||||
.expect("assistant message pushed above"),
|
||||
),
|
||||
tools_ran,
|
||||
);
|
||||
if stalled {
|
||||
stall_nudges += 1;
|
||||
// Pushed as a system turn, not a user one. The model needs to see it, but the
|
||||
// transcript must not claim the user asked for it: this text is persisted,
|
||||
// re-sent on every later request, and folded into the next compaction summary,
|
||||
// where a user-labelled nudge becomes indistinguishable from something the
|
||||
// user actually said.
|
||||
self.session
|
||||
.push_message(ConversationMessage::system_text(STALL_NUDGE_PROMPT))
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
tools_ran = true;
|
||||
|
||||
for (tool_use_id, tool_name, input) in pending_tool_uses {
|
||||
let pre_hook_result = self.run_pre_tool_use_hook(&tool_name, &input);
|
||||
@@ -573,13 +716,62 @@ where
|
||||
self.session
|
||||
}
|
||||
|
||||
/// Compaction check run immediately before a request is sent, based on a locally estimated
|
||||
/// transcript size rather than on provider-reported usage.
|
||||
///
|
||||
/// This is the guard that actually keeps a session inside the context window: it sees growth
|
||||
/// that usage counters cannot, because it runs after tool results and long generations have
|
||||
/// been appended but before the request that would carry them is built.
|
||||
/// Decides whether an empty or unreadable reply was an overflow, by size rather than by text.
|
||||
///
|
||||
/// A stream that produces no content says nothing about why. Some backends answer an
|
||||
/// oversized request that way instead of with a typed error, so the failure is worth
|
||||
/// recovering from — but only when the session was already large enough for that to be the
|
||||
/// plausible cause. Below the compaction threshold it is a blip, and discarding the
|
||||
/// transcript would cost more than the retry could win.
|
||||
fn classify_empty_reply(&self, error: RuntimeError) -> RuntimeError {
|
||||
if error.is_context_window_failure() {
|
||||
return error;
|
||||
}
|
||||
if estimate_session_tokens(&self.session)
|
||||
>= self.auto_compaction_input_tokens_threshold as usize
|
||||
{
|
||||
return RuntimeError::context_window(error.to_string());
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
fn maybe_auto_compact_before_request(&mut self) -> Option<AutoCompactionEvent> {
|
||||
let estimated = estimate_session_tokens(&self.session);
|
||||
if estimated < self.auto_compaction_input_tokens_threshold as usize {
|
||||
return None;
|
||||
}
|
||||
self.compact_now()
|
||||
}
|
||||
|
||||
fn maybe_auto_compact(&mut self) -> Option<AutoCompactionEvent> {
|
||||
if self.usage_tracker.cumulative_usage().input_tokens
|
||||
// Gate on the LIVE context size — the most recent call's input counters — not on a
|
||||
// cumulative running total, and count cached input as the context it is.
|
||||
//
|
||||
// The previous gate (`cumulative_usage().input_tokens`) was wrong twice over:
|
||||
// 1. It ignored `cache_read_input_tokens`. With prompt caching a warm call reports
|
||||
// `input_tokens: 2`, so the counter crawled and compaction never fired however
|
||||
// large the context grew.
|
||||
// 2. Summing across calls is not a context measurement, and the sum never decreased,
|
||||
// so once it did trip, every later iteration compacted forever.
|
||||
// Reading the latest turn fixes both: it rises with the real context and falls back
|
||||
// below the threshold as soon as a compaction has done its job.
|
||||
if self.usage_tracker.current_turn_usage().total_input_tokens()
|
||||
< self.auto_compaction_input_tokens_threshold
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
self.compact_now()
|
||||
}
|
||||
|
||||
/// Compacts the session unconditionally, returning `None` when nothing actually changed.
|
||||
fn compact_now(&mut self) -> Option<AutoCompactionEvent> {
|
||||
let result = compact_session(
|
||||
&self.session,
|
||||
CompactionConfig {
|
||||
@@ -588,7 +780,12 @@ where
|
||||
},
|
||||
);
|
||||
|
||||
if result.removed_message_count == 0 {
|
||||
// `removed_message_count` alone is the wrong test for "did anything happen": compaction
|
||||
// also trims oversized tool-result payloads in messages it must PRESERVE, and on a
|
||||
// session too short to have anything droppable that trimming is the entire result.
|
||||
// Gating on the count would compute the smaller session and then throw it away.
|
||||
let shrank = result.compacted_session.messages != self.session.messages;
|
||||
if result.removed_message_count == 0 && !shrank {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -709,19 +906,88 @@ where
|
||||
/// Reads the automatic compaction threshold from the environment.
|
||||
#[must_use]
|
||||
pub fn auto_compaction_threshold_from_env() -> u32 {
|
||||
parse_auto_compaction_threshold(
|
||||
resolve_auto_compaction_threshold(
|
||||
std::env::var(AUTO_COMPACTION_THRESHOLD_ENV_VAR)
|
||||
.ok()
|
||||
.as_deref(),
|
||||
std::env::var(CONTEXT_WINDOW_ENV_VAR).ok().as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolves the threshold from an explicit setting, else from the declared context window.
|
||||
///
|
||||
/// `DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD` is a 200k-window number. Against a smaller
|
||||
/// server it sits above the window entirely, so the pre-request guard can never fire and the
|
||||
/// first sign of trouble is the backend rejecting the request. Deriving from the declared window
|
||||
/// keeps the guard meaningful without every caller having to compute a second number by hand.
|
||||
#[must_use]
|
||||
fn resolve_auto_compaction_threshold(explicit: Option<&str>, context_window: Option<&str>) -> u32 {
|
||||
if let Some(threshold) = parse_positive_u32(explicit) {
|
||||
return threshold;
|
||||
}
|
||||
parse_positive_u32(context_window).map_or(
|
||||
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD,
|
||||
|window| {
|
||||
(u64::from(window) * u64::from(AUTO_COMPACTION_WINDOW_PERCENT) / 100)
|
||||
.try_into()
|
||||
.unwrap_or(DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn parse_auto_compaction_threshold(value: Option<&str>) -> u32 {
|
||||
fn parse_positive_u32(value: Option<&str>) -> Option<u32> {
|
||||
value
|
||||
.and_then(|raw| raw.trim().parse::<u32>().ok())
|
||||
.filter(|threshold| *threshold > 0)
|
||||
.unwrap_or(DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD)
|
||||
.filter(|parsed| *parsed > 0)
|
||||
}
|
||||
|
||||
/// How many auto-continue nudges a single turn may inject. `0` disables the behaviour.
|
||||
#[must_use]
|
||||
pub fn max_stall_nudges_from_env() -> usize {
|
||||
parse_max_stall_nudges(std::env::var(MAX_STALL_NUDGES_ENV_VAR).ok().as_deref())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn parse_max_stall_nudges(value: Option<&str>) -> usize {
|
||||
value
|
||||
.and_then(|raw| raw.trim().parse::<usize>().ok())
|
||||
.unwrap_or(DEFAULT_MAX_STALL_NUDGES)
|
||||
}
|
||||
|
||||
/// Concatenated text of an assistant message, ignoring thinking and tool blocks.
|
||||
#[must_use]
|
||||
fn assistant_text(message: &ConversationMessage) -> String {
|
||||
message
|
||||
.blocks
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// True when a tool-less reply is a stall rather than a finished turn.
|
||||
///
|
||||
/// Small local models routinely narrate the next tool call in prose and then emit EOS, which
|
||||
/// ends the turn with the announced work never performed. `after_tool_use` additionally treats
|
||||
/// a mid-task question as a stall: once tools have run in this turn, stopping to ask for a
|
||||
/// go-ahead is the same failure wearing a different hat.
|
||||
#[must_use]
|
||||
fn is_stalled_reply(text: &str, after_tool_use: bool) -> bool {
|
||||
let Some(last_line) = text.lines().map(str::trim).rfind(|line| !line.is_empty()) else {
|
||||
return false;
|
||||
};
|
||||
let lowered = last_line.to_lowercase();
|
||||
|
||||
if after_tool_use && last_line.ends_with('?') {
|
||||
return true;
|
||||
}
|
||||
PENDING_ACTION_MARKERS
|
||||
.iter()
|
||||
.any(|marker| lowered.contains(marker))
|
||||
}
|
||||
|
||||
fn build_assistant_message(
|
||||
@@ -853,11 +1119,13 @@ impl ToolExecutor for StaticToolExecutor {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_assistant_message, parse_auto_compaction_threshold, ApiClient, ApiRequest,
|
||||
AssistantEvent, AutoCompactionEvent, ConversationRuntime, PromptCacheEvent, RuntimeError,
|
||||
assistant_text, build_assistant_message, is_stalled_reply, parse_max_stall_nudges,
|
||||
resolve_auto_compaction_threshold, ApiClient, ApiRequest, AssistantEvent,
|
||||
AutoCompactionEvent, ConversationRuntime, PromptCacheEvent, RuntimeError,
|
||||
StaticToolExecutor, ToolExecutor, DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD,
|
||||
DEFAULT_MAX_STALL_NUDGES,
|
||||
};
|
||||
use crate::compact::CompactionConfig;
|
||||
use crate::compact::{estimate_session_tokens, CompactionConfig};
|
||||
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
||||
use crate::permissions::{
|
||||
PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter,
|
||||
@@ -1701,19 +1969,246 @@ mod tests {
|
||||
assert_eq!(runtime.session().messages.len(), 2);
|
||||
}
|
||||
|
||||
/// Prompt caching reports the bulk of the context as `cache_read_input_tokens` and leaves
|
||||
/// `input_tokens` at a token or two, so a gate that reads `input_tokens` alone never fires
|
||||
/// on a cached session no matter how large the context has grown.
|
||||
#[test]
|
||||
fn auto_compaction_counts_cached_context_toward_threshold() {
|
||||
struct CachedApi;
|
||||
impl ApiClient for CachedApi {
|
||||
fn stream(
|
||||
&mut self,
|
||||
_request: ApiRequest,
|
||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
Ok(vec![
|
||||
AssistantEvent::TextDelta("done".to_string()),
|
||||
AssistantEvent::Usage(TokenUsage {
|
||||
input_tokens: 2,
|
||||
output_tokens: 4,
|
||||
cache_creation_input_tokens: 1_804,
|
||||
cache_read_input_tokens: 112_639,
|
||||
}),
|
||||
AssistantEvent::MessageStop,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
let mut session = Session::new();
|
||||
session.messages = vec![
|
||||
crate::session::ConversationMessage::user_text("one"),
|
||||
crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
text: "two".to_string(),
|
||||
}]),
|
||||
crate::session::ConversationMessage::user_text("three"),
|
||||
crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
text: "four".to_string(),
|
||||
}]),
|
||||
];
|
||||
|
||||
let mut runtime = ConversationRuntime::new(
|
||||
session,
|
||||
CachedApi,
|
||||
StaticToolExecutor::new(),
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
)
|
||||
.with_auto_compaction_input_tokens_threshold(100_000);
|
||||
|
||||
let summary = runtime
|
||||
.run_turn("trigger", None)
|
||||
.expect("turn should succeed");
|
||||
|
||||
assert_eq!(
|
||||
summary.auto_compaction,
|
||||
Some(AutoCompactionEvent {
|
||||
removed_message_count: 2,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// The threshold describes the LIVE context, not a monotonic running total. Once compaction
|
||||
/// has shrunk the context back down, later small turns must not keep compacting.
|
||||
#[test]
|
||||
fn auto_compaction_stops_once_the_context_shrinks_again() {
|
||||
struct ShrinkingApi {
|
||||
calls: u32,
|
||||
}
|
||||
impl ApiClient for ShrinkingApi {
|
||||
fn stream(
|
||||
&mut self,
|
||||
_request: ApiRequest,
|
||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
let call = self.calls;
|
||||
self.calls += 1;
|
||||
// The first call is over budget; every call after it is small.
|
||||
let input_tokens = if call == 0 { 150_000 } else { 900 };
|
||||
Ok(vec![
|
||||
AssistantEvent::TextDelta("done".to_string()),
|
||||
AssistantEvent::Usage(TokenUsage {
|
||||
input_tokens,
|
||||
output_tokens: 4,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
}),
|
||||
AssistantEvent::MessageStop,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
let mut session = Session::new();
|
||||
session.messages = vec![
|
||||
crate::session::ConversationMessage::user_text("one"),
|
||||
crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
text: "two".to_string(),
|
||||
}]),
|
||||
crate::session::ConversationMessage::user_text("three"),
|
||||
crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
text: "four".to_string(),
|
||||
}]),
|
||||
];
|
||||
|
||||
let mut runtime = ConversationRuntime::new(
|
||||
session,
|
||||
ShrinkingApi { calls: 0 },
|
||||
// Every turn after a compaction runs the session health probe, which calls
|
||||
// `glob_search`; without it the probe fails the turn before the assertion is reached.
|
||||
StaticToolExecutor::new().register("glob_search", |_| Ok(String::new())),
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
)
|
||||
.with_auto_compaction_input_tokens_threshold(100_000);
|
||||
|
||||
let first = runtime.run_turn("trigger", None).expect("first turn");
|
||||
assert!(
|
||||
first.auto_compaction.is_some(),
|
||||
"the over-budget turn should compact"
|
||||
);
|
||||
|
||||
let second = runtime.run_turn("again", None).expect("second turn");
|
||||
assert_eq!(
|
||||
second.auto_compaction, None,
|
||||
"a small turn after compaction must not compact again"
|
||||
);
|
||||
let third = runtime.run_turn("and again", None).expect("third turn");
|
||||
assert_eq!(
|
||||
third.auto_compaction, None,
|
||||
"compaction must not latch on once the threshold has been crossed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Usage-based compaction is post-hoc: it only sees the context AFTER a call returns. A
|
||||
/// large tool result (or a long reasoning block) appended after that measurement can blow
|
||||
/// the window on the very next request, which the provider rejects outright — so there must
|
||||
/// also be a check BEFORE the request goes out, based on a locally estimated session size.
|
||||
#[test]
|
||||
fn auto_compaction_fires_before_the_first_request_when_the_session_is_already_oversized() {
|
||||
struct RecordingApi {
|
||||
largest_request_messages: std::rc::Rc<std::cell::Cell<usize>>,
|
||||
}
|
||||
impl ApiClient for RecordingApi {
|
||||
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
self.largest_request_messages.set(
|
||||
self.largest_request_messages
|
||||
.get()
|
||||
.max(request.messages.len()),
|
||||
);
|
||||
Ok(vec![
|
||||
AssistantEvent::TextDelta("done".to_string()),
|
||||
// Deliberately no Usage event: nothing has been measured yet, which is
|
||||
// exactly the situation the pre-send check has to cover.
|
||||
AssistantEvent::MessageStop,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// Six messages that are individually large — the shape of a session that has just
|
||||
// absorbed a couple of big file reads.
|
||||
let bulk = "x".repeat(40_000);
|
||||
let mut session = Session::new();
|
||||
for _ in 0..3 {
|
||||
session
|
||||
.messages
|
||||
.push(crate::session::ConversationMessage::user_text(bulk.clone()));
|
||||
session
|
||||
.messages
|
||||
.push(crate::session::ConversationMessage::assistant(vec![
|
||||
ContentBlock::Text { text: bulk.clone() },
|
||||
]));
|
||||
}
|
||||
let before = estimate_session_tokens(&session);
|
||||
assert!(
|
||||
before > 24_000,
|
||||
"test fixture must start over the threshold, got {before}"
|
||||
);
|
||||
|
||||
let largest = std::rc::Rc::new(std::cell::Cell::new(0usize));
|
||||
let mut runtime = ConversationRuntime::new(
|
||||
session,
|
||||
RecordingApi {
|
||||
largest_request_messages: std::rc::Rc::clone(&largest),
|
||||
},
|
||||
StaticToolExecutor::new().register("glob_search", |_| Ok(String::new())),
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
)
|
||||
.with_auto_compaction_input_tokens_threshold(24_000);
|
||||
|
||||
let summary = runtime
|
||||
.run_turn("trigger", None)
|
||||
.expect("turn should succeed");
|
||||
|
||||
assert!(
|
||||
summary.auto_compaction.is_some(),
|
||||
"an already-oversized session must compact before the request is sent"
|
||||
);
|
||||
assert!(
|
||||
estimate_session_tokens(runtime.session()) < before,
|
||||
"compaction must actually shrink the session"
|
||||
);
|
||||
assert!(
|
||||
largest.get() < 8,
|
||||
"the request must be sent with the compacted history, not the original 7 messages"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_compaction_threshold_defaults_and_parses_values() {
|
||||
assert_eq!(
|
||||
parse_auto_compaction_threshold(None),
|
||||
resolve_auto_compaction_threshold(None, None),
|
||||
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
|
||||
);
|
||||
assert_eq!(parse_auto_compaction_threshold(Some("4321")), 4321);
|
||||
assert_eq!(resolve_auto_compaction_threshold(Some("4321"), None), 4321);
|
||||
assert_eq!(
|
||||
parse_auto_compaction_threshold(Some("0")),
|
||||
resolve_auto_compaction_threshold(Some("0"), None),
|
||||
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
|
||||
);
|
||||
assert_eq!(
|
||||
parse_auto_compaction_threshold(Some("not-a-number")),
|
||||
resolve_auto_compaction_threshold(Some("not-a-number"), None),
|
||||
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_compaction_threshold_derives_from_a_declared_context_window() {
|
||||
// 70% of 65536, so the guard sits inside the window instead of 34k above it.
|
||||
assert_eq!(
|
||||
resolve_auto_compaction_threshold(None, Some("65536")),
|
||||
45875
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_threshold_still_wins_over_the_declared_window() {
|
||||
assert_eq!(
|
||||
resolve_auto_compaction_threshold(Some("48000"), Some("65536")),
|
||||
48000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unparseable_context_window_falls_back_to_the_default_threshold() {
|
||||
assert_eq!(
|
||||
resolve_auto_compaction_threshold(None, Some("plenty")),
|
||||
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
|
||||
);
|
||||
}
|
||||
@@ -1885,6 +2380,146 @@ mod tests {
|
||||
assert_eq!(error.to_string(), "unknown tool: missing");
|
||||
}
|
||||
|
||||
/// Replays a fixed script of stream outcomes, one per call.
|
||||
struct ScriptedApi {
|
||||
steps: Vec<Result<Vec<AssistantEvent>, String>>,
|
||||
calls: usize,
|
||||
}
|
||||
|
||||
impl ScriptedApi {
|
||||
fn new(steps: Vec<Result<Vec<AssistantEvent>, String>>) -> Self {
|
||||
Self { steps, calls: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiClient for ScriptedApi {
|
||||
fn stream(&mut self, _request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
let step = self
|
||||
.steps
|
||||
.get(self.calls)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Err("script exhausted".to_string()));
|
||||
self.calls += 1;
|
||||
step.map_err(RuntimeError::new)
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_call_events(id: &str) -> Vec<AssistantEvent> {
|
||||
vec![
|
||||
AssistantEvent::ToolUse {
|
||||
id: id.to_string(),
|
||||
name: "echo".to_string(),
|
||||
input: "payload".to_string(),
|
||||
},
|
||||
AssistantEvent::MessageStop,
|
||||
]
|
||||
}
|
||||
|
||||
fn scripted_runtime(
|
||||
steps: Vec<Result<Vec<AssistantEvent>, String>>,
|
||||
) -> ConversationRuntime<ScriptedApi, StaticToolExecutor> {
|
||||
ConversationRuntime::new(
|
||||
Session::new(),
|
||||
ScriptedApi::new(steps),
|
||||
StaticToolExecutor::new().register("echo", |input| Ok(input.to_string())),
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_turn_keeps_the_results_of_tools_that_already_ran() {
|
||||
// given: a turn whose first iteration runs a tool, and whose second call dies
|
||||
let mut runtime = scripted_runtime(vec![
|
||||
Ok(tool_call_events("tool-1")),
|
||||
Err("upstream failed".to_string()),
|
||||
]);
|
||||
|
||||
// when
|
||||
runtime
|
||||
.run_turn("do the thing", None)
|
||||
.expect_err("the second call fails");
|
||||
|
||||
// then: the executed tool's result is still in the transcript. The side effect happened;
|
||||
// a caller that discards this record will let the model run the same tool again.
|
||||
let tool_results = runtime
|
||||
.session()
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|message| {
|
||||
message
|
||||
.blocks
|
||||
.iter()
|
||||
.any(|block| matches!(block, ContentBlock::ToolResult { .. }))
|
||||
})
|
||||
.count();
|
||||
assert_eq!(tool_results, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_turn_continues_without_appending_another_user_message() {
|
||||
// given: a session that already carries a user turn and a completed tool result
|
||||
let mut runtime = scripted_runtime(vec![
|
||||
Ok(tool_call_events("tool-1")),
|
||||
Err("upstream failed".to_string()),
|
||||
Ok(vec![
|
||||
AssistantEvent::TextDelta("done".to_string()),
|
||||
AssistantEvent::MessageStop,
|
||||
]),
|
||||
]);
|
||||
runtime.run_turn("do the thing", None).expect_err("fails");
|
||||
let user_messages_before = runtime
|
||||
.session()
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|message| message.role == MessageRole::User)
|
||||
.count();
|
||||
|
||||
// when
|
||||
runtime
|
||||
.resume_turn(None)
|
||||
.expect("the resumed turn succeeds");
|
||||
|
||||
// then: the prompt was not repeated
|
||||
let user_messages_after = runtime
|
||||
.session()
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|message| message.role == MessageRole::User)
|
||||
.count();
|
||||
assert_eq!(user_messages_before, user_messages_after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stall_nudge_is_recorded_as_a_system_turn_not_a_user_one() {
|
||||
// given: a reply that announces work and stops, so the nudge fires once
|
||||
let mut runtime = nudge_runtime(
|
||||
vec![
|
||||
text_only("Let me check the logs."),
|
||||
text_only("The logs were clean."),
|
||||
],
|
||||
1,
|
||||
);
|
||||
runtime
|
||||
.run_turn("check the logs", None)
|
||||
.expect("the nudged turn completes");
|
||||
|
||||
// then: exactly one user message — the real one
|
||||
let user_messages = runtime
|
||||
.session()
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|message| message.role == MessageRole::User)
|
||||
.count();
|
||||
assert_eq!(user_messages, 1);
|
||||
assert!(runtime
|
||||
.session()
|
||||
.messages
|
||||
.iter()
|
||||
.any(|message| message.role == MessageRole::System
|
||||
&& assistant_text(message).contains("[auto-continue]")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_turn_errors_when_max_iterations_is_exceeded() {
|
||||
struct LoopingApi;
|
||||
@@ -1956,4 +2591,136 @@ mod tests {
|
||||
// then
|
||||
assert_eq!(error.to_string(), "upstream failed");
|
||||
}
|
||||
|
||||
fn text_only(text: &str) -> Vec<AssistantEvent> {
|
||||
vec![
|
||||
AssistantEvent::TextDelta(text.to_string()),
|
||||
AssistantEvent::MessageStop,
|
||||
]
|
||||
}
|
||||
|
||||
struct CountingApi {
|
||||
calls: usize,
|
||||
replies: Vec<Vec<AssistantEvent>>,
|
||||
}
|
||||
|
||||
impl ApiClient for CountingApi {
|
||||
fn stream(&mut self, _request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
let reply = self
|
||||
.replies
|
||||
.get(self.calls)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| text_only("Now running the next probe:"));
|
||||
self.calls += 1;
|
||||
Ok(reply)
|
||||
}
|
||||
}
|
||||
|
||||
fn nudge_runtime(
|
||||
replies: Vec<Vec<AssistantEvent>>,
|
||||
max_stall_nudges: usize,
|
||||
) -> ConversationRuntime<CountingApi, StaticToolExecutor> {
|
||||
ConversationRuntime::new(
|
||||
Session::new(),
|
||||
CountingApi { calls: 0, replies },
|
||||
StaticToolExecutor::new().register("probe", |_| Ok("probe output".to_string())),
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
)
|
||||
.with_max_stall_nudges(max_stall_nudges)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn announced_but_uncalled_tool_is_auto_continued() {
|
||||
// given: the model narrates the next probe instead of calling it
|
||||
let mut runtime = nudge_runtime(
|
||||
vec![
|
||||
text_only("Now running the Ford EEC-V tuner probe (ATSP1):"),
|
||||
vec![
|
||||
AssistantEvent::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "probe".to_string(),
|
||||
input: "atsp1".to_string(),
|
||||
},
|
||||
AssistantEvent::MessageStop,
|
||||
],
|
||||
text_only("No response on ATSP1, which is expected here."),
|
||||
],
|
||||
3,
|
||||
);
|
||||
|
||||
// when
|
||||
let summary = runtime.run_turn("run the probes", None).expect("turn runs");
|
||||
|
||||
// then: the nudge pulled it back into the loop and the tool actually ran
|
||||
assert_eq!(summary.iterations, 3);
|
||||
assert_eq!(summary.tool_results.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nudges_are_bounded_so_a_stuck_model_still_stops() {
|
||||
// given: every reply is an announcement and nothing is ever called
|
||||
let mut runtime = nudge_runtime(Vec::new(), 2);
|
||||
|
||||
// when
|
||||
let summary = runtime.run_turn("run the probes", None).expect("turn runs");
|
||||
|
||||
// then: initial reply plus exactly two nudged retries
|
||||
assert_eq!(summary.iterations, 3);
|
||||
assert!(summary.tool_results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_completion_is_not_nudged() {
|
||||
// given
|
||||
let mut runtime = nudge_runtime(vec![text_only("The DTC scan came back clean.")], 3);
|
||||
|
||||
// when
|
||||
let summary = runtime.run_turn("scan for codes", None).expect("turn runs");
|
||||
|
||||
// then
|
||||
assert_eq!(summary.iterations, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_budget_disables_auto_continue() {
|
||||
// given
|
||||
let mut runtime = nudge_runtime(vec![text_only("Let me run the DTC scan.")], 0);
|
||||
|
||||
// when
|
||||
let summary = runtime.run_turn("scan for codes", None).expect("turn runs");
|
||||
|
||||
// then
|
||||
assert_eq!(summary.iterations, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_question_stalls_only_after_a_tool_has_run() {
|
||||
assert!(is_stalled_reply("Should I run the DTC scan next?", true));
|
||||
assert!(!is_stalled_reply("Which port is the adapter on?", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stall_detection_reads_the_last_line_not_the_whole_reply() {
|
||||
// an announcement earlier in the reply does not make a finished turn look stalled
|
||||
assert!(!is_stalled_reply(
|
||||
"Let me check the port.\nThe scan finished with no codes stored.",
|
||||
true
|
||||
));
|
||||
assert!(is_stalled_reply(
|
||||
"The scan finished.\nNext I'll read the freeze-frame data.",
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stall_nudge_budget_parses_from_env_value() {
|
||||
// asserted against the literal, not the constant: comparing the parser to the same
|
||||
// constant it falls back to would pass no matter what the default became
|
||||
assert_eq!(DEFAULT_MAX_STALL_NUDGES, 3);
|
||||
assert_eq!(parse_max_stall_nudges(None), 3);
|
||||
assert_eq!(parse_max_stall_nudges(Some("not a number")), 3);
|
||||
assert_eq!(parse_max_stall_nudges(Some(" 5 ")), 5);
|
||||
assert_eq!(parse_max_stall_nudges(Some("0")), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,48 @@ use serde::{Deserialize, Serialize};
|
||||
use walkdir::{DirEntry, WalkDir};
|
||||
|
||||
/// Maximum file size that can be read (10 MB).
|
||||
///
|
||||
/// This is a *process* guard — it stops us slurping a huge or binary blob into memory. It is
|
||||
/// not a context guard: 10 MB is on the order of 2.5 M tokens, so it never fires on the files
|
||||
/// that actually blow a model's context window. `DEFAULT_READ_LINES` / `MAX_READ_CHARS` below
|
||||
/// are the context guard.
|
||||
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
|
||||
|
||||
/// Lines returned by `read_file` when the caller does not pass an explicit `limit`.
|
||||
///
|
||||
/// This is a *paging* size, not a refusal. An unbounded read is the easiest way for a model to
|
||||
/// destroy its own context: one 100 KB source file is ~25 k tokens, which does not fit alongside
|
||||
/// a system prompt in a 32 k window. When that happens the backend rejects the whole request and
|
||||
/// the session dies — so reading everything at once is what makes it stop. Returning a page plus
|
||||
/// `nextOffset` means the read always succeeds and the model can keep going.
|
||||
const DEFAULT_READ_LINES: usize = 2000;
|
||||
|
||||
/// Hard ceiling on the characters `read_file` returns, applied even when `limit` is explicit.
|
||||
///
|
||||
/// `DEFAULT_READ_LINES` alone does not bound the payload: minified bundles, JSON blobs and
|
||||
/// generated sources routinely put megabytes on a handful of lines. ~64 KB is ~16 k tokens.
|
||||
///
|
||||
/// That default assumes a large (200 k) context window. Against a small self-hosted backend it
|
||||
/// is far too generous: two reads at this size put a request over 47 k tokens, which a 32 k
|
||||
/// window rejects outright — and compaction cannot recover it, because the preserved recent
|
||||
/// messages ARE the oversized tool results. Override with [`MAX_READ_CHARS_ENV_VAR`].
|
||||
const DEFAULT_MAX_READ_CHARS: usize = 64 * 1024;
|
||||
|
||||
/// Environment override for [`DEFAULT_MAX_READ_CHARS`], in characters.
|
||||
const MAX_READ_CHARS_ENV_VAR: &str = "CLAW_MAX_READ_CHARS";
|
||||
|
||||
/// Effective character ceiling for one `read_file` page.
|
||||
///
|
||||
/// Read per call rather than cached: a long-lived process should pick up a changed budget, and
|
||||
/// this runs once per read, not per line.
|
||||
fn max_read_chars() -> usize {
|
||||
std::env::var(MAX_READ_CHARS_ENV_VAR)
|
||||
.ok()
|
||||
.and_then(|raw| raw.trim().parse::<usize>().ok())
|
||||
.filter(|chars| *chars > 0)
|
||||
.unwrap_or(DEFAULT_MAX_READ_CHARS)
|
||||
}
|
||||
|
||||
/// Maximum file size that can be written (10 MB).
|
||||
const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024;
|
||||
|
||||
@@ -80,6 +120,17 @@ pub struct TextFilePayload {
|
||||
pub start_line: usize,
|
||||
#[serde(rename = "totalLines")]
|
||||
pub total_lines: usize,
|
||||
/// True when the file has more lines past this page. Signals the caller that it has NOT seen
|
||||
/// the whole file, so it does not silently reason over a partial view.
|
||||
#[serde(default)]
|
||||
pub truncated: bool,
|
||||
/// Line index to pass back as `offset` to continue reading. Absent once the file is exhausted.
|
||||
#[serde(
|
||||
rename = "nextOffset",
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub next_offset: Option<usize>,
|
||||
}
|
||||
|
||||
/// Output envelope for the `read_file` tool.
|
||||
@@ -228,10 +279,28 @@ pub fn read_file(
|
||||
let content = fs::read_to_string(&absolute_path)?;
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let start_index = offset.unwrap_or(0).min(lines.len());
|
||||
let end_index = limit.map_or(lines.len(), |limit| {
|
||||
start_index.saturating_add(limit).min(lines.len())
|
||||
});
|
||||
// An absent `limit` pages rather than reading to EOF: see DEFAULT_READ_LINES.
|
||||
let requested_end = start_index
|
||||
.saturating_add(limit.unwrap_or(DEFAULT_READ_LINES))
|
||||
.min(lines.len());
|
||||
|
||||
// Apply the character ceiling on top of the line window, so a file with very long lines
|
||||
// cannot blow the budget the line cap was meant to enforce. Always yield at least one line,
|
||||
// otherwise an oversized single line would return nothing and the caller could not advance.
|
||||
let mut end_index = start_index;
|
||||
let mut chars = 0usize;
|
||||
let char_ceiling = max_read_chars();
|
||||
for line in &lines[start_index..requested_end] {
|
||||
let next = chars + line.chars().count() + 1;
|
||||
if next > char_ceiling && end_index > start_index {
|
||||
break;
|
||||
}
|
||||
chars = next;
|
||||
end_index += 1;
|
||||
}
|
||||
|
||||
let selected = lines[start_index..end_index].join("\n");
|
||||
let truncated = end_index < lines.len();
|
||||
|
||||
Ok(ReadFileOutput {
|
||||
kind: String::from("text"),
|
||||
@@ -241,6 +310,8 @@ pub fn read_file(
|
||||
num_lines: end_index.saturating_sub(start_index),
|
||||
start_line: start_index.saturating_add(1),
|
||||
total_lines: lines.len(),
|
||||
truncated,
|
||||
next_offset: truncated.then_some(end_index),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -812,11 +883,18 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
component_contains_glob, derive_glob_walk_root, edit_file, expand_braces, glob_search,
|
||||
grep_search, is_symlink_escape, normalize_for_comparison, read_file,
|
||||
grep_search, is_symlink_escape, max_read_chars, normalize_for_comparison, read_file,
|
||||
read_file_in_workspace, write_file, write_file_in_workspace, GrepSearchInput,
|
||||
MAX_WRITE_SIZE,
|
||||
DEFAULT_MAX_READ_CHARS, DEFAULT_READ_LINES, MAX_READ_CHARS_ENV_VAR, MAX_WRITE_SIZE,
|
||||
};
|
||||
|
||||
/// Serializes the tests that mutate `CLAW_MAX_READ_CHARS`; env vars are process-global.
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn temp_path(name: &str) -> std::path::PathBuf {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -837,6 +915,132 @@ mod tests {
|
||||
assert_eq!(read_output.file.content, "two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbounded_read_of_a_large_file_pages_instead_of_returning_everything() {
|
||||
// Regression: an unbounded read used to return the whole file, which on a small-context
|
||||
// backend overflows the window and the request is rejected outright — the session dies.
|
||||
let path = temp_path("paged-read.txt");
|
||||
let total = DEFAULT_READ_LINES + 500;
|
||||
let body = (0..total)
|
||||
.map(|i| format!("line {i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
write_file(path.to_string_lossy().as_ref(), &body).expect("write should succeed");
|
||||
|
||||
let first = read_file(path.to_string_lossy().as_ref(), None, None).expect("first page");
|
||||
assert_eq!(first.file.num_lines, DEFAULT_READ_LINES);
|
||||
assert_eq!(first.file.total_lines, total);
|
||||
assert!(first.file.truncated, "caller must be told it is partial");
|
||||
assert_eq!(first.file.next_offset, Some(DEFAULT_READ_LINES));
|
||||
assert!(first.file.content.starts_with("line 0\n"));
|
||||
|
||||
// The advertised next_offset must actually resume where the last page stopped.
|
||||
let second = read_file(
|
||||
path.to_string_lossy().as_ref(),
|
||||
first.file.next_offset,
|
||||
None,
|
||||
)
|
||||
.expect("second page");
|
||||
assert_eq!(second.file.num_lines, 500);
|
||||
assert!(!second.file.truncated, "the tail is the final page");
|
||||
assert_eq!(second.file.next_offset, None);
|
||||
assert!(second
|
||||
.file
|
||||
.content
|
||||
.starts_with(&format!("line {DEFAULT_READ_LINES}\n")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_ceiling_bounds_files_whose_lines_are_enormous() {
|
||||
// A line cap alone does not bound the payload: minified bundles put megabytes on a few
|
||||
// lines, so the byte ceiling has to apply too — and must still yield forward progress.
|
||||
let _guard = env_lock();
|
||||
std::env::remove_var(MAX_READ_CHARS_ENV_VAR);
|
||||
let path = temp_path("long-lines.txt");
|
||||
let huge = "x".repeat(DEFAULT_MAX_READ_CHARS);
|
||||
let body = format!("{huge}\n{huge}\n{huge}");
|
||||
write_file(path.to_string_lossy().as_ref(), &body).expect("write should succeed");
|
||||
|
||||
let page = read_file(path.to_string_lossy().as_ref(), None, None).expect("first page");
|
||||
assert_eq!(page.file.num_lines, 1, "one oversized line still advances");
|
||||
assert!(page.file.truncated);
|
||||
assert_eq!(page.file.next_offset, Some(1));
|
||||
|
||||
// An explicit limit must not be able to defeat the ceiling.
|
||||
let greedy = read_file(path.to_string_lossy().as_ref(), None, Some(3)).expect("greedy");
|
||||
assert_eq!(greedy.file.num_lines, 1);
|
||||
assert!(greedy.file.truncated);
|
||||
}
|
||||
|
||||
/// The 64 KB default is a 200 k-context number. A small self-hosted backend must be able to
|
||||
/// shrink it, or two reads overflow a 32 k window in a single turn.
|
||||
#[test]
|
||||
fn read_char_ceiling_is_configurable_via_the_environment() {
|
||||
let _guard = env_lock();
|
||||
let path = temp_path("configurable-ceiling.txt");
|
||||
// 40 lines of 100 chars = ~4 KB: far under the default ceiling, over a 1 KB one.
|
||||
let body = (0..40)
|
||||
.map(|_| "y".repeat(100))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
write_file(path.to_string_lossy().as_ref(), &body).expect("write should succeed");
|
||||
|
||||
std::env::remove_var(MAX_READ_CHARS_ENV_VAR);
|
||||
let whole = read_file(path.to_string_lossy().as_ref(), None, None).expect("default read");
|
||||
assert_eq!(
|
||||
whole.file.num_lines, 40,
|
||||
"default ceiling returns the file whole"
|
||||
);
|
||||
assert!(!whole.file.truncated);
|
||||
|
||||
std::env::set_var(MAX_READ_CHARS_ENV_VAR, "1024");
|
||||
let clipped = read_file(path.to_string_lossy().as_ref(), None, None).expect("clipped read");
|
||||
std::env::remove_var(MAX_READ_CHARS_ENV_VAR);
|
||||
assert!(
|
||||
clipped.file.num_lines < 40,
|
||||
"a smaller ceiling must actually clip, got {} lines",
|
||||
clipped.file.num_lines
|
||||
);
|
||||
assert!(
|
||||
clipped.file.truncated,
|
||||
"a clipped page must report truncated"
|
||||
);
|
||||
assert_eq!(
|
||||
clipped.file.next_offset,
|
||||
Some(clipped.file.num_lines),
|
||||
"the caller must be told where to resume"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_char_ceiling_ignores_junk_and_zero_values() {
|
||||
let _guard = env_lock();
|
||||
for value in ["", "0", "-5", "not-a-number"] {
|
||||
std::env::set_var(MAX_READ_CHARS_ENV_VAR, value);
|
||||
assert_eq!(
|
||||
max_read_chars(),
|
||||
DEFAULT_MAX_READ_CHARS,
|
||||
"{value:?} must fall back to the default rather than disabling reads"
|
||||
);
|
||||
}
|
||||
std::env::set_var(MAX_READ_CHARS_ENV_VAR, "24000");
|
||||
assert_eq!(max_read_chars(), 24_000);
|
||||
std::env::remove_var(MAX_READ_CHARS_ENV_VAR);
|
||||
assert_eq!(max_read_chars(), DEFAULT_MAX_READ_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_files_are_returned_whole_and_not_marked_truncated() {
|
||||
let path = temp_path("small-read.txt");
|
||||
write_file(path.to_string_lossy().as_ref(), "one\ntwo\nthree").expect("write");
|
||||
|
||||
let output = read_file(path.to_string_lossy().as_ref(), None, None).expect("read");
|
||||
assert_eq!(output.file.content, "one\ntwo\nthree");
|
||||
assert_eq!(output.file.num_lines, 3);
|
||||
assert!(!output.file.truncated);
|
||||
assert_eq!(output.file.next_offset, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_file_contents() {
|
||||
let path = temp_path("edit.txt");
|
||||
|
||||
@@ -42,7 +42,25 @@ pub const SYSTEM_PROMPT_DYNAMIC_BOUNDARY: &str = "__SYSTEM_PROMPT_DYNAMIC_BOUNDA
|
||||
pub const FRONTIER_MODEL_NAME: &str = "Claude Opus 4.6";
|
||||
const MAX_INSTRUCTION_FILE_CHARS: usize = 4_000;
|
||||
const MAX_TOTAL_INSTRUCTION_CHARS: usize = 12_000;
|
||||
const MAX_GIT_DIFF_CHARS: usize = 50_000;
|
||||
/// Character budget for the git diff snapshot embedded in the system prompt.
|
||||
///
|
||||
/// This lives in the *system prompt*, so its cost is paid on every session whose
|
||||
/// working tree has changed — and a diff changes on essentially every edit. The
|
||||
/// previous 50_000 budget was four times the entire instruction-file budget and
|
||||
/// could add ~15_000 tokens to the prompt from a single modified file, which is
|
||||
/// affordable against a hosted frontier model and ruinous against a local one.
|
||||
///
|
||||
/// Override with `CLAW_MAX_GIT_DIFF_CHARS`. `0` drops the diff snapshot entirely;
|
||||
/// git status and the recent-commit list are unaffected.
|
||||
const DEFAULT_MAX_GIT_DIFF_CHARS: usize = 4_000;
|
||||
const MAX_GIT_DIFF_CHARS_ENV_VAR: &str = "CLAW_MAX_GIT_DIFF_CHARS";
|
||||
|
||||
fn max_git_diff_chars() -> usize {
|
||||
std::env::var(MAX_GIT_DIFF_CHARS_ENV_VAR)
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse().ok())
|
||||
.unwrap_or(DEFAULT_MAX_GIT_DIFF_CHARS)
|
||||
}
|
||||
|
||||
/// Neutral identity for the model family line in generated prompts.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
@@ -498,8 +516,15 @@ fn read_git_diff(cwd: &Path) -> Option<String> {
|
||||
}
|
||||
|
||||
fn truncate_diff(mut diff: String) -> String {
|
||||
if diff.len() > MAX_GIT_DIFF_CHARS {
|
||||
let mut end = MAX_GIT_DIFF_CHARS;
|
||||
let budget = max_git_diff_chars();
|
||||
// A zero budget means "omit the diff", not "truncate to nothing" -- returning
|
||||
// an empty string here would still push an empty "Git diff snapshot:" heading
|
||||
// into the prompt.
|
||||
if budget == 0 {
|
||||
return String::new();
|
||||
}
|
||||
if diff.len() > budget {
|
||||
let mut end = budget;
|
||||
while !diff.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
@@ -758,6 +783,7 @@ fn get_simple_doing_tasks_section() -> String {
|
||||
"Do not add speculative abstractions, compatibility shims, or unrelated cleanup.".to_string(),
|
||||
"Do not create files unless they are required to complete the task.".to_string(),
|
||||
"If an approach fails, diagnose the failure before switching tactics.".to_string(),
|
||||
"Act in the turn you plan in: when the next step is a tool call, make the call. Never end a turn describing a step you have not taken, and do not stop to ask for a go-ahead on work already requested.".to_string(),
|
||||
"Be careful not to introduce security vulnerabilities such as command injection, XSS, or SQL injection.".to_string(),
|
||||
"Report outcomes faithfully: if verification fails or was not run, say so explicitly.".to_string(),
|
||||
]);
|
||||
@@ -782,7 +808,8 @@ mod tests {
|
||||
collapse_blank_lines, display_context_path, normalize_instruction_content,
|
||||
render_instruction_content, render_instruction_files, truncate_diff,
|
||||
truncate_instruction_content, ContextFile, ModelFamilyIdentity, ProjectContext,
|
||||
SystemPromptBuilder, MAX_GIT_DIFF_CHARS, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
SystemPromptBuilder, DEFAULT_MAX_GIT_DIFF_CHARS, MAX_GIT_DIFF_CHARS_ENV_VAR,
|
||||
SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
};
|
||||
use crate::config::ConfigLoader;
|
||||
use std::fs;
|
||||
@@ -1444,33 +1471,69 @@ mod tests {
|
||||
assert!(rendered.contains("Project rules"));
|
||||
}
|
||||
|
||||
/// Serializes the tests that read or mutate `CLAW_MAX_GIT_DIFF_CHARS`; env vars
|
||||
/// are process-global and the default-budget tests fail if another test leaves
|
||||
/// an override set.
|
||||
fn diff_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_preserves_short_content() {
|
||||
let _guard = diff_env_lock();
|
||||
std::env::remove_var(MAX_GIT_DIFF_CHARS_ENV_VAR);
|
||||
let short = "a".repeat(1_000);
|
||||
let result = truncate_diff(short.clone());
|
||||
assert_eq!(result, short);
|
||||
assert!(!result.contains("[diff truncated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_honors_env_override() {
|
||||
let _guard = diff_env_lock();
|
||||
std::env::set_var(MAX_GIT_DIFF_CHARS_ENV_VAR, "100");
|
||||
let result = truncate_diff("z".repeat(1_000));
|
||||
std::env::remove_var(MAX_GIT_DIFF_CHARS_ENV_VAR);
|
||||
let marker = "\n\n... [diff truncated — too large for system prompt]";
|
||||
assert!(result.ends_with(marker));
|
||||
assert_eq!(result.len() - marker.len(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_zero_budget_omits_the_snapshot_entirely() {
|
||||
// Zero must yield nothing at all -- a truncated-to-empty body would still
|
||||
// drag a "Git diff snapshot:" heading and the truncation marker into the
|
||||
// prompt, which is the opposite of what the operator asked for.
|
||||
let _guard = diff_env_lock();
|
||||
std::env::set_var(MAX_GIT_DIFF_CHARS_ENV_VAR, "0");
|
||||
let result = truncate_diff("z".repeat(1_000));
|
||||
std::env::remove_var(MAX_GIT_DIFF_CHARS_ENV_VAR);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_caps_oversized_content() {
|
||||
let large = "x".repeat(MAX_GIT_DIFF_CHARS + 5_000);
|
||||
let _guard = diff_env_lock();
|
||||
std::env::remove_var(MAX_GIT_DIFF_CHARS_ENV_VAR);
|
||||
let large = "x".repeat(DEFAULT_MAX_GIT_DIFF_CHARS + 5_000);
|
||||
let result = truncate_diff(large);
|
||||
assert!(result.contains("... [diff truncated — too large for system prompt]"));
|
||||
// The body before the marker must be at most MAX_GIT_DIFF_CHARS bytes
|
||||
// The body before the marker must be at most DEFAULT_MAX_GIT_DIFF_CHARS bytes
|
||||
let marker = "\n\n... [diff truncated — too large for system prompt]";
|
||||
let body_len = result.len() - marker.len();
|
||||
assert!(body_len <= MAX_GIT_DIFF_CHARS);
|
||||
assert!(body_len <= DEFAULT_MAX_GIT_DIFF_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_respects_utf8_char_boundaries() {
|
||||
// Build a string where MAX_GIT_DIFF_CHARS falls in the middle of a
|
||||
// Build a string where DEFAULT_MAX_GIT_DIFF_CHARS falls in the middle of a
|
||||
// multi-byte character (U+1F600 = 4 bytes in UTF-8).
|
||||
let prefix_len = MAX_GIT_DIFF_CHARS - 2;
|
||||
let prefix_len = DEFAULT_MAX_GIT_DIFF_CHARS - 2;
|
||||
let mut input = "a".repeat(prefix_len);
|
||||
// Append a 4-byte emoji so bytes [prefix_len..prefix_len+4] are the
|
||||
// emoji. MAX_GIT_DIFF_CHARS lands at prefix_len+2, inside the emoji.
|
||||
// emoji. DEFAULT_MAX_GIT_DIFF_CHARS lands at prefix_len+2, inside the emoji.
|
||||
input.push('\u{1F600}');
|
||||
input.push_str(&"b".repeat(10_000));
|
||||
|
||||
@@ -1482,7 +1545,7 @@ mod tests {
|
||||
// inside it would be invalid UTF-8.
|
||||
let marker = "\n\n... [diff truncated — too large for system prompt]";
|
||||
let body = &result[..result.len() - marker.len()];
|
||||
assert!(body.len() <= MAX_GIT_DIFF_CHARS);
|
||||
assert!(body.len() <= DEFAULT_MAX_GIT_DIFF_CHARS);
|
||||
assert!(body.is_char_boundary(body.len()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,6 +718,20 @@ impl ConversationMessage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text the harness injected on its own initiative, not something the user typed.
|
||||
///
|
||||
/// Reaches the model as an ordinary turn — the wire protocol has nowhere else to put it — but
|
||||
/// stays labelled in the transcript, so a replay, an export, or a compaction summary does not
|
||||
/// present the harness's own prompting back as the user's words.
|
||||
#[must_use]
|
||||
pub fn system_text(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: MessageRole::System,
|
||||
blocks: vec![ContentBlock::Text { text: text.into() }],
|
||||
usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn assistant(blocks: Vec<ContentBlock>) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -29,7 +29,7 @@ impl Default for TridentConfig {
|
||||
}
|
||||
|
||||
/// Statistics from a Trident compaction run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct TridentStats {
|
||||
pub superseded_count: usize,
|
||||
pub collapsed_chains: usize,
|
||||
@@ -41,21 +41,6 @@ pub struct TridentStats {
|
||||
pub final_message_count: usize,
|
||||
}
|
||||
|
||||
impl Default for TridentStats {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
superseded_count: 0,
|
||||
collapsed_chains: 0,
|
||||
messages_collapsed: 0,
|
||||
clusters_found: 0,
|
||||
messages_clustered: 0,
|
||||
tokens_saved_estimate: 0,
|
||||
original_message_count: 0,
|
||||
final_message_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TridentStats {
|
||||
pub fn format_report(&self) -> String {
|
||||
let compression = if self.final_message_count > 0 {
|
||||
@@ -192,7 +177,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
|
||||
|
||||
let mut obsolete_indices: BTreeSet<usize> = BTreeSet::new();
|
||||
|
||||
for (_path, ops) in &file_ops {
|
||||
for ops in file_ops.values() {
|
||||
if ops.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
@@ -205,10 +190,8 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
|
||||
|
||||
if let Some(last_write) = last_write_idx {
|
||||
for op in ops {
|
||||
if op.op_type == FileOp::Read && op.index < last_write {
|
||||
obsolete_indices.insert(op.index);
|
||||
} else if (op.op_type == FileOp::Write || op.op_type == FileOp::Edit)
|
||||
&& op.index < last_write
|
||||
if op.index < last_write
|
||||
&& matches!(op.op_type, FileOp::Read | FileOp::Write | FileOp::Edit)
|
||||
{
|
||||
obsolete_indices.insert(op.index);
|
||||
}
|
||||
@@ -325,7 +308,7 @@ fn stage2_collapse(
|
||||
usage: None,
|
||||
});
|
||||
} else {
|
||||
result.extend(buffer.drain(..));
|
||||
result.append(&mut buffer);
|
||||
}
|
||||
buffer.clear();
|
||||
result.push(msg.clone());
|
||||
@@ -476,7 +459,7 @@ fn stage3_cluster(
|
||||
}
|
||||
|
||||
let total_clustered: usize = cluster_assignments.len();
|
||||
let clusters_found = cluster_id as usize;
|
||||
let clusters_found = cluster_id;
|
||||
|
||||
let mut result: Vec<ConversationMessage> = Vec::new();
|
||||
let mut cluster_buffers: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||
@@ -677,7 +660,7 @@ fn truncate_text(text: &str, max_chars: usize) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::compact::CompactionConfig;
|
||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
use crate::session::{ContentBlock, ConversationMessage, Session};
|
||||
|
||||
#[test]
|
||||
fn stage1_removes_obsolete_file_reads() {
|
||||
@@ -736,7 +719,7 @@ mod tests {
|
||||
fn stage2_collapses_chatty_messages() {
|
||||
let mut messages = vec![];
|
||||
for i in 0..6 {
|
||||
messages.push(ConversationMessage::user_text(&format!("ok {i}")));
|
||||
messages.push(ConversationMessage::user_text(format!("ok {i}")));
|
||||
messages.push(ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
text: format!("got {i}"),
|
||||
}]));
|
||||
@@ -767,9 +750,9 @@ mod tests {
|
||||
},
|
||||
]));
|
||||
messages.push(ConversationMessage::tool_result(
|
||||
&format!("read_{i}"),
|
||||
format!("read_{i}"),
|
||||
"read_file",
|
||||
&format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
|
||||
format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -89,6 +89,19 @@ impl TokenUsage {
|
||||
+ self.cache_read_input_tokens
|
||||
}
|
||||
|
||||
/// Total tokens the provider had to read to serve this call, i.e. the live size of the
|
||||
/// context window.
|
||||
///
|
||||
/// `input_tokens` alone is NOT that number. With prompt caching a warm call reports
|
||||
/// `input_tokens: 2` and puts the real bulk in `cache_read_input_tokens`, so anything
|
||||
/// budgeting against the context window must sum all three input counters.
|
||||
#[must_use]
|
||||
pub fn total_input_tokens(self) -> u32 {
|
||||
self.input_tokens
|
||||
.saturating_add(self.cache_creation_input_tokens)
|
||||
.saturating_add(self.cache_read_input_tokens)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn estimate_cost_usd(self) -> UsageCostEstimate {
|
||||
self.estimate_cost_usd_with_pricing(ModelPricing::default_sonnet_tier())
|
||||
@@ -243,6 +256,41 @@ mod tests {
|
||||
assert_eq!(tracker.cumulative_usage().total_tokens(), 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_input_tokens_includes_cached_context() {
|
||||
// The shape a warm prompt-cached call actually reports: input_tokens is ~nothing and
|
||||
// the real context size is in the cache counters.
|
||||
let cached = TokenUsage {
|
||||
input_tokens: 2,
|
||||
output_tokens: 1_133,
|
||||
cache_creation_input_tokens: 1_453,
|
||||
cache_read_input_tokens: 111_186,
|
||||
};
|
||||
assert_eq!(cached.total_input_tokens(), 112_641);
|
||||
// Output tokens are not part of the input budget.
|
||||
assert_ne!(cached.total_input_tokens(), cached.total_tokens());
|
||||
|
||||
// A backend without prompt caching reports the whole prompt as input_tokens.
|
||||
let uncached = TokenUsage {
|
||||
input_tokens: 29_056,
|
||||
output_tokens: 73,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
};
|
||||
assert_eq!(uncached.total_input_tokens(), 29_056);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_input_tokens_saturates_instead_of_overflowing() {
|
||||
let usage = TokenUsage {
|
||||
input_tokens: u32::MAX,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: u32::MAX,
|
||||
cache_read_input_tokens: u32::MAX,
|
||||
};
|
||||
assert_eq!(usage.total_input_tokens(), u32::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computes_cost_summary_lines() {
|
||||
let usage = TokenUsage {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,6 @@ use std::io::{self, IsTerminal, Write};
|
||||
|
||||
use runtime::{save_user_provider_settings, ConfigLoader, RuntimeProviderConfig};
|
||||
|
||||
use serde_json;
|
||||
|
||||
const PROVIDERS: &[(&str, &str, &str)] = &[
|
||||
("1", "Anthropic", "anthropic"),
|
||||
("2", "xAI / Grok", "xai"),
|
||||
|
||||
@@ -7,7 +7,9 @@ use std::process::{Command, Output, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
|
||||
use mock_anthropic_service::{
|
||||
MockAnthropicService, CONTEXT_WINDOW_RETRY_TEXT, SCENARIO_PREFIX, STREAM_THEN_ERROR_TEXT,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -189,6 +191,199 @@ fn text_prompt_mode_streams_assistant_text_once_before_spinner_completion() {
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_prompt_mode_emits_the_response_body_exactly_once() {
|
||||
// given a workspace pointed at the mock Anthropic service running the
|
||||
// streaming_text scenario, which emits a single assistant text block
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let workspace = unique_temp_dir("text-prompt-once");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
// when we invoke claw in normal text prompt mode
|
||||
let prompt = format!("{SCENARIO_PREFIX}streaming_text");
|
||||
let output = run_claw(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&base_url,
|
||||
&[
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
&prompt,
|
||||
],
|
||||
);
|
||||
|
||||
// then the response body reaches stdout exactly once: the emitter owns the
|
||||
// terminal while the turn runs, so the caller must not print the assistant
|
||||
// text a second time
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"text prompt run should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let plain_stdout = strip_ansi_codes(&stdout);
|
||||
assert_eq!(
|
||||
plain_stdout
|
||||
.matches("Mock streaming says hello from the parity harness.")
|
||||
.count(),
|
||||
1,
|
||||
"text prompt stdout should carry the response body exactly once ({stdout:?})"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_prompt_mode_emits_retried_body_once_after_auto_compact() {
|
||||
// given a mock service whose first answer to this scenario is a context
|
||||
// window rejection and whose answer to the retry is a streamed response
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let workspace = unique_temp_dir("text-prompt-auto-compact-retry");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
// when claw is rejected for context window overflow and recovers by
|
||||
// compacting the session and retrying the same prompt
|
||||
let prompt = format!("{SCENARIO_PREFIX}context_window_retry");
|
||||
let output = run_claw(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&base_url,
|
||||
&[
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
&prompt,
|
||||
],
|
||||
);
|
||||
|
||||
// then the retry succeeds and its response body is on stdout exactly once.
|
||||
// The recovery path builds a fresh runtime with its own terminal tail, so
|
||||
// this is where a tail that only tracked the first attempt would leave the
|
||||
// streamed line unterminated and let `spinner.finish` clear it.
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"auto-compact retry should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let plain_stdout = strip_ansi_codes(&stdout);
|
||||
assert_eq!(
|
||||
plain_stdout.matches(CONTEXT_WINDOW_RETRY_TEXT).count(),
|
||||
1,
|
||||
"auto-compact retry stdout should carry the response body exactly once ({stdout:?})"
|
||||
);
|
||||
assert!(
|
||||
plain_stdout
|
||||
.lines()
|
||||
.any(|line| line == CONTEXT_WINDOW_RETRY_TEXT),
|
||||
"the retried response body should land on its own line ({stdout:?})"
|
||||
);
|
||||
assert!(
|
||||
plain_stdout.contains("✨ Done (after auto-compact)"),
|
||||
"the retry should report the auto-compact completion ({stdout:?})"
|
||||
);
|
||||
|
||||
// and the scenario really was requested twice, so the retry path ran
|
||||
let captured = runtime.block_on(server.captured_requests());
|
||||
let messages: Vec<_> = captured
|
||||
.iter()
|
||||
.filter(|request| request.path == "/v1/messages")
|
||||
.collect();
|
||||
assert!(
|
||||
messages.len() >= 2,
|
||||
"the scenario should be requested twice so the retry is exercised ({captured:?})"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_prompt_mode_keeps_streamed_text_when_the_turn_fails() {
|
||||
// given a mock service that streams part of an answer and then fails the
|
||||
// response, so the turn ends with text already rendered on the terminal
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let workspace = unique_temp_dir("text-prompt-stream-then-error");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
// when the turn fails after the emitter already streamed a partial line
|
||||
let prompt = format!("{SCENARIO_PREFIX}stream_then_error");
|
||||
let output = run_claw(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&base_url,
|
||||
&[
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
&prompt,
|
||||
],
|
||||
);
|
||||
|
||||
// then the failed turn is reported...
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"a mid-stream failure should fail the run\nstdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let plain_stdout = strip_ansi_codes(&stdout);
|
||||
|
||||
// ...and the text the emitter already streamed survives the failure banner.
|
||||
// `spinner.fail` moves to column 0 and clears the current line, so a caller
|
||||
// that does not know a partial line is pending erases that text instead of
|
||||
// printing the banner underneath it. This is the failure arm of the same
|
||||
// ownership rule the success arm follows.
|
||||
assert!(
|
||||
plain_stdout
|
||||
.lines()
|
||||
.any(|line| line == STREAM_THEN_ERROR_TEXT),
|
||||
"the streamed text should keep its own line despite the failure ({stdout:?})"
|
||||
);
|
||||
assert!(
|
||||
plain_stdout.contains("Request failed"),
|
||||
"the failure banner should still be reported ({stdout:?})"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_flag_with_json_output_emits_structured_json() {
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
|
||||
@@ -451,11 +451,16 @@ fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
|
||||
.output()
|
||||
.expect("git init should launch");
|
||||
|
||||
for (command, expected_kind, expected_status) in [
|
||||
("/version", "version", "ok"),
|
||||
("/sandbox", "sandbox", "warn"),
|
||||
("/diff", "diff", "ok"),
|
||||
("/status", "status", "ok"),
|
||||
// `/sandbox`'s status reflects the host's real sandbox capability, so it is the one
|
||||
// entry with more than one legitimate value: "ok" where namespace isolation works,
|
||||
// "warn" where it is unsupported and only the filesystem sandbox is active (#731).
|
||||
// This test is about #831 routing — that these commands produce a local JSON action
|
||||
// instead of `interactive_only` — not about the kernel the suite happens to run on.
|
||||
for (command, expected_kind, expected_statuses) in [
|
||||
("/version", "version", &["ok"][..]),
|
||||
("/sandbox", "sandbox", &["ok", "warn"][..]),
|
||||
("/diff", "diff", &["ok"][..]),
|
||||
("/status", "status", &["ok"][..]),
|
||||
] {
|
||||
let output = run_claw(&root, &["--output-format", "json", command], &[]);
|
||||
assert!(
|
||||
@@ -470,9 +475,9 @@ fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
|
||||
.unwrap_or_else(|_| panic!("{command} must emit JSON (#831), got: {stdout:?}"));
|
||||
|
||||
assert_eq!(parsed["kind"], expected_kind, "{command} kind: {parsed}");
|
||||
assert_eq!(
|
||||
parsed["status"], expected_status,
|
||||
"{command} status: {parsed}"
|
||||
assert!(
|
||||
expected_statuses.contains(&parsed["status"].as_str().unwrap_or_default()),
|
||||
"{command} status should be one of {expected_statuses:?}: {parsed}"
|
||||
);
|
||||
assert_ne!(
|
||||
parsed["error_kind"], "interactive_only",
|
||||
@@ -1007,13 +1012,13 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
||||
assert!(
|
||||
!plugins
|
||||
.as_object()
|
||||
.map_or(false, |o| o.contains_key("reload_runtime")),
|
||||
.is_some_and(|o| o.contains_key("reload_runtime")),
|
||||
"plugins list should not include reload_runtime"
|
||||
);
|
||||
assert!(
|
||||
!plugins
|
||||
.as_object()
|
||||
.map_or(false, |o| o.contains_key("target")),
|
||||
.is_some_and(|o| o.contains_key("target")),
|
||||
"plugins list should not include target"
|
||||
);
|
||||
// #703: structured summary replaces prose message
|
||||
@@ -1707,13 +1712,13 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
|
||||
assert!(
|
||||
!plugins
|
||||
.as_object()
|
||||
.map_or(false, |o| o.contains_key("reload_runtime")),
|
||||
.is_some_and(|o| o.contains_key("reload_runtime")),
|
||||
"plugins list should not include reload_runtime"
|
||||
);
|
||||
assert!(
|
||||
!plugins
|
||||
.as_object()
|
||||
.map_or(false, |o| o.contains_key("target")),
|
||||
.is_some_and(|o| o.contains_key("target")),
|
||||
"plugins list should not include target"
|
||||
);
|
||||
assert!(
|
||||
@@ -2946,7 +2951,7 @@ fn prompt_empty_arg_json_stdout_missing_prompt_823() {
|
||||
"claw prompt empty arg must retain abort action (#823); got: {parsed}"
|
||||
);
|
||||
assert!(
|
||||
parsed["hint"].as_str().map_or(false, |h| !h.is_empty()),
|
||||
parsed["hint"].as_str().is_some_and(|h| !h.is_empty()),
|
||||
"claw prompt empty arg missing_prompt hint must be non-empty (#823)"
|
||||
);
|
||||
}
|
||||
@@ -2984,9 +2989,9 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
|
||||
"invalid --reasoning-effort must be invalid_flag_value (#756): {parsed}"
|
||||
);
|
||||
assert!(
|
||||
parsed["hint"].as_str().map_or(false, |h| h.contains("low")
|
||||
|| h.contains("medium")
|
||||
|| h.contains("high")),
|
||||
parsed["hint"]
|
||||
.as_str()
|
||||
.is_some_and(|h| h.contains("low") || h.contains("medium") || h.contains("high")),
|
||||
"hint must mention valid values (#756): {parsed}"
|
||||
);
|
||||
|
||||
@@ -3012,7 +3017,7 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
|
||||
"missing --model value must be missing_flag_value (#756): {parsed2}"
|
||||
);
|
||||
assert!(
|
||||
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()),
|
||||
parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()),
|
||||
"missing --model hint must be non-empty (#756): {parsed2}"
|
||||
);
|
||||
}
|
||||
@@ -3256,7 +3261,7 @@ fn short_p_flag_swallows_no_flags_755() {
|
||||
"flag-like token after -p must be rejected as missing_prompt (#755): {parsed2}"
|
||||
);
|
||||
assert!(
|
||||
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()),
|
||||
parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()),
|
||||
"missing_prompt hint must be non-empty (#755)"
|
||||
);
|
||||
}
|
||||
@@ -3398,7 +3403,7 @@ fn config_unsupported_section_json_hint_741() {
|
||||
assert!(
|
||||
parsed["supported_sections"]
|
||||
.as_array()
|
||||
.map_or(false, |a| !a.is_empty()),
|
||||
.is_some_and(|a| !a.is_empty()),
|
||||
"config {section} JSON must include supported_sections (#741)"
|
||||
);
|
||||
}
|
||||
@@ -3455,7 +3460,7 @@ fn export_json_has_kind_702() {
|
||||
// On success stdout has kind:export; on failure stderr has type:error.
|
||||
// Either way, both envelopes must be valid JSON.
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr)
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr)
|
||||
.lines()
|
||||
.filter(|l| l.starts_with('{'))
|
||||
.collect::<Vec<_>>()
|
||||
@@ -3547,8 +3552,8 @@ fn config_parse_error_has_typed_error_kind_and_hint_764() {
|
||||
!output.status.success(),
|
||||
"malformed settings.json should cause non-zero exit"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
@@ -3582,8 +3587,8 @@ fn login_logout_removed_subcommands_have_error_kind_and_hint_765() {
|
||||
!output.status.success(),
|
||||
"claw {subcmd} should exit non-zero"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
@@ -3722,8 +3727,8 @@ fn resume_non_slash_trailing_arg_has_typed_error_kind_and_hint_768() {
|
||||
!output.status.success(),
|
||||
"claw --resume latest compact should exit non-zero"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
@@ -3762,8 +3767,8 @@ fn session_with_unknown_subcommand_returns_interactive_only_not_credentials_767(
|
||||
!output.status.success(),
|
||||
"claw session {sub} should exit non-zero"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
@@ -3816,7 +3821,7 @@ fn slash_only_verbs_with_args_return_interactive_only_not_credentials_770() {
|
||||
"claw {} should exit non-zero",
|
||||
args.join(" ")
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
@@ -3858,8 +3863,8 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
|
||||
{
|
||||
let output = run_claw(&root, &["--output-format", "json", "agents", "bogus"], &[]);
|
||||
assert!(!output.status.success(), "agents bogus should fail");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
@@ -3882,8 +3887,8 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
|
||||
{
|
||||
let output = run_claw(&root, &["--output-format", "json", "plugins", "bogus"], &[]);
|
||||
assert!(!output.status.success(), "plugins bogus should fail");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
@@ -3902,7 +3907,7 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
|
||||
{
|
||||
let output = run_claw(&root, &["--output-format", "json", "mcp", "bogus"], &[]);
|
||||
assert!(!output.status.success(), "mcp bogus should fail");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_str = if stdout.trim().starts_with('{') {
|
||||
@@ -4017,7 +4022,7 @@ fn interactive_only_guard_batch_769_to_771() {
|
||||
"claw {} should exit non-zero",
|
||||
args.join(" ")
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
@@ -4081,7 +4086,7 @@ fn resume_plugin_mutations_are_typed_interactive_only_777() {
|
||||
!output.status.success(),
|
||||
"/plugins {mutation} in resume mode should exit non-zero"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
@@ -4138,7 +4143,7 @@ fn resume_skills_invocation_is_typed_interactive_only_779() {
|
||||
!output.status.success(),
|
||||
"/skills <skill> in resume mode should exit non-zero"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
@@ -4179,8 +4184,8 @@ fn acp_unsupported_invocation_has_hint_782() {
|
||||
|
||||
let output = run_claw(&root, &["--output-format", "json", "acp", "start"], &[]);
|
||||
assert!(!output.status.success(), "acp start should fail");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
@@ -4217,7 +4222,7 @@ fn init_json_envelope_has_hint_and_already_initialized_783() {
|
||||
// Fresh init — already_initialized should be false, hint should mention CLAUDE.md
|
||||
let output = run_claw(&root, &["--output-format", "json", "init"], &[]);
|
||||
assert!(output.status.success(), "init should succeed");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let raw = if stdout.trim_start().starts_with('{') {
|
||||
@@ -4286,7 +4291,7 @@ fn init_json_envelope_has_hint_and_already_initialized_783() {
|
||||
// Idempotent re-init — already_initialized should be true
|
||||
let output2 = run_claw(&root, &["--output-format", "json", "init"], &[]);
|
||||
assert!(output2.status.success(), "re-init should succeed");
|
||||
let stdout2 = String::from_utf8_lossy(&output2.stdout);
|
||||
let _stdout2 = String::from_utf8_lossy(&output2.stdout);
|
||||
let stderr2 = String::from_utf8_lossy(&output2.stderr);
|
||||
let stdout2 = String::from_utf8_lossy(&output2.stdout);
|
||||
let raw2 = if stdout2.trim_start().starts_with('{') {
|
||||
@@ -4367,7 +4372,7 @@ fn export_arg_errors_have_typed_kind_and_hint_784() {
|
||||
&[],
|
||||
);
|
||||
assert!(!out1.status.success(), "--output with no value should fail");
|
||||
let stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let _stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let stdout1 = String::from_utf8_lossy(&out1.stdout);
|
||||
let j1: serde_json::Value = stdout1
|
||||
.lines()
|
||||
@@ -4394,7 +4399,7 @@ fn export_arg_errors_have_typed_kind_and_hint_784() {
|
||||
&[],
|
||||
);
|
||||
assert!(!out2.status.success(), "extra positional should fail");
|
||||
let stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let _stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let stdout2 = String::from_utf8_lossy(&out2.stdout);
|
||||
let j2: serde_json::Value = stdout2
|
||||
.lines()
|
||||
@@ -4431,7 +4436,7 @@ fn unknown_subcommand_returns_typed_kind_785() {
|
||||
// "dump" is close enough to "dump-manifests" to trigger the typo suggestion path
|
||||
let output = run_claw(&root, &["--output-format", "json", "dump"], &[]);
|
||||
assert!(!output.status.success(), "unknown subcommand should fail");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
@@ -4479,7 +4484,7 @@ fn dump_manifests_missing_dir_has_typed_kind_and_hint_786() {
|
||||
&[],
|
||||
);
|
||||
assert!(!out1.status.success());
|
||||
let stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let _stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let stdout1 = String::from_utf8_lossy(&out1.stdout);
|
||||
let j1: serde_json::Value = stdout1
|
||||
.lines()
|
||||
@@ -4511,7 +4516,7 @@ fn dump_manifests_missing_dir_has_typed_kind_and_hint_786() {
|
||||
&[],
|
||||
);
|
||||
assert!(!out2.status.success());
|
||||
let stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let _stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let stdout2 = String::from_utf8_lossy(&out2.stdout);
|
||||
let j2: serde_json::Value = stdout2
|
||||
.lines()
|
||||
@@ -4561,7 +4566,7 @@ fn resume_directory_path_returns_typed_kind_and_hint_787() {
|
||||
!output.status.success(),
|
||||
"resume with directory should fail"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
@@ -4611,7 +4616,7 @@ fn skills_show_not_found_emits_single_json_object_788() {
|
||||
assert!(!output.status.success(), "skills show unknown should fail");
|
||||
// Skills handler emits JSON to stdout; the duplicate was on stderr from the main error path.
|
||||
// After fix: stdout has 1 JSON object, stderr has none (no duplicate).
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
@@ -4773,7 +4778,7 @@ fn system_prompt_unknown_option_returns_typed_kind_790() {
|
||||
&[],
|
||||
);
|
||||
assert!(!out1.status.success());
|
||||
let stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let _stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let stdout1 = String::from_utf8_lossy(&out1.stdout);
|
||||
let j1: serde_json::Value = stdout1
|
||||
.lines()
|
||||
@@ -4800,7 +4805,7 @@ fn system_prompt_unknown_option_returns_typed_kind_790() {
|
||||
&[],
|
||||
);
|
||||
assert!(!out2.status.success());
|
||||
let stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let _stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let stdout2 = String::from_utf8_lossy(&out2.stdout);
|
||||
let j2: serde_json::Value = stdout2
|
||||
.lines()
|
||||
@@ -4838,7 +4843,7 @@ fn config_extra_args_have_non_null_hint_791() {
|
||||
&[],
|
||||
);
|
||||
assert!(!out1.status.success());
|
||||
let stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let _stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let stdout1 = String::from_utf8_lossy(&out1.stdout);
|
||||
let j1: serde_json::Value = stdout1
|
||||
.lines()
|
||||
@@ -4872,7 +4877,7 @@ fn config_extra_args_have_non_null_hint_791() {
|
||||
&[],
|
||||
);
|
||||
assert!(!out2.status.success());
|
||||
let stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let _stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let stdout2 = String::from_utf8_lossy(&out2.stdout);
|
||||
let j2: serde_json::Value = stdout2
|
||||
.lines()
|
||||
@@ -5058,7 +5063,7 @@ fn plugins_uninstall_not_found_has_hint_793() {
|
||||
"plugins uninstall not-found must exit non-zero (#793)"
|
||||
);
|
||||
// Error envelope goes to stderr (propagated via ? to main error handler)
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
@@ -5103,7 +5108,7 @@ fn plugins_install_not_found_path_returns_typed_kind_794() {
|
||||
!output.status.success(),
|
||||
"plugins install not-found-path must exit non-zero (#794)"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
@@ -5367,7 +5372,7 @@ fn agents_create_scaffolds_toml_and_lists_locally_431() {
|
||||
.iter()
|
||||
.any(|agent| {
|
||||
agent["name"] == "my-agent"
|
||||
&& PathBuf::from(agent["path"].as_str().expect("listed agent path"))
|
||||
&& Path::new(agent["path"].as_str().expect("listed agent path"))
|
||||
== fs::canonicalize(&agent_path).expect("canonical listed agent path")
|
||||
}));
|
||||
}
|
||||
@@ -5492,7 +5497,7 @@ fn plugins_extra_args_have_non_null_hint_797() {
|
||||
!output.status.success(),
|
||||
"plugins show with extra arg must exit non-zero (#797)"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
@@ -5558,7 +5563,7 @@ fn plugins_list_trailing_dash_text_error_stays_on_stderr_817() {
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stderr.contains("[error-kind: cli_parse]"), "{stderr}");
|
||||
assert!(
|
||||
stderr.contains("unknown option for `claw plugins list`: --"),
|
||||
@@ -5583,7 +5588,7 @@ fn empty_prompt_has_non_null_hint_798() {
|
||||
!output.status.success(),
|
||||
"empty prompt must exit non-zero (#798)"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
|
||||
@@ -506,13 +506,17 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
||||
},
|
||||
ToolSpec {
|
||||
name: "read_file",
|
||||
description: "Read a text file from the workspace.",
|
||||
description: "Read a text file from the workspace. Large files come back one page at \
|
||||
a time: if the response has \"truncated\": true you have NOT seen the \
|
||||
whole file, and \"nextOffset\" is the line to pass as `offset` to \
|
||||
continue. Prefer grep_search to locate what you need over paging \
|
||||
through a big file, and never re-read a page you already have.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string" },
|
||||
"offset": { "type": "integer", "minimum": 0 },
|
||||
"limit": { "type": "integer", "minimum": 1 }
|
||||
"offset": { "type": "integer", "minimum": 0, "description": "First line to read (0-based). Pass the previous response's nextOffset to continue." },
|
||||
"limit": { "type": "integer", "minimum": 1, "description": "Maximum lines to return. Capped by a byte ceiling regardless." }
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
@@ -10401,6 +10405,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn repl_executes_python_code() {
|
||||
// Resolving the python runtime reads PATH, and PATH is process-global: the PowerShell
|
||||
// tests below blank it while they run. Without this guard that races and this test
|
||||
// intermittently fails with "python runtime not found" on a machine that has python.
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let result = execute_tool(
|
||||
"REPL",
|
||||
&json!({"language": "python", "code": "print(1 + 1)", "timeout_ms": 500}),
|
||||
|
||||
Reference in New Issue
Block a user