mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-04-25 11:18:22 +00:00
fix: react key warnings from duplicate message IDs + establish jest testing framework (#655)
* fix: resolve issue #588 - react key warnings from duplicate message IDs + establish jest testing framework * Update the makefile and workflow with the js test * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
f2be4d6af1
commit
1d71f8910e
2
.github/workflows/lint.yaml
vendored
2
.github/workflows/lint.yaml
vendored
@ -61,7 +61,7 @@ jobs:
|
|||||||
- name: Running the frontend tests
|
- name: Running the frontend tests
|
||||||
run: |
|
run: |
|
||||||
cd web
|
cd web
|
||||||
node --test tests/*.test.ts
|
pnpm test:run
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
2
Makefile
2
Makefile
@ -20,7 +20,7 @@ lint-frontend: ## Lint frontend code, run tests, and check build
|
|||||||
cd web && pnpm install --frozen-lockfile
|
cd web && pnpm install --frozen-lockfile
|
||||||
cd web && pnpm lint
|
cd web && pnpm lint
|
||||||
cd web && pnpm typecheck
|
cd web && pnpm typecheck
|
||||||
cd web && npx tsx --test tests/*.test.ts
|
cd web && pnpm test:run
|
||||||
cd web && pnpm build
|
cd web && pnpm build
|
||||||
|
|
||||||
serve: ## Start development server with reload
|
serve: ## Start development server with reload
|
||||||
|
|||||||
53
web/jest.config.mjs
Normal file
53
web/jest.config.mjs
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
export default {
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
|
roots: ['<rootDir>'],
|
||||||
|
testMatch: [
|
||||||
|
'**/tests/**/*.test.ts',
|
||||||
|
'**/tests/**/*.test.tsx',
|
||||||
|
],
|
||||||
|
testPathIgnorePatterns: ['/node_modules/', '/.next/'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^~(.*)$': '<rootDir>/src$1',
|
||||||
|
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
|
||||||
|
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/tests/__mocks__/fileMock.js',
|
||||||
|
'^~/core/store/store$': '<rootDir>/tests/__mocks__/store-mock.ts',
|
||||||
|
},
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'src/**/*.{ts,tsx}',
|
||||||
|
'!src/**/*.d.ts',
|
||||||
|
'!src/**/*.stories.tsx',
|
||||||
|
'!src/**/index.{ts,tsx}',
|
||||||
|
],
|
||||||
|
setupFilesAfterEnv: [],
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': ['ts-jest', {
|
||||||
|
useESM: true,
|
||||||
|
tsconfig: {
|
||||||
|
jsx: 'react-jsx',
|
||||||
|
esModuleInterop: true,
|
||||||
|
allowSyntheticDefaultImports: true,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
// The following packages are ESM-only or otherwise require transformation by Jest.
|
||||||
|
// If you encounter "SyntaxError: Cannot use import statement outside a module" or similar errors
|
||||||
|
// for a dependency, add it to this list. See: https://jestjs.io/docs/configuration#transformignorepatterns-arraystring
|
||||||
|
//
|
||||||
|
// Packages included:
|
||||||
|
// - framer-motion: ESM-only
|
||||||
|
// - nanoid: ESM-only
|
||||||
|
// - @tiptap: ESM-only
|
||||||
|
// - lowlight: ESM-only
|
||||||
|
// - highlight.js: ESM-only
|
||||||
|
// - zustand: ESM-only
|
||||||
|
// - sonner: ESM-only
|
||||||
|
// - next-intl: ESM-only
|
||||||
|
// - immer: ESM-only
|
||||||
|
// - use-debounce: ESM-only
|
||||||
|
// - use-stick-to-bottom: ESM-only
|
||||||
|
transformIgnorePatterns: [
|
||||||
|
'node_modules/(?!(framer-motion|nanoid|@tiptap|lowlight|highlight\\.js|zustand|sonner|next-intl|immer|use-debounce|use-stick-to-bottom)/)',
|
||||||
|
],
|
||||||
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||||
|
extensionsToTreatAsEsm: ['.ts', '.tsx'],
|
||||||
|
};
|
||||||
5
web/jest.setup.js
Normal file
5
web/jest.setup.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
// Jest setup file
|
||||||
|
Object.defineProperty(globalThis, '__ESM__', {
|
||||||
|
value: true,
|
||||||
|
writable: false,
|
||||||
|
});
|
||||||
@ -14,7 +14,10 @@
|
|||||||
"lint:fix": "next lint --fix",
|
"lint:fix": "next lint --fix",
|
||||||
"preview": "next build && next start",
|
"preview": "next build && next start",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "jest --watch",
|
||||||
|
"test:run": "jest",
|
||||||
|
"test:coverage": "jest --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.0.0",
|
"@ant-design/icons": "^6.0.0",
|
||||||
@ -89,7 +92,10 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@tailwindcss/postcss": "^4.0.15",
|
"@tailwindcss/postcss": "^4.0.15",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/hast": "^3.0.4",
|
"@types/hast": "^3.0.4",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
@ -97,11 +103,15 @@
|
|||||||
"dotenv-cli": "^8.0.0",
|
"dotenv-cli": "^8.0.0",
|
||||||
"eslint": "^9.23.0",
|
"eslint": "^9.23.0",
|
||||||
"eslint-config-next": "^15.2.3",
|
"eslint-config-next": "^15.2.3",
|
||||||
|
"identity-obj-proxy": "^3.0.0",
|
||||||
|
"jest": "^30.2.0",
|
||||||
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"raw-loader": "^4.0.2",
|
"raw-loader": "^4.0.2",
|
||||||
"tailwindcss": "^4.0.15",
|
"tailwindcss": "^4.0.15",
|
||||||
|
"ts-jest": "^29.4.5",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"typescript-eslint": "^8.27.0"
|
"typescript-eslint": "^8.27.0"
|
||||||
},
|
},
|
||||||
|
|||||||
2935
web/pnpm-lock.yaml
generated
2935
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -43,7 +43,7 @@ import {
|
|||||||
useLastFeedbackMessageId,
|
useLastFeedbackMessageId,
|
||||||
useLastInterruptMessage,
|
useLastInterruptMessage,
|
||||||
useMessage,
|
useMessage,
|
||||||
useMessageIds,
|
useRenderableMessageIds,
|
||||||
useResearchMessage,
|
useResearchMessage,
|
||||||
useStore,
|
useStore,
|
||||||
} from "~/core/store";
|
} from "~/core/store";
|
||||||
@ -63,7 +63,8 @@ export function MessageListView({
|
|||||||
) => void;
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
const scrollContainerRef = useRef<ScrollContainerRef>(null);
|
const scrollContainerRef = useRef<ScrollContainerRef>(null);
|
||||||
const messageIds = useMessageIds();
|
// Use renderable message IDs to avoid React key warnings from duplicate or non-rendering messages
|
||||||
|
const messageIds = useRenderableMessageIds();
|
||||||
const interruptMessage = useLastInterruptMessage();
|
const interruptMessage = useLastInterruptMessage();
|
||||||
const waitingForFeedbackMessageId = useLastFeedbackMessageId();
|
const waitingForFeedbackMessageId = useLastFeedbackMessageId();
|
||||||
const responding = useStore((state) => state.responding);
|
const responding = useStore((state) => state.responding);
|
||||||
|
|||||||
@ -46,10 +46,16 @@ export const useStore = create<{
|
|||||||
openResearchId: null,
|
openResearchId: null,
|
||||||
|
|
||||||
appendMessage(message: Message) {
|
appendMessage(message: Message) {
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
messageIds: [...state.messageIds, message.id],
|
// Prevent duplicate message IDs in the array to avoid React key warnings
|
||||||
messages: new Map(state.messages).set(message.id, message),
|
const newMessageIds = state.messageIds.includes(message.id)
|
||||||
}));
|
? state.messageIds
|
||||||
|
: [...state.messageIds, message.id];
|
||||||
|
return {
|
||||||
|
messageIds: newMessageIds,
|
||||||
|
messages: new Map(state.messages).set(message.id, message),
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
updateMessage(message: Message) {
|
updateMessage(message: Message) {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@ -137,32 +143,48 @@ export async function sendMessage(
|
|||||||
try {
|
try {
|
||||||
for await (const event of stream) {
|
for await (const event of stream) {
|
||||||
const { type, data } = event;
|
const { type, data } = event;
|
||||||
messageId = data.id;
|
|
||||||
let message: Message | undefined;
|
let message: Message | undefined;
|
||||||
|
|
||||||
|
// Handle tool_call_result specially: use the message that contains the tool call
|
||||||
if (type === "tool_call_result") {
|
if (type === "tool_call_result") {
|
||||||
message = findMessageByToolCallId(data.tool_call_id);
|
message = findMessageByToolCallId(data.tool_call_id);
|
||||||
} else if (!existsMessage(messageId)) {
|
if (message) {
|
||||||
message = {
|
// Use the found message's ID, not data.id
|
||||||
id: messageId,
|
messageId = message.id;
|
||||||
threadId: data.thread_id,
|
} else {
|
||||||
agent: data.agent,
|
// Shouldn't happen, but handle gracefully
|
||||||
role: data.role,
|
if (process.env.NODE_ENV === "development") {
|
||||||
content: "",
|
console.warn(`Tool call result without matching message: ${data.tool_call_id}`);
|
||||||
contentChunks: [],
|
}
|
||||||
reasoningContent: "",
|
continue; // Skip this event
|
||||||
reasoningContentChunks: [],
|
}
|
||||||
isStreaming: true,
|
} else {
|
||||||
interruptFeedback,
|
// For other event types, use data.id
|
||||||
};
|
messageId = data.id;
|
||||||
appendMessage(message);
|
|
||||||
|
if (!existsMessage(messageId)) {
|
||||||
|
message = {
|
||||||
|
id: messageId,
|
||||||
|
threadId: data.thread_id,
|
||||||
|
agent: data.agent,
|
||||||
|
role: data.role,
|
||||||
|
content: "",
|
||||||
|
contentChunks: [],
|
||||||
|
reasoningContent: "",
|
||||||
|
reasoningContentChunks: [],
|
||||||
|
isStreaming: true,
|
||||||
|
interruptFeedback,
|
||||||
|
};
|
||||||
|
appendMessage(message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
message ??= getMessage(messageId);
|
message ??= getMessage(messageId);
|
||||||
if (message) {
|
if (message) {
|
||||||
message = mergeMessage(message, event);
|
message = mergeMessage(message, event);
|
||||||
// Collect pending messages for update, instead of updating immediately.
|
// Collect pending messages for update, instead of updating immediately.
|
||||||
pendingUpdates.set(message.id, message);
|
pendingUpdates.set(message.id, message);
|
||||||
scheduleUpdate();
|
scheduleUpdate();
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -383,6 +405,29 @@ export function useMessageIds() {
|
|||||||
return useStore(useShallow((state) => state.messageIds));
|
return useStore(useShallow((state) => state.messageIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useRenderableMessageIds() {
|
||||||
|
return useStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
// Filter to only messages that will actually render in MessageListView
|
||||||
|
// This prevents duplicate keys and React warnings when messages change state
|
||||||
|
return state.messageIds.filter((messageId) => {
|
||||||
|
const message = state.messages.get(messageId);
|
||||||
|
if (!message) return false;
|
||||||
|
|
||||||
|
// Only include messages that match MessageListItem rendering conditions
|
||||||
|
// These are the same conditions checked in MessageListItem component
|
||||||
|
return (
|
||||||
|
message.role === "user" ||
|
||||||
|
message.agent === "coordinator" ||
|
||||||
|
message.agent === "planner" ||
|
||||||
|
message.agent === "podcast" ||
|
||||||
|
state.researchIds.includes(messageId) // startOfResearch condition
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useLastInterruptMessage() {
|
export function useLastInterruptMessage() {
|
||||||
return useStore(
|
return useStore(
|
||||||
useShallow((state) => {
|
useShallow((state) => {
|
||||||
|
|||||||
1
web/tests/__mocks__/fileMock.js
Normal file
1
web/tests/__mocks__/fileMock.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
module.exports = 'test-file-stub';
|
||||||
19
web/tests/__mocks__/store-mock.ts
Normal file
19
web/tests/__mocks__/store-mock.ts
Normal file
@ -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(),
|
||||||
|
};
|
||||||
@ -1,9 +1,6 @@
|
|||||||
import { describe, it } from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
|
|
||||||
import katex from "katex";
|
import katex from "katex";
|
||||||
|
|
||||||
import { katexOptions } from "../src/core/markdown/katex.ts";
|
import { katexOptions } from "../src/core/markdown/katex";
|
||||||
|
|
||||||
function render(expression: string) {
|
function render(expression: string) {
|
||||||
return katex.renderToString(expression, {
|
return katex.renderToString(expression, {
|
||||||
@ -14,26 +11,26 @@ function render(expression: string) {
|
|||||||
|
|
||||||
describe("markdown physics katex support", () => {
|
describe("markdown physics katex support", () => {
|
||||||
it("renders vector calculus operators", () => {
|
it("renders vector calculus operators", () => {
|
||||||
assert.doesNotThrow(() => {
|
expect(() => {
|
||||||
render("\\curl{\\vect{B}} = \\mu_0 \\vect{J} + \\mu_0 \\varepsilon_0 \\pdv{\\vect{E}}{t}");
|
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", () => {
|
it("renders quantum mechanics bra-ket notation", () => {
|
||||||
const html = render("\\braket{\\psi}{\\phi}");
|
const html = render("\\braket{\\psi}{\\phi}");
|
||||||
assert.ok(html.includes("⟨") && html.includes("⟩"));
|
expect(html.includes("⟨") && html.includes("⟩")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders vector magnitude formula with subscripts and square root", () => {
|
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})");
|
const html = render("(F_1) (F_2), (F=\\sqrt{F_1^2+F_2^2})");
|
||||||
assert.ok(html.includes("F"));
|
expect(html.includes("F")).toBeTruthy();
|
||||||
assert.ok(html.includes("₁") || html.includes("sub")); // subscript check
|
expect(html.includes("₁") || html.includes("sub")).toBeTruthy(); // subscript check
|
||||||
assert.ok(html.includes("√") || html.includes("sqrt")); // square root check
|
expect(html.includes("√") || html.includes("sqrt")).toBeTruthy(); // square root check
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders chemical equations via mhchem", () => {
|
it("renders chemical equations via mhchem", () => {
|
||||||
assert.doesNotThrow(() => {
|
expect(() => {
|
||||||
render("\\ce{H2O ->[\\Delta] H+ + OH-}");
|
render("\\ce{H2O ->[\\Delta] H+ + OH-}");
|
||||||
});
|
}).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,55 +1,52 @@
|
|||||||
import { describe, it } from "node:test";
|
import { normalizeMathForEditor, normalizeMathForDisplay, unescapeLatexInMath } from "../src/core/utils/markdown";
|
||||||
import assert from "node:assert/strict";
|
|
||||||
|
|
||||||
import { normalizeMathForEditor, normalizeMathForDisplay, unescapeLatexInMath } from "../src/core/utils/markdown.ts";
|
|
||||||
|
|
||||||
describe("markdown math normalization for editor", () => {
|
describe("markdown math normalization for editor", () => {
|
||||||
it("converts LaTeX display delimiters to $$ for editor", () => {
|
it("converts LaTeX display delimiters to $$ for editor", () => {
|
||||||
const input = "Here is a formula \\[E=mc^2\\] in the text.";
|
const input = "Here is a formula \\[E=mc^2\\] in the text.";
|
||||||
const output = normalizeMathForEditor(input);
|
const output = normalizeMathForEditor(input);
|
||||||
assert.strictEqual(output, "Here is a formula $$E=mc^2$$ in the text.");
|
expect(output).toBe("Here is a formula $$E=mc^2$$ in the text.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("converts LaTeX display delimiters to $ with \\ for editor", () => {
|
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 input = "Here is a formula \\(F = k\\frac{q_1q_2}{r^2}\\) in the text.";
|
||||||
const output = normalizeMathForEditor(input);
|
const output = normalizeMathForEditor(input);
|
||||||
assert.strictEqual(output, "Here is a formula $F = k\\frac{q_1q_2}{r^2}$ in the text.");
|
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", () => {
|
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 input = "Here is a formula \\(F = k\\\\frac{q_1q_2}{r^2}\\) in the text.";
|
||||||
const output = normalizeMathForEditor(input);
|
const output = normalizeMathForEditor(input);
|
||||||
assert.strictEqual(output, "Here is a formula $F = k\\frac{q_1q_2}{r^2}$ in the text.");
|
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", () => {
|
it("converts escaped LaTeX display delimiters to $$ for editor", () => {
|
||||||
const input = "Formula \\\\[x^2 + y^2 = z^2\\\\] here.";
|
const input = "Formula \\\\[x^2 + y^2 = z^2\\\\] here.";
|
||||||
const output = normalizeMathForEditor(input);
|
const output = normalizeMathForEditor(input);
|
||||||
assert.strictEqual(output, "Formula $$x^2 + y^2 = z^2$$ here.");
|
expect(output).toBe("Formula $$x^2 + y^2 = z^2$$ here.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("converts LaTeX inline delimiters to $ for editor", () => {
|
it("converts LaTeX inline delimiters to $ for editor", () => {
|
||||||
const input = "Inline formula \\(a + b = c\\) in text.";
|
const input = "Inline formula \\(a + b = c\\) in text.";
|
||||||
const output = normalizeMathForEditor(input);
|
const output = normalizeMathForEditor(input);
|
||||||
assert.strictEqual(output, "Inline formula $a + b = c$ in text.");
|
expect(output).toBe("Inline formula $a + b = c$ in text.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("converts escaped LaTeX inline delimiters to $ for editor", () => {
|
it("converts escaped LaTeX inline delimiters to $ for editor", () => {
|
||||||
const input = "Inline \\\\(x = 5\\\\) here.";
|
const input = "Inline \\\\(x = 5\\\\) here.";
|
||||||
const output = normalizeMathForEditor(input);
|
const output = normalizeMathForEditor(input);
|
||||||
assert.strictEqual(output, "Inline $x = 5$ here.");
|
expect(output).toBe("Inline $x = 5$ here.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles mixed delimiters for editor", () => {
|
it("handles mixed delimiters for editor", () => {
|
||||||
const input = "Display \\[E=mc^2\\] and inline \\(F=ma\\) formulas.";
|
const input = "Display \\[E=mc^2\\] and inline \\(F=ma\\) formulas.";
|
||||||
const output = normalizeMathForEditor(input);
|
const output = normalizeMathForEditor(input);
|
||||||
assert.strictEqual(output, "Display $$E=mc^2$$ and inline $F=ma$ formulas.");
|
expect(output).toBe("Display $$E=mc^2$$ and inline $F=ma$ formulas.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves already normalized math syntax for editor", () => {
|
it("preserves already normalized math syntax for editor", () => {
|
||||||
const input = "Already normalized $$E=mc^2$$ and $F=ma$ formulas.";
|
const input = "Already normalized $$E=mc^2$$ and $F=ma$ formulas.";
|
||||||
const output = normalizeMathForEditor(input);
|
const output = normalizeMathForEditor(input);
|
||||||
assert.strictEqual(output, "Already normalized $$E=mc^2$$ and $F=ma$ formulas.");
|
expect(output).toBe("Already normalized $$E=mc^2$$ and $F=ma$ formulas.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -57,38 +54,38 @@ describe("markdown math normalization for display", () => {
|
|||||||
it("converts LaTeX display delimiters to $$ for display", () => {
|
it("converts LaTeX display delimiters to $$ for display", () => {
|
||||||
const input = "Here is a formula \\[E=mc^2\\] in the text.";
|
const input = "Here is a formula \\[E=mc^2\\] in the text.";
|
||||||
const output = normalizeMathForDisplay(input);
|
const output = normalizeMathForDisplay(input);
|
||||||
assert.strictEqual(output, "Here is a formula $$E=mc^2$$ in the text.");
|
expect(output).toBe("Here is a formula $$E=mc^2$$ in the text.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("converts escaped LaTeX display delimiters to $$ for display", () => {
|
it("converts escaped LaTeX display delimiters to $$ for display", () => {
|
||||||
const input = "Formula \\\\[x^2 + y^2 = z^2\\\\] here.";
|
const input = "Formula \\\\[x^2 + y^2 = z^2\\\\] here.";
|
||||||
const output = normalizeMathForDisplay(input);
|
const output = normalizeMathForDisplay(input);
|
||||||
assert.strictEqual(output, "Formula $$x^2 + y^2 = z^2$$ here.");
|
expect(output).toBe("Formula $$x^2 + y^2 = z^2$$ here.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("converts LaTeX inline delimiters to $$ for display", () => {
|
it("converts LaTeX inline delimiters to $$ for display", () => {
|
||||||
const input = "Inline formula \\(a + b = c\\) in text.";
|
const input = "Inline formula \\(a + b = c\\) in text.";
|
||||||
const output = normalizeMathForDisplay(input);
|
const output = normalizeMathForDisplay(input);
|
||||||
assert.strictEqual(output, "Inline formula $$a + b = c$$ in text.");
|
expect(output).toBe("Inline formula $$a + b = c$$ in text.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("converts escaped LaTeX inline delimiters to $$ for display", () => {
|
it("converts escaped LaTeX inline delimiters to $$ for display", () => {
|
||||||
const input = "Inline \\\\(x = 5\\\\) here.";
|
const input = "Inline \\\\(x = 5\\\\) here.";
|
||||||
const output = normalizeMathForDisplay(input);
|
const output = normalizeMathForDisplay(input);
|
||||||
assert.strictEqual(output, "Inline $$x = 5$$ here.");
|
expect(output).toBe("Inline $$x = 5$$ here.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles mixed delimiters for display", () => {
|
it("handles mixed delimiters for display", () => {
|
||||||
const input = "Display \\[E=mc^2\\] and inline \\(F=ma\\) formulas.";
|
const input = "Display \\[E=mc^2\\] and inline \\(F=ma\\) formulas.";
|
||||||
const output = normalizeMathForDisplay(input);
|
const output = normalizeMathForDisplay(input);
|
||||||
assert.strictEqual(output, "Display $$E=mc^2$$ and inline $$F=ma$$ formulas.");
|
expect(output).toBe("Display $$E=mc^2$$ and inline $$F=ma$$ formulas.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles complex physics formulas", () => {
|
it("handles complex physics formulas", () => {
|
||||||
const input = "Maxwell equation: \\[\\nabla \\times \\vec{E} = -\\frac{\\partial \\vec{B}}{\\partial t}\\]";
|
const input = "Maxwell equation: \\[\\nabla \\times \\vec{E} = -\\frac{\\partial \\vec{B}}{\\partial t}\\]";
|
||||||
const output = normalizeMathForDisplay(input);
|
const output = normalizeMathForDisplay(input);
|
||||||
assert.ok(output.includes("$$"));
|
expect(output.includes("$$")).toBeTruthy();
|
||||||
assert.ok(output.includes("nabla"));
|
expect(output.includes("nabla")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -98,8 +95,8 @@ describe("markdown math round-trip consistency", () => {
|
|||||||
const forEditor = normalizeMathForEditor(original);
|
const forEditor = normalizeMathForEditor(original);
|
||||||
|
|
||||||
// Simulate editor output (should have $ and $$)
|
// Simulate editor output (should have $ and $$)
|
||||||
assert.ok(forEditor.includes("$$"));
|
expect(forEditor.includes("$$")).toBeTruthy();
|
||||||
assert.ok(forEditor.includes("$"));
|
expect(forEditor.includes("$")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles multiple formulas correctly", () => {
|
it("handles multiple formulas correctly", () => {
|
||||||
@ -117,16 +114,16 @@ Momentum: \\[p = mv\\]
|
|||||||
const forDisplay = normalizeMathForDisplay(input);
|
const forDisplay = normalizeMathForDisplay(input);
|
||||||
|
|
||||||
// Both should have converted the delimiters
|
// Both should have converted the delimiters
|
||||||
assert.ok(forEditor.includes("$$"));
|
expect(forEditor.includes("$$")).toBeTruthy();
|
||||||
assert.ok(forDisplay.includes("$$"));
|
expect(forDisplay.includes("$$")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves text content around formulas", () => {
|
it("preserves text content around formulas", () => {
|
||||||
const input = "Text before \\[E=mc^2\\] text after";
|
const input = "Text before \\[E=mc^2\\] text after";
|
||||||
const output = normalizeMathForEditor(input);
|
const output = normalizeMathForEditor(input);
|
||||||
|
|
||||||
assert.ok(output.startsWith("Text before"));
|
expect(output.startsWith("Text before")).toBeTruthy();
|
||||||
assert.ok(output.endsWith("text after"));
|
expect(output.endsWith("text after")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -134,71 +131,71 @@ describe("markdown math unescape (issue #608 fix)", () => {
|
|||||||
it("unescapes asterisks in inline math", () => {
|
it("unescapes asterisks in inline math", () => {
|
||||||
const escaped = "Formula $(f \\* g)(t) = t^2$";
|
const escaped = "Formula $(f \\* g)(t) = t^2$";
|
||||||
const unescaped = unescapeLatexInMath(escaped);
|
const unescaped = unescapeLatexInMath(escaped);
|
||||||
assert.strictEqual(unescaped, "Formula $(f * g)(t) = t^2$");
|
expect(unescaped).toBe("Formula $(f * g)(t) = t^2$");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unescapes underscores in display math", () => {
|
it("unescapes underscores in display math", () => {
|
||||||
const escaped = "Formula $$x\\_{n+1} = x_n - f(x_n)/f'(x_n)$$";
|
const escaped = "Formula $$x\\_{n+1} = x_n - f(x_n)/f'(x_n)$$";
|
||||||
const unescaped = unescapeLatexInMath(escaped);
|
const unescaped = unescapeLatexInMath(escaped);
|
||||||
assert.strictEqual(unescaped, "Formula $$x_{n+1} = x_n - f(x_n)/f'(x_n)$$");
|
expect(unescaped).toBe("Formula $$x_{n+1} = x_n - f(x_n)/f'(x_n)$$");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unescapes backslashes for LaTeX commands", () => {
|
it("unescapes backslashes for LaTeX commands", () => {
|
||||||
const escaped = "Formula $$\\\\int_{-\\\\infty}^{\\\\infty} f(x)dx$$";
|
const escaped = "Formula $$\\\\int_{-\\\\infty}^{\\\\infty} f(x)dx$$";
|
||||||
const unescaped = unescapeLatexInMath(escaped);
|
const unescaped = unescapeLatexInMath(escaped);
|
||||||
assert.strictEqual(unescaped, "Formula $$\\int_{-\\infty}^{\\infty} f(x)dx$$");
|
expect(unescaped).toBe("Formula $$\\int_{-\\infty}^{\\infty} f(x)dx$$");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unescapes square brackets in math", () => {
|
it("unescapes square brackets in math", () => {
|
||||||
const escaped = "Array $a\\[0\\] = b$ and $$c\\[n\\] = d$$";
|
const escaped = "Array $a\\[0\\] = b$ and $$c\\[n\\] = d$$";
|
||||||
const unescaped = unescapeLatexInMath(escaped);
|
const unescaped = unescapeLatexInMath(escaped);
|
||||||
assert.strictEqual(unescaped, "Array $a[0] = b$ and $$c[n] = d$$");
|
expect(unescaped).toBe("Array $a[0] = b$ and $$c[n] = d$$");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles complex formula from issue #608", () => {
|
it("handles complex formula from issue #608", () => {
|
||||||
const escaped = `| Discrete | $(f \\* g)\\[n\\] = \\\\sum\\_{k=-\\\\infty}^{\\\\infty} f\\[k\\]g\\[n-k\\]$ |`;
|
const escaped = `| Discrete | $(f \\* g)\\[n\\] = \\\\sum\\_{k=-\\\\infty}^{\\\\infty} f\\[k\\]g\\[n-k\\]$ |`;
|
||||||
const unescaped = unescapeLatexInMath(escaped);
|
const unescaped = unescapeLatexInMath(escaped);
|
||||||
// Should unescape special characters within math delimiters
|
// Should unescape special characters within math delimiters
|
||||||
assert.ok(unescaped.includes("(f * g)"));
|
expect(unescaped.includes("(f * g)")).toBeTruthy();
|
||||||
assert.ok(unescaped.includes("[n]"));
|
expect(unescaped.includes("[n]")).toBeTruthy();
|
||||||
assert.ok(unescaped.includes("\\sum"));
|
expect(unescaped.includes("\\sum")).toBeTruthy();
|
||||||
assert.ok(unescaped.includes("_{k"));
|
expect(unescaped.includes("_{k")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves text outside math delimiters", () => {
|
it("preserves text outside math delimiters", () => {
|
||||||
const escaped = "Before $a \\* b$ middle $$c \\* d$$ after";
|
const escaped = "Before $a \\* b$ middle $$c \\* d$$ after";
|
||||||
const unescaped = unescapeLatexInMath(escaped);
|
const unescaped = unescapeLatexInMath(escaped);
|
||||||
assert.ok(unescaped.startsWith("Before"));
|
expect(unescaped.startsWith("Before")).toBeTruthy();
|
||||||
assert.ok(unescaped.endsWith("after"));
|
expect(unescaped.endsWith("after")).toBeTruthy();
|
||||||
assert.ok(unescaped.includes("middle"));
|
expect(unescaped.includes("middle")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles mixed escaped and unescaped characters", () => {
|
it("handles mixed escaped and unescaped characters", () => {
|
||||||
const escaped = "$$f(x) = \\\\int_0^\\\\infty e^{-x^2} \\* dx$$";
|
const escaped = "$$f(x) = \\\\int_0^\\\\infty e^{-x^2} \\* dx$$";
|
||||||
const unescaped = unescapeLatexInMath(escaped);
|
const unescaped = unescapeLatexInMath(escaped);
|
||||||
assert.strictEqual(unescaped, "$$f(x) = \\int_0^\\infty e^{-x^2} * dx$$");
|
expect(unescaped).toBe("$$f(x) = \\int_0^\\infty e^{-x^2} * dx$$");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles multiple inline formulas", () => {
|
it("handles multiple inline formulas", () => {
|
||||||
const escaped = "Formulas $a \\* b$ and $c \\* d$ and $e \\* f$";
|
const escaped = "Formulas $a \\* b$ and $c \\* d$ and $e \\* f$";
|
||||||
const unescaped = unescapeLatexInMath(escaped);
|
const unescaped = unescapeLatexInMath(escaped);
|
||||||
const matches = unescaped.match(/\* /g);
|
const matches = unescaped.match(/\* /g);
|
||||||
assert.strictEqual(matches?.length, 3);
|
expect(matches?.length).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not modify non-formula text with backslashes", () => {
|
it("does not modify non-formula text with backslashes", () => {
|
||||||
const text = "Use \\* in text and $a \\* b$ in formula";
|
const text = "Use \\* in text and $a \\* b$ in formula";
|
||||||
const unescaped = unescapeLatexInMath(text);
|
const unescaped = unescapeLatexInMath(text);
|
||||||
// Text outside formulas should not be changed
|
// Text outside formulas should not be changed
|
||||||
assert.ok(unescaped.includes("Use \\*"));
|
expect(unescaped.includes("Use \\*")).toBeTruthy();
|
||||||
assert.ok(unescaped.includes("a * b"));
|
expect(unescaped.includes("a * b")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles edge case of empty math delimiters", () => {
|
it("handles edge case of empty math delimiters", () => {
|
||||||
const escaped = "Empty $$ and $$$$";
|
const escaped = "Empty $$ and $$$$";
|
||||||
const unescaped = unescapeLatexInMath(escaped);
|
const unescaped = unescapeLatexInMath(escaped);
|
||||||
// Should not crash, just return as-is
|
// Should not crash, just return as-is
|
||||||
assert.ok(typeof unescaped === "string");
|
expect(typeof unescaped === "string").toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trip test: escaped content → unescape → original", () => {
|
it("round-trip test: escaped content → unescape → original", () => {
|
||||||
@ -210,9 +207,9 @@ describe("markdown math unescape (issue #608 fix)", () => {
|
|||||||
const unescaped = unescapeLatexInMath(escapedByTiptap);
|
const unescaped = unescapeLatexInMath(escapedByTiptap);
|
||||||
|
|
||||||
// Should restore formula content and preserve backslash sequences
|
// Should restore formula content and preserve backslash sequences
|
||||||
assert.ok(unescaped.includes("(f * g)"));
|
expect(unescaped.includes("(f * g)")).toBeTruthy();
|
||||||
assert.ok(unescaped.includes("[n]"));
|
expect(unescaped.includes("[n]")).toBeTruthy();
|
||||||
assert.ok(unescaped.includes("\\sum"));
|
expect(unescaped.includes("\\sum")).toBeTruthy();
|
||||||
assert.ok(unescaped.includes("f[k]"));
|
expect(unescaped.includes("f[k]")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
420
web/tests/message-list-view.test.tsx
Normal file
420
web/tests/message-list-view.test.tsx
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
507
web/tests/store.test.ts
Normal file
507
web/tests/store.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -38,5 +38,5 @@
|
|||||||
"**/*.js",
|
"**/*.js",
|
||||||
".next/types/**/*.ts"
|
".next/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules", "tests/**/*.test.ts"]
|
"exclude": ["node_modules", "tests/**/*.test.ts", "tests/**/*.test.tsx"]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user