mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-06 16:14:49 +08:00
Compare commits
8 Commits
fix/p0-10-
...
fix/p03-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73bf52467d | ||
|
|
31163be347 | ||
|
|
eb4d3b11ee | ||
|
|
9bd7a78ca8 | ||
|
|
24d8f916c8 | ||
|
|
30883bddbd | ||
|
|
fa72cd665e | ||
|
|
1f53d961ff |
68
.github/workflows/release.yml
vendored
Normal file
68
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
name: Release binaries
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build-${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-x64
|
||||
os: ubuntu-latest
|
||||
bin: claw
|
||||
artifact_name: claw-linux-x64
|
||||
- name: macos-arm64
|
||||
os: macos-14
|
||||
bin: claw
|
||||
artifact_name: claw-macos-arm64
|
||||
defaults:
|
||||
run:
|
||||
working-directory: rust
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: rust -> target
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release -p rusty-claude-cli
|
||||
|
||||
- name: Package artifact
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp "target/release/${{ matrix.bin }}" "dist/${{ matrix.artifact_name }}"
|
||||
chmod +x "dist/${{ matrix.artifact_name }}"
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact_name }}
|
||||
path: rust/dist/${{ matrix.artifact_name }}
|
||||
|
||||
- name: Upload release asset
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: rust/dist/${{ matrix.artifact_name }}
|
||||
fail_on_unmatched_files: true
|
||||
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,
|
||||
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,
|
||||
Auth(String),
|
||||
InvalidApiKeyEnv(VarError),
|
||||
@@ -48,6 +55,7 @@ impl ApiError {
|
||||
Self::Api { retryable, .. } => *retryable,
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
|
||||
Self::MissingCredentials { .. }
|
||||
| Self::ContextWindowExceeded { .. }
|
||||
| Self::ExpiredOAuthToken
|
||||
| Self::Auth(_)
|
||||
| Self::InvalidApiKeyEnv(_)
|
||||
@@ -67,6 +75,16 @@ impl Display for ApiError {
|
||||
"missing {provider} credentials; export {} before calling the {provider} API",
|
||||
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 => {
|
||||
write!(
|
||||
f,
|
||||
|
||||
@@ -14,7 +14,7 @@ use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, Session
|
||||
use crate::error::ApiError;
|
||||
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
|
||||
|
||||
use super::{Provider, ProviderFuture};
|
||||
use super::{preflight_message_request, Provider, ProviderFuture};
|
||||
use crate::sse::SseParser;
|
||||
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 request_id = request_id_from_headers(response.headers());
|
||||
let mut response = response
|
||||
@@ -337,6 +339,7 @@ impl AnthropicClient {
|
||||
&self,
|
||||
request: &MessageRequest,
|
||||
) -> Result<MessageStream, ApiError> {
|
||||
preflight_message_request(request)?;
|
||||
let response = self
|
||||
.send_with_retry(&request.clone().with_streaming())
|
||||
.await?;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::types::{MessageRequest, MessageResponse};
|
||||
|
||||
@@ -40,6 +42,12 @@ pub struct ProviderMetadata {
|
||||
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)] = &[
|
||||
(
|
||||
"opus",
|
||||
@@ -182,17 +190,86 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
|
||||
|
||||
#[must_use]
|
||||
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);
|
||||
if canonical.contains("opus") {
|
||||
32_000
|
||||
} else {
|
||||
64_000
|
||||
match canonical.as_str() {
|
||||
"claude-opus-4-6" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 32_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)]
|
||||
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]
|
||||
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("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,
|
||||
};
|
||||
|
||||
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_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
@@ -128,6 +128,7 @@ impl OpenAiCompatClient {
|
||||
stream: false,
|
||||
..request.clone()
|
||||
};
|
||||
preflight_message_request(&request)?;
|
||||
let response = self.send_with_retry(&request).await?;
|
||||
let request_id = request_id_from_headers(response.headers());
|
||||
let payload = response.json::<ChatCompletionResponse>().await?;
|
||||
@@ -142,6 +143,7 @@ impl OpenAiCompatClient {
|
||||
&self,
|
||||
request: &MessageRequest,
|
||||
) -> Result<MessageStream, ApiError> {
|
||||
preflight_message_request(request)?;
|
||||
let response = self
|
||||
.send_with_retry(&request.clone().with_streaming())
|
||||
.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]
|
||||
async fn send_message_applies_request_profile_and_records_telemetry() {
|
||||
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 api::{
|
||||
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
|
||||
InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OpenAiCompatClient,
|
||||
OpenAiCompatConfig, OutputContentBlock, ProviderClient, StreamEvent, ToolChoice,
|
||||
ToolDefinition,
|
||||
ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
|
||||
ContentBlockStopEvent, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest,
|
||||
OpenAiCompatClient, OpenAiCompatConfig, OutputContentBlock, ProviderClient, StreamEvent,
|
||||
ToolChoice, ToolDefinition,
|
||||
};
|
||||
use serde_json::json;
|
||||
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"));
|
||||
}
|
||||
|
||||
#[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]
|
||||
async fn send_message_accepts_full_chat_completions_endpoint_override() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
|
||||
@@ -2142,13 +2142,22 @@ pub fn handle_plugins_slash_command(
|
||||
}
|
||||
|
||||
pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
|
||||
if let Some(args) = normalize_optional_args(args) {
|
||||
if let Some(help_path) = help_path_from_args(args) {
|
||||
return Ok(match help_path.as_slice() {
|
||||
[] => render_agents_usage(None),
|
||||
_ => render_agents_usage(Some(&help_path.join(" "))),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
match normalize_optional_args(args) {
|
||||
None | Some("list") => {
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
Ok(render_agents_report(&agents))
|
||||
}
|
||||
Some("-h" | "--help" | "help") => Ok(render_agents_usage(None)),
|
||||
Some(args) if is_help_arg(args) => Ok(render_agents_usage(None)),
|
||||
Some(args) => Ok(render_agents_usage(Some(args))),
|
||||
}
|
||||
}
|
||||
@@ -2162,6 +2171,16 @@ pub fn handle_mcp_slash_command(
|
||||
}
|
||||
|
||||
pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
|
||||
if let Some(args) = normalize_optional_args(args) {
|
||||
if let Some(help_path) = help_path_from_args(args) {
|
||||
return Ok(match help_path.as_slice() {
|
||||
[] => render_skills_usage(None),
|
||||
["install", ..] => render_skills_usage(Some("install")),
|
||||
_ => render_skills_usage(Some(&help_path.join(" "))),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
match normalize_optional_args(args) {
|
||||
None | Some("list") => {
|
||||
let roots = discover_skill_roots(cwd);
|
||||
@@ -2177,7 +2196,7 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
||||
let install = install_skill(target, cwd)?;
|
||||
Ok(render_skill_install_report(&install))
|
||||
}
|
||||
Some("-h" | "--help" | "help") => Ok(render_skills_usage(None)),
|
||||
Some(args) if is_help_arg(args) => Ok(render_skills_usage(None)),
|
||||
Some(args) => Ok(render_skills_usage(Some(args))),
|
||||
}
|
||||
}
|
||||
@@ -2187,6 +2206,16 @@ fn render_mcp_report_for(
|
||||
cwd: &Path,
|
||||
args: Option<&str>,
|
||||
) -> Result<String, runtime::ConfigError> {
|
||||
if let Some(args) = normalize_optional_args(args) {
|
||||
if let Some(help_path) = help_path_from_args(args) {
|
||||
return Ok(match help_path.as_slice() {
|
||||
[] => render_mcp_usage(None),
|
||||
["show", ..] => render_mcp_usage(Some("show")),
|
||||
_ => render_mcp_usage(Some(&help_path.join(" "))),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
match normalize_optional_args(args) {
|
||||
None | Some("list") => {
|
||||
let runtime_config = loader.load()?;
|
||||
@@ -2195,7 +2224,7 @@ fn render_mcp_report_for(
|
||||
runtime_config.mcp().servers(),
|
||||
))
|
||||
}
|
||||
Some("-h" | "--help" | "help") => Ok(render_mcp_usage(None)),
|
||||
Some(args) if is_help_arg(args) => Ok(render_mcp_usage(None)),
|
||||
Some("show") => Ok(render_mcp_usage(Some("show"))),
|
||||
Some(args) if args.split_whitespace().next() == Some("show") => {
|
||||
let mut parts = args.split_whitespace();
|
||||
@@ -3036,6 +3065,16 @@ fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
|
||||
args.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn is_help_arg(arg: &str) -> bool {
|
||||
matches!(arg, "help" | "-h" | "--help")
|
||||
}
|
||||
|
||||
fn help_path_from_args(args: &str) -> Option<Vec<&str>> {
|
||||
let parts = args.split_whitespace().collect::<Vec<_>>();
|
||||
let help_index = parts.iter().position(|part| is_help_arg(part))?;
|
||||
Some(parts[..help_index].to_vec())
|
||||
}
|
||||
|
||||
fn render_agents_usage(unexpected: Option<&str>) -> String {
|
||||
let mut lines = vec![
|
||||
"Agents".to_string(),
|
||||
@@ -4005,7 +4044,17 @@ mod tests {
|
||||
|
||||
let skills_unexpected =
|
||||
super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage");
|
||||
assert!(skills_unexpected.contains("Unexpected show help"));
|
||||
assert!(skills_unexpected.contains("Unexpected show"));
|
||||
|
||||
let skills_install_help = super::handle_skills_slash_command(Some("install --help"), &cwd)
|
||||
.expect("nested skills help");
|
||||
assert!(skills_install_help.contains("Usage /skills [list|install <path>|help]"));
|
||||
assert!(skills_install_help.contains("Unexpected install"));
|
||||
|
||||
let skills_unknown_help =
|
||||
super::handle_skills_slash_command(Some("show --help"), &cwd).expect("skills help");
|
||||
assert!(skills_unknown_help.contains("Usage /skills [list|install <path>|help]"));
|
||||
assert!(skills_unknown_help.contains("Unexpected show"));
|
||||
|
||||
let _ = fs::remove_dir_all(cwd);
|
||||
}
|
||||
@@ -4022,6 +4071,16 @@ mod tests {
|
||||
super::handle_mcp_slash_command(Some("show alpha beta"), &cwd).expect("mcp usage");
|
||||
assert!(unexpected.contains("Unexpected show alpha beta"));
|
||||
|
||||
let nested_help =
|
||||
super::handle_mcp_slash_command(Some("show --help"), &cwd).expect("mcp help");
|
||||
assert!(nested_help.contains("Usage /mcp [list|show <server>|help]"));
|
||||
assert!(nested_help.contains("Unexpected show"));
|
||||
|
||||
let unknown_help =
|
||||
super::handle_mcp_slash_command(Some("inspect --help"), &cwd).expect("mcp usage");
|
||||
assert!(unknown_help.contains("Usage /mcp [list|show <server>|help]"));
|
||||
assert!(unknown_help.contains("Unexpected inspect"));
|
||||
|
||||
let _ = fs::remove_dir_all(cwd);
|
||||
}
|
||||
|
||||
|
||||
@@ -114,8 +114,12 @@ impl LaneEvent {
|
||||
|
||||
#[must_use]
|
||||
pub fn finished(emitted_at: impl Into<String>, detail: Option<String>) -> Self {
|
||||
Self::new(LaneEventName::Finished, LaneEventStatus::Completed, emitted_at)
|
||||
.with_optional_detail(detail)
|
||||
Self::new(
|
||||
LaneEventName::Finished,
|
||||
LaneEventStatus::Completed,
|
||||
emitted_at,
|
||||
)
|
||||
.with_optional_detail(detail)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -161,19 +165,14 @@ impl LaneEvent {
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass,
|
||||
};
|
||||
use super::{LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass};
|
||||
|
||||
#[test]
|
||||
fn canonical_lane_event_names_serialize_to_expected_wire_values() {
|
||||
let cases = [
|
||||
(LaneEventName::Started, "lane.started"),
|
||||
(LaneEventName::Ready, "lane.ready"),
|
||||
(
|
||||
LaneEventName::PromptMisdelivery,
|
||||
"lane.prompt_misdelivery",
|
||||
),
|
||||
(LaneEventName::PromptMisdelivery, "lane.prompt_misdelivery"),
|
||||
(LaneEventName::Blocked, "lane.blocked"),
|
||||
(LaneEventName::Red, "lane.red"),
|
||||
(LaneEventName::Green, "lane.green"),
|
||||
@@ -193,7 +192,10 @@ mod tests {
|
||||
];
|
||||
|
||||
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 {
|
||||
McpPhaseResult::Failure { phase: failed_phase, error } => {
|
||||
McpPhaseResult::Failure {
|
||||
phase: failed_phase,
|
||||
error,
|
||||
} => {
|
||||
assert_eq!(failed_phase, phase);
|
||||
assert_eq!(error.phase, phase);
|
||||
assert_eq!(
|
||||
|
||||
@@ -360,8 +360,10 @@ impl McpServerManagerError {
|
||||
}
|
||||
|
||||
fn recoverable(&self) -> bool {
|
||||
!matches!(self.lifecycle_phase(), McpLifecyclePhase::InitializeHandshake)
|
||||
&& matches!(self, Self::Transport { .. } | Self::Timeout { .. })
|
||||
!matches!(
|
||||
self.lifecycle_phase(),
|
||||
McpLifecyclePhase::InitializeHandshake
|
||||
) && matches!(self, Self::Transport { .. } | Self::Timeout { .. })
|
||||
}
|
||||
|
||||
fn discovery_failure(&self, server_name: &str) -> McpDiscoveryFailure {
|
||||
@@ -417,10 +419,9 @@ impl McpServerManagerError {
|
||||
("method".to_string(), (*method).to_string()),
|
||||
("timeout_ms".to_string(), timeout_ms.to_string()),
|
||||
]),
|
||||
Self::UnknownTool { qualified_name } => BTreeMap::from([(
|
||||
"qualified_tool".to_string(),
|
||||
qualified_name.clone(),
|
||||
)]),
|
||||
Self::UnknownTool { qualified_name } => {
|
||||
BTreeMap::from([("qualified_tool".to_string(), qualified_name.clone())])
|
||||
}
|
||||
Self::UnknownServer { server_name } => {
|
||||
BTreeMap::from([("server".to_string(), server_name.clone())])
|
||||
}
|
||||
@@ -1425,11 +1426,10 @@ mod tests {
|
||||
use crate::mcp_client::McpClientBootstrap;
|
||||
|
||||
use super::{
|
||||
spawn_mcp_stdio_process, JsonRpcId, JsonRpcRequest, JsonRpcResponse,
|
||||
McpInitializeClientInfo, McpInitializeParams, McpInitializeResult, McpInitializeServerInfo,
|
||||
McpListToolsResult, McpReadResourceParams, McpReadResourceResult, McpServerManager,
|
||||
McpServerManagerError, McpStdioProcess, McpTool, McpToolCallParams,
|
||||
unsupported_server_failed_server,
|
||||
spawn_mcp_stdio_process, unsupported_server_failed_server, JsonRpcId, JsonRpcRequest,
|
||||
JsonRpcResponse, McpInitializeClientInfo, McpInitializeParams, McpInitializeResult,
|
||||
McpInitializeServerInfo, McpListToolsResult, McpReadResourceParams, McpReadResourceResult,
|
||||
McpServerManager, McpServerManagerError, McpStdioProcess, McpTool, McpToolCallParams,
|
||||
};
|
||||
use crate::McpLifecyclePhase;
|
||||
|
||||
@@ -2698,7 +2698,10 @@ mod tests {
|
||||
);
|
||||
assert!(!report.failed_servers[0].recoverable);
|
||||
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")
|
||||
);
|
||||
assert!(report.failed_servers[0].error.contains("initialize"));
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
|
||||
use crate::session::{Session, SessionError};
|
||||
|
||||
pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
|
||||
|
||||
@@ -66,11 +66,7 @@ pub fn validate_packet(packet: TaskPacket) -> Result<ValidatedPacket, TaskPacket
|
||||
&packet.reporting_contract,
|
||||
&mut errors,
|
||||
);
|
||||
validate_required(
|
||||
"escalation_policy",
|
||||
&packet.escalation_policy,
|
||||
&mut errors,
|
||||
);
|
||||
validate_required("escalation_policy", &packet.escalation_policy, &mut errors);
|
||||
|
||||
for (index, test) in packet.acceptance_tests.iter().enumerate() {
|
||||
if test.trim().is_empty() {
|
||||
@@ -146,9 +142,9 @@ mod tests {
|
||||
assert!(error
|
||||
.errors()
|
||||
.contains(&"repo must not be empty".to_string()));
|
||||
assert!(error.errors().contains(
|
||||
&"acceptance_tests contains an empty value at index 1".to_string()
|
||||
));
|
||||
assert!(error
|
||||
.errors()
|
||||
.contains(&"acceptance_tests contains an empty value at index 1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -76,11 +76,7 @@ impl TaskRegistry {
|
||||
}
|
||||
|
||||
pub fn create(&self, prompt: &str, description: Option<&str>) -> Task {
|
||||
self.create_task(
|
||||
prompt.to_owned(),
|
||||
description.map(str::to_owned),
|
||||
None,
|
||||
)
|
||||
self.create_task(prompt.to_owned(), description.map(str::to_owned), None)
|
||||
}
|
||||
|
||||
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 message = match observation.target {
|
||||
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!(
|
||||
"worker prompt landed in the wrong target instead of {}: {}",
|
||||
@@ -312,7 +314,9 @@ impl WorkerRegistry {
|
||||
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.prompt_in_flight = false;
|
||||
if matches!(
|
||||
@@ -412,7 +416,10 @@ impl WorkerRegistry {
|
||||
worker_id: worker.worker_id.clone(),
|
||||
status: worker.status,
|
||||
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(),
|
||||
last_error: worker.last_error.clone(),
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use runtime::Session;
|
||||
use serde_json::Value;
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
@@ -38,64 +37,6 @@ fn status_command_applies_model_and_permission_mode_flags() {
|
||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_command_emits_structured_json_when_requested() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("status-json");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
|
||||
// when
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_claw"))
|
||||
.current_dir(&temp_dir)
|
||||
.args([
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
"--output-format",
|
||||
"json",
|
||||
"status",
|
||||
])
|
||||
.output()
|
||||
.expect("claw should launch");
|
||||
|
||||
// then
|
||||
assert_success(&output);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("status output should be json");
|
||||
assert_eq!(parsed["kind"], "status");
|
||||
assert_eq!(parsed["model"], "claude-sonnet-4-6");
|
||||
assert_eq!(parsed["permission_mode"], "read-only");
|
||||
assert_eq!(parsed["workspace"]["session"], "live-repl");
|
||||
assert!(parsed["sandbox"].is_object());
|
||||
|
||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_command_emits_structured_json_when_requested() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("sandbox-json");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
|
||||
// when
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_claw"))
|
||||
.current_dir(&temp_dir)
|
||||
.args(["--output-format", "json", "sandbox"])
|
||||
.output()
|
||||
.expect("claw should launch");
|
||||
|
||||
// then
|
||||
assert_success(&output);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("sandbox output should be json");
|
||||
assert_eq!(parsed["kind"], "sandbox");
|
||||
assert!(parsed["sandbox"].is_object());
|
||||
assert!(parsed["sandbox"]["requested"].is_object());
|
||||
|
||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_flag_loads_a_saved_session_and_dispatches_status() {
|
||||
// given
|
||||
@@ -219,6 +160,42 @@ fn config_command_loads_defaults_from_standard_config_locations() {
|
||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_help_flags_render_usage_instead_of_falling_through() {
|
||||
let temp_dir = unique_temp_dir("nested-help");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
|
||||
let mcp_output = command_in(&temp_dir)
|
||||
.args(["mcp", "show", "--help"])
|
||||
.output()
|
||||
.expect("claw should launch");
|
||||
assert_success(&mcp_output);
|
||||
let mcp_stdout = String::from_utf8(mcp_output.stdout).expect("stdout should be utf8");
|
||||
assert!(mcp_stdout.contains("Usage /mcp [list|show <server>|help]"));
|
||||
assert!(mcp_stdout.contains("Unexpected show"));
|
||||
assert!(!mcp_stdout.contains("server `--help` is not configured"));
|
||||
|
||||
let skills_output = command_in(&temp_dir)
|
||||
.args(["skills", "install", "--help"])
|
||||
.output()
|
||||
.expect("claw should launch");
|
||||
assert_success(&skills_output);
|
||||
let skills_stdout = String::from_utf8(skills_output.stdout).expect("stdout should be utf8");
|
||||
assert!(skills_stdout.contains("Usage /skills [list|install <path>|help]"));
|
||||
assert!(skills_stdout.contains("Unexpected install"));
|
||||
|
||||
let unknown_output = command_in(&temp_dir)
|
||||
.args(["mcp", "inspect", "--help"])
|
||||
.output()
|
||||
.expect("claw should launch");
|
||||
assert_success(&unknown_output);
|
||||
let unknown_stdout = String::from_utf8(unknown_output.stdout).expect("stdout should be utf8");
|
||||
assert!(unknown_stdout.contains("Usage /mcp [list|show <server>|help]"));
|
||||
assert!(unknown_stdout.contains("Unexpected inspect"));
|
||||
|
||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
fn command_in(cwd: &Path) -> Command {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
|
||||
command.current_dir(cwd);
|
||||
|
||||
@@ -16,7 +16,7 @@ use runtime::{
|
||||
use crate::AgentOutput;
|
||||
|
||||
/// Detects if a lane should be automatically marked as completed.
|
||||
///
|
||||
///
|
||||
/// Returns `Some(LaneContext)` with `completed = true` if all conditions met,
|
||||
/// `None` if lane should remain active.
|
||||
#[allow(dead_code)]
|
||||
@@ -29,29 +29,29 @@ pub(crate) fn detect_lane_completion(
|
||||
if output.error.is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
// Must have finished status
|
||||
if !output.status.eq_ignore_ascii_case("completed")
|
||||
&& !output.status.eq_ignore_ascii_case("finished")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
// Must have no current blocker
|
||||
if output.current_blocker.is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
// Must have green tests
|
||||
if !test_green {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
// Must have pushed code
|
||||
if !has_pushed {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
// All conditions met — create completed context
|
||||
Some(LaneContext {
|
||||
lane_id: output.agent_id.clone(),
|
||||
@@ -67,9 +67,7 @@ pub(crate) fn detect_lane_completion(
|
||||
|
||||
/// Evaluates policy actions for a completed lane.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn evaluate_completed_lane(
|
||||
context: &LaneContext,
|
||||
) -> Vec<PolicyAction> {
|
||||
pub(crate) fn evaluate_completed_lane(context: &LaneContext) -> Vec<PolicyAction> {
|
||||
let engine = PolicyEngine::new(vec![
|
||||
PolicyRule::new(
|
||||
"closeout-completed-lane",
|
||||
@@ -87,7 +85,7 @@ pub(crate) fn evaluate_completed_lane(
|
||||
5,
|
||||
),
|
||||
]);
|
||||
|
||||
|
||||
evaluate(&engine, context)
|
||||
}
|
||||
|
||||
@@ -114,53 +112,53 @@ mod tests {
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn detects_completion_when_all_conditions_met() {
|
||||
let output = test_output();
|
||||
let result = detect_lane_completion(&output, true, true);
|
||||
|
||||
|
||||
assert!(result.is_some());
|
||||
let context = result.unwrap();
|
||||
assert!(context.completed);
|
||||
assert_eq!(context.green_level, 3);
|
||||
assert_eq!(context.blocker, LaneBlocker::None);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn no_completion_when_error_present() {
|
||||
let mut output = test_output();
|
||||
output.error = Some("Build failed".to_string());
|
||||
|
||||
|
||||
let result = detect_lane_completion(&output, true, true);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn no_completion_when_not_finished() {
|
||||
let mut output = test_output();
|
||||
output.status = "Running".to_string();
|
||||
|
||||
|
||||
let result = detect_lane_completion(&output, true, true);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn no_completion_when_tests_not_green() {
|
||||
let output = test_output();
|
||||
|
||||
|
||||
let result = detect_lane_completion(&output, false, true);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn no_completion_when_not_pushed() {
|
||||
let output = test_output();
|
||||
|
||||
|
||||
let result = detect_lane_completion(&output, true, false);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn evaluate_triggers_closeout_for_completed_lane() {
|
||||
let context = LaneContext {
|
||||
@@ -173,9 +171,9 @@ mod tests {
|
||||
completed: true,
|
||||
reconciled: false,
|
||||
};
|
||||
|
||||
|
||||
let actions = evaluate_completed_lane(&context);
|
||||
|
||||
|
||||
assert!(actions.contains(&PolicyAction::CloseoutLane));
|
||||
assert!(actions.contains(&PolicyAction::CleanupSession));
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ use runtime::{
|
||||
permission_enforcer::{EnforcementResult, PermissionEnforcer},
|
||||
read_file,
|
||||
summary_compression::compress_summary_text,
|
||||
TaskPacket,
|
||||
task_registry::TaskRegistry,
|
||||
team_cron_registry::{CronRegistry, TeamRegistry},
|
||||
worker_boot::{WorkerReadySnapshot, WorkerRegistry},
|
||||
@@ -25,7 +24,7 @@ use runtime::{
|
||||
BranchFreshness, ContentBlock, ConversationMessage, ConversationRuntime, GrepSearchInput,
|
||||
LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass,
|
||||
McpDegradedReport, MessageRole, PermissionMode, PermissionPolicy, PromptCacheEvent,
|
||||
RuntimeError, Session, ToolError, ToolExecutor,
|
||||
RuntimeError, Session, TaskPacket, ToolError, ToolExecutor,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
@@ -1878,27 +1877,25 @@ fn branch_divergence_output(
|
||||
dangerously_disable_sandbox: None,
|
||||
return_code_interpretation: Some("preflight_blocked:branch_divergence".to_string()),
|
||||
no_output_expected: Some(false),
|
||||
structured_content: Some(vec![
|
||||
serde_json::to_value(
|
||||
LaneEvent::new(
|
||||
LaneEventName::BranchStaleAgainstMain,
|
||||
LaneEventStatus::Blocked,
|
||||
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")
|
||||
})),
|
||||
structured_content: Some(vec![serde_json::to_value(
|
||||
LaneEvent::new(
|
||||
LaneEventName::BranchStaleAgainstMain,
|
||||
LaneEventStatus::Blocked,
|
||||
iso8601_now(),
|
||||
)
|
||||
.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_size: None,
|
||||
sandbox_status: None,
|
||||
@@ -3297,12 +3294,12 @@ fn persist_agent_terminal_state(
|
||||
next_manifest.current_blocker = blocker.clone();
|
||||
next_manifest.error = error;
|
||||
if let Some(blocker) = blocker {
|
||||
next_manifest.lane_events.push(
|
||||
LaneEvent::blocked(iso8601_now(), &blocker),
|
||||
);
|
||||
next_manifest.lane_events.push(
|
||||
LaneEvent::failed(iso8601_now(), &blocker),
|
||||
);
|
||||
next_manifest
|
||||
.lane_events
|
||||
.push(LaneEvent::blocked(iso8601_now(), &blocker));
|
||||
next_manifest
|
||||
.lane_events
|
||||
.push(LaneEvent::failed(iso8601_now(), &blocker));
|
||||
} else {
|
||||
next_manifest.current_blocker = None;
|
||||
let compressed_detail = result
|
||||
@@ -4952,8 +4949,8 @@ mod tests {
|
||||
agent_permission_policy, allowed_tools_for_subagent, classify_lane_failure,
|
||||
execute_agent_with_spawn, execute_tool, final_assistant_text, mvp_tool_specs,
|
||||
permission_mode_from_plugin, persist_agent_terminal_state, push_output_block,
|
||||
run_task_packet, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName,
|
||||
LaneFailureClass, SubagentToolExecutor,
|
||||
run_task_packet, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass,
|
||||
SubagentToolExecutor,
|
||||
};
|
||||
use api::OutputContentBlock;
|
||||
use runtime::{
|
||||
@@ -5977,7 +5974,10 @@ mod tests {
|
||||
"gateway routing rejected the request",
|
||||
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),
|
||||
];
|
||||
|
||||
@@ -6000,11 +6000,17 @@ mod tests {
|
||||
(LaneEventName::MergeReady, "lane.merge.ready"),
|
||||
(LaneEventName::Finished, "lane.finished"),
|
||||
(LaneEventName::Failed, "lane.failed"),
|
||||
(LaneEventName::BranchStaleAgainstMain, "branch.stale_against_main"),
|
||||
(
|
||||
LaneEventName::BranchStaleAgainstMain,
|
||||
"branch.stale_against_main",
|
||||
),
|
||||
];
|
||||
|
||||
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