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