diff --git a/docs/specs/tool-output-guardrails/plan.md b/docs/specs/tool-output-guardrails/plan.md new file mode 100644 index 000000000..784ab5e35 --- /dev/null +++ b/docs/specs/tool-output-guardrails/plan.md @@ -0,0 +1,30 @@ +# Tool Output Guardrails Plan + +## Summary + +- Keep the existing single-tool offload behavior. +- Add batch fitting for tool results in the new session agent path only. +- Preserve the largest prefix of tool results that can still fit the next model call. +- Downgrade overflow tail results to the fixed context-window failure message before continuing. +- Keep terminal error fallback when even the fully downgraded batch cannot fit. + +## Implementation + +- Extend `ToolOutputGuard` with a batch fitting helper that: + - evaluates the full staged batch against the context budget + - downgrades tail items one by one to the fixed failure message + - cleans up offload files for downgraded items + - returns terminal fallback if the fully downgraded batch still does not fit +- Refactor `executeTools()` in `deepchatAgentPresenter/dispatch.ts` into two phases: + - execute tools and stage candidate outputs plus side effects + - fit the staged batch, then commit final tool messages, blocks, hooks, and search persistence once +- Keep `question` and `permission` pauses on the immediate path; they are not part of staged batch fitting. +- Keep deferred permission-resume behavior unchanged. + +## Test Plan + +- Multi-`read` batch: keep prefix, downgrade overflow tail, continue next provider turn. +- Mixed `exec`/`read`: downgraded offloaded results must delete their `.offload` files. +- Search resource result in downgraded tail: no search block and no persisted search rows. +- Fully downgraded batch still too large: return terminal error. +- Preserve existing deferred single-tool resume regressions. diff --git a/docs/specs/tool-output-guardrails/spec.md b/docs/specs/tool-output-guardrails/spec.md index cbc470cf0..32e593c27 100644 --- a/docs/specs/tool-output-guardrails/spec.md +++ b/docs/specs/tool-output-guardrails/spec.md @@ -10,23 +10,27 @@ - Provider 报错会出现在主进程日志, 但 UI 未必能看到错误信息. - `directory_tree` 无深度限制, 可能产生巨量输出, 触发 10MB 限制. - 工具返回过大时会被直接注入到 LLM 上下文, 容易导致请求失败. +- 多个 tool call 在单次 loop 内各自不大, 但累计后仍可能挤爆上下文窗口, 尤其是 `read` 一次读取大量文件时. ## 目标 - 让生成失败时的错误信息可见并可追溯. - 给 `directory_tree` 增加深度控制, 最大不超过 3. - 对过大的工具输出做 offload, 用小的 stub 替代进入上下文. +- 当同一轮多个 tool 结果累计超窗时, 保留能放下的前缀结果, 将尾部结果统一降级为固定失败文案并继续后续模型调用. ## 非目标 - 不改动或替换 `agentPresenter/tool` 下的 `ToolRegistry`/`toolRouter`. - 不改变 MCP UI 资源与搜索结果的解析逻辑. +- 不改 legacy `AgentPresenter` 链路, 本次仅覆盖新 session agent. ## 用户故事 1. 作为用户, 我希望生成失败时能在 UI 直接看到原始错误文本. 2. 作为模型, 我希望能指定目录树深度, 避免一次输出过大. 3. 作为系统, 我希望工具输出过大时自动 offload, 仍可在需要时读取完整内容. +4. 作为模型, 我希望当同一批 tool 结果累计超窗时, 能明确知道哪些尾部 tool 因上下文不足而失败, 从而调整下一步策略. ## 验收标准 @@ -57,3 +61,17 @@ - 模型可以通过文件类工具读取上述路径. - 文件类读取工具仅放行当前会话 `conversationId` 对应目录. - `tool_call_response_raw` 不被改写, 避免影响 MCP UI/搜索结果处理. + +### 同轮批量尾部降级 + +- 仅在新 session agent 链路启用. +- 同一轮多个已完成 tool call 在准备进入下一次上下文前, 必须作为一个 batch 统一做预算拟合. +- 如果所有结果都能放下, 保持原样进入上下文. +- 如果累计超窗, 系统从该 batch 的尾部开始逐个降级为固定失败文案: + - `The tool call with ID and name failed because the remaining context window is too small to continue this turn.` +- 降级的 tool 视为失败: + - assistant tool_call block 显示固定失败文案 + - 不保留 search block / search result 持久化 + - 不保留成功型 hooks +- 经过尾部降级后只要 batch 可以放进上下文, 就继续后续模型调用. +- 如果把该 batch 所有 tool 都降级为固定失败文案后仍无法放进上下文, 保持 terminal error 兜底, 结束该 turn. diff --git a/src/main/presenter/deepchatAgentPresenter/dispatch.ts b/src/main/presenter/deepchatAgentPresenter/dispatch.ts index 33f506d4a..6874379db 100644 --- a/src/main/presenter/deepchatAgentPresenter/dispatch.ts +++ b/src/main/presenter/deepchatAgentPresenter/dispatch.ts @@ -16,11 +16,27 @@ import type { } from './types' import type { ChatMessage } from '@shared/types/core/chat-message' import { nanoid } from 'nanoid' -import type { ToolOutputGuard } from './toolOutputGuard' +import type { ToolBatchOutputFitItem, ToolOutputGuard } from './toolOutputGuard' import { buildTerminalErrorBlocks } from './messageStore' type PermissionType = 'read' | 'write' | 'all' | 'command' +type ExtractedSearchPayload = ReturnType + +type StagedToolResult = { + toolCallId: string + toolName: string + toolArgs: string + responseText: string + isError: boolean + offloadPath?: string + searchPayload: ExtractedSearchPayload + rtkApplied?: boolean + rtkMode?: 'rewrite' | 'direct' | 'bypass' + rtkFallbackReason?: string + postHookKind: 'success' | 'failure' +} + type PermissionRequestLike = { toolName?: string serverName?: string @@ -189,6 +205,90 @@ function updateToolCallBlock( } } +function persistToolExecutionState(io: IoParams, state: StreamState): void { + if (!state.dirty) { + return + } + + flushBlocksToRenderer(io, state.blocks) + io.messageStore.updateAssistantContent(io.messageId, state.blocks) + state.dirty = false +} + +function applyFinalizedToolResults(params: { + stagedResults: StagedToolResult[] + fittedResults: ToolBatchOutputFitItem[] + conversation: ChatMessage[] + state: StreamState + io: IoParams + hooks?: ProcessHooks + appendToConversation: boolean +}): void { + const { stagedResults, fittedResults, conversation, state, io, hooks, appendToConversation } = + params + + for (let index = 0; index < stagedResults.length; index += 1) { + const stagedResult = stagedResults[index] + const fittedResult = fittedResults[index] + if (!fittedResult) { + continue + } + + if (appendToConversation) { + conversation.push({ + role: 'tool', + tool_call_id: fittedResult.toolCallId, + content: fittedResult.contextResponseText + }) + } + + if (!fittedResult.downgraded && stagedResult.searchPayload) { + state.blocks.push(stagedResult.searchPayload.block) + for (const result of stagedResult.searchPayload.results) { + io.messageStore.addSearchResult({ + sessionId: io.sessionId, + messageId: io.messageId, + searchId: result.searchId, + rank: typeof result.rank === 'number' ? result.rank : null, + result + }) + } + } + + updateToolCallBlock( + state.blocks, + fittedResult.toolCallId, + fittedResult.responseText, + fittedResult.isError, + fittedResult.downgraded + ? undefined + : { + rtkApplied: stagedResult.rtkApplied, + rtkMode: stagedResult.rtkMode, + rtkFallbackReason: stagedResult.rtkFallbackReason + } + ) + + if (fittedResult.isError) { + hooks?.onPostToolUseFailure?.({ + callId: stagedResult.toolCallId, + name: stagedResult.toolName, + params: stagedResult.toolArgs, + error: fittedResult.responseText + }) + } else if (stagedResult.postHookKind === 'success') { + hooks?.onPostToolUse?.({ + callId: stagedResult.toolCallId, + name: stagedResult.toolName, + params: stagedResult.toolArgs, + response: fittedResult.responseText + }) + } + } + + state.dirty = true +} + function isPermissionType(value: unknown): value is PermissionType { return value === 'read' || value === 'write' || value === 'all' || value === 'command' } @@ -450,6 +550,7 @@ export async function executeTools( let executed = 0 const pendingInteractions: PendingToolInteraction[] = [] + const stagedResults: StagedToolResult[] = [] for (const tc of state.completedToolCalls) { if (io.abortSignal.aborted) break @@ -486,8 +587,7 @@ export async function executeTools( updateToolCallBlock(state.blocks, tc.id, errorText, true) state.dirty = true executed += 1 - flushBlocksToRenderer(io, state.blocks) - io.messageStore.updateAssistantContent(io.messageId, state.blocks) + persistToolExecutionState(io, state) continue } @@ -584,100 +684,83 @@ export async function executeTools( toolContext.name, toolContext.serverName ) - if (searchPayload) { - state.blocks.push(searchPayload.block) - for (const result of searchPayload.results) { - io.messageStore.addSearchResult({ - sessionId: io.sessionId, - messageId: io.messageId, - searchId: result.searchId, - rank: typeof result.rank === 'number' ? result.rank : null, - result - }) - } - } const responseText = toolResponseToText(toolRawData.content) - const guardedResult = await toolOutputGuard.guardToolOutput({ + const preparedResult = await toolOutputGuard.prepareToolOutput({ sessionId: io.sessionId, toolCallId: tc.id, toolName: toolContext.name, - rawContent: responseText, - conversationMessages: conversation, - toolDefinitions: tools, - contextLength, - maxTokens + rawContent: responseText }) + const stagedResponseText = + preparedResult.kind === 'tool_error' ? preparedResult.message : preparedResult.content + const stagedIsError = preparedResult.kind === 'tool_error' || toolRawData.isError === true - if (guardedResult.kind === 'terminal_error') { - updateToolCallBlock(state.blocks, tc.id, guardedResult.message, true) - hooks?.onPostToolUseFailure?.({ - callId: tc.id, - name: tc.name, - params: tc.arguments, - error: guardedResult.message - }) - state.dirty = true - executed += 1 - flushBlocksToRenderer(io, state.blocks) - io.messageStore.updateAssistantContent(io.messageId, state.blocks) - return { - executed, - pendingInteractions, - terminalError: guardedResult.message - } - } - - const isToolError = guardedResult.kind === 'tool_error' || toolRawData.isError === true - const toolMessageContent = - guardedResult.kind === 'tool_error' ? guardedResult.message : guardedResult.content - conversation.push({ - role: 'tool', - tool_call_id: tc.id, - content: toolMessageContent - }) - updateToolCallBlock(state.blocks, tc.id, toolMessageContent, isToolError, { + stagedResults.push({ + toolCallId: tc.id, + toolName: tc.name, + toolArgs: tc.arguments, + responseText: stagedResponseText, + isError: stagedIsError, + offloadPath: preparedResult.kind === 'ok' ? preparedResult.offloadPath : undefined, + searchPayload, rtkApplied: toolRawData.rtkApplied, rtkMode: toolRawData.rtkMode, - rtkFallbackReason: toolRawData.rtkFallbackReason + rtkFallbackReason: toolRawData.rtkFallbackReason, + postHookKind: stagedIsError ? 'failure' : 'success' }) - if (isToolError) { - hooks?.onPostToolUseFailure?.({ - callId: tc.id, - name: tc.name, - params: tc.arguments, - error: toolMessageContent - }) - } else { - hooks?.onPostToolUse?.({ - callId: tc.id, - name: tc.name, - params: tc.arguments, - response: toolMessageContent - }) - } + executed += 1 } catch (err) { const errorText = err instanceof Error ? err.message : String(err) - conversation.push({ - role: 'tool', - tool_call_id: tc.id, - content: `Error: ${errorText}` - }) - updateToolCallBlock(state.blocks, tc.id, `Error: ${errorText}`, true) - hooks?.onPostToolUseFailure?.({ - callId: tc.id, - name: tc.name, - params: tc.arguments, - error: `Error: ${errorText}` + stagedResults.push({ + toolCallId: tc.id, + toolName: tc.name, + toolArgs: tc.arguments, + responseText: `Error: ${errorText}`, + isError: true, + searchPayload: null, + postHookKind: 'failure' }) + executed += 1 } + } + + if (stagedResults.length > 0) { + const fittedResults = await toolOutputGuard.fitToolBatchOutputs({ + conversationMessages: conversation, + results: stagedResults.map((result) => ({ + toolCallId: result.toolCallId, + toolName: result.toolName, + responseText: result.responseText, + isError: result.isError, + offloadPath: result.offloadPath + })), + toolDefinitions: tools, + contextLength, + maxTokens + }) - state.dirty = true - executed += 1 - flushBlocksToRenderer(io, state.blocks) - io.messageStore.updateAssistantContent(io.messageId, state.blocks) + applyFinalizedToolResults({ + stagedResults, + fittedResults: fittedResults.results, + conversation, + state, + io, + hooks, + appendToConversation: fittedResults.kind === 'ok' + }) + persistToolExecutionState(io, state) + + if (fittedResults.kind === 'terminal_error') { + return { + executed, + pendingInteractions, + terminalError: fittedResults.message + } + } } + persistToolExecutionState(io, state) return { executed, pendingInteractions } } diff --git a/src/main/presenter/deepchatAgentPresenter/toolOutputGuard.ts b/src/main/presenter/deepchatAgentPresenter/toolOutputGuard.ts index a9e44b255..f97efe1a0 100644 --- a/src/main/presenter/deepchatAgentPresenter/toolOutputGuard.ts +++ b/src/main/presenter/deepchatAgentPresenter/toolOutputGuard.ts @@ -12,6 +12,19 @@ const TOOLS_REQUIRING_OFFLOAD = new Set(['exec', 'ls', 'find', 'grep', 'cdp_send type ToolMessageUpdateMode = 'append' | 'replace' +export interface ToolBatchOutputCandidate { + toolCallId: string + toolName: string + responseText: string + isError: boolean + offloadPath?: string +} + +export interface ToolBatchOutputFitItem extends ToolBatchOutputCandidate { + contextResponseText: string + downgraded: boolean +} + export type PreparedToolOutputResult = | { kind: 'ok' @@ -40,6 +53,17 @@ export type ToolOutputGuardResult = message: string } +export type ToolBatchOutputFitResult = + | { + kind: 'ok' + results: ToolBatchOutputFitItem[] + } + | { + kind: 'terminal_error' + message: string + results: ToolBatchOutputFitItem[] + } + interface PrepareToolOutputParams { sessionId: string toolCallId: string @@ -68,6 +92,10 @@ interface FitToolErrorParams extends ContextBudgetParams { mode?: ToolMessageUpdateMode } +interface FitToolBatchOutputsParams extends ContextBudgetParams { + results: ToolBatchOutputCandidate[] +} + export class ToolOutputGuard { async prepareToolOutput(params: PrepareToolOutputParams): Promise { const { sessionId, toolCallId, toolName, rawContent } = params @@ -142,6 +170,101 @@ export class ToolOutputGuard { return overflowResult } + async fitToolBatchOutputs(params: FitToolBatchOutputsParams): Promise { + if (params.results.length === 0) { + return { + kind: 'ok', + results: [] + } + } + + const fittedResults: ToolBatchOutputFitItem[] = params.results.map((result) => ({ + ...result, + contextResponseText: result.responseText, + downgraded: false + })) + + if ( + this.hasContextBudget({ + conversationMessages: this.withToolBatchMessages( + params.conversationMessages, + fittedResults + ), + toolDefinitions: params.toolDefinitions, + contextLength: params.contextLength, + maxTokens: params.maxTokens + }) + ) { + return { + kind: 'ok', + results: fittedResults + } + } + + for (let index = fittedResults.length - 1; index >= 0; index -= 1) { + const current = fittedResults[index] + const displayResponseText = this.buildTerminalErrorMessage( + current.toolCallId, + current.toolName + ) + const downgradedBase: ToolBatchOutputFitItem = { + ...current, + responseText: displayResponseText, + contextResponseText: '', + isError: true, + downgraded: true + } + + const contextResponseCandidates = this.buildBatchFailureContextCandidates( + current.toolCallId, + current.toolName + ) + + for (const contextResponseText of contextResponseCandidates) { + fittedResults[index] = { + ...downgradedBase, + contextResponseText + } + + if ( + this.hasContextBudget({ + conversationMessages: this.withToolBatchMessages( + params.conversationMessages, + fittedResults + ), + toolDefinitions: params.toolDefinitions, + contextLength: params.contextLength, + maxTokens: params.maxTokens + }) + ) { + await this.cleanupOffloadedResults(fittedResults.filter((result) => result.downgraded)) + return { + kind: 'ok', + results: fittedResults.map((result) => + result.downgraded ? { ...result, offloadPath: undefined } : result + ) + } + } + } + + fittedResults[index] = downgradedBase + } + + await this.cleanupOffloadedResults(fittedResults) + + return { + kind: 'terminal_error', + message: this.buildTerminalErrorMessage( + fittedResults[0].toolCallId, + fittedResults[0].toolName + ), + results: fittedResults.map((result) => ({ + ...result, + offloadPath: undefined + })) + } + } + hasContextBudget(params: ContextBudgetParams): boolean { const { conversationMessages, toolDefinitions, contextLength, maxTokens } = params const toolDefinitionTokens = toolDefinitions.reduce( @@ -252,6 +375,28 @@ export class ToolOutputGuard { ] } + private withToolBatchMessages( + conversationMessages: ChatMessage[], + results: ToolBatchOutputFitItem[] + ): ChatMessage[] { + if (results.length === 0) { + return conversationMessages + } + + return [ + ...conversationMessages, + ...results.map((result) => ({ + role: 'tool' as const, + tool_call_id: result.toolCallId, + content: result.contextResponseText + })) + ] + } + + private async cleanupOffloadedResults(results: ToolBatchOutputCandidate[]): Promise { + await Promise.all(results.map((result) => this.cleanupOffloadedOutput(result.offloadPath))) + } + private buildOffloadStub(rawContent: string, filePath: string): string { const preview = rawContent.slice(0, TOOL_OUTPUT_PREVIEW_LENGTH) return [ @@ -270,4 +415,15 @@ export class ToolOutputGuard { private buildTerminalErrorMessage(toolCallId: string, toolName: string): string { return `The tool call with ID ${toolCallId} and name ${toolName} failed because the remaining context window is too small to continue this turn.` } + + private buildBatchFailureContextCandidates(toolCallId: string, toolName: string): string[] { + return Array.from( + new Set([ + this.buildTerminalErrorMessage(toolCallId, toolName), + 'Error: context window too small.', + 'Error', + '' + ]) + ) + } } diff --git a/src/main/presenter/lifecyclePresenter/SplashWindowManager.ts b/src/main/presenter/lifecyclePresenter/SplashWindowManager.ts index a165b9ba0..1befe54ef 100644 --- a/src/main/presenter/lifecyclePresenter/SplashWindowManager.ts +++ b/src/main/presenter/lifecyclePresenter/SplashWindowManager.ts @@ -5,7 +5,7 @@ import path from 'path' import { BrowserWindow, nativeImage } from 'electron' import { eventBus } from '../../eventbus' -import { LIFECYCLE_EVENTS } from '@/events' +import { LIFECYCLE_EVENTS, WINDOW_EVENTS } from '@/events' import { ISplashWindowManager } from '@shared/presenter' import { is } from '@electron-toolkit/utils' import icon from '../../../../resources/icon.png?asset' // 应用图标 (macOS/Linux) @@ -31,11 +31,24 @@ interface SplashUpdatePayload { activities: Array> } +type WindowCreatedPayload = + | number + | { + windowId?: number + isMainWindow?: boolean + windowType?: string + } + const MAX_SPLASH_ACTIVITIES = 3 +const SPLASH_SHOW_DELAY_MS = 200 export class SplashWindowManager implements ISplashWindowManager { private splashWindow: BrowserWindow | null = null private activities = new Map() + private splashReadyToShow = false + private splashShowDelayElapsed = false + private suppressSplashShow = false + private splashShowDelayTimer: ReturnType | null = null private readonly onHookExecuted = (data: HookExecutedEventData) => { if (!this.isStartupPhase(data.phase)) { return @@ -71,6 +84,16 @@ export class SplashWindowManager implements ISplashWindowManager { this.pruneActivities() this.emitState() } + private readonly onMainWindowCreated = (payload?: WindowCreatedPayload) => { + if (!this.shouldSuppressForWindowCreated(payload) || this.isVisible()) { + return + } + + this.suppressSplashShow = true + this.clearSplashShowDelayTimer() + eventBus.off(WINDOW_EVENTS.WINDOW_CREATED, this.onMainWindowCreated) + this.closeHiddenSplashWindow() + } constructor() { this.setupLifecycleListeners() @@ -84,6 +107,17 @@ export class SplashWindowManager implements ISplashWindowManager { return } + this.splashReadyToShow = false + this.splashShowDelayElapsed = false + this.suppressSplashShow = false + this.clearSplashShowDelayTimer() + eventBus.on(WINDOW_EVENTS.WINDOW_CREATED, this.onMainWindowCreated) + + this.splashShowDelayTimer = setTimeout(() => { + this.splashShowDelayElapsed = true + this.maybeShowSplash() + }, SPLASH_SHOW_DELAY_MS) + const iconFile = nativeImage.createFromPath(process.platform === 'win32' ? iconWin : icon) try { @@ -107,9 +141,9 @@ export class SplashWindowManager implements ISplashWindowManager { } }) - // Show the window this.splashWindow.on('ready-to-show', () => { - this.splashWindow?.show() + this.splashReadyToShow = true + this.maybeShowSplash() }) this.splashWindow.webContents.on('did-finish-load', () => { @@ -125,9 +159,16 @@ export class SplashWindowManager implements ISplashWindowManager { // Handle window closed event6 this.splashWindow.on('closed', () => { + this.clearSplashShowDelayTimer() this.splashWindow = null }) + + if (this.suppressSplashShow) { + this.closeHiddenSplashWindow() + } } catch (error) { + eventBus.off(WINDOW_EVENTS.WINDOW_CREATED, this.onMainWindowCreated) + this.clearSplashShowDelayTimer() console.error('Failed to create splash window:', error) throw error } @@ -167,17 +208,21 @@ export class SplashWindowManager implements ISplashWindowManager { eventBus.off(LIFECYCLE_EVENTS.HOOK_COMPLETED, this.onHookCompleted) eventBus.off(LIFECYCLE_EVENTS.HOOK_FAILED, this.onHookFailed) eventBus.off(LIFECYCLE_EVENTS.ERROR_OCCURRED, this.onErrorOccurred) + eventBus.off(WINDOW_EVENTS.WINDOW_CREATED, this.onMainWindowCreated) this.activities.clear() this.emitState() + this.clearSplashShowDelayTimer() if (!this.splashWindow || this.splashWindow.isDestroyed()) { return } try { - // Add a small delay for smooth transition - await new Promise((resolve) => setTimeout(resolve, 500)) + if (this.splashWindow.isVisible()) { + // Add a small delay for smooth transition when the splash is actually visible. + await new Promise((resolve) => setTimeout(resolve, 500)) + } this.splashWindow.close() this.splashWindow = null @@ -251,4 +296,45 @@ export class SplashWindowManager implements ISplashWindowManager { this.splashWindow.webContents.send('splash-update', payload) } + + private maybeShowSplash(): void { + if ( + !this.splashWindow || + this.splashWindow.isDestroyed() || + this.suppressSplashShow || + !this.splashReadyToShow || + !this.splashShowDelayElapsed + ) { + return + } + + this.splashWindow.show() + } + + private clearSplashShowDelayTimer(): void { + if (this.splashShowDelayTimer) { + clearTimeout(this.splashShowDelayTimer) + this.splashShowDelayTimer = null + } + } + + private shouldSuppressForWindowCreated(payload?: WindowCreatedPayload): boolean { + if (!payload || typeof payload === 'number') { + return false + } + + return payload.isMainWindow === true || payload.windowType === 'main' + } + + private closeHiddenSplashWindow(): void { + if (!this.splashWindow || this.splashWindow.isDestroyed() || this.splashWindow.isVisible()) { + return + } + + try { + this.splashWindow.close() + } catch (error) { + console.error('Failed to close hidden splash window:', error) + } + } } diff --git a/src/main/presenter/windowPresenter/index.ts b/src/main/presenter/windowPresenter/index.ts index 09860a09e..6b1ff811b 100644 --- a/src/main/presenter/windowPresenter/index.ts +++ b/src/main/presenter/windowPresenter/index.ts @@ -643,7 +643,10 @@ export class WindowPresenter implements IWindowPresenter { if (!appWindow.isDestroyed()) { appWindow.show() appWindow.focus() - eventBus.sendToMain(WINDOW_EVENTS.WINDOW_CREATED, windowId) + eventBus.sendToMain(WINDOW_EVENTS.WINDOW_CREATED, { + windowId, + isMainWindow: windowId === this.mainWindowId + }) } else { console.warn(`Window ${windowId} was destroyed before ready-to-show.`) } diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 6e9743c64..b41254416 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -264,6 +264,11 @@ "provider": "Tjenesteudbyder", "providerSetting": "Udbyderindstillinger", "selectModel": "Vælg en model", + "form": { + "id": { + "label": "Identifikator" + } + }, "modelConfig": { "cancel": "Annuller", "contextLength": { diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index 10bc982bc..1ca5ba1aa 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -318,6 +318,11 @@ "provider": "Service provider", "providerSetting": "Service provider settings", "selectModel": "Select a model", + "form": { + "id": { + "label": "Identifier" + } + }, "modelConfig": { "cancel": "Cancel", "contextLength": { diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index c4d8968eb..50724723a 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -318,6 +318,11 @@ "provider": "فراهم‌کننده خدمات", "providerSetting": "تنظیمات فراهم‌کننده خدمات", "selectModel": "انتخاب مدل", + "form": { + "id": { + "label": "شناسه" + } + }, "modelConfig": { "cancel": "لغو کردن", "contextLength": { diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index a9cb8001a..96adab2bd 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -318,6 +318,11 @@ "provider": "Fournisseur de service", "providerSetting": "Paramètres du fournisseur", "selectModel": "Sélectionner un modèle", + "form": { + "id": { + "label": "Identifiant" + } + }, "modelConfig": { "cancel": "Annuler", "contextLength": { diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 0b2a8571e..5a558087e 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -318,6 +318,11 @@ "provider": "ספק שירות", "providerSetting": "הגדרות ספק שירות", "selectModel": "בחר מודל", + "form": { + "id": { + "label": "מזהה" + } + }, "modelConfig": { "cancel": "ביטול", "contextLength": { diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index 5a2f426a2..be8127aed 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -318,6 +318,11 @@ "provider": "サービスプロバイダー", "providerSetting": "サービスプロバイダーの設定", "selectModel": "モデルを選択します", + "form": { + "id": { + "label": "識別子" + } + }, "modelConfig": { "cancel": "キャンセル", "contextLength": { diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index 6f6f10806..01d2bcc21 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -318,6 +318,11 @@ "provider": "서비스 제공 업체", "providerSetting": "서비스 제공 업체 설정", "selectModel": "모델 선택", + "form": { + "id": { + "label": "식별자" + } + }, "modelConfig": { "cancel": "취소", "contextLength": { diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index a457b8f83..9063f71b1 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -318,6 +318,11 @@ "provider": "Provedor de serviço", "providerSetting": "Configurações do provedor de serviço", "selectModel": "Selecionar um modelo", + "form": { + "id": { + "label": "Identificador" + } + }, "modelConfig": { "cancel": "Cancelar", "contextLength": { diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index 58b81485b..f5ef3f0b2 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -318,6 +318,11 @@ "provider": "Поставщик услуг", "providerSetting": "Настройки поставщика услуг", "selectModel": "Выберите модель", + "form": { + "id": { + "label": "Идентификатор" + } + }, "modelConfig": { "cancel": "Отмена", "contextLength": { diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index c71b0ab34..efa223409 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -318,6 +318,11 @@ "providerSetting": "服务商设置", "configureModel": "配置模型", "addModel": "添加模型", + "form": { + "id": { + "label": "标识符" + } + }, "modelConfig": { "title": "自定义模型参数", "description": "请注意,此配置仅对当前模型有效,不会影响其他模型,请谨慎修改,错误的参数可能导致模型无法正常工作。", diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index 6b0922eb7..9122edbef 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -318,6 +318,11 @@ "provider": "服務商", "providerSetting": "服務商設置", "selectModel": "選擇模型", + "form": { + "id": { + "label": "識別碼" + } + }, "modelConfig": { "cancel": "取消", "contextLength": { diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index 9fa77e994..c45d8ca78 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -318,6 +318,11 @@ "providerSetting": "服務提供者設定", "configureModel": "設定模型", "addModel": "新增模型", + "form": { + "id": { + "label": "識別碼" + } + }, "modelConfig": { "cancel": "取消", "contextLength": { diff --git a/src/renderer/src/stores/mcp.ts b/src/renderer/src/stores/mcp.ts index 04623b99b..db85f1284 100644 --- a/src/renderer/src/stores/mcp.ts +++ b/src/renderer/src/stores/mcp.ts @@ -401,7 +401,9 @@ export const useMcpStore = defineStore('mcp', () => { return 0 // 保持原有顺序 }) }) - const enabledServers = computed(() => serverList.value.filter((server) => server.enabled)) + const enabledServers = computed(() => + config.value.mcpEnabled ? serverList.value.filter((server) => server.enabled) : [] + ) const enabledServerCount = computed(() => enabledServers.value.length) // 工具数量 diff --git a/test/main/presenter/deepchatAgentPresenter/dispatch.test.ts b/test/main/presenter/deepchatAgentPresenter/dispatch.test.ts index 366c5cafc..6f3e589f9 100644 --- a/test/main/presenter/deepchatAgentPresenter/dispatch.test.ts +++ b/test/main/presenter/deepchatAgentPresenter/dispatch.test.ts @@ -3,12 +3,14 @@ import fs from 'fs/promises' import os from 'os' import path from 'path' import { app } from 'electron' +import { approximateTokenSize } from 'tokenx' import type { InterleavedReasoningConfig, IoParams, StreamState } from '@/presenter/deepchatAgentPresenter/types' import { createState } from '@/presenter/deepchatAgentPresenter/types' +import { estimateMessagesTokens } from '@/presenter/deepchatAgentPresenter/contextBuilder' import type { MCPToolDefinition } from '@shared/presenter' import type { IToolPresenter } from '@shared/types/presenters/tool.presenter' import { ToolOutputGuard } from '@/presenter/deepchatAgentPresenter/toolOutputGuard' @@ -53,6 +55,7 @@ function createIo(overrides?: Partial): IoParams { sessionId: 's1', messageId: 'm1', messageStore: { + addSearchResult: vi.fn(), updateAssistantContent: vi.fn(), finalizeAssistantMessage: vi.fn(), setMessageError: vi.fn() @@ -757,7 +760,308 @@ describe('dispatch', () => { expect(state.blocks[0].status).toBe('error') }) - it('marks the tool as error when offload succeeds but context budget cannot fit the stub', async () => { + it('keeps the largest prefix of tool results and downgrades the overflow tail', async () => { + const tools = [makeTool('read')] + const toolPresenter = createMockToolPresenter() + const hooks = { + onPreToolUse: vi.fn(), + onPermissionRequest: vi.fn(), + onPostToolUse: vi.fn(), + onPostToolUseFailure: vi.fn() + } + const conversation: any[] = [] + + ;(toolPresenter.callTool as ReturnType) + .mockResolvedValueOnce({ + content: 'a'.repeat(60), + rawData: { toolCallId: 'tc1', content: 'a'.repeat(60), isError: false } + }) + .mockResolvedValueOnce({ + content: 'b'.repeat(4000), + rawData: { toolCallId: 'tc2', content: 'b'.repeat(4000), isError: false } + }) + + state.blocks.push({ + type: 'tool_call', + content: '', + status: 'pending', + timestamp: Date.now(), + tool_call: { id: 'tc1', name: 'read', params: '{"path":"a.txt"}', response: '' } + }) + state.blocks.push({ + type: 'tool_call', + content: '', + status: 'pending', + timestamp: Date.now(), + tool_call: { id: 'tc2', name: 'read', params: '{"path":"b.txt"}', response: '' } + }) + state.completedToolCalls = [ + { id: 'tc1', name: 'read', arguments: '{"path":"a.txt"}' }, + { id: 'tc2', name: 'read', arguments: '{"path":"b.txt"}' } + ] + + const executed = await executeTools( + state, + conversation, + 0, + tools, + toolPresenter, + 'gpt-4', + io, + 'full_access', + new ToolOutputGuard(), + 260, + 32, + hooks + ) + + const toolMessages = conversation.filter((message: any) => message.role === 'tool') + expect(executed.terminalError).toBeUndefined() + expect(toolMessages).toHaveLength(2) + expect(toolMessages[0].content).toBe('a'.repeat(60)) + expect(toolMessages[1].content).toContain('remaining context window is too small') + expect(state.blocks[0].status).toBe('success') + expect(state.blocks[0].tool_call?.response).toBe('a'.repeat(60)) + expect(state.blocks[1].status).toBe('error') + expect(state.blocks[1].tool_call?.response).toContain('remaining context window is too small') + expect(hooks.onPostToolUse).toHaveBeenCalledTimes(1) + expect(hooks.onPostToolUseFailure).toHaveBeenCalledTimes(1) + }) + + it('keeps the fitting prefix when a short overflow tail is downgraded', async () => { + const tools = [makeTool('read')] + const toolPresenter = createMockToolPresenter() + const conversation: any[] = [] + + ;(toolPresenter.callTool as ReturnType) + .mockResolvedValueOnce({ + content: 'a'.repeat(40), + rawData: { toolCallId: 'tc1', content: 'a'.repeat(40), isError: false } + }) + .mockResolvedValueOnce({ + content: 'b'.repeat(40), + rawData: { toolCallId: 'tc2', content: 'b'.repeat(40), isError: false } + }) + .mockResolvedValueOnce({ + content: 'OK', + rawData: { toolCallId: 'tc3', content: 'OK', isError: false } + }) + + state.blocks.push({ + type: 'tool_call', + content: '', + status: 'pending', + timestamp: Date.now(), + tool_call: { id: 'tc1', name: 'read', params: '{"path":"a.txt"}', response: '' } + }) + state.blocks.push({ + type: 'tool_call', + content: '', + status: 'pending', + timestamp: Date.now(), + tool_call: { id: 'tc2', name: 'read', params: '{"path":"b.txt"}', response: '' } + }) + state.blocks.push({ + type: 'tool_call', + content: '', + status: 'pending', + timestamp: Date.now(), + tool_call: { id: 'tc3', name: 'read', params: '{"path":"c.txt"}', response: '' } + }) + state.completedToolCalls = [ + { id: 'tc1', name: 'read', arguments: '{"path":"a.txt"}' }, + { id: 'tc2', name: 'read', arguments: '{"path":"b.txt"}' }, + { id: 'tc3', name: 'read', arguments: '{"path":"c.txt"}' } + ] + + const assistantMessage = { + role: 'assistant' as const, + content: '', + tool_calls: state.completedToolCalls.map((tc) => ({ + id: tc.id, + type: 'function' as const, + function: { name: tc.name, arguments: tc.arguments } + })) + } + const fittingPrefixMessages = [ + assistantMessage, + { role: 'tool' as const, tool_call_id: 'tc1', content: 'a'.repeat(40) }, + { role: 'tool' as const, tool_call_id: 'tc2', content: 'b'.repeat(40) } + ] + const toolDefinitionTokens = tools.reduce( + (total, tool) => total + approximateTokenSize(JSON.stringify(tool)), + 0 + ) + const contextLength = estimateMessagesTokens(fittingPrefixMessages) + toolDefinitionTokens + + const executed = await executeTools( + state, + conversation, + 0, + tools, + toolPresenter, + 'gpt-4', + io, + 'full_access', + new ToolOutputGuard(), + contextLength, + 0 + ) + + const toolMessages = conversation.filter((message: any) => message.role === 'tool') + expect(executed.terminalError).toBeUndefined() + expect(toolMessages).toHaveLength(3) + expect(toolMessages[0].content).toBe('a'.repeat(40)) + expect(toolMessages[1].content).toBe('b'.repeat(40)) + expect(toolMessages[2].content).toBe('') + expect(state.blocks[0].status).toBe('success') + expect(state.blocks[1].status).toBe('success') + expect(state.blocks[2].status).toBe('error') + expect(state.blocks[2].tool_call?.response).toContain('remaining context window is too small') + }) + + it('cleans offload files when a tail tool is downgraded during batch fitting', async () => { + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'deepchat-dispatch-tail-offload-')) + getPathSpy = vi.spyOn(app, 'getPath').mockReturnValue(tempHome) + + const tools = [makeTool('read'), makeTool('exec')] + const toolPresenter = createMockToolPresenter() + const conversation: any[] = [] + + ;(toolPresenter.callTool as ReturnType) + .mockResolvedValueOnce({ + content: 'a'.repeat(60), + rawData: { toolCallId: 'tc1', content: 'a'.repeat(60), isError: false } + }) + .mockResolvedValueOnce({ + content: 'x'.repeat(7000), + rawData: { toolCallId: 'tc2', content: 'x'.repeat(7000), isError: false } + }) + + state.blocks.push({ + type: 'tool_call', + content: '', + status: 'pending', + timestamp: Date.now(), + tool_call: { id: 'tc1', name: 'read', params: '{"path":"a.txt"}', response: '' } + }) + state.blocks.push({ + type: 'tool_call', + content: '', + status: 'pending', + timestamp: Date.now(), + tool_call: { id: 'tc2', name: 'exec', params: '{"command":"ls"}', response: '' } + }) + state.completedToolCalls = [ + { id: 'tc1', name: 'read', arguments: '{"path":"a.txt"}' }, + { id: 'tc2', name: 'exec', arguments: '{"command":"ls"}' } + ] + + const executed = await executeTools( + state, + conversation, + 0, + tools, + toolPresenter, + 'gpt-4', + io, + 'full_access', + new ToolOutputGuard(), + 260, + 32 + ) + + expect(executed.terminalError).toBeUndefined() + expect(state.blocks[1].tool_call?.response).toContain('remaining context window is too small') + expect(state.blocks[1].tool_call?.response).not.toContain('[Tool output offloaded]') + await expect( + fs.access(path.join(tempHome, '.deepchat', 'sessions', 's1', 'tool_tc2.offload')) + ).rejects.toThrow() + }) + + it('drops search side effects for downgraded tail tool results', async () => { + const tools = [makeTool('read'), makeTool('search_docs')] + const toolPresenter = createMockToolPresenter() + const conversation: any[] = [] + const searchResource = JSON.stringify({ + title: 'Example', + url: 'https://example.com', + content: 'x'.repeat(4000), + description: 'x'.repeat(4000) + }) + + ;(toolPresenter.callTool as ReturnType) + .mockResolvedValueOnce({ + content: 'a'.repeat(60), + rawData: { toolCallId: 'tc1', content: 'a'.repeat(60), isError: false } + }) + .mockResolvedValueOnce({ + content: [ + { + type: 'resource', + resource: { + uri: 'https://example.com', + mimeType: 'application/deepchat-webpage', + text: searchResource + } + } + ], + rawData: { + toolCallId: 'tc2', + content: [ + { + type: 'resource', + resource: { + uri: 'https://example.com', + mimeType: 'application/deepchat-webpage', + text: searchResource + } + } + ], + isError: false + } + }) + + state.blocks.push({ + type: 'tool_call', + content: '', + status: 'pending', + timestamp: Date.now(), + tool_call: { id: 'tc1', name: 'read', params: '{"path":"a.txt"}', response: '' } + }) + state.blocks.push({ + type: 'tool_call', + content: '', + status: 'pending', + timestamp: Date.now(), + tool_call: { id: 'tc2', name: 'search_docs', params: '{"q":"x"}', response: '' } + }) + state.completedToolCalls = [ + { id: 'tc1', name: 'read', arguments: '{"path":"a.txt"}' }, + { id: 'tc2', name: 'search_docs', arguments: '{"q":"x"}' } + ] + + const executed = await executeTools( + state, + conversation, + 0, + tools, + toolPresenter, + 'gpt-4', + io, + 'full_access', + new ToolOutputGuard(), + 260, + 32 + ) + + expect(executed.terminalError).toBeUndefined() + expect(state.blocks.find((block) => block.type === 'search')).toBeUndefined() + expect(state.blocks[1].tool_call?.response).toContain('remaining context window is too small') + expect((io.messageStore as any).addSearchResult).not.toHaveBeenCalled() + }) + + it('marks the tool as error when offload succeeds but context budget cannot fit the result', async () => { tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'deepchat-dispatch-offload-clean-')) getPathSpy = vi.spyOn(app, 'getPath').mockReturnValue(tempHome) @@ -801,7 +1105,7 @@ describe('dispatch', () => { ) const toolMessage = conversation.find((message: any) => message.role === 'tool') - expect(toolMessage.content).toContain('remaining context window is insufficient') + expect(toolMessage.content).toContain('remaining context window is too small') expect(state.blocks[0].status).toBe('error') await expect( fs.access(path.join(tempHome, '.deepchat', 'sessions', 's1', 'tool_tc1.offload')) diff --git a/test/main/presenter/deepchatAgentPresenter/process.test.ts b/test/main/presenter/deepchatAgentPresenter/process.test.ts index acb0c537f..971b79e2c 100644 --- a/test/main/presenter/deepchatAgentPresenter/process.test.ts +++ b/test/main/presenter/deepchatAgentPresenter/process.test.ts @@ -39,8 +39,17 @@ vi.mock('@/presenter', () => ({ import { processStream } from '@/presenter/deepchatAgentPresenter/process' import { eventBus } from '@/eventbus' +const DEFAULT_INTERLEAVED_REASONING = { + preserveReasoningContent: false, + forcedBySessionSetting: false, + portraitInterleaved: false, + reasoningSupported: false, + providerDbSourceUrl: 'https://example.com/provider-db.json' +} as const + function createMockMessageStore() { return { + addSearchResult: vi.fn(), getMessage: vi.fn().mockReturnValue(null), updateAssistantContent: vi.fn(), finalizeAssistantMessage: vi.fn(), @@ -122,6 +131,7 @@ describe('processStream', () => { modelConfig: {} as any, temperature: 0.7, maxTokens: 4096, + interleavedReasoning: DEFAULT_INTERLEAVED_REASONING, permissionMode: 'full_access', toolOutputGuard: new ToolOutputGuard(), io: { @@ -309,6 +319,75 @@ describe('processStream', () => { expect(coreStream).toHaveBeenCalledTimes(2) }) + it('continues the next provider turn after downgrading an overflow tail tool result', async () => { + let callCount = 0 + const toolPresenter = createMockToolPresenter() + + ;(toolPresenter.callTool as ReturnType) + .mockResolvedValueOnce({ + content: 'a'.repeat(60), + rawData: { toolCallId: 'tc1', content: 'a'.repeat(60), isError: false } + }) + .mockResolvedValueOnce({ + content: 'b'.repeat(4000), + rawData: { toolCallId: 'tc2', content: 'b'.repeat(4000), isError: false } + }) + + const coreStream = vi.fn(function () { + callCount++ + if (callCount === 1) { + return (async function* () { + yield { + type: 'tool_call_start', + tool_call_id: 'tc1', + tool_call_name: 'read' + } as LLMCoreStreamEvent + yield { + type: 'tool_call_end', + tool_call_id: 'tc1', + tool_call_arguments_complete: '{"path":"a.txt"}' + } as LLMCoreStreamEvent + yield { + type: 'tool_call_start', + tool_call_id: 'tc2', + tool_call_name: 'read' + } as LLMCoreStreamEvent + yield { + type: 'tool_call_end', + tool_call_id: 'tc2', + tool_call_arguments_complete: '{"path":"b.txt"}' + } as LLMCoreStreamEvent + yield { type: 'stop', stop_reason: 'tool_use' } as LLMCoreStreamEvent + })() + } + + return (async function* () { + yield { type: 'text', content: 'Continued answer' } as LLMCoreStreamEvent + yield { type: 'stop', stop_reason: 'complete' } as LLMCoreStreamEvent + })() + }) as unknown as ProcessParams['coreStream'] + + const params = createParams({ + coreStream, + toolPresenter, + tools: [makeTool('read')], + modelConfig: { contextLength: 260 } as any, + maxTokens: 32 + }) + + const promise = processStream(params) + await vi.runAllTimersAsync() + await promise + + expect(coreStream).toHaveBeenCalledTimes(2) + const secondCallMessages = (coreStream as ReturnType).mock.calls[1][0] + const toolMessages = secondCallMessages.filter((message: any) => message.role === 'tool') + expect(toolMessages).toHaveLength(2) + expect(toolMessages[0].content).toBe('a'.repeat(60)) + expect(toolMessages[1].content).toContain('remaining context window is too small') + expect(messageStore.finalizeAssistantMessage).toHaveBeenCalled() + }) + it('multi-turn tool loop', async () => { let callCount = 0 const toolPresenter = createMockToolPresenter({ get_weather: 'Sunny' }) @@ -430,7 +509,7 @@ describe('processStream', () => { conversationId: 's1', messageId: 'm1', eventId: 'm1', - error: 'Generation cancelled' + error: 'common.error.userCanceledGeneration' }) ) }) @@ -543,8 +622,8 @@ describe('processStream', () => { await promise expect(toolPresenter.callTool).toHaveBeenCalledTimes(1) - // Should still finalize (abort detected after executeTools, before next loop) - expect(messageStore.finalizeAssistantMessage).toHaveBeenCalled() + expect(messageStore.setMessageError).toHaveBeenCalled() + expect(messageStore.finalizeAssistantMessage).not.toHaveBeenCalled() }) it('stream error event → finalizeError', async () => { diff --git a/test/main/presenter/lifecyclePresenter/SplashWindowManager.display.test.ts b/test/main/presenter/lifecyclePresenter/SplashWindowManager.display.test.ts new file mode 100644 index 000000000..5ff91742a --- /dev/null +++ b/test/main/presenter/lifecyclePresenter/SplashWindowManager.display.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { eventBus } from '../../../../src/main/eventbus' +import { WINDOW_EVENTS } from '../../../../src/main/events' + +const createdWindows = vi.hoisted(() => [] as MockBrowserWindow[]) + +class MockBrowserWindow { + public visible = false + public destroyed = false + public readonly show = vi.fn(() => { + this.visible = true + }) + public readonly close = vi.fn(() => { + this.destroyed = true + this.emit('closed') + }) + public readonly loadURL = vi.fn().mockResolvedValue(undefined) + public readonly loadFile = vi.fn().mockResolvedValue(undefined) + public readonly webContents = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + const handlers = this.webContentsHandlers.get(event) ?? [] + handlers.push(handler) + this.webContentsHandlers.set(event, handlers) + }), + send: vi.fn() + } + + private readonly handlers = new Map void>>() + private readonly webContentsHandlers = new Map void>>() + + constructor() { + createdWindows.push(this) + } + + on(event: string, handler: (...args: unknown[]) => void) { + const handlers = this.handlers.get(event) ?? [] + handlers.push(handler) + this.handlers.set(event, handlers) + } + + emit(event: string, ...args: unknown[]) { + for (const handler of this.handlers.get(event) ?? []) { + handler(...args) + } + } + + isDestroyed() { + return this.destroyed + } + + isVisible() { + return this.visible + } +} + +vi.mock('electron', () => ({ + BrowserWindow: MockBrowserWindow, + nativeImage: { + createFromPath: vi.fn(() => ({})) + } +})) + +describe('SplashWindowManager display gating', () => { + let manager: InstanceType< + typeof import('../../../../src/main/presenter/lifecyclePresenter/SplashWindowManager').SplashWindowManager + > | null = null + + beforeEach(() => { + vi.useFakeTimers() + createdWindows.length = 0 + }) + + afterEach(async () => { + if (manager) { + const closePromise = manager.close() + await vi.runAllTimersAsync() + await closePromise + manager = null + } + vi.useRealTimers() + createdWindows.length = 0 + }) + + it('waits 200ms before showing the splash window', async () => { + const { SplashWindowManager } = + await import('../../../../src/main/presenter/lifecyclePresenter/SplashWindowManager') + + manager = new SplashWindowManager() + await manager.create() + + const splashWindow = createdWindows[0] + expect(splashWindow).toBeTruthy() + + splashWindow.emit('ready-to-show') + expect(splashWindow.show).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(199) + expect(splashWindow.show).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(splashWindow.show).toHaveBeenCalledTimes(1) + }) + + it('skips showing the splash window when the main window is created first', async () => { + const { SplashWindowManager } = + await import('../../../../src/main/presenter/lifecyclePresenter/SplashWindowManager') + + manager = new SplashWindowManager() + await manager.create() + + const splashWindow = createdWindows[0] + expect(splashWindow).toBeTruthy() + + splashWindow.emit('ready-to-show') + eventBus.sendToMain(WINDOW_EVENTS.WINDOW_CREATED, { + windowId: 1, + isMainWindow: true + }) + await vi.advanceTimersByTimeAsync(200) + + expect(splashWindow.close).toHaveBeenCalledTimes(1) + expect(splashWindow.show).not.toHaveBeenCalled() + expect(manager.isVisible()).toBe(false) + }) + + it('does not suppress the splash when a non-main window is created first', async () => { + const { SplashWindowManager } = + await import('../../../../src/main/presenter/lifecyclePresenter/SplashWindowManager') + + manager = new SplashWindowManager() + await manager.create() + + const splashWindow = createdWindows[0] + expect(splashWindow).toBeTruthy() + + splashWindow.emit('ready-to-show') + eventBus.sendToMain(WINDOW_EVENTS.WINDOW_CREATED, { + windowId: 2, + isMainWindow: false + }) + await vi.advanceTimersByTimeAsync(200) + + expect(splashWindow.close).not.toHaveBeenCalled() + expect(splashWindow.show).toHaveBeenCalledTimes(1) + expect(manager.isVisible()).toBe(true) + }) + + it('closes a hidden splash immediately without waiting for the 500ms transition delay', async () => { + const { SplashWindowManager } = + await import('../../../../src/main/presenter/lifecyclePresenter/SplashWindowManager') + + manager = new SplashWindowManager() + await manager.create() + + const splashWindow = createdWindows[0] + expect(splashWindow).toBeTruthy() + + const closePromise = manager.close() + await Promise.resolve() + + expect(splashWindow.close).toHaveBeenCalledTimes(1) + await closePromise + }) +}) diff --git a/test/renderer/stores/mcpStore.test.ts b/test/renderer/stores/mcpStore.test.ts index 5739c353a..13c6a0a35 100644 --- a/test/renderer/stores/mcpStore.test.ts +++ b/test/renderer/stores/mcpStore.test.ts @@ -121,4 +121,30 @@ describe('useMcpStore toggleServer rollback', () => { expect(mcpPresenterMock.startServer).not.toHaveBeenCalled() expect(mcpPresenterMock.stopServer).not.toHaveBeenCalled() }) + + it('hides enabled servers when MCP is globally disabled', () => { + const store = useMcpStore() + + store.config = { + mcpServers: { + demo: { + command: 'demo-command', + args: [], + env: {}, + descriptions: 'Demo server', + icons: 'D', + autoApprove: [], + disable: false, + type: 'stdio', + enabled: true + } + }, + mcpEnabled: false, + ready: true + } + + expect(store.serverList).toHaveLength(1) + expect(store.enabledServers).toEqual([]) + expect(store.enabledServerCount).toBe(0) + }) })