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:
parent
d35c966390
commit
e7dffeca37
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { .. }));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue