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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user