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:
Abeautifulsnow authored and GitHub committed 2026-07-02 18:49:50 +08:00
1 parent d35c966390
commit e7dffeca37
6 files changed
+314 -27

No files matched your search

+93 -5
View File
@@ -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 { .. }));
}
}