claw-code/rust/crates/rusty-claude-cli/src/execution/stream.rs

187 lines
6.7 KiB
Rust

use crate::*;
use api::*;
use runtime::*;
use std::io::{self, Write};
use std::sync::{Arc, Mutex};
use std::path::Path;
pub(crate) fn response_to_events(
response: MessageResponse,
out: &mut (impl Write + ?Sized),
) -> Result<Vec<AssistantEvent>, RuntimeError> {
let mut events = Vec::new();
let mut pending_tool = None;
for block in response.content {
let mut block_has_thinking_summary = false;
push_output_block(
block,
out,
&mut events,
&mut pending_tool,
false,
&mut block_has_thinking_summary,
)?;
if let Some((id, name, input)) = pending_tool.take() {
events.push(AssistantEvent::ToolUse { id, name, input });
}
}
events.push(AssistantEvent::Usage(response.usage.token_usage()));
events.push(AssistantEvent::MessageStop);
Ok(events)
}
pub(crate) fn push_prompt_cache_record(client: &ApiProviderClient, events: &mut Vec<AssistantEvent>) {
// `ApiProviderClient::take_last_prompt_cache_record` is a pass-through
// to the Anthropic variant and returns `None` for OpenAI-compat /
// xAI variants, which do not have a prompt cache. So this helper
// remains a no-op on non-Anthropic providers without any extra
// branching here.
if let Some(record) = client.take_last_prompt_cache_record() {
if let Some(event) = prompt_cache_record_to_runtime_event(record) {
events.push(AssistantEvent::PromptCache(event));
}
}
}
pub(crate) fn prompt_cache_record_to_runtime_event(
record: api::PromptCacheRecord,
) -> Option<PromptCacheEvent> {
let cache_break = record.cache_break?;
Some(PromptCacheEvent {
unexpected: cache_break.unexpected,
reason: cache_break.reason,
previous_cache_read_input_tokens: cache_break.previous_cache_read_input_tokens,
current_cache_read_input_tokens: cache_break.current_cache_read_input_tokens,
token_drop: cache_break.token_drop,
})
}
/// Returns `true` when the conversation ends with a tool-result message,
/// meaning the model is expected to continue after tool execution.
pub(crate) fn request_ends_with_tool_result(request: &ApiRequest) -> bool {
request
.messages
.last()
.is_some_and(|message| message.role == MessageRole::Tool)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn response_to_events_preserves_empty_object_json_input_outside_streaming() {
let mut out = Vec::new();
let events = response_to_events(
MessageResponse {
id: "msg-1".to_string(),
kind: "message".to_string(),
model: "claude-opus-4-6".to_string(),
role: "assistant".to_string(),
content: vec![OutputContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: json!({}),
}],
stop_reason: Some("tool_use".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
request_id: None,
},
&mut out,
)
.expect("response conversion should succeed");
assert!(matches!(
&events[0],
AssistantEvent::ToolUse { name, input, .. }
if name == "read_file" && input == "{}"
));
}
#[test]
fn response_to_events_preserves_non_empty_json_input_outside_streaming() {
let mut out = Vec::new();
let events = response_to_events(
MessageResponse {
id: "msg-2".to_string(),
kind: "message".to_string(),
model: "claude-opus-4-6".to_string(),
role: "assistant".to_string(),
content: vec![OutputContentBlock::ToolUse {
id: "tool-2".to_string(),
name: "read_file".to_string(),
input: json!({ "path": "rust/Cargo.toml" }),
}],
stop_reason: Some("tool_use".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
request_id: None,
},
&mut out,
)
.expect("response conversion should succeed");
assert!(matches!(
&events[0],
AssistantEvent::ToolUse { name, input, .. }
if name == "read_file" && input == "{\"path\":\"rust/Cargo.toml\"}"
));
}
#[test]
fn response_to_events_renders_collapsed_thinking_summary() {
let mut out = Vec::new();
let events = response_to_events(
MessageResponse {
id: "msg-3".to_string(),
kind: "message".to_string(),
model: "claude-opus-4-6".to_string(),
role: "assistant".to_string(),
content: vec![
OutputContentBlock::Thinking {
thinking: "step 1".to_string(),
signature: Some("sig_123".to_string()),
},
OutputContentBlock::Text {
text: "Final answer".to_string(),
},
],
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
request_id: None,
},
&mut out,
)
.expect("response conversion should succeed");
assert!(matches!(
&events[0],
AssistantEvent::TextDelta(text) if text == "Final answer"
));
let rendered = String::from_utf8(out).expect("utf8");
assert!(rendered.contains("▶ Thinking (6 chars hidden)"));
assert!(!rendered.contains("step 1"));
}
}