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 { formatAiModelOption } from "@/lib/aiModelPresentation";
|
||||||
import type { AgentEvent } from "@/lib/tauri";
|
import type { AgentEvent } from "@/lib/tauri";
|
||||||
import { buildAiAgentPlan } from "@/lib/aiAgentPlan";
|
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 { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/aiCodeHighlighter";
|
||||||
import { createAiMessageRenderer } from "@/lib/aiMessageRender";
|
import { createAiMessageRenderer } from "@/lib/aiMessageRender";
|
||||||
import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/aiMarkdown";
|
import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/aiMarkdown";
|
||||||
@@ -401,17 +401,18 @@ function agentStepIcon(tone: AiAgentStepTone) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function agentStepClass(tone: AiAgentStepTone): string {
|
function agentStepClass(tone: AiAgentStepTone): string {
|
||||||
|
const base = "transition-colors duration-200 ease-out motion-safe:transition-colors motion-reduce:transition-none";
|
||||||
switch (tone) {
|
switch (tone) {
|
||||||
case "success":
|
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":
|
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":
|
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":
|
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:
|
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;
|
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 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 labelKey = 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 tone: AiAgentStepTone = event.is_error ? "danger" : "success";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: `${event.tool_call_id || ""}-${event.type}`,
|
key: toolKey,
|
||||||
labelKey,
|
labelKey,
|
||||||
tone,
|
tone,
|
||||||
titleKey: undefined,
|
|
||||||
titleParams: { tool: event.tool_name || "" },
|
|
||||||
toolName: event.tool_name,
|
toolName: event.tool_name,
|
||||||
toolArgs: event.type === "tool_call_start" ? (event.args as Record<string, unknown>) : undefined,
|
toolResult: extractToolResultContent(event.result),
|
||||||
toolResult: event.type === "tool_call_end" ? extractToolResultContent(event.result) : undefined,
|
explainData: extractExplainData(event.result),
|
||||||
explainData: event.type === "tool_call_end" ? extractExplainData(event.result) : undefined,
|
isError: event.is_error,
|
||||||
isError: event.type === "tool_call_end" ? event.is_error : undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,7 +806,7 @@ async function send() {
|
|||||||
if (msg) {
|
if (msg) {
|
||||||
if (!msg.agentSteps) msg.agentSteps = [];
|
if (!msg.agentSteps) msg.agentSteps = [];
|
||||||
const step = agentEventToStep(event, agentEvents.length - 1);
|
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 };
|
pendingCompaction.value = { summary: event.summary, compactedMessages: event.compacted_messages };
|
||||||
}
|
}
|
||||||
@@ -804,7 +816,7 @@ async function send() {
|
|||||||
if (msg) {
|
if (msg) {
|
||||||
if (!msg.agentSteps) msg.agentSteps = [];
|
if (!msg.agentSteps) msg.agentSteps = [];
|
||||||
const step = agentEventToStep(event, agentEvents.length - 1);
|
const step = agentEventToStep(event, agentEvents.length - 1);
|
||||||
if (step) msg.agentSteps.push(step);
|
if (step) upsertAgentStep(msg.agentSteps, step);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
@@ -818,9 +830,14 @@ async function send() {
|
|||||||
const msg = messages.value[assistantIdx];
|
const msg = messages.value[assistantIdx];
|
||||||
if (msg) msg.isThinking = false;
|
if (msg) msg.isThinking = false;
|
||||||
isGenerating.value = 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) {
|
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
|
// Fallback: use aiAgentPlan for backward compatibility
|
||||||
if (msg && !msg.agentSteps?.length) {
|
if (msg && !msg.agentSteps?.length) {
|
||||||
@@ -1117,7 +1134,7 @@ async function openExternalUrl(url: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="msg.content || msg.reasoning || msg.isThinking" class="flex">
|
<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">
|
<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)">
|
<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 }" />
|
<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) {
|
.ai-markdown :deep(table) {
|
||||||
border-collapse: collapse;
|
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;
|
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(th),
|
||||||
.ai-markdown :deep(td) {
|
.ai-markdown :deep(td) {
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
padding: 0.25em 0.5em;
|
padding: 0.25em 0.5em;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.ai-markdown :deep(th) {
|
.ai-markdown :deep(th) {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: hsl(var(--muted));
|
background: hsl(var(--muted));
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.ai-code-block :deep(.line) {
|
.ai-code-block :deep(.line) {
|
||||||
min-height: 1lh;
|
min-height: 1lh;
|
||||||
|
|||||||
@@ -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", () => {
|
describe("normalizeAiMarkdownLink", () => {
|
||||||
it("accepts absolute http and https urls", () => {
|
it("accepts absolute http and https urls", () => {
|
||||||
expect(normalizeAiMarkdownLink("https://example.com/docs")).toBe("https://example.com/docs");
|
expect(normalizeAiMarkdownLink("https://example.com/docs")).toBe("https://example.com/docs");
|
||||||
|
|||||||
@@ -20,6 +20,35 @@ export interface AiAgentStepItem {
|
|||||||
explainData?: unknown;
|
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[] {
|
export function buildAiAgentStepItems(plan: AiAgentPlan): AiAgentStepItem[] {
|
||||||
return plan.steps.map(presentStep);
|
return plan.steps.map(presentStep);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,22 @@ const markedInstance = new Marked({
|
|||||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
||||||
return `<a href="${escapeHtml(safeHref)}"${titleAttr} target="_blank" rel="noopener noreferrer">${label}</a>`;
|
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>`;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ fn take_text(m: &std::sync::Mutex<String>) -> String {
|
|||||||
m.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
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 {
|
enum LoopExit {
|
||||||
Completed,
|
Completed,
|
||||||
Cancelled,
|
Cancelled,
|
||||||
@@ -196,9 +209,11 @@ pub async fn run_agent_loop(
|
|||||||
emitted.store(true, Ordering::Relaxed);
|
emitted.store(true, Ordering::Relaxed);
|
||||||
acc.lock().unwrap_or_else(|e| e.into_inner()).push_str(&chunk.delta);
|
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);
|
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() {
|
if collected_tool_calls.is_empty() {
|
||||||
match validate_final_answer(task_contract.as_ref(), &accumulated_text) {
|
match validate_final_answer(task_contract.as_ref(), &accumulated_text) {
|
||||||
FinalAnswerCheck::Satisfied => {
|
FinalAnswerCheck::Satisfied => {
|
||||||
if !accumulated_text.is_empty() {
|
|
||||||
on_event(AgentEvent::TextDelta { delta: accumulated_text.clone() });
|
|
||||||
}
|
|
||||||
final_text = accumulated_text;
|
final_text = accumulated_text;
|
||||||
loop_exit = LoopExit::Completed;
|
loop_exit = LoopExit::Completed;
|
||||||
break;
|
break;
|
||||||
@@ -1196,4 +1208,80 @@ mod tests {
|
|||||||
assert!(wrapped.contains("continue the original user task"));
|
assert!(wrapped.contains("continue the original user task"));
|
||||||
assert!(wrapped.contains("Columns of tb_customer"));
|
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 { .. }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { strict as assert } from "node:assert";
|
import { strict as assert } from "node:assert";
|
||||||
import { test } from "vitest";
|
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";
|
import type { AiAgentPlan } from "../../apps/desktop/src/lib/aiAgentPlan.ts";
|
||||||
|
|
||||||
test("presents auto-execute agent plans as completed generation, safety, and execution steps", () => {
|
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"],
|
["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"],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user