fix(ai): tool call status cards, table overflow, streaming TextDelta
* fix(ai): merge tool call status cards
* fix(ai): constrain markdown table overflow
* fix(ai): restore streaming of answer text via TextDelta events
The on_chunk closure was missing live AgentEvent::TextDelta emission for text content, causing the AI answer text to appear all at once after the turn completed. Reasoning deltas were unaffected.
Root cause: commit c30033628 rewrote the on_chunk closure and accidentally dropped the TextDelta emit line while keeping ReasoningDelta intact.
Fix: restore TextDelta emit in on_chunk for incremental streaming, and remove the post-turn full-TextDelta workaround which would duplicate content.
* test(ai): extract chunk_to_events and add streaming event tests
Extract the event-generation logic from the on_chunk closure into a pure function chunk_to_events() to make it testable, and add 6 unit tests covering all chunk content combinations: text-only, reasoning-only, mixed, empty, and edge cases.
This ensures a regression like the one fixed in the previous commit (where TextDelta emission was dropped while ReasoningDelta was kept) would be caught by tests.
This commit is contained in:
1 parent
d35c966390
commit
e7dffeca37
6 files changed
+314
-27
No files matched your search
@@ -27,6 +27,19 @@ fn take_text(m: &std::sync::Mutex<String>) -> String {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
|
||||
/// Convert a streaming AI chunk into agent events for the frontend.
|
||||
/// Pure function — no side effects, easily testable.
|
||||
fn chunk_to_events(chunk: &AiStreamChunk) -> Vec<AgentEvent> {
|
||||
let mut events = Vec::new();
|
||||
if !chunk.delta.is_empty() {
|
||||
events.push(AgentEvent::TextDelta { delta: chunk.delta.clone() });
|
||||
}
|
||||
if let Some(ref reasoning) = chunk.reasoning_delta {
|
||||
events.push(AgentEvent::ReasoningDelta { delta: reasoning.clone() });
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
enum LoopExit {
|
||||
Completed,
|
||||
Cancelled,
|
||||
@@ -196,9 +209,11 @@ pub async fn run_agent_loop(
|
||||
emitted.store(true, Ordering::Relaxed);
|
||||
acc.lock().unwrap_or_else(|e| e.into_inner()).push_str(&chunk.delta);
|
||||
}
|
||||
if let Some(ref reasoning) = chunk.reasoning_delta {
|
||||
if chunk.reasoning_delta.is_some() {
|
||||
emitted.store(true, Ordering::Relaxed);
|
||||
on_event2(AgentEvent::ReasoningDelta { delta: reasoning.clone() });
|
||||
}
|
||||
for event in chunk_to_events(&chunk) {
|
||||
on_event2(event);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -282,9 +297,6 @@ pub async fn run_agent_loop(
|
||||
if collected_tool_calls.is_empty() {
|
||||
match validate_final_answer(task_contract.as_ref(), &accumulated_text) {
|
||||
FinalAnswerCheck::Satisfied => {
|
||||
if !accumulated_text.is_empty() {
|
||||
on_event(AgentEvent::TextDelta { delta: accumulated_text.clone() });
|
||||
}
|
||||
final_text = accumulated_text;
|
||||
loop_exit = LoopExit::Completed;
|
||||
break;
|
||||
@@ -1196,4 +1208,80 @@ mod tests {
|
||||
assert!(wrapped.contains("continue the original user task"));
|
||||
assert!(wrapped.contains("Columns of tb_customer"));
|
||||
}
|
||||
|
||||
// --- chunk_to_events tests ---
|
||||
|
||||
#[test]
|
||||
fn chunk_to_events_emits_text_delta_for_text() {
|
||||
let chunk = AiStreamChunk {
|
||||
session_id: "test".to_string(),
|
||||
delta: "hello".to_string(),
|
||||
reasoning_delta: None,
|
||||
done: false,
|
||||
};
|
||||
let events = chunk_to_events(&chunk);
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(&events[0], AgentEvent::TextDelta { delta } if delta == "hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_to_events_emits_reasoning_delta_for_reasoning() {
|
||||
let chunk = AiStreamChunk {
|
||||
session_id: "test".to_string(),
|
||||
delta: String::new(),
|
||||
reasoning_delta: Some("thinking...".to_string()),
|
||||
done: false,
|
||||
};
|
||||
let events = chunk_to_events(&chunk);
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(&events[0], AgentEvent::ReasoningDelta { delta } if delta == "thinking..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_to_events_emits_both_for_mixed_chunk() {
|
||||
let chunk = AiStreamChunk {
|
||||
session_id: "test".to_string(),
|
||||
delta: "answer".to_string(),
|
||||
reasoning_delta: Some("thinking...".to_string()),
|
||||
done: false,
|
||||
};
|
||||
let events = chunk_to_events(&chunk);
|
||||
assert_eq!(events.len(), 2);
|
||||
assert!(matches!(&events[0], AgentEvent::TextDelta { delta } if delta == "answer"));
|
||||
assert!(matches!(&events[1], AgentEvent::ReasoningDelta { delta } if delta == "thinking..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_to_events_returns_empty_for_empty_chunk() {
|
||||
let chunk =
|
||||
AiStreamChunk { session_id: "test".to_string(), delta: String::new(), reasoning_delta: None, done: false };
|
||||
let events = chunk_to_events(&chunk);
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_to_events_reasoning_only_no_text() {
|
||||
let chunk = AiStreamChunk {
|
||||
session_id: "test".to_string(),
|
||||
delta: String::new(),
|
||||
reasoning_delta: Some("reasoning".to_string()),
|
||||
done: false,
|
||||
};
|
||||
let events = chunk_to_events(&chunk);
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(&events[0], AgentEvent::ReasoningDelta { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_to_events_text_only_no_reasoning() {
|
||||
let chunk = AiStreamChunk {
|
||||
session_id: "test".to_string(),
|
||||
delta: "text only".to_string(),
|
||||
reasoning_delta: None,
|
||||
done: false,
|
||||
};
|
||||
let events = chunk_to_events(&chunk);
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(&events[0], AgentEvent::TextDelta { .. }));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user