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

@@ -22,7 +22,7 @@ import { buildAiContext, runAgentStream, isVectorDbType, type AiAction } from "@
import { formatAiModelOption } from "@/lib/aiModelPresentation";
import type { AgentEvent } from "@/lib/tauri";
import { buildAiAgentPlan } from "@/lib/aiAgentPlan";
import { buildAiAgentStepItems, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/aiAgentStepPresentation";
import { buildAiAgentStepItems, toolCallStepKey, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/aiAgentStepPresentation";
import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/aiCodeHighlighter";
import { createAiMessageRenderer } from "@/lib/aiMessageRender";
import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/aiMarkdown";
@@ -401,17 +401,18 @@ function agentStepIcon(tone: AiAgentStepTone) {
}
function agentStepClass(tone: AiAgentStepTone): string {
const base = "transition-colors duration-200 ease-out motion-safe:transition-colors motion-reduce:transition-none";
switch (tone) {
case "success":
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
return `border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 ${base}`;
case "active":
return "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300";
return `border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300 ${base}`;
case "warning":
return "border-amber-500/35 bg-amber-500/10 text-amber-700 dark:text-amber-300";
return `border-amber-500/35 bg-amber-500/10 text-amber-700 dark:text-amber-300 ${base}`;
case "danger":
return "border-red-500/35 bg-red-500/10 text-red-700 dark:text-red-300";
return `border-red-500/35 bg-red-500/10 text-red-700 dark:text-red-300 ${base}`;
default:
return "border-border bg-background/60 text-muted-foreground";
return `border-border bg-background/60 text-muted-foreground ${base}`;
}
}
@@ -468,21 +469,32 @@ function agentEventToStep(event: AgentEvent, index: number): AiAgentStepItem | u
if (event.type !== "tool_call_start" && event.type !== "tool_call_end") return undefined;
// Use a stable key based on tool_call_id so start and end events map to the same card.
const toolKey = toolCallStepKey(event.tool_call_id, index, event.type);
if (event.type === "tool_call_start") {
return {
key: toolKey,
labelKey: "ai.agentSteps.callingTool",
tone: "active",
toolName: event.tool_name,
toolArgs: event.args as Record<string, unknown>,
};
}
// tool_call_end: produce a final step; toolArgs will be merged from the start step by upsert if missing.
const isExecuteQuery = event.tool_name === "execute_query" || event.tool_name === "dbx_execute_query";
const labelKey = event.type === "tool_call_start" ? "ai.agentSteps.callingTool" : isExecuteQuery ? (event.is_error ? "ai.agentSteps.executeBlocked" : "ai.agentSteps.executeSafe") : event.is_error ? "ai.agentSteps.toolError" : "ai.agentSteps.toolDone";
const tone = (event.type === "tool_call_start" ? "active" : event.is_error ? "danger" : "success") as AiAgentStepTone;
const labelKey = isExecuteQuery ? (event.is_error ? "ai.agentSteps.executeBlocked" : "ai.agentSteps.executeSafe") : event.is_error ? "ai.agentSteps.toolError" : "ai.agentSteps.toolDone";
const tone: AiAgentStepTone = event.is_error ? "danger" : "success";
return {
key: `${event.tool_call_id || ""}-${event.type}`,
key: toolKey,
labelKey,
tone,
titleKey: undefined,
titleParams: { tool: event.tool_name || "" },
toolName: event.tool_name,
toolArgs: event.type === "tool_call_start" ? (event.args as Record<string, unknown>) : undefined,
toolResult: event.type === "tool_call_end" ? extractToolResultContent(event.result) : undefined,
explainData: event.type === "tool_call_end" ? extractExplainData(event.result) : undefined,
isError: event.type === "tool_call_end" ? event.is_error : undefined,
toolResult: extractToolResultContent(event.result),
explainData: extractExplainData(event.result),
isError: event.is_error,
};
}
@@ -794,7 +806,7 @@ async function send() {
if (msg) {
if (!msg.agentSteps) msg.agentSteps = [];
const step = agentEventToStep(event, agentEvents.length - 1);
if (step) msg.agentSteps.push(step);
if (step) upsertAgentStep(msg.agentSteps, step);
}
pendingCompaction.value = { summary: event.summary, compactedMessages: event.compacted_messages };
}
@@ -804,7 +816,7 @@ async function send() {
if (msg) {
if (!msg.agentSteps) msg.agentSteps = [];
const step = agentEventToStep(event, agentEvents.length - 1);
if (step) msg.agentSteps.push(step);
if (step) upsertAgentStep(msg.agentSteps, step);
}
}
scrollToBottom();
@@ -818,9 +830,14 @@ async function send() {
const msg = messages.value[assistantIdx];
if (msg) msg.isThinking = false;
isGenerating.value = false;
// Render agent tool call steps from agent events
// Render agent tool call steps from agent events (fallback when no real-time steps)
if (msg && agentEvents.length > 0 && !msg.agentSteps?.length) {
msg.agentSteps = agentEvents.map((e, index) => agentEventToStep(e, index)).filter((step): step is AiAgentStepItem => Boolean(step));
const steps: AiAgentStepItem[] = [];
agentEvents.forEach((e, index) => {
const step = agentEventToStep(e, index);
if (step) upsertAgentStep(steps, step);
});
if (steps.length) msg.agentSteps = steps;
}
// Fallback: use aiAgentPlan for backward compatibility
if (msg && !msg.agentSteps?.length) {
@@ -1117,7 +1134,7 @@ async function openExternalUrl(url: string) {
</div>
<div v-else-if="msg.content || msg.reasoning || msg.isThinking" class="flex">
<div class="max-w-[95%] rounded-lg bg-muted px-3 py-2 text-xs leading-relaxed">
<div class="max-w-[95%] min-w-0 rounded-lg bg-muted px-3 py-2 text-xs leading-relaxed">
<div v-if="msg.reasoning || msg.isThinking" class="mb-2">
<button class="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors" @click="toggleReasoning(i)">
<ChevronRight class="h-3 w-3 transition-transform duration-200" :class="{ 'rotate-90': expandedReasoning.has(i) || msg.isThinking }" />
@@ -1470,18 +1487,36 @@ async function openExternalUrl(url: string) {
}
.ai-markdown :deep(table) {
border-collapse: collapse;
margin: 0;
width: max-content;
min-width: 100%;
}
.ai-markdown :deep(.ai-markdown-table-wrap) {
overflow-x: auto;
max-height: 320px;
overflow-y: auto;
max-width: 100%;
margin: 0.3em 0;
width: 100%;
border-radius: 0.375rem;
border: 1px solid hsl(var(--border));
}
.ai-markdown :deep(.ai-markdown-table-wrap table) {
border: none;
margin: 0;
}
.ai-markdown :deep(th),
.ai-markdown :deep(td) {
border: 1px solid hsl(var(--border));
padding: 0.25em 0.5em;
text-align: left;
white-space: nowrap;
}
.ai-markdown :deep(th) {
font-weight: 600;
background: hsl(var(--muted));
position: sticky;
top: 0;
z-index: 1;
}
.ai-code-block :deep(.line) {
min-height: 1lh;