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 2026-07-02 18:49:50 +08:00 committed by GitHub
parent d35c966390
commit e7dffeca37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 314 additions and 27 deletions

View File

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

View File

@ -26,6 +26,62 @@ describe("formatAiInlineMarkdown", () => {
});
});
describe("markdown table overflow wrapping", () => {
it("wraps tables in scroll container", () => {
const html = formatAiInlineMarkdown("| a | b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |");
expect(html).toContain('<div class="ai-markdown-table-wrap">');
expect(html).toContain("<table>");
expect(html).toContain("<th");
expect(html).toContain("<td");
expect(html).toContain("<tbody>");
});
it("does not inject table wrapper when no table is present", () => {
const html = formatAiInlineMarkdown("Hello **world**.");
expect(html).not.toContain("ai-markdown-table-wrap");
expect(html).not.toContain("<table>");
});
it("preserves table column alignment", () => {
const html = formatAiInlineMarkdown("| a | b | c |\n| :--- | :---: | ---: |\n| 1 | 2 | 3 |");
expect(html).toContain('align="left"');
expect(html).toContain('align="center"');
expect(html).toContain('align="right"');
});
it("handles tables with only headers", () => {
const html = formatAiInlineMarkdown("| a | b |\n| --- | --- |");
expect(html).toContain('<div class="ai-markdown-table-wrap">');
expect(html).toContain("<thead>");
expect(html).toContain("<tbody>");
expect(html).not.toContain("<td");
});
it("handles inline formatting inside table cells", () => {
const html = formatAiInlineMarkdown("| a | b |\n| --- | --- |\n| **bold** | `code` |");
expect(html).toContain("<strong>bold</strong>");
expect(html).toContain("<code");
expect(html).toContain("code</code>");
});
it("handles multi-column, multi-row tables", () => {
const html = formatAiInlineMarkdown(["| a | b | c |", "| -- | -- | -- |", "| 1 | 2 | 3 |", "| 4 | 5 | 6 |"].join("\n"));
expect(html).toContain('<div class="ai-markdown-table-wrap">');
expect(html).toContain("<td>1</td>");
expect(html).toContain("<td>2</td>");
expect(html).toContain("<td>3</td>");
expect(html).toContain("<td>4</td>");
expect(html).toContain("<td>5</td>");
expect(html).toContain("<td>6</td>");
});
});
describe("normalizeAiMarkdownLink", () => {
it("accepts absolute http and https urls", () => {
expect(normalizeAiMarkdownLink("https://example.com/docs")).toBe("https://example.com/docs");

View File

@ -20,6 +20,35 @@ export interface AiAgentStepItem {
explainData?: unknown;
}
/** Backend fallback tool_call_id values that repeat across calls and must not be used as stable merge keys. */
const REPEATING_TOOL_CALL_IDS = new Set(["cli-tool-call"]);
/**
* Build a tool step key. Real tool_call_id values merge start/end into one card;
* missing or known repeating fallback IDs stay event-specific to avoid collapsing unrelated calls.
*/
export function toolCallStepKey(toolCallId: string, index: number, eventType: string): string {
if (toolCallId && !REPEATING_TOOL_CALL_IDS.has(toolCallId)) return `tool-${toolCallId}`;
return `tool-${eventType}-${index}`;
}
/** Upsert a step, preserving details gathered from the previous state of the same card. */
export function upsertAgentStep(steps: AiAgentStepItem[], step: AiAgentStepItem) {
const idx = steps.findIndex((s) => s.key === step.key);
if (idx < 0) {
steps.push(step);
return;
}
const existing = steps[idx];
const merged: AiAgentStepItem = { ...step };
if (!merged.toolArgs && existing.toolArgs) merged.toolArgs = existing.toolArgs;
if (!merged.explainData && existing.explainData) merged.explainData = existing.explainData;
if (!merged.titleKey && existing.titleKey) merged.titleKey = existing.titleKey;
if (!merged.titleParams && existing.titleParams) merged.titleParams = existing.titleParams;
steps.splice(idx, 1, merged);
}
export function buildAiAgentStepItems(plan: AiAgentPlan): AiAgentStepItem[] {
return plan.steps.map(presentStep);
}

View File

@ -29,6 +29,22 @@ const markedInstance = new Marked({
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
return `<a href="${escapeHtml(safeHref)}"${titleAttr} target="_blank" rel="noopener noreferrer">${label}</a>`;
},
table({ header, rows, align }: Tokens.Table) {
const renderCell = (cell: Tokens.TableCell, tag: "th" | "td", colIndex: number): string => {
const content = this.parser.parseInline(cell.tokens);
const alignAttr = align?.[colIndex] ? ` align="${escapeHtml(align[colIndex]!)}"` : "";
return `<${tag}${alignAttr}>${content}</${tag}>`;
};
const thead = header.length > 0 ? `<thead><tr>${header.map((cell, i) => renderCell(cell, "th", i)).join("")}</tr></thead>` : "";
const tbodyRows = rows
.map((row) => {
const cells = row.map((cell, i) => renderCell(cell, "td", i)).join("");
return `<tr>${cells}</tr>`;
})
.join("");
const tbody = `<tbody>${tbodyRows}</tbody>`;
return `<div class="ai-markdown-table-wrap"><table>${thead}${tbody}</table></div>`;
},
},
});

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 { .. }));
}
}

View File

@ -1,6 +1,6 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { buildAiAgentStepItems } from "../../apps/desktop/src/lib/aiAgentStepPresentation.ts";
import { buildAiAgentStepItems, toolCallStepKey, upsertAgentStep, type AiAgentStepItem } from "../../apps/desktop/src/lib/aiAgentStepPresentation.ts";
import type { AiAgentPlan } from "../../apps/desktop/src/lib/aiAgentPlan.ts";
test("presents auto-execute agent plans as completed generation, safety, and execution steps", () => {
@ -136,3 +136,66 @@ test("presents no-sql and no-intent plans as muted skipped states", () => {
["ai.agentSteps.generated", "ai.agentSteps.notRequested"],
);
});
test("uses stable tool step keys only for trustworthy tool call ids", () => {
assert.equal(toolCallStepKey("call-1", 1, "tool_call_start"), "tool-call-1");
assert.equal(toolCallStepKey("call-1", 2, "tool_call_end"), "tool-call-1");
assert.equal(toolCallStepKey("", 1, "tool_call_start"), "tool-tool_call_start-1");
assert.equal(toolCallStepKey("cli-tool-call", 2, "tool_call_end"), "tool-tool_call_end-2");
});
test("upserts tool call steps and preserves start args when final result arrives", () => {
const steps: AiAgentStepItem[] = [
{ key: "context", labelKey: "ai.agentSteps.contextCompacted", tone: "active" },
{
key: "tool-call-1",
labelKey: "ai.agentSteps.callingTool",
tone: "active",
toolName: "list_tables",
toolArgs: { schema: "public" },
},
];
upsertAgentStep(steps, {
key: "tool-call-1",
labelKey: "ai.agentSteps.toolDone",
tone: "success",
toolName: "list_tables",
toolResult: "users\norders",
isError: false,
});
assert.deepEqual(steps, [
{ key: "context", labelKey: "ai.agentSteps.contextCompacted", tone: "active" },
{
key: "tool-call-1",
labelKey: "ai.agentSteps.toolDone",
tone: "success",
toolName: "list_tables",
toolArgs: { schema: "public" },
toolResult: "users\norders",
isError: false,
},
]);
});
test("upserts with splice so array identity and ordering are preserved", () => {
const steps: AiAgentStepItem[] = [
{ key: "before", labelKey: "before", tone: "muted" },
{ key: "tool-call-1", labelKey: "ai.agentSteps.callingTool", tone: "active" },
{ key: "after", labelKey: "after", tone: "muted" },
];
const sameArray = steps;
upsertAgentStep(steps, { key: "tool-call-1", labelKey: "ai.agentSteps.toolError", tone: "danger", isError: true });
assert.equal(steps, sameArray);
assert.deepEqual(
steps.map((step) => [step.key, step.labelKey, step.tone]),
[
["before", "before", "muted"],
["tool-call-1", "ai.agentSteps.toolError", "danger"],
["after", "after", "muted"],
],
);
});