deepdata: core product code (backend, frontend, runtime, deepflow module)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
module.exports = 'test-file-stub';
|
||||
@@ -0,0 +1,19 @@
|
||||
// Mock store for testing without ESM module dependencies
|
||||
|
||||
export const mockUseStore = {
|
||||
getState: jest.fn(() => ({
|
||||
responding: false,
|
||||
messageIds: [] as string[],
|
||||
messages: new Map(),
|
||||
researchIds: [] as string[],
|
||||
researchPlanIds: new Map(),
|
||||
researchReportIds: new Map(),
|
||||
researchActivityIds: new Map(),
|
||||
ongoingResearchId: null,
|
||||
openResearchId: null,
|
||||
appendMessage: jest.fn(),
|
||||
updateMessage: jest.fn(),
|
||||
updateMessages: jest.fn(),
|
||||
})),
|
||||
setState: jest.fn(),
|
||||
};
|
||||
@@ -0,0 +1,442 @@
|
||||
import { parseJSON } from "../src/core/utils/json";
|
||||
|
||||
describe("parseJSON - extractValidJSON helper", () => {
|
||||
it("extracts JSON object with extra tokens after closing brace", () => {
|
||||
const input = '{"key": "value"} extra tokens here';
|
||||
const result = parseJSON(input, null);
|
||||
expect(result.key).toBe("value");
|
||||
});
|
||||
|
||||
it("extracts JSON array with extra tokens after closing bracket", () => {
|
||||
const input = '[1, 2, 3] garbage data here';
|
||||
const result = parseJSON(input, []);
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("handles nested JSON with extra tokens", () => {
|
||||
const input = '{"nested": {"inner": [1, 2, 3]}} invalid text';
|
||||
const result = parseJSON(input, null);
|
||||
expect(result).toEqual({
|
||||
nested: {
|
||||
inner: [1, 2, 3],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("handles JSON with strings containing braces", () => {
|
||||
const input = '{"text": "this has {braces} in it"} extra';
|
||||
const result = parseJSON(input, null);
|
||||
expect(result.text).toBe("this has {braces} in it");
|
||||
});
|
||||
|
||||
it("handles JSON with escaped quotes in strings", () => {
|
||||
const input = '{"text": "quote \\"here\\""} junk';
|
||||
const result = parseJSON(input, null);
|
||||
expect(result.text).toBe('quote "here"');
|
||||
});
|
||||
|
||||
it("handles clean JSON without extra tokens", () => {
|
||||
const input = '{"key": "value"}';
|
||||
const result = parseJSON(input, null);
|
||||
expect(result.key).toBe("value");
|
||||
});
|
||||
|
||||
it("handles empty object", () => {
|
||||
const input = '{} extra';
|
||||
const result = parseJSON(input, {});
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("handles empty array", () => {
|
||||
const input = '[] more stuff';
|
||||
const result = parseJSON(input, []);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles JSON with null values", () => {
|
||||
const input = '{"value": null} trash';
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.value).toBeNull();
|
||||
});
|
||||
|
||||
it("handles JSON with boolean values", () => {
|
||||
const input = '{"active": true, "deleted": false} garbage';
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.active).toBe(true);
|
||||
expect(result.deleted).toBe(false);
|
||||
});
|
||||
|
||||
it("handles JSON with numbers", () => {
|
||||
const input = '{"int": 42, "float": 3.14, "negative": -7} data';
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.int).toBe(42);
|
||||
expect(result.float).toBe(3.14);
|
||||
expect(result.negative).toBe(-7);
|
||||
});
|
||||
|
||||
it("handles JSON with unicode characters", () => {
|
||||
const input = '{"name": "测试", "emoji": "🎯"} extra';
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.name).toBe("测试");
|
||||
expect(result.emoji).toBe("🎯");
|
||||
});
|
||||
|
||||
it("handles multiple levels of nesting", () => {
|
||||
const input = '{"a": {"b": {"c": {"d": "value"}}}} junk';
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.a.b.c.d).toBe("value");
|
||||
});
|
||||
|
||||
it("handles arrays of objects", () => {
|
||||
const input = '[{"id": 1, "name": "test1"}, {"id": 2, "name": "test2"}] garbage';
|
||||
const result = parseJSON(input, []);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].id).toBe(1);
|
||||
expect(result[1].name).toBe("test2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJSON - with code block markers", () => {
|
||||
it("strips json code block markers", () => {
|
||||
const input = '```json\n{"key": "value"}\n```';
|
||||
const result = parseJSON(input, null);
|
||||
expect(result.key).toBe("value");
|
||||
});
|
||||
|
||||
it("strips js code block markers", () => {
|
||||
const input = '```js\n{"key": "value"}\n```';
|
||||
const result = parseJSON(input, null);
|
||||
expect(result.key).toBe("value");
|
||||
});
|
||||
|
||||
it("strips ts code block markers", () => {
|
||||
const input = '```ts\n{"key": "value"}\n```';
|
||||
const result = parseJSON(input, null);
|
||||
expect(result.key).toBe("value");
|
||||
});
|
||||
|
||||
it("strips plaintext code block markers", () => {
|
||||
const input = '```plaintext\n{"key": "value"}\n```';
|
||||
const result = parseJSON(input, null);
|
||||
expect(result.key).toBe("value");
|
||||
});
|
||||
|
||||
it("strips generic code block markers", () => {
|
||||
const input = '```\n{"key": "value"}\n```';
|
||||
const result = parseJSON(input, null);
|
||||
expect(result.key).toBe("value");
|
||||
});
|
||||
|
||||
it("handles code block without closing marker", () => {
|
||||
const input = '```json\n{"key": "value"}';
|
||||
const result = parseJSON(input, null);
|
||||
expect(result.key).toBe("value");
|
||||
});
|
||||
|
||||
it("handles code block with extra whitespace", () => {
|
||||
const input = '```json \n{"key": "value"}\n``` ';
|
||||
const result = parseJSON(input, null);
|
||||
expect(result.key).toBe("value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJSON - issue #598 specific cases", () => {
|
||||
it("handles JSON with extra tokens from quantized models", () => {
|
||||
// This is similar to what Qwen3 235B returns
|
||||
const input =
|
||||
'{"text": "Published: 2010-01-07\\nTitle: Photon Counting OTDR", "data": "Published:", "reminding": " 2010-01-07\\nTitle: Photon"} some garbage tokens';
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.text).toBeTruthy();
|
||||
expect(result.text).toContain("Published");
|
||||
expect(result.data).toBeTruthy();
|
||||
expect(result.reminding).toBeTruthy();
|
||||
});
|
||||
|
||||
it("handles search results JSON with extra tokens", () => {
|
||||
const input = `[
|
||||
{"type": "page", "title": "Example", "url": "https://example.com", "content": "Example content"},
|
||||
{"type": "page", "title": "Test", "url": "https://test.com", "content": "Test content"}
|
||||
] trailing garbage`;
|
||||
const result = parseJSON(input, []);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].type).toBe("page");
|
||||
expect(result[1].title).toBe("Test");
|
||||
});
|
||||
|
||||
it("handles crawler response with extra tokens", () => {
|
||||
const input = `{
|
||||
"title": "Article Title",
|
||||
"content": "Article content here..."
|
||||
} [incomplete json or garbage`;
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.title).toBe("Article Title");
|
||||
expect(result.content).toContain("Article content");
|
||||
});
|
||||
|
||||
it("handles non-JSON content gracefully", () => {
|
||||
const input = "This is just plain text, not JSON";
|
||||
const fallback = { default: true };
|
||||
const result = parseJSON(input, fallback);
|
||||
// best-effort-json-parser may parse plain text as key-value pairs
|
||||
// Just ensure we get some result (not throwing an error)
|
||||
expect(result).toBeDefined();
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns fallback for null input", () => {
|
||||
const fallback = [{ default: true }];
|
||||
const result = parseJSON(null, fallback);
|
||||
expect(result).toEqual(fallback);
|
||||
});
|
||||
|
||||
it("returns fallback for undefined input", () => {
|
||||
const fallback = [];
|
||||
const result = parseJSON(undefined, fallback);
|
||||
expect(result).toEqual(fallback);
|
||||
});
|
||||
|
||||
it("returns fallback for empty string input", () => {
|
||||
const fallback = {};
|
||||
const result = parseJSON("", fallback);
|
||||
expect(result).toEqual(fallback);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJSON - edge cases", () => {
|
||||
it("handles JSON with special characters in strings", () => {
|
||||
const input = '{"text": "Special chars: @#$%^&*()"} extra';
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.text).toBe("Special chars: @#$%^&*()");
|
||||
});
|
||||
|
||||
it("handles JSON with newlines in strings", () => {
|
||||
const input = '{"text": "Line 1\\nLine 2\\nLine 3"} junk';
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.text).toContain("Line");
|
||||
});
|
||||
|
||||
it("handles JSON with tabs in strings", () => {
|
||||
const input = '{"text": "Col1\\tCol2\\tCol3"} trash';
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.text).toContain("Col");
|
||||
});
|
||||
|
||||
it("handles deeply nested objects", () => {
|
||||
const input = '{"a":{"b":{"c":{"d":{"e":{"f":"deep"}}}}}}} extra';
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.a.b.c.d.e.f).toBe("deep");
|
||||
});
|
||||
|
||||
it("handles large arrays", () => {
|
||||
const largeArray = Array.from({ length: 100 }, (_, i) => ({ id: i }));
|
||||
const input = JSON.stringify(largeArray) + " garbage text";
|
||||
const result = parseJSON(input, []);
|
||||
expect(result.length).toBe(100);
|
||||
expect(result[99].id).toBe(99);
|
||||
});
|
||||
|
||||
it("handles whitespace in JSON", () => {
|
||||
const input = `{
|
||||
"key" : "value" ,
|
||||
"number" : 42
|
||||
} extra`;
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.key).toBe("value");
|
||||
expect(result.number).toBe(42);
|
||||
});
|
||||
|
||||
it("handles JSON with escaped slashes", () => {
|
||||
const input = '{"url": "https:\\/\\/example.com"} junk';
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.url).toContain("example.com");
|
||||
});
|
||||
|
||||
it("preserves numeric precision", () => {
|
||||
const input = '{"value": 1.23456789} extra';
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.value).toBe(1.23456789);
|
||||
});
|
||||
|
||||
it("handles JSON with very long strings", () => {
|
||||
const longString = "A".repeat(10000);
|
||||
const input = `{"text": "${longString}"} garbage`;
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.text.length).toBe(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJSON - type safety", () => {
|
||||
it("properly types object results", () => {
|
||||
interface TestObject {
|
||||
id: number;
|
||||
name: string;
|
||||
active: boolean;
|
||||
}
|
||||
const input = '{"id": 1, "name": "test", "active": true} junk';
|
||||
const fallback: TestObject = { id: 0, name: "", active: false };
|
||||
const result = parseJSON<TestObject>(input, fallback);
|
||||
expect(result.id).toBe(1);
|
||||
expect(result.name).toBe("test");
|
||||
expect(result.active).toBe(true);
|
||||
});
|
||||
|
||||
it("properly types array results", () => {
|
||||
interface Item {
|
||||
id: number;
|
||||
label: string;
|
||||
}
|
||||
const input = '[{"id": 1, "label": "a"}, {"id": 2, "label": "b"}] extra';
|
||||
const fallback: Item[] = [];
|
||||
const result = parseJSON<Item[]>(input, fallback);
|
||||
expect(result[0].id).toBe(1);
|
||||
expect(result[1].label).toBe("b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJSON - malformed JSON recovery", () => {
|
||||
it("handles missing closing braces", () => {
|
||||
const input = '{"key": "value"';
|
||||
const result = parseJSON(input, { key: "default" });
|
||||
// Should return something (either fixed JSON or fallback)
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles extra closing braces", () => {
|
||||
const input = '{"key": "value"}}}';
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.key).toBe("value");
|
||||
});
|
||||
|
||||
it("handles mixed quotes", () => {
|
||||
const input = '{"key": "value"} extra';
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.key).toBe("value");
|
||||
});
|
||||
|
||||
it("handles unquoted keys (not valid JSON, uses fallback)", () => {
|
||||
const input = "{key: 'value'} extra";
|
||||
const fallback = { key: "default" };
|
||||
const result = parseJSON(input, fallback);
|
||||
// Should return something
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJSON - real-world scenarios", () => {
|
||||
it("handles Tavily search results format", () => {
|
||||
const input = `[
|
||||
{
|
||||
"type": "page",
|
||||
"title": "Sample Article",
|
||||
"url": "https://example.com/article",
|
||||
"content": "This is sample content..."
|
||||
}
|
||||
] processing complete`;
|
||||
const result = parseJSON(input, []);
|
||||
expect(result[0].type).toBe("page");
|
||||
expect(result[0].title).toBe("Sample Article");
|
||||
});
|
||||
|
||||
it("handles crawler article format", () => {
|
||||
const input = `{
|
||||
"title": "News Article",
|
||||
"content": "Article body text...",
|
||||
"author": "John Doe",
|
||||
"date": "2024-01-01"
|
||||
} [incomplete extra`;
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.title).toBe("News Article");
|
||||
expect(result.content).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles local search tool results", () => {
|
||||
const input = `[
|
||||
{
|
||||
"id": "doc-1",
|
||||
"title": "Document 1",
|
||||
"content": "Document content here"
|
||||
},
|
||||
{
|
||||
"id": "doc-2",
|
||||
"title": "Document 2",
|
||||
"content": "Another document"
|
||||
}
|
||||
] extra garbage`;
|
||||
const result = parseJSON(input, []);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].id).toBe("doc-1");
|
||||
});
|
||||
|
||||
it("handles Python REPL output with JSON", () => {
|
||||
const input = `{"result": 42, "error": null, "stdout": "Output here"} [process ended]`;
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.result).toBe(42);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it("handles MCP tool response format", () => {
|
||||
const input = `{
|
||||
"tool": "web_search",
|
||||
"status": "success",
|
||||
"data": [{"title": "Result", "url": "https://example.com"}]
|
||||
} additional text`;
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.tool).toBe("web_search");
|
||||
expect(result.data[0].title).toBe("Result");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJSON - issue #598 regression tests", () => {
|
||||
it("does not lose data when removing extra tokens", () => {
|
||||
const input = `{
|
||||
"research": "Complete research data here with lots of information",
|
||||
"sources": [
|
||||
{"title": "Source 1", "url": "https://source1.com"},
|
||||
{"title": "Source 2", "url": "https://source2.com"}
|
||||
]
|
||||
} garbage tokens that should be removed`;
|
||||
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.research).toBeDefined();
|
||||
expect(result.sources.length).toBe(2);
|
||||
expect(result.sources[0].title).toBe("Source 1");
|
||||
});
|
||||
|
||||
it("handles consecutive tool calls with JSON", () => {
|
||||
const firstResult = '{"step": 1, "data": "first"} extra';
|
||||
const secondResult = '{"step": 2, "data": "second"} junk';
|
||||
|
||||
const result1 = parseJSON(firstResult, {});
|
||||
const result2 = parseJSON(secondResult, {});
|
||||
|
||||
expect(result1.step).toBe(1);
|
||||
expect(result2.step).toBe(2);
|
||||
});
|
||||
|
||||
it("maintains performance with large responses", () => {
|
||||
const largeContent = "A".repeat(50000);
|
||||
const input = `{"content": "${largeContent}", "status": "ok"} extra data`;
|
||||
|
||||
const startTime = Date.now();
|
||||
const result = parseJSON(input, {});
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(result.content).toBeDefined();
|
||||
expect(result.status).toBe("ok");
|
||||
// Should complete quickly (< 2 seconds for this size)
|
||||
expect(duration).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
it("handles multiple consecutive extra tokens", () => {
|
||||
const input =
|
||||
'{"data": "value"}} } ] unexpected tokens here } { [ ) ] incomplete';
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.data).toBe("value");
|
||||
});
|
||||
|
||||
it("handles unicode garbage after JSON", () => {
|
||||
const input = '{"text": "测试"} 乱码数据 🎯 garbage';
|
||||
const result = parseJSON(input, {});
|
||||
expect(result.text).toBe("测试");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import katex from "katex";
|
||||
|
||||
import { katexOptions } from "../src/core/markdown/katex";
|
||||
|
||||
function render(expression: string) {
|
||||
return katex.renderToString(expression, {
|
||||
...katexOptions,
|
||||
displayMode: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("markdown physics katex support", () => {
|
||||
it("renders vector calculus operators", () => {
|
||||
expect(() => {
|
||||
render("\\curl{\\vect{B}} = \\mu_0 \\vect{J} + \\mu_0 \\varepsilon_0 \\pdv{\\vect{E}}{t}");
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("renders quantum mechanics bra-ket notation", () => {
|
||||
const html = render("\\braket{\\psi}{\\phi}");
|
||||
expect(html.includes("⟨") && html.includes("⟩")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders vector magnitude formula with subscripts and square root", () => {
|
||||
const html = render("(F_1) (F_2), (F=\\sqrt{F_1^2+F_2^2})");
|
||||
expect(html.includes("F")).toBeTruthy();
|
||||
expect(html.includes("₁") || html.includes("sub")).toBeTruthy(); // subscript check
|
||||
expect(html.includes("√") || html.includes("sqrt")).toBeTruthy(); // square root check
|
||||
});
|
||||
|
||||
it("renders chemical equations via mhchem", () => {
|
||||
expect(() => {
|
||||
render("\\ce{H2O ->[\\Delta] H+ + OH-}");
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { normalizeMathForEditor, normalizeMathForDisplay, unescapeLatexInMath } from "../src/core/utils/markdown";
|
||||
|
||||
describe("markdown math normalization for editor", () => {
|
||||
it("converts LaTeX display delimiters to $$ for editor", () => {
|
||||
const input = "Here is a formula \\[E=mc^2\\] in the text.";
|
||||
const output = normalizeMathForEditor(input);
|
||||
expect(output).toBe("Here is a formula $$E=mc^2$$ in the text.");
|
||||
});
|
||||
|
||||
it("converts LaTeX display delimiters to $ with \\ for editor", () => {
|
||||
const input = "Here is a formula \\(F = k\\frac{q_1q_2}{r^2}\\) in the text.";
|
||||
const output = normalizeMathForEditor(input);
|
||||
expect(output).toBe("Here is a formula $F = k\\frac{q_1q_2}{r^2}$ in the text.");
|
||||
});
|
||||
|
||||
it("converts LaTeX display delimiters to $ with \\\\ for editor", () => {
|
||||
const input = "Here is a formula \\(F = k\\\\frac{q_1q_2}{r^2}\\) in the text.";
|
||||
const output = normalizeMathForEditor(input);
|
||||
expect(output).toBe("Here is a formula $F = k\\frac{q_1q_2}{r^2}$ in the text.");
|
||||
});
|
||||
|
||||
it("converts escaped LaTeX display delimiters to $$ for editor", () => {
|
||||
const input = "Formula \\\\[x^2 + y^2 = z^2\\\\] here.";
|
||||
const output = normalizeMathForEditor(input);
|
||||
expect(output).toBe("Formula $$x^2 + y^2 = z^2$$ here.");
|
||||
});
|
||||
|
||||
it("converts LaTeX inline delimiters to $ for editor", () => {
|
||||
const input = "Inline formula \\(a + b = c\\) in text.";
|
||||
const output = normalizeMathForEditor(input);
|
||||
expect(output).toBe("Inline formula $a + b = c$ in text.");
|
||||
});
|
||||
|
||||
it("converts escaped LaTeX inline delimiters to $ for editor", () => {
|
||||
const input = "Inline \\\\(x = 5\\\\) here.";
|
||||
const output = normalizeMathForEditor(input);
|
||||
expect(output).toBe("Inline $x = 5$ here.");
|
||||
});
|
||||
|
||||
it("handles mixed delimiters for editor", () => {
|
||||
const input = "Display \\[E=mc^2\\] and inline \\(F=ma\\) formulas.";
|
||||
const output = normalizeMathForEditor(input);
|
||||
expect(output).toBe("Display $$E=mc^2$$ and inline $F=ma$ formulas.");
|
||||
});
|
||||
|
||||
it("preserves already normalized math syntax for editor", () => {
|
||||
const input = "Already normalized $$E=mc^2$$ and $F=ma$ formulas.";
|
||||
const output = normalizeMathForEditor(input);
|
||||
expect(output).toBe("Already normalized $$E=mc^2$$ and $F=ma$ formulas.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdown math normalization for display", () => {
|
||||
it("converts LaTeX display delimiters to $$ for display", () => {
|
||||
const input = "Here is a formula \\[E=mc^2\\] in the text.";
|
||||
const output = normalizeMathForDisplay(input);
|
||||
expect(output).toBe("Here is a formula $$E=mc^2$$ in the text.");
|
||||
});
|
||||
|
||||
it("converts escaped LaTeX display delimiters to $$ for display", () => {
|
||||
const input = "Formula \\\\[x^2 + y^2 = z^2\\\\] here.";
|
||||
const output = normalizeMathForDisplay(input);
|
||||
expect(output).toBe("Formula $$x^2 + y^2 = z^2$$ here.");
|
||||
});
|
||||
|
||||
it("converts LaTeX inline delimiters to $$ for display", () => {
|
||||
const input = "Inline formula \\(a + b = c\\) in text.";
|
||||
const output = normalizeMathForDisplay(input);
|
||||
expect(output).toBe("Inline formula $$a + b = c$$ in text.");
|
||||
});
|
||||
|
||||
it("converts escaped LaTeX inline delimiters to $$ for display", () => {
|
||||
const input = "Inline \\\\(x = 5\\\\) here.";
|
||||
const output = normalizeMathForDisplay(input);
|
||||
expect(output).toBe("Inline $$x = 5$$ here.");
|
||||
});
|
||||
|
||||
it("handles mixed delimiters for display", () => {
|
||||
const input = "Display \\[E=mc^2\\] and inline \\(F=ma\\) formulas.";
|
||||
const output = normalizeMathForDisplay(input);
|
||||
expect(output).toBe("Display $$E=mc^2$$ and inline $$F=ma$$ formulas.");
|
||||
});
|
||||
|
||||
it("handles complex physics formulas", () => {
|
||||
const input = "Maxwell equation: \\[\\nabla \\times \\vec{E} = -\\frac{\\partial \\vec{B}}{\\partial t}\\]";
|
||||
const output = normalizeMathForDisplay(input);
|
||||
expect(output.includes("$$")).toBeTruthy();
|
||||
expect(output.includes("nabla")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdown math round-trip consistency", () => {
|
||||
it("handles editor normalization consistently", () => {
|
||||
const original = "Formula \\[E=mc^2\\] and \\(F=ma\\)";
|
||||
const forEditor = normalizeMathForEditor(original);
|
||||
|
||||
// Simulate editor output (should have $ and $$)
|
||||
expect(forEditor.includes("$$")).toBeTruthy();
|
||||
expect(forEditor.includes("$")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("handles multiple formulas correctly", () => {
|
||||
const input = `
|
||||
# Physics Formulas
|
||||
|
||||
Energy: \\[E = mc^2\\]
|
||||
|
||||
Force: \\(F = ma\\)
|
||||
|
||||
Momentum: \\[p = mv\\]
|
||||
`;
|
||||
|
||||
const forEditor = normalizeMathForEditor(input);
|
||||
const forDisplay = normalizeMathForDisplay(input);
|
||||
|
||||
// Both should have converted the delimiters
|
||||
expect(forEditor.includes("$$")).toBeTruthy();
|
||||
expect(forDisplay.includes("$$")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("preserves text content around formulas", () => {
|
||||
const input = "Text before \\[E=mc^2\\] text after";
|
||||
const output = normalizeMathForEditor(input);
|
||||
|
||||
expect(output.startsWith("Text before")).toBeTruthy();
|
||||
expect(output.endsWith("text after")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdown math unescape (issue #608 fix)", () => {
|
||||
it("unescapes asterisks in inline math", () => {
|
||||
const escaped = "Formula $(f \\* g)(t) = t^2$";
|
||||
const unescaped = unescapeLatexInMath(escaped);
|
||||
expect(unescaped).toBe("Formula $(f * g)(t) = t^2$");
|
||||
});
|
||||
|
||||
it("unescapes underscores in display math", () => {
|
||||
const escaped = "Formula $$x\\_{n+1} = x_n - f(x_n)/f'(x_n)$$";
|
||||
const unescaped = unescapeLatexInMath(escaped);
|
||||
expect(unescaped).toBe("Formula $$x_{n+1} = x_n - f(x_n)/f'(x_n)$$");
|
||||
});
|
||||
|
||||
it("unescapes backslashes for LaTeX commands", () => {
|
||||
const escaped = "Formula $$\\\\int_{-\\\\infty}^{\\\\infty} f(x)dx$$";
|
||||
const unescaped = unescapeLatexInMath(escaped);
|
||||
expect(unescaped).toBe("Formula $$\\int_{-\\infty}^{\\infty} f(x)dx$$");
|
||||
});
|
||||
|
||||
it("unescapes square brackets in math", () => {
|
||||
const escaped = "Array $a\\[0\\] = b$ and $$c\\[n\\] = d$$";
|
||||
const unescaped = unescapeLatexInMath(escaped);
|
||||
expect(unescaped).toBe("Array $a[0] = b$ and $$c[n] = d$$");
|
||||
});
|
||||
|
||||
it("handles complex formula from issue #608", () => {
|
||||
const escaped = `| Discrete | $(f \\* g)\\[n\\] = \\\\sum\\_{k=-\\\\infty}^{\\\\infty} f\\[k\\]g\\[n-k\\]$ |`;
|
||||
const unescaped = unescapeLatexInMath(escaped);
|
||||
// Should unescape special characters within math delimiters
|
||||
expect(unescaped.includes("(f * g)")).toBeTruthy();
|
||||
expect(unescaped.includes("[n]")).toBeTruthy();
|
||||
expect(unescaped.includes("\\sum")).toBeTruthy();
|
||||
expect(unescaped.includes("_{k")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("preserves text outside math delimiters", () => {
|
||||
const escaped = "Before $a \\* b$ middle $$c \\* d$$ after";
|
||||
const unescaped = unescapeLatexInMath(escaped);
|
||||
expect(unescaped.startsWith("Before")).toBeTruthy();
|
||||
expect(unescaped.endsWith("after")).toBeTruthy();
|
||||
expect(unescaped.includes("middle")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("handles mixed escaped and unescaped characters", () => {
|
||||
const escaped = "$$f(x) = \\\\int_0^\\\\infty e^{-x^2} \\* dx$$";
|
||||
const unescaped = unescapeLatexInMath(escaped);
|
||||
expect(unescaped).toBe("$$f(x) = \\int_0^\\infty e^{-x^2} * dx$$");
|
||||
});
|
||||
|
||||
it("handles multiple inline formulas", () => {
|
||||
const escaped = "Formulas $a \\* b$ and $c \\* d$ and $e \\* f$";
|
||||
const unescaped = unescapeLatexInMath(escaped);
|
||||
const matches = unescaped.match(/\* /g);
|
||||
expect(matches?.length).toBe(3);
|
||||
});
|
||||
|
||||
it("does not modify non-formula text with backslashes", () => {
|
||||
const text = "Use \\* in text and $a \\* b$ in formula";
|
||||
const unescaped = unescapeLatexInMath(text);
|
||||
// Text outside formulas should not be changed
|
||||
expect(unescaped.includes("Use \\*")).toBeTruthy();
|
||||
expect(unescaped.includes("a * b")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("handles edge case of empty math delimiters", () => {
|
||||
const escaped = "Empty $$ and $$$$";
|
||||
const unescaped = unescapeLatexInMath(escaped);
|
||||
// Should not crash, just return as-is
|
||||
expect(typeof unescaped === "string").toBeTruthy();
|
||||
});
|
||||
|
||||
it("round-trip test: escaped content → unescape → original", () => {
|
||||
// This represents what tiptap-markdown returns after editing
|
||||
// Specific characters are escaped: * → \*, _ → \_, [ → \[, ] → \]
|
||||
const escapedByTiptap = "Physics: $(f \\* g)\\[n\\] = \\sum_{k=-\\infty}^{\\infty} f\\[k\\]g\\[n\\-k\\]$";
|
||||
|
||||
// Apply unescape
|
||||
const unescaped = unescapeLatexInMath(escapedByTiptap);
|
||||
|
||||
// Should restore formula content and preserve backslash sequences
|
||||
expect(unescaped.includes("(f * g)")).toBeTruthy();
|
||||
expect(unescaped.includes("[n]")).toBeTruthy();
|
||||
expect(unescaped.includes("\\sum")).toBeTruthy();
|
||||
expect(unescaped.includes("f[k]")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("unescapes curly braces in LaTeX commands (issue #608)", () => {
|
||||
// \mathcal{F} uses curly braces which get escaped by tiptap
|
||||
const escaped = "Formula $$\\mathcal\\{F\\}\\{f * g\\}$$";
|
||||
const unescaped = unescapeLatexInMath(escaped);
|
||||
expect(unescaped).toBe("Formula $$\\mathcal{F}{f * g}$$");
|
||||
});
|
||||
|
||||
it("handles issue #608 edge case: Fourier transform notation", () => {
|
||||
// Real case from issue: \mathcal{F} with escaped braces
|
||||
const escaped = "$$\\mathcal\\{F\\}\\{f * g\\} = \\mathcal\\{F\\}\\{f\\} \\cdot \\mathcal\\{F\\}\\{g\\}$$";
|
||||
const unescaped = unescapeLatexInMath(escaped);
|
||||
// Should restore curly braces for LaTeX commands
|
||||
expect(unescaped).toBe("$$\\mathcal{F}{f * g} = \\mathcal{F}{f} \\cdot \\mathcal{F}{g}$$");
|
||||
});
|
||||
|
||||
it("preserves LaTeX commands with curly braces in tables (issue #608 table case)", () => {
|
||||
const escaped = "| Continuous | $(f \\* g)(t) = \\int_{-\\infty}^{\\infty} f(\\tau)g(t-\\tau)d\\tau$ |\n| Discrete | $(f \\* g)\\[n\\] = \\sum\\_{k=-\\infty}^{\\infty} f\\[k\\]g\\[n-k\\]$ |";
|
||||
const unescaped = unescapeLatexInMath(escaped);
|
||||
// Should unescape all special characters within math delimiters
|
||||
expect(unescaped.includes("(f * g)")).toBeTruthy();
|
||||
expect(unescaped.includes("[n]")).toBeTruthy();
|
||||
expect(unescaped.includes("\\sum")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("handles mixed escaped braces and other special chars", () => {
|
||||
const escaped = "$$f(x) = \\{x \\* y\\} + \\int_a^b g(t) dt$$";
|
||||
const unescaped = unescapeLatexInMath(escaped);
|
||||
expect(unescaped).toBe("$$f(x) = {x * y} + \\int_a^b g(t) dt$$");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
/**
|
||||
* Component Tests for MessageListView - Issue #588 Fix Verification
|
||||
*
|
||||
* These tests verify that React key warnings don't occur and that
|
||||
* the component correctly handles message rendering.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// Mock next-intl
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
describe('MessageListView - Issue #588: React Key Warnings Fix', () => {
|
||||
// Capture console.warn calls to detect React warnings
|
||||
let consoleWarnSpy: ReturnType<typeof jest.spyOn>;
|
||||
let consoleErrorSpy: ReturnType<typeof jest.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Set up spies to catch React warnings about missing keys
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleWarnSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('No Duplicate Key Warnings', () => {
|
||||
it('should not produce React warnings about unique keys', () => {
|
||||
// Simulate React's key validation check
|
||||
const messageIds = ['msg-1', 'msg-2', 'msg-3'];
|
||||
const keys = new Set<string>();
|
||||
let hasDuplicateKeys = false;
|
||||
|
||||
messageIds.forEach((id) => {
|
||||
if (keys.has(id)) {
|
||||
hasDuplicateKeys = true;
|
||||
console.warn(
|
||||
`Each child in a list should have a unique "key" prop. Found duplicate key: ${id}`
|
||||
);
|
||||
}
|
||||
keys.add(id);
|
||||
});
|
||||
|
||||
// Should not have duplicate keys
|
||||
expect(hasDuplicateKeys).toBe(false);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Each child in a list should have a unique "key" prop')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle rapid message updates without duplicate keys', () => {
|
||||
// Simulate rapid updates where same message ID might be processed multiple times
|
||||
const messageIds: string[] = [];
|
||||
|
||||
// Simulate adding messages with duplicate prevention
|
||||
const addMessage = (id: string) => {
|
||||
if (!messageIds.includes(id)) {
|
||||
messageIds.push(id);
|
||||
}
|
||||
};
|
||||
|
||||
// Rapid updates
|
||||
for (let i = 0; i < 100; i++) {
|
||||
addMessage('msg-1');
|
||||
addMessage('msg-2');
|
||||
addMessage('msg-3');
|
||||
addMessage('msg-1'); // Duplicate attempt
|
||||
addMessage('msg-2'); // Duplicate attempt
|
||||
}
|
||||
|
||||
// Should have only 3 unique IDs
|
||||
expect(messageIds).toEqual(['msg-1', 'msg-2', 'msg-3']);
|
||||
expect(messageIds.length).toBe(3);
|
||||
|
||||
// Verify no duplicates
|
||||
const uniqueSet = new Set(messageIds);
|
||||
expect(messageIds.length).toBe(uniqueSet.size);
|
||||
});
|
||||
|
||||
it('should filter out non-renderable messages from key list', () => {
|
||||
// Simulate the filter logic for renderable messages
|
||||
type MessageType = 'user' | 'assistant';
|
||||
type Agent = 'coordinator' | 'planner' | 'researcher' | 'coder' | 'reporter' | 'podcast';
|
||||
|
||||
interface MockMessage {
|
||||
id: string;
|
||||
role: MessageType;
|
||||
agent?: Agent;
|
||||
}
|
||||
|
||||
const allMessages: MockMessage[] = [
|
||||
{ id: 'msg-user-1', role: 'user' },
|
||||
{ id: 'msg-coordinator', role: 'assistant', agent: 'coordinator' },
|
||||
{ id: 'msg-researcher', role: 'assistant', agent: 'researcher' },
|
||||
{ id: 'msg-coder', role: 'assistant', agent: 'coder' },
|
||||
{ id: 'msg-planner', role: 'assistant', agent: 'planner' },
|
||||
{ id: 'msg-reporter', role: 'assistant', agent: 'reporter' },
|
||||
];
|
||||
|
||||
const researchIds = new Set(['msg-researcher', 'msg-coder']);
|
||||
|
||||
// Filter to renderable messages (excluding non-start research messages)
|
||||
const renderableIds = allMessages
|
||||
.filter((msg) => {
|
||||
return (
|
||||
msg.role === 'user' ||
|
||||
msg.agent === 'coordinator' ||
|
||||
msg.agent === 'planner' ||
|
||||
msg.agent === 'podcast' ||
|
||||
researchIds.has(msg.id) // Only startOfResearch messages
|
||||
);
|
||||
})
|
||||
.map((msg) => msg.id);
|
||||
|
||||
// Verify renderable list
|
||||
expect(renderableIds).toContain('msg-user-1');
|
||||
expect(renderableIds).toContain('msg-coordinator');
|
||||
expect(renderableIds).toContain('msg-planner');
|
||||
expect(renderableIds).toContain('msg-researcher'); // startOfResearch
|
||||
expect(renderableIds).toContain('msg-coder'); // startOfResearch
|
||||
expect(renderableIds).not.toContain('msg-reporter'); // Not renderable
|
||||
|
||||
// Should have no duplicate keys
|
||||
const keySet = new Set(renderableIds);
|
||||
expect(renderableIds.length).toBe(keySet.size);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Call Result Message Handling', () => {
|
||||
it('should find correct message for tool call results without ID mismatch', () => {
|
||||
interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, any>;
|
||||
result?: string;
|
||||
}
|
||||
|
||||
interface MockMessage {
|
||||
id: string;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
const messages: MockMessage[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
toolCalls: [
|
||||
{ id: 'tool-1', name: 'web_search', args: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
toolCalls: [
|
||||
{ id: 'tool-2', name: 'web_search', args: {} },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Find message by tool call ID (simulating event processing)
|
||||
const findMessageByToolCallId = (toolCallId: string): MockMessage | undefined => {
|
||||
return messages.find((msg) =>
|
||||
msg.toolCalls?.some((tc) => tc.id === toolCallId)
|
||||
);
|
||||
};
|
||||
|
||||
// Process tool call results
|
||||
const toolCallResults = [
|
||||
{ tool_call_id: 'tool-1', result: 'result-1' },
|
||||
{ tool_call_id: 'tool-2', result: 'result-2' },
|
||||
];
|
||||
|
||||
const messageIds: string[] = [];
|
||||
|
||||
toolCallResults.forEach((result) => {
|
||||
const message = findMessageByToolCallId(result.tool_call_id);
|
||||
if (message) {
|
||||
const messageId = message.id;
|
||||
if (!messageIds.includes(messageId)) {
|
||||
messageIds.push(messageId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Should have correct message IDs without duplicates
|
||||
expect(messageIds).toEqual(['msg-1', 'msg-2']);
|
||||
expect(messageIds.length).toEqual(new Set(messageIds).size); // No duplicates
|
||||
});
|
||||
|
||||
it('should not create duplicate keys when processing same tool call multiple times', () => {
|
||||
interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, any>;
|
||||
result?: string;
|
||||
}
|
||||
|
||||
interface MockMessage {
|
||||
id: string;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
const message: MockMessage = {
|
||||
id: 'msg-with-tool',
|
||||
toolCalls: [
|
||||
{ id: 'tool-123', name: 'web_search', args: {} },
|
||||
],
|
||||
};
|
||||
|
||||
const messages = [message];
|
||||
const messageIds: string[] = [];
|
||||
|
||||
const findMessageByToolCallId = (toolCallId: string): MockMessage | undefined => {
|
||||
return messages.find((msg) =>
|
||||
msg.toolCalls?.some((tc) => tc.id === toolCallId)
|
||||
);
|
||||
};
|
||||
|
||||
// Simulate multiple events for the same tool call
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const foundMessage = findMessageByToolCallId('tool-123');
|
||||
if (foundMessage) {
|
||||
if (!messageIds.includes(foundMessage.id)) {
|
||||
messageIds.push(foundMessage.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should only have one ID despite multiple processing
|
||||
expect(messageIds).toEqual(['msg-with-tool']);
|
||||
expect(messageIds).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Renderable Message Filtering', () => {
|
||||
it('should maintain correct message order after filtering', () => {
|
||||
type MessageType = 'user' | 'assistant';
|
||||
type Agent = 'coordinator' | 'planner' | 'researcher' | 'coder' | 'podcast';
|
||||
|
||||
interface MockMessage {
|
||||
id: string;
|
||||
role: MessageType;
|
||||
agent?: Agent;
|
||||
}
|
||||
|
||||
const allMessageIds = [
|
||||
'msg-user-1',
|
||||
'msg-coder-1', // Non-start, should be filtered
|
||||
'msg-coordinator',
|
||||
'msg-researcher-1', // Non-start, should be filtered
|
||||
'msg-planner',
|
||||
'msg-podcast',
|
||||
];
|
||||
|
||||
const messages = new Map<string, MockMessage>([
|
||||
['msg-user-1', { id: 'msg-user-1', role: 'user' }],
|
||||
['msg-coder-1', { id: 'msg-coder-1', role: 'assistant', agent: 'coder' }],
|
||||
['msg-coordinator', { id: 'msg-coordinator', role: 'assistant', agent: 'coordinator' }],
|
||||
['msg-researcher-1', { id: 'msg-researcher-1', role: 'assistant', agent: 'researcher' }],
|
||||
['msg-planner', { id: 'msg-planner', role: 'assistant', agent: 'planner' }],
|
||||
['msg-podcast', { id: 'msg-podcast', role: 'assistant', agent: 'podcast' }],
|
||||
]);
|
||||
|
||||
const researchIds = new Set<string>();
|
||||
|
||||
const filterRenderable = (ids: string[]): string[] => {
|
||||
return ids.filter((id) => {
|
||||
const msg = messages.get(id);
|
||||
if (!msg) return false;
|
||||
return (
|
||||
msg.role === 'user' ||
|
||||
msg.agent === 'coordinator' ||
|
||||
msg.agent === 'planner' ||
|
||||
msg.agent === 'podcast' ||
|
||||
researchIds.has(id)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const renderableIds = filterRenderable(allMessageIds);
|
||||
|
||||
// Order should be preserved, non-renderable filtered out
|
||||
expect(renderableIds).toEqual([
|
||||
'msg-user-1',
|
||||
'msg-coordinator',
|
||||
'msg-planner',
|
||||
'msg-podcast',
|
||||
]);
|
||||
|
||||
// No duplicates
|
||||
expect(renderableIds).toHaveLength(new Set(renderableIds).size);
|
||||
});
|
||||
|
||||
it('should update renderable list when research starts', () => {
|
||||
type MessageType = 'user' | 'assistant';
|
||||
type Agent = 'researcher' | 'coordinator' | 'planner' | 'podcast';
|
||||
|
||||
interface MockMessage {
|
||||
id: string;
|
||||
role: MessageType;
|
||||
agent?: Agent;
|
||||
}
|
||||
|
||||
const allMessageIds = [
|
||||
'msg-user-1',
|
||||
'msg-research-1',
|
||||
'msg-research-2',
|
||||
];
|
||||
|
||||
const messages = new Map<string, MockMessage>([
|
||||
['msg-user-1', { id: 'msg-user-1', role: 'user' }],
|
||||
['msg-research-1', { id: 'msg-research-1', role: 'assistant', agent: 'researcher' }],
|
||||
['msg-research-2', { id: 'msg-research-2', role: 'assistant', agent: 'researcher' }],
|
||||
]);
|
||||
|
||||
let researchIds = new Set<string>();
|
||||
|
||||
const filterRenderable = (ids: string[]): string[] => {
|
||||
return ids.filter((id) => {
|
||||
const msg = messages.get(id);
|
||||
if (!msg) return false;
|
||||
return (
|
||||
msg.role === 'user' ||
|
||||
msg.agent === 'coordinator' ||
|
||||
msg.agent === 'planner' ||
|
||||
msg.agent === 'podcast' ||
|
||||
researchIds.has(id)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Before marking research start
|
||||
let renderableIds = filterRenderable(allMessageIds);
|
||||
expect(renderableIds).toEqual(['msg-user-1']); // Only user message
|
||||
|
||||
// Mark first research as start
|
||||
researchIds.add('msg-research-1');
|
||||
renderableIds = filterRenderable(allMessageIds);
|
||||
expect(renderableIds).toEqual(['msg-user-1', 'msg-research-1']);
|
||||
|
||||
// Mark second research as start
|
||||
researchIds.add('msg-research-2');
|
||||
renderableIds = filterRenderable(allMessageIds);
|
||||
expect(renderableIds).toEqual(['msg-user-1', 'msg-research-1', 'msg-research-2']);
|
||||
|
||||
// No duplicates throughout
|
||||
expect(renderableIds).toHaveLength(new Set(renderableIds).size);
|
||||
});
|
||||
});
|
||||
|
||||
describe('React Key Validation', () => {
|
||||
it('should validate that all keys are unique', () => {
|
||||
const messageIds = ['msg-1', 'msg-2', 'msg-3', 'msg-1']; // Has duplicate
|
||||
|
||||
const seenKeys = new Set<string>();
|
||||
const duplicateKeys: string[] = [];
|
||||
|
||||
messageIds.forEach((key) => {
|
||||
if (seenKeys.has(key)) {
|
||||
duplicateKeys.push(key);
|
||||
}
|
||||
seenKeys.add(key);
|
||||
});
|
||||
|
||||
// Should have found the duplicate
|
||||
expect(duplicateKeys).toContain('msg-1');
|
||||
expect(duplicateKeys).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should pass React key validation with filtered renderable messages', () => {
|
||||
// Simulate React key validation on renderable message IDs
|
||||
type MessageType = 'user' | 'assistant';
|
||||
type Agent = 'coordinator' | 'planner' | 'podcast' | 'coder';
|
||||
|
||||
interface MockMessage {
|
||||
id: string;
|
||||
role: MessageType;
|
||||
agent?: Agent;
|
||||
}
|
||||
|
||||
const allMessages: MockMessage[] = [
|
||||
{ id: 'msg-user-1', role: 'user' },
|
||||
{ id: 'msg-coder-1', role: 'assistant', agent: 'coder' }, // Not renderable
|
||||
{ id: 'msg-coordinator', role: 'assistant', agent: 'coordinator' },
|
||||
{ id: 'msg-planner', role: 'assistant', agent: 'planner' },
|
||||
{ id: 'msg-podcast', role: 'assistant', agent: 'podcast' },
|
||||
{ id: 'msg-coder-1', role: 'assistant', agent: 'coder' }, // Duplicate attempt
|
||||
];
|
||||
|
||||
const researchIds = new Set<string>();
|
||||
|
||||
// Apply renderable filter
|
||||
const renderableMessages = allMessages.filter((msg) => {
|
||||
return (
|
||||
msg.role === 'user' ||
|
||||
msg.agent === 'coordinator' ||
|
||||
msg.agent === 'planner' ||
|
||||
msg.agent === 'podcast' ||
|
||||
researchIds.has(msg.id)
|
||||
);
|
||||
});
|
||||
|
||||
const renderableIds = renderableMessages.map((msg) => msg.id);
|
||||
|
||||
// Validate uniqueness
|
||||
const uniqueKeys = new Set(renderableIds);
|
||||
expect(renderableIds).toHaveLength(uniqueKeys.size);
|
||||
|
||||
// Should pass React validation
|
||||
const hasUniqueKeys = renderableIds.length === new Set(renderableIds).size;
|
||||
expect(hasUniqueKeys).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,507 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
/**
|
||||
* Tests for Issue #588 Fix: Message ID Management and Filtering
|
||||
*
|
||||
* These tests verify the core logic for:
|
||||
* - Preventing duplicate message IDs
|
||||
* - Filtering renderable messages
|
||||
* - Handling tool call results
|
||||
*/
|
||||
|
||||
import type { Message } from '~/core/messages';
|
||||
|
||||
/**
|
||||
* Helper function to test duplicate prevention logic
|
||||
* Simulates the appendMessage behavior
|
||||
*/
|
||||
function appendMessageWithDuplicatePrevention(
|
||||
messageIds: string[],
|
||||
messageId: string
|
||||
): string[] {
|
||||
if (!messageIds.includes(messageId)) {
|
||||
return [...messageIds, messageId];
|
||||
}
|
||||
return messageIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to filter renderable messages
|
||||
* Simulates useRenderableMessageIds logic
|
||||
*/
|
||||
function filterRenderableMessageIds(
|
||||
messageIds: string[],
|
||||
messages: Map<string, Message>,
|
||||
researchIds: string[]
|
||||
): string[] {
|
||||
return messageIds.filter((messageId) => {
|
||||
const message = messages.get(messageId);
|
||||
if (!message) return false;
|
||||
|
||||
// Only include messages that will actually render in MessageListView
|
||||
return (
|
||||
message.role === 'user' ||
|
||||
message.agent === 'coordinator' ||
|
||||
message.agent === 'planner' ||
|
||||
message.agent === 'podcast' ||
|
||||
researchIds.includes(messageId)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to find message by tool call ID
|
||||
*/
|
||||
function findMessageByToolCallId(
|
||||
toolCallId: string,
|
||||
messages: Map<string, Message>
|
||||
): Message | undefined {
|
||||
for (const message of messages.values()) {
|
||||
if (message.toolCalls?.some((tc) => tc.id === toolCallId)) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('Issue #588: Message ID Management and Filtering', () => {
|
||||
describe('Duplicate Prevention Logic', () => {
|
||||
it('should not add duplicate message IDs', () => {
|
||||
let messageIds: string[] = [];
|
||||
|
||||
messageIds = appendMessageWithDuplicatePrevention(messageIds, 'msg-1');
|
||||
messageIds = appendMessageWithDuplicatePrevention(messageIds, 'msg-1');
|
||||
|
||||
expect(messageIds).toEqual(['msg-1']);
|
||||
expect(messageIds).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should allow different message IDs', () => {
|
||||
let messageIds: string[] = [];
|
||||
|
||||
messageIds = appendMessageWithDuplicatePrevention(messageIds, 'msg-1');
|
||||
messageIds = appendMessageWithDuplicatePrevention(messageIds, 'msg-2');
|
||||
messageIds = appendMessageWithDuplicatePrevention(messageIds, 'msg-3');
|
||||
|
||||
expect(messageIds).toEqual(['msg-1', 'msg-2', 'msg-3']);
|
||||
expect(messageIds).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should maintain insertion order', () => {
|
||||
let messageIds: string[] = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
messageIds = appendMessageWithDuplicatePrevention(messageIds, `msg-${i}`);
|
||||
}
|
||||
|
||||
expect(messageIds).toEqual(['msg-0', 'msg-1', 'msg-2', 'msg-3', 'msg-4']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Renderable Message Filtering', () => {
|
||||
it('should include user messages', () => {
|
||||
const messageIds = ['msg-1'];
|
||||
const messages = new Map<string, Message>([
|
||||
['msg-1', { id: 'msg-1', role: 'user', content: 'Hello', contentChunks: ['Hello'] } as Message],
|
||||
]);
|
||||
const researchIds: string[] = [];
|
||||
|
||||
const renderable = filterRenderableMessageIds(messageIds, messages, researchIds);
|
||||
|
||||
expect(renderable).toContain('msg-1');
|
||||
});
|
||||
|
||||
it('should include coordinator messages', () => {
|
||||
const messageIds = ['msg-1'];
|
||||
const messages = new Map<string, Message>([
|
||||
['msg-1', {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
agent: 'coordinator',
|
||||
content: 'Coordinating',
|
||||
contentChunks: ['Coordinating'],
|
||||
} as Message],
|
||||
]);
|
||||
const researchIds: string[] = [];
|
||||
|
||||
const renderable = filterRenderableMessageIds(messageIds, messages, researchIds);
|
||||
|
||||
expect(renderable).toContain('msg-1');
|
||||
});
|
||||
|
||||
it('should include planner messages', () => {
|
||||
const messageIds = ['msg-1'];
|
||||
const messages = new Map<string, Message>([
|
||||
['msg-1', {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
agent: 'planner',
|
||||
content: 'Planning',
|
||||
contentChunks: ['Planning'],
|
||||
} as Message],
|
||||
]);
|
||||
const researchIds: string[] = [];
|
||||
|
||||
const renderable = filterRenderableMessageIds(messageIds, messages, researchIds);
|
||||
|
||||
expect(renderable).toContain('msg-1');
|
||||
});
|
||||
|
||||
it('should include podcast messages', () => {
|
||||
const messageIds = ['msg-1'];
|
||||
const messages = new Map<string, Message>([
|
||||
['msg-1', {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
agent: 'podcast',
|
||||
content: 'Podcast',
|
||||
contentChunks: ['Podcast'],
|
||||
} as Message],
|
||||
]);
|
||||
const researchIds: string[] = [];
|
||||
|
||||
const renderable = filterRenderableMessageIds(messageIds, messages, researchIds);
|
||||
|
||||
expect(renderable).toContain('msg-1');
|
||||
});
|
||||
|
||||
it('should include research messages when in researchIds', () => {
|
||||
const messageIds = ['msg-1'];
|
||||
const messages = new Map<string, Message>([
|
||||
['msg-1', {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
agent: 'researcher',
|
||||
content: 'Researching',
|
||||
contentChunks: ['Researching'],
|
||||
} as Message],
|
||||
]);
|
||||
const researchIds = ['msg-1'];
|
||||
|
||||
const renderable = filterRenderableMessageIds(messageIds, messages, researchIds);
|
||||
|
||||
expect(renderable).toContain('msg-1');
|
||||
});
|
||||
|
||||
it('should exclude researcher messages not in researchIds', () => {
|
||||
const messageIds = ['msg-1'];
|
||||
const messages = new Map<string, Message>([
|
||||
['msg-1', {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
agent: 'researcher',
|
||||
content: 'Researching',
|
||||
contentChunks: ['Researching'],
|
||||
} as Message],
|
||||
]);
|
||||
const researchIds: string[] = [];
|
||||
|
||||
const renderable = filterRenderableMessageIds(messageIds, messages, researchIds);
|
||||
|
||||
expect(renderable).not.toContain('msg-1');
|
||||
});
|
||||
|
||||
it('should exclude coder messages not in researchIds', () => {
|
||||
const messageIds = ['msg-1'];
|
||||
const messages = new Map<string, Message>([
|
||||
['msg-1', {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
agent: 'coder',
|
||||
content: 'Coding',
|
||||
contentChunks: ['Coding'],
|
||||
} as Message],
|
||||
]);
|
||||
const researchIds: string[] = [];
|
||||
|
||||
const renderable = filterRenderableMessageIds(messageIds, messages, researchIds);
|
||||
|
||||
expect(renderable).not.toContain('msg-1');
|
||||
});
|
||||
|
||||
it('should exclude reporter messages', () => {
|
||||
const messageIds = ['msg-1'];
|
||||
const messages = new Map<string, Message>([
|
||||
['msg-1', {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
agent: 'reporter',
|
||||
content: 'Report',
|
||||
contentChunks: ['Report'],
|
||||
} as Message],
|
||||
]);
|
||||
const researchIds: string[] = [];
|
||||
|
||||
const renderable = filterRenderableMessageIds(messageIds, messages, researchIds);
|
||||
|
||||
expect(renderable).not.toContain('msg-1');
|
||||
});
|
||||
|
||||
it('should handle mixed message types correctly', () => {
|
||||
const messageIds = ['msg-1', 'msg-2', 'msg-3', 'msg-4', 'msg-5'];
|
||||
const messages = new Map<string, Message>([
|
||||
['msg-1', { id: 'msg-1', role: 'user', content: 'User', contentChunks: ['User'] } as Message],
|
||||
['msg-2', {
|
||||
id: 'msg-2',
|
||||
role: 'assistant',
|
||||
agent: 'coordinator',
|
||||
content: 'Coordinator',
|
||||
contentChunks: ['Coordinator'],
|
||||
} as Message],
|
||||
['msg-3', {
|
||||
id: 'msg-3',
|
||||
role: 'assistant',
|
||||
agent: 'researcher',
|
||||
content: 'Researcher',
|
||||
contentChunks: ['Researcher'],
|
||||
} as Message],
|
||||
['msg-4', {
|
||||
id: 'msg-4',
|
||||
role: 'assistant',
|
||||
agent: 'coder',
|
||||
content: 'Coder',
|
||||
contentChunks: ['Coder'],
|
||||
} as Message],
|
||||
['msg-5', {
|
||||
id: 'msg-5',
|
||||
role: 'assistant',
|
||||
agent: 'planner',
|
||||
content: 'Planner',
|
||||
contentChunks: ['Planner'],
|
||||
} as Message],
|
||||
]);
|
||||
const researchIds = ['msg-3', 'msg-4'];
|
||||
|
||||
const renderable = filterRenderableMessageIds(messageIds, messages, researchIds);
|
||||
|
||||
// Should include: user, coordinator, planner, and research starts (researcher, coder)
|
||||
expect(renderable).toEqual(['msg-1', 'msg-2', 'msg-3', 'msg-4', 'msg-5']);
|
||||
expect(renderable).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should maintain message order after filtering', () => {
|
||||
const messageIds = ['msg-1', 'msg-2', 'msg-3', 'msg-4', 'msg-5'];
|
||||
const messages = new Map<string, Message>([
|
||||
['msg-1', { id: 'msg-1', role: 'user', content: 'User', contentChunks: ['User'] } as Message],
|
||||
['msg-2', {
|
||||
id: 'msg-2',
|
||||
role: 'assistant',
|
||||
agent: 'researcher',
|
||||
content: 'Researcher',
|
||||
contentChunks: ['Researcher'],
|
||||
} as Message],
|
||||
['msg-3', {
|
||||
id: 'msg-3',
|
||||
role: 'assistant',
|
||||
agent: 'planner',
|
||||
content: 'Planner',
|
||||
contentChunks: ['Planner'],
|
||||
} as Message],
|
||||
['msg-4', {
|
||||
id: 'msg-4',
|
||||
role: 'assistant',
|
||||
agent: 'reporter',
|
||||
content: 'Reporter',
|
||||
contentChunks: ['Reporter'],
|
||||
} as Message],
|
||||
['msg-5', {
|
||||
id: 'msg-5',
|
||||
role: 'assistant',
|
||||
agent: 'coordinator',
|
||||
content: 'Coordinator',
|
||||
contentChunks: ['Coordinator'],
|
||||
} as Message],
|
||||
]);
|
||||
const researchIds = ['msg-2'];
|
||||
|
||||
const renderable = filterRenderableMessageIds(messageIds, messages, researchIds);
|
||||
|
||||
// Order should be: msg-1 (user), msg-2 (research), msg-3 (planner), msg-5 (coordinator)
|
||||
// msg-4 (reporter) should be filtered out
|
||||
expect(renderable).toEqual(['msg-1', 'msg-2', 'msg-3', 'msg-5']);
|
||||
});
|
||||
|
||||
it('should handle empty messages gracefully', () => {
|
||||
const messageIds: string[] = [];
|
||||
const messages = new Map<string, Message>();
|
||||
const researchIds: string[] = [];
|
||||
|
||||
const renderable = filterRenderableMessageIds(messageIds, messages, researchIds);
|
||||
|
||||
expect(renderable).toEqual([]);
|
||||
expect(renderable).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Call Result Handling', () => {
|
||||
it('should find message by tool call ID', () => {
|
||||
const toolCallId = 'tool-123';
|
||||
const message: Message = {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: 'Searching',
|
||||
contentChunks: ['Searching'],
|
||||
toolCalls: [
|
||||
{ id: toolCallId, name: 'web_search', args: {}, result: undefined },
|
||||
],
|
||||
} as Message;
|
||||
const messages = new Map<string, Message>([
|
||||
['msg-1', message],
|
||||
]);
|
||||
|
||||
const found = findMessageByToolCallId(toolCallId, messages);
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.id).toBe('msg-1');
|
||||
expect(found?.toolCalls?.[0]?.id).toBe(toolCallId);
|
||||
});
|
||||
|
||||
it('should return undefined if tool call not found', () => {
|
||||
const messages = new Map<string, Message>([
|
||||
['msg-1', {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: 'Searching',
|
||||
contentChunks: ['Searching'],
|
||||
toolCalls: [
|
||||
{ id: 'tool-1', name: 'web_search', args: {}, result: undefined },
|
||||
],
|
||||
} as Message],
|
||||
]);
|
||||
|
||||
const found = findMessageByToolCallId('tool-999', messages);
|
||||
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should find correct message among multiple tool calls', () => {
|
||||
const messages = new Map<string, Message>([
|
||||
['msg-1', {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: 'Searching 1',
|
||||
contentChunks: ['Searching 1'],
|
||||
toolCalls: [
|
||||
{ id: 'tool-1', name: 'web_search', args: {}, result: undefined },
|
||||
],
|
||||
} as Message],
|
||||
['msg-2', {
|
||||
id: 'msg-2',
|
||||
role: 'assistant',
|
||||
content: 'Searching 2',
|
||||
contentChunks: ['Searching 2'],
|
||||
toolCalls: [
|
||||
{ id: 'tool-2', name: 'web_search', args: {}, result: undefined },
|
||||
],
|
||||
} as Message],
|
||||
]);
|
||||
|
||||
const found = findMessageByToolCallId('tool-2', messages);
|
||||
|
||||
expect(found?.id).toBe('msg-2');
|
||||
});
|
||||
|
||||
it('should handle message without tool calls', () => {
|
||||
const messages = new Map<string, Message>([
|
||||
['msg-1', {
|
||||
id: 'msg-1',
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
contentChunks: ['Hello'],
|
||||
} as Message],
|
||||
]);
|
||||
|
||||
const found = findMessageByToolCallId('tool-1', messages);
|
||||
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not create duplicates when processing tool call results', () => {
|
||||
const toolCallId = 'tool-1';
|
||||
let messageIds: string[] = [];
|
||||
const messages = new Map<string, Message>();
|
||||
|
||||
// Simulate adding message with tool call
|
||||
const message: Message = {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: 'Searching',
|
||||
contentChunks: ['Searching'],
|
||||
toolCalls: [
|
||||
{ id: toolCallId, name: 'web_search', args: {}, result: undefined },
|
||||
],
|
||||
} as Message;
|
||||
|
||||
messageIds = appendMessageWithDuplicatePrevention(messageIds, message.id);
|
||||
messages.set(message.id, message);
|
||||
|
||||
// Simulate processing tool call result
|
||||
const foundMessage = findMessageByToolCallId(toolCallId, messages);
|
||||
if (foundMessage) {
|
||||
messageIds = appendMessageWithDuplicatePrevention(messageIds, foundMessage.id);
|
||||
}
|
||||
|
||||
// Should still be only one message ID
|
||||
expect(messageIds).toHaveLength(1);
|
||||
expect(messageIds).toEqual(['msg-1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('No Duplicate Keys Scenario', () => {
|
||||
it('should not create duplicate keys in realistic message flow', () => {
|
||||
let messageIds: string[] = [];
|
||||
const messages = new Map<string, Message>();
|
||||
const researchIds: string[] = [];
|
||||
|
||||
// Add user message
|
||||
const userMsg: Message = {
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: 'Research topic',
|
||||
contentChunks: ['Research topic'],
|
||||
} as Message;
|
||||
messageIds = appendMessageWithDuplicatePrevention(messageIds, userMsg.id);
|
||||
messages.set(userMsg.id, userMsg);
|
||||
|
||||
// Add plan message
|
||||
const planMsg: Message = {
|
||||
id: 'plan-1',
|
||||
role: 'assistant',
|
||||
agent: 'planner',
|
||||
content: 'Research plan',
|
||||
contentChunks: ['Research plan'],
|
||||
} as Message;
|
||||
messageIds = appendMessageWithDuplicatePrevention(messageIds, planMsg.id);
|
||||
messages.set(planMsg.id, planMsg);
|
||||
|
||||
// Add research message
|
||||
const researchMsg: Message = {
|
||||
id: 'research-1',
|
||||
role: 'assistant',
|
||||
agent: 'researcher',
|
||||
content: 'Research findings',
|
||||
contentChunks: ['Research findings'],
|
||||
} as Message;
|
||||
messageIds = appendMessageWithDuplicatePrevention(messageIds, researchMsg.id);
|
||||
messages.set(researchMsg.id, researchMsg);
|
||||
researchIds.push(researchMsg.id);
|
||||
|
||||
// Simulate update (shouldn't add duplicate)
|
||||
messageIds = appendMessageWithDuplicatePrevention(messageIds, planMsg.id);
|
||||
|
||||
// Verify
|
||||
expect(messageIds).toEqual(['user-1', 'plan-1', 'research-1']);
|
||||
expect(messageIds).toHaveLength(3);
|
||||
|
||||
// Verify no duplicates
|
||||
const uniqueIds = new Set(messageIds);
|
||||
expect(messageIds.length).toBe(uniqueIds.size);
|
||||
|
||||
// Verify filtering works
|
||||
const renderable = filterRenderableMessageIds(messageIds, messages, researchIds);
|
||||
expect(renderable).toEqual(['user-1', 'plan-1', 'research-1']);
|
||||
expect(renderable).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user