feat: real-time tool call streaming indicator with blinking UI
- Add ToolParsingHint enum (Detected/Active/Complete) for UI feedback - New UiWriter methods: print_tool_streaming_hint(), print_tool_streaming_active() - Refactor ConsoleUiWriter state to use atomics in ParsingHintState - Add tool_call_streaming field to CompletionChunk for provider hints - Anthropic provider sends streaming hints when tool name detected - New streaming helpers: make_tool_streaming_hint(), make_tool_streaming_active() Parser improvements: - Add is_json_invalidated() to detect false positive tool patterns - Fix tool result poisoning when file contents contain partial JSON - Unescaped newlines in strings or prose after JSON invalidates detection User sees ' ● tool_name |' immediately when tool call starts streaming, with blinking indicator while args are received.
This commit is contained in:
@@ -1959,6 +1959,17 @@ Skip if nothing new. Be brief."#;
|
||||
);
|
||||
}
|
||||
|
||||
// Handle tool call streaming hint (show UI indicator immediately)
|
||||
if let Some(ref tool_name) = chunk.tool_call_streaming {
|
||||
if tool_name.is_empty() {
|
||||
// Empty string = "active" hint for blinking
|
||||
self.ui_writer.print_tool_streaming_active();
|
||||
} else {
|
||||
// Non-empty = "detected" hint with tool name
|
||||
self.ui_writer.print_tool_streaming_hint(tool_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Store raw chunk for debugging (limit to first 20 and last 5)
|
||||
if chunks_received < 20 || chunk.finished {
|
||||
raw_chunks.push(format!(
|
||||
|
||||
@@ -587,7 +587,8 @@ Some text after"#;
|
||||
finished: true,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
|
||||
let tools = parser.process_chunk(&chunk);
|
||||
|
||||
@@ -67,6 +67,14 @@ pub trait UiWriter: Send + Sync {
|
||||
/// Notify that an SSE event was received (including pings)
|
||||
fn notify_sse_received(&self);
|
||||
|
||||
/// Print a hint that a tool call is being streamed (show indicator immediately)
|
||||
/// This is called when the provider starts receiving a tool call but args are still streaming
|
||||
fn print_tool_streaming_hint(&self, tool_name: &str);
|
||||
|
||||
/// Signal that a tool call is still actively streaming (for blinking indicator)
|
||||
/// This is called periodically while tool args are being received
|
||||
fn print_tool_streaming_active(&self);
|
||||
|
||||
/// Flush any buffered output
|
||||
fn flush(&self);
|
||||
|
||||
@@ -127,6 +135,8 @@ impl UiWriter for NullUiWriter {
|
||||
fn print_agent_prompt(&self) {}
|
||||
fn print_agent_response(&self, _content: &str) {}
|
||||
fn notify_sse_received(&self) {}
|
||||
fn print_tool_streaming_hint(&self, _tool_name: &str) {}
|
||||
fn print_tool_streaming_active(&self) {}
|
||||
fn flush(&self) {}
|
||||
fn finish_streaming_markdown(&self) {}
|
||||
fn wants_full_output(&self) -> bool {
|
||||
|
||||
@@ -14,6 +14,7 @@ fn chunk(content: &str, finished: bool) -> CompletionChunk {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ fn test_has_incomplete_tool_call_no_tool_pattern() {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
parser.process_chunk(&chunk);
|
||||
assert!(!parser.has_incomplete_tool_call());
|
||||
@@ -32,6 +33,7 @@ fn test_has_incomplete_tool_call_complete_tool_call() {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
parser.process_chunk(&chunk);
|
||||
// Complete JSON should NOT be detected as incomplete
|
||||
@@ -48,6 +50,7 @@ fn test_has_incomplete_tool_call_truncated_tool_call() {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
parser.process_chunk(&chunk);
|
||||
// Incomplete JSON should be detected
|
||||
@@ -64,6 +67,7 @@ fn test_has_incomplete_tool_call_truncated_mid_value() {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
parser.process_chunk(&chunk);
|
||||
// Incomplete JSON should be detected
|
||||
@@ -82,6 +86,7 @@ fn test_has_incomplete_tool_call_with_text_before() {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
parser.process_chunk(&chunk);
|
||||
// Incomplete JSON should be detected
|
||||
@@ -99,6 +104,7 @@ fn test_has_incomplete_tool_call_malformed_like_trace() {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
parser.process_chunk(&chunk);
|
||||
// Truncated JSON (missing closing braces) should be detected as incomplete
|
||||
@@ -120,6 +126,7 @@ fn test_has_unexecuted_tool_call_no_tool_pattern() {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
parser.process_chunk(&chunk);
|
||||
assert!(!parser.has_unexecuted_tool_call());
|
||||
@@ -134,6 +141,7 @@ fn test_has_unexecuted_tool_call_complete_tool_call() {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
parser.process_chunk(&chunk);
|
||||
// Complete JSON tool call that wasn't executed should be detected
|
||||
@@ -149,6 +157,7 @@ fn test_has_unexecuted_tool_call_incomplete_json() {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
parser.process_chunk(&chunk);
|
||||
// Incomplete JSON should NOT be detected as unexecuted (it's incomplete, not unexecuted)
|
||||
@@ -167,6 +176,7 @@ Some trailing text after the JSON"#.to_string(),
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
parser.process_chunk(&chunk);
|
||||
// Complete JSON tool call should be detected even with trailing text
|
||||
@@ -186,6 +196,7 @@ I'll execute this command now."#.to_string(),
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
parser.process_chunk(&chunk);
|
||||
// Complete JSON tool call should be detected
|
||||
|
||||
@@ -354,6 +354,7 @@ mod streaming_repro {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ mod streaming_parser_characterization {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
|
||||
let tools = parser.process_chunk(&chunk);
|
||||
@@ -67,6 +68,7 @@ mod streaming_parser_characterization {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
let tools1 = parser.process_chunk(&chunk1);
|
||||
assert!(tools1.is_empty(), "No tool call yet");
|
||||
@@ -78,6 +80,7 @@ mod streaming_parser_characterization {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
let tools2 = parser.process_chunk(&chunk2);
|
||||
assert_eq!(tools2.len(), 1, "Should detect tool call");
|
||||
@@ -101,6 +104,7 @@ mod streaming_parser_characterization {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
|
||||
parser.process_chunk(&chunk);
|
||||
@@ -122,6 +126,7 @@ mod streaming_parser_characterization {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
|
||||
// Process but don't execute
|
||||
@@ -144,6 +149,7 @@ mod streaming_parser_characterization {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
|
||||
let _tools = parser.process_chunk(&chunk);
|
||||
@@ -168,6 +174,7 @@ mod streaming_parser_characterization {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
};
|
||||
|
||||
parser.process_chunk(&chunk);
|
||||
|
||||
@@ -18,6 +18,7 @@ fn chunk(content: &str, finished: bool) -> CompletionChunk {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ impl UiWriter for MockUiWriter {
|
||||
.push(format!("CHOICE: {} Options: {:?}", message, options));
|
||||
self.choice_responses.lock().unwrap().pop().unwrap_or(0)
|
||||
}
|
||||
fn print_tool_streaming_hint(&self, _tool_name: &str) {}
|
||||
fn print_tool_streaming_active(&self) {}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -18,6 +18,7 @@ fn chunk(content: &str, finished: bool) -> CompletionChunk {
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
stop_reason: None,
|
||||
tool_call_streaming: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user