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:
Dhanji R. Prasanna
2026-01-15 12:11:44 +05:30
parent d68f059acf
commit 0ae1a13cdb
18 changed files with 271 additions and 42 deletions

View File

@@ -14,6 +14,7 @@ fn chunk(content: &str, finished: bool) -> CompletionChunk {
tool_calls: None,
usage: None,
stop_reason: None,
tool_call_streaming: None,
}
}

View File

@@ -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

View File

@@ -354,6 +354,7 @@ mod streaming_repro {
tool_calls: None,
usage: None,
stop_reason: None,
tool_call_streaming: None,
}
}

View File

@@ -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);

View File

@@ -18,6 +18,7 @@ fn chunk(content: &str, finished: bool) -> CompletionChunk {
tool_calls: None,
usage: None,
stop_reason: None,
tool_call_streaming: None,
}
}

View File

@@ -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]

View File

@@ -18,6 +18,7 @@ fn chunk(content: &str, finished: bool) -> CompletionChunk {
tool_calls: None,
usage: None,
stop_reason: None,
tool_call_streaming: None,
}
}