mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-06 16:14:49 +08:00
Compare commits
8 Commits
fix/p2-19-
...
fix/p011-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c65126a339 | ||
|
|
31163be347 | ||
|
|
eb4d3b11ee | ||
|
|
9bd7a78ca8 | ||
|
|
24d8f916c8 | ||
|
|
30883bddbd | ||
|
|
1a2fa1581e | ||
|
|
fa72cd665e |
BIN
assets/sigrid-photo.png
Normal file
BIN
assets/sigrid-photo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
2
rust/.claw/sessions/session-1775386832313-0.jsonl
Normal file
2
rust/.claw/sessions/session-1775386832313-0.jsonl
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{"created_at_ms":1775386832313,"session_id":"session-1775386832313-0","type":"session_meta","updated_at_ms":1775386832313,"version":1}
|
||||||
|
{"message":{"blocks":[{"text":"status --help","type":"text"}],"role":"user"},"type":"message"}
|
||||||
2
rust/.claw/sessions/session-1775386842352-0.jsonl
Normal file
2
rust/.claw/sessions/session-1775386842352-0.jsonl
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{"created_at_ms":1775386842352,"session_id":"session-1775386842352-0","type":"session_meta","updated_at_ms":1775386842352,"version":1}
|
||||||
|
{"message":{"blocks":[{"text":"doctor --help","type":"text"}],"role":"user"},"type":"message"}
|
||||||
2
rust/.claw/sessions/session-1775386852257-0.jsonl
Normal file
2
rust/.claw/sessions/session-1775386852257-0.jsonl
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{"created_at_ms":1775386852257,"session_id":"session-1775386852257-0","type":"session_meta","updated_at_ms":1775386852257,"version":1}
|
||||||
|
{"message":{"blocks":[{"text":"doctor --help","type":"text"}],"role":"user"},"type":"message"}
|
||||||
2
rust/.claw/sessions/session-1775386853666-0.jsonl
Normal file
2
rust/.claw/sessions/session-1775386853666-0.jsonl
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{"created_at_ms":1775386853666,"session_id":"session-1775386853666-0","type":"session_meta","updated_at_ms":1775386853666,"version":1}
|
||||||
|
{"message":{"blocks":[{"text":"status --help","type":"text"}],"role":"user"},"type":"message"}
|
||||||
@@ -8,6 +8,13 @@ pub enum ApiError {
|
|||||||
provider: &'static str,
|
provider: &'static str,
|
||||||
env_vars: &'static [&'static str],
|
env_vars: &'static [&'static str],
|
||||||
},
|
},
|
||||||
|
ContextWindowExceeded {
|
||||||
|
model: String,
|
||||||
|
estimated_input_tokens: u32,
|
||||||
|
requested_output_tokens: u32,
|
||||||
|
estimated_total_tokens: u32,
|
||||||
|
context_window_tokens: u32,
|
||||||
|
},
|
||||||
ExpiredOAuthToken,
|
ExpiredOAuthToken,
|
||||||
Auth(String),
|
Auth(String),
|
||||||
InvalidApiKeyEnv(VarError),
|
InvalidApiKeyEnv(VarError),
|
||||||
@@ -48,6 +55,7 @@ impl ApiError {
|
|||||||
Self::Api { retryable, .. } => *retryable,
|
Self::Api { retryable, .. } => *retryable,
|
||||||
Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
|
Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
|
||||||
Self::MissingCredentials { .. }
|
Self::MissingCredentials { .. }
|
||||||
|
| Self::ContextWindowExceeded { .. }
|
||||||
| Self::ExpiredOAuthToken
|
| Self::ExpiredOAuthToken
|
||||||
| Self::Auth(_)
|
| Self::Auth(_)
|
||||||
| Self::InvalidApiKeyEnv(_)
|
| Self::InvalidApiKeyEnv(_)
|
||||||
@@ -67,6 +75,16 @@ impl Display for ApiError {
|
|||||||
"missing {provider} credentials; export {} before calling the {provider} API",
|
"missing {provider} credentials; export {} before calling the {provider} API",
|
||||||
env_vars.join(" or ")
|
env_vars.join(" or ")
|
||||||
),
|
),
|
||||||
|
Self::ContextWindowExceeded {
|
||||||
|
model,
|
||||||
|
estimated_input_tokens,
|
||||||
|
requested_output_tokens,
|
||||||
|
estimated_total_tokens,
|
||||||
|
context_window_tokens,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"context_window_blocked for {model}: estimated input {estimated_input_tokens} + requested output {requested_output_tokens} = {estimated_total_tokens} tokens exceeds the {context_window_tokens}-token context window; compact the session or reduce request size before retrying"
|
||||||
|
),
|
||||||
Self::ExpiredOAuthToken => {
|
Self::ExpiredOAuthToken => {
|
||||||
write!(
|
write!(
|
||||||
f,
|
f,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, Session
|
|||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
|
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
|
||||||
|
|
||||||
use super::{Provider, ProviderFuture};
|
use super::{preflight_message_request, Provider, ProviderFuture};
|
||||||
use crate::sse::SseParser;
|
use crate::sse::SseParser;
|
||||||
use crate::types::{MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage};
|
use crate::types::{MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage};
|
||||||
|
|
||||||
@@ -294,6 +294,8 @@ impl AnthropicClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
preflight_message_request(&request)?;
|
||||||
|
|
||||||
let response = self.send_with_retry(&request).await?;
|
let response = self.send_with_retry(&request).await?;
|
||||||
let request_id = request_id_from_headers(response.headers());
|
let request_id = request_id_from_headers(response.headers());
|
||||||
let mut response = response
|
let mut response = response
|
||||||
@@ -337,6 +339,7 @@ impl AnthropicClient {
|
|||||||
&self,
|
&self,
|
||||||
request: &MessageRequest,
|
request: &MessageRequest,
|
||||||
) -> Result<MessageStream, ApiError> {
|
) -> Result<MessageStream, ApiError> {
|
||||||
|
preflight_message_request(request)?;
|
||||||
let response = self
|
let response = self
|
||||||
.send_with_retry(&request.clone().with_streaming())
|
.send_with_retry(&request.clone().with_streaming())
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use crate::types::{MessageRequest, MessageResponse};
|
use crate::types::{MessageRequest, MessageResponse};
|
||||||
|
|
||||||
@@ -40,6 +42,12 @@ pub struct ProviderMetadata {
|
|||||||
pub default_base_url: &'static str,
|
pub default_base_url: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ModelTokenLimit {
|
||||||
|
pub max_output_tokens: u32,
|
||||||
|
pub context_window_tokens: u32,
|
||||||
|
}
|
||||||
|
|
||||||
const MODEL_REGISTRY: &[(&str, ProviderMetadata)] = &[
|
const MODEL_REGISTRY: &[(&str, ProviderMetadata)] = &[
|
||||||
(
|
(
|
||||||
"opus",
|
"opus",
|
||||||
@@ -182,17 +190,86 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
|
|||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn max_tokens_for_model(model: &str) -> u32 {
|
pub fn max_tokens_for_model(model: &str) -> u32 {
|
||||||
|
model_token_limit(model).map_or_else(
|
||||||
|
|| {
|
||||||
|
let canonical = resolve_model_alias(model);
|
||||||
|
if canonical.contains("opus") {
|
||||||
|
32_000
|
||||||
|
} else {
|
||||||
|
64_000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|limit| limit.max_output_tokens,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
||||||
let canonical = resolve_model_alias(model);
|
let canonical = resolve_model_alias(model);
|
||||||
if canonical.contains("opus") {
|
match canonical.as_str() {
|
||||||
32_000
|
"claude-opus-4-6" => Some(ModelTokenLimit {
|
||||||
} else {
|
max_output_tokens: 32_000,
|
||||||
64_000
|
context_window_tokens: 200_000,
|
||||||
|
}),
|
||||||
|
"claude-sonnet-4-6" | "claude-haiku-4-5-20251213" => Some(ModelTokenLimit {
|
||||||
|
max_output_tokens: 64_000,
|
||||||
|
context_window_tokens: 200_000,
|
||||||
|
}),
|
||||||
|
"grok-3" | "grok-3-mini" => Some(ModelTokenLimit {
|
||||||
|
max_output_tokens: 64_000,
|
||||||
|
context_window_tokens: 131_072,
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn preflight_message_request(request: &MessageRequest) -> Result<(), ApiError> {
|
||||||
|
let Some(limit) = model_token_limit(&request.model) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let estimated_input_tokens = estimate_message_request_input_tokens(request);
|
||||||
|
let estimated_total_tokens = estimated_input_tokens.saturating_add(request.max_tokens);
|
||||||
|
if estimated_total_tokens > limit.context_window_tokens {
|
||||||
|
return Err(ApiError::ContextWindowExceeded {
|
||||||
|
model: resolve_model_alias(&request.model),
|
||||||
|
estimated_input_tokens,
|
||||||
|
requested_output_tokens: request.max_tokens,
|
||||||
|
estimated_total_tokens,
|
||||||
|
context_window_tokens: limit.context_window_tokens,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
estimate = estimate.saturating_add(estimate_serialized_tokens(&request.tool_choice));
|
||||||
|
estimate
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimate_serialized_tokens<T: Serialize>(value: &T) -> u32 {
|
||||||
|
serde_json::to_vec(value)
|
||||||
|
.ok()
|
||||||
|
.map_or(0, |bytes| (bytes.len() / 4 + 1) as u32)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{detect_provider_kind, max_tokens_for_model, resolve_model_alias, ProviderKind};
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::types::{
|
||||||
|
InputContentBlock, InputMessage, MessageRequest, ToolChoice, ToolDefinition,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
detect_provider_kind, max_tokens_for_model, model_token_limit, preflight_message_request,
|
||||||
|
resolve_model_alias, ProviderKind,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_grok_aliases() {
|
fn resolves_grok_aliases() {
|
||||||
@@ -215,4 +292,86 @@ mod tests {
|
|||||||
assert_eq!(max_tokens_for_model("opus"), 32_000);
|
assert_eq!(max_tokens_for_model("opus"), 32_000);
|
||||||
assert_eq!(max_tokens_for_model("grok-3"), 64_000);
|
assert_eq!(max_tokens_for_model("grok-3"), 64_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_context_window_metadata_for_supported_models() {
|
||||||
|
assert_eq!(
|
||||||
|
model_token_limit("claude-sonnet-4-6")
|
||||||
|
.expect("claude-sonnet-4-6 should be registered")
|
||||||
|
.context_window_tokens,
|
||||||
|
200_000
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
model_token_limit("grok-mini")
|
||||||
|
.expect("grok-mini should resolve to a registered model")
|
||||||
|
.context_window_tokens,
|
||||||
|
131_072
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preflight_blocks_requests_that_exceed_the_model_context_window() {
|
||||||
|
let request = MessageRequest {
|
||||||
|
model: "claude-sonnet-4-6".to_string(),
|
||||||
|
max_tokens: 64_000,
|
||||||
|
messages: vec![InputMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: vec![InputContentBlock::Text {
|
||||||
|
text: "x".repeat(600_000),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
system: Some("Keep the answer short.".to_string()),
|
||||||
|
tools: Some(vec![ToolDefinition {
|
||||||
|
name: "weather".to_string(),
|
||||||
|
description: Some("Fetches weather".to_string()),
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": { "city": { "type": "string" } },
|
||||||
|
}),
|
||||||
|
}]),
|
||||||
|
tool_choice: Some(ToolChoice::Auto),
|
||||||
|
stream: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = preflight_message_request(&request)
|
||||||
|
.expect_err("oversized request should be rejected before the provider call");
|
||||||
|
|
||||||
|
match error {
|
||||||
|
ApiError::ContextWindowExceeded {
|
||||||
|
model,
|
||||||
|
estimated_input_tokens,
|
||||||
|
requested_output_tokens,
|
||||||
|
estimated_total_tokens,
|
||||||
|
context_window_tokens,
|
||||||
|
} => {
|
||||||
|
assert_eq!(model, "claude-sonnet-4-6");
|
||||||
|
assert!(estimated_input_tokens > 136_000);
|
||||||
|
assert_eq!(requested_output_tokens, 64_000);
|
||||||
|
assert!(estimated_total_tokens > context_window_tokens);
|
||||||
|
assert_eq!(context_window_tokens, 200_000);
|
||||||
|
}
|
||||||
|
other => panic!("expected context-window preflight failure, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preflight_skips_unknown_models() {
|
||||||
|
let request = MessageRequest {
|
||||||
|
model: "unknown-model".to_string(),
|
||||||
|
max_tokens: 64_000,
|
||||||
|
messages: vec![InputMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: vec![InputContentBlock::Text {
|
||||||
|
text: "x".repeat(600_000),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
system: None,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
preflight_message_request(&request)
|
||||||
|
.expect("models without context metadata should skip the guarded preflight");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use crate::types::{
|
|||||||
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
|
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{Provider, ProviderFuture};
|
use super::{preflight_message_request, Provider, ProviderFuture};
|
||||||
|
|
||||||
pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
|
pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
|
||||||
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||||||
@@ -128,6 +128,7 @@ impl OpenAiCompatClient {
|
|||||||
stream: false,
|
stream: false,
|
||||||
..request.clone()
|
..request.clone()
|
||||||
};
|
};
|
||||||
|
preflight_message_request(&request)?;
|
||||||
let response = self.send_with_retry(&request).await?;
|
let response = self.send_with_retry(&request).await?;
|
||||||
let request_id = request_id_from_headers(response.headers());
|
let request_id = request_id_from_headers(response.headers());
|
||||||
let payload = response.json::<ChatCompletionResponse>().await?;
|
let payload = response.json::<ChatCompletionResponse>().await?;
|
||||||
@@ -142,6 +143,7 @@ impl OpenAiCompatClient {
|
|||||||
&self,
|
&self,
|
||||||
request: &MessageRequest,
|
request: &MessageRequest,
|
||||||
) -> Result<MessageStream, ApiError> {
|
) -> Result<MessageStream, ApiError> {
|
||||||
|
preflight_message_request(request)?;
|
||||||
let response = self
|
let response = self
|
||||||
.send_with_retry(&request.clone().with_streaming())
|
.send_with_retry(&request.clone().with_streaming())
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -103,6 +103,41 @@ async fn send_message_posts_json_and_parses_response() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_message_blocks_oversized_requests_before_the_http_call() {
|
||||||
|
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||||
|
let server = spawn_server(
|
||||||
|
state.clone(),
|
||||||
|
vec![http_response("200 OK", "application/json", "{}")],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
|
||||||
|
let error = client
|
||||||
|
.send_message(&MessageRequest {
|
||||||
|
model: "claude-sonnet-4-6".to_string(),
|
||||||
|
max_tokens: 64_000,
|
||||||
|
messages: vec![InputMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: vec![InputContentBlock::Text {
|
||||||
|
text: "x".repeat(600_000),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
system: Some("Keep the answer short.".to_string()),
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
stream: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("oversized request should fail local context-window preflight");
|
||||||
|
|
||||||
|
assert!(matches!(error, ApiError::ContextWindowExceeded { .. }));
|
||||||
|
assert!(
|
||||||
|
state.lock().await.is_empty(),
|
||||||
|
"preflight failure should avoid any upstream HTTP request"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn send_message_applies_request_profile_and_records_telemetry() {
|
async fn send_message_applies_request_profile_and_records_telemetry() {
|
||||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ use std::sync::Arc;
|
|||||||
use std::sync::{Mutex as StdMutex, OnceLock};
|
use std::sync::{Mutex as StdMutex, OnceLock};
|
||||||
|
|
||||||
use api::{
|
use api::{
|
||||||
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
|
ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
|
||||||
InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OpenAiCompatClient,
|
ContentBlockStopEvent, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest,
|
||||||
OpenAiCompatConfig, OutputContentBlock, ProviderClient, StreamEvent, ToolChoice,
|
OpenAiCompatClient, OpenAiCompatConfig, OutputContentBlock, ProviderClient, StreamEvent,
|
||||||
ToolDefinition,
|
ToolChoice, ToolDefinition,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
@@ -63,6 +63,42 @@ async fn send_message_uses_openai_compatible_endpoint_and_auth() {
|
|||||||
assert_eq!(body["tools"][0]["type"], json!("function"));
|
assert_eq!(body["tools"][0]["type"], json!("function"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_message_blocks_oversized_xai_requests_before_the_http_call() {
|
||||||
|
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||||
|
let server = spawn_server(
|
||||||
|
state.clone(),
|
||||||
|
vec![http_response("200 OK", "application/json", "{}")],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
|
||||||
|
.with_base_url(server.base_url());
|
||||||
|
let error = client
|
||||||
|
.send_message(&MessageRequest {
|
||||||
|
model: "grok-3".to_string(),
|
||||||
|
max_tokens: 64_000,
|
||||||
|
messages: vec![InputMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: vec![InputContentBlock::Text {
|
||||||
|
text: "x".repeat(300_000),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
system: Some("Keep the answer short.".to_string()),
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
stream: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("oversized request should fail local context-window preflight");
|
||||||
|
|
||||||
|
assert!(matches!(error, ApiError::ContextWindowExceeded { .. }));
|
||||||
|
assert!(
|
||||||
|
state.lock().await.is_empty(),
|
||||||
|
"preflight failure should avoid any upstream HTTP request"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn send_message_accepts_full_chat_completions_endpoint_override() {
|
async fn send_message_accepts_full_chat_completions_endpoint_override() {
|
||||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||||
|
|||||||
@@ -1954,25 +1954,49 @@ pub struct PluginsCommandResult {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
enum DefinitionSource {
|
enum DefinitionSource {
|
||||||
|
ProjectClaw,
|
||||||
ProjectCodex,
|
ProjectCodex,
|
||||||
ProjectClaude,
|
ProjectClaude,
|
||||||
|
UserClawConfigHome,
|
||||||
UserCodexHome,
|
UserCodexHome,
|
||||||
|
UserClaw,
|
||||||
UserCodex,
|
UserCodex,
|
||||||
UserClaude,
|
UserClaude,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DefinitionSource {
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
enum DefinitionScope {
|
||||||
|
Project,
|
||||||
|
UserConfigHome,
|
||||||
|
UserHome,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DefinitionScope {
|
||||||
fn label(self) -> &'static str {
|
fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::ProjectCodex => "Project (.codex)",
|
Self::Project => "Project (.claw)",
|
||||||
Self::ProjectClaude => "Project (.claude)",
|
Self::UserConfigHome => "User ($CLAW_CONFIG_HOME)",
|
||||||
Self::UserCodexHome => "User ($CODEX_HOME)",
|
Self::UserHome => "User (~/.claw)",
|
||||||
Self::UserCodex => "User (~/.codex)",
|
|
||||||
Self::UserClaude => "User (~/.claude)",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DefinitionSource {
|
||||||
|
fn report_scope(self) -> DefinitionScope {
|
||||||
|
match self {
|
||||||
|
Self::ProjectClaw | Self::ProjectCodex | Self::ProjectClaude => {
|
||||||
|
DefinitionScope::Project
|
||||||
|
}
|
||||||
|
Self::UserClawConfigHome | Self::UserCodexHome => DefinitionScope::UserConfigHome,
|
||||||
|
Self::UserClaw | Self::UserCodex | Self::UserClaude => DefinitionScope::UserHome,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
self.report_scope().label()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct AgentSummary {
|
struct AgentSummary {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -2302,6 +2326,11 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P
|
|||||||
let mut roots = Vec::new();
|
let mut roots = Vec::new();
|
||||||
|
|
||||||
for ancestor in cwd.ancestors() {
|
for ancestor in cwd.ancestors() {
|
||||||
|
push_unique_root(
|
||||||
|
&mut roots,
|
||||||
|
DefinitionSource::ProjectClaw,
|
||||||
|
ancestor.join(".claw").join(leaf),
|
||||||
|
);
|
||||||
push_unique_root(
|
push_unique_root(
|
||||||
&mut roots,
|
&mut roots,
|
||||||
DefinitionSource::ProjectCodex,
|
DefinitionSource::ProjectCodex,
|
||||||
@@ -2314,6 +2343,14 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Ok(claw_config_home) = env::var("CLAW_CONFIG_HOME") {
|
||||||
|
push_unique_root(
|
||||||
|
&mut roots,
|
||||||
|
DefinitionSource::UserClawConfigHome,
|
||||||
|
PathBuf::from(claw_config_home).join(leaf),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if let Ok(codex_home) = env::var("CODEX_HOME") {
|
if let Ok(codex_home) = env::var("CODEX_HOME") {
|
||||||
push_unique_root(
|
push_unique_root(
|
||||||
&mut roots,
|
&mut roots,
|
||||||
@@ -2324,6 +2361,11 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P
|
|||||||
|
|
||||||
if let Some(home) = env::var_os("HOME") {
|
if let Some(home) = env::var_os("HOME") {
|
||||||
let home = PathBuf::from(home);
|
let home = PathBuf::from(home);
|
||||||
|
push_unique_root(
|
||||||
|
&mut roots,
|
||||||
|
DefinitionSource::UserClaw,
|
||||||
|
home.join(".claw").join(leaf),
|
||||||
|
);
|
||||||
push_unique_root(
|
push_unique_root(
|
||||||
&mut roots,
|
&mut roots,
|
||||||
DefinitionSource::UserCodex,
|
DefinitionSource::UserCodex,
|
||||||
@@ -2343,6 +2385,12 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
|
|||||||
let mut roots = Vec::new();
|
let mut roots = Vec::new();
|
||||||
|
|
||||||
for ancestor in cwd.ancestors() {
|
for ancestor in cwd.ancestors() {
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
DefinitionSource::ProjectClaw,
|
||||||
|
ancestor.join(".claw").join("skills"),
|
||||||
|
SkillOrigin::SkillsDir,
|
||||||
|
);
|
||||||
push_unique_skill_root(
|
push_unique_skill_root(
|
||||||
&mut roots,
|
&mut roots,
|
||||||
DefinitionSource::ProjectCodex,
|
DefinitionSource::ProjectCodex,
|
||||||
@@ -2355,6 +2403,12 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
|
|||||||
ancestor.join(".claude").join("skills"),
|
ancestor.join(".claude").join("skills"),
|
||||||
SkillOrigin::SkillsDir,
|
SkillOrigin::SkillsDir,
|
||||||
);
|
);
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
DefinitionSource::ProjectClaw,
|
||||||
|
ancestor.join(".claw").join("commands"),
|
||||||
|
SkillOrigin::LegacyCommandsDir,
|
||||||
|
);
|
||||||
push_unique_skill_root(
|
push_unique_skill_root(
|
||||||
&mut roots,
|
&mut roots,
|
||||||
DefinitionSource::ProjectCodex,
|
DefinitionSource::ProjectCodex,
|
||||||
@@ -2369,6 +2423,22 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Ok(claw_config_home) = env::var("CLAW_CONFIG_HOME") {
|
||||||
|
let claw_config_home = PathBuf::from(claw_config_home);
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
DefinitionSource::UserClawConfigHome,
|
||||||
|
claw_config_home.join("skills"),
|
||||||
|
SkillOrigin::SkillsDir,
|
||||||
|
);
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
DefinitionSource::UserClawConfigHome,
|
||||||
|
claw_config_home.join("commands"),
|
||||||
|
SkillOrigin::LegacyCommandsDir,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if let Ok(codex_home) = env::var("CODEX_HOME") {
|
if let Ok(codex_home) = env::var("CODEX_HOME") {
|
||||||
let codex_home = PathBuf::from(codex_home);
|
let codex_home = PathBuf::from(codex_home);
|
||||||
push_unique_skill_root(
|
push_unique_skill_root(
|
||||||
@@ -2387,6 +2457,18 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
|
|||||||
|
|
||||||
if let Some(home) = env::var_os("HOME") {
|
if let Some(home) = env::var_os("HOME") {
|
||||||
let home = PathBuf::from(home);
|
let home = PathBuf::from(home);
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
DefinitionSource::UserClaw,
|
||||||
|
home.join(".claw").join("skills"),
|
||||||
|
SkillOrigin::SkillsDir,
|
||||||
|
);
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
DefinitionSource::UserClaw,
|
||||||
|
home.join(".claw").join("commands"),
|
||||||
|
SkillOrigin::LegacyCommandsDir,
|
||||||
|
);
|
||||||
push_unique_skill_root(
|
push_unique_skill_root(
|
||||||
&mut roots,
|
&mut roots,
|
||||||
DefinitionSource::UserCodex,
|
DefinitionSource::UserCodex,
|
||||||
@@ -2467,15 +2549,18 @@ fn install_skill_into(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn default_skill_install_root() -> std::io::Result<PathBuf> {
|
fn default_skill_install_root() -> std::io::Result<PathBuf> {
|
||||||
|
if let Ok(claw_config_home) = env::var("CLAW_CONFIG_HOME") {
|
||||||
|
return Ok(PathBuf::from(claw_config_home).join("skills"));
|
||||||
|
}
|
||||||
if let Ok(codex_home) = env::var("CODEX_HOME") {
|
if let Ok(codex_home) = env::var("CODEX_HOME") {
|
||||||
return Ok(PathBuf::from(codex_home).join("skills"));
|
return Ok(PathBuf::from(codex_home).join("skills"));
|
||||||
}
|
}
|
||||||
if let Some(home) = env::var_os("HOME") {
|
if let Some(home) = env::var_os("HOME") {
|
||||||
return Ok(PathBuf::from(home).join(".codex").join("skills"));
|
return Ok(PathBuf::from(home).join(".claw").join("skills"));
|
||||||
}
|
}
|
||||||
Err(std::io::Error::new(
|
Err(std::io::Error::new(
|
||||||
std::io::ErrorKind::NotFound,
|
std::io::ErrorKind::NotFound,
|
||||||
"unable to resolve a skills install root; set CODEX_HOME or HOME",
|
"unable to resolve a skills install root; set CLAW_CONFIG_HOME or HOME",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2841,22 +2926,20 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
|
|||||||
String::new(),
|
String::new(),
|
||||||
];
|
];
|
||||||
|
|
||||||
for source in [
|
for scope in [
|
||||||
DefinitionSource::ProjectCodex,
|
DefinitionScope::Project,
|
||||||
DefinitionSource::ProjectClaude,
|
DefinitionScope::UserConfigHome,
|
||||||
DefinitionSource::UserCodexHome,
|
DefinitionScope::UserHome,
|
||||||
DefinitionSource::UserCodex,
|
|
||||||
DefinitionSource::UserClaude,
|
|
||||||
] {
|
] {
|
||||||
let group = agents
|
let group = agents
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|agent| agent.source == source)
|
.filter(|agent| agent.source.report_scope() == scope)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
if group.is_empty() {
|
if group.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(format!("{}:", source.label()));
|
lines.push(format!("{}:", scope.label()));
|
||||||
for agent in group {
|
for agent in group {
|
||||||
let detail = agent_detail(agent);
|
let detail = agent_detail(agent);
|
||||||
match agent.shadowed_by {
|
match agent.shadowed_by {
|
||||||
@@ -2899,22 +2982,20 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
|
|||||||
String::new(),
|
String::new(),
|
||||||
];
|
];
|
||||||
|
|
||||||
for source in [
|
for scope in [
|
||||||
DefinitionSource::ProjectCodex,
|
DefinitionScope::Project,
|
||||||
DefinitionSource::ProjectClaude,
|
DefinitionScope::UserConfigHome,
|
||||||
DefinitionSource::UserCodexHome,
|
DefinitionScope::UserHome,
|
||||||
DefinitionSource::UserCodex,
|
|
||||||
DefinitionSource::UserClaude,
|
|
||||||
] {
|
] {
|
||||||
let group = skills
|
let group = skills
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|skill| skill.source == source)
|
.filter(|skill| skill.source.report_scope() == scope)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
if group.is_empty() {
|
if group.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(format!("{}:", source.label()));
|
lines.push(format!("{}:", scope.label()));
|
||||||
for skill in group {
|
for skill in group {
|
||||||
let mut parts = vec![skill.name.clone()];
|
let mut parts = vec![skill.name.clone()];
|
||||||
if let Some(description) = &skill.description {
|
if let Some(description) = &skill.description {
|
||||||
@@ -3080,7 +3161,7 @@ fn render_agents_usage(unexpected: Option<&str>) -> String {
|
|||||||
"Agents".to_string(),
|
"Agents".to_string(),
|
||||||
" Usage /agents [list|help]".to_string(),
|
" Usage /agents [list|help]".to_string(),
|
||||||
" Direct CLI claw agents".to_string(),
|
" Direct CLI claw agents".to_string(),
|
||||||
" Sources .codex/agents, .claude/agents, $CODEX_HOME/agents".to_string(),
|
" Sources .claw/agents, ~/.claw/agents, $CLAW_CONFIG_HOME/agents".to_string(),
|
||||||
];
|
];
|
||||||
if let Some(args) = unexpected {
|
if let Some(args) = unexpected {
|
||||||
lines.push(format!(" Unexpected {args}"));
|
lines.push(format!(" Unexpected {args}"));
|
||||||
@@ -3093,8 +3174,8 @@ fn render_skills_usage(unexpected: Option<&str>) -> String {
|
|||||||
"Skills".to_string(),
|
"Skills".to_string(),
|
||||||
" Usage /skills [list|install <path>|help]".to_string(),
|
" Usage /skills [list|install <path>|help]".to_string(),
|
||||||
" Direct CLI claw skills [list|install <path>|help]".to_string(),
|
" Direct CLI claw skills [list|install <path>|help]".to_string(),
|
||||||
" Install root $CODEX_HOME/skills or ~/.codex/skills".to_string(),
|
" Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills".to_string(),
|
||||||
" Sources .codex/skills, .claude/skills, legacy /commands".to_string(),
|
" Sources .claw/skills, ~/.claw/skills, legacy /commands".to_string(),
|
||||||
];
|
];
|
||||||
if let Some(args) = unexpected {
|
if let Some(args) = unexpected {
|
||||||
lines.push(format!(" Unexpected {args}"));
|
lines.push(format!(" Unexpected {args}"));
|
||||||
@@ -3933,7 +4014,7 @@ mod tests {
|
|||||||
let workspace = temp_dir("agents-workspace");
|
let workspace = temp_dir("agents-workspace");
|
||||||
let project_agents = workspace.join(".codex").join("agents");
|
let project_agents = workspace.join(".codex").join("agents");
|
||||||
let user_home = temp_dir("agents-home");
|
let user_home = temp_dir("agents-home");
|
||||||
let user_agents = user_home.join(".codex").join("agents");
|
let user_agents = user_home.join(".claude").join("agents");
|
||||||
|
|
||||||
write_agent(
|
write_agent(
|
||||||
&project_agents,
|
&project_agents,
|
||||||
@@ -3966,10 +4047,10 @@ mod tests {
|
|||||||
|
|
||||||
assert!(report.contains("Agents"));
|
assert!(report.contains("Agents"));
|
||||||
assert!(report.contains("2 active agents"));
|
assert!(report.contains("2 active agents"));
|
||||||
assert!(report.contains("Project (.codex):"));
|
assert!(report.contains("Project (.claw):"));
|
||||||
assert!(report.contains("planner · Project planner · gpt-5.4 · medium"));
|
assert!(report.contains("planner · Project planner · gpt-5.4 · medium"));
|
||||||
assert!(report.contains("User (~/.codex):"));
|
assert!(report.contains("User (~/.claw):"));
|
||||||
assert!(report.contains("(shadowed by Project (.codex)) planner · User planner"));
|
assert!(report.contains("(shadowed by Project (.claw)) planner · User planner"));
|
||||||
assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high"));
|
assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high"));
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(workspace);
|
let _ = fs::remove_dir_all(workspace);
|
||||||
@@ -4011,12 +4092,11 @@ mod tests {
|
|||||||
|
|
||||||
assert!(report.contains("Skills"));
|
assert!(report.contains("Skills"));
|
||||||
assert!(report.contains("3 available skills"));
|
assert!(report.contains("3 available skills"));
|
||||||
assert!(report.contains("Project (.codex):"));
|
assert!(report.contains("Project (.claw):"));
|
||||||
assert!(report.contains("plan · Project planning guidance"));
|
assert!(report.contains("plan · Project planning guidance"));
|
||||||
assert!(report.contains("Project (.claude):"));
|
|
||||||
assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands"));
|
assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands"));
|
||||||
assert!(report.contains("User (~/.codex):"));
|
assert!(report.contains("User (~/.claw):"));
|
||||||
assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance"));
|
assert!(report.contains("(shadowed by Project (.claw)) plan · User planning guidance"));
|
||||||
assert!(report.contains("help · Help guidance"));
|
assert!(report.contains("help · Help guidance"));
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(workspace);
|
let _ = fs::remove_dir_all(workspace);
|
||||||
@@ -4031,6 +4111,8 @@ mod tests {
|
|||||||
super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help");
|
super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help");
|
||||||
assert!(agents_help.contains("Usage /agents [list|help]"));
|
assert!(agents_help.contains("Usage /agents [list|help]"));
|
||||||
assert!(agents_help.contains("Direct CLI claw agents"));
|
assert!(agents_help.contains("Direct CLI claw agents"));
|
||||||
|
assert!(agents_help
|
||||||
|
.contains("Sources .claw/agents, ~/.claw/agents, $CLAW_CONFIG_HOME/agents"));
|
||||||
|
|
||||||
let agents_unexpected =
|
let agents_unexpected =
|
||||||
super::handle_agents_slash_command(Some("show planner"), &cwd).expect("agents usage");
|
super::handle_agents_slash_command(Some("show planner"), &cwd).expect("agents usage");
|
||||||
@@ -4039,7 +4121,7 @@ mod tests {
|
|||||||
let skills_help =
|
let skills_help =
|
||||||
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
|
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
|
||||||
assert!(skills_help.contains("Usage /skills [list|install <path>|help]"));
|
assert!(skills_help.contains("Usage /skills [list|install <path>|help]"));
|
||||||
assert!(skills_help.contains("Install root $CODEX_HOME/skills or ~/.codex/skills"));
|
assert!(skills_help.contains("Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills"));
|
||||||
assert!(skills_help.contains("legacy /commands"));
|
assert!(skills_help.contains("legacy /commands"));
|
||||||
|
|
||||||
let skills_unexpected =
|
let skills_unexpected =
|
||||||
@@ -4213,7 +4295,7 @@ mod tests {
|
|||||||
let listed = render_skills_report(
|
let listed = render_skills_report(
|
||||||
&load_skills_from_roots(&roots).expect("installed skills should load"),
|
&load_skills_from_roots(&roots).expect("installed skills should load"),
|
||||||
);
|
);
|
||||||
assert!(listed.contains("User ($CODEX_HOME):"));
|
assert!(listed.contains("User ($CLAW_CONFIG_HOME):"));
|
||||||
assert!(listed.contains("help · Helpful skill"));
|
assert!(listed.contains("help · Helpful skill"));
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(workspace);
|
let _ = fs::remove_dir_all(workspace);
|
||||||
|
|||||||
@@ -114,8 +114,12 @@ impl LaneEvent {
|
|||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn finished(emitted_at: impl Into<String>, detail: Option<String>) -> Self {
|
pub fn finished(emitted_at: impl Into<String>, detail: Option<String>) -> Self {
|
||||||
Self::new(LaneEventName::Finished, LaneEventStatus::Completed, emitted_at)
|
Self::new(
|
||||||
.with_optional_detail(detail)
|
LaneEventName::Finished,
|
||||||
|
LaneEventStatus::Completed,
|
||||||
|
emitted_at,
|
||||||
|
)
|
||||||
|
.with_optional_detail(detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -161,19 +165,14 @@ impl LaneEvent {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use super::{
|
use super::{LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass};
|
||||||
LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_lane_event_names_serialize_to_expected_wire_values() {
|
fn canonical_lane_event_names_serialize_to_expected_wire_values() {
|
||||||
let cases = [
|
let cases = [
|
||||||
(LaneEventName::Started, "lane.started"),
|
(LaneEventName::Started, "lane.started"),
|
||||||
(LaneEventName::Ready, "lane.ready"),
|
(LaneEventName::Ready, "lane.ready"),
|
||||||
(
|
(LaneEventName::PromptMisdelivery, "lane.prompt_misdelivery"),
|
||||||
LaneEventName::PromptMisdelivery,
|
|
||||||
"lane.prompt_misdelivery",
|
|
||||||
),
|
|
||||||
(LaneEventName::Blocked, "lane.blocked"),
|
(LaneEventName::Blocked, "lane.blocked"),
|
||||||
(LaneEventName::Red, "lane.red"),
|
(LaneEventName::Red, "lane.red"),
|
||||||
(LaneEventName::Green, "lane.green"),
|
(LaneEventName::Green, "lane.green"),
|
||||||
@@ -193,7 +192,10 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (event, expected) in cases {
|
for (event, expected) in cases {
|
||||||
assert_eq!(serde_json::to_value(event).expect("serialize event"), json!(expected));
|
assert_eq!(
|
||||||
|
serde_json::to_value(event).expect("serialize event"),
|
||||||
|
json!(expected)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -599,7 +599,10 @@ mod tests {
|
|||||||
));
|
));
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
McpPhaseResult::Failure { phase: failed_phase, error } => {
|
McpPhaseResult::Failure {
|
||||||
|
phase: failed_phase,
|
||||||
|
error,
|
||||||
|
} => {
|
||||||
assert_eq!(failed_phase, phase);
|
assert_eq!(failed_phase, phase);
|
||||||
assert_eq!(error.phase, phase);
|
assert_eq!(error.phase, phase);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -360,8 +360,10 @@ impl McpServerManagerError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn recoverable(&self) -> bool {
|
fn recoverable(&self) -> bool {
|
||||||
!matches!(self.lifecycle_phase(), McpLifecyclePhase::InitializeHandshake)
|
!matches!(
|
||||||
&& matches!(self, Self::Transport { .. } | Self::Timeout { .. })
|
self.lifecycle_phase(),
|
||||||
|
McpLifecyclePhase::InitializeHandshake
|
||||||
|
) && matches!(self, Self::Transport { .. } | Self::Timeout { .. })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn discovery_failure(&self, server_name: &str) -> McpDiscoveryFailure {
|
fn discovery_failure(&self, server_name: &str) -> McpDiscoveryFailure {
|
||||||
@@ -417,10 +419,9 @@ impl McpServerManagerError {
|
|||||||
("method".to_string(), (*method).to_string()),
|
("method".to_string(), (*method).to_string()),
|
||||||
("timeout_ms".to_string(), timeout_ms.to_string()),
|
("timeout_ms".to_string(), timeout_ms.to_string()),
|
||||||
]),
|
]),
|
||||||
Self::UnknownTool { qualified_name } => BTreeMap::from([(
|
Self::UnknownTool { qualified_name } => {
|
||||||
"qualified_tool".to_string(),
|
BTreeMap::from([("qualified_tool".to_string(), qualified_name.clone())])
|
||||||
qualified_name.clone(),
|
}
|
||||||
)]),
|
|
||||||
Self::UnknownServer { server_name } => {
|
Self::UnknownServer { server_name } => {
|
||||||
BTreeMap::from([("server".to_string(), server_name.clone())])
|
BTreeMap::from([("server".to_string(), server_name.clone())])
|
||||||
}
|
}
|
||||||
@@ -1425,11 +1426,10 @@ mod tests {
|
|||||||
use crate::mcp_client::McpClientBootstrap;
|
use crate::mcp_client::McpClientBootstrap;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
spawn_mcp_stdio_process, JsonRpcId, JsonRpcRequest, JsonRpcResponse,
|
spawn_mcp_stdio_process, unsupported_server_failed_server, JsonRpcId, JsonRpcRequest,
|
||||||
McpInitializeClientInfo, McpInitializeParams, McpInitializeResult, McpInitializeServerInfo,
|
JsonRpcResponse, McpInitializeClientInfo, McpInitializeParams, McpInitializeResult,
|
||||||
McpListToolsResult, McpReadResourceParams, McpReadResourceResult, McpServerManager,
|
McpInitializeServerInfo, McpListToolsResult, McpReadResourceParams, McpReadResourceResult,
|
||||||
McpServerManagerError, McpStdioProcess, McpTool, McpToolCallParams,
|
McpServerManager, McpServerManagerError, McpStdioProcess, McpTool, McpToolCallParams,
|
||||||
unsupported_server_failed_server,
|
|
||||||
};
|
};
|
||||||
use crate::McpLifecyclePhase;
|
use crate::McpLifecyclePhase;
|
||||||
|
|
||||||
@@ -2698,7 +2698,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(!report.failed_servers[0].recoverable);
|
assert!(!report.failed_servers[0].recoverable);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
report.failed_servers[0].context.get("method").map(String::as_str),
|
report.failed_servers[0]
|
||||||
|
.context
|
||||||
|
.get("method")
|
||||||
|
.map(String::as_str),
|
||||||
Some("initialize")
|
Some("initialize")
|
||||||
);
|
);
|
||||||
assert!(report.failed_servers[0].error.contains("initialize"));
|
assert!(report.failed_servers[0].error.contains("initialize"));
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ use std::fs;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::UNIX_EPOCH;
|
use std::time::UNIX_EPOCH;
|
||||||
|
|
||||||
|
|
||||||
use crate::session::{Session, SessionError};
|
use crate::session::{Session, SessionError};
|
||||||
|
|
||||||
pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
|
pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
|
||||||
|
|||||||
@@ -66,11 +66,7 @@ pub fn validate_packet(packet: TaskPacket) -> Result<ValidatedPacket, TaskPacket
|
|||||||
&packet.reporting_contract,
|
&packet.reporting_contract,
|
||||||
&mut errors,
|
&mut errors,
|
||||||
);
|
);
|
||||||
validate_required(
|
validate_required("escalation_policy", &packet.escalation_policy, &mut errors);
|
||||||
"escalation_policy",
|
|
||||||
&packet.escalation_policy,
|
|
||||||
&mut errors,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (index, test) in packet.acceptance_tests.iter().enumerate() {
|
for (index, test) in packet.acceptance_tests.iter().enumerate() {
|
||||||
if test.trim().is_empty() {
|
if test.trim().is_empty() {
|
||||||
@@ -146,9 +142,9 @@ mod tests {
|
|||||||
assert!(error
|
assert!(error
|
||||||
.errors()
|
.errors()
|
||||||
.contains(&"repo must not be empty".to_string()));
|
.contains(&"repo must not be empty".to_string()));
|
||||||
assert!(error.errors().contains(
|
assert!(error
|
||||||
&"acceptance_tests contains an empty value at index 1".to_string()
|
.errors()
|
||||||
));
|
.contains(&"acceptance_tests contains an empty value at index 1".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -76,11 +76,7 @@ impl TaskRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn create(&self, prompt: &str, description: Option<&str>) -> Task {
|
pub fn create(&self, prompt: &str, description: Option<&str>) -> Task {
|
||||||
self.create_task(
|
self.create_task(prompt.to_owned(), description.map(str::to_owned), None)
|
||||||
prompt.to_owned(),
|
|
||||||
description.map(str::to_owned),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_from_packet(
|
pub fn create_from_packet(
|
||||||
|
|||||||
@@ -257,7 +257,9 @@ impl WorkerRegistry {
|
|||||||
let prompt_preview = prompt_preview(worker.last_prompt.as_deref().unwrap_or_default());
|
let prompt_preview = prompt_preview(worker.last_prompt.as_deref().unwrap_or_default());
|
||||||
let message = match observation.target {
|
let message = match observation.target {
|
||||||
WorkerPromptTarget::Shell => {
|
WorkerPromptTarget::Shell => {
|
||||||
format!("worker prompt landed in shell instead of coding agent: {prompt_preview}")
|
format!(
|
||||||
|
"worker prompt landed in shell instead of coding agent: {prompt_preview}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
WorkerPromptTarget::WrongTarget => format!(
|
WorkerPromptTarget::WrongTarget => format!(
|
||||||
"worker prompt landed in the wrong target instead of {}: {}",
|
"worker prompt landed in the wrong target instead of {}: {}",
|
||||||
@@ -312,7 +314,9 @@ impl WorkerRegistry {
|
|||||||
worker.last_error = None;
|
worker.last_error = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
if detect_ready_for_prompt(screen_text, &lowered) && worker.status != WorkerStatus::ReadyForPrompt {
|
if detect_ready_for_prompt(screen_text, &lowered)
|
||||||
|
&& worker.status != WorkerStatus::ReadyForPrompt
|
||||||
|
{
|
||||||
worker.status = WorkerStatus::ReadyForPrompt;
|
worker.status = WorkerStatus::ReadyForPrompt;
|
||||||
worker.prompt_in_flight = false;
|
worker.prompt_in_flight = false;
|
||||||
if matches!(
|
if matches!(
|
||||||
@@ -412,7 +416,10 @@ impl WorkerRegistry {
|
|||||||
worker_id: worker.worker_id.clone(),
|
worker_id: worker.worker_id.clone(),
|
||||||
status: worker.status,
|
status: worker.status,
|
||||||
ready: worker.status == WorkerStatus::ReadyForPrompt,
|
ready: worker.status == WorkerStatus::ReadyForPrompt,
|
||||||
blocked: matches!(worker.status, WorkerStatus::TrustRequired | WorkerStatus::Failed),
|
blocked: matches!(
|
||||||
|
worker.status,
|
||||||
|
WorkerStatus::TrustRequired | WorkerStatus::Failed
|
||||||
|
),
|
||||||
replay_prompt_ready: worker.replay_prompt.is_some(),
|
replay_prompt_ready: worker.replay_prompt.is_some(),
|
||||||
last_error: worker.last_error.clone(),
|
last_error: worker.last_error.clone(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
const STARTER_CLAUDE_JSON: &str = concat!(
|
const STARTER_CLAW_JSON: &str = concat!(
|
||||||
"{\n",
|
"{\n",
|
||||||
" \"permissions\": {\n",
|
" \"permissions\": {\n",
|
||||||
" \"defaultMode\": \"dontAsk\"\n",
|
" \"defaultMode\": \"dontAsk\"\n",
|
||||||
@@ -9,7 +9,7 @@ const STARTER_CLAUDE_JSON: &str = concat!(
|
|||||||
"}\n",
|
"}\n",
|
||||||
);
|
);
|
||||||
const GITIGNORE_COMMENT: &str = "# Claw Code local artifacts";
|
const GITIGNORE_COMMENT: &str = "# Claw Code local artifacts";
|
||||||
const GITIGNORE_ENTRIES: [&str; 2] = [".claude/settings.local.json", ".claude/sessions/"];
|
const GITIGNORE_ENTRIES: [&str; 2] = [".claw/settings.local.json", ".claw/sessions/"];
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum InitStatus {
|
pub(crate) enum InitStatus {
|
||||||
@@ -80,16 +80,16 @@ struct RepoDetection {
|
|||||||
pub(crate) fn initialize_repo(cwd: &Path) -> Result<InitReport, Box<dyn std::error::Error>> {
|
pub(crate) fn initialize_repo(cwd: &Path) -> Result<InitReport, Box<dyn std::error::Error>> {
|
||||||
let mut artifacts = Vec::new();
|
let mut artifacts = Vec::new();
|
||||||
|
|
||||||
let claude_dir = cwd.join(".claude");
|
let claw_dir = cwd.join(".claw");
|
||||||
artifacts.push(InitArtifact {
|
artifacts.push(InitArtifact {
|
||||||
name: ".claude/",
|
name: ".claw/",
|
||||||
status: ensure_dir(&claude_dir)?,
|
status: ensure_dir(&claw_dir)?,
|
||||||
});
|
});
|
||||||
|
|
||||||
let claude_json = cwd.join(".claude.json");
|
let claw_json = cwd.join(".claw.json");
|
||||||
artifacts.push(InitArtifact {
|
artifacts.push(InitArtifact {
|
||||||
name: ".claude.json",
|
name: ".claw.json",
|
||||||
status: write_file_if_missing(&claude_json, STARTER_CLAUDE_JSON)?,
|
status: write_file_if_missing(&claw_json, STARTER_CLAW_JSON)?,
|
||||||
});
|
});
|
||||||
|
|
||||||
let gitignore = cwd.join(".gitignore");
|
let gitignore = cwd.join(".gitignore");
|
||||||
@@ -209,7 +209,7 @@ pub(crate) fn render_init_claude_md(cwd: &Path) -> String {
|
|||||||
|
|
||||||
lines.push("## Working agreement".to_string());
|
lines.push("## Working agreement".to_string());
|
||||||
lines.push("- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.".to_string());
|
lines.push("- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.".to_string());
|
||||||
lines.push("- Keep shared defaults in `.claude.json`; reserve `.claude/settings.local.json` for machine-local overrides.".to_string());
|
lines.push("- Keep shared defaults in `.claw.json`; reserve `.claw/settings.local.json` for machine-local overrides.".to_string());
|
||||||
lines.push("- Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change.".to_string());
|
lines.push("- Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change.".to_string());
|
||||||
lines.push(String::new());
|
lines.push(String::new());
|
||||||
|
|
||||||
@@ -354,15 +354,16 @@ mod tests {
|
|||||||
|
|
||||||
let report = initialize_repo(&root).expect("init should succeed");
|
let report = initialize_repo(&root).expect("init should succeed");
|
||||||
let rendered = report.render();
|
let rendered = report.render();
|
||||||
assert!(rendered.contains(".claude/ created"));
|
assert!(rendered.contains(".claw/"));
|
||||||
assert!(rendered.contains(".claude.json created"));
|
assert!(rendered.contains(".claw.json"));
|
||||||
|
assert!(rendered.contains("created"));
|
||||||
assert!(rendered.contains(".gitignore created"));
|
assert!(rendered.contains(".gitignore created"));
|
||||||
assert!(rendered.contains("CLAUDE.md created"));
|
assert!(rendered.contains("CLAUDE.md created"));
|
||||||
assert!(root.join(".claude").is_dir());
|
assert!(root.join(".claw").is_dir());
|
||||||
assert!(root.join(".claude.json").is_file());
|
assert!(root.join(".claw.json").is_file());
|
||||||
assert!(root.join("CLAUDE.md").is_file());
|
assert!(root.join("CLAUDE.md").is_file());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fs::read_to_string(root.join(".claude.json")).expect("read claude json"),
|
fs::read_to_string(root.join(".claw.json")).expect("read claw json"),
|
||||||
concat!(
|
concat!(
|
||||||
"{\n",
|
"{\n",
|
||||||
" \"permissions\": {\n",
|
" \"permissions\": {\n",
|
||||||
@@ -372,8 +373,8 @@ mod tests {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
|
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
|
||||||
assert!(gitignore.contains(".claude/settings.local.json"));
|
assert!(gitignore.contains(".claw/settings.local.json"));
|
||||||
assert!(gitignore.contains(".claude/sessions/"));
|
assert!(gitignore.contains(".claw/sessions/"));
|
||||||
let claude_md = fs::read_to_string(root.join("CLAUDE.md")).expect("read claude md");
|
let claude_md = fs::read_to_string(root.join("CLAUDE.md")).expect("read claude md");
|
||||||
assert!(claude_md.contains("Languages: Rust."));
|
assert!(claude_md.contains("Languages: Rust."));
|
||||||
assert!(claude_md.contains("cargo clippy --workspace --all-targets -- -D warnings"));
|
assert!(claude_md.contains("cargo clippy --workspace --all-targets -- -D warnings"));
|
||||||
@@ -386,8 +387,7 @@ mod tests {
|
|||||||
let root = temp_dir();
|
let root = temp_dir();
|
||||||
fs::create_dir_all(&root).expect("create root");
|
fs::create_dir_all(&root).expect("create root");
|
||||||
fs::write(root.join("CLAUDE.md"), "custom guidance\n").expect("write existing claude md");
|
fs::write(root.join("CLAUDE.md"), "custom guidance\n").expect("write existing claude md");
|
||||||
fs::write(root.join(".gitignore"), ".claude/settings.local.json\n")
|
fs::write(root.join(".gitignore"), ".claw/settings.local.json\n").expect("write gitignore");
|
||||||
.expect("write gitignore");
|
|
||||||
|
|
||||||
let first = initialize_repo(&root).expect("first init should succeed");
|
let first = initialize_repo(&root).expect("first init should succeed");
|
||||||
assert!(first
|
assert!(first
|
||||||
@@ -395,8 +395,9 @@ mod tests {
|
|||||||
.contains("CLAUDE.md skipped (already exists)"));
|
.contains("CLAUDE.md skipped (already exists)"));
|
||||||
let second = initialize_repo(&root).expect("second init should succeed");
|
let second = initialize_repo(&root).expect("second init should succeed");
|
||||||
let second_rendered = second.render();
|
let second_rendered = second.render();
|
||||||
assert!(second_rendered.contains(".claude/ skipped (already exists)"));
|
assert!(second_rendered.contains(".claw/"));
|
||||||
assert!(second_rendered.contains(".claude.json skipped (already exists)"));
|
assert!(second_rendered.contains(".claw.json"));
|
||||||
|
assert!(second_rendered.contains("skipped (already exists)"));
|
||||||
assert!(second_rendered.contains(".gitignore skipped (already exists)"));
|
assert!(second_rendered.contains(".gitignore skipped (already exists)"));
|
||||||
assert!(second_rendered.contains("CLAUDE.md skipped (already exists)"));
|
assert!(second_rendered.contains("CLAUDE.md skipped (already exists)"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -404,8 +405,8 @@ mod tests {
|
|||||||
"custom guidance\n"
|
"custom guidance\n"
|
||||||
);
|
);
|
||||||
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
|
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
|
||||||
assert_eq!(gitignore.matches(".claude/settings.local.json").count(), 1);
|
assert_eq!(gitignore.matches(".claw/settings.local.json").count(), 1);
|
||||||
assert_eq!(gitignore.matches(".claude/sessions/").count(), 1);
|
assert_eq!(gitignore.matches(".claw/sessions/").count(), 1);
|
||||||
|
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ use runtime::{
|
|||||||
UsageTracker,
|
UsageTracker,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::{json, Value};
|
||||||
use tools::{GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput};
|
use tools::{GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput};
|
||||||
|
|
||||||
const DEFAULT_MODEL: &str = "claude-opus-4-6";
|
const DEFAULT_MODEL: &str = "claude-opus-4-6";
|
||||||
@@ -117,12 +117,14 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
CliAction::ResumeSession {
|
CliAction::ResumeSession {
|
||||||
session_path,
|
session_path,
|
||||||
commands,
|
commands,
|
||||||
} => resume_session(&session_path, &commands),
|
output_format,
|
||||||
|
} => resume_session(&session_path, &commands, output_format),
|
||||||
CliAction::Status {
|
CliAction::Status {
|
||||||
model,
|
model,
|
||||||
permission_mode,
|
permission_mode,
|
||||||
} => print_status_snapshot(&model, permission_mode)?,
|
output_format,
|
||||||
CliAction::Sandbox => print_sandbox_status_snapshot()?,
|
} => print_status_snapshot(&model, permission_mode, output_format)?,
|
||||||
|
CliAction::Sandbox { output_format } => print_sandbox_status_snapshot(output_format)?,
|
||||||
CliAction::Prompt {
|
CliAction::Prompt {
|
||||||
prompt,
|
prompt,
|
||||||
model,
|
model,
|
||||||
@@ -165,12 +167,16 @@ enum CliAction {
|
|||||||
ResumeSession {
|
ResumeSession {
|
||||||
session_path: PathBuf,
|
session_path: PathBuf,
|
||||||
commands: Vec<String>,
|
commands: Vec<String>,
|
||||||
|
output_format: CliOutputFormat,
|
||||||
},
|
},
|
||||||
Status {
|
Status {
|
||||||
model: String,
|
model: String,
|
||||||
permission_mode: PermissionMode,
|
permission_mode: PermissionMode,
|
||||||
|
output_format: CliOutputFormat,
|
||||||
|
},
|
||||||
|
Sandbox {
|
||||||
|
output_format: CliOutputFormat,
|
||||||
},
|
},
|
||||||
Sandbox,
|
|
||||||
Prompt {
|
Prompt {
|
||||||
prompt: String,
|
prompt: String,
|
||||||
model: String,
|
model: String,
|
||||||
@@ -339,9 +345,10 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if rest.first().map(String::as_str) == Some("--resume") {
|
if rest.first().map(String::as_str) == Some("--resume") {
|
||||||
return parse_resume_args(&rest[1..]);
|
return parse_resume_args(&rest[1..], output_format);
|
||||||
}
|
}
|
||||||
if let Some(action) = parse_single_word_command_alias(&rest, &model, permission_mode_override)
|
if let Some(action) =
|
||||||
|
parse_single_word_command_alias(&rest, &model, permission_mode_override, output_format)
|
||||||
{
|
{
|
||||||
return action;
|
return action;
|
||||||
}
|
}
|
||||||
@@ -392,6 +399,7 @@ fn parse_single_word_command_alias(
|
|||||||
rest: &[String],
|
rest: &[String],
|
||||||
model: &str,
|
model: &str,
|
||||||
permission_mode_override: Option<PermissionMode>,
|
permission_mode_override: Option<PermissionMode>,
|
||||||
|
output_format: CliOutputFormat,
|
||||||
) -> Option<Result<CliAction, String>> {
|
) -> Option<Result<CliAction, String>> {
|
||||||
if rest.len() != 1 {
|
if rest.len() != 1 {
|
||||||
return None;
|
return None;
|
||||||
@@ -403,8 +411,9 @@ fn parse_single_word_command_alias(
|
|||||||
"status" => Some(Ok(CliAction::Status {
|
"status" => Some(Ok(CliAction::Status {
|
||||||
model: model.to_string(),
|
model: model.to_string(),
|
||||||
permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode),
|
permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode),
|
||||||
|
output_format,
|
||||||
})),
|
})),
|
||||||
"sandbox" => Some(Ok(CliAction::Sandbox)),
|
"sandbox" => Some(Ok(CliAction::Sandbox { output_format })),
|
||||||
other => bare_slash_command_guidance(other).map(Err),
|
other => bare_slash_command_guidance(other).map(Err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -698,7 +707,7 @@ fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
Ok(CliAction::PrintSystemPrompt { cwd, date })
|
Ok(CliAction::PrintSystemPrompt { cwd, date })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
|
fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> {
|
||||||
let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() {
|
let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() {
|
||||||
None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]),
|
None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]),
|
||||||
Some(first) if looks_like_slash_command_token(first) => {
|
Some(first) if looks_like_slash_command_token(first) => {
|
||||||
@@ -738,6 +747,7 @@ fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
Ok(CliAction::ResumeSession {
|
Ok(CliAction::ResumeSession {
|
||||||
session_path,
|
session_path,
|
||||||
commands,
|
commands,
|
||||||
|
output_format,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -928,7 +938,7 @@ fn print_version() {
|
|||||||
println!("{}", render_version_report());
|
println!("{}", render_version_report());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resume_session(session_path: &Path, commands: &[String]) {
|
fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) {
|
||||||
let resolved_path = if session_path.exists() {
|
let resolved_path = if session_path.exists() {
|
||||||
session_path.to_path_buf()
|
session_path.to_path_buf()
|
||||||
} else {
|
} else {
|
||||||
@@ -950,15 +960,31 @@ fn resume_session(session_path: &Path, commands: &[String]) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if commands.is_empty() {
|
if commands.is_empty() {
|
||||||
println!(
|
match output_format {
|
||||||
"Restored session from {} ({} messages).",
|
CliOutputFormat::Text => {
|
||||||
resolved_path.display(),
|
println!(
|
||||||
session.messages.len()
|
"Restored session from {} ({} messages).",
|
||||||
);
|
resolved_path.display(),
|
||||||
|
session.messages.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
CliOutputFormat::Json => {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serialize_json_output(&json!({
|
||||||
|
"kind": "resume",
|
||||||
|
"session_file": resolved_path.display().to_string(),
|
||||||
|
"messages": session.messages.len(),
|
||||||
|
}))
|
||||||
|
.unwrap_or_else(|error| format!(r#"{{"kind":"error","message":"{error}"}}"#))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut session = session;
|
let mut session = session;
|
||||||
|
let mut json_outputs = Vec::new();
|
||||||
for raw_command in commands {
|
for raw_command in commands {
|
||||||
let command = match SlashCommand::parse(raw_command) {
|
let command = match SlashCommand::parse(raw_command) {
|
||||||
Ok(Some(command)) => command,
|
Ok(Some(command)) => command,
|
||||||
@@ -971,14 +997,19 @@ fn resume_session(session_path: &Path, commands: &[String]) {
|
|||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match run_resume_command(&resolved_path, &session, &command) {
|
match run_resume_command(&resolved_path, &session, &command, output_format) {
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: next_session,
|
session: next_session,
|
||||||
message,
|
message,
|
||||||
}) => {
|
}) => {
|
||||||
session = next_session;
|
session = next_session;
|
||||||
if let Some(message) = message {
|
if let Some(message) = message {
|
||||||
println!("{message}");
|
match output_format {
|
||||||
|
CliOutputFormat::Text => {
|
||||||
|
println!("{}", render_resume_text_output(&message))
|
||||||
|
}
|
||||||
|
CliOutputFormat::Json => json_outputs.push(message),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -987,12 +1018,27 @@ fn resume_session(session_path: &Path, commands: &[String]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if matches!(output_format, CliOutputFormat::Json) {
|
||||||
|
let payload = if json_outputs.len() == 1 {
|
||||||
|
json_outputs.pop().expect("single json output")
|
||||||
|
} else {
|
||||||
|
Value::Array(json_outputs)
|
||||||
|
};
|
||||||
|
match serialize_json_output(&payload) {
|
||||||
|
Ok(rendered) => println!("{rendered}"),
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("failed to render json output: {error}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct ResumeCommandOutcome {
|
struct ResumeCommandOutcome {
|
||||||
session: Session,
|
session: Session,
|
||||||
message: Option<String>,
|
message: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -1317,16 +1363,28 @@ fn parse_git_status_metadata_for(
|
|||||||
(project_root, branch)
|
(project_root, branch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn serialize_json_output(value: &Value) -> Result<String, serde_json::Error> {
|
||||||
|
serde_json::to_string_pretty(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_resume_text_output(value: &Value) -> String {
|
||||||
|
value.get("message").and_then(Value::as_str).map_or_else(
|
||||||
|
|| serialize_json_output(value).unwrap_or_else(|_| value.to_string()),
|
||||||
|
ToString::to_string,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
fn run_resume_command(
|
fn run_resume_command(
|
||||||
session_path: &Path,
|
session_path: &Path,
|
||||||
session: &Session,
|
session: &Session,
|
||||||
command: &SlashCommand,
|
command: &SlashCommand,
|
||||||
|
output_format: CliOutputFormat,
|
||||||
) -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
|
) -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
|
||||||
match command {
|
match command {
|
||||||
SlashCommand::Help => Ok(ResumeCommandOutcome {
|
SlashCommand::Help => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_repl_help()),
|
message: Some(json!({ "kind": "help", "message": render_repl_help() })),
|
||||||
}),
|
}),
|
||||||
SlashCommand::Compact => {
|
SlashCommand::Compact => {
|
||||||
let result = runtime::compact_session(
|
let result = runtime::compact_session(
|
||||||
@@ -1342,16 +1400,20 @@ fn run_resume_command(
|
|||||||
result.compacted_session.save_to_path(session_path)?;
|
result.compacted_session.save_to_path(session_path)?;
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: result.compacted_session,
|
session: result.compacted_session,
|
||||||
message: Some(format_compact_report(removed, kept, skipped)),
|
message: Some(json!({
|
||||||
|
"kind": "compact",
|
||||||
|
"message": format_compact_report(removed, kept, skipped),
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Clear { confirm } => {
|
SlashCommand::Clear { confirm } => {
|
||||||
if !confirm {
|
if !confirm {
|
||||||
return Ok(ResumeCommandOutcome {
|
return Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(
|
message: Some(json!({
|
||||||
"clear: confirmation required; rerun with /clear --confirm".to_string(),
|
"kind": "clear",
|
||||||
),
|
"message": "clear: confirmation required; rerun with /clear --confirm",
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let backup_path = write_session_clear_backup(session, session_path)?;
|
let backup_path = write_session_clear_backup(session, session_path)?;
|
||||||
@@ -1361,55 +1423,85 @@ fn run_resume_command(
|
|||||||
cleared.save_to_path(session_path)?;
|
cleared.save_to_path(session_path)?;
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: cleared,
|
session: cleared,
|
||||||
message: Some(format!(
|
message: Some(json!({
|
||||||
"Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}",
|
"kind": "clear",
|
||||||
backup_path.display(),
|
"message": format!(
|
||||||
backup_path.display(),
|
"Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}",
|
||||||
session_path.display()
|
backup_path.display(),
|
||||||
)),
|
backup_path.display(),
|
||||||
|
session_path.display()
|
||||||
|
),
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Status => {
|
SlashCommand::Status => {
|
||||||
let tracker = UsageTracker::from_session(session);
|
let tracker = UsageTracker::from_session(session);
|
||||||
let usage = tracker.cumulative_usage();
|
let usage = tracker.cumulative_usage();
|
||||||
|
let status_usage = StatusUsage {
|
||||||
|
message_count: session.messages.len(),
|
||||||
|
turns: tracker.turns(),
|
||||||
|
latest: tracker.current_turn_usage(),
|
||||||
|
cumulative: usage,
|
||||||
|
estimated_tokens: 0,
|
||||||
|
};
|
||||||
|
let context = status_context(Some(session_path))?;
|
||||||
|
let status_json = status_report_json(
|
||||||
|
"restored-session",
|
||||||
|
status_usage,
|
||||||
|
default_permission_mode().as_str(),
|
||||||
|
&context,
|
||||||
|
);
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(format_status_report(
|
message: Some(match output_format {
|
||||||
"restored-session",
|
CliOutputFormat::Text => json!({
|
||||||
StatusUsage {
|
"kind": "status-text",
|
||||||
message_count: session.messages.len(),
|
"message": format_status_report(
|
||||||
turns: tracker.turns(),
|
"restored-session",
|
||||||
latest: tracker.current_turn_usage(),
|
status_usage,
|
||||||
cumulative: usage,
|
default_permission_mode().as_str(),
|
||||||
estimated_tokens: 0,
|
&context,
|
||||||
},
|
),
|
||||||
default_permission_mode().as_str(),
|
}),
|
||||||
&status_context(Some(session_path))?,
|
CliOutputFormat::Json => status_json,
|
||||||
)),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Sandbox => {
|
SlashCommand::Sandbox => {
|
||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
let loader = ConfigLoader::default_for(&cwd);
|
let loader = ConfigLoader::default_for(&cwd);
|
||||||
let runtime_config = loader.load()?;
|
let runtime_config = loader.load()?;
|
||||||
|
let sandbox_status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(format_sandbox_report(&resolve_sandbox_status(
|
message: Some(match output_format {
|
||||||
runtime_config.sandbox(),
|
CliOutputFormat::Text => json!({
|
||||||
&cwd,
|
"kind": "sandbox-text",
|
||||||
))),
|
"message": format_sandbox_report(&sandbox_status),
|
||||||
|
}),
|
||||||
|
CliOutputFormat::Json => json!({
|
||||||
|
"kind": "sandbox",
|
||||||
|
"sandbox": sandbox_status_json(&sandbox_status),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Cost => {
|
SlashCommand::Cost => {
|
||||||
let usage = UsageTracker::from_session(session).cumulative_usage();
|
let usage = UsageTracker::from_session(session).cumulative_usage();
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(format_cost_report(usage)),
|
message: Some(json!({
|
||||||
|
"kind": "cost",
|
||||||
|
"message": format_cost_report(usage),
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
|
SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_config_report(section.as_deref())?),
|
message: Some(json!({
|
||||||
|
"kind": "config",
|
||||||
|
"message": render_config_report(section.as_deref())?,
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
SlashCommand::Mcp { action, target } => {
|
SlashCommand::Mcp { action, target } => {
|
||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
@@ -1421,51 +1513,75 @@ fn run_resume_command(
|
|||||||
};
|
};
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)?),
|
message: Some(json!({
|
||||||
|
"kind": "mcp",
|
||||||
|
"message": handle_mcp_slash_command(args.as_deref(), &cwd)?,
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Memory => Ok(ResumeCommandOutcome {
|
SlashCommand::Memory => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_memory_report()?),
|
message: Some(json!({
|
||||||
|
"kind": "memory",
|
||||||
|
"message": render_memory_report()?,
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
SlashCommand::Init => Ok(ResumeCommandOutcome {
|
SlashCommand::Init => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(init_claude_md()?),
|
message: Some(json!({
|
||||||
|
"kind": "init",
|
||||||
|
"message": init_claude_md()?,
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
SlashCommand::Diff => Ok(ResumeCommandOutcome {
|
SlashCommand::Diff => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_diff_report_for(
|
message: Some(json!({
|
||||||
session_path.parent().unwrap_or_else(|| Path::new(".")),
|
"kind": "diff",
|
||||||
)?),
|
"message": render_diff_report_for(
|
||||||
|
session_path.parent().unwrap_or_else(|| Path::new(".")),
|
||||||
|
)?,
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
SlashCommand::Version => Ok(ResumeCommandOutcome {
|
SlashCommand::Version => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_version_report()),
|
message: Some(json!({
|
||||||
|
"kind": "version",
|
||||||
|
"message": render_version_report(),
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
SlashCommand::Export { path } => {
|
SlashCommand::Export { path } => {
|
||||||
let export_path = resolve_export_path(path.as_deref(), session)?;
|
let export_path = resolve_export_path(path.as_deref(), session)?;
|
||||||
fs::write(&export_path, render_export_text(session))?;
|
fs::write(&export_path, render_export_text(session))?;
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(format!(
|
message: Some(json!({
|
||||||
"Export\n Result wrote transcript\n File {}\n Messages {}",
|
"kind": "export",
|
||||||
export_path.display(),
|
"message": format!(
|
||||||
session.messages.len(),
|
"Export\n Result wrote transcript\n File {}\n Messages {}",
|
||||||
)),
|
export_path.display(),
|
||||||
|
session.messages.len(),
|
||||||
|
),
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Agents { args } => {
|
SlashCommand::Agents { args } => {
|
||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?),
|
message: Some(json!({
|
||||||
|
"kind": "agents",
|
||||||
|
"message": handle_agents_slash_command(args.as_deref(), &cwd)?,
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Skills { args } => {
|
SlashCommand::Skills { args } => {
|
||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?),
|
message: Some(json!({
|
||||||
|
"kind": "skills",
|
||||||
|
"message": handle_skills_slash_command(args.as_deref(), &cwd)?,
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()),
|
SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()),
|
||||||
@@ -1751,37 +1867,38 @@ impl RuntimeMcpState {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|server_name| !failed_server_names.contains(server_name))
|
.filter(|server_name| !failed_server_names.contains(server_name))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let failed_servers = discovery
|
let failed_servers =
|
||||||
.failed_servers
|
discovery
|
||||||
.iter()
|
.failed_servers
|
||||||
.map(|failure| runtime::McpFailedServer {
|
.iter()
|
||||||
server_name: failure.server_name.clone(),
|
.map(|failure| runtime::McpFailedServer {
|
||||||
phase: runtime::McpLifecyclePhase::ToolDiscovery,
|
server_name: failure.server_name.clone(),
|
||||||
error: runtime::McpErrorSurface::new(
|
phase: runtime::McpLifecyclePhase::ToolDiscovery,
|
||||||
runtime::McpLifecyclePhase::ToolDiscovery,
|
|
||||||
Some(failure.server_name.clone()),
|
|
||||||
failure.error.clone(),
|
|
||||||
std::collections::BTreeMap::new(),
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
.chain(discovery.unsupported_servers.iter().map(|server| {
|
|
||||||
runtime::McpFailedServer {
|
|
||||||
server_name: server.server_name.clone(),
|
|
||||||
phase: runtime::McpLifecyclePhase::ServerRegistration,
|
|
||||||
error: runtime::McpErrorSurface::new(
|
error: runtime::McpErrorSurface::new(
|
||||||
runtime::McpLifecyclePhase::ServerRegistration,
|
runtime::McpLifecyclePhase::ToolDiscovery,
|
||||||
Some(server.server_name.clone()),
|
Some(failure.server_name.clone()),
|
||||||
server.reason.clone(),
|
failure.error.clone(),
|
||||||
std::collections::BTreeMap::from([(
|
std::collections::BTreeMap::new(),
|
||||||
"transport".to_string(),
|
true,
|
||||||
format!("{:?}", server.transport).to_ascii_lowercase(),
|
|
||||||
)]),
|
|
||||||
false,
|
|
||||||
),
|
),
|
||||||
}
|
})
|
||||||
}))
|
.chain(discovery.unsupported_servers.iter().map(|server| {
|
||||||
.collect::<Vec<_>>();
|
runtime::McpFailedServer {
|
||||||
|
server_name: server.server_name.clone(),
|
||||||
|
phase: runtime::McpLifecyclePhase::ServerRegistration,
|
||||||
|
error: runtime::McpErrorSurface::new(
|
||||||
|
runtime::McpLifecyclePhase::ServerRegistration,
|
||||||
|
Some(server.server_name.clone()),
|
||||||
|
server.reason.clone(),
|
||||||
|
std::collections::BTreeMap::from([(
|
||||||
|
"transport".to_string(),
|
||||||
|
format!("{:?}", server.transport).to_ascii_lowercase(),
|
||||||
|
)]),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let degraded_report = (!failed_servers.is_empty()).then(|| {
|
let degraded_report = (!failed_servers.is_empty()).then(|| {
|
||||||
runtime::McpDegradedReport::new(
|
runtime::McpDegradedReport::new(
|
||||||
working_servers,
|
working_servers,
|
||||||
@@ -3179,22 +3296,31 @@ fn render_repl_help() -> String {
|
|||||||
fn print_status_snapshot(
|
fn print_status_snapshot(
|
||||||
model: &str,
|
model: &str,
|
||||||
permission_mode: PermissionMode,
|
permission_mode: PermissionMode,
|
||||||
|
output_format: CliOutputFormat,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
println!(
|
let usage = StatusUsage {
|
||||||
"{}",
|
message_count: 0,
|
||||||
format_status_report(
|
turns: 0,
|
||||||
model,
|
latest: TokenUsage::default(),
|
||||||
StatusUsage {
|
cumulative: TokenUsage::default(),
|
||||||
message_count: 0,
|
estimated_tokens: 0,
|
||||||
turns: 0,
|
};
|
||||||
latest: TokenUsage::default(),
|
let context = status_context(None)?;
|
||||||
cumulative: TokenUsage::default(),
|
match output_format {
|
||||||
estimated_tokens: 0,
|
CliOutputFormat::Text => println!(
|
||||||
},
|
"{}",
|
||||||
permission_mode.as_str(),
|
format_status_report(model, usage, permission_mode.as_str(), &context)
|
||||||
&status_context(None)?,
|
),
|
||||||
)
|
CliOutputFormat::Json => println!(
|
||||||
);
|
"{}",
|
||||||
|
serialize_json_output(&status_report_json(
|
||||||
|
model,
|
||||||
|
usage,
|
||||||
|
permission_mode.as_str(),
|
||||||
|
&context,
|
||||||
|
))?
|
||||||
|
),
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3292,6 +3418,61 @@ fn format_status_report(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn status_report_json(
|
||||||
|
model: &str,
|
||||||
|
usage: StatusUsage,
|
||||||
|
permission_mode: &str,
|
||||||
|
context: &StatusContext,
|
||||||
|
) -> Value {
|
||||||
|
json!({
|
||||||
|
"kind": "status",
|
||||||
|
"model": model,
|
||||||
|
"permission_mode": permission_mode,
|
||||||
|
"messages": usage.message_count,
|
||||||
|
"turns": usage.turns,
|
||||||
|
"estimated_tokens": usage.estimated_tokens,
|
||||||
|
"usage": {
|
||||||
|
"latest": token_usage_json(usage.latest),
|
||||||
|
"cumulative": token_usage_json(usage.cumulative),
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"cwd": context.cwd.display().to_string(),
|
||||||
|
"project_root": context.project_root.as_ref().map(|path| path.display().to_string()),
|
||||||
|
"git_branch": context.git_branch.clone(),
|
||||||
|
"git_state": context.git_summary.headline(),
|
||||||
|
"changed_files": context.git_summary.changed_files,
|
||||||
|
"staged_files": context.git_summary.staged_files,
|
||||||
|
"unstaged_files": context.git_summary.unstaged_files,
|
||||||
|
"untracked_files": context.git_summary.untracked_files,
|
||||||
|
"session": status_session_label(context.session_path.as_deref()),
|
||||||
|
"config_files": {
|
||||||
|
"loaded": context.loaded_config_files,
|
||||||
|
"discovered": context.discovered_config_files,
|
||||||
|
},
|
||||||
|
"memory_files": context.memory_file_count,
|
||||||
|
"suggested_flow": ["/status", "/diff", "/commit"],
|
||||||
|
},
|
||||||
|
"sandbox": sandbox_status_json(&context.sandbox_status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_usage_json(usage: TokenUsage) -> Value {
|
||||||
|
json!({
|
||||||
|
"input_tokens": usage.input_tokens,
|
||||||
|
"output_tokens": usage.output_tokens,
|
||||||
|
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
|
||||||
|
"cache_read_input_tokens": usage.cache_read_input_tokens,
|
||||||
|
"total_tokens": usage.total_tokens(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_session_label(session_path: Option<&Path>) -> String {
|
||||||
|
session_path.map_or_else(
|
||||||
|
|| "live-repl".to_string(),
|
||||||
|
|path| path.display().to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn format_sandbox_report(status: &runtime::SandboxStatus) -> String {
|
fn format_sandbox_report(status: &runtime::SandboxStatus) -> String {
|
||||||
format!(
|
format!(
|
||||||
"Sandbox
|
"Sandbox
|
||||||
@@ -3335,6 +3516,31 @@ fn format_sandbox_report(status: &runtime::SandboxStatus) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sandbox_status_json(status: &runtime::SandboxStatus) -> Value {
|
||||||
|
json!({
|
||||||
|
"enabled": status.enabled,
|
||||||
|
"active": status.active,
|
||||||
|
"supported": status.supported,
|
||||||
|
"namespace_supported": status.namespace_supported,
|
||||||
|
"namespace_active": status.namespace_active,
|
||||||
|
"network_supported": status.network_supported,
|
||||||
|
"network_active": status.network_active,
|
||||||
|
"filesystem_mode": status.filesystem_mode.as_str(),
|
||||||
|
"filesystem_active": status.filesystem_active,
|
||||||
|
"allowed_mounts": status.allowed_mounts.clone(),
|
||||||
|
"in_container": status.in_container,
|
||||||
|
"container_markers": status.container_markers.clone(),
|
||||||
|
"fallback_reason": status.fallback_reason.clone(),
|
||||||
|
"requested": {
|
||||||
|
"enabled": status.requested.enabled,
|
||||||
|
"namespace_restrictions": status.requested.namespace_restrictions,
|
||||||
|
"network_isolation": status.requested.network_isolation,
|
||||||
|
"filesystem_mode": status.requested.filesystem_mode.as_str(),
|
||||||
|
"allowed_mounts": status.requested.allowed_mounts.clone(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn format_commit_preflight_report(branch: Option<&str>, summary: GitWorkspaceSummary) -> String {
|
fn format_commit_preflight_report(branch: Option<&str>, summary: GitWorkspaceSummary) -> String {
|
||||||
format!(
|
format!(
|
||||||
"Commit
|
"Commit
|
||||||
@@ -3358,16 +3564,25 @@ fn format_commit_skipped_report() -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_sandbox_status_snapshot() -> Result<(), Box<dyn std::error::Error>> {
|
fn print_sandbox_status_snapshot(
|
||||||
|
output_format: CliOutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
let loader = ConfigLoader::default_for(&cwd);
|
let loader = ConfigLoader::default_for(&cwd);
|
||||||
let runtime_config = loader
|
let runtime_config = loader
|
||||||
.load()
|
.load()
|
||||||
.unwrap_or_else(|_| runtime::RuntimeConfig::empty());
|
.unwrap_or_else(|_| runtime::RuntimeConfig::empty());
|
||||||
println!(
|
let sandbox_status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
|
||||||
"{}",
|
match output_format {
|
||||||
format_sandbox_report(&resolve_sandbox_status(runtime_config.sandbox(), &cwd))
|
CliOutputFormat::Text => println!("{}", format_sandbox_report(&sandbox_status)),
|
||||||
);
|
CliOutputFormat::Json => println!(
|
||||||
|
"{}",
|
||||||
|
serialize_json_output(&json!({
|
||||||
|
"kind": "sandbox",
|
||||||
|
"sandbox": sandbox_status_json(&sandbox_status),
|
||||||
|
}))?
|
||||||
|
),
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5650,12 +5865,12 @@ mod tests {
|
|||||||
parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary,
|
parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary,
|
||||||
permission_policy, print_help_to, push_output_block, render_config_report,
|
permission_policy, print_help_to, push_output_block, render_config_report,
|
||||||
render_diff_report, render_diff_report_for, render_memory_report, render_repl_help,
|
render_diff_report, render_diff_report_for, render_memory_report, render_repl_help,
|
||||||
render_resume_usage, resolve_model_alias, resolve_session_reference, response_to_events,
|
render_resume_text_output, render_resume_usage, resolve_model_alias,
|
||||||
resume_supported_slash_commands, run_resume_command,
|
resolve_session_reference, response_to_events, resume_supported_slash_commands,
|
||||||
slash_command_completion_candidates_with_sessions, status_context, validate_no_args,
|
run_resume_command, slash_command_completion_candidates_with_sessions, status_context,
|
||||||
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
|
validate_no_args, write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor,
|
||||||
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, SlashCommand,
|
GitWorkspaceSummary, InternalPromptProgressEvent, InternalPromptProgressState, LiveCli,
|
||||||
StatusUsage, DEFAULT_MODEL,
|
SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||||
};
|
};
|
||||||
use api::{MessageResponse, OutputContentBlock, Usage};
|
use api::{MessageResponse, OutputContentBlock, Usage};
|
||||||
use plugins::{
|
use plugins::{
|
||||||
@@ -6063,11 +6278,36 @@ mod tests {
|
|||||||
CliAction::Status {
|
CliAction::Status {
|
||||||
model: DEFAULT_MODEL.to_string(),
|
model: DEFAULT_MODEL.to_string(),
|
||||||
permission_mode: PermissionMode::DangerFullAccess,
|
permission_mode: PermissionMode::DangerFullAccess,
|
||||||
|
output_format: CliOutputFormat::Text,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&["sandbox".to_string()]).expect("sandbox should parse"),
|
parse_args(&["sandbox".to_string()]).expect("sandbox should parse"),
|
||||||
CliAction::Sandbox
|
CliAction::Sandbox {
|
||||||
|
output_format: CliOutputFormat::Text,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_json_output_for_status_and_sandbox_aliases() {
|
||||||
|
let _guard = env_lock();
|
||||||
|
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&["--output-format=json".to_string(), "status".to_string()])
|
||||||
|
.expect("json status should parse"),
|
||||||
|
CliAction::Status {
|
||||||
|
model: DEFAULT_MODEL.to_string(),
|
||||||
|
permission_mode: PermissionMode::DangerFullAccess,
|
||||||
|
output_format: CliOutputFormat::Json,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&["--output-format=json".to_string(), "sandbox".to_string()])
|
||||||
|
.expect("json sandbox should parse"),
|
||||||
|
CliAction::Sandbox {
|
||||||
|
output_format: CliOutputFormat::Json,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6173,6 +6413,7 @@ mod tests {
|
|||||||
CliAction::ResumeSession {
|
CliAction::ResumeSession {
|
||||||
session_path: PathBuf::from("session.jsonl"),
|
session_path: PathBuf::from("session.jsonl"),
|
||||||
commands: vec!["/compact".to_string()],
|
commands: vec!["/compact".to_string()],
|
||||||
|
output_format: CliOutputFormat::Text,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6184,6 +6425,7 @@ mod tests {
|
|||||||
CliAction::ResumeSession {
|
CliAction::ResumeSession {
|
||||||
session_path: PathBuf::from("latest"),
|
session_path: PathBuf::from("latest"),
|
||||||
commands: vec![],
|
commands: vec![],
|
||||||
|
output_format: CliOutputFormat::Text,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -6192,6 +6434,7 @@ mod tests {
|
|||||||
CliAction::ResumeSession {
|
CliAction::ResumeSession {
|
||||||
session_path: PathBuf::from("latest"),
|
session_path: PathBuf::from("latest"),
|
||||||
commands: vec!["/status".to_string()],
|
commands: vec!["/status".to_string()],
|
||||||
|
output_format: CliOutputFormat::Text,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6214,6 +6457,7 @@ mod tests {
|
|||||||
"/compact".to_string(),
|
"/compact".to_string(),
|
||||||
"/cost".to_string(),
|
"/cost".to_string(),
|
||||||
],
|
],
|
||||||
|
output_format: CliOutputFormat::Text,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6244,6 +6488,7 @@ mod tests {
|
|||||||
"/export notes.txt".to_string(),
|
"/export notes.txt".to_string(),
|
||||||
"/clear --confirm".to_string(),
|
"/clear --confirm".to_string(),
|
||||||
],
|
],
|
||||||
|
output_format: CliOutputFormat::Text,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6262,6 +6507,25 @@ mod tests {
|
|||||||
CliAction::ResumeSession {
|
CliAction::ResumeSession {
|
||||||
session_path: PathBuf::from("session.jsonl"),
|
session_path: PathBuf::from("session.jsonl"),
|
||||||
commands: vec!["/export /tmp/notes.txt".to_string(), "/status".to_string()],
|
commands: vec!["/export /tmp/notes.txt".to_string(), "/status".to_string()],
|
||||||
|
output_format: CliOutputFormat::Text,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_json_output_for_resumed_status_queries() {
|
||||||
|
let args = vec![
|
||||||
|
"--output-format=json".to_string(),
|
||||||
|
"--resume".to_string(),
|
||||||
|
"session.jsonl".to_string(),
|
||||||
|
"/status".to_string(),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&args).expect("json resume status should parse"),
|
||||||
|
CliAction::ResumeSession {
|
||||||
|
session_path: PathBuf::from("session.jsonl"),
|
||||||
|
commands: vec!["/status".to_string()],
|
||||||
|
output_format: CliOutputFormat::Json,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6782,10 +7046,16 @@ UU conflicted.rs",
|
|||||||
|
|
||||||
let session = Session::load_from_path(&session_path).expect("session should load");
|
let session = Session::load_from_path(&session_path).expect("session should load");
|
||||||
let outcome = with_current_dir(&root, || {
|
let outcome = with_current_dir(&root, || {
|
||||||
run_resume_command(&session_path, &session, &SlashCommand::Diff)
|
run_resume_command(
|
||||||
.expect("resume diff should work")
|
&session_path,
|
||||||
|
&session,
|
||||||
|
&SlashCommand::Diff,
|
||||||
|
CliOutputFormat::Text,
|
||||||
|
)
|
||||||
|
.expect("resume diff should work")
|
||||||
});
|
});
|
||||||
let message = outcome.message.expect("diff message should exist");
|
let message =
|
||||||
|
render_resume_text_output(&outcome.message.expect("diff message should exist"));
|
||||||
assert!(message.contains("Unstaged changes:"));
|
assert!(message.contains("Unstaged changes:"));
|
||||||
assert!(message.contains("tracked.txt"));
|
assert!(message.contains("tracked.txt"));
|
||||||
|
|
||||||
@@ -7509,8 +7779,12 @@ UU conflicted.rs",
|
|||||||
let runtime_config = loader.load().expect("runtime config should load");
|
let runtime_config = loader.load().expect("runtime config should load");
|
||||||
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
|
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
|
||||||
.expect("runtime plugin state should load");
|
.expect("runtime plugin state should load");
|
||||||
let mut executor =
|
let mut executor = CliToolExecutor::new(
|
||||||
CliToolExecutor::new(None, false, state.tool_registry.clone(), state.mcp_state.clone());
|
None,
|
||||||
|
false,
|
||||||
|
state.tool_registry.clone(),
|
||||||
|
state.mcp_state.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
let search_output = executor
|
let search_output = executor
|
||||||
.execute("ToolSearch", r#"{"query":"remote","max_results":5}"#)
|
.execute("ToolSearch", r#"{"query":"remote","max_results":5}"#)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
use runtime::ContentBlock;
|
use runtime::ContentBlock;
|
||||||
use runtime::Session;
|
use runtime::Session;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
@@ -221,6 +222,52 @@ fn resume_latest_restores_the_most_recent_managed_session() {
|
|||||||
assert!(stdout.contains(newer_path.to_str().expect("utf8 path")));
|
assert!(stdout.contains(newer_path.to_str().expect("utf8 path")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resumed_status_command_emits_structured_json_when_requested() {
|
||||||
|
// given
|
||||||
|
let temp_dir = unique_temp_dir("resume-status-json");
|
||||||
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||||
|
let session_path = temp_dir.join("session.jsonl");
|
||||||
|
|
||||||
|
let mut session = Session::new();
|
||||||
|
session
|
||||||
|
.push_user_text("resume status json fixture")
|
||||||
|
.expect("session write should succeed");
|
||||||
|
session
|
||||||
|
.save_to_path(&session_path)
|
||||||
|
.expect("session should persist");
|
||||||
|
|
||||||
|
// when
|
||||||
|
let output = run_claw(
|
||||||
|
&temp_dir,
|
||||||
|
&[
|
||||||
|
"--output-format",
|
||||||
|
"json",
|
||||||
|
"--resume",
|
||||||
|
session_path.to_str().expect("utf8 path"),
|
||||||
|
"/status",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"stdout:\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 parsed: Value =
|
||||||
|
serde_json::from_str(stdout.trim()).expect("resume status output should be json");
|
||||||
|
assert_eq!(parsed["kind"], "status");
|
||||||
|
assert_eq!(parsed["messages"], 1);
|
||||||
|
assert_eq!(
|
||||||
|
parsed["workspace"]["session"],
|
||||||
|
session_path.to_str().expect("utf8 path")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
|
fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
|
||||||
run_claw_with_env(current_dir, args, &[])
|
run_claw_with_env(current_dir, args, &[])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use runtime::{
|
|||||||
use crate::AgentOutput;
|
use crate::AgentOutput;
|
||||||
|
|
||||||
/// Detects if a lane should be automatically marked as completed.
|
/// Detects if a lane should be automatically marked as completed.
|
||||||
///
|
///
|
||||||
/// Returns `Some(LaneContext)` with `completed = true` if all conditions met,
|
/// Returns `Some(LaneContext)` with `completed = true` if all conditions met,
|
||||||
/// `None` if lane should remain active.
|
/// `None` if lane should remain active.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -29,29 +29,29 @@ pub(crate) fn detect_lane_completion(
|
|||||||
if output.error.is_some() {
|
if output.error.is_some() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must have finished status
|
// Must have finished status
|
||||||
if !output.status.eq_ignore_ascii_case("completed")
|
if !output.status.eq_ignore_ascii_case("completed")
|
||||||
&& !output.status.eq_ignore_ascii_case("finished")
|
&& !output.status.eq_ignore_ascii_case("finished")
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must have no current blocker
|
// Must have no current blocker
|
||||||
if output.current_blocker.is_some() {
|
if output.current_blocker.is_some() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must have green tests
|
// Must have green tests
|
||||||
if !test_green {
|
if !test_green {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must have pushed code
|
// Must have pushed code
|
||||||
if !has_pushed {
|
if !has_pushed {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All conditions met — create completed context
|
// All conditions met — create completed context
|
||||||
Some(LaneContext {
|
Some(LaneContext {
|
||||||
lane_id: output.agent_id.clone(),
|
lane_id: output.agent_id.clone(),
|
||||||
@@ -67,9 +67,7 @@ pub(crate) fn detect_lane_completion(
|
|||||||
|
|
||||||
/// Evaluates policy actions for a completed lane.
|
/// Evaluates policy actions for a completed lane.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub(crate) fn evaluate_completed_lane(
|
pub(crate) fn evaluate_completed_lane(context: &LaneContext) -> Vec<PolicyAction> {
|
||||||
context: &LaneContext,
|
|
||||||
) -> Vec<PolicyAction> {
|
|
||||||
let engine = PolicyEngine::new(vec![
|
let engine = PolicyEngine::new(vec![
|
||||||
PolicyRule::new(
|
PolicyRule::new(
|
||||||
"closeout-completed-lane",
|
"closeout-completed-lane",
|
||||||
@@ -87,7 +85,7 @@ pub(crate) fn evaluate_completed_lane(
|
|||||||
5,
|
5,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
evaluate(&engine, context)
|
evaluate(&engine, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,53 +112,53 @@ mod tests {
|
|||||||
error: None,
|
error: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detects_completion_when_all_conditions_met() {
|
fn detects_completion_when_all_conditions_met() {
|
||||||
let output = test_output();
|
let output = test_output();
|
||||||
let result = detect_lane_completion(&output, true, true);
|
let result = detect_lane_completion(&output, true, true);
|
||||||
|
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
let context = result.unwrap();
|
let context = result.unwrap();
|
||||||
assert!(context.completed);
|
assert!(context.completed);
|
||||||
assert_eq!(context.green_level, 3);
|
assert_eq!(context.green_level, 3);
|
||||||
assert_eq!(context.blocker, LaneBlocker::None);
|
assert_eq!(context.blocker, LaneBlocker::None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_completion_when_error_present() {
|
fn no_completion_when_error_present() {
|
||||||
let mut output = test_output();
|
let mut output = test_output();
|
||||||
output.error = Some("Build failed".to_string());
|
output.error = Some("Build failed".to_string());
|
||||||
|
|
||||||
let result = detect_lane_completion(&output, true, true);
|
let result = detect_lane_completion(&output, true, true);
|
||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_completion_when_not_finished() {
|
fn no_completion_when_not_finished() {
|
||||||
let mut output = test_output();
|
let mut output = test_output();
|
||||||
output.status = "Running".to_string();
|
output.status = "Running".to_string();
|
||||||
|
|
||||||
let result = detect_lane_completion(&output, true, true);
|
let result = detect_lane_completion(&output, true, true);
|
||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_completion_when_tests_not_green() {
|
fn no_completion_when_tests_not_green() {
|
||||||
let output = test_output();
|
let output = test_output();
|
||||||
|
|
||||||
let result = detect_lane_completion(&output, false, true);
|
let result = detect_lane_completion(&output, false, true);
|
||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_completion_when_not_pushed() {
|
fn no_completion_when_not_pushed() {
|
||||||
let output = test_output();
|
let output = test_output();
|
||||||
|
|
||||||
let result = detect_lane_completion(&output, true, false);
|
let result = detect_lane_completion(&output, true, false);
|
||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn evaluate_triggers_closeout_for_completed_lane() {
|
fn evaluate_triggers_closeout_for_completed_lane() {
|
||||||
let context = LaneContext {
|
let context = LaneContext {
|
||||||
@@ -173,9 +171,9 @@ mod tests {
|
|||||||
completed: true,
|
completed: true,
|
||||||
reconciled: false,
|
reconciled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let actions = evaluate_completed_lane(&context);
|
let actions = evaluate_completed_lane(&context);
|
||||||
|
|
||||||
assert!(actions.contains(&PolicyAction::CloseoutLane));
|
assert!(actions.contains(&PolicyAction::CloseoutLane));
|
||||||
assert!(actions.contains(&PolicyAction::CleanupSession));
|
assert!(actions.contains(&PolicyAction::CleanupSession));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ use runtime::{
|
|||||||
permission_enforcer::{EnforcementResult, PermissionEnforcer},
|
permission_enforcer::{EnforcementResult, PermissionEnforcer},
|
||||||
read_file,
|
read_file,
|
||||||
summary_compression::compress_summary_text,
|
summary_compression::compress_summary_text,
|
||||||
TaskPacket,
|
|
||||||
task_registry::TaskRegistry,
|
task_registry::TaskRegistry,
|
||||||
team_cron_registry::{CronRegistry, TeamRegistry},
|
team_cron_registry::{CronRegistry, TeamRegistry},
|
||||||
worker_boot::{WorkerReadySnapshot, WorkerRegistry},
|
worker_boot::{WorkerReadySnapshot, WorkerRegistry},
|
||||||
@@ -25,7 +24,7 @@ use runtime::{
|
|||||||
BranchFreshness, ContentBlock, ConversationMessage, ConversationRuntime, GrepSearchInput,
|
BranchFreshness, ContentBlock, ConversationMessage, ConversationRuntime, GrepSearchInput,
|
||||||
LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass,
|
LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass,
|
||||||
McpDegradedReport, MessageRole, PermissionMode, PermissionPolicy, PromptCacheEvent,
|
McpDegradedReport, MessageRole, PermissionMode, PermissionPolicy, PromptCacheEvent,
|
||||||
RuntimeError, Session, ToolError, ToolExecutor,
|
RuntimeError, Session, TaskPacket, ToolError, ToolExecutor,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -1878,27 +1877,25 @@ fn branch_divergence_output(
|
|||||||
dangerously_disable_sandbox: None,
|
dangerously_disable_sandbox: None,
|
||||||
return_code_interpretation: Some("preflight_blocked:branch_divergence".to_string()),
|
return_code_interpretation: Some("preflight_blocked:branch_divergence".to_string()),
|
||||||
no_output_expected: Some(false),
|
no_output_expected: Some(false),
|
||||||
structured_content: Some(vec![
|
structured_content: Some(vec![serde_json::to_value(
|
||||||
serde_json::to_value(
|
LaneEvent::new(
|
||||||
LaneEvent::new(
|
LaneEventName::BranchStaleAgainstMain,
|
||||||
LaneEventName::BranchStaleAgainstMain,
|
LaneEventStatus::Blocked,
|
||||||
LaneEventStatus::Blocked,
|
iso8601_now(),
|
||||||
iso8601_now(),
|
|
||||||
)
|
|
||||||
.with_failure_class(LaneFailureClass::BranchDivergence)
|
|
||||||
.with_detail(stderr.clone())
|
|
||||||
.with_data(json!({
|
|
||||||
"branch": branch,
|
|
||||||
"mainRef": main_ref,
|
|
||||||
"commitsBehind": commits_behind,
|
|
||||||
"commitsAhead": commits_ahead,
|
|
||||||
"missingCommits": missing_fixes,
|
|
||||||
"blockedCommand": command,
|
|
||||||
"recommendedAction": format!("merge or rebase {main_ref} before workspace tests")
|
|
||||||
})),
|
|
||||||
)
|
)
|
||||||
.expect("lane event should serialize"),
|
.with_failure_class(LaneFailureClass::BranchDivergence)
|
||||||
]),
|
.with_detail(stderr.clone())
|
||||||
|
.with_data(json!({
|
||||||
|
"branch": branch,
|
||||||
|
"mainRef": main_ref,
|
||||||
|
"commitsBehind": commits_behind,
|
||||||
|
"commitsAhead": commits_ahead,
|
||||||
|
"missingCommits": missing_fixes,
|
||||||
|
"blockedCommand": command,
|
||||||
|
"recommendedAction": format!("merge or rebase {main_ref} before workspace tests")
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.expect("lane event should serialize")]),
|
||||||
persisted_output_path: None,
|
persisted_output_path: None,
|
||||||
persisted_output_size: None,
|
persisted_output_size: None,
|
||||||
sandbox_status: None,
|
sandbox_status: None,
|
||||||
@@ -2979,15 +2976,21 @@ fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut candidates = Vec::new();
|
let mut candidates = Vec::new();
|
||||||
|
if let Ok(claw_config_home) = std::env::var("CLAW_CONFIG_HOME") {
|
||||||
|
candidates.push(std::path::PathBuf::from(claw_config_home).join("skills"));
|
||||||
|
}
|
||||||
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
|
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
|
||||||
candidates.push(std::path::PathBuf::from(codex_home).join("skills"));
|
candidates.push(std::path::PathBuf::from(codex_home).join("skills"));
|
||||||
}
|
}
|
||||||
if let Ok(home) = std::env::var("HOME") {
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
let home = std::path::PathBuf::from(home);
|
let home = std::path::PathBuf::from(home);
|
||||||
|
candidates.push(home.join(".claw").join("skills"));
|
||||||
candidates.push(home.join(".agents").join("skills"));
|
candidates.push(home.join(".agents").join("skills"));
|
||||||
candidates.push(home.join(".config").join("opencode").join("skills"));
|
candidates.push(home.join(".config").join("opencode").join("skills"));
|
||||||
candidates.push(home.join(".codex").join("skills"));
|
candidates.push(home.join(".codex").join("skills"));
|
||||||
|
candidates.push(home.join(".claude").join("skills"));
|
||||||
}
|
}
|
||||||
|
candidates.push(std::path::PathBuf::from("/home/bellman/.claw/skills"));
|
||||||
candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills"));
|
candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills"));
|
||||||
|
|
||||||
for root in candidates {
|
for root in candidates {
|
||||||
@@ -3297,12 +3300,12 @@ fn persist_agent_terminal_state(
|
|||||||
next_manifest.current_blocker = blocker.clone();
|
next_manifest.current_blocker = blocker.clone();
|
||||||
next_manifest.error = error;
|
next_manifest.error = error;
|
||||||
if let Some(blocker) = blocker {
|
if let Some(blocker) = blocker {
|
||||||
next_manifest.lane_events.push(
|
next_manifest
|
||||||
LaneEvent::blocked(iso8601_now(), &blocker),
|
.lane_events
|
||||||
);
|
.push(LaneEvent::blocked(iso8601_now(), &blocker));
|
||||||
next_manifest.lane_events.push(
|
next_manifest
|
||||||
LaneEvent::failed(iso8601_now(), &blocker),
|
.lane_events
|
||||||
);
|
.push(LaneEvent::failed(iso8601_now(), &blocker));
|
||||||
} else {
|
} else {
|
||||||
next_manifest.current_blocker = None;
|
next_manifest.current_blocker = None;
|
||||||
let compressed_detail = result
|
let compressed_detail = result
|
||||||
@@ -4952,8 +4955,8 @@ mod tests {
|
|||||||
agent_permission_policy, allowed_tools_for_subagent, classify_lane_failure,
|
agent_permission_policy, allowed_tools_for_subagent, classify_lane_failure,
|
||||||
execute_agent_with_spawn, execute_tool, final_assistant_text, mvp_tool_specs,
|
execute_agent_with_spawn, execute_tool, final_assistant_text, mvp_tool_specs,
|
||||||
permission_mode_from_plugin, persist_agent_terminal_state, push_output_block,
|
permission_mode_from_plugin, persist_agent_terminal_state, push_output_block,
|
||||||
run_task_packet, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName,
|
run_task_packet, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass,
|
||||||
LaneFailureClass, SubagentToolExecutor,
|
SubagentToolExecutor,
|
||||||
};
|
};
|
||||||
use api::OutputContentBlock;
|
use api::OutputContentBlock;
|
||||||
use runtime::{
|
use runtime::{
|
||||||
@@ -5977,7 +5980,10 @@ mod tests {
|
|||||||
"gateway routing rejected the request",
|
"gateway routing rejected the request",
|
||||||
LaneFailureClass::GatewayRouting,
|
LaneFailureClass::GatewayRouting,
|
||||||
),
|
),
|
||||||
("tool failed: denied tool execution from hook", LaneFailureClass::ToolRuntime),
|
(
|
||||||
|
"tool failed: denied tool execution from hook",
|
||||||
|
LaneFailureClass::ToolRuntime,
|
||||||
|
),
|
||||||
("thread creation failed", LaneFailureClass::Infra),
|
("thread creation failed", LaneFailureClass::Infra),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -6000,11 +6006,17 @@ mod tests {
|
|||||||
(LaneEventName::MergeReady, "lane.merge.ready"),
|
(LaneEventName::MergeReady, "lane.merge.ready"),
|
||||||
(LaneEventName::Finished, "lane.finished"),
|
(LaneEventName::Finished, "lane.finished"),
|
||||||
(LaneEventName::Failed, "lane.failed"),
|
(LaneEventName::Failed, "lane.failed"),
|
||||||
(LaneEventName::BranchStaleAgainstMain, "branch.stale_against_main"),
|
(
|
||||||
|
LaneEventName::BranchStaleAgainstMain,
|
||||||
|
"branch.stale_against_main",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (event, expected) in cases {
|
for (event, expected) in cases {
|
||||||
assert_eq!(serde_json::to_value(event).expect("serialize lane event"), json!(expected));
|
assert_eq!(
|
||||||
|
serde_json::to_value(event).expect("serialize lane event"),
|
||||||
|
json!(expected)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user