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 authored and GitHub committed 2026-07-02 18:49:50 +08:00
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);
}
+16
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>`;
},
},
});