Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 231 additions & 0 deletions app/extension/src/__tests__/AssistantMessage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
/** @jest-environment jsdom */

/// <reference types="node" />

import { afterEach, describe, expect, it, jest } from "@jest/globals";
import { act } from "react-dom/test-utils";
import { createRoot } from "react-dom/client";

import { AssistantMessage } from "../sidepanel/components/AssistantMessage";
import type { ChatMessage } from "../sidepanel/types";

jest.mock("../sidepanel/components/AssistantStatusCard", () => ({
AssistantStatusCard: () => null,
}));

jest.mock("../sidepanel/components/IconButton", () => ({
IconButton: ({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
}) => {
const React = require("react");
return React.createElement("button", { type: "button", onClick }, children);
},
}));

jest.mock("../sidepanel/components/LinkCardsBlock", () => ({
LinkCardsBlock: () => null,
}));

jest.mock("../sidepanel/components/MarkdownContent", () => ({
MarkdownContent: ({ text }: { text: string }) => {
const React = require("react");
return React.createElement("div", null, text);
},
}));

jest.mock("../sidepanel/components/MessageFooter", () => ({
MessageFooter: ({ children }: { children: React.ReactNode }) => {
const React = require("react");
return React.createElement("div", null, children);
},
}));

jest.mock("../sidepanel/components/ReasoningBlock", () => ({
ReasoningBlock: ({ text }: { text: string }) => {
const React = require("react");
return React.createElement("div", null, text);
},
}));

jest.mock("../sidepanel/components/ToolCallBlock", () => ({
ToolCallBlock: () => null,
}));

jest.mock("../i18n", () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}));

(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;

function renderAssistantMessage(
props: Partial<React.ComponentProps<typeof AssistantMessage>> = {}
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const message: ChatMessage = {
id: "assistant-1",
role: "assistant",
parts: [],
status: "running",
};

act(() => {
root.render(
<AssistantMessage
isLast={true}
isRunning={true}
message={message}
thinkingMode={false}
{...props}
/>
);
});

return {
container,
cleanup: () => {
act(() => root.unmount());
container.remove();
},
};
}

describe("AssistantMessage", () => {
afterEach(() => {
document.body.innerHTML = "";
});

it("shows a preparing response indicator before assistant text arrives", () => {
const { container, cleanup } = renderAssistantMessage();
const indicator = container.querySelector('[role="status"]');

expect(indicator?.getAttribute("aria-label")).toBe("common.loading");
expect(container.querySelectorAll(".claude-dot")).toHaveLength(3);
expect(indicator?.className).not.toContain("rounded-full");
expect(indicator?.className).not.toContain("border");

cleanup();
});

it("keeps the preparing indicator visible for a leading step-start part", () => {
const { container, cleanup } = renderAssistantMessage({
message: {
id: "assistant-2",
role: "assistant",
parts: [{ type: "step-start" }],
status: "running",
},
});

expect(container.querySelector('[role="status"]')).not.toBeNull();
expect(container.querySelectorAll(".claude-dot")).toHaveLength(3);

cleanup();
});

it("keeps the preparing indicator visible while reasoning is streaming", () => {
const { container, cleanup } = renderAssistantMessage({
message: {
id: "assistant-3",
role: "assistant",
parts: [{ type: "reasoning", text: "Thinking", streaming: true }],
status: "running",
},
thinkingMode: true,
});

expect(container.querySelector('[role="status"]')).not.toBeNull();
expect(container.textContent).toContain("Thinking");

cleanup();
});

it("keeps the preparing indicator visible while a tool call is running", () => {
const { container, cleanup } = renderAssistantMessage({
message: {
id: "assistant-4",
role: "assistant",
parts: [
{
type: "tool-call",
toolCallId: "tool-1",
toolName: "search_web",
args: { query: "huntly" },
},
],
status: "running",
},
});

expect(container.querySelector('[role="status"]')).not.toBeNull();

cleanup();
});

it("shows the preparing indicator again after earlier text when a tool call starts", () => {
const { container, cleanup } = renderAssistantMessage({
message: {
id: "assistant-5",
role: "assistant",
parts: [
{ type: "text", text: "先给你一个结论。" },
{
type: "tool-call",
toolCallId: "tool-2",
toolName: "search_web",
args: { query: "huntly" },
},
],
status: "running",
},
});

expect(container.querySelector('[role="status"]')).not.toBeNull();

cleanup();
});

it("shows the preparing indicator again after earlier text when a new step starts", () => {
const { container, cleanup } = renderAssistantMessage({
message: {
id: "assistant-6",
role: "assistant",
parts: [
{ type: "text", text: "先给你一个结论。" },
{ type: "step-start" },
],
status: "running",
},
});

expect(container.querySelector('[role="status"]')).not.toBeNull();

cleanup();
});

it("hides the preparing indicator once visible text arrives", () => {
const { container, cleanup } = renderAssistantMessage({
message: {
id: "assistant-7",
role: "assistant",
parts: [{ type: "text", text: "hello" }],
status: "running",
},
});

expect(container.querySelector('[role="status"]')).toBeNull();

cleanup();
});
});
59 changes: 59 additions & 0 deletions app/extension/src/__tests__/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
getOpenAICompatibleBaseUrl,
getOllamaBaseUrl,
getOllamaOpenAIBaseUrl,
usesRawOpenAICompatibleStream,
} from "../ai/openAICompatibleProviders";
import { getEffectiveApiFormat, PROVIDER_REGISTRY } from "../ai/types";

Expand Down Expand Up @@ -44,6 +45,64 @@ describe("providers helpers", () => {
);
});

it("uses raw OpenAI-compatible streaming for providers that need explicit thinking control", () => {
expect(
usesRawOpenAICompatibleStream({
type: "qwen",
enabled: true,
apiKey: "test",
baseUrl: "",
enabledModels: ["qwen3.5-plus"],
updatedAt: Date.now(),
})
).toBe(true);

expect(
usesRawOpenAICompatibleStream({
type: "zhipu",
enabled: true,
apiKey: "test",
baseUrl: "",
enabledModels: ["glm-5"],
updatedAt: Date.now(),
})
).toBe(true);

expect(
usesRawOpenAICompatibleStream({
type: "openai",
enabled: true,
apiKey: "test",
baseUrl: "https://api.openai.com/v1",
enabledModels: ["gpt-4.1"],
updatedAt: Date.now(),
})
).toBe(false);

expect(
usesRawOpenAICompatibleStream({
type: "openai",
enabled: true,
apiKey: "test",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
enabledModels: ["qwen-plus"],
updatedAt: Date.now(),
})
).toBe(false);

expect(
usesRawOpenAICompatibleStream({
type: "qwen",
enabled: true,
apiKey: "test",
baseUrl: "",
enabledModels: ["qwen3.5-plus"],
updatedAt: Date.now(),
apiFormat: "anthropic",
})
).toBe(false);
});

it("falls back to the provider native format when no override is given", () => {
expect(getEffectiveApiFormat({ type: "qwen" })).toBe("openai");
expect(getEffectiveApiFormat({ type: "anthropic" })).toBe("anthropic");
Expand Down
8 changes: 8 additions & 0 deletions app/extension/src/__tests__/thinkingMode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { getThinkingModeOptions } from "../ai/thinkingMode";

describe("thinking mode helpers", () => {
it("always sends an explicit enable_thinking flag", () => {
expect(getThinkingModeOptions(true)).toEqual({ enable_thinking: true });
expect(getThinkingModeOptions(false)).toEqual({ enable_thinking: false });
});
});
24 changes: 23 additions & 1 deletion app/extension/src/ai/openAICompatibleProviders.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { AIProviderConfig, PROVIDER_REGISTRY } from "./types";
import {
AIProviderConfig,
getEffectiveApiFormat,
PROVIDER_REGISTRY,
} from "./types";

function trimTrailingSlash(url: string): string {
return url.replace(/\/+$/, "");
Expand All @@ -12,6 +16,24 @@ export function getProviderBaseUrl(config: AIProviderConfig): string | undefined
);
}

export function usesRawOpenAICompatibleStream(
config: AIProviderConfig
): boolean {
const format = getEffectiveApiFormat({
type: config.type,
apiFormat: config.apiFormat,
});
if (format !== "openai") {
return false;
}

if (PROVIDER_REGISTRY[config.type]?.requiresRawOpenAICompatibleStream) {
return true;
}

return false;
}

/**
* @deprecated use {@link getProviderBaseUrl}. Kept for call sites still being migrated.
*/
Expand Down
Loading
Loading