From 8f00321a60138af43637171941afebc3e6f9e469 Mon Sep 17 00:00:00 2001
From: fullex <106392080+0xfullex@users.noreply.github.com>
Date: Thu, 27 Nov 2025 10:31:34 +0800
Subject: [PATCH 03/17] fix: inconsistent text color in release notes last line
(#11480)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Move color and font-size styles from p selector to container level
in UpdateNotesWrapper. This ensures all content (including li elements
not wrapped in p tags) uses consistent color.
The issue occurred because .replace(/\n/g, '\n\n') creates a "loose list"
in Markdown where most list items get wrapped in
tags, but the last
item (without trailing newline) may not, causing it to inherit a different
color from the parent .markdown class.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude
---
src/renderer/src/pages/settings/AboutSettings.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/renderer/src/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx
index 5feb2fa5bb..3bf02a518d 100644
--- a/src/renderer/src/pages/settings/AboutSettings.tsx
+++ b/src/renderer/src/pages/settings/AboutSettings.tsx
@@ -404,11 +404,11 @@ const UpdateNotesWrapper = styled.div`
margin: 8px 0;
background-color: var(--color-bg-2);
border-radius: 6px;
+ color: var(--color-text-2);
+ font-size: 14px;
p {
margin: 0;
- color: var(--color-text-2);
- font-size: 14px;
}
`
From a2f67dddb6ed64646101fd7e10abbfdb9edb8ce0 Mon Sep 17 00:00:00 2001
From: MyPrototypeWhat
Date: Thu, 27 Nov 2025 13:41:33 +0800
Subject: [PATCH 04/17] fix: resolve readonly property error in assistant
preset settings (#11491)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When updating assistant preset settings, if agent.settings was undefined,
it was assigned the DEFAULT_ASSISTANT_SETTINGS object directly. Since this
object is defined with `as const`, it is readonly and subsequent property
assignments would fail with "Cannot assign to read only property".
Fixed by creating a shallow copy of DEFAULT_ASSISTANT_SETTINGS instead of
referencing it directly.
Closes #11490
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude
---
src/renderer/src/store/assistants.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/renderer/src/store/assistants.ts b/src/renderer/src/store/assistants.ts
index 4db73a9547..51638be9f6 100644
--- a/src/renderer/src/store/assistants.ts
+++ b/src/renderer/src/store/assistants.ts
@@ -216,7 +216,7 @@ const assistantsSlice = createSlice({
if (agent.id === action.payload.assistantId) {
for (const key in settings) {
if (!agent.settings) {
- agent.settings = DEFAULT_ASSISTANT_SETTINGS
+ agent.settings = { ...DEFAULT_ASSISTANT_SETTINGS }
}
agent.settings[key] = settings[key]
}
From d15571c727deac1bea7771a6806935913ee0ca93 Mon Sep 17 00:00:00 2001
From: fullex <106392080+0xfullex@users.noreply.github.com>
Date: Thu, 27 Nov 2025 14:05:14 +0800
Subject: [PATCH 05/17] fix(code-tools): support Chinese paths and validate
directory existence (#11489)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add `chcp 65001` to Windows batch file to switch CMD.exe to UTF-8 code page,
fixing CLI tool launch failure when working directory contains Chinese or
other non-ASCII characters
- Add directory existence validation before launching terminal to provide
immediate error feedback instead of delayed failure
Closes #11483
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude
---
src/main/services/CodeToolsService.ts | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts
index 82c9c64f87..35655a88e7 100644
--- a/src/main/services/CodeToolsService.ts
+++ b/src/main/services/CodeToolsService.ts
@@ -548,6 +548,17 @@ class CodeToolsService {
logger.debug(`Environment variables:`, Object.keys(env))
logger.debug(`Options:`, options)
+ // Validate directory exists before proceeding
+ if (!directory || !fs.existsSync(directory)) {
+ const errorMessage = `Directory does not exist: ${directory}`
+ logger.error(errorMessage)
+ return {
+ success: false,
+ message: errorMessage,
+ command: ''
+ }
+ }
+
const packageName = await this.getPackageName(cliTool)
const bunPath = await this.getBunPath()
const executableName = await this.getCliExecutableName(cliTool)
@@ -709,6 +720,7 @@ class CodeToolsService {
// Build bat file content, including debug information
const batContent = [
'@echo off',
+ 'chcp 65001 >nul 2>&1', // Switch to UTF-8 code page for international path support
`title ${cliTool} - Cherry Studio`, // Set window title in bat file
'echo ================================================',
'echo Cherry Studio CLI Tool Launcher',
From d8191bd4fb9a0ff4ea951aa857a6f6c0b196b35f Mon Sep 17 00:00:00 2001
From: Phantom
Date: Thu, 27 Nov 2025 17:22:33 +0800
Subject: [PATCH 06/17] refactor: improve verbosity configuration with
type-safe validators (#11463)
* refactor(models): improve verbosity level handling for GPT-5 models
Replace hardcoded verbosity configuration with validator functions
Add support for GPT-5.1 series models
* test(models): restructure model utility tests into logical groups
Improve test organization by grouping related test cases under descriptive describe blocks for better maintainability and readability. Each model utility function now has its own dedicated test section with clear subcategories for different behaviors.
* fix: add null check for model in getModelSupportedVerbosity
Handle null model case defensively by returning default verbosity
* refactor(config): remove redundant as const from MODEL_SUPPORTED_VERBOSITY array
* refactor(models): simplify validator function in MODEL_SUPPORTED_VERBOSITY
* test(model utils): add tests for undefined/null input handling
* fix(models): handle undefined/null input in getModelSupportedVerbosity
Remove ts-expect-error comments and update type signature to explicitly handle undefined/null inputs. Also add support for GPT-5.1 series models.
* test(models): add test case for gpt-5-pro variant model
---
.../src/config/models/__tests__/utils.test.ts | 508 ++++++++++++------
src/renderer/src/config/models/utils.ts | 58 +-
2 files changed, 387 insertions(+), 179 deletions(-)
diff --git a/src/renderer/src/config/models/__tests__/utils.test.ts b/src/renderer/src/config/models/__tests__/utils.test.ts
index f3f4d402af..a163061ea1 100644
--- a/src/renderer/src/config/models/__tests__/utils.test.ts
+++ b/src/renderer/src/config/models/__tests__/utils.test.ts
@@ -125,195 +125,371 @@ describe('model utils', () => {
openAIWebSearchOnlyMock.mockReturnValue(false)
})
- it('detects OpenAI LLM models through reasoning and GPT prefix', () => {
- expect(isOpenAILLMModel(undefined as unknown as Model)).toBe(false)
- expect(isOpenAILLMModel(createModel({ id: 'gpt-4o-image' }))).toBe(false)
+ describe('OpenAI model detection', () => {
+ describe('isOpenAILLMModel', () => {
+ it('returns false for undefined model', () => {
+ expect(isOpenAILLMModel(undefined as unknown as Model)).toBe(false)
+ })
- reasoningMock.mockReturnValueOnce(true)
- expect(isOpenAILLMModel(createModel({ id: 'o1-preview' }))).toBe(true)
+ it('returns false for image generation models', () => {
+ expect(isOpenAILLMModel(createModel({ id: 'gpt-4o-image' }))).toBe(false)
+ })
- expect(isOpenAILLMModel(createModel({ id: 'GPT-5-turbo' }))).toBe(true)
- })
+ it('returns true for reasoning models', () => {
+ reasoningMock.mockReturnValueOnce(true)
+ expect(isOpenAILLMModel(createModel({ id: 'o1-preview' }))).toBe(true)
+ })
- it('detects OpenAI models via GPT prefix or reasoning support', () => {
- expect(isOpenAIModel(createModel({ id: 'gpt-4.1' }))).toBe(true)
- reasoningMock.mockReturnValueOnce(true)
- expect(isOpenAIModel(createModel({ id: 'o3' }))).toBe(true)
- })
-
- it('evaluates support for flex service tier and alias helper', () => {
- expect(isSupportFlexServiceTierModel(createModel({ id: 'o3' }))).toBe(true)
- expect(isSupportFlexServiceTierModel(createModel({ id: 'o3-mini' }))).toBe(false)
- expect(isSupportFlexServiceTierModel(createModel({ id: 'o4-mini' }))).toBe(true)
- expect(isSupportFlexServiceTierModel(createModel({ id: 'gpt-5-preview' }))).toBe(true)
- expect(isSupportedFlexServiceTier(createModel({ id: 'gpt-4o' }))).toBe(false)
- })
-
- it('detects verbosity support for GPT-5+ families', () => {
- expect(isSupportVerbosityModel(createModel({ id: 'gpt-5' }))).toBe(true)
- expect(isSupportVerbosityModel(createModel({ id: 'gpt-5-chat' }))).toBe(false)
- expect(isSupportVerbosityModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(true)
- })
-
- it('limits verbosity controls for GPT-5 Pro models', () => {
- const proModel = createModel({ id: 'gpt-5-pro' })
- const previewModel = createModel({ id: 'gpt-5-preview' })
- expect(getModelSupportedVerbosity(proModel)).toEqual([undefined, 'high'])
- expect(getModelSupportedVerbosity(previewModel)).toEqual([undefined, 'low', 'medium', 'high'])
- expect(isGPT5ProModel(proModel)).toBe(true)
- expect(isGPT5ProModel(previewModel)).toBe(false)
- })
-
- it('identifies OpenAI chat-completion-only models', () => {
- expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'gpt-4o-search-preview' }))).toBe(true)
- expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'o1-mini' }))).toBe(true)
- expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'gpt-4o' }))).toBe(false)
- })
-
- it('filters unsupported OpenAI catalog entries', () => {
- expect(isSupportedModel({ id: 'gpt-4', object: 'model' } as any)).toBe(true)
- expect(isSupportedModel({ id: 'tts-1', object: 'model' } as any)).toBe(false)
- })
-
- it('calculates temperature/top-p support correctly', () => {
- const model = createModel({ id: 'o1' })
- reasoningMock.mockReturnValue(true)
- expect(isNotSupportTemperatureAndTopP(model)).toBe(true)
-
- const openWeight = createModel({ id: 'gpt-oss-debug' })
- expect(isNotSupportTemperatureAndTopP(openWeight)).toBe(false)
-
- const chatOnly = createModel({ id: 'o1-preview' })
- reasoningMock.mockReturnValue(false)
- expect(isNotSupportTemperatureAndTopP(chatOnly)).toBe(true)
-
- const qwenMt = createModel({ id: 'qwen-mt-large', provider: 'aliyun' })
- expect(isNotSupportTemperatureAndTopP(qwenMt)).toBe(true)
- })
-
- it('handles gemma and gemini detections plus zhipu tagging', () => {
- expect(isGemmaModel(createModel({ id: 'Gemma-3-27B' }))).toBe(true)
- expect(isGemmaModel(createModel({ group: 'Gemma' }))).toBe(true)
- expect(isGemmaModel(createModel({ id: 'gpt-4o' }))).toBe(false)
-
- expect(isGeminiModel(createModel({ id: 'Gemini-2.0' }))).toBe(true)
-
- expect(isZhipuModel(createModel({ provider: 'zhipu' }))).toBe(true)
- expect(isZhipuModel(createModel({ provider: 'openai' }))).toBe(false)
- })
-
- it('groups qwen models by prefix', () => {
- const qwen = createModel({ id: 'Qwen-7B', provider: 'qwen', name: 'Qwen-7B' })
- const qwenOmni = createModel({ id: 'qwen2.5-omni', name: 'qwen2.5-omni' })
- const other = createModel({ id: 'deepseek-v3', group: 'DeepSeek' })
-
- const grouped = groupQwenModels([qwen, qwenOmni, other])
- expect(Object.keys(grouped)).toContain('qwen-7b')
- expect(Object.keys(grouped)).toContain('qwen2.5')
- expect(grouped.DeepSeek).toContain(other)
- })
-
- it('aggregates boolean helpers based on regex rules', () => {
- expect(isAnthropicModel(createModel({ id: 'claude-3.5' }))).toBe(true)
- expect(isQwenMTModel(createModel({ id: 'qwen-mt-plus' }))).toBe(true)
- expect(isNotSupportSystemMessageModel(createModel({ id: 'gemma-moe' }))).toBe(true)
- expect(isOpenAIOpenWeightModel(createModel({ id: 'gpt-oss-free' }))).toBe(true)
- })
-
- describe('isNotSupportedTextDelta', () => {
- it('returns true for qwen-mt-turbo and qwen-mt-plus models', () => {
- // qwen-mt series that don't support text delta
- expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-turbo' }))).toBe(true)
- expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-plus' }))).toBe(true)
- expect(isNotSupportTextDeltaModel(createModel({ id: 'Qwen-MT-Turbo' }))).toBe(true)
- expect(isNotSupportTextDeltaModel(createModel({ id: 'QWEN-MT-PLUS' }))).toBe(true)
+ it('returns true for GPT-prefixed models', () => {
+ expect(isOpenAILLMModel(createModel({ id: 'GPT-5-turbo' }))).toBe(true)
+ })
})
- it('returns false for qwen-mt-flash and other models', () => {
- // qwen-mt-flash supports text delta
- expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-flash' }))).toBe(false)
- expect(isNotSupportTextDeltaModel(createModel({ id: 'Qwen-MT-Flash' }))).toBe(false)
+ describe('isOpenAIModel', () => {
+ it('detects models via GPT prefix', () => {
+ expect(isOpenAIModel(createModel({ id: 'gpt-4.1' }))).toBe(true)
+ })
- // Legacy qwen models without mt prefix (support text delta)
- expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-turbo' }))).toBe(false)
- expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-plus' }))).toBe(false)
-
- // Other qwen models
- expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-max' }))).toBe(false)
- expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen2.5-72b' }))).toBe(false)
- expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-vl-plus' }))).toBe(false)
-
- // Non-qwen models
- expect(isNotSupportTextDeltaModel(createModel({ id: 'gpt-4o' }))).toBe(false)
- expect(isNotSupportTextDeltaModel(createModel({ id: 'claude-3.5' }))).toBe(false)
- expect(isNotSupportTextDeltaModel(createModel({ id: 'glm-4-plus' }))).toBe(false)
+ it('detects models via reasoning support', () => {
+ reasoningMock.mockReturnValueOnce(true)
+ expect(isOpenAIModel(createModel({ id: 'o3' }))).toBe(true)
+ })
})
- it('handles models with version suffixes', () => {
- // qwen-mt models with version suffixes
- expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-turbo-1201' }))).toBe(true)
- expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-plus-0828' }))).toBe(true)
+ describe('isOpenAIChatCompletionOnlyModel', () => {
+ it('identifies chat-completion-only models', () => {
+ expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'gpt-4o-search-preview' }))).toBe(true)
+ expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'o1-mini' }))).toBe(true)
+ })
- // Legacy qwen models with version suffixes (support text delta)
- expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-turbo-0828' }))).toBe(false)
- expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-plus-latest' }))).toBe(false)
+ it('returns false for general models', () => {
+ expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'gpt-4o' }))).toBe(false)
+ })
})
})
- it('evaluates GPT-5 family helpers', () => {
- expect(isGPT5SeriesModel(createModel({ id: 'gpt-5-preview' }))).toBe(true)
- expect(isGPT5SeriesModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(false)
- expect(isGPT51SeriesModel(createModel({ id: 'gpt-5.1-mini' }))).toBe(true)
- expect(isGPT5SeriesReasoningModel(createModel({ id: 'gpt-5-prompt' }))).toBe(true)
- expect(isSupportVerbosityModel(createModel({ id: 'gpt-5-chat' }))).toBe(false)
+ describe('GPT-5 family detection', () => {
+ describe('isGPT5SeriesModel', () => {
+ it('returns true for GPT-5 models', () => {
+ expect(isGPT5SeriesModel(createModel({ id: 'gpt-5-preview' }))).toBe(true)
+ })
+
+ it('returns false for GPT-5.1 models', () => {
+ expect(isGPT5SeriesModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(false)
+ })
+ })
+
+ describe('isGPT51SeriesModel', () => {
+ it('returns true for GPT-5.1 models', () => {
+ expect(isGPT51SeriesModel(createModel({ id: 'gpt-5.1-mini' }))).toBe(true)
+ })
+ })
+
+ describe('isGPT5SeriesReasoningModel', () => {
+ it('returns true for GPT-5 reasoning models', () => {
+ expect(isGPT5SeriesReasoningModel(createModel({ id: 'gpt-5' }))).toBe(true)
+ })
+ it('returns false for gpt-5-chat', () => {
+ expect(isGPT5SeriesReasoningModel(createModel({ id: 'gpt-5-chat' }))).toBe(false)
+ })
+ })
+
+ describe('isGPT5ProModel', () => {
+ it('returns true for GPT-5 Pro models', () => {
+ expect(isGPT5ProModel(createModel({ id: 'gpt-5-pro' }))).toBe(true)
+ })
+
+ it('returns false for non-Pro GPT-5 models', () => {
+ expect(isGPT5ProModel(createModel({ id: 'gpt-5-preview' }))).toBe(false)
+ })
+ })
})
- it('wraps generate/vision helpers that operate on arrays', () => {
- const models = [createModel({ id: 'gpt-4o' }), createModel({ id: 'gpt-4o-mini' })]
- expect(isVisionModels(models)).toBe(true)
- visionMock.mockReturnValueOnce(true).mockReturnValueOnce(false)
- expect(isVisionModels(models)).toBe(false)
+ describe('Verbosity support', () => {
+ describe('isSupportVerbosityModel', () => {
+ it('returns true for GPT-5 models', () => {
+ expect(isSupportVerbosityModel(createModel({ id: 'gpt-5' }))).toBe(true)
+ })
- expect(isGenerateImageModels(models)).toBe(true)
- generateImageMock.mockReturnValueOnce(true).mockReturnValueOnce(false)
- expect(isGenerateImageModels(models)).toBe(false)
+ it('returns false for GPT-5 chat models', () => {
+ expect(isSupportVerbosityModel(createModel({ id: 'gpt-5-chat' }))).toBe(false)
+ })
+
+ it('returns true for GPT-5.1 models', () => {
+ expect(isSupportVerbosityModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(true)
+ })
+ })
+
+ describe('getModelSupportedVerbosity', () => {
+ it('returns only "high" for GPT-5 Pro models', () => {
+ expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro' }))).toEqual([undefined, 'high'])
+ expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro-2025-10-06' }))).toEqual([undefined, 'high'])
+ })
+
+ it('returns all levels for non-Pro GPT-5 models', () => {
+ const previewModel = createModel({ id: 'gpt-5-preview' })
+ expect(getModelSupportedVerbosity(previewModel)).toEqual([undefined, 'low', 'medium', 'high'])
+ })
+
+ it('returns all levels for GPT-5.1 models', () => {
+ const gpt51Model = createModel({ id: 'gpt-5.1-preview' })
+ expect(getModelSupportedVerbosity(gpt51Model)).toEqual([undefined, 'low', 'medium', 'high'])
+ })
+
+ it('returns only undefined for non-GPT-5 models', () => {
+ expect(getModelSupportedVerbosity(createModel({ id: 'gpt-4o' }))).toEqual([undefined])
+ expect(getModelSupportedVerbosity(createModel({ id: 'claude-3.5' }))).toEqual([undefined])
+ })
+
+ it('returns only undefined for undefiend/null input', () => {
+ expect(getModelSupportedVerbosity(undefined)).toEqual([undefined])
+ expect(getModelSupportedVerbosity(null)).toEqual([undefined])
+ })
+ })
})
- it('filters models for agent usage', () => {
- expect(agentModelFilter(createModel())).toBe(true)
+ describe('Flex service tier support', () => {
+ describe('isSupportFlexServiceTierModel', () => {
+ it('returns true for supported models', () => {
+ expect(isSupportFlexServiceTierModel(createModel({ id: 'o3' }))).toBe(true)
+ expect(isSupportFlexServiceTierModel(createModel({ id: 'o4-mini' }))).toBe(true)
+ expect(isSupportFlexServiceTierModel(createModel({ id: 'gpt-5-preview' }))).toBe(true)
+ })
- embeddingMock.mockReturnValueOnce(true)
- expect(agentModelFilter(createModel({ id: 'text-embedding' }))).toBe(false)
+ it('returns false for unsupported models', () => {
+ expect(isSupportFlexServiceTierModel(createModel({ id: 'o3-mini' }))).toBe(false)
+ })
+ })
- embeddingMock.mockReturnValue(false)
- rerankMock.mockReturnValueOnce(true)
- expect(agentModelFilter(createModel({ id: 'rerank' }))).toBe(false)
-
- rerankMock.mockReturnValue(false)
- textToImageMock.mockReturnValueOnce(true)
- expect(agentModelFilter(createModel({ id: 'gpt-image-1' }))).toBe(false)
+ describe('isSupportedFlexServiceTier', () => {
+ it('returns false for non-flex models', () => {
+ expect(isSupportedFlexServiceTier(createModel({ id: 'gpt-4o' }))).toBe(false)
+ })
+ })
})
- it('identifies models with maximum temperature of 1.0', () => {
- // Zhipu models should have max temperature of 1.0
- expect(isMaxTemperatureOneModel(createModel({ id: 'glm-4' }))).toBe(true)
- expect(isMaxTemperatureOneModel(createModel({ id: 'GLM-4-Plus' }))).toBe(true)
- expect(isMaxTemperatureOneModel(createModel({ id: 'glm-3-turbo' }))).toBe(true)
+ describe('Temperature and top-p support', () => {
+ describe('isNotSupportTemperatureAndTopP', () => {
+ it('returns true for reasoning models', () => {
+ const model = createModel({ id: 'o1' })
+ reasoningMock.mockReturnValue(true)
+ expect(isNotSupportTemperatureAndTopP(model)).toBe(true)
+ })
- // Anthropic models should have max temperature of 1.0
- expect(isMaxTemperatureOneModel(createModel({ id: 'claude-3.5-sonnet' }))).toBe(true)
- expect(isMaxTemperatureOneModel(createModel({ id: 'Claude-3-opus' }))).toBe(true)
- expect(isMaxTemperatureOneModel(createModel({ id: 'claude-2.1' }))).toBe(true)
+ it('returns false for open weight models', () => {
+ const openWeight = createModel({ id: 'gpt-oss-debug' })
+ expect(isNotSupportTemperatureAndTopP(openWeight)).toBe(false)
+ })
- // Moonshot models should have max temperature of 1.0
- expect(isMaxTemperatureOneModel(createModel({ id: 'moonshot-1.0' }))).toBe(true)
- expect(isMaxTemperatureOneModel(createModel({ id: 'kimi-k2-thinking' }))).toBe(true)
- expect(isMaxTemperatureOneModel(createModel({ id: 'Moonshot-Pro' }))).toBe(true)
+ it('returns true for chat-only models without reasoning', () => {
+ const chatOnly = createModel({ id: 'o1-preview' })
+ reasoningMock.mockReturnValue(false)
+ expect(isNotSupportTemperatureAndTopP(chatOnly)).toBe(true)
+ })
- // Other models should return false
- expect(isMaxTemperatureOneModel(createModel({ id: 'gpt-4o' }))).toBe(false)
- expect(isMaxTemperatureOneModel(createModel({ id: 'gpt-4-turbo' }))).toBe(false)
- expect(isMaxTemperatureOneModel(createModel({ id: 'qwen-max' }))).toBe(false)
- expect(isMaxTemperatureOneModel(createModel({ id: 'gemini-pro' }))).toBe(false)
+ it('returns true for Qwen MT models', () => {
+ const qwenMt = createModel({ id: 'qwen-mt-large', provider: 'aliyun' })
+ expect(isNotSupportTemperatureAndTopP(qwenMt)).toBe(true)
+ })
+ })
+ })
+
+ describe('Text delta support', () => {
+ describe('isNotSupportTextDeltaModel', () => {
+ it('returns true for qwen-mt-turbo and qwen-mt-plus models', () => {
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-turbo' }))).toBe(true)
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-plus' }))).toBe(true)
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'Qwen-MT-Turbo' }))).toBe(true)
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'QWEN-MT-PLUS' }))).toBe(true)
+ })
+
+ it('returns false for qwen-mt-flash and other models', () => {
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-flash' }))).toBe(false)
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'Qwen-MT-Flash' }))).toBe(false)
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-turbo' }))).toBe(false)
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-plus' }))).toBe(false)
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-max' }))).toBe(false)
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen2.5-72b' }))).toBe(false)
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-vl-plus' }))).toBe(false)
+ })
+
+ it('returns false for non-qwen models', () => {
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'gpt-4o' }))).toBe(false)
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'claude-3.5' }))).toBe(false)
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'glm-4-plus' }))).toBe(false)
+ })
+
+ it('handles models with version suffixes', () => {
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-turbo-1201' }))).toBe(true)
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-plus-0828' }))).toBe(true)
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-turbo-0828' }))).toBe(false)
+ expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-plus-latest' }))).toBe(false)
+ })
+ })
+ })
+
+ describe('Model provider detection', () => {
+ describe('isGemmaModel', () => {
+ it('detects Gemma models by ID', () => {
+ expect(isGemmaModel(createModel({ id: 'Gemma-3-27B' }))).toBe(true)
+ })
+
+ it('detects Gemma models by group', () => {
+ expect(isGemmaModel(createModel({ group: 'Gemma' }))).toBe(true)
+ })
+
+ it('returns false for non-Gemma models', () => {
+ expect(isGemmaModel(createModel({ id: 'gpt-4o' }))).toBe(false)
+ })
+ })
+
+ describe('isGeminiModel', () => {
+ it('detects Gemini models', () => {
+ expect(isGeminiModel(createModel({ id: 'Gemini-2.0' }))).toBe(true)
+ })
+ })
+
+ describe('isZhipuModel', () => {
+ it('detects Zhipu models by provider', () => {
+ expect(isZhipuModel(createModel({ provider: 'zhipu' }))).toBe(true)
+ })
+
+ it('returns false for non-Zhipu models', () => {
+ expect(isZhipuModel(createModel({ provider: 'openai' }))).toBe(false)
+ })
+ })
+
+ describe('isAnthropicModel', () => {
+ it('detects Anthropic models', () => {
+ expect(isAnthropicModel(createModel({ id: 'claude-3.5' }))).toBe(true)
+ })
+ })
+
+ describe('isQwenMTModel', () => {
+ it('detects Qwen MT models', () => {
+ expect(isQwenMTModel(createModel({ id: 'qwen-mt-plus' }))).toBe(true)
+ })
+ })
+
+ describe('isOpenAIOpenWeightModel', () => {
+ it('detects OpenAI open weight models', () => {
+ expect(isOpenAIOpenWeightModel(createModel({ id: 'gpt-oss-free' }))).toBe(true)
+ })
+ })
+ })
+
+ describe('System message support', () => {
+ describe('isNotSupportSystemMessageModel', () => {
+ it('returns true for models that do not support system messages', () => {
+ expect(isNotSupportSystemMessageModel(createModel({ id: 'gemma-moe' }))).toBe(true)
+ })
+ })
+ })
+
+ describe('Model grouping', () => {
+ describe('groupQwenModels', () => {
+ it('groups qwen models by prefix', () => {
+ const qwen = createModel({ id: 'Qwen-7B', provider: 'qwen', name: 'Qwen-7B' })
+ const qwenOmni = createModel({ id: 'qwen2.5-omni', name: 'qwen2.5-omni' })
+ const other = createModel({ id: 'deepseek-v3', group: 'DeepSeek' })
+
+ const grouped = groupQwenModels([qwen, qwenOmni, other])
+ expect(Object.keys(grouped)).toContain('qwen-7b')
+ expect(Object.keys(grouped)).toContain('qwen2.5')
+ expect(grouped.DeepSeek).toContain(other)
+ })
+ })
+ })
+
+ describe('Vision and image generation', () => {
+ describe('isVisionModels', () => {
+ it('returns true when all models support vision', () => {
+ const models = [createModel({ id: 'gpt-4o' }), createModel({ id: 'gpt-4o-mini' })]
+ expect(isVisionModels(models)).toBe(true)
+ })
+
+ it('returns false when some models do not support vision', () => {
+ const models = [createModel({ id: 'gpt-4o' }), createModel({ id: 'gpt-4o-mini' })]
+ visionMock.mockReturnValueOnce(true).mockReturnValueOnce(false)
+ expect(isVisionModels(models)).toBe(false)
+ })
+ })
+
+ describe('isGenerateImageModels', () => {
+ it('returns true when all models support image generation', () => {
+ const models = [createModel({ id: 'gpt-4o' }), createModel({ id: 'gpt-4o-mini' })]
+ expect(isGenerateImageModels(models)).toBe(true)
+ })
+
+ it('returns false when some models do not support image generation', () => {
+ const models = [createModel({ id: 'gpt-4o' }), createModel({ id: 'gpt-4o-mini' })]
+ generateImageMock.mockReturnValueOnce(true).mockReturnValueOnce(false)
+ expect(isGenerateImageModels(models)).toBe(false)
+ })
+ })
+ })
+
+ describe('Model filtering', () => {
+ describe('isSupportedModel', () => {
+ it('filters supported OpenAI catalog entries', () => {
+ expect(isSupportedModel({ id: 'gpt-4', object: 'model' } as any)).toBe(true)
+ })
+
+ it('filters unsupported OpenAI catalog entries', () => {
+ expect(isSupportedModel({ id: 'tts-1', object: 'model' } as any)).toBe(false)
+ })
+ })
+
+ describe('agentModelFilter', () => {
+ it('returns true for regular models', () => {
+ expect(agentModelFilter(createModel())).toBe(true)
+ })
+
+ it('filters out embedding models', () => {
+ embeddingMock.mockReturnValueOnce(true)
+ expect(agentModelFilter(createModel({ id: 'text-embedding' }))).toBe(false)
+ })
+
+ it('filters out rerank models', () => {
+ embeddingMock.mockReturnValue(false)
+ rerankMock.mockReturnValueOnce(true)
+ expect(agentModelFilter(createModel({ id: 'rerank' }))).toBe(false)
+ })
+
+ it('filters out text-to-image models', () => {
+ rerankMock.mockReturnValue(false)
+ textToImageMock.mockReturnValueOnce(true)
+ expect(agentModelFilter(createModel({ id: 'gpt-image-1' }))).toBe(false)
+ })
+ })
+ })
+
+ describe('Temperature limits', () => {
+ describe('isMaxTemperatureOneModel', () => {
+ it('returns true for Zhipu models', () => {
+ expect(isMaxTemperatureOneModel(createModel({ id: 'glm-4' }))).toBe(true)
+ expect(isMaxTemperatureOneModel(createModel({ id: 'GLM-4-Plus' }))).toBe(true)
+ expect(isMaxTemperatureOneModel(createModel({ id: 'glm-3-turbo' }))).toBe(true)
+ })
+
+ it('returns true for Anthropic models', () => {
+ expect(isMaxTemperatureOneModel(createModel({ id: 'claude-3.5-sonnet' }))).toBe(true)
+ expect(isMaxTemperatureOneModel(createModel({ id: 'Claude-3-opus' }))).toBe(true)
+ expect(isMaxTemperatureOneModel(createModel({ id: 'claude-2.1' }))).toBe(true)
+ })
+
+ it('returns true for Moonshot models', () => {
+ expect(isMaxTemperatureOneModel(createModel({ id: 'moonshot-1.0' }))).toBe(true)
+ expect(isMaxTemperatureOneModel(createModel({ id: 'kimi-k2-thinking' }))).toBe(true)
+ expect(isMaxTemperatureOneModel(createModel({ id: 'Moonshot-Pro' }))).toBe(true)
+ })
+
+ it('returns false for other models', () => {
+ expect(isMaxTemperatureOneModel(createModel({ id: 'gpt-4o' }))).toBe(false)
+ expect(isMaxTemperatureOneModel(createModel({ id: 'gpt-4-turbo' }))).toBe(false)
+ expect(isMaxTemperatureOneModel(createModel({ id: 'qwen-max' }))).toBe(false)
+ expect(isMaxTemperatureOneModel(createModel({ id: 'gemini-pro' }))).toBe(false)
+ })
+ })
})
})
diff --git a/src/renderer/src/config/models/utils.ts b/src/renderer/src/config/models/utils.ts
index 1d5c9a6443..25e802b257 100644
--- a/src/renderer/src/config/models/utils.ts
+++ b/src/renderer/src/config/models/utils.ts
@@ -4,7 +4,14 @@ import { type Model, SystemProviderIds } from '@renderer/types'
import type { OpenAIVerbosity, ValidOpenAIVerbosity } from '@renderer/types/aiCoreTypes'
import { getLowerBaseModelName } from '@renderer/utils'
-import { isOpenAIChatCompletionOnlyModel, isOpenAIOpenWeightModel, isOpenAIReasoningModel } from './openai'
+import {
+ isGPT5ProModel,
+ isGPT5SeriesModel,
+ isGPT51SeriesModel,
+ isOpenAIChatCompletionOnlyModel,
+ isOpenAIOpenWeightModel,
+ isOpenAIReasoningModel
+} from './openai'
import { isQwenMTModel } from './qwen'
import { isGenerateImageModel, isTextToImageModel, isVisionModel } from './vision'
export const NOT_SUPPORTED_REGEX = /(?:^tts|whisper|speech)/i
@@ -123,21 +130,46 @@ export const isNotSupportSystemMessageModel = (model: Model): boolean => {
return isQwenMTModel(model) || isGemmaModel(model)
}
-// GPT-5 verbosity configuration
+// Verbosity settings is only supported by GPT-5 and newer models
+// Specifically, GPT-5 and GPT-5.1 for now
// gpt-5-pro only supports 'high', other GPT-5 models support all levels
-export const MODEL_SUPPORTED_VERBOSITY: Record = {
- 'gpt-5-pro': ['high'],
- default: ['low', 'medium', 'high']
-} as const
+const MODEL_SUPPORTED_VERBOSITY: readonly {
+ readonly validator: (model: Model) => boolean
+ readonly values: readonly ValidOpenAIVerbosity[]
+}[] = [
+ // gpt-5-pro
+ { validator: isGPT5ProModel, values: ['high'] },
+ // gpt-5 except gpt-5-pro
+ {
+ validator: (model: Model) => isGPT5SeriesModel(model) && !isGPT5ProModel(model),
+ values: ['low', 'medium', 'high']
+ },
+ // gpt-5.1
+ { validator: isGPT51SeriesModel, values: ['low', 'medium', 'high'] }
+]
-export const getModelSupportedVerbosity = (model: Model): OpenAIVerbosity[] => {
- const modelId = getLowerBaseModelName(model.id)
- let supportedValues: ValidOpenAIVerbosity[]
- if (modelId.includes('gpt-5-pro')) {
- supportedValues = MODEL_SUPPORTED_VERBOSITY['gpt-5-pro']
- } else {
- supportedValues = MODEL_SUPPORTED_VERBOSITY.default
+/**
+ * Returns the list of supported verbosity levels for the given model.
+ * If the model is not recognized as a GPT-5 series model, only `undefined` is returned.
+ * For GPT-5-pro, only 'high' is supported; for other GPT-5 models, 'low', 'medium', and 'high' are supported.
+ * For GPT-5.1 series models, 'low', 'medium', and 'high' are supported.
+ * @param model - The model to check
+ * @returns An array of supported verbosity levels, always including `undefined` as the first element
+ */
+export const getModelSupportedVerbosity = (model: Model | undefined | null): OpenAIVerbosity[] => {
+ if (!model) {
+ return [undefined]
}
+
+ let supportedValues: ValidOpenAIVerbosity[] = []
+
+ for (const { validator, values } of MODEL_SUPPORTED_VERBOSITY) {
+ if (validator(model)) {
+ supportedValues = [...values]
+ break
+ }
+ }
+
return [undefined, ...supportedValues]
}
From d0bd10190d632268a47b4e6adee028c954ed74d5 Mon Sep 17 00:00:00 2001
From: fullex <106392080+0xfullex@users.noreply.github.com>
Date: Thu, 27 Nov 2025 19:52:31 +0800
Subject: [PATCH 07/17] feat(test): e2e framework (#11494)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(test): e2e framework
Add Playwright-based e2e testing framework for Electron app with:
- Custom fixtures for electronApp and mainWindow
- Page Object Model (POM) pattern implementation
- 15 example test cases covering app launch, navigation, settings, and chat
- Comprehensive README for humans and AI assistants
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
* refactor(tests): update imports and improve code readability
- Changed imports from 'import { Page, Locator }' to 'import type { Locator, Page }' for better type clarity across multiple page files.
- Reformatted waitFor calls in ChatPage and HomePage for improved readability.
- Updated index.ts to correct the export order of ChatPage and SidebarPage.
- Minor adjustments in electron.fixture.ts and electron-app.ts for consistency in import statements.
These changes enhance the maintainability and clarity of the test codebase.
* chore: update linting configuration to include tests directory
- Added 'tests/**' to the ignore patterns in .oxlintrc.json and eslint.config.mjs to ensure test files are not linted.
- Minor adjustment in electron.fixture.ts to improve the fixture definition.
These changes streamline the linting process and enhance code organization.
* fix(test): select main window by title to fix flaky e2e tests on Mac
On Mac, the app may create miniWindow for QuickAssistant alongside mainWindow.
Using firstWindow() could randomly select the wrong window, causing test failures.
Now we wait for the window with title "Cherry Studio" to ensure we get the main window.
Also removed unused electron-app.ts utility file.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---------
Co-authored-by: Claude
---
.oxlintrc.json | 1 +
eslint.config.mjs | 1 +
package.json | 3 +-
playwright.config.ts | 82 +++--
tests/e2e/README.md | 310 ++++++++++++++++++
tests/e2e/fixtures/electron.fixture.ts | 53 +++
tests/e2e/global-setup.ts | 25 ++
tests/e2e/global-teardown.ts | 16 +
tests/e2e/launch.test.tsx | 13 -
tests/e2e/pages/base.page.ts | 110 +++++++
tests/e2e/pages/chat.page.ts | 140 ++++++++
tests/e2e/pages/home.page.ts | 110 +++++++
tests/e2e/pages/index.ts | 8 +
tests/e2e/pages/settings.page.ts | 159 +++++++++
tests/e2e/pages/sidebar.page.ts | 122 +++++++
tests/e2e/specs/app-launch.spec.ts | 49 +++
.../e2e/specs/conversation/basic-chat.spec.ts | 35 ++
tests/e2e/specs/navigation.spec.ts | 46 +++
tests/e2e/specs/settings/general.spec.ts | 55 ++++
tests/e2e/utils/index.ts | 4 +
tests/e2e/utils/wait-helpers.ts | 103 ++++++
yarn.lock | 55 +---
22 files changed, 1415 insertions(+), 85 deletions(-)
create mode 100644 tests/e2e/README.md
create mode 100644 tests/e2e/fixtures/electron.fixture.ts
create mode 100644 tests/e2e/global-setup.ts
create mode 100644 tests/e2e/global-teardown.ts
delete mode 100644 tests/e2e/launch.test.tsx
create mode 100644 tests/e2e/pages/base.page.ts
create mode 100644 tests/e2e/pages/chat.page.ts
create mode 100644 tests/e2e/pages/home.page.ts
create mode 100644 tests/e2e/pages/index.ts
create mode 100644 tests/e2e/pages/settings.page.ts
create mode 100644 tests/e2e/pages/sidebar.page.ts
create mode 100644 tests/e2e/specs/app-launch.spec.ts
create mode 100644 tests/e2e/specs/conversation/basic-chat.spec.ts
create mode 100644 tests/e2e/specs/navigation.spec.ts
create mode 100644 tests/e2e/specs/settings/general.spec.ts
create mode 100644 tests/e2e/utils/index.ts
create mode 100644 tests/e2e/utils/wait-helpers.ts
diff --git a/.oxlintrc.json b/.oxlintrc.json
index 7d18f83c7c..093ae25f18 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -11,6 +11,7 @@
"dist/**",
"out/**",
"local/**",
+ "tests/**",
".yarn/**",
".gitignore",
"scripts/cloudflare-worker.js",
diff --git a/eslint.config.mjs b/eslint.config.mjs
index fcc952ed65..64fdefa1dc 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -58,6 +58,7 @@ export default defineConfig([
'dist/**',
'out/**',
'local/**',
+ 'tests/**',
'.yarn/**',
'.gitignore',
'scripts/cloudflare-worker.js',
diff --git a/package.json b/package.json
index 5550405df3..de89b4514c 100644
--- a/package.json
+++ b/package.json
@@ -172,7 +172,7 @@
"@opentelemetry/sdk-trace-node": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0",
"@opeoginni/github-copilot-openai-compatible": "^0.1.21",
- "@playwright/test": "^1.52.0",
+ "@playwright/test": "^1.55.1",
"@radix-ui/react-context-menu": "^2.2.16",
"@reduxjs/toolkit": "^2.2.5",
"@shikijs/markdown-it": "^3.12.0",
@@ -321,7 +321,6 @@
"p-queue": "^8.1.0",
"pdf-lib": "^1.17.1",
"pdf-parse": "^1.1.1",
- "playwright": "^1.55.1",
"proxy-agent": "^6.5.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
diff --git a/playwright.config.ts b/playwright.config.ts
index e12ce7ab6d..0b67f0e76f 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,42 +1,64 @@
-import { defineConfig, devices } from '@playwright/test'
+import { defineConfig } from '@playwright/test'
/**
- * See https://playwright.dev/docs/test-configuration.
+ * Playwright configuration for Electron e2e testing.
+ * See https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
- // Look for test files, relative to this configuration file.
- testDir: './tests/e2e',
- /* Run tests in files in parallel */
- fullyParallel: true,
- /* Fail the build on CI if you accidentally left test.only in the source code. */
- forbidOnly: !!process.env.CI,
- /* Retry on CI only */
- retries: process.env.CI ? 2 : 0,
- /* Opt out of parallel tests on CI. */
- workers: process.env.CI ? 1 : undefined,
- /* Reporter to use. See https://playwright.dev/docs/test-reporters */
- reporter: 'html',
- /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
- use: {
- /* Base URL to use in actions like `await page.goto('/')`. */
- // baseURL: 'http://localhost:3000',
+ // Look for test files in the specs directory
+ testDir: './tests/e2e/specs',
- /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
- trace: 'on-first-retry'
+ // Global timeout for each test
+ timeout: 60000,
+
+ // Assertion timeout
+ expect: {
+ timeout: 10000
},
- /* Configure projects for major browsers */
+ // Electron apps should run tests sequentially to avoid conflicts
+ fullyParallel: false,
+ workers: 1,
+
+ // Fail the build on CI if you accidentally left test.only in the source code
+ forbidOnly: !!process.env.CI,
+
+ // Retry on CI only
+ retries: process.env.CI ? 2 : 0,
+
+ // Reporter configuration
+ reporter: [['html', { outputFolder: 'playwright-report' }], ['list']],
+
+ // Global setup and teardown
+ globalSetup: './tests/e2e/global-setup.ts',
+ globalTeardown: './tests/e2e/global-teardown.ts',
+
+ // Output directory for test artifacts
+ outputDir: './test-results',
+
+ // Shared settings for all tests
+ use: {
+ // Collect trace when retrying the failed test
+ trace: 'retain-on-failure',
+
+ // Take screenshot only on failure
+ screenshot: 'only-on-failure',
+
+ // Record video only on failure
+ video: 'retain-on-failure',
+
+ // Action timeout
+ actionTimeout: 15000,
+
+ // Navigation timeout
+ navigationTimeout: 30000
+ },
+
+ // Single project for Electron testing
projects: [
{
- name: 'chromium',
- use: { ...devices['Desktop Chrome'] }
+ name: 'electron',
+ testMatch: '**/*.spec.ts'
}
]
-
- /* Run your local dev server before starting the tests */
- // webServer: {
- // command: 'npm run start',
- // url: 'http://localhost:3000',
- // reuseExistingServer: !process.env.CI,
- // },
})
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
new file mode 100644
index 0000000000..6da89ddd6e
--- /dev/null
+++ b/tests/e2e/README.md
@@ -0,0 +1,310 @@
+# E2E Testing Guide
+
+本目录包含 Cherry Studio 的端到端 (E2E) 测试,使用 Playwright 测试 Electron 应用。
+
+## 目录结构
+
+```
+tests/e2e/
+├── README.md # 本文档
+├── global-setup.ts # 全局测试初始化
+├── global-teardown.ts # 全局测试清理
+├── fixtures/
+│ └── electron.fixture.ts # Electron 应用启动 fixture
+├── utils/
+│ ├── wait-helpers.ts # 等待辅助函数
+│ └── index.ts # 工具导出
+├── pages/ # Page Object Model
+│ ├── base.page.ts # 基础页面对象类
+│ ├── sidebar.page.ts # 侧边栏导航
+│ ├── home.page.ts # 首页/聊天页
+│ ├── settings.page.ts # 设置页
+│ ├── chat.page.ts # 聊天交互
+│ └── index.ts # 页面对象导出
+└── specs/ # 测试用例
+ ├── app-launch.spec.ts # 应用启动测试
+ ├── navigation.spec.ts # 页面导航测试
+ ├── settings/ # 设置相关测试
+ │ └── general.spec.ts
+ └── conversation/ # 对话相关测试
+ └── basic-chat.spec.ts
+```
+
+---
+
+## 运行测试
+
+### 前置条件
+
+1. 安装依赖:`yarn install`
+2. 构建应用:`yarn build`
+
+### 运行命令
+
+```bash
+# 运行所有 e2e 测试
+yarn test:e2e
+
+# 带可视化窗口运行(可以看到测试过程)
+yarn test:e2e --headed
+
+# 运行特定测试文件
+yarn playwright test tests/e2e/specs/app-launch.spec.ts
+
+# 运行匹配名称的测试
+yarn playwright test -g "should launch"
+
+# 调试模式(会暂停并打开调试器)
+yarn playwright test --debug
+
+# 使用 Playwright UI 模式
+yarn playwright test --ui
+
+# 查看测试报告
+yarn playwright show-report
+```
+
+### 常见问题
+
+**Q: 测试时看不到窗口?**
+A: 默认是 headless 模式,使用 `--headed` 参数可看到窗口。
+
+**Q: 测试失败,提示找不到元素?**
+A:
+1. 确保已运行 `yarn build` 构建最新代码
+2. 检查选择器是否正确,UI 可能已更新
+
+**Q: 测试超时?**
+A: Electron 应用启动较慢,可在测试中增加超时时间:
+```typescript
+test.setTimeout(60000) // 60秒
+```
+
+---
+
+## AI 助手指南:创建新测试用例
+
+以下内容供 AI 助手(如 Claude、GPT)在创建新测试用例时参考。
+
+### 基本原则
+
+1. **使用 Page Object Model (POM)**:所有页面交互应通过 `pages/` 目录下的页面对象进行
+2. **使用自定义 fixture**:从 `../fixtures/electron.fixture` 导入 `test` 和 `expect`
+3. **等待策略**:使用 `utils/wait-helpers.ts` 中的等待函数,避免硬编码 `waitForTimeout`
+4. **测试独立性**:每个测试应该独立运行,不依赖其他测试的状态
+
+### 创建新测试文件
+
+```typescript
+// tests/e2e/specs/[feature]/[feature].spec.ts
+
+import { test, expect } from '../../fixtures/electron.fixture'
+import { SomePageObject } from '../../pages/some.page'
+import { waitForAppReady } from '../../utils/wait-helpers'
+
+test.describe('Feature Name', () => {
+ let pageObject: SomePageObject
+
+ test.beforeEach(async ({ mainWindow }) => {
+ await waitForAppReady(mainWindow)
+ pageObject = new SomePageObject(mainWindow)
+ })
+
+ test('should do something', async ({ mainWindow }) => {
+ // 测试逻辑
+ })
+})
+```
+
+### 创建新页面对象
+
+```typescript
+// tests/e2e/pages/[feature].page.ts
+
+import { Page, Locator } from '@playwright/test'
+import { BasePage } from './base.page'
+
+export class FeaturePage extends BasePage {
+ // 定义页面元素定位器
+ readonly someButton: Locator
+ readonly someInput: Locator
+
+ constructor(page: Page) {
+ super(page)
+ // 使用多种选择器策略,提高稳定性
+ this.someButton = page.locator('[class*="SomeButton"], button:has-text("Some Text")')
+ this.someInput = page.locator('input[placeholder*="placeholder"]')
+ }
+
+ // 页面操作方法
+ async doSomething(): Promise {
+ await this.someButton.click()
+ }
+
+ // 状态检查方法
+ async isSomethingVisible(): Promise {
+ return this.someButton.isVisible()
+ }
+}
+```
+
+### 选择器最佳实践
+
+```typescript
+// 优先级从高到低:
+
+// 1. data-testid(最稳定,但需要在源码中添加)
+page.locator('[data-testid="submit-button"]')
+
+// 2. 语义化角色
+page.locator('button[role="submit"]')
+page.locator('[aria-label="Send message"]')
+
+// 3. 类名模糊匹配(适应 CSS Modules / styled-components)
+page.locator('[class*="SendButton"]')
+page.locator('[class*="send-button"]')
+
+// 4. 文本内容
+page.locator('button:has-text("发送")')
+page.locator('text=Submit')
+
+// 5. 组合选择器(提高稳定性)
+page.locator('[class*="ChatInput"] textarea, [class*="InputBar"] textarea')
+
+// 避免使用:
+// - 精确类名(容易因构建变化而失效)
+// - 层级过深的选择器
+// - 索引选择器(如 nth-child)除非必要
+```
+
+### 等待策略
+
+```typescript
+import { waitForAppReady, waitForNavigation, waitForModal } from '../../utils/wait-helpers'
+
+// 等待应用就绪
+await waitForAppReady(mainWindow)
+
+// 等待导航完成(HashRouter)
+await waitForNavigation(mainWindow, '/settings')
+
+// 等待模态框出现
+await waitForModal(mainWindow)
+
+// 等待元素可见
+await page.locator('.some-element').waitFor({ state: 'visible', timeout: 10000 })
+
+// 等待元素消失
+await page.locator('.loading').waitFor({ state: 'hidden' })
+
+// 避免使用固定等待时间
+// BAD: await page.waitForTimeout(3000)
+// GOOD: await page.waitForSelector('.element', { state: 'visible' })
+```
+
+### 断言模式
+
+```typescript
+// 使用 Playwright 的自动重试断言
+await expect(page.locator('.element')).toBeVisible()
+await expect(page.locator('.element')).toHaveText('expected text')
+await expect(page.locator('.element')).toHaveCount(3)
+
+// 检查 URL(HashRouter)
+await expect(page).toHaveURL(/.*#\/settings.*/)
+
+// 软断言(不会立即失败)
+await expect.soft(page.locator('.element')).toBeVisible()
+
+// 自定义超时
+await expect(page.locator('.slow-element')).toBeVisible({ timeout: 30000 })
+```
+
+### 处理 Electron 特性
+
+```typescript
+// 访问 Electron 主进程
+const bounds = await electronApp.evaluate(({ BrowserWindow }) => {
+ const win = BrowserWindow.getAllWindows()[0]
+ return win?.getBounds()
+})
+
+// 检查窗口状态
+const isMaximized = await electronApp.evaluate(({ BrowserWindow }) => {
+ const win = BrowserWindow.getAllWindows()[0]
+ return win?.isMaximized()
+})
+
+// 调用 IPC(通过 preload 暴露的 API)
+const result = await mainWindow.evaluate(() => {
+ return (window as any).api.someMethod()
+})
+```
+
+### 测试文件命名规范
+
+```
+specs/
+├── [feature].spec.ts # 单文件测试
+├── [feature]/
+│ ├── [sub-feature].spec.ts # 子功能测试
+│ └── [another].spec.ts
+```
+
+示例:
+- `app-launch.spec.ts` - 应用启动
+- `navigation.spec.ts` - 页面导航
+- `settings/general.spec.ts` - 通用设置
+- `conversation/basic-chat.spec.ts` - 基础聊天
+
+### 添加新页面对象后的清单
+
+1. 在 `pages/` 目录创建 `[feature].page.ts`
+2. 继承 `BasePage` 类
+3. 在 `pages/index.ts` 中导出
+4. 在对应的 spec 文件中导入使用
+
+### 测试用例编写清单
+
+- [ ] 使用自定义 fixture (`test`, `expect`)
+- [ ] 在 `beforeEach` 中调用 `waitForAppReady`
+- [ ] 使用 Page Object 进行页面交互
+- [ ] 使用描述性的测试名称
+- [ ] 添加适当的断言
+- [ ] 处理可能的异步操作
+- [ ] 考虑测试失败时的清理
+
+### 调试技巧
+
+```typescript
+// 截图调试
+await mainWindow.screenshot({ path: 'debug.png' })
+
+// 打印页面 HTML
+console.log(await mainWindow.content())
+
+// 暂停测试进行调试
+await mainWindow.pause()
+
+// 打印元素数量
+console.log(await page.locator('.element').count())
+```
+
+---
+
+## 配置文件
+
+主要配置在项目根目录的 `playwright.config.ts`:
+
+- `testDir`: 测试目录 (`./tests/e2e/specs`)
+- `timeout`: 测试超时 (60秒)
+- `workers`: 并发数 (1,Electron 需要串行)
+- `retries`: 重试次数 (CI 环境下为 2)
+
+---
+
+## 相关文档
+
+- [Playwright 官方文档](https://playwright.dev/docs/intro)
+- [Playwright Electron 测试](https://playwright.dev/docs/api/class-electron)
+- [Page Object Model](https://playwright.dev/docs/pom)
diff --git a/tests/e2e/fixtures/electron.fixture.ts b/tests/e2e/fixtures/electron.fixture.ts
new file mode 100644
index 0000000000..cf9def26e0
--- /dev/null
+++ b/tests/e2e/fixtures/electron.fixture.ts
@@ -0,0 +1,53 @@
+import type { ElectronApplication, Page } from '@playwright/test'
+import { _electron as electron, test as base } from '@playwright/test'
+
+/**
+ * Custom fixtures for Electron e2e testing.
+ * Provides electronApp and mainWindow to all tests.
+ */
+export type ElectronFixtures = {
+ electronApp: ElectronApplication
+ mainWindow: Page
+}
+
+export const test = base.extend({
+ electronApp: async ({}, use) => {
+ // Launch Electron app from project root
+ // The args ['.'] tells Electron to load the app from current directory
+ const electronApp = await electron.launch({
+ args: ['.'],
+ env: {
+ ...process.env,
+ NODE_ENV: 'development'
+ },
+ timeout: 60000
+ })
+
+ await use(electronApp)
+
+ // Cleanup: close the app after test
+ await electronApp.close()
+ },
+
+ mainWindow: async ({ electronApp }, use) => {
+ // Wait for the main window (title: "Cherry Studio", not "Quick Assistant")
+ // On Mac, the app may create miniWindow for QuickAssistant with different title
+ const mainWindow = await electronApp.waitForEvent('window', {
+ predicate: async (window) => {
+ const title = await window.title()
+ return title === 'Cherry Studio'
+ },
+ timeout: 60000
+ })
+
+ // Wait for React app to mount
+ await mainWindow.waitForSelector('#root', { state: 'attached', timeout: 60000 })
+
+ // Wait for initial content to load
+ await mainWindow.waitForLoadState('domcontentloaded')
+
+ await use(mainWindow)
+ }
+})
+
+export { expect } from '@playwright/test'
diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts
new file mode 100644
index 0000000000..edda731d5d
--- /dev/null
+++ b/tests/e2e/global-setup.ts
@@ -0,0 +1,25 @@
+import * as fs from 'fs'
+import * as path from 'path'
+
+/**
+ * Global setup for Playwright e2e tests.
+ * This runs once before all tests.
+ */
+async function globalSetup() {
+ console.log('Running global setup...')
+
+ // Create test results directories
+ const resultsDir = path.join(process.cwd(), 'test-results')
+ const screenshotsDir = path.join(resultsDir, 'screenshots')
+
+ if (!fs.existsSync(screenshotsDir)) {
+ fs.mkdirSync(screenshotsDir, { recursive: true })
+ }
+
+ // Set environment variables for testing
+ process.env.NODE_ENV = 'test'
+
+ console.log('Global setup complete')
+}
+
+export default globalSetup
diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts
new file mode 100644
index 0000000000..6336248e14
--- /dev/null
+++ b/tests/e2e/global-teardown.ts
@@ -0,0 +1,16 @@
+/**
+ * Global teardown for Playwright e2e tests.
+ * This runs once after all tests complete.
+ */
+async function globalTeardown() {
+ console.log('Running global teardown...')
+
+ // Cleanup tasks can be added here:
+ // - Kill orphaned Electron processes
+ // - Clean up temporary test data
+ // - Reset test databases
+
+ console.log('Global teardown complete')
+}
+
+export default globalTeardown
diff --git a/tests/e2e/launch.test.tsx b/tests/e2e/launch.test.tsx
deleted file mode 100644
index 8636c01695..0000000000
--- a/tests/e2e/launch.test.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { _electron as electron, expect, test } from '@playwright/test'
-
-let electronApp: any
-let window: any
-
-test.describe('App Launch', () => {
- test('should launch and close the main application', async () => {
- electronApp = await electron.launch({ args: ['.'] })
- window = await electronApp.firstWindow()
- expect(window).toBeDefined()
- await electronApp.close()
- })
-})
diff --git a/tests/e2e/pages/base.page.ts b/tests/e2e/pages/base.page.ts
new file mode 100644
index 0000000000..fe8065a650
--- /dev/null
+++ b/tests/e2e/pages/base.page.ts
@@ -0,0 +1,110 @@
+import type { Locator, Page } from '@playwright/test'
+import * as fs from 'fs'
+import * as path from 'path'
+
+/**
+ * Base Page Object class.
+ * All page objects should extend this class.
+ */
+export abstract class BasePage {
+ constructor(protected page: Page) {}
+
+ /**
+ * Navigate to a path using HashRouter.
+ * The app uses HashRouter, so we need to change window.location.hash.
+ */
+ async navigateTo(routePath: string): Promise {
+ await this.page.evaluate((p) => {
+ window.location.hash = p
+ }, routePath)
+ await this.page.waitForLoadState('domcontentloaded')
+ }
+
+ /**
+ * Wait for an element to be visible.
+ */
+ async waitForElement(selector: string, timeout: number = 10000): Promise {
+ const locator = this.page.locator(selector)
+ await locator.waitFor({ state: 'visible', timeout })
+ return locator
+ }
+
+ /**
+ * Wait for an element to be hidden.
+ */
+ async waitForElementHidden(selector: string, timeout: number = 10000): Promise {
+ const locator = this.page.locator(selector)
+ await locator.waitFor({ state: 'hidden', timeout })
+ }
+
+ /**
+ * Take a screenshot for debugging.
+ */
+ async takeScreenshot(name: string): Promise {
+ const screenshotsDir = path.join(process.cwd(), 'test-results', 'screenshots')
+ if (!fs.existsSync(screenshotsDir)) {
+ fs.mkdirSync(screenshotsDir, { recursive: true })
+ }
+
+ await this.page.screenshot({
+ path: path.join(screenshotsDir, `${name}.png`),
+ fullPage: true
+ })
+ }
+
+ /**
+ * Get the current route from the hash.
+ */
+ async getCurrentRoute(): Promise {
+ const url = this.page.url()
+ const hash = new URL(url).hash
+ return hash.replace('#', '') || '/'
+ }
+
+ /**
+ * Click an element with retry.
+ */
+ async clickWithRetry(selector: string, maxRetries: number = 3): Promise {
+ for (let i = 0; i < maxRetries; i++) {
+ try {
+ await this.page.click(selector, { timeout: 5000 })
+ return
+ } catch (error) {
+ if (i === maxRetries - 1) throw error
+ await this.page.waitForTimeout(500)
+ }
+ }
+ }
+
+ /**
+ * Fill an input field.
+ */
+ async fillInput(selector: string, value: string): Promise {
+ const input = this.page.locator(selector)
+ await input.fill(value)
+ }
+
+ /**
+ * Get text content of an element.
+ */
+ async getTextContent(selector: string): Promise {
+ const locator = this.page.locator(selector)
+ return locator.textContent()
+ }
+
+ /**
+ * Check if an element is visible.
+ */
+ async isElementVisible(selector: string): Promise {
+ const locator = this.page.locator(selector)
+ return locator.isVisible()
+ }
+
+ /**
+ * Count elements matching a selector.
+ */
+ async countElements(selector: string): Promise {
+ const locator = this.page.locator(selector)
+ return locator.count()
+ }
+}
diff --git a/tests/e2e/pages/chat.page.ts b/tests/e2e/pages/chat.page.ts
new file mode 100644
index 0000000000..c0b6b91814
--- /dev/null
+++ b/tests/e2e/pages/chat.page.ts
@@ -0,0 +1,140 @@
+import type { Locator, Page } from '@playwright/test'
+
+import { BasePage } from './base.page'
+
+/**
+ * Page Object for the Chat/Conversation interface.
+ * Handles message input, sending, and conversation management.
+ */
+export class ChatPage extends BasePage {
+ readonly chatContainer: Locator
+ readonly inputArea: Locator
+ readonly sendButton: Locator
+ readonly messageList: Locator
+ readonly userMessages: Locator
+ readonly assistantMessages: Locator
+ readonly newTopicButton: Locator
+ readonly topicList: Locator
+ readonly stopButton: Locator
+
+ constructor(page: Page) {
+ super(page)
+ this.chatContainer = page.locator('#chat, [class*="Chat"]')
+ this.inputArea = page.locator(
+ '[class*="Inputbar"] textarea, [class*="InputBar"] textarea, [contenteditable="true"]'
+ )
+ this.sendButton = page.locator(
+ '[class*="SendMessageButton"], [class*="send-button"], button[aria-label*="send"], button[title*="send"]'
+ )
+ this.messageList = page.locator('#messages, [class*="Messages"], [class*="MessageList"]')
+ this.userMessages = page.locator('[class*="UserMessage"], [class*="user-message"]')
+ this.assistantMessages = page.locator('[class*="AssistantMessage"], [class*="assistant-message"]')
+ this.newTopicButton = page.locator('[class*="NewTopicButton"], [class*="new-topic"]')
+ this.topicList = page.locator('[class*="TopicList"], [class*="topic-list"]')
+ this.stopButton = page.locator('[class*="StopButton"], [class*="stop-button"]')
+ }
+
+ /**
+ * Navigate to chat/home page.
+ */
+ async goto(): Promise {
+ await this.navigateTo('/')
+ await this.chatContainer
+ .first()
+ .waitFor({ state: 'visible', timeout: 15000 })
+ .catch(() => {})
+ }
+
+ /**
+ * Check if chat is visible.
+ */
+ async isChatVisible(): Promise {
+ return this.chatContainer.first().isVisible()
+ }
+
+ /**
+ * Type a message in the input area.
+ */
+ async typeMessage(message: string): Promise {
+ await this.inputArea.first().fill(message)
+ }
+
+ /**
+ * Clear the input area.
+ */
+ async clearInput(): Promise {
+ await this.inputArea.first().clear()
+ }
+
+ /**
+ * Click the send button.
+ */
+ async clickSend(): Promise {
+ await this.sendButton.first().click()
+ }
+
+ /**
+ * Type and send a message.
+ */
+ async sendMessage(message: string): Promise {
+ await this.typeMessage(message)
+ await this.clickSend()
+ }
+
+ /**
+ * Get the current input value.
+ */
+ async getInputValue(): Promise {
+ return (await this.inputArea.first().inputValue()) || (await this.inputArea.first().textContent()) || ''
+ }
+
+ /**
+ * Get the count of user messages.
+ */
+ async getUserMessageCount(): Promise {
+ return this.userMessages.count()
+ }
+
+ /**
+ * Get the count of assistant messages.
+ */
+ async getAssistantMessageCount(): Promise {
+ return this.assistantMessages.count()
+ }
+
+ /**
+ * Check if send button is enabled.
+ */
+ async isSendButtonEnabled(): Promise {
+ const isDisabled = await this.sendButton.first().isDisabled()
+ return !isDisabled
+ }
+
+ /**
+ * Create a new topic/conversation.
+ */
+ async createNewTopic(): Promise {
+ await this.newTopicButton.first().click()
+ }
+
+ /**
+ * Check if stop button is visible (indicates ongoing generation).
+ */
+ async isGenerating(): Promise {
+ return this.stopButton.first().isVisible()
+ }
+
+ /**
+ * Click stop button to stop generation.
+ */
+ async stopGeneration(): Promise {
+ await this.stopButton.first().click()
+ }
+
+ /**
+ * Wait for generation to complete.
+ */
+ async waitForGenerationComplete(timeout: number = 60000): Promise {
+ await this.stopButton.first().waitFor({ state: 'hidden', timeout })
+ }
+}
diff --git a/tests/e2e/pages/home.page.ts b/tests/e2e/pages/home.page.ts
new file mode 100644
index 0000000000..4d3efb88aa
--- /dev/null
+++ b/tests/e2e/pages/home.page.ts
@@ -0,0 +1,110 @@
+import type { Locator, Page } from '@playwright/test'
+
+import { BasePage } from './base.page'
+
+/**
+ * Page Object for the Home/Chat page.
+ * This is the main page where users interact with AI assistants.
+ */
+export class HomePage extends BasePage {
+ readonly homePage: Locator
+ readonly chatContainer: Locator
+ readonly inputBar: Locator
+ readonly messagesList: Locator
+ readonly sendButton: Locator
+ readonly newTopicButton: Locator
+ readonly assistantTabs: Locator
+ readonly topicList: Locator
+
+ constructor(page: Page) {
+ super(page)
+ this.homePage = page.locator('#home-page, [class*="HomePage"], [class*="Home"]')
+ this.chatContainer = page.locator('#chat, [class*="Chat"]')
+ this.inputBar = page.locator('[class*="Inputbar"], [class*="InputBar"], [class*="input-bar"]')
+ this.messagesList = page.locator('#messages, [class*="Messages"], [class*="MessageList"]')
+ this.sendButton = page.locator('[class*="SendMessageButton"], [class*="send-button"], button[type="submit"]')
+ this.newTopicButton = page.locator('[class*="NewTopicButton"], [class*="new-topic"]')
+ this.assistantTabs = page.locator('[class*="HomeTabs"], [class*="AssistantTabs"]')
+ this.topicList = page.locator('[class*="TopicList"], [class*="topic-list"]')
+ }
+
+ /**
+ * Navigate to the home page.
+ */
+ async goto(): Promise {
+ await this.navigateTo('/')
+ await this.homePage
+ .first()
+ .waitFor({ state: 'visible', timeout: 15000 })
+ .catch(() => {})
+ }
+
+ /**
+ * Check if the home page is loaded.
+ */
+ async isLoaded(): Promise {
+ return this.homePage.first().isVisible()
+ }
+
+ /**
+ * Type a message in the input area.
+ */
+ async typeMessage(message: string): Promise {
+ const input = this.page.locator(
+ '[class*="Inputbar"] textarea, [class*="Inputbar"] [contenteditable], [class*="InputBar"] textarea'
+ )
+ await input.first().fill(message)
+ }
+
+ /**
+ * Click the send button to send a message.
+ */
+ async sendMessage(): Promise {
+ await this.sendButton.first().click()
+ }
+
+ /**
+ * Type and send a message.
+ */
+ async sendChatMessage(message: string): Promise {
+ await this.typeMessage(message)
+ await this.sendMessage()
+ }
+
+ /**
+ * Get the count of messages in the chat.
+ */
+ async getMessageCount(): Promise {
+ const messages = this.page.locator('[class*="Message"]:not([class*="Messages"]):not([class*="MessageList"])')
+ return messages.count()
+ }
+
+ /**
+ * Create a new topic/conversation.
+ */
+ async createNewTopic(): Promise {
+ await this.newTopicButton.first().click()
+ }
+
+ /**
+ * Check if the chat interface is visible.
+ */
+ async isChatVisible(): Promise {
+ return this.chatContainer.first().isVisible()
+ }
+
+ /**
+ * Check if the input bar is visible.
+ */
+ async isInputBarVisible(): Promise {
+ return this.inputBar.first().isVisible()
+ }
+
+ /**
+ * Get the placeholder text of the input field.
+ */
+ async getInputPlaceholder(): Promise {
+ const input = this.page.locator('[class*="Inputbar"] textarea, [class*="InputBar"] textarea')
+ return input.first().getAttribute('placeholder')
+ }
+}
diff --git a/tests/e2e/pages/index.ts b/tests/e2e/pages/index.ts
new file mode 100644
index 0000000000..453b8fa532
--- /dev/null
+++ b/tests/e2e/pages/index.ts
@@ -0,0 +1,8 @@
+/**
+ * Export all page objects for easy importing.
+ */
+export { BasePage } from './base.page'
+export { ChatPage } from './chat.page'
+export { HomePage } from './home.page'
+export { SettingsPage } from './settings.page'
+export { SidebarPage } from './sidebar.page'
diff --git a/tests/e2e/pages/settings.page.ts b/tests/e2e/pages/settings.page.ts
new file mode 100644
index 0000000000..44fd2b683b
--- /dev/null
+++ b/tests/e2e/pages/settings.page.ts
@@ -0,0 +1,159 @@
+import type { Locator, Page } from '@playwright/test'
+
+import { BasePage } from './base.page'
+
+/**
+ * Page Object for the Settings page.
+ * Handles navigation and interaction with various settings sections.
+ */
+export class SettingsPage extends BasePage {
+ readonly settingsContainer: Locator
+ readonly providerMenuItem: Locator
+ readonly modelMenuItem: Locator
+ readonly generalMenuItem: Locator
+ readonly displayMenuItem: Locator
+ readonly dataMenuItem: Locator
+ readonly mcpMenuItem: Locator
+ readonly memoryMenuItem: Locator
+ readonly aboutMenuItem: Locator
+
+ constructor(page: Page) {
+ super(page)
+ this.settingsContainer = page.locator('[id="content-container"], [class*="Settings"]')
+ this.providerMenuItem = page.locator('a[href*="/settings/provider"]')
+ this.modelMenuItem = page.locator('a[href*="/settings/model"]')
+ this.generalMenuItem = page.locator('a[href*="/settings/general"]')
+ this.displayMenuItem = page.locator('a[href*="/settings/display"]')
+ this.dataMenuItem = page.locator('a[href*="/settings/data"]')
+ this.mcpMenuItem = page.locator('a[href*="/settings/mcp"]')
+ this.memoryMenuItem = page.locator('a[href*="/settings/memory"]')
+ this.aboutMenuItem = page.locator('a[href*="/settings/about"]')
+ }
+
+ /**
+ * Navigate to settings page (provider by default).
+ */
+ async goto(): Promise {
+ await this.navigateTo('/settings/provider')
+ await this.waitForElement('[id="content-container"], [class*="Settings"]')
+ }
+
+ /**
+ * Check if settings page is loaded.
+ */
+ async isLoaded(): Promise {
+ return this.settingsContainer.first().isVisible()
+ }
+
+ /**
+ * Navigate to Provider settings.
+ */
+ async goToProvider(): Promise {
+ try {
+ await this.providerMenuItem.click({ timeout: 5000 })
+ } catch {
+ await this.navigateTo('/settings/provider')
+ }
+ await this.page.waitForURL('**/#/settings/provider**', { timeout: 10000 }).catch(() => {})
+ }
+
+ /**
+ * Navigate to Model settings.
+ */
+ async goToModel(): Promise {
+ try {
+ await this.modelMenuItem.click({ timeout: 5000 })
+ } catch {
+ await this.navigateTo('/settings/model')
+ }
+ await this.page.waitForURL('**/#/settings/model**', { timeout: 10000 }).catch(() => {})
+ }
+
+ /**
+ * Navigate to General settings.
+ */
+ async goToGeneral(): Promise {
+ try {
+ await this.generalMenuItem.click({ timeout: 5000 })
+ } catch {
+ await this.navigateTo('/settings/general')
+ }
+ await this.page.waitForURL('**/#/settings/general**', { timeout: 10000 }).catch(() => {})
+ }
+
+ /**
+ * Navigate to Display settings.
+ */
+ async goToDisplay(): Promise {
+ try {
+ await this.displayMenuItem.click({ timeout: 5000 })
+ } catch {
+ await this.navigateTo('/settings/display')
+ }
+ await this.page.waitForURL('**/#/settings/display**', { timeout: 10000 }).catch(() => {})
+ }
+
+ /**
+ * Navigate to Data settings.
+ */
+ async goToData(): Promise {
+ try {
+ await this.dataMenuItem.click({ timeout: 5000 })
+ } catch {
+ await this.navigateTo('/settings/data')
+ }
+ await this.page.waitForURL('**/#/settings/data**', { timeout: 10000 }).catch(() => {})
+ }
+
+ /**
+ * Navigate to MCP settings.
+ */
+ async goToMCP(): Promise {
+ try {
+ await this.mcpMenuItem.click({ timeout: 5000 })
+ } catch {
+ await this.navigateTo('/settings/mcp')
+ }
+ await this.page.waitForURL('**/#/settings/mcp**', { timeout: 10000 }).catch(() => {})
+ }
+
+ /**
+ * Navigate to Memory settings.
+ */
+ async goToMemory(): Promise {
+ try {
+ await this.memoryMenuItem.click({ timeout: 5000 })
+ } catch {
+ await this.navigateTo('/settings/memory')
+ }
+ await this.page.waitForURL('**/#/settings/memory**', { timeout: 10000 }).catch(() => {})
+ }
+
+ /**
+ * Navigate to About page.
+ */
+ async goToAbout(): Promise {
+ try {
+ await this.aboutMenuItem.click({ timeout: 5000 })
+ } catch {
+ await this.navigateTo('/settings/about')
+ }
+ await this.page.waitForURL('**/#/settings/about**', { timeout: 10000 }).catch(() => {})
+ }
+
+ /**
+ * Toggle a switch setting by its label.
+ */
+ async toggleSwitch(label: string): Promise {
+ const switchElement = this.page.locator(`text=${label}`).locator('..').locator('button[role="switch"], .ant-switch')
+ await switchElement.first().click()
+ }
+
+ /**
+ * Check if a menu item is active/selected.
+ */
+ async isMenuItemActive(menuItem: Locator): Promise {
+ const className = await menuItem.getAttribute('class')
+ return className?.includes('active') || className?.includes('selected') || false
+ }
+}
diff --git a/tests/e2e/pages/sidebar.page.ts b/tests/e2e/pages/sidebar.page.ts
new file mode 100644
index 0000000000..a65c332165
--- /dev/null
+++ b/tests/e2e/pages/sidebar.page.ts
@@ -0,0 +1,122 @@
+import type { Locator, Page } from '@playwright/test'
+
+import { BasePage } from './base.page'
+
+/**
+ * Page Object for the Sidebar/Navigation component.
+ * Handles navigation between different sections of the app.
+ */
+export class SidebarPage extends BasePage {
+ readonly sidebar: Locator
+ readonly homeLink: Locator
+ readonly storeLink: Locator
+ readonly knowledgeLink: Locator
+ readonly filesLink: Locator
+ readonly settingsLink: Locator
+ readonly appsLink: Locator
+ readonly translateLink: Locator
+
+ constructor(page: Page) {
+ super(page)
+ this.sidebar = page.locator('[class*="Sidebar"], nav, aside')
+ this.homeLink = page.locator('a[href="#/"], a[href="#!/"]').first()
+ this.storeLink = page.locator('a[href*="/store"]')
+ this.knowledgeLink = page.locator('a[href*="/knowledge"]')
+ this.filesLink = page.locator('a[href*="/files"]')
+ this.settingsLink = page.locator('a[href*="/settings"]')
+ this.appsLink = page.locator('a[href*="/apps"]')
+ this.translateLink = page.locator('a[href*="/translate"]')
+ }
+
+ /**
+ * Navigate to Home page.
+ */
+ async goToHome(): Promise {
+ // Try clicking the home link, or navigate directly
+ try {
+ await this.homeLink.click({ timeout: 5000 })
+ } catch {
+ await this.navigateTo('/')
+ }
+ await this.page.waitForURL(/.*#\/$|.*#$|.*#\/home.*/, { timeout: 10000 }).catch(() => {})
+ }
+
+ /**
+ * Navigate to Knowledge page.
+ */
+ async goToKnowledge(): Promise {
+ try {
+ await this.knowledgeLink.click({ timeout: 5000 })
+ } catch {
+ await this.navigateTo('/knowledge')
+ }
+ await this.page.waitForURL('**/#/knowledge**', { timeout: 10000 }).catch(() => {})
+ }
+
+ /**
+ * Navigate to Settings page.
+ */
+ async goToSettings(): Promise {
+ try {
+ await this.settingsLink.click({ timeout: 5000 })
+ } catch {
+ await this.navigateTo('/settings/provider')
+ }
+ await this.page.waitForURL('**/#/settings/**', { timeout: 10000 }).catch(() => {})
+ }
+
+ /**
+ * Navigate to Files page.
+ */
+ async goToFiles(): Promise {
+ try {
+ await this.filesLink.click({ timeout: 5000 })
+ } catch {
+ await this.navigateTo('/files')
+ }
+ await this.page.waitForURL('**/#/files**', { timeout: 10000 }).catch(() => {})
+ }
+
+ /**
+ * Navigate to Apps page.
+ */
+ async goToApps(): Promise {
+ try {
+ await this.appsLink.click({ timeout: 5000 })
+ } catch {
+ await this.navigateTo('/apps')
+ }
+ await this.page.waitForURL('**/#/apps**', { timeout: 10000 }).catch(() => {})
+ }
+
+ /**
+ * Navigate to Store page.
+ */
+ async goToStore(): Promise {
+ try {
+ await this.storeLink.click({ timeout: 5000 })
+ } catch {
+ await this.navigateTo('/store')
+ }
+ await this.page.waitForURL('**/#/store**', { timeout: 10000 }).catch(() => {})
+ }
+
+ /**
+ * Navigate to Translate page.
+ */
+ async goToTranslate(): Promise {
+ try {
+ await this.translateLink.click({ timeout: 5000 })
+ } catch {
+ await this.navigateTo('/translate')
+ }
+ await this.page.waitForURL('**/#/translate**', { timeout: 10000 }).catch(() => {})
+ }
+
+ /**
+ * Check if sidebar is visible.
+ */
+ async isVisible(): Promise {
+ return this.sidebar.first().isVisible()
+ }
+}
diff --git a/tests/e2e/specs/app-launch.spec.ts b/tests/e2e/specs/app-launch.spec.ts
new file mode 100644
index 0000000000..0a58c64fb9
--- /dev/null
+++ b/tests/e2e/specs/app-launch.spec.ts
@@ -0,0 +1,49 @@
+import { expect, test } from '../fixtures/electron.fixture'
+import { waitForAppReady } from '../utils/wait-helpers'
+
+test.describe('App Launch', () => {
+ test('should launch the application successfully', async ({ mainWindow }) => {
+ await waitForAppReady(mainWindow)
+ expect(mainWindow).toBeDefined()
+
+ const title = await mainWindow.title()
+ expect(title).toBeTruthy()
+ })
+
+ test('should display the main content', async ({ mainWindow }) => {
+ await waitForAppReady(mainWindow)
+
+ // Check for main app content
+ const hasContent = await mainWindow.evaluate(() => {
+ const root = document.querySelector('#root')
+ return root !== null && root.innerHTML.length > 100
+ })
+
+ expect(hasContent).toBe(true)
+ })
+
+ test('should have React root mounted', async ({ mainWindow }) => {
+ await waitForAppReady(mainWindow)
+
+ const hasReactRoot = await mainWindow.evaluate(() => {
+ const root = document.querySelector('#root')
+ return root !== null && root.children.length > 0
+ })
+
+ expect(hasReactRoot).toBe(true)
+ })
+
+ test('should have window with reasonable size', async ({ electronApp, mainWindow }) => {
+ await waitForAppReady(mainWindow)
+
+ const bounds = await electronApp.evaluate(({ BrowserWindow }) => {
+ const win = BrowserWindow.getAllWindows()[0]
+ return win?.getBounds()
+ })
+
+ expect(bounds).toBeDefined()
+ // Window should have some reasonable size (may vary based on saved state)
+ expect(bounds!.width).toBeGreaterThan(400)
+ expect(bounds!.height).toBeGreaterThan(300)
+ })
+})
diff --git a/tests/e2e/specs/conversation/basic-chat.spec.ts b/tests/e2e/specs/conversation/basic-chat.spec.ts
new file mode 100644
index 0000000000..2e03ed4ede
--- /dev/null
+++ b/tests/e2e/specs/conversation/basic-chat.spec.ts
@@ -0,0 +1,35 @@
+import { expect, test } from '../../fixtures/electron.fixture'
+import { waitForAppReady } from '../../utils/wait-helpers'
+
+test.describe('Basic Chat', () => {
+ test.beforeEach(async ({ mainWindow }) => {
+ await waitForAppReady(mainWindow)
+ })
+
+ test('should display main content on home page', async ({ mainWindow }) => {
+ // Home page is the default, just verify content exists
+ const hasContent = await mainWindow.evaluate(() => {
+ const root = document.querySelector('#root')
+ return root !== null && root.innerHTML.length > 100
+ })
+
+ expect(hasContent).toBe(true)
+ })
+
+ test('should have input area for chat', async ({ mainWindow }) => {
+ // Look for textarea or input elements that could be chat input
+ const inputElements = mainWindow.locator('textarea, [contenteditable="true"], input[type="text"]')
+ const count = await inputElements.count()
+
+ // There should be at least one input element
+ expect(count).toBeGreaterThan(0)
+ })
+
+ test('should have interactive elements', async ({ mainWindow }) => {
+ // Check for buttons or clickable elements
+ const buttons = mainWindow.locator('button')
+ const count = await buttons.count()
+
+ expect(count).toBeGreaterThan(0)
+ })
+})
diff --git a/tests/e2e/specs/navigation.spec.ts b/tests/e2e/specs/navigation.spec.ts
new file mode 100644
index 0000000000..085bff3930
--- /dev/null
+++ b/tests/e2e/specs/navigation.spec.ts
@@ -0,0 +1,46 @@
+import { expect, test } from '../fixtures/electron.fixture'
+import { SidebarPage } from '../pages/sidebar.page'
+import { waitForAppReady } from '../utils/wait-helpers'
+
+test.describe('Navigation', () => {
+ let sidebarPage: SidebarPage
+
+ test.beforeEach(async ({ mainWindow }) => {
+ await waitForAppReady(mainWindow)
+ sidebarPage = new SidebarPage(mainWindow)
+ })
+
+ test('should navigate to Settings page', async ({ mainWindow }) => {
+ await sidebarPage.goToSettings()
+
+ // Wait a bit for navigation to complete
+ await mainWindow.waitForTimeout(1000)
+
+ const currentUrl = mainWindow.url()
+ expect(currentUrl).toContain('/settings')
+ })
+
+ test('should navigate to Files page', async ({ mainWindow }) => {
+ await sidebarPage.goToFiles()
+
+ await mainWindow.waitForTimeout(1000)
+
+ const currentUrl = mainWindow.url()
+ expect(currentUrl).toContain('/files')
+ })
+
+ test('should navigate back to Home', async ({ mainWindow }) => {
+ // First go to settings
+ await sidebarPage.goToSettings()
+ await mainWindow.waitForTimeout(1000)
+
+ // Then go back to home
+ await sidebarPage.goToHome()
+ await mainWindow.waitForTimeout(1000)
+
+ // Verify we're on home page
+ const currentUrl = mainWindow.url()
+ // Home page URL should be either / or empty hash
+ expect(currentUrl).toMatch(/#\/?$|#$/)
+ })
+})
diff --git a/tests/e2e/specs/settings/general.spec.ts b/tests/e2e/specs/settings/general.spec.ts
new file mode 100644
index 0000000000..6943cf3504
--- /dev/null
+++ b/tests/e2e/specs/settings/general.spec.ts
@@ -0,0 +1,55 @@
+import { expect, test } from '../../fixtures/electron.fixture'
+import { SettingsPage } from '../../pages/settings.page'
+import { SidebarPage } from '../../pages/sidebar.page'
+import { waitForAppReady } from '../../utils/wait-helpers'
+
+test.describe('Settings Page', () => {
+ let settingsPage: SettingsPage
+ let sidebarPage: SidebarPage
+
+ test.beforeEach(async ({ mainWindow }) => {
+ await waitForAppReady(mainWindow)
+ sidebarPage = new SidebarPage(mainWindow)
+ settingsPage = new SettingsPage(mainWindow)
+
+ // Navigate to settings
+ await sidebarPage.goToSettings()
+ await mainWindow.waitForTimeout(1000)
+ })
+
+ test('should display settings page', async ({ mainWindow }) => {
+ const currentUrl = mainWindow.url()
+ expect(currentUrl).toContain('/settings')
+ })
+
+ test('should have settings menu items', async ({ mainWindow }) => {
+ // Check for settings menu items by looking for links
+ const menuItems = mainWindow.locator('a[href*="/settings/"]')
+ const count = await menuItems.count()
+ expect(count).toBeGreaterThan(0)
+ })
+
+ test('should navigate to General settings', async ({ mainWindow }) => {
+ await settingsPage.goToGeneral()
+ await mainWindow.waitForTimeout(500)
+
+ const currentUrl = mainWindow.url()
+ expect(currentUrl).toContain('/settings/general')
+ })
+
+ test('should navigate to Display settings', async ({ mainWindow }) => {
+ await settingsPage.goToDisplay()
+ await mainWindow.waitForTimeout(500)
+
+ const currentUrl = mainWindow.url()
+ expect(currentUrl).toContain('/settings/display')
+ })
+
+ test('should navigate to About page', async ({ mainWindow }) => {
+ await settingsPage.goToAbout()
+ await mainWindow.waitForTimeout(500)
+
+ const currentUrl = mainWindow.url()
+ expect(currentUrl).toContain('/settings/about')
+ })
+})
diff --git a/tests/e2e/utils/index.ts b/tests/e2e/utils/index.ts
new file mode 100644
index 0000000000..908302f024
--- /dev/null
+++ b/tests/e2e/utils/index.ts
@@ -0,0 +1,4 @@
+/**
+ * Export all utilities for easy importing.
+ */
+export * from './wait-helpers'
diff --git a/tests/e2e/utils/wait-helpers.ts b/tests/e2e/utils/wait-helpers.ts
new file mode 100644
index 0000000000..f2ad09ccc1
--- /dev/null
+++ b/tests/e2e/utils/wait-helpers.ts
@@ -0,0 +1,103 @@
+import type { Page } from '@playwright/test'
+
+/**
+ * Wait for the application to be fully ready.
+ * The app uses PersistGate which may delay initial render.
+ * Layout can be either Sidebar-based or TabsContainer-based depending on settings.
+ */
+export async function waitForAppReady(page: Page, timeout: number = 60000): Promise {
+ // First, wait for React root to be attached
+ await page.waitForSelector('#root', { state: 'attached', timeout })
+
+ // Wait for main app content to render
+ // The app may show either:
+ // 1. Sidebar layout (navbarPosition === 'left')
+ // 2. TabsContainer layout (default)
+ // 3. Home page content
+ await page.waitForSelector(
+ [
+ '#home-page', // Home page container
+ '[class*="Sidebar"]', // Sidebar component
+ '[class*="TabsContainer"]', // Tabs container
+ '[class*="home-navbar"]', // Home navbar
+ '[class*="Container"]' // Generic container from styled-components
+ ].join(', '),
+ {
+ state: 'visible',
+ timeout
+ }
+ )
+
+ // Additional wait for React to fully hydrate
+ await page.waitForLoadState('domcontentloaded')
+}
+
+/**
+ * Wait for navigation to a specific path.
+ * The app uses HashRouter, so paths are prefixed with #.
+ */
+export async function waitForNavigation(page: Page, path: string, timeout: number = 15000): Promise {
+ await page.waitForURL(`**/#${path}**`, { timeout })
+}
+
+/**
+ * Wait for the chat interface to be ready.
+ */
+export async function waitForChatReady(page: Page, timeout: number = 30000): Promise {
+ await page.waitForSelector(
+ ['#home-page', '[class*="Chat"]', '[class*="Inputbar"]', '[class*="home-tabs"]'].join(', '),
+ { state: 'visible', timeout }
+ )
+}
+
+/**
+ * Wait for the settings page to load.
+ */
+export async function waitForSettingsLoad(page: Page, timeout: number = 30000): Promise {
+ await page.waitForSelector(['[class*="SettingsPage"]', '[class*="Settings"]', 'a[href*="/settings/"]'].join(', '), {
+ state: 'visible',
+ timeout
+ })
+}
+
+/**
+ * Wait for a modal/dialog to appear.
+ */
+export async function waitForModal(page: Page, timeout: number = 10000): Promise {
+ await page.waitForSelector('.ant-modal, [role="dialog"], .ant-drawer', { state: 'visible', timeout })
+}
+
+/**
+ * Wait for a modal/dialog to close.
+ */
+export async function waitForModalClose(page: Page, timeout: number = 10000): Promise {
+ await page.waitForSelector('.ant-modal, [role="dialog"], .ant-drawer', { state: 'hidden', timeout })
+}
+
+/**
+ * Wait for loading state to complete.
+ */
+export async function waitForLoadingComplete(page: Page, timeout: number = 30000): Promise {
+ const spinner = page.locator('.ant-spin, [class*="Loading"], [class*="Spinner"]')
+ if ((await spinner.count()) > 0) {
+ await spinner.first().waitFor({ state: 'hidden', timeout })
+ }
+}
+
+/**
+ * Wait for a notification/toast to appear.
+ */
+export async function waitForNotification(page: Page, timeout: number = 10000): Promise {
+ await page.waitForSelector('.ant-notification, .ant-message, [class*="Notification"]', {
+ state: 'visible',
+ timeout
+ })
+}
+
+/**
+ * Sleep for a specified duration.
+ * Use sparingly - prefer explicit waits when possible.
+ */
+export async function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
diff --git a/yarn.lock b/yarn.lock
index 02d11ef5d5..7f7ed62da7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5437,14 +5437,14 @@ __metadata:
languageName: node
linkType: hard
-"@playwright/test@npm:^1.52.0":
- version: 1.52.0
- resolution: "@playwright/test@npm:1.52.0"
+"@playwright/test@npm:^1.55.1":
+ version: 1.57.0
+ resolution: "@playwright/test@npm:1.57.0"
dependencies:
- playwright: "npm:1.52.0"
+ playwright: "npm:1.57.0"
bin:
playwright: cli.js
- checksum: 10c0/1c428b421593eb4f79b7c99783a389c3ab3526c9051ec772749f4fca61414dfa9f2344eba846faac5f238084aa96c836364a91d81d3034ac54924f239a93e247
+ checksum: 10c0/35ba4b28be72bf0a53e33dbb11c6cff848fb9a37f49e893ce63a90675b5291ec29a1ba82c8a3b043abaead129400f0589623e9ace2e6a1c8eaa409721ecc3774
languageName: node
linkType: hard
@@ -10059,7 +10059,7 @@ __metadata:
"@opentelemetry/sdk-trace-web": "npm:^2.0.0"
"@opeoginni/github-copilot-openai-compatible": "npm:^0.1.21"
"@paymoapp/electron-shutdown-handler": "npm:^1.1.2"
- "@playwright/test": "npm:^1.52.0"
+ "@playwright/test": "npm:^1.55.1"
"@radix-ui/react-context-menu": "npm:^2.2.16"
"@reduxjs/toolkit": "npm:^2.2.5"
"@shikijs/markdown-it": "npm:^3.12.0"
@@ -10219,7 +10219,6 @@ __metadata:
p-queue: "npm:^8.1.0"
pdf-lib: "npm:^1.17.1"
pdf-parse: "npm:^1.1.1"
- playwright: "npm:^1.55.1"
proxy-agent: "npm:^6.5.0"
qrcode.react: "npm:^4.2.0"
react: "npm:^19.2.0"
@@ -20699,51 +20698,27 @@ __metadata:
languageName: node
linkType: hard
-"playwright-core@npm:1.52.0":
- version: 1.52.0
- resolution: "playwright-core@npm:1.52.0"
+"playwright-core@npm:1.57.0":
+ version: 1.57.0
+ resolution: "playwright-core@npm:1.57.0"
bin:
playwright-core: cli.js
- checksum: 10c0/640945507e6ca2144e9f596b2a6ecac042c2fd3683ff99e6271e9a7b38f3602d415f282609d569456f66680aab8b3c5bb1b257d8fb63a7fc0ed648261110421f
+ checksum: 10c0/798e35d83bf48419a8c73de20bb94d68be5dde68de23f95d80a0ebe401e3b83e29e3e84aea7894d67fa6c79d2d3d40cc5bcde3e166f657ce50987aaa2421b6a9
languageName: node
linkType: hard
-"playwright-core@npm:1.56.1":
- version: 1.56.1
- resolution: "playwright-core@npm:1.56.1"
- bin:
- playwright-core: cli.js
- checksum: 10c0/ffd40142b99c68678b387445d5b42f1fee4ab0b65d983058c37f342e5629f9cdbdac0506ea80a0dfd41a8f9f13345bad54e9a8c35826ef66dc765f4eb3db8da7
- languageName: node
- linkType: hard
-
-"playwright@npm:1.52.0":
- version: 1.52.0
- resolution: "playwright@npm:1.52.0"
+"playwright@npm:1.57.0":
+ version: 1.57.0
+ resolution: "playwright@npm:1.57.0"
dependencies:
fsevents: "npm:2.3.2"
- playwright-core: "npm:1.52.0"
+ playwright-core: "npm:1.57.0"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
- checksum: 10c0/2c6edf1e15e59bbaf77f3fa0fe0ac975793c17cff835d9c8b8bc6395a3b6f1c01898b3058ab37891b2e4d424bcc8f1b4844fe70d943e0143d239d7451408c579
- languageName: node
- linkType: hard
-
-"playwright@npm:^1.55.1":
- version: 1.56.1
- resolution: "playwright@npm:1.56.1"
- dependencies:
- fsevents: "npm:2.3.2"
- playwright-core: "npm:1.56.1"
- dependenciesMeta:
- fsevents:
- optional: true
- bin:
- playwright: cli.js
- checksum: 10c0/8e9965aede86df0f4722063385748498977b219630a40a10d1b82b8bd8d4d4e9b6b65ecbfa024331a30800163161aca292fb6dd7446c531a1ad25f4155625ab4
+ checksum: 10c0/ab03c99a67b835bdea9059f516ad3b6e42c21025f9adaa161a4ef6bc7ca716dcba476d287140bb240d06126eb23f889a8933b8f5f1f1a56b80659d92d1358899
languageName: node
linkType: hard
From 0836eef1a6a5bf95782315de891e39fa4153c0b3 Mon Sep 17 00:00:00 2001
From: xerxesliu
Date: Thu, 27 Nov 2025 20:22:27 +0800
Subject: [PATCH 08/17] fix: store JSON custom parameters as strings instead of
objects (#11501) (#11503)
Previously, JSON-type custom parameters were incorrectly parsed and stored
as objects in the UI layer, causing API requests to fail when getCustomParameters()
attempted to JSON.parse() an already-parsed object.
Changes:
- AssistantModelSettings.tsx: Remove JSON.parse() in onChange handler, store as string
- reasoning.ts: Add comments explaining JSON parsing flow
- BaseApiClient.ts: Add comments for legacy API clients
---
.../src/aiCore/legacy/clients/BaseApiClient.ts | 3 +++
src/renderer/src/aiCore/utils/reasoning.ts | 4 ++++
.../AssistantModelSettings.tsx | 18 ++++++++++++------
3 files changed, 19 insertions(+), 6 deletions(-)
diff --git a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts
index c1c06b359b..e755ce3f20 100644
--- a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts
+++ b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts
@@ -405,6 +405,9 @@ export abstract class BaseApiClient<
if (!param.name?.trim()) {
return acc
}
+ // Parse JSON type parameters (Legacy API clients)
+ // Related: src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx:133-148
+ // The UI stores JSON type params as strings, this function parses them before sending to API
if (param.type === 'json') {
const value = param.value as string
if (value === 'undefined') {
diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts
index ba4ab35f8e..8f0df91e7b 100644
--- a/src/renderer/src/aiCore/utils/reasoning.ts
+++ b/src/renderer/src/aiCore/utils/reasoning.ts
@@ -684,6 +684,10 @@ export function getCustomParameters(assistant: Assistant): Record {
if (!param.name?.trim()) {
return acc
}
+ // Parse JSON type parameters
+ // Related: src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx:133-148
+ // The UI stores JSON type params as strings (e.g., '{"key":"value"}')
+ // This function parses them into objects before sending to the API
if (param.type === 'json') {
const value = param.value as string
if (value === 'undefined') {
diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx
index c452096d47..bc594235a7 100644
--- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx
+++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx
@@ -135,12 +135,18 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA
{
- try {
- const jsonValue = JSON.parse(e.target.value)
- onUpdateCustomParameter(index, 'value', jsonValue)
- } catch {
- onUpdateCustomParameter(index, 'value', e.target.value)
- }
+ // For JSON type parameters, always store the value as a STRING
+ //
+ // Data Flow:
+ // 1. UI stores: { name: "config", value: '{"key":"value"}', type: "json" } ← STRING format
+ // 2. API parses: getCustomParameters() in src/renderer/src/aiCore/utils/reasoning.ts:687-696
+ // calls JSON.parse() to convert string to object
+ // 3. Request sends: The parsed object is sent to the AI provider
+ //
+ // Previously this code was parsing JSON here and storing
+ // the object directly, which caused getCustomParameters() to fail when trying
+ // to JSON.parse() an already-parsed object.
+ onUpdateCustomParameter(index, 'value', e.target.value)
}}
/>
)
From 1746e8b21f14e84b7912da1be3bec945cedf98fd Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Thu, 27 Nov 2025 21:50:51 +0800
Subject: [PATCH 09/17] Fix MCP server confusion when multiple instances of the
same server are configured (#10897)
* Initial plan
* Fix MCP server confusion by making tool IDs unique with serverId
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
* Run yarn format to fix code formatting
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
* Fix unit test: allow dash separator in tool names
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
* Fix edge cases: preserve suffix on truncation, handle non-alphanumeric serverId
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
---
src/main/services/MCPService.ts | 2 +-
src/main/utils/__tests__/mcp.test.ts | 196 +++++++++++++++++++++++++++
src/main/utils/mcp.ts | 31 ++++-
3 files changed, 224 insertions(+), 5 deletions(-)
create mode 100644 src/main/utils/__tests__/mcp.test.ts
diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts
index 3831d0af1e..0b8db73930 100644
--- a/src/main/services/MCPService.ts
+++ b/src/main/services/MCPService.ts
@@ -620,7 +620,7 @@ class McpService {
tools.map((tool: SDKTool) => {
const serverTool: MCPTool = {
...tool,
- id: buildFunctionCallToolName(server.name, tool.name),
+ id: buildFunctionCallToolName(server.name, tool.name, server.id),
serverId: server.id,
serverName: server.name,
type: 'mcp'
diff --git a/src/main/utils/__tests__/mcp.test.ts b/src/main/utils/__tests__/mcp.test.ts
new file mode 100644
index 0000000000..b1a35f925e
--- /dev/null
+++ b/src/main/utils/__tests__/mcp.test.ts
@@ -0,0 +1,196 @@
+import { describe, expect, it } from 'vitest'
+
+import { buildFunctionCallToolName } from '../mcp'
+
+describe('buildFunctionCallToolName', () => {
+ describe('basic functionality', () => {
+ it('should combine server name and tool name', () => {
+ const result = buildFunctionCallToolName('github', 'search_issues')
+ expect(result).toContain('github')
+ expect(result).toContain('search')
+ })
+
+ it('should sanitize names by replacing dashes with underscores', () => {
+ const result = buildFunctionCallToolName('my-server', 'my-tool')
+ // Input dashes are replaced, but the separator between server and tool is a dash
+ expect(result).toBe('my_serv-my_tool')
+ expect(result).toContain('_')
+ })
+
+ it('should handle empty server names gracefully', () => {
+ const result = buildFunctionCallToolName('', 'tool')
+ expect(result).toBeTruthy()
+ })
+ })
+
+ describe('uniqueness with serverId', () => {
+ it('should generate different IDs for same server name but different serverIds', () => {
+ const serverId1 = 'server-id-123456'
+ const serverId2 = 'server-id-789012'
+ const serverName = 'github'
+ const toolName = 'search_repos'
+
+ const result1 = buildFunctionCallToolName(serverName, toolName, serverId1)
+ const result2 = buildFunctionCallToolName(serverName, toolName, serverId2)
+
+ expect(result1).not.toBe(result2)
+ expect(result1).toContain('123456')
+ expect(result2).toContain('789012')
+ })
+
+ it('should generate same ID when serverId is not provided', () => {
+ const serverName = 'github'
+ const toolName = 'search_repos'
+
+ const result1 = buildFunctionCallToolName(serverName, toolName)
+ const result2 = buildFunctionCallToolName(serverName, toolName)
+
+ expect(result1).toBe(result2)
+ })
+
+ it('should include serverId suffix when provided', () => {
+ const serverId = 'abc123def456'
+ const result = buildFunctionCallToolName('server', 'tool', serverId)
+
+ // Should include last 6 chars of serverId
+ expect(result).toContain('ef456')
+ })
+ })
+
+ describe('character sanitization', () => {
+ it('should replace invalid characters with underscores', () => {
+ const result = buildFunctionCallToolName('test@server', 'tool#name')
+ expect(result).not.toMatch(/[@#]/)
+ expect(result).toMatch(/^[a-zA-Z0-9_-]+$/)
+ })
+
+ it('should ensure name starts with a letter', () => {
+ const result = buildFunctionCallToolName('123server', '456tool')
+ expect(result).toMatch(/^[a-zA-Z]/)
+ })
+
+ it('should handle consecutive underscores/dashes', () => {
+ const result = buildFunctionCallToolName('my--server', 'my__tool')
+ expect(result).not.toMatch(/[_-]{2,}/)
+ })
+ })
+
+ describe('length constraints', () => {
+ it('should truncate names longer than 63 characters', () => {
+ const longServerName = 'a'.repeat(50)
+ const longToolName = 'b'.repeat(50)
+ const result = buildFunctionCallToolName(longServerName, longToolName, 'id123456')
+
+ expect(result.length).toBeLessThanOrEqual(63)
+ })
+
+ it('should not end with underscore or dash after truncation', () => {
+ const longServerName = 'a'.repeat(50)
+ const longToolName = 'b'.repeat(50)
+ const result = buildFunctionCallToolName(longServerName, longToolName, 'id123456')
+
+ expect(result).not.toMatch(/[_-]$/)
+ })
+
+ it('should preserve serverId suffix even with long server/tool names', () => {
+ const longServerName = 'a'.repeat(50)
+ const longToolName = 'b'.repeat(50)
+ const serverId = 'server-id-xyz789'
+
+ const result = buildFunctionCallToolName(longServerName, longToolName, serverId)
+
+ // The suffix should be preserved and not truncated
+ expect(result).toContain('xyz789')
+ expect(result.length).toBeLessThanOrEqual(63)
+ })
+
+ it('should ensure two long-named servers with different IDs produce different results', () => {
+ const longServerName = 'a'.repeat(50)
+ const longToolName = 'b'.repeat(50)
+ const serverId1 = 'server-id-abc123'
+ const serverId2 = 'server-id-def456'
+
+ const result1 = buildFunctionCallToolName(longServerName, longToolName, serverId1)
+ const result2 = buildFunctionCallToolName(longServerName, longToolName, serverId2)
+
+ // Both should be within limit
+ expect(result1.length).toBeLessThanOrEqual(63)
+ expect(result2.length).toBeLessThanOrEqual(63)
+
+ // They should be different due to preserved suffix
+ expect(result1).not.toBe(result2)
+ })
+ })
+
+ describe('edge cases with serverId', () => {
+ it('should handle serverId with only non-alphanumeric characters', () => {
+ const serverId = '------' // All dashes
+ const result = buildFunctionCallToolName('server', 'tool', serverId)
+
+ // Should still produce a valid unique suffix via fallback hash
+ expect(result).toBeTruthy()
+ expect(result.length).toBeLessThanOrEqual(63)
+ expect(result).toMatch(/^[a-zA-Z][a-zA-Z0-9_-]*$/)
+ // Should have a suffix (underscore followed by something)
+ expect(result).toMatch(/_[a-z0-9]+$/)
+ })
+
+ it('should produce different results for different non-alphanumeric serverIds', () => {
+ const serverId1 = '------'
+ const serverId2 = '!!!!!!'
+
+ const result1 = buildFunctionCallToolName('server', 'tool', serverId1)
+ const result2 = buildFunctionCallToolName('server', 'tool', serverId2)
+
+ // Should be different because the hash fallback produces different values
+ expect(result1).not.toBe(result2)
+ })
+
+ it('should handle empty string serverId differently from undefined', () => {
+ const resultWithEmpty = buildFunctionCallToolName('server', 'tool', '')
+ const resultWithUndefined = buildFunctionCallToolName('server', 'tool', undefined)
+
+ // Empty string is falsy, so both should behave the same (no suffix)
+ expect(resultWithEmpty).toBe(resultWithUndefined)
+ })
+
+ it('should handle serverId with mixed alphanumeric and special chars', () => {
+ const serverId = 'ab@#cd' // Mixed chars, last 6 chars contain some alphanumeric
+ const result = buildFunctionCallToolName('server', 'tool', serverId)
+
+ // Should extract alphanumeric chars: 'abcd' from 'ab@#cd'
+ expect(result).toContain('abcd')
+ })
+ })
+
+ describe('real-world scenarios', () => {
+ it('should handle GitHub MCP server instances correctly', () => {
+ const serverName = 'github'
+ const toolName = 'search_repositories'
+
+ const githubComId = 'server-github-com-abc123'
+ const gheId = 'server-ghe-internal-xyz789'
+
+ const tool1 = buildFunctionCallToolName(serverName, toolName, githubComId)
+ const tool2 = buildFunctionCallToolName(serverName, toolName, gheId)
+
+ // Should be different
+ expect(tool1).not.toBe(tool2)
+
+ // Both should be valid identifiers
+ expect(tool1).toMatch(/^[a-zA-Z][a-zA-Z0-9_-]*$/)
+ expect(tool2).toMatch(/^[a-zA-Z][a-zA-Z0-9_-]*$/)
+
+ // Both should be <= 63 chars
+ expect(tool1.length).toBeLessThanOrEqual(63)
+ expect(tool2.length).toBeLessThanOrEqual(63)
+ })
+
+ it('should handle tool names that already include server name prefix', () => {
+ const result = buildFunctionCallToolName('github', 'github_search_repos')
+ expect(result).toBeTruthy()
+ // Should not double the server name
+ expect(result.split('github').length - 1).toBeLessThanOrEqual(2)
+ })
+ })
+})
diff --git a/src/main/utils/mcp.ts b/src/main/utils/mcp.ts
index 23d19806d9..cfa700f2e6 100644
--- a/src/main/utils/mcp.ts
+++ b/src/main/utils/mcp.ts
@@ -1,7 +1,25 @@
-export function buildFunctionCallToolName(serverName: string, toolName: string) {
+export function buildFunctionCallToolName(serverName: string, toolName: string, serverId?: string) {
const sanitizedServer = serverName.trim().replace(/-/g, '_')
const sanitizedTool = toolName.trim().replace(/-/g, '_')
+ // Calculate suffix first to reserve space for it
+ // Suffix format: "_" + 6 alphanumeric chars = 7 chars total
+ let serverIdSuffix = ''
+ if (serverId) {
+ // Take the last 6 characters of the serverId for brevity
+ serverIdSuffix = serverId.slice(-6).replace(/[^a-zA-Z0-9]/g, '')
+
+ // Fallback: if suffix becomes empty (all non-alphanumeric chars), use a simple hash
+ if (!serverIdSuffix) {
+ const hash = serverId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
+ serverIdSuffix = hash.toString(36).slice(-6) || 'x'
+ }
+ }
+
+ // Reserve space for suffix when calculating max base name length
+ const SUFFIX_LENGTH = serverIdSuffix ? serverIdSuffix.length + 1 : 0 // +1 for underscore
+ const MAX_BASE_LENGTH = 63 - SUFFIX_LENGTH
+
// Combine server name and tool name
let name = sanitizedTool
if (!sanitizedTool.includes(sanitizedServer.slice(0, 7))) {
@@ -20,9 +38,9 @@ export function buildFunctionCallToolName(serverName: string, toolName: string)
// Remove consecutive underscores/dashes (optional improvement)
name = name.replace(/[_-]{2,}/g, '_')
- // Truncate to 63 characters maximum
- if (name.length > 63) {
- name = name.slice(0, 63)
+ // Truncate base name BEFORE adding suffix to ensure suffix is never cut off
+ if (name.length > MAX_BASE_LENGTH) {
+ name = name.slice(0, MAX_BASE_LENGTH)
}
// Handle edge case: ensure we still have a valid name if truncation left invalid chars at edges
@@ -30,5 +48,10 @@ export function buildFunctionCallToolName(serverName: string, toolName: string)
name = name.slice(0, -1)
}
+ // Now append the suffix - it will always fit within 63 chars
+ if (serverIdSuffix) {
+ name = `${name}_${serverIdSuffix}`
+ }
+
return name
}
From 0d12b5fbc2257b8ef25f4513a8a7a4757f7d3e15 Mon Sep 17 00:00:00 2001
From: Phantom
Date: Thu, 27 Nov 2025 22:22:04 +0800
Subject: [PATCH 10/17] fix(SelectModelPopup): memoize adapted models to avoid
unnecessary updates (#11506)
fix(SelectModelPopup): memoize adapted models to avoid unnecessary update
---
.../src/components/Popups/SelectModelPopup/api-model-popup.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx
index df4dbb0485..3924d6b57f 100644
--- a/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx
+++ b/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx
@@ -57,7 +57,7 @@ const PopupContainer: React.FC = ({ model, apiFilter, modelFilter, showTa
const [_searchText, setSearchText] = useState('')
const searchText = useDeferredValue(_searchText)
const { models, isLoading } = useApiModels(apiFilter)
- const adaptedModels = models.map((model) => apiModelAdapter(model))
+ const adaptedModels = useMemo(() => models.map((model) => apiModelAdapter(model)), [models])
// 当前选中的模型ID
const currentModelId = model ? model.id : ''
From bf35902696ce9f374b9f0a3d299faee8d51efa21 Mon Sep 17 00:00:00 2001
From: SuYao
Date: Thu, 27 Nov 2025 22:35:24 +0800
Subject: [PATCH 11/17] fix(mcp): ensure tool uniqueness by using tool IDs for
multiple server instances (#11508)
---
.../src/aiCore/utils/__tests__/mcp.test.ts | 47 ++++++++++---------
src/renderer/src/aiCore/utils/mcp.ts | 4 +-
2 files changed, 29 insertions(+), 22 deletions(-)
diff --git a/src/renderer/src/aiCore/utils/__tests__/mcp.test.ts b/src/renderer/src/aiCore/utils/__tests__/mcp.test.ts
index a832e9f632..dc26a03c80 100644
--- a/src/renderer/src/aiCore/utils/__tests__/mcp.test.ts
+++ b/src/renderer/src/aiCore/utils/__tests__/mcp.test.ts
@@ -71,10 +71,11 @@ describe('mcp utils', () => {
const result = setupToolsConfig(mcpTools)
expect(result).not.toBeUndefined()
- expect(Object.keys(result!)).toEqual(['test-tool'])
- expect(result!['test-tool']).toHaveProperty('description')
- expect(result!['test-tool']).toHaveProperty('inputSchema')
- expect(result!['test-tool']).toHaveProperty('execute')
+ // Tools are now keyed by id (which includes serverId suffix) for uniqueness
+ expect(Object.keys(result!)).toEqual(['test-tool-1'])
+ expect(result!['test-tool-1']).toHaveProperty('description')
+ expect(result!['test-tool-1']).toHaveProperty('inputSchema')
+ expect(result!['test-tool-1']).toHaveProperty('execute')
})
it('should handle multiple MCP tools', () => {
@@ -109,7 +110,8 @@ describe('mcp utils', () => {
expect(result).not.toBeUndefined()
expect(Object.keys(result!)).toHaveLength(2)
- expect(Object.keys(result!)).toEqual(['tool1', 'tool2'])
+ // Tools are keyed by id for uniqueness
+ expect(Object.keys(result!)).toEqual(['tool1-id', 'tool2-id'])
})
})
@@ -135,9 +137,10 @@ describe('mcp utils', () => {
const result = convertMcpToolsToAiSdkTools(mcpTools)
- expect(Object.keys(result)).toEqual(['get-weather'])
+ // Tools are keyed by id for uniqueness when multiple server instances exist
+ expect(Object.keys(result)).toEqual(['get-weather-id'])
- const tool = result['get-weather'] as Tool
+ const tool = result['get-weather-id'] as Tool
expect(tool.description).toBe('Get weather information')
expect(tool.inputSchema).toBeDefined()
expect(typeof tool.execute).toBe('function')
@@ -160,8 +163,8 @@ describe('mcp utils', () => {
const result = convertMcpToolsToAiSdkTools(mcpTools)
- expect(Object.keys(result)).toEqual(['no-desc-tool'])
- const tool = result['no-desc-tool'] as Tool
+ expect(Object.keys(result)).toEqual(['no-desc-tool-id'])
+ const tool = result['no-desc-tool-id'] as Tool
expect(tool.description).toBe('Tool from test-server')
})
@@ -202,13 +205,13 @@ describe('mcp utils', () => {
const result = convertMcpToolsToAiSdkTools(mcpTools)
- expect(Object.keys(result)).toEqual(['complex-tool'])
- const tool = result['complex-tool'] as Tool
+ expect(Object.keys(result)).toEqual(['complex-tool-id'])
+ const tool = result['complex-tool-id'] as Tool
expect(tool.inputSchema).toBeDefined()
expect(typeof tool.execute).toBe('function')
})
- it('should preserve tool names with special characters', () => {
+ it('should preserve tool id with special characters', () => {
const mcpTools: MCPTool[] = [
{
id: 'special-tool-id',
@@ -225,7 +228,8 @@ describe('mcp utils', () => {
]
const result = convertMcpToolsToAiSdkTools(mcpTools)
- expect(Object.keys(result)).toEqual(['tool_with-special.chars'])
+ // Tools are keyed by id for uniqueness
+ expect(Object.keys(result)).toEqual(['special-tool-id'])
})
it('should handle multiple tools with different schemas', () => {
@@ -276,10 +280,11 @@ describe('mcp utils', () => {
const result = convertMcpToolsToAiSdkTools(mcpTools)
- expect(Object.keys(result).sort()).toEqual(['boolean-tool', 'number-tool', 'string-tool'])
- expect(result['string-tool']).toBeDefined()
- expect(result['number-tool']).toBeDefined()
- expect(result['boolean-tool']).toBeDefined()
+ // Tools are keyed by id for uniqueness
+ expect(Object.keys(result).sort()).toEqual(['boolean-tool-id', 'number-tool-id', 'string-tool-id'])
+ expect(result['string-tool-id']).toBeDefined()
+ expect(result['number-tool-id']).toBeDefined()
+ expect(result['boolean-tool-id']).toBeDefined()
})
})
@@ -310,7 +315,7 @@ describe('mcp utils', () => {
]
const tools = convertMcpToolsToAiSdkTools(mcpTools)
- const tool = tools['test-exec-tool'] as Tool
+ const tool = tools['test-exec-tool-id'] as Tool
const result = await tool.execute!({}, { messages: [], abortSignal: undefined, toolCallId: 'test-call-123' })
expect(requestToolConfirmation).toHaveBeenCalled()
@@ -343,7 +348,7 @@ describe('mcp utils', () => {
]
const tools = convertMcpToolsToAiSdkTools(mcpTools)
- const tool = tools['cancelled-tool'] as Tool
+ const tool = tools['cancelled-tool-id'] as Tool
const result = await tool.execute!({}, { messages: [], abortSignal: undefined, toolCallId: 'cancel-call-123' })
expect(requestToolConfirmation).toHaveBeenCalled()
@@ -385,7 +390,7 @@ describe('mcp utils', () => {
]
const tools = convertMcpToolsToAiSdkTools(mcpTools)
- const tool = tools['error-tool'] as Tool
+ const tool = tools['error-tool-id'] as Tool
await expect(
tool.execute!({}, { messages: [], abortSignal: undefined, toolCallId: 'error-call-123' })
@@ -421,7 +426,7 @@ describe('mcp utils', () => {
]
const tools = convertMcpToolsToAiSdkTools(mcpTools)
- const tool = tools['auto-approve-tool'] as Tool
+ const tool = tools['auto-approve-tool-id'] as Tool
const result = await tool.execute!({}, { messages: [], abortSignal: undefined, toolCallId: 'auto-call-123' })
expect(requestToolConfirmation).not.toHaveBeenCalled()
diff --git a/src/renderer/src/aiCore/utils/mcp.ts b/src/renderer/src/aiCore/utils/mcp.ts
index 84bc661aa0..7d3be9ac96 100644
--- a/src/renderer/src/aiCore/utils/mcp.ts
+++ b/src/renderer/src/aiCore/utils/mcp.ts
@@ -28,7 +28,9 @@ export function convertMcpToolsToAiSdkTools(mcpTools: MCPTool[]): ToolSet {
const tools: ToolSet = {}
for (const mcpTool of mcpTools) {
- tools[mcpTool.name] = tool({
+ // Use mcpTool.id (which includes serverId suffix) to ensure uniqueness
+ // when multiple instances of the same MCP server type are configured
+ tools[mcpTool.id] = tool({
description: mcpTool.description || `Tool from ${mcpTool.serverName}`,
inputSchema: jsonSchema(mcpTool.inputSchema as JSONSchema7),
execute: async (params, { toolCallId }) => {
From 77a9504f74578a1aa18e999a5e715533dff978e3 Mon Sep 17 00:00:00 2001
From: SuYao
Date: Thu, 27 Nov 2025 22:45:43 +0800
Subject: [PATCH 12/17] Fix/condition OpenAI settings (#11509)
* fix(provider): update service tier support logic for OpenAI and Azure providers
* fix(settings): enhance OpenAI settings visibility logic with verbosity support
---
src/renderer/src/pages/home/Tabs/SettingsTab.tsx | 9 ++++++---
src/renderer/src/utils/provider.ts | 12 +++++++++---
2 files changed, 15 insertions(+), 6 deletions(-)
diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx
index 31dcfe437e..57dac8c78a 100644
--- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx
+++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx
@@ -9,7 +9,7 @@ import {
DEFAULT_TEMPERATURE,
MAX_CONTEXT_COUNT
} from '@renderer/config/constant'
-import { isOpenAIModel } from '@renderer/config/models'
+import { isOpenAIModel, isSupportVerbosityModel } from '@renderer/config/models'
import { UNKNOWN } from '@renderer/config/translate'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useTheme } from '@renderer/context/ThemeProvider'
@@ -56,7 +56,7 @@ import type { Assistant, AssistantSettings, CodeStyleVarious, MathEngine } from
import { isGroqSystemProvider, ThemeMode } from '@renderer/types'
import { modalConfirm } from '@renderer/utils'
import { getSendMessageShortcutLabel } from '@renderer/utils/input'
-import { isSupportServiceTierProvider } from '@renderer/utils/provider'
+import { isSupportServiceTierProvider, isSupportVerbosityProvider } from '@renderer/utils/provider'
import { Button, Col, InputNumber, Row, Slider, Switch } from 'antd'
import { Settings2 } from 'lucide-react'
import type { FC } from 'react'
@@ -183,7 +183,10 @@ const SettingsTab: FC = (props) => {
const model = assistant.model || getDefaultModel()
- const showOpenAiSettings = isOpenAIModel(model) || isSupportServiceTierProvider(provider)
+ const showOpenAiSettings =
+ isOpenAIModel(model) ||
+ isSupportServiceTierProvider(provider) ||
+ (isSupportVerbosityModel(model) && isSupportVerbosityProvider(provider))
return (
diff --git a/src/renderer/src/utils/provider.ts b/src/renderer/src/utils/provider.ts
index e8fc1b5cc7..66b4d708d7 100644
--- a/src/renderer/src/utils/provider.ts
+++ b/src/renderer/src/utils/provider.ts
@@ -71,15 +71,21 @@ export const isSupportEnableThinkingProvider = (provider: Provider) => {
)
}
-const NOT_SUPPORT_SERVICE_TIER_PROVIDERS = ['github', 'copilot', 'cerebras'] as const satisfies SystemProviderId[]
+const SUPPORT_SERVICE_TIER_PROVIDERS = [
+ SystemProviderIds.openai,
+ SystemProviderIds['azure-openai'],
+ SystemProviderIds.groq
+ // TODO: 等待上游支持aws-bedrock
+]
/**
- * 判断提供商是否支持 service_tier 设置。 Only for OpenAI API.
+ * 判断提供商是否支持 service_tier 设置
*/
export const isSupportServiceTierProvider = (provider: Provider) => {
return (
provider.apiOptions?.isSupportServiceTier === true ||
- (isSystemProvider(provider) && !NOT_SUPPORT_SERVICE_TIER_PROVIDERS.some((pid) => pid === provider.id))
+ provider.type === 'azure-openai' ||
+ (isSystemProvider(provider) && SUPPORT_SERVICE_TIER_PROVIDERS.some((pid) => pid === provider.id))
)
}
From 7ce1590eafc2fba66e346d9d48519afdb1dfd17e Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Fri, 28 Nov 2025 10:12:21 +0800
Subject: [PATCH 13/17] fix: add null checks and type guards to all
MessageAgentTools to prevent rendering errors (#11512)
* Initial plan
* fix: add null checks to BashTool to prevent rendering errors
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
* fix: add null checks to all MessageAgentTools to prevent rendering errors
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
* fix: add Array.isArray checks to prevent map errors on non-array values
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
* fix: add typeof checks for string operations to prevent type errors
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
* refactor: remove redundant typeof string checks for typed outputs
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
---
.../Tools/MessageAgentTools/BashOutputTool.tsx | 4 ++--
.../Messages/Tools/MessageAgentTools/BashTool.tsx | 8 ++++----
.../Messages/Tools/MessageAgentTools/EditTool.tsx | 8 ++++----
.../Tools/MessageAgentTools/ExitPlanModeTool.tsx | 7 ++++---
.../Messages/Tools/MessageAgentTools/GlobTool.tsx | 4 ++--
.../Messages/Tools/MessageAgentTools/GrepTool.tsx | 6 +++---
.../Tools/MessageAgentTools/MultiEditTool.tsx | 11 ++++++-----
.../Tools/MessageAgentTools/NotebookEditTool.tsx | 6 +++---
.../Messages/Tools/MessageAgentTools/ReadTool.tsx | 4 ++--
.../Tools/MessageAgentTools/SearchTool.tsx | 6 +++---
.../Tools/MessageAgentTools/SkillTool.tsx | 4 ++--
.../Messages/Tools/MessageAgentTools/TaskTool.tsx | 15 ++++++++-------
.../Tools/MessageAgentTools/TodoWriteTool.tsx | 9 +++++----
.../Tools/MessageAgentTools/WebFetchTool.tsx | 4 ++--
.../Tools/MessageAgentTools/WebSearchTool.tsx | 4 ++--
.../Tools/MessageAgentTools/WriteTool.tsx | 6 +++---
16 files changed, 55 insertions(+), 51 deletions(-)
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx
index b47bb3f64a..39d72abcf8 100644
--- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx
+++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx
@@ -76,7 +76,7 @@ export function BashOutputTool({
input,
output
}: {
- input: BashOutputToolInput
+ input?: BashOutputToolInput
output?: BashOutputToolOutput
}): NonNullable[number] {
const parsedOutput = parseBashOutput(output)
@@ -144,7 +144,7 @@ export function BashOutputTool({
label="Bash Output"
params={