diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a7a46ecb7f..c2fdbdba84 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -118,4 +118,4 @@ body: attributes: label: Relevant logs or errors (optional) description: Paste relevant output or errors. Use triple backticks (```) for formatting. - render: shell \ No newline at end of file + render: shell diff --git a/apps/vscode-e2e/src/suite/extension.test.ts b/apps/vscode-e2e/src/suite/extension.test.ts index 1abf412949..d3dc653e34 100644 --- a/apps/vscode-e2e/src/suite/extension.test.ts +++ b/apps/vscode-e2e/src/suite/extension.test.ts @@ -20,10 +20,6 @@ suite("Roo Code Extension", function () { "openInNewTab", "settingsButtonClicked", "historyButtonClicked", - "showHumanRelayDialog", - "registerHumanRelayCallback", - "unregisterHumanRelayCallback", - "handleHumanRelayResponse", "newTask", "setCustomStoragePath", "focusInput", diff --git a/packages/evals/src/cli/runTask.ts b/packages/evals/src/cli/runTask.ts index a6ae6c0305..d93aa5bc37 100644 --- a/packages/evals/src/cli/runTask.ts +++ b/packages/evals/src/cli/runTask.ts @@ -301,6 +301,7 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO "diff_error", "condense_context", "condense_context_error", + "api_req_rate_limit_wait", "api_req_retry_delayed", "api_req_retried", ] diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index a774c76da9..6657489bcf 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -110,7 +110,6 @@ export const globalSettingsSchema = z.object({ allowedMaxCost: z.number().nullish(), autoCondenseContext: z.boolean().optional(), autoCondenseContextPercent: z.number().optional(), - filterErrorCorrectionMessages: z.boolean().optional(), maxConcurrentFileReads: z.number().optional(), /** diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 738d0843aa..dd63fa8877 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -131,6 +131,7 @@ export function isNonBlockingAsk(ask: ClineAsk): ask is NonBlockingAsk { * - `api_req_finished`: Indicates an API request has completed successfully * - `api_req_retried`: Indicates an API request is being retried after a failure * - `api_req_retry_delayed`: Indicates an API request retry has been delayed + * - `api_req_rate_limit_wait`: Indicates a configured rate-limit wait (not an error) * - `api_req_deleted`: Indicates an API request has been deleted/cancelled * - `text`: General text message or assistant response * - `reasoning`: Assistant's reasoning or thought process (often hidden from user) @@ -157,6 +158,7 @@ export const clineSays = [ "api_req_finished", "api_req_retried", "api_req_retry_delayed", + "api_req_rate_limit_wait", "api_req_deleted", "text", "image", @@ -174,6 +176,7 @@ export const clineSays = [ "subtask_result", "checkpoint_saved", "rooignore_error", + "rollback_xml_tool", "diff_error", "condense_context", "condense_context_error", diff --git a/src/api/index.ts b/src/api/index.ts index e66f7832c0..3f3830efc1 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -216,7 +216,6 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { case "baseten": return new BasetenHandler(options) default: - // apiProvider satisfies "gemini-cli" | undefined return new AnthropicHandler(options) } } diff --git a/src/api/providers/claude-code.ts b/src/api/providers/claude-code.ts index 8fd64fb161..26d34da996 100644 --- a/src/api/providers/claude-code.ts +++ b/src/api/providers/claude-code.ts @@ -3,6 +3,7 @@ import OpenAI from "openai" import { claudeCodeDefaultModelId, type ClaudeCodeModelId, + claudeCodeModels, claudeCodeReasoningConfig, type ClaudeCodeReasoningLevel, getClaudeCodeModels, @@ -123,151 +124,184 @@ export class ClaudeCodeHandler implements ApiHandler, SingleCompletionHandler { // Reset per-request state that we persist into apiConversationHistory this.lastThinkingSignature = undefined - // Get access token from OAuth manager - const accessToken = await claudeCodeOAuthManager.getAccessToken() - - if (!accessToken) { - throw new Error( + const buildNotAuthenticatedError = () => + new Error( t("common:errors.claudeCode.notAuthenticated", { defaultValue: "Not authenticated with Claude Code. Please sign in using the Claude Code OAuth flow.", }), ) - } - - // Get user email for generating user_id metadata - const email = await claudeCodeOAuthManager.getEmail() - - const model = this.getModel() - - // Validate that the model ID is a valid ClaudeCodeModelId - const modelId = model.id ?? claudeCodeDefaultModelId - - // Generate user_id metadata in the format required by Claude Code API - const userId = generateUserId(email || undefined) - - // Convert OpenAI tools to Anthropic format if provided and protocol is native - // Exclude tools when tool_choice is "none" since that means "don't use tools" - const shouldIncludeNativeTools = - metadata?.tools && - metadata.tools.length > 0 && - metadata?.toolProtocol !== "xml" && - metadata?.tool_choice !== "none" - - const anthropicTools = shouldIncludeNativeTools ? convertOpenAIToolsToAnthropic(metadata.tools!) : undefined - - const anthropicToolChoice = shouldIncludeNativeTools - ? convertOpenAIToolChoice(metadata.tool_choice, metadata.parallelToolCalls) - : undefined - // Determine reasoning effort and thinking configuration - const reasoningLevel = this.getReasoningEffort(model.info) - - let thinking: ThinkingConfig - // With interleaved thinking (enabled via beta header), budget_tokens can exceed max_tokens - // as the token limit becomes the entire context window. We use the model's maxTokens. - // See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking - const maxTokens = model.info.maxTokens ?? 16384 - - if (reasoningLevel) { - // Use thinking mode with budget_tokens from config - const config = claudeCodeReasoningConfig[reasoningLevel] - thinking = { - type: "enabled", - budget_tokens: config.budgetTokens, + async function* streamOnce(this: ClaudeCodeHandler, accessToken: string): ApiStream { + // Get user email for generating user_id metadata + const email = await claudeCodeOAuthManager.getEmail() + + const model = this.getModel() + + // Validate that the model ID is a valid ClaudeCodeModelId + const modelId = Object.hasOwn(claudeCodeModels, model.id) + ? (model.id as ClaudeCodeModelId) + : claudeCodeDefaultModelId + + // Generate user_id metadata in the format required by Claude Code API + const userId = generateUserId(email || undefined) + + // Convert OpenAI tools to Anthropic format if provided and protocol is native + // Exclude tools when tool_choice is "none" since that means "don't use tools" + const shouldIncludeNativeTools = + metadata?.tools && + metadata.tools.length > 0 && + metadata?.toolProtocol !== "xml" && + metadata?.tool_choice !== "none" + + const anthropicTools = shouldIncludeNativeTools ? convertOpenAIToolsToAnthropic(metadata.tools!) : undefined + + const anthropicToolChoice = shouldIncludeNativeTools + ? convertOpenAIToolChoice(metadata.tool_choice, metadata.parallelToolCalls) + : undefined + + // Determine reasoning effort and thinking configuration + const reasoningLevel = this.getReasoningEffort(model.info) + + let thinking: ThinkingConfig + // With interleaved thinking (enabled via beta header), budget_tokens can exceed max_tokens + // as the token limit becomes the entire context window. We use the model's maxTokens. + // See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking + const maxTokens = model.info.maxTokens ?? 16384 + + if (reasoningLevel) { + // Use thinking mode with budget_tokens from config + const config = claudeCodeReasoningConfig[reasoningLevel] + thinking = { + type: "enabled", + budget_tokens: config.budgetTokens, + } + } else { + // Explicitly disable thinking + thinking = { type: "disabled" } } - } else { - // Explicitly disable thinking - thinking = { type: "disabled" } - } - - // Create streaming request using OAuth - const stream = createStreamingMessage({ - accessToken, - model: modelId, - systemPrompt, - messages, - maxTokens, - thinking, - tools: anthropicTools, - toolChoice: anthropicToolChoice, - metadata: { - user_id: userId, - }, - }) - // Track usage for cost calculation - let inputTokens = 0 - let outputTokens = 0 - let cacheReadTokens = 0 - let cacheWriteTokens = 0 - - for await (const chunk of stream) { - switch (chunk.type) { - case "text": - yield { - type: "text", - text: chunk.text, - } - break - - case "reasoning": - yield { - type: "reasoning", - text: chunk.text, - } - break - - case "thinking_complete": - // Capture the signature for persistence in api_conversation_history - // This enables tool use continuations where thinking blocks must be passed back - if (chunk.signature) { - this.lastThinkingSignature = chunk.signature - } - // Emit a complete thinking block with signature - // This is critical for interleaved thinking with tool use - // The signature must be included when passing thinking blocks back to the API - yield { - type: "reasoning", - text: chunk.thinking, - signature: chunk.signature, + // Create streaming request using OAuth + const stream = createStreamingMessage({ + accessToken, + model: modelId, + systemPrompt, + messages, + maxTokens, + thinking, + tools: anthropicTools, + toolChoice: anthropicToolChoice, + metadata: { + user_id: userId, + }, + }) + + // Track usage for cost calculation + let inputTokens = 0 + let outputTokens = 0 + let cacheReadTokens = 0 + let cacheWriteTokens = 0 + + for await (const chunk of stream) { + switch (chunk.type) { + case "text": + yield { + type: "text", + text: chunk.text, + } + break + + case "reasoning": + yield { + type: "reasoning", + text: chunk.text, + } + break + + case "thinking_complete": + // Capture the signature for persistence in api_conversation_history + // This enables tool use continuations where thinking blocks must be passed back + if (chunk.signature) { + this.lastThinkingSignature = chunk.signature + } + // Emit a complete thinking block with signature + // This is critical for interleaved thinking with tool use + // The signature must be included when passing thinking blocks back to the API + yield { + type: "reasoning", + text: chunk.thinking, + signature: chunk.signature, + } + break + + case "tool_call_partial": + yield { + type: "tool_call_partial", + index: chunk.index, + id: chunk.id, + name: chunk.name, + arguments: chunk.arguments, + } + break + + case "usage": { + inputTokens = chunk.inputTokens + outputTokens = chunk.outputTokens + cacheReadTokens = chunk.cacheReadTokens || 0 + cacheWriteTokens = chunk.cacheWriteTokens || 0 + + // Claude Code is subscription-based, no per-token cost + const usageChunk: ApiStreamUsageChunk = { + type: "usage", + inputTokens, + outputTokens, + cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, + cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, + totalCost: 0, + } + + yield usageChunk + break } - break - case "tool_call_partial": - yield { - type: "tool_call_partial", - index: chunk.index, - id: chunk.id, - name: chunk.name, - arguments: chunk.arguments, - } - break + case "error": + throw new Error(chunk.error) + } + } + } - case "usage": { - inputTokens = chunk.inputTokens - outputTokens = chunk.outputTokens - cacheReadTokens = chunk.cacheReadTokens || 0 - cacheWriteTokens = chunk.cacheWriteTokens || 0 - - // Claude Code is subscription-based, no per-token cost - const usageChunk: ApiStreamUsageChunk = { - type: "usage", - inputTokens, - outputTokens, - cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, - cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, - totalCost: 0, - } + // Get access token from OAuth manager + let accessToken = await claudeCodeOAuthManager.getAccessToken() + if (!accessToken) { + throw buildNotAuthenticatedError() + } - yield usageChunk - break + // Try the request with at most one force-refresh retry on auth failure + for (let attempt = 0; attempt < 2; attempt++) { + try { + yield* streamOnce.call(this, accessToken) + return + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const isAuthFailure = /unauthorized|invalid token|not authenticated|authentication/i.test(message) + + // Only retry on auth failure during first attempt + const canRetry = attempt === 0 && isAuthFailure + if (!canRetry) { + throw error } - case "error": - throw new Error(chunk.error) + // Force refresh the token for retry + const refreshed = await claudeCodeOAuthManager.forceRefreshAccessToken() + if (!refreshed) { + throw buildNotAuthenticatedError() + } + accessToken = refreshed } } + + // Unreachable: loop always returns on success or throws on failure + throw buildNotAuthenticatedError() } getModel(): { id: string; info: ModelInfo } { @@ -317,7 +351,9 @@ export class ClaudeCodeHandler implements ApiHandler, SingleCompletionHandler { const model = this.getModel() // Validate that the model ID is a valid ClaudeCodeModelId - const modelId = model.id ?? claudeCodeDefaultModelId + const modelId = Object.hasOwn(claudeCodeModels, model.id) + ? (model.id as ClaudeCodeModelId) + : claudeCodeDefaultModelId // Generate user_id metadata in the format required by Claude Code API const userId = generateUserId(email || undefined) diff --git a/src/api/providers/utils/response-render-config.ts b/src/api/providers/utils/response-render-config.ts index 9fd4cea58b..6f4a3f6165 100644 --- a/src/api/providers/utils/response-render-config.ts +++ b/src/api/providers/utils/response-render-config.ts @@ -3,16 +3,16 @@ import { isJetbrainsPlatform } from "../../../utils/platform" export const renderModes = { noLimit: { - interval: 6, + interval: 5, }, fast: { - interval: 15, + interval: 10, }, medium: { - interval: 30, + interval: 20, }, slow: { - interval: 60, + interval: 40, }, } diff --git a/src/api/providers/zgsm.ts b/src/api/providers/zgsm.ts index 39b3eb8676..23ca9e7a91 100644 --- a/src/api/providers/zgsm.ts +++ b/src/api/providers/zgsm.ts @@ -39,6 +39,7 @@ import { getModels } from "./fetchers/modelCache" import { ClineApiReqCancelReason } from "../../shared/ExtensionMessage" import { getEditorType } from "../../utils/getEditorType" import { ChatCompletionChunk } from "openai/resources/index.mjs" +import { convertToZAiFormat } from "../transform/zai-format" const autoModeModelId = "Auto" const isDev = process.env.NODE_ENV === "development" @@ -48,6 +49,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl private client: OpenAI private readonly providerName = "zgsm" private baseURL: string + private toolProtocol: "native" | "xml" = "xml" private chatType?: "user" | "system" private modelInfo = {} as ModelInfo private apiResponseRenderModeInfo = renderModes.fast @@ -104,12 +106,12 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { + this.setToolProtocol(metadata?.toolProtocol) // Performance monitoring log this.abortController = new AbortController() const requestId = uuidv7() const workflowModes = ["strict", "plan"] as Array await this.updateModelInfo() - const isNative = isNativeProtocol(metadata?.toolProtocol) const fromWorkflow = metadata?.zgsmWorkflowMode || workflowModes.includes(metadata?.mode) || @@ -117,7 +119,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl workflowModes.includes(metadata?.parentTaskMode) || workflowModes.includes(metadata?.zgsmCodeMode) this.apiResponseRenderModeInfo = getApiResponseRenderMode() - if ("review" === metadata?.mode) { + if ("review" === metadata?.mode && this.client) { this.client.maxRetries = 1 } // 1. Cache calculation results and configuration @@ -125,13 +127,14 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl const modelUrl = this.baseURL || ZgsmAuthConfig.getInstance().getDefaultApiBaseUrl() const enabledR1Format = this.options.openAiR1FormatEnabled ?? false const enabledLegacyFormat = this.options.openAiLegacyFormat ?? false + const isNative = isNativeProtocol(this?.toolProtocol) // Cache boolean calculation results const isAzureAiInference = this._isAzureAiInference(modelUrl) const isDeepseekReasoner = modelId.includes("deepseek-reasoner") + const isMiniMax = modelId.toLowerCase().includes("minimax") const deepseekReasoner = isDeepseekReasoner || enabledR1Format const isArk = modelUrl.includes(".volces.com") - const ark = isArk const isGrokXAI = this._isGrokXAI(this.baseURL) const isO1Family = modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4") @@ -150,10 +153,12 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl try { const tokens = await ZgsmAuthService.getInstance()?.getTokens() - this.client.apiKey = tokens?.access_token || "not-provided" + if (this.client) { + this.client.apiKey = tokens?.access_token || "not-provided" + } } catch (error) { this.logger.info( - `[createMessage] getting new tokens failed \n\nuse old tokens: ${this.client.apiKey} \n\n${error.message}`, + `[createMessage] getting new tokens failed \n\nuse old tokens: ${this.client?.apiKey} \n\n${error.message}`, ) } @@ -164,7 +169,8 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl systemPrompt, messages, deepseekReasoner, - ark, + isArk, + isMiniMax, enabledLegacyFormat, modelInfo, isNative, @@ -173,6 +179,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl convertedMessages, deepseekReasoner, isGrokXAI, + isMiniMax, reasoning, modelInfo, metadata, @@ -193,7 +200,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl metadata.onRequestHeadersReady(_headers) } - const { data, response } = await this.client.chat.completions + const { data, response } = await (this.client as OpenAI).chat.completions .create( requestOptions, Object.assign(isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, { @@ -254,7 +261,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl try { requestIdTimestamp = Date.now() this.logger.info(`[RequestID]:`, requestId) - response = await this.client.chat.completions.create( + response = await (this.client as OpenAI).chat.completions.create( requestOptions, Object.assign(isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, { headers: _headers, @@ -360,16 +367,22 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl messages: Anthropic.Messages.MessageParam[], isDeepseekReasoner: boolean, isArk: boolean, + isMiniMax: boolean, isLegacyFormat: boolean, modelInfo: ModelInfo, isNative: boolean, - ): OpenAI.Chat.ChatCompletionMessageParam[] { - let convertedMessages: OpenAI.Chat.ChatCompletionMessageParam[] - + ): Array { + let convertedMessages: Array + const _mid = modelInfo.id?.toLowerCase() if (isDeepseekReasoner) { convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) } else if (isArk || isLegacyFormat) { convertedMessages = [{ role: "system", content: systemPrompt }, ...convertToSimpleMessages(messages)] + } else if (_mid?.includes("glm-4.7")) { + convertedMessages = [ + { role: "system", content: systemPrompt }, + ...convertToZAiFormat(messages, { mergeToolResultText: true }), + ] } else { const systemMessage = modelInfo.supportsPromptCache ? { @@ -384,7 +397,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl } : { role: "system" as const, content: systemPrompt } - convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)] + convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages, { mergeToolResultText: true })] } // Apply cache control logic @@ -396,7 +409,10 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl /** * Apply cache control logic (extracted as separate method) */ - private applyCacheControlLogic(messages: OpenAI.Chat.ChatCompletionMessageParam[], modelInfo: ModelInfo): void { + private applyCacheControlLogic( + messages: Array, + modelInfo: ModelInfo, + ): void { if (!modelInfo.supportsPromptCache) { return } @@ -426,15 +442,16 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl * Build streaming request options */ private buildStreamingRequestOptions( - messages: OpenAI.Chat.ChatCompletionMessageParam[], + messages: Array, isDeepseekReasoner: boolean, isGrokXAI: boolean, + isMiniMax: boolean, reasoning: any, modelInfo: ModelInfo, metadata?: ApiHandlerCreateMessageMetadata, isNative?: boolean, ): OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & { extra_body: any } { - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { + let requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & { extra_body: any } = { model: modelInfo.id, temperature: this.options.modelTemperature ?? (isDeepseekReasoner ? DEEP_SEEK_DEFAULT_TEMPERATURE : undefined), @@ -442,16 +459,19 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl stream: true as const, ...(isGrokXAI ? {} : { stream_options: { include_usage: true } }), ...(reasoning && reasoning), - ...(isNative - ? { - ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }), - ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), - ...{ parallel_tool_calls: metadata?.parallelToolCalls ?? false }, - } - : undefined), - extra_body: { - mode: metadata?.mode, - }, + } + + if (isNative) { + requestOptions = { + ...requestOptions, + ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }), + ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), + ...{ parallel_tool_calls: metadata?.parallelToolCalls ?? false }, + } + } + + requestOptions.extra_body = { + mode: metadata?.mode, } this.addMaxTokensIfNeeded(requestOptions, modelInfo) @@ -601,17 +621,29 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl } // Process reasoning content - if ("reasoning_content" in delta && delta.reasoning_content) { + if (delta) { // eslint-disable-next-line @typescript-eslint/no-unused-expressions isDev && - this.logger.warn( - `[ResponseID ${this.options.zgsmModelId} sse "reasoning_content":`, + this.logger.info( + `[ResponseID ${this.options.zgsmModelId} sse rendering chunk]:`, requestId, - delta.reasoning_content, + JSON.stringify(chunk), ) - yield { - type: "reasoning", - text: delta.reasoning_content as string, + for (const key of ["reasoning_content", "reasoning"] as const) { + if (key in delta) { + const reasoning_content = ((delta as any)[key] as string | undefined) || "" + if (reasoning_content?.trim()) { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + isDev && + this.logger.warn( + `[ResponseID ${this.options.zgsmModelId} sse "${key} -> reasoning_content":`, + requestId, + reasoning_content, + ) + yield { type: "reasoning", text: reasoning_content } + } + break + } } } @@ -699,8 +731,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl this.logger.warn( `[ResponseID ${this.options.zgsmModelId} sse "toolCall arguments":`, requestId, - toolCall.function?.name, - toolCall.function?.arguments, + JSON.stringify(toolCall), ) yield { type: "tool_call_partial", @@ -786,7 +817,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl // Add max_tokens if needed this.addMaxTokensIfNeeded(requestOptions, modelInfo) try { - const response = await this.client.chat.completions.create( + const response = await (this.client as OpenAI).chat.completions.create( requestOptions, Object.assign(isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, { headers: { @@ -850,7 +881,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl this.addMaxTokensIfNeeded(requestOptions, modelInfo) let stream try { - stream = await this.client.chat.completions.create( + stream = await (this.client as OpenAI).chat.completions.create( requestOptions, Object.assign(methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, { signal: this.abortController?.signal, @@ -889,7 +920,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl let response try { - response = await this.client.chat.completions.create( + response = await (this.client as OpenAI).chat.completions.create( requestOptions, Object.assign(methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, { signal: this.abortController?.signal, @@ -1024,6 +1055,10 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl setChatType(type: "user" | "system"): void { this.chatType = type } + setToolProtocol(toolProtocol?: "native" | "xml"): void { + if (!toolProtocol) return + this.toolProtocol = toolProtocol + } getChatType() { return this.chatType diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index dbd5acf236..03e5c40923 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -851,7 +851,14 @@ export class NativeToolCallParser { } as NativeArgsFor } break - + case "list_files": + if (args.path !== undefined && args.recursive !== undefined) { + nativeArgs = { + path: args.path, + recursive: args.recursive, + } as NativeArgsFor + } + break default: if (customToolRegistry.has(resolvedName)) { nativeArgs = args as NativeArgsFor diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index d7c229ba44..3238eca707 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -436,11 +436,9 @@ export function getMessagesSinceLastSummary(messages: ApiMessage[]): ApiMessage[ * as they have been replaced by that summary. * Messages with a truncationParent that points to an existing truncation marker are also filtered out, * as they have been hidden by sliding window truncation. - * Messages with an errorCorrectionParent that points to an existing error correction marker are also filtered out, - * as they represent error-correction pairs that can be omitted to reduce context. * - * This allows non-destructive condensing, truncation, and error filtering where messages are tagged but not deleted, - * enabling accurate rewind operations while still sending condensed/truncated/filtered history to the API. + * This allows non-destructive condensing and truncation where messages are tagged but not deleted, + * enabling accurate rewind operations while still sending condensed/truncated history to the API. * * @param messages - The full API conversation history including tagged messages * @returns The filtered history that should be sent to the API @@ -450,8 +448,6 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] { const existingSummaryIds = new Set() // Collect all truncationIds of truncation markers that exist in the current history const existingTruncationIds = new Set() - // Collect all errorCorrectionIds of error correction markers that exist in the current history - const existingErrorCorrectionIds = new Set() for (const msg of messages) { if (msg.isSummary && msg.condenseId) { @@ -460,14 +456,10 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] { if (msg.isTruncationMarker && msg.truncationId) { existingTruncationIds.add(msg.truncationId) } - if (msg.isErrorCorrectionMarker && msg.errorCorrectionId) { - existingErrorCorrectionIds.add(msg.errorCorrectionId) - } } - // Filter out messages whose condenseParent points to an existing summary, - // whose truncationParent points to an existing truncation marker, - // or whose errorCorrectionParent points to an existing error correction marker. + // Filter out messages whose condenseParent points to an existing summary + // or whose truncationParent points to an existing truncation marker. // Messages with orphaned parents (summary/marker was deleted) are included return messages.filter((msg) => { // Filter out condensed messages if their summary exists @@ -478,32 +470,26 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] { if (msg.truncationParent && existingTruncationIds.has(msg.truncationParent)) { return false } - // Filter out error-correction messages if their correction marker exists - if (msg.errorCorrectionParent && existingErrorCorrectionIds.has(msg.errorCorrectionParent)) { - return false - } return true }) } /** - * Cleans up orphaned condenseParent, truncationParent, and errorCorrectionParent references after a truncation operation (rewind/delete). - * When a summary message, truncation marker, or error correction marker is deleted, messages that were tagged with its ID + * Cleans up orphaned condenseParent and truncationParent references after a truncation operation (rewind/delete). + * When a summary message or truncation marker is deleted, messages that were tagged with its ID * should have their parent reference cleared so they become active again. * * This function should be called after any operation that truncates the API history - * to ensure messages are properly restored when their summary, truncation marker, or error correction marker is deleted. + * to ensure messages are properly restored when their summary or truncation marker is deleted. * * @param messages - The API conversation history after truncation - * @returns The cleaned history with orphaned condenseParent, truncationParent, and errorCorrectionParent fields cleared + * @returns The cleaned history with orphaned condenseParent and truncationParent fields cleared */ export function cleanupAfterTruncation(messages: ApiMessage[]): ApiMessage[] { // Collect all condenseIds of summaries that still exist const existingSummaryIds = new Set() // Collect all truncationIds of truncation markers that still exist const existingTruncationIds = new Set() - // Collect all errorCorrectionIds of error correction markers that still exist - const existingErrorCorrectionIds = new Set() for (const msg of messages) { if (msg.isSummary && msg.condenseId) { @@ -512,12 +498,9 @@ export function cleanupAfterTruncation(messages: ApiMessage[]): ApiMessage[] { if (msg.isTruncationMarker && msg.truncationId) { existingTruncationIds.add(msg.truncationId) } - if (msg.isErrorCorrectionMarker && msg.errorCorrectionId) { - existingErrorCorrectionIds.add(msg.errorCorrectionId) - } } - // Clear orphaned parent references for messages whose summary, truncation marker, or error correction marker was deleted + // Clear orphaned parent references for messages whose summary or truncation marker was deleted return messages.map((msg) => { let needsUpdate = false @@ -531,14 +514,9 @@ export function cleanupAfterTruncation(messages: ApiMessage[]): ApiMessage[] { needsUpdate = true } - // Check for orphaned errorCorrectionParent - if (msg.errorCorrectionParent && !existingErrorCorrectionIds.has(msg.errorCorrectionParent)) { - needsUpdate = true - } - if (needsUpdate) { // Create a new object without orphaned parent references - const { condenseParent, truncationParent, errorCorrectionParent, ...rest } = msg + const { condenseParent, truncationParent, ...rest } = msg const result: ApiMessage = rest as ApiMessage // Keep condenseParent if its summary still exists @@ -551,11 +529,6 @@ export function cleanupAfterTruncation(messages: ApiMessage[]): ApiMessage[] { result.truncationParent = truncationParent } - // Keep errorCorrectionParent if its error correction marker still exists - if (errorCorrectionParent && existingErrorCorrectionIds.has(errorCorrectionParent)) { - result.errorCorrectionParent = errorCorrectionParent - } - return result } return msg diff --git a/src/core/costrict/error-code/ErrorCodeManager.ts b/src/core/costrict/error-code/ErrorCodeManager.ts index b169047588..bd8178e940 100644 --- a/src/core/costrict/error-code/ErrorCodeManager.ts +++ b/src/core/costrict/error-code/ErrorCodeManager.ts @@ -144,7 +144,7 @@ export class ErrorCodeManager { this.unknownError.message = rawError let status = error.status as number const { code, headers } = error - const requestId = headers?.get("x-request-id") ?? null + const requestId = (headers && headers?.get?.("x-request-id")) ?? null const { apiConfiguration, errorCode } = await this.provider.getState() const { zgsmApiKeyExpiredAt, zgsmApiKeyUpdatedAt, isOldModeLoginState } = this.parseZgsmTokenInfo( apiConfiguration.zgsmAccessToken, diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index e04b2d23c2..c281c6bf35 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -36,8 +36,10 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT, maxWorkspaceFiles = MAX_WORKSPACE_FILES, terminalShellIntegrationDisabled, + maxOpenTabsContext, } = state ?? {} const shell = getShell(terminalShellIntegrationDisabled) + const maxTabs = maxOpenTabsContext ?? 20 // It could be useful for cline to know if the user went from one or no // file to another between messages, so we always include this context. @@ -45,56 +47,44 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo .map((editor) => editor.document?.uri) .filter(Boolean) - const existingVisibleFilePaths = await Promise.all( - visibleTabFilePaths.map(async (uri) => { - try { - await fs.stat(uri.fsPath) - const absolutePath = uri.fsPath - return path.relative(cline.cwd, absolutePath).toPosix() - } catch (error) { - return null - } - }), - ) - const visibleFilePaths = existingVisibleFilePaths.filter((path) => path !== null).slice(0, maxWorkspaceFiles) - - // Filter paths through rooIgnoreController - const allowedVisibleFiles = cline.rooIgnoreController - ? cline.rooIgnoreController.filterPaths(visibleFilePaths) - : visibleFilePaths.map((p) => p.toPosix()).join("\n") - - if (allowedVisibleFiles) { - details += "\n\n# VSCode Visible Files" - details += `\n${allowedVisibleFiles}` - } - - const { maxOpenTabsContext } = state ?? {} - const maxTabs = maxOpenTabsContext ?? 20 - - // 获取所有文本编辑器标签的文件路径 const tabUris = (vscode.window.tabGroups?.all || []) .flatMap((group) => group.tabs) .filter((tab) => tab.input instanceof vscode.TabInputText) .map((tab) => (tab.input as vscode.TabInputText).uri) .filter(Boolean) - // 使用 Promise.all 并行检查文件是否存在 - const existingTabPaths = await Promise.all( - tabUris.map(async (uri) => { - try { - // 检查文件是否存在 - await fs.stat(uri.fsPath) - // 文件存在,返回相对路径 - const absolutePath = uri.fsPath - return path.relative(cline.cwd, absolutePath).toPosix() - } catch (error) { - // 文件不存在或无法访问,返回 null - return null - } - }), - ) + const [existingVisibleFilePaths, existingTabPaths] = await Promise.all([ + Promise.all( + visibleTabFilePaths.map(async (uri) => { + try { + await fs.stat(uri.fsPath) + const absolutePath = uri.fsPath + return path.relative(cline.cwd, absolutePath).toPosix() + } catch (error) { + return null + } + }), + ), + Promise.all( + tabUris.map(async (uri) => { + try { + await fs.stat(uri.fsPath) + const absolutePath = uri.fsPath + return path.relative(cline.cwd, absolutePath).toPosix() + } catch (error) { + return null + } + }), + ), + ]) + + const visibleFilePaths = existingVisibleFilePaths.filter((path) => path !== null).slice(0, maxWorkspaceFiles) + + // Filter paths through rooIgnoreController + const allowedVisibleFiles = cline.rooIgnoreController + ? cline.rooIgnoreController.filterPaths(visibleFilePaths) + : visibleFilePaths.map((p) => p.toPosix()).join("\n") - // 过滤掉 null 值(不存在的文件)并限制数量 const openTabPaths = existingTabPaths.filter((path) => path !== null).slice(0, maxTabs) // Filter paths through rooIgnoreController @@ -102,6 +92,11 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo ? cline.rooIgnoreController.filterPaths(openTabPaths) : openTabPaths.map((p) => p.toPosix()).join("\n") + if (allowedVisibleFiles) { + details += "\n\n# VSCode Visible Files" + details += `\n${allowedVisibleFiles}` + } + if (allowedOpenTabs) { details += "\n\n# VSCode Open Tabs" details += `\n${allowedOpenTabs}` diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 695ed1bebe..ab5dfd75de 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -63,7 +63,6 @@ export const formatResponse = { }, noToolsUsed: (protocol?: ToolProtocol, preUserContent?: string, preAssistantMessage?: string) => { - // const instructions = getToolInstructionsReminder(protocol) return `SYSTEM NOTICE (MUST COMPLY): In the previous turn, no tool was called. @@ -75,19 +74,9 @@ In this turn: - Do NOT repeat previous content. - Do NOT respond conversationally. - If you have completed the user's task, use the attempt_completion tool.` + // const instructions = getToolInstructionsReminder(protocol) - // return `SYSTEM NOTICE (MUST COMPLY): - - // In the previous turn, no tool was called. - // This violates a system rule: EVERY assistant turn MUST include at least one tool call. - - // In this turn: - // - You MUST call one appropriate tool. - // - Do NOT explain or justify the previous response. - // - Do NOT repeat previous content. - // - Do NOT respond conversationally. - // - If you have completed the user's task, use the attempt_completion tool. - + // return `[ERROR] You did not use a tool in your previous response! Please retry with a tool use. // ${instructions} // # Next Steps diff --git a/src/core/prompts/sections/__tests__/skills.spec.ts b/src/core/prompts/sections/__tests__/skills.spec.ts new file mode 100644 index 0000000000..707d151252 --- /dev/null +++ b/src/core/prompts/sections/__tests__/skills.spec.ts @@ -0,0 +1,32 @@ +import { getSkillsSection } from "../skills" + +describe("getSkillsSection", () => { + it("should emit XML with name, description, and location", async () => { + const mockSkillsManager = { + getSkillsForMode: vi.fn().mockReturnValue([ + { + name: "pdf-processing", + description: "Extracts text & tables from PDFs", + path: "/abs/path/pdf-processing/SKILL.md", + source: "global" as const, + }, + ]), + } + + const result = await getSkillsSection(mockSkillsManager, "code") + + expect(result).toContain("") + expect(result).toContain("") + expect(result).toContain("") + expect(result).toContain("pdf-processing") + // Ensure XML escaping for '&' + expect(result).toContain("Extracts text & tables from PDFs") + // For filesystem-based agents, location should be the absolute path to SKILL.md + expect(result).toContain("/abs/path/pdf-processing/SKILL.md") + }) + + it("should return empty string when skillsManager or currentMode is missing", async () => { + await expect(getSkillsSection(undefined, "code")).resolves.toBe("") + await expect(getSkillsSection({ getSkillsForMode: vi.fn() }, undefined)).resolves.toBe("") + }) +}) diff --git a/src/core/prompts/sections/skills.ts b/src/core/prompts/sections/skills.ts index 999d4f135f..954d0451bd 100644 --- a/src/core/prompts/sections/skills.ts +++ b/src/core/prompts/sections/skills.ts @@ -1,16 +1,14 @@ -import { SkillsManager, SkillMetadata } from "../../../services/skills/SkillsManager" +import type { SkillsManager } from "../../../services/skills/SkillsManager" -/** - * Get a display-friendly relative path for a skill. - * Converts absolute paths to relative paths to avoid leaking sensitive filesystem info. - * - * @param skill - The skill metadata - * @returns A relative path like ".roo/skills/name/SKILL.md" or "~/.roo/skills/name/SKILL.md" - */ -function getDisplayPath(skill: SkillMetadata): string { - const basePath = skill.source === "project" ? ".roo" : "~/.roo" - const skillsDir = skill.mode ? `skills-${skill.mode}` : "skills" - return `${basePath}/${skillsDir}/${skill.name}/SKILL.md` +type SkillsManagerLike = Pick + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'") } /** @@ -22,7 +20,7 @@ function getDisplayPath(skill: SkillMetadata): string { * @param currentMode - The current mode slug (e.g., 'code', 'architect') */ export async function getSkillsSection( - skillsManager: SkillsManager | undefined, + skillsManager: SkillsManagerLike | undefined, currentMode: string | undefined, ): Promise { if (!skillsManager || !currentMode) return "" @@ -31,41 +29,68 @@ export async function getSkillsSection( const skills = skillsManager.getSkillsForMode(currentMode) if (skills.length === 0) return "" - // Separate generic and mode-specific skills for display - const genericSkills = skills.filter((s) => !s.mode) - const modeSpecificSkills = skills.filter((s) => s.mode === currentMode) + const skillsXml = skills + .map((skill) => { + const name = escapeXml(skill.name) + const description = escapeXml(skill.description) + // Per the Agent Skills integration guidance for filesystem-based agents, + // location should be an absolute path to the SKILL.md file. + const location = escapeXml(skill.path) + return ` \n ${name}\n ${description}\n ${location}\n ` + }) + .join("\n") - let skillsList = "" + return `==== - if (modeSpecificSkills.length > 0) { - skillsList += modeSpecificSkills - .map( - (skill) => - ` * "${skill.name}" skill (${currentMode} mode) - ${skill.description} [${getDisplayPath(skill)}]`, - ) - .join("\n") - } +AVAILABLE SKILLS - if (genericSkills.length > 0) { - if (skillsList) skillsList += "\n" - skillsList += genericSkills - .map((skill) => ` * "${skill.name}" skill - ${skill.description} [${getDisplayPath(skill)}]`) - .join("\n") - } + +${skillsXml} + - return `==== + +REQUIRED PRECONDITION -AVAILABLE SKILLS +Before producing ANY user-facing response, you MUST perform a skill applicability check. + +Step 1: Skill Evaluation +- Evaluate the user's request against ALL available skill entries in . +- Determine whether at least one skill clearly and unambiguously applies. + +Step 2: Branching Decision + + +- Select EXACTLY ONE skill. +- Prefer the most specific skill when multiple skills match. +- Read the full SKILL.md file at the skill's . +- Load the SKILL.md contents fully into context BEFORE continuing. +- Follow the SKILL.md instructions precisely. +- Do NOT respond outside the skill-defined flow. + + + +- Proceed with a normal response. +- Do NOT load any SKILL.md files. + + +CONSTRAINTS: +- Do NOT load every SKILL.md up front. +- Load SKILL.md ONLY after a skill is selected. +- Do NOT skip this check. +- FAILURE to perform this check is an error. + -Skills are pre-packaged instructions for specific tasks. When a user request matches a skill description, read the full SKILL.md file to get detailed instructions. + +- The skill list is already filtered for the current mode: "${currentMode}". +- Mode-specific skills may come from skills-${currentMode}/ with project-level overrides taking precedence over global skills. + -- These are the currently available skills for "${currentMode}" mode: -${skillsList} + +This section is for internal control only. +Do NOT include this section in user-facing output. -To use a skill: -1. Identify which skill matches the user's request based on the description -2. Use read_file to load the full SKILL.md file from the path shown in brackets -3. Follow the instructions in the skill file -4. Access any bundled files (scripts, references, assets) as needed +After completing the evaluation, internally confirm: +true|false + ` } diff --git a/src/core/task-persistence/apiMessages.ts b/src/core/task-persistence/apiMessages.ts index 981dca6a78..c05538db6a 100644 --- a/src/core/task-persistence/apiMessages.ts +++ b/src/core/task-persistence/apiMessages.ts @@ -35,13 +35,6 @@ export type ApiMessage = Anthropic.MessageParam & { truncationParent?: string // Identifies a message as a truncation boundary marker isTruncationMarker?: boolean - // For error correction filtering: unique identifier for a successful correction - errorCorrectionId?: string - // For error correction filtering: points to the errorCorrectionId that marks this as part of an error-correction pair - // Messages with errorCorrectionParent are filtered out when sending to API if the correction marker exists - errorCorrectionParent?: string - // Identifies a message as an error correction marker (the successful response after error) - isErrorCorrectionMarker?: boolean } export async function readApiMessages({ diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1664832477..a058c043e5 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -238,6 +238,7 @@ export class Task extends EventEmitter implements TaskLike { * @private */ private _taskToolProtocol: ToolProtocol | undefined + private _taskToolProtocolChange: boolean | undefined /** * Promise that resolves when the task mode has been initialized. @@ -281,6 +282,7 @@ export class Task extends EventEmitter implements TaskLike { // API api: ApiHandler & { setChatType?: (type: "user" | "system") => void + setToolProtocol?: (toolProtocol?: "native" | "xml") => void getChatType?: () => "user" | "system" cancelChat?: (cancelType?: ClineApiReqCancelReason) => void } @@ -357,9 +359,11 @@ export class Task extends EventEmitter implements TaskLike { assistantMessageContent: AssistantMessageContent[] = [] presentAssistantMessageLocked = false presentAssistantMessageHasPendingUpdates = false - userMessageContent: ((Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam) & { - __isNoToolsUsed?: boolean - })[] = [] + userMessageContent: Array< + (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam) & { + __isNoToolsUsed?: boolean + } + > = [] userMessageContentReady = false didRejectTool = false didAlreadyUseTool = false @@ -920,89 +924,6 @@ export class Task extends EventEmitter implements TaskLike { await this.saveApiConversationHistory() } - /** - * Mark recent error-correction message pairs when the model successfully uses tools after an error. - * This allows the system to filter out error messages and their corrections to reduce context. - * - * When enabled, this will tag: - * 1. The assistant message that failed to use tools - * 2. The user message with the error correction prompt - * 3. Mark the current successful assistant message as a correction marker - */ - private async markErrorCorrectionPair(): Promise { - // Check if error correction filtering is enabled - const state = await this.providerRef.deref()?.getState() - if (!state?.filterErrorCorrectionMessages) { - return // Feature is disabled, do nothing - } - - // Find all unmarked error messages (with __isNoToolsUsed that haven't been tagged yet) - const errorUserMessageIndices: number[] = [] - for (let i = this.apiConversationHistory.length - 1; i >= 0; i--) { - const msg = this.apiConversationHistory[i] - if (msg.role === "user" && Array.isArray(msg.content)) { - // Skip messages that are already marked as part of an error-correction pair - if (msg.errorCorrectionParent) { - continue - } - - const hasNoToolsUsedError = msg.content.some((block: any) => block.__isNoToolsUsed === true) - if (hasNoToolsUsedError) { - errorUserMessageIndices.push(i) - } - } - } - - // If no unmarked error messages found, nothing to do - if (errorUserMessageIndices.length === 0) { - return - } - - // Verify that the last message in history is an assistant message - // This is a safety check to ensure we're in the expected state - const lastMessageIndex = this.apiConversationHistory.length - 1 - if (lastMessageIndex < 0 || this.apiConversationHistory[lastMessageIndex].role !== "assistant") { - // Unexpected state: last message should be the successful assistant response - this.providerRef - ?.deref() - ?.log(`Warning: markErrorCorrectionPair called but last message is not from assistant`) - return - } - - // Avoid marking the same assistant message as a correction marker multiple times - if (this.apiConversationHistory[lastMessageIndex].isErrorCorrectionMarker) { - // This assistant message is already marked as a correction marker - return - } - - // Generate a single errorCorrectionId for all the error-correction pairs - const errorCorrectionId = crypto.randomUUID() - - // Mark all found error messages and their preceding assistant messages - for (const errorUserMessageIndex of errorUserMessageIndices) { - // Mark the error user message - this.apiConversationHistory[errorUserMessageIndex].errorCorrectionParent = errorCorrectionId - - // Find and mark the assistant message that triggered the error (the one before the error user message) - for (let i = errorUserMessageIndex - 1; i >= 0; i--) { - if (this.apiConversationHistory[i].role === "assistant") { - // Only mark if not already marked (defensive check) - if (!this.apiConversationHistory[i].errorCorrectionParent) { - this.apiConversationHistory[i].errorCorrectionParent = errorCorrectionId - } - break - } - } - } - - // Mark the current (most recent) assistant message as the correction marker - this.apiConversationHistory[lastMessageIndex].isErrorCorrectionMarker = true - this.apiConversationHistory[lastMessageIndex].errorCorrectionId = errorCorrectionId - - // Save the updated history - await this.saveApiConversationHistory() - } - /** * Flush any pending tool results to the API conversation history. * @@ -1940,7 +1861,8 @@ export class Task extends EventEmitter implements TaskLike { // If we don't have a persisted tool protocol (old tasks before this feature), // detect it from the API history. This ensures tasks that previously used // XML tools will continue using XML even if NTC is now enabled. - if (!this._taskToolProtocol) { + if (!this._taskToolProtocol || this._taskToolProtocolChange) { + this._taskToolProtocolChange = false const detectedProtocol = detectToolProtocolFromHistory(this.apiConversationHistory) if (detectedProtocol) { // Found tool calls in history - lock to that protocol @@ -2463,13 +2385,18 @@ export class Task extends EventEmitter implements TaskLike { ) as { type: string; text: string } // Use the task's locked protocol, NOT the current settings (fallback to xml if not set) - nextUserContent = [ - { - type: "text", - __isNoToolsUsed: true, - text: formatResponse.noToolsUsed(this._taskToolProtocol ?? "xml", _nextUserContent?.text), - }, - ] + const content = { + type: "text", + text: formatResponse.noToolsUsed(this._taskToolProtocol ?? "xml", _nextUserContent?.text), + } as Anthropic.Messages.ContentBlockParam & { __isNoToolsUsed?: boolean } + + Object.defineProperty(content, "__isNoToolsUsed", { + value: true, + enumerable: false, // 不可枚举,JSON 序列化时会被忽略 + writable: false, + configurable: false, + }) + nextUserContent = [content] } } } @@ -2543,6 +2470,18 @@ export class Task extends EventEmitter implements TaskLike { // Determine API protocol based on provider and model const modelId = getModelId(this.apiConfiguration) const apiProtocol = getApiProtocol(this.apiConfiguration.apiProvider, modelId) + + // Respect user-configured provider rate limiting BEFORE we emit api_req_started. + // This prevents the UI from showing an "API Request..." spinner while we are + // intentionally waiting due to the rate limit slider. + // + // NOTE: We also set Task.lastGlobalApiRequestTime here to reserve this slot + // before we build environment details (which can take time). + // This ensures subsequent requests (including subtasks) still honour the + // provider rate-limit window. + await this.maybeWaitForProviderRateLimit(currentItem.retryAttempt ?? 0) + Task.lastGlobalApiRequestTime = performance.now() + await this.say( "api_req_started", JSON.stringify({ @@ -2758,12 +2697,12 @@ export class Task extends EventEmitter implements TaskLike { streamModelInfo, this._taskToolProtocol, ) - const shouldUseXmlParser = streamProtocol === "xml" + let shouldUseXmlParser = streamProtocol === "xml" // Yields only if the first chunk is successful, otherwise will // allow the user to retry the request (most likely due to rate // limit error, which gets thrown on the first chunk). - const stream = this.attemptApiRequest() + const stream = this.attemptApiRequest(currentItem.retryAttempt ?? 0, { skipProviderRateLimit: true }) let assistantMessage = "" let reasoningMessage = "" let streamingFailedMessage: string | undefined = "" @@ -3003,7 +2942,18 @@ export class Task extends EventEmitter implements TaskLike { } case "text": { assistantMessage += chunk.text - + if (this._taskToolProtocolChange) { + shouldUseXmlParser = + resolveToolProtocol( + this.apiConfiguration, + streamModelInfo, + this._taskToolProtocol, + ) === "xml" + + if (shouldUseXmlParser) { + this._taskToolProtocolChange = false + } + } // Use the protocol determined at the start of streaming // Don't rely solely on parser existence - parser might exist from previous state if (shouldUseXmlParser && this.assistantMessageParser) { @@ -3321,6 +3271,28 @@ export class Task extends EventEmitter implements TaskLike { this.abortReason = cancelReason await this.abortTask() } else { + // Handle tool protocol errors with immediate retry + if (this.isToolProtocolError(error, this.apiConfiguration)) { + await this.rollbackXmlToolProtocol(error) + + // Protocol switched successfully, retry immediately without backoff + console.log( + `[Task#${this.taskId}.${this.instanceId}] Protocol switched to XML, retrying immediately`, + ) + + // Push back onto stack with userMessageWasRemoved flag + // rollbackXmlToolProtocol already removed the user message from history, + // so we need to signal that it should be re-added on retry + stack.push({ + userContent: currentUserContent, + includeFileDetails: false, + retryAttempt: 0, + userMessageWasRemoved: true, + }) + // Continue immediately to next iteration + continue + } + // Stream failed - log the error and retry with the same content // The existing rate limiting will prevent rapid retries console.error( @@ -3566,22 +3538,28 @@ export class Task extends EventEmitter implements TaskLike { } // Use the task's locked protocol for consistent behavior - this.userMessageContent.push({ + const _content = { type: "text", - __isNoToolsUsed: true, text: formatResponse.noToolsUsed( this._taskToolProtocol ?? "xml", undefined, preAssistantMessage, ), + } as (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam) & { + __isNoToolsUsed?: boolean + } + + Object.defineProperty(_content, "__isNoToolsUsed", { + value: true, + enumerable: false, // 不可枚举,JSON 序列化时会被忽略 + writable: false, + configurable: false, }) + + this.userMessageContent.push(_content) } else { // Reset counter when tools are used successfully this.consecutiveNoToolUseCount = 0 - - // Mark error-correction pairs if this is a successful response after an error - // This allows filtering out the error and its correction from context - await this.markErrorCorrectionPair() } // Push to stack if there's content OR if we're paused waiting for a subtask. @@ -3944,7 +3922,44 @@ export class Task extends EventEmitter implements TaskLike { await this.providerRef.deref()?.postMessageToWebview({ type: "condenseTaskContextResponse", text: this.taskId }) } - public async *attemptApiRequest(retryAttempt: number = 0): ApiStream { + /** + * Enforce the user-configured provider rate limit. + * + * NOTE: This is intentionally treated as expected behavior and is surfaced via + * the `api_req_rate_limit_wait` say type (not an error). + */ + private async maybeWaitForProviderRateLimit(retryAttempt: number): Promise { + const state = await this.providerRef.deref()?.getState() + const rateLimitSeconds = + state?.apiConfiguration?.rateLimitSeconds ?? this.apiConfiguration?.rateLimitSeconds ?? 0 + + if (rateLimitSeconds <= 0 || !Task.lastGlobalApiRequestTime) { + return + } + + const now = performance.now() + const timeSinceLastRequest = now - Task.lastGlobalApiRequestTime + const rateLimitDelay = Math.ceil( + Math.min(rateLimitSeconds, Math.max(0, rateLimitSeconds * 1000 - timeSinceLastRequest) / 1000), + ) + + // Only show the countdown UX on the first attempt. Retry flows have their own delay messaging. + if (rateLimitDelay > 0 && retryAttempt === 0) { + for (let i = rateLimitDelay; i > 0; i--) { + // Send structured JSON data for i18n-safe transport + const delayMessage = JSON.stringify({ seconds: i }) + await this.say("api_req_rate_limit_wait", delayMessage, undefined, true) + await delay(1000) + } + // Finalize the partial message so the UI doesn't keep rendering an in-progress spinner. + await this.say("api_req_rate_limit_wait", undefined, undefined, false) + } + } + + public async *attemptApiRequest( + retryAttempt: number = 0, + options: { skipProviderRateLimit?: boolean } = {}, + ): ApiStream { const state = await this.providerRef.deref()?.getState() const { @@ -3983,29 +3998,17 @@ export class Task extends EventEmitter implements TaskLike { } } - let rateLimitDelay = 0 - - // Use the shared timestamp so that subtasks respect the same rate-limit - // window as their parent tasks. - if (Task.lastGlobalApiRequestTime) { - const now = performance.now() - const timeSinceLastRequest = now - Task.lastGlobalApiRequestTime - const rateLimit = apiConfiguration?.rateLimitSeconds ?? 1 - rateLimitDelay = Math.ceil(Math.min(rateLimit, Math.max(0, rateLimit * 1000 - timeSinceLastRequest) / 1000)) - } - - // Only show rate limiting message if we're not retrying. If retrying, we'll include the delay there. - if (rateLimitDelay > 0 && retryAttempt === 0) { - // Show countdown timer - for (let i = rateLimitDelay; i > 0; i--) { - const delayMessage = `Rate limiting for ${i} seconds...` - this.providerRef?.deref()?.log(`"api_req_retry_delayed" ${delayMessage}`) - await delay(1000) - } + if (!options.skipProviderRateLimit) { + await this.maybeWaitForProviderRateLimit(retryAttempt) } - // Update last request time before making the request so that subsequent + // Update last request time right before making the request so that subsequent // requests — even from new subtasks — will honour the provider's rate-limit. + // + // NOTE: When recursivelyMakeClineRequests handles rate limiting, it sets the + // timestamp earlier to include the environment details build. We still set it + // here for direct callers (tests) and for the case where we didn't rate-limit + // in the caller. Task.lastGlobalApiRequestTime = performance.now() const systemPrompt = await this.getSystemPrompt() @@ -4300,6 +4303,23 @@ export class Task extends EventEmitter implements TaskLike { this.currentRequestAbortController = undefined const isContextWindowExceededError = checkContextWindowExceededError(error) + // Automatic protocol fallback: If using native protocol and error is protocol-related, + // switch to XML protocol and retry. This only happens once per task. + if (this.isToolProtocolError(error, this.apiConfiguration)) { + console.log( + `[Task#${this.taskId}] Protocol error detected on attempt ${retryAttempt + 1}. ` + + `Falling back to XML protocol...`, + ) + + await this.rollbackXmlToolProtocol(error) + + // Set chat type for system message + this.api?.setChatType?.("system") + + yield* this.attemptApiRequest() + return + } + // If it's a context window error and we haven't exceeded max retries for this error type if (isContextWindowExceededError && retryAttempt < MAX_CONTEXT_WINDOW_RETRIES) { console.warn( @@ -4585,6 +4605,261 @@ export class Task extends EventEmitter implements TaskLike { return cleanConversationHistory } + + async rollbackXmlToolProtocol(error: Error) { + console.log(`[Task#${this.taskId}] Switching from native to XML tool protocol due to API error`) + + // Update protocol flag + this._taskToolProtocol = "xml" + this._taskToolProtocolChange = true + // Notify API provider about protocol change + this.api?.setToolProtocol?.(this._taskToolProtocol) + + // Update system prompt to reflect new protocol (if exists in conversation history) + const firstMessage = this.apiConversationHistory[0] as any + let lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1] + let userFeedback = "" + if (lastMessage?.role === "user") { + if (Array.isArray(lastMessage.content)) { + this.apiConversationHistory.pop() + const lastMessageToolcontent = ( + lastMessage.content.find((block: any) => block.type === "tool_result") as any + )?.content as string + + if ( + lastMessageToolcontent.includes("") || + lastMessageToolcontent.includes("") || + lastMessageToolcontent.includes("") || + lastMessageToolcontent.includes("") + ) { + userFeedback = lastMessageToolcontent + } + } + } + + if ( + firstMessage?.role === "system" && + firstMessage?.content && + Array.isArray(firstMessage.content) && + firstMessage.content[1]?.type === "text" + ) { + firstMessage.content[1].text = firstMessage.content[1].text.replace( + "native", + "xml", + ) + } + + // Find the first user message (skip system prompt if it exists) + const startIndex = (this.apiConversationHistory[0] as any)?.role === "system" ? 1 : 0 + const originalUserMessage = this.apiConversationHistory[startIndex] + + // If no user message exists, can't proceed - just clear history + if (!originalUserMessage || originalUserMessage.role !== "user") { + console.warn(`[Task#${this.taskId}] No original user message found, resetting to empty history`) + this.apiConversationHistory = + (this.apiConversationHistory[0] as any)?.role === "system" ? [this.apiConversationHistory[0]] : [] + + // Reset parser and state + this.resetParserAndState() + await this.say("rollback_xml_tool", error?.message) + return + } + + // Collect all tool calls and their results + const toolUseMap = new Map() // toolCallId -> toolName + const toolResults: string[] = [] + + for (let i = startIndex + 1; i < this.apiConversationHistory.length; i++) { + const message = this.apiConversationHistory[i] + + if (message?.role === "assistant" && Array.isArray(message.content)) { + // Record all tool_use IDs and names for later matching + for (const block of message.content) { + if (block.type === "tool_use" && (block as any).id && (block as any).name) { + toolUseMap.set((block as any).id, (block as any).name) + } + } + } else if (message?.role === "user" && Array.isArray(message.content)) { + // Match tool_result with corresponding tool_use + for (const block of message.content) { + if (block.type === "tool_result") { + const toolResultBlock = block as any + const toolCallId = toolResultBlock.tool_use_id + const toolName = toolCallId ? toolUseMap.get(toolCallId) : undefined + + // Format content based on its type + let contentStr = "" + if (typeof toolResultBlock.content === "string") { + contentStr = toolResultBlock.content + } else if (Array.isArray(toolResultBlock.content)) { + // Handle content array (e.g., [{ type: "text", text: "..." }]) + contentStr = toolResultBlock.content + .map((c: any) => (c.type === "text" ? c.text : JSON.stringify(c))) + .join("\n") + } else if (toolResultBlock.content) { + contentStr = JSON.stringify(toolResultBlock.content) + } + + if (contentStr) { + const resultText = toolName ? `["${toolName}" tool result]\n${contentStr}` : contentStr + toolResults.push(resultText) + } + } + } + } + } + + // Build new assistant message content + const assistantContent: any[] = [] + + // Preserve non-tool text blocks from the first assistant message + const firstAssistantIndex = startIndex + 1 + const firstAssistant = this.apiConversationHistory[firstAssistantIndex] + if (firstAssistant?.role === "assistant" && Array.isArray(firstAssistant.content)) { + const textBlocks = firstAssistant.content.filter((block: any) => block.type === "text") + if (textBlocks.length > 0) { + assistantContent.push(...textBlocks) + } + } + + // Add tool results summary if any exist + if (toolResults.length > 0) { + assistantContent.push({ + type: "text", + text: toolResults.join("\n\n"), + }) + } + + // Rebuild conversation history + const newHistory: any[] = [] + + // Keep system prompt if it exists + if ((this.apiConversationHistory[0] as any)?.role === "system") { + newHistory.push(this.apiConversationHistory[0]) + } + + // Add original user message + newHistory.push(originalUserMessage) + + // Add assistant message only if it has content + if (assistantContent.length > 0) { + newHistory.push({ + role: "assistant", + content: assistantContent, + }) + } + + if (userFeedback?.length) { + newHistory.push({ + role: "user", + content: userFeedback, + }) + } + + this.apiConversationHistory = newHistory + + // Reset parser and state + this.resetParserAndState() + + console.log( + `[Task#${this.taskId}] Rolled back to ${newHistory.length} messages ` + + `(${toolResults.length} tool results preserved)`, + ) + await this.say("rollback_xml_tool", error?.message) + } + + /** + * Helper method to reset XML parser and streaming state + */ + private resetParserAndState() { + // Force reset XML parser to ensure clean state for first retry + if (this.assistantMessageParser) { + this.assistantMessageParser.reset() + console.log(`[Task#${this.taskId}] Reset existing XML parser state`) + } else { + this.assistantMessageParser = new AssistantMessageParser(this.getCustomToolNames()) + console.log(`[Task#${this.taskId}] Initialized new XML parser`) + } + + // Clear all streaming state to prevent interference with XML parsing + this.assistantMessageContent = [] + this.streamingToolCallIndices.clear() + this.currentStreamingContentIndex = 0 + this.consecutiveMistakeCount = 0 + this.didCompleteReadingStream = false + + console.log(`[Task#${this.taskId}] Cleared all streaming state for protocol switch`) + } + /** + * Detects if an error is related to tool protocol incompatibility. + * This enables automatic fallback from native to XML tool protocol when errors occur. + * + * @param error - The error object to analyze + * @returns true if the error is related to tool protocol incompatibility, false otherwise + * + * Common tool protocol errors detected: + * - Missing or invalid tool_call_id in tool_result messages + * - Invalid tool call format or structure + * - Tool use blocks without proper ID fields + * - Mismatched tool call and result references + * - Malformed tool call syntax + * + * Performance optimization: Uses regex pattern matching instead of multiple string comparisons + */ + private isToolProtocolError(error: any, apiConfiguration: ProviderSettings): boolean { + if (this._taskToolProtocol !== "native" || !error?.message) { + return false + } + + const errorMessage = error.message.toLowerCase() + + if ( + apiConfiguration.apiProvider === "zgsm" && + apiConfiguration.zgsmModelId?.toLowerCase().includes("claude") && + errorMessage.includes("provider returned error") + ) { + return true + } + + // Optimized regex pattern covering all common tool protocol errors + // This single regex is more performant than multiple string.includes() calls + const toolProtocolErrorPattern = new RegExp( + [ + // Missing or invalid tool_call_id + "no previous assistant message with a tool call", + "tool_call_id\\s+is not found", + "tool_call_id is required", + "invalid tool_call_id", + "tool_result requires tool_call_id", + + // Tool use format errors + "tool use must have an id", + "tool_use blocks must have an id field", + "missing id in tool_use", + + // Tool call/result mismatch + "tool result does not match any tool call", + "unexpected tool_result", + "tool_result without matching tool call", + + // Invalid tool structure + "invalid tool format", + "tool calls must be in the correct format", + "malformed tool call", + + "message content parts cannot be empty", + ].join("|"), + "i", // case-insensitive flag + ) + + const isProtocolError = toolProtocolErrorPattern.test(errorMessage) + + if (isProtocolError) { + console.warn(`[Task#${this.taskId}] Tool protocol error detected: ${error.message.slice(0, 200)}...`) + } + + return isProtocolError + } public async checkpointRestore(options: CheckpointRestoreOptions) { return checkpointRestore(this, options) } @@ -4774,7 +5049,8 @@ export class Task extends EventEmitter implements TaskLike { const errorCodeManager = ErrorCodeManager.getInstance() errorMsg = await errorCodeManager.parseResponse(error, isZgsm, this.taskId, this.instanceId) - const requestId = error.headers?.get("x-request-id") || this?.lastApiRequestHeaders?.["X-Request-ID"] + const requestId = + (error.headers && error.headers?.get?.("x-request-id")) || this?.lastApiRequestHeaders?.["X-Request-ID"] if (requestId) { // Store raw error errorCodeManager.setRawError(requestId, error) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 3d2c882804..f4233f755a 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1075,6 +1075,9 @@ describe("Cline", () => { startTask: false, }) + // Spy on child.say to verify the emitted message type + const saySpy = vi.spyOn(child, "say") + // Mock the child's API stream const childMockStream = { async *[Symbol.asyncIterator]() { @@ -1101,6 +1104,17 @@ describe("Cline", () => { // Verify rate limiting was applied expect(mockDelay).toHaveBeenCalledTimes(mockApiConfig.rateLimitSeconds) expect(mockDelay).toHaveBeenCalledWith(1000) + + // Verify we used the non-error rate-limit wait message type (JSON format) + expect(saySpy).toHaveBeenCalledWith( + "api_req_rate_limit_wait", + expect.stringMatching(/\{"seconds":\d+\}/), + undefined, + true, + ) + + // Verify the wait message was finalized + expect(saySpy).toHaveBeenCalledWith("api_req_rate_limit_wait", undefined, undefined, false) }, 10000) // Increase timeout to 10 seconds it("should not apply rate limiting if enough time has passed", async () => { diff --git a/src/core/task/__tests__/markErrorCorrectionPair.spec.ts b/src/core/task/__tests__/markErrorCorrectionPair.spec.ts deleted file mode 100644 index 157de4941a..0000000000 --- a/src/core/task/__tests__/markErrorCorrectionPair.spec.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { describe, it, expect } from "vitest" -import type { ApiMessage } from "../../task-persistence/apiMessages" - -/** - * Test suite for markErrorCorrectionPair optimization - * - * This tests the logic for marking error-correction pairs in conversation history. - * The actual implementation is in Task.ts, but we test the core logic here. - */ - -describe("markErrorCorrectionPair 优化验证", () => { - /** - * Simulates the marking logic from Task.ts - */ - function markErrorCorrectionPairs(history: ApiMessage[]): ApiMessage[] { - // Find all unmarked error messages - const errorUserMessageIndices: number[] = [] - for (let i = history.length - 1; i >= 0; i--) { - const msg = history[i] - if (msg.role === "user" && Array.isArray(msg.content)) { - // Skip already marked messages - if (msg.errorCorrectionParent) { - continue - } - - const hasNoToolsUsedError = msg.content.some((block: any) => block.__isNoToolsUsed === true) - if (hasNoToolsUsedError) { - errorUserMessageIndices.push(i) - } - } - } - - // If no unmarked errors, return unchanged - if (errorUserMessageIndices.length === 0) { - return history - } - - // Verify last message is assistant - const lastMessageIndex = history.length - 1 - if (lastMessageIndex < 0 || history[lastMessageIndex].role !== "assistant") { - return history - } - - // Avoid duplicate marking - if (history[lastMessageIndex].isErrorCorrectionMarker) { - return history - } - - // Generate errorCorrectionId - const errorCorrectionId = "test-error-correction-id" - - // Mark all error messages and their preceding assistant messages - for (const errorUserMessageIndex of errorUserMessageIndices) { - history[errorUserMessageIndex].errorCorrectionParent = errorCorrectionId - - // Find preceding assistant message - for (let i = errorUserMessageIndex - 1; i >= 0; i--) { - if (history[i].role === "assistant") { - if (!history[i].errorCorrectionParent) { - history[i].errorCorrectionParent = errorCorrectionId - } - break - } - } - } - - // Mark current assistant as correction marker - history[lastMessageIndex].isErrorCorrectionMarker = true - history[lastMessageIndex].errorCorrectionId = errorCorrectionId - - return history - } - - it("问题1: 应该标记所有连续的错误消息", () => { - const history: ApiMessage[] = [ - { role: "assistant", content: [{ type: "text", text: "response 1" }] }, - { role: "user", content: [{ type: "text", text: "error", __isNoToolsUsed: true } as any] }, - { role: "assistant", content: [{ type: "text", text: "response 2" }] }, - { role: "user", content: [{ type: "text", text: "error", __isNoToolsUsed: true } as any] }, - { role: "assistant", content: [{ type: "tool_use", id: "1", name: "test", input: {} } as any] }, - ] - - const result = markErrorCorrectionPairs(history) - - // 验证两个错误消息都被标记 - expect(result[1].errorCorrectionParent).toBe("test-error-correction-id") - expect(result[3].errorCorrectionParent).toBe("test-error-correction-id") - - // 验证对应的失败助手消息都被标记 - expect(result[0].errorCorrectionParent).toBe("test-error-correction-id") - expect(result[2].errorCorrectionParent).toBe("test-error-correction-id") - - // 验证最后的成功助手消息被标记为 marker - expect(result[4].isErrorCorrectionMarker).toBe(true) - expect(result[4].errorCorrectionId).toBe("test-error-correction-id") - }) - - it("问题2: 应该避免重复标记已经被标记的消息", () => { - const history: ApiMessage[] = [ - { role: "assistant", content: [{ type: "text", text: "response 1" }] }, - { - role: "user", - content: [{ type: "text", text: "error", __isNoToolsUsed: true } as any], - errorCorrectionParent: "existing-id", // 已经被标记 - }, - { role: "assistant", content: [{ type: "tool_use", id: "1", name: "test", input: {} } as any] }, - ] - - const result = markErrorCorrectionPairs(history) - - // 已标记的消息不应该被修改 - expect(result[1].errorCorrectionParent).toBe("existing-id") - - // 最后的助手消息不应该被标记为 marker(因为没有未标记的错误) - expect(result[2].isErrorCorrectionMarker).toBeUndefined() - }) - - it("问题3: 应该处理最后一条消息不是助手的情况", () => { - const history: ApiMessage[] = [ - { role: "assistant", content: [{ type: "text", text: "response 1" }] }, - { role: "user", content: [{ type: "text", text: "error", __isNoToolsUsed: true } as any] }, - { role: "user", content: [{ type: "text", text: "another user message" }] }, // 最后是 user - ] - - const result = markErrorCorrectionPairs(history) - - // 不应该标记任何消息(因为最后不是助手消息) - expect(result[1].errorCorrectionParent).toBeUndefined() - }) - - it("问题4: 应该避免重复标记同一个助手消息为 marker", () => { - const history: ApiMessage[] = [ - { role: "assistant", content: [{ type: "text", text: "response 1" }] }, - { role: "user", content: [{ type: "text", text: "error", __isNoToolsUsed: true } as any] }, - { - role: "assistant", - content: [{ type: "tool_use", id: "1", name: "test", input: {} } as any], - isErrorCorrectionMarker: true, // 已经是 marker - errorCorrectionId: "existing-id", - }, - ] - - const originalMarker = history[2].isErrorCorrectionMarker - const originalId = history[2].errorCorrectionId - - const result = markErrorCorrectionPairs(history) - - // marker 应该保持不变 - expect(result[2].isErrorCorrectionMarker).toBe(originalMarker) - expect(result[2].errorCorrectionId).toBe(originalId) - - // 错误消息不应该被标记 - expect(result[1].errorCorrectionParent).toBeUndefined() - }) - - it("边界情况: 空历史记录", () => { - const history: ApiMessage[] = [] - - const result = markErrorCorrectionPairs(history) - - expect(result).toEqual([]) - }) - - it("边界情况: 只有用户消息", () => { - const history: ApiMessage[] = [ - { role: "user", content: [{ type: "text", text: "error", __isNoToolsUsed: true } as any] }, - ] - - const result = markErrorCorrectionPairs(history) - - // 不应该标记(没有助手消息) - expect(result[0].errorCorrectionParent).toBeUndefined() - }) - - it("正常场景: 单个错误-修正对", () => { - const history: ApiMessage[] = [ - { role: "assistant", content: [{ type: "text", text: "no tools" }] }, - { role: "user", content: [{ type: "text", text: "error", __isNoToolsUsed: true } as any] }, - { role: "assistant", content: [{ type: "tool_use", id: "1", name: "test", input: {} } as any] }, - ] - - const result = markErrorCorrectionPairs(history) - - // 验证标记 - expect(result[0].errorCorrectionParent).toBe("test-error-correction-id") - expect(result[1].errorCorrectionParent).toBe("test-error-correction-id") - expect(result[2].isErrorCorrectionMarker).toBe(true) - expect(result[2].errorCorrectionId).toBe("test-error-correction-id") - }) - - it("防御性检查: 助手消息已经被其他 error pair 标记", () => { - const history: ApiMessage[] = [ - { - role: "assistant", - content: [{ type: "text", text: "response 1" }], - errorCorrectionParent: "other-id", // 已经被标记 - }, - { role: "user", content: [{ type: "text", text: "error", __isNoToolsUsed: true } as any] }, - { role: "assistant", content: [{ type: "tool_use", id: "1", name: "test", input: {} } as any] }, - ] - - const result = markErrorCorrectionPairs(history) - - // 已标记的助手消息应该保持原有的 errorCorrectionParent - expect(result[0].errorCorrectionParent).toBe("other-id") - - // 新的错误消息应该被标记 - expect(result[1].errorCorrectionParent).toBe("test-error-correction-id") - - // 最后的助手消息应该成为新的 marker - expect(result[2].isErrorCorrectionMarker).toBe(true) - }) -}) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index e0b35ab3cc..f64ee1ec46 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -206,6 +206,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { pushToolResult(message) await task.diffViewProvider.reset() + this.resetPartialState() task.processQueuedMessages() @@ -213,15 +214,26 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } catch (error) { await handleError("writing file", error as Error) await task.diffViewProvider.reset() + this.resetPartialState() return } } + // Track the last seen path during streaming to detect when the path has stabilized + private lastSeenPartialPath: string | undefined = undefined + override async handlePartial(task: Task, block: ToolUse<"write_to_file">): Promise { const relPath: string | undefined = block.params.path let newContent: string | undefined = block.params.content - if (!relPath || newContent === undefined) { + // During streaming, the partial-json library may return truncated string values + // when chunk boundaries fall mid-value. To avoid creating files at incorrect paths, + // we wait until the path stops changing between consecutive partial blocks before + // creating the file. This ensures we have the complete, final path value. + const pathHasStabilized = this.lastSeenPartialPath !== undefined && this.lastSeenPartialPath === relPath + this.lastSeenPartialPath = relPath + + if (!pathHasStabilized || !relPath || newContent === undefined) { return } @@ -287,6 +299,13 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { ) } } + + /** + * Reset state when the tool finishes (called from execute or on error) + */ + resetPartialState(): void { + this.lastSeenPartialPath = undefined + } } export const writeToFileTool = new WriteToFileTool() diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 7b41ee0f79..34a9df232a 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -139,6 +139,7 @@ describe("writeToFileTool", () => { beforeEach(() => { vi.clearAllMocks() + writeToFileTool.resetPartialState() mockedPathResolve.mockReturnValue(absoluteFilePath) mockedFileExistsAtPath.mockResolvedValue(false) @@ -312,10 +313,14 @@ describe("writeToFileTool", () => { ) it.skipIf(process.platform === "win32")( - "creates parent directories early when file does not exist (partial)", + "creates parent directories when path has stabilized (partial)", async () => { + // First call - path not yet stabilized await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(mockedCreateDirectoriesForFile).not.toHaveBeenCalled() + // Second call with same path - path is now stabilized + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) expect(mockedCreateDirectoriesForFile).toHaveBeenCalledWith(absoluteFilePath) }, ) @@ -428,9 +433,14 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() }) - it("streams content updates during partial execution", async () => { + it("streams content updates during partial execution after path stabilizes", async () => { + // First call - path not yet stabilized, early return (no file operations) await executeWriteFileTool({}, { isPartial: true }) + expect(mockCline.ask).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() + // Second call with same path - path is now stabilized, file operations proceed + await executeWriteFileTool({}, { isPartial: true }) expect(mockCline.ask).toHaveBeenCalled() expect(mockCline.diffViewProvider.open).toHaveBeenCalledWith(testFilePath) expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(testContent, false) @@ -476,11 +486,15 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() }) - it("handles partial streaming errors", async () => { + it("handles partial streaming errors after path stabilizes", async () => { mockCline.diffViewProvider.open.mockRejectedValue(new Error("Open failed")) + // First call - path not yet stabilized, no error yet await executeWriteFileTool({}, { isPartial: true }) + expect(mockHandleError).not.toHaveBeenCalled() + // Second call with same path - path is now stabilized, error occurs + await executeWriteFileTool({}, { isPartial: true }) expect(mockHandleError).toHaveBeenCalledWith("handling partial write_to_file", expect.any(Error)) }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 679bc6d5aa..93bd321687 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2081,7 +2081,7 @@ export class ClineProvider featureRoomoteControlEnabled, isBrowserSessionActive, autoCleanup, - filterErrorCorrectionMessages, + // Messages, } = await this.getState() // let cloudOrganizations: CloudOrganizationMembership[] = [] @@ -2120,7 +2120,6 @@ export class ClineProvider version: this.context.extension?.packageJSON?.version ?? "", apiConfiguration, autoCleanup, - filterErrorCorrectionMessages, customInstructions, alwaysAllowReadOnly: alwaysAllowReadOnly ?? false, alwaysAllowReadOnlyOutsideWorkspace: alwaysAllowReadOnlyOutsideWorkspace ?? false, @@ -2382,7 +2381,6 @@ export class ClineProvider providerSettings.openAiHeaders = providerSettings.openAiHeaders ?? {} return { autoCleanup: stateValues.autoCleanup ?? DEFAULT_AUTO_CLEANUP_SETTINGS, - filterErrorCorrectionMessages: stateValues.filterErrorCorrectionMessages ?? false, apiConfiguration: providerSettings, lastShownAnnouncementId: stateValues.lastShownAnnouncementId, customInstructions: stateValues.customInstructions, diff --git a/src/core/webview/__tests__/filterErrorCorrectionMessages.spec.ts b/src/core/webview/__tests__/filterErrorCorrectionMessages.spec.ts deleted file mode 100644 index b9972a97c7..0000000000 --- a/src/core/webview/__tests__/filterErrorCorrectionMessages.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" - -describe("filterErrorCorrectionMessages 配置持久化", () => { - let mockContextProxy: any - - beforeEach(() => { - vi.clearAllMocks() - - // Mock contextProxy - mockContextProxy = { - getValues: vi.fn(() => ({ - filterErrorCorrectionMessages: false, // 初始值 - })), - setValue: vi.fn(), - getProviderSettings: vi.fn(() => ({})), - } - }) - - it("应该正确处理 filterErrorCorrectionMessages 配置的更新", async () => { - // 模拟 updateSettings 消息处理 - const updatedSettings = { - filterErrorCorrectionMessages: true, - } - - // 模拟配置保存过程 - for (const [key, value] of Object.entries(updatedSettings)) { - await mockContextProxy.setValue(key, value) - } - - // 验证配置被正确保存 - expect(mockContextProxy.setValue).toHaveBeenCalledWith("filterErrorCorrectionMessages", true) - }) - - it("应该在 getState 中返回正确的 filterErrorCorrectionMessages 值", async () => { - // 测试 true 值 - mockContextProxy.getValues.mockReturnValueOnce({ - filterErrorCorrectionMessages: true, - }) - - const stateValues = mockContextProxy.getValues() - expect(stateValues.filterErrorCorrectionMessages).toBe(true) - - // 测试 false 值 - mockContextProxy.getValues.mockReturnValueOnce({ - filterErrorCorrectionMessages: false, - }) - - const stateValues2 = mockContextProxy.getValues() - expect(stateValues2.filterErrorCorrectionMessages).toBe(false) - - // 测试 undefined 值(应该使用默认值 false) - mockContextProxy.getValues.mockReturnValueOnce({}) - const stateValues3 = mockContextProxy.getValues() - expect(stateValues3.filterErrorCorrectionMessages ?? false).toBe(false) - }) - - it("应该在 Task 中正确使用 filterErrorCorrectionMessages 配置", async () => { - // 验证配置获取逻辑 - const mockGetState = vi.fn().mockResolvedValue({ - filterErrorCorrectionMessages: true, - }) - - // 验证基本的配置获取逻辑 - const state = await mockGetState() - expect(state.filterErrorCorrectionMessages).toBe(true) - }) -}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 2776c4e892..aaa0f56f4b 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3415,7 +3415,7 @@ export const webviewMessageHandler = async ( } case "copyApiError": { const { message: errorMessage, originModelId, selectedLLM } = message.values ?? {} - const { apiConfiguration } = await provider.getState() + const { apiConfiguration, currentTaskItem } = await provider.getState() const httpProxy = process.env.http_proxy || process.env.HTTP_PROXY const httpsProxy = process.env.https_proxy || process.env.HTTPS_PROXY @@ -3446,6 +3446,7 @@ export const webviewMessageHandler = async ( editorType: ${editorType} httpProxy: ${httpProxy} httpsProxy: ${httpsProxy} + toolProtocol: ${currentTaskItem?.toolProtocol || apiConfiguration?.toolProtocol} ${rawErrorMessage ? `${rawErrorMessage}` : ""} `) vscode.window.showInformationMessage(t("common:window.success.copy_success")) diff --git a/src/extension.ts b/src/extension.ts index 3f128ab5b0..4cc11e49ee 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -108,7 +108,7 @@ export async function activate(context: vscode.ExtensionContext) { TerminalRegistry.initialize() // Initialize Claude Code OAuth manager for direct API access. - claudeCodeOAuthManager.initialize(context) + claudeCodeOAuthManager.initialize(context, (message) => outputChannel.appendLine(message)) // Get default commands from configuration. const defaultCommands = vscode.workspace.getConfiguration(Package.name).get("allowedCommands") || [] diff --git a/src/integrations/claude-code/__tests__/oauth.spec.ts b/src/integrations/claude-code/__tests__/oauth.spec.ts index 526ef2f6f7..7de75ec529 100644 --- a/src/integrations/claude-code/__tests__/oauth.spec.ts +++ b/src/integrations/claude-code/__tests__/oauth.spec.ts @@ -195,4 +195,41 @@ describe("Claude Code OAuth", () => { expect(CLAUDE_CODE_OAUTH_CONFIG.callbackPort).toBe(54545) }) }) + + describe("refresh token behavior", () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + test("refresh responses may omit refresh_token (should be tolerated)", async () => { + const { refreshAccessToken } = await import("../oauth") + + // Mock fetch to return a refresh response with no refresh_token + const mockFetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + access_token: "new-access", + expires_in: 3600, + // refresh_token intentionally omitted + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) + + vi.stubGlobal("fetch", mockFetch) + + const creds: ClaudeCodeCredentials = { + type: "claude" as const, + access_token: "old-access", + refresh_token: "old-refresh", + expired: new Date(Date.now() - 1000).toISOString(), + email: "test@example.com", + } + + const refreshed = await refreshAccessToken(creds) + expect(refreshed.access_token).toBe("new-access") + expect(refreshed.refresh_token).toBe("old-refresh") + expect(refreshed.email).toBe("test@example.com") + }) + }) }) diff --git a/src/integrations/claude-code/oauth.ts b/src/integrations/claude-code/oauth.ts index abee04557e..2539be2ff7 100644 --- a/src/integrations/claude-code/oauth.ts +++ b/src/integrations/claude-code/oauth.ts @@ -31,12 +31,74 @@ export type ClaudeCodeCredentials = z.infer // Token response schema from Anthropic const tokenResponseSchema = z.object({ access_token: z.string(), - refresh_token: z.string(), + // Refresh responses may omit refresh_token (common OAuth behavior). When omitted, + // callers must preserve the existing refresh token. + refresh_token: z.string().min(1).optional(), expires_in: z.number(), email: z.string().optional(), token_type: z.string().optional(), }) +class ClaudeCodeOAuthTokenError extends Error { + public readonly status?: number + public readonly errorCode?: string + + constructor(message: string, opts?: { status?: number; errorCode?: string }) { + super(message) + this.name = "ClaudeCodeOAuthTokenError" + this.status = opts?.status + this.errorCode = opts?.errorCode + } + + public isLikelyInvalidGrant(): boolean { + if (this.errorCode && /invalid_grant/i.test(this.errorCode)) { + return true + } + if (this.status === 400 || this.status === 401 || this.status === 403) { + return /invalid_grant|revoked|expired|invalid refresh/i.test(this.message) + } + return false + } +} + +function parseOAuthErrorDetails(errorText: string): { errorCode?: string; errorMessage?: string } { + try { + const json: unknown = JSON.parse(errorText) + if (!json || typeof json !== "object") { + return {} + } + + const obj = json as Record + const errorField = obj.error + + const errorCode: string | undefined = + typeof errorField === "string" + ? errorField + : errorField && + typeof errorField === "object" && + typeof (errorField as Record).type === "string" + ? ((errorField as Record).type as string) + : undefined + + const errorDescription = obj.error_description + const errorMessageFromError = + errorField && typeof errorField === "object" ? (errorField as Record).message : undefined + + const errorMessage: string | undefined = + typeof errorDescription === "string" + ? errorDescription + : typeof errorMessageFromError === "string" + ? errorMessageFromError + : typeof obj.message === "string" + ? obj.message + : undefined + + return { errorCode, errorMessage } + } catch { + return {} + } +} + /** * Generates a cryptographically random PKCE code verifier * Must be 43-128 characters long using unreserved characters @@ -134,6 +196,11 @@ export async function exchangeCodeForTokens( const data = await response.json() const tokenResponse = tokenResponseSchema.parse(data) + if (!tokenResponse.refresh_token) { + // The access token is unusable without a refresh token for persistence. + throw new Error("Token exchange did not return a refresh_token") + } + // Calculate expiry time const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000) @@ -149,11 +216,11 @@ export async function exchangeCodeForTokens( /** * Refreshes the access token using the refresh token */ -export async function refreshAccessToken(refreshToken: string): Promise { +export async function refreshAccessToken(credentials: ClaudeCodeCredentials): Promise { const body = { grant_type: "refresh_token", client_id: CLAUDE_CODE_OAUTH_CONFIG.clientId, - refresh_token: refreshToken, + refresh_token: credentials.refresh_token, } const response = await fetch(CLAUDE_CODE_OAUTH_CONFIG.tokenEndpoint, { @@ -167,7 +234,12 @@ export async function refreshAccessToken(refreshToken: string): Promise void) | null = null + private refreshPromise: Promise | null = null private pendingAuth: { codeVerifier: string state: string server?: http.Server } | null = null + private log(message: string): void { + if (this.logFn) { + this.logFn(message) + } else { + console.log(message) + } + } + + private logError(message: string, error?: unknown): void { + const details = error instanceof Error ? error.message : error !== undefined ? String(error) : undefined + const full = details ? `${message} ${details}` : message + this.log(full) + console.error(full) + } + /** * Initialize the OAuth manager with VS Code extension context */ - initialize(context: ExtensionContext): void { + initialize(context: ExtensionContext, logFn?: (message: string) => void): void { this.context = context + this.logFn = logFn ?? null + } + + /** + * Force a refresh using the stored refresh token even if the access token is not expired. + * Useful when the server invalidates an access token early. + */ + async forceRefreshAccessToken(): Promise { + if (!this.credentials) { + await this.loadCredentials() + } + + if (!this.credentials) { + return null + } + + try { + // De-dupe concurrent refreshes + if (!this.refreshPromise) { + const prevRefreshToken = this.credentials.refresh_token + this.log(`[claude-code-oauth] Forcing token refresh (expired=${this.credentials.expired})...`) + this.refreshPromise = refreshAccessToken(this.credentials).then((newCreds) => { + const rotated = newCreds.refresh_token !== prevRefreshToken + this.log( + `[claude-code-oauth] Forced refresh response received (expires_in≈${Math.round( + (new Date(newCreds.expired).getTime() - Date.now()) / 1000, + )}s, refresh_token_rotated=${rotated})`, + ) + return newCreds + }) + } + + const newCredentials = await this.refreshPromise + this.refreshPromise = null + await this.saveCredentials(newCredentials) + this.log(`[claude-code-oauth] Forced token persisted (expired=${newCredentials.expired})`) + return newCredentials.access_token + } catch (error) { + this.refreshPromise = null + this.logError("[claude-code-oauth] Failed to force refresh token:", error) + if (error instanceof ClaudeCodeOAuthTokenError && error.isLikelyInvalidGrant()) { + this.log("[claude-code-oauth] Refresh token appears invalid; clearing stored credentials") + await this.clearCredentials() + } + return null + } } /** @@ -231,7 +366,7 @@ export class ClaudeCodeOAuthManager { this.credentials = claudeCodeCredentialsSchema.parse(parsed) return this.credentials } catch (error) { - console.error("[claude-code-oauth] Failed to load credentials:", error) + this.logError("[claude-code-oauth] Failed to load credentials:", error) return null } } @@ -279,12 +414,36 @@ export class ClaudeCodeOAuthManager { // Check if token is expired and refresh if needed if (isTokenExpired(this.credentials)) { try { - const newCredentials = await refreshAccessToken(this.credentials.refresh_token) + // De-dupe concurrent refreshes + if (!this.refreshPromise) { + this.log( + `[claude-code-oauth] Access token expired (expired=${this.credentials.expired}). Refreshing...`, + ) + const prevRefreshToken = this.credentials.refresh_token + this.refreshPromise = refreshAccessToken(this.credentials).then((newCreds) => { + const rotated = newCreds.refresh_token !== prevRefreshToken + this.log( + `[claude-code-oauth] Refresh response received (expires_in≈${Math.round( + (new Date(newCreds.expired).getTime() - Date.now()) / 1000, + )}s, refresh_token_rotated=${rotated})`, + ) + return newCreds + }) + } + + const newCredentials = await this.refreshPromise + this.refreshPromise = null await this.saveCredentials(newCredentials) + this.log(`[claude-code-oauth] Token persisted (expired=${newCredentials.expired})`) } catch (error) { - console.error("[claude-code-oauth] Failed to refresh token:", error) - // Clear invalid credentials - await this.clearCredentials() + this.refreshPromise = null + this.logError("[claude-code-oauth] Failed to refresh token:", error) + + // Only clear secrets when the refresh token is clearly invalid/revoked. + if (error instanceof ClaudeCodeOAuthTokenError && error.isLikelyInvalidGrant()) { + this.log("[claude-code-oauth] Refresh token appears invalid; clearing stored credentials") + await this.clearCredentials() + } return null } } diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts index 267d0c2183..59b50cf171 100644 --- a/src/services/skills/SkillsManager.ts +++ b/src/services/skills/SkillsManager.ts @@ -116,12 +116,43 @@ export class SkillsManager { return } + // Strict spec validation (https://agentskills.io/specification) + // Name constraints: + // - 1-64 chars + // - lowercase letters/numbers/hyphens only + // - must not start/end with hyphen + // - must not contain consecutive hyphens + if (effectiveSkillName.length < 1 || effectiveSkillName.length > 64) { + console.error( + `Skill name "${effectiveSkillName}" is invalid: name must be 1-64 characters (got ${effectiveSkillName.length})`, + ) + return + } + const nameFormat = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + if (!nameFormat.test(effectiveSkillName)) { + console.error( + `Skill name "${effectiveSkillName}" is invalid: must be lowercase letters/numbers/hyphens only (no leading/trailing hyphen, no consecutive hyphens)`, + ) + return + } + + // Description constraints: + // - 1-1024 chars + // - non-empty (after trimming) + const description = frontmatter.description.trim() + if (description.length < 1 || description.length > 1024) { + console.error( + `Skill "${effectiveSkillName}" has an invalid description length: must be 1-1024 characters (got ${description.length})`, + ) + return + } + // Create unique key combining name, source, and mode for override resolution const skillKey = this.getSkillKey(effectiveSkillName, source, mode) this.skills.set(skillKey, { name: effectiveSkillName, - description: frontmatter.description, + description, path: skillMdPath, source, mode, // undefined for generic skills, string for mode-specific diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts index e6f00e5aa4..4b6549108b 100644 --- a/src/services/skills/__tests__/SkillsManager.spec.ts +++ b/src/services/skills/__tests__/SkillsManager.spec.ts @@ -340,6 +340,121 @@ description: Name doesn't match directory expect(skills).toHaveLength(0) }) + it("should skip skills with invalid name formats (spec compliance)", async () => { + const invalidNames = [ + "PDF-processing", // uppercase + "-pdf", // leading hyphen + "pdf-", // trailing hyphen + "pdf--processing", // consecutive hyphens + ] + + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? invalidNames : [])) + + mockStat.mockImplementation(async (pathArg: string) => { + if (invalidNames.some((name) => pathArg === p(globalSkillsDir, name))) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return invalidNames.some((name) => file === p(globalSkillsDir, name, "SKILL.md")) + }) + + mockReadFile.mockImplementation(async (file: string) => { + const match = invalidNames.find((name) => file === p(globalSkillsDir, name, "SKILL.md")) + if (!match) throw new Error("File not found") + return `--- +name: ${match} +description: Invalid name format +--- + +# Invalid Skill` + }) + + await skillsManager.discoverSkills() + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(0) + }) + + it("should skip skills with name longer than 64 characters (spec compliance)", async () => { + const longName = "a".repeat(65) + const longDir = p(globalSkillsDir, longName) + const longMd = p(longDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? [longName] : [])) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === longDir) return { isDirectory: () => true } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => file === longMd) + mockReadFile.mockResolvedValue(`--- +name: ${longName} +description: Too long name +--- + +# Long Name Skill`) + + await skillsManager.discoverSkills() + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(0) + }) + + it("should skip skills with empty/whitespace-only description (spec compliance)", async () => { + const skillDir = p(globalSkillsDir, "valid-name") + const skillMd = p(skillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["valid-name"] : [])) + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === skillDir) return { isDirectory: () => true } + throw new Error("Not found") + }) + mockFileExists.mockImplementation(async (file: string) => file === skillMd) + mockReadFile.mockResolvedValue(`--- +name: valid-name +description: " " +--- + +# Empty Description`) + + await skillsManager.discoverSkills() + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(0) + }) + + it("should skip skills with too-long descriptions (spec compliance)", async () => { + const skillDir = p(globalSkillsDir, "valid-name") + const skillMd = p(skillDir, "SKILL.md") + const longDescription = "d".repeat(1025) + + mockDirectoryExists.mockImplementation(async (dir: string) => dir === globalSkillsDir) + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + mockReaddir.mockImplementation(async (dir: string) => (dir === globalSkillsDir ? ["valid-name"] : [])) + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === skillDir) return { isDirectory: () => true } + throw new Error("Not found") + }) + mockFileExists.mockImplementation(async (file: string) => file === skillMd) + mockReadFile.mockResolvedValue(`--- +name: valid-name +description: ${longDescription} +--- + +# Too Long Description`) + + await skillsManager.discoverSkills() + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(0) + }) + it("should handle symlinked skills directory", async () => { const sharedSkillDir = p(SHARED_DIR, "shared-skill") const sharedSkillMd = p(sharedSkillDir, "SKILL.md") diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 17e0fc0bab..a55719a4ac 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -329,7 +329,6 @@ export type ExtensionState = Pick< | "includeCurrentCost" | "maxGitStatusFiles" | "requestDelaySeconds" - | "filterErrorCorrectionMessages" > & { version: string clineMessages: ClineMessage[] diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 157f69109a..25393aee87 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -265,7 +265,6 @@ export interface WebviewMessage { organizationId?: string | null // For organization switching useProviderSignup?: boolean // For rooCloudSignIn to use provider signup flow autoCleanup?: AutoCleanupSettings - filterErrorCorrectionMessages?: boolean codeIndexSettings?: { // Global state settings zgsmCodebaseIndexEnabled: boolean diff --git a/src/shared/tools.ts b/src/shared/tools.ts index f58ec4a5e0..c35dc96d75 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -69,6 +69,7 @@ export const toolParamNames = [ "todos", "prompt", "image", + "line_ranges", "files", // Native protocol parameter for read_file "operations", // search_and_replace parameter for multiple operations "patch", // apply_patch parameter @@ -121,6 +122,7 @@ export type NativeToolArgs = { update_todo_list: { todos: string } use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record } write_to_file: { path: string; content: string } + list_files: { path: string; recursive?: boolean } // Add more tools as they are migrated to native protocol } diff --git a/src/utils/resolveToolProtocol.ts b/src/utils/resolveToolProtocol.ts index 7429754182..a8a575b8f2 100644 --- a/src/utils/resolveToolProtocol.ts +++ b/src/utils/resolveToolProtocol.ts @@ -44,7 +44,7 @@ export function resolveToolProtocol( return _providerSettings.toolProtocol } - // 5. Final fallback + // 3. Final fallback return _providerSettings?.apiProvider === "zgsm" ? TOOL_PROTOCOL.XML : TOOL_PROTOCOL.NATIVE } diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index ae13e80120..3632e4493b 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -45,6 +45,7 @@ import { BatchFilePermission } from "./BatchFilePermission" import { BatchDiffApproval } from "./BatchDiffApproval" import { ProgressIndicator } from "./ProgressIndicator" import { Markdown } from "./Markdown" +import { CollapsibleMarkdownBlock } from "./CollapsibleMarkdownBlock" import { CommandExecution } from "./CommandExecution" import { CommandExecutionError } from "./CommandExecutionError" import { AutoApprovedRequestLimitWarning } from "./AutoApprovedRequestLimitWarning" @@ -422,6 +423,8 @@ export const ChatRowContent = ({ )} , ] + case "api_req_rate_limit_wait": + return [] case "api_req_retry_delayed": return [] case "api_req_started": @@ -436,8 +439,10 @@ export const ChatRowContent = ({ getIconSpan("arrow-swap", normalColor) ) : apiRequestFailedMessage ? ( getIconSpan("error", errorColor) - ) : ( + ) : isLast ? ( + ) : ( + getIconSpan("arrow-swap", normalColor) ), apiReqCancelReason !== null && apiReqCancelReason !== undefined ? ( apiReqCancelReason === "user_cancelled" ? ( @@ -484,13 +489,13 @@ export const ChatRowContent = ({ type, isCommandExecuting, t, - message.text, - message.ts, isMcpServerResponding, reviewTask.status, apiReqCancelReason, cost, apiRequestFailedMessage, + message, + isLast, ]) const headerStyle: React.CSSProperties = { @@ -1173,6 +1178,15 @@ export const ChatRowContent = ({ switch (message.type) { case "say": switch (message.say) { + case "rollback_xml_tool": + return ( + + ) case "diff_error": return ( - + @@ -1455,6 +1469,35 @@ export const ChatRowContent = ({ } /> ) + case "api_req_rate_limit_wait": { + const isWaiting = message.partial === true + + const waitSeconds = (() => { + if (!message.text) return undefined + try { + const data = JSON.parse(message.text) + return typeof data.seconds === "number" ? data.seconds : undefined + } catch { + return undefined + } + })() + + return isWaiting && waitSeconds !== undefined ? ( +
+
+ + {t("chat:apiRequest.rateLimitWait")} +
+ {waitSeconds}s +
+ ) : null + } case "api_req_finished": return null // we should never see this message type case "text": @@ -1633,7 +1676,7 @@ export const ChatRowContent = ({ } // Fallback for generic errors - return + return case "completion_result": return ( <> @@ -1844,7 +1887,7 @@ export const ChatRowContent = ({ case "ask": switch (message.ask) { case "mistake_limit_reached": - return + return case "command": return ( (false) const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [isAtBottom, setIsAtBottom] = useState(false) + const userExpandingRef = useRef(false) const lastTtsRef = useRef("") const [wasStreaming, setWasStreaming] = useState(false) const [checkpointWarning, setCheckpointWarning] = useState< @@ -429,6 +431,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction 1) { if ( - lastMessage.text && // has text + typeof lastMessage.text === "string" && // has text (must be string for startsWith) (lastMessage.say === "text" || lastMessage.say === "completion_result") && // is a text message !lastMessage.partial && // not a partial message !lastMessage.text.startsWith("{") // not a json object @@ -1206,15 +1210,32 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + // Mark that user is actively expanding/collapsing content + userExpandingRef.current = true + // Immediately disable sticky follow to prevent Virtuoso from auto-scrolling + stickyFollowRef.current = false handleSetExpandedRow(ts) // The logic to set disableAutoScrollRef.current = true on expansion // is now handled by the useEffect hook that observes expandedRows. + + // Clear the flag after content has had time to render and settle + // Increased timeout to handle large content blocks + setTimeout(() => { + userExpandingRef.current = false + }, 1000) }, [handleSetExpandedRow], ) const handleRowHeightChange = useCallback( (isTaller: boolean) => { + // Don't auto-scroll if the user is actively expanding/collapsing content + // This prevents scroll conflicts when user manually expands the last message + // or expands Markdown content + if (userExpandingRef.current || markdownExpandingRef.current) { + return + } + if (isAtBottom) { if (isTaller) { scrollToBottomSmooth() @@ -1649,7 +1670,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction isAtBottom || stickyFollowRef.current} + followOutput={(isAtBottom: boolean) => { + // Disable auto-scrolling when user is manually expanding/collapsing content + // This prevents scroll jumping when expanding the last message or Markdown content + if (userExpandingRef.current || markdownExpandingRef.current) { + return false + } + return isAtBottom || stickyFollowRef.current + }} atBottomStateChange={(isAtBottom: boolean) => { setIsAtBottom(isAtBottom) // Only show the scroll-to-bottom button if not at bottom diff --git a/webview-ui/src/components/chat/CollapsibleMarkdownBlock.tsx b/webview-ui/src/components/chat/CollapsibleMarkdownBlock.tsx new file mode 100644 index 0000000000..e381dd46fe --- /dev/null +++ b/webview-ui/src/components/chat/CollapsibleMarkdownBlock.tsx @@ -0,0 +1,78 @@ +import { memo, useState, useRef, useEffect } from "react" +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" + +import { StandardTooltip } from "@src/components/ui" + +import MarkdownBlock from "../common/MarkdownBlock" + +const MAX_COLLAPSED_HEIGHT = 300 + +interface CollapsibleMarkdownBlockProps { + markdown?: string +} + +export const CollapsibleMarkdownBlock = memo(({ markdown }: CollapsibleMarkdownBlockProps) => { + const [isHovering, setIsHovering] = useState(false) + const [isExpanded, setIsExpanded] = useState(false) + const [showExpandButton, setShowExpandButton] = useState(false) + const contentRef = useRef(null) + + useEffect(() => { + if (contentRef.current) { + const contentHeight = contentRef.current.scrollHeight + setShowExpandButton(contentHeight > MAX_COLLAPSED_HEIGHT) + } + }, [markdown]) + + if (!markdown || markdown.length === 0) { + return null + } + + return ( +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + style={{ position: "relative" }}> + {isHovering && showExpandButton && ( +
+ + + + setIsExpanded(!isExpanded)}> + + + +
+ )} + +
+ +
+
+ ) +}) diff --git a/webview-ui/src/components/chat/ErrorRow.tsx b/webview-ui/src/components/chat/ErrorRow.tsx index e1cfa6bed3..81c421d0d0 100644 --- a/webview-ui/src/components/chat/ErrorRow.tsx +++ b/webview-ui/src/components/chat/ErrorRow.tsx @@ -9,6 +9,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from " import { Button } from "../ui" import { useExtensionState } from "@src/context/ExtensionStateContext" import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" +import { ProgressIndicator } from "./ProgressIndicator" /** * Unified error display component for all error types in the chat. @@ -54,12 +55,14 @@ export interface ErrorRowProps { | "api_failure" | "diff_error" | "streaming_failed" + | "rollback_xml_tool" | "cancelled" | "api_req_retry_delayed" title?: string message: string showCopyButton?: boolean expandable?: boolean + isLast?: boolean defaultExpanded?: boolean additionalContent?: React.ReactNode headerClassName?: string @@ -80,6 +83,7 @@ export const ErrorRow = memo( showCopyButton = false, expandable = false, defaultExpanded = false, + isLast = false, additionalContent, headerClassName, messageClassName, @@ -191,6 +195,48 @@ export const ErrorRow = memo( const errorTitle = getDefaultTitle() + // For rollback_xml_tool type with expandable content + if (type === "rollback_xml_tool" && expandable) { + return ( +
+
+
+ {isLast && } + + {"🪄 CoStrict Auto Switch ToolProtocol: NATIVE -> XML"} + +
+ {showCopyButton && ( + + + + )} + +
+
+ {isExpanded && ( +
+
+ +
+
+ )} +
+
+ ) + } + // For diff_error type with expandable content if (type === "diff_error" && expandable) { return ( diff --git a/webview-ui/src/components/chat/Markdown.tsx b/webview-ui/src/components/chat/Markdown.tsx index 87780d5df8..3d5947f427 100644 --- a/webview-ui/src/components/chat/Markdown.tsx +++ b/webview-ui/src/components/chat/Markdown.tsx @@ -1,4 +1,4 @@ -import { memo, useState } from "react" +import { memo, useState, useRef, useEffect } from "react" import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import { useCopyToClipboard } from "@src/utils/clipboard" @@ -6,12 +6,28 @@ import { StandardTooltip } from "@src/components/ui" import MarkdownBlock from "../common/MarkdownBlock" +const MAX_COLLAPSED_HEIGHT = 400 + +// Global flag to prevent auto-scrolling when expanding markdown content +// This is set to true when user clicks expand button and cleared after content settles +export const markdownExpandingRef = { current: false } + export const Markdown = memo(({ markdown, partial }: { markdown?: string; partial?: boolean }) => { const [isHovering, setIsHovering] = useState(false) + const [isExpanded, setIsExpanded] = useState(false) + const [showExpandButton, setShowExpandButton] = useState(false) + const contentRef = useRef(null) // Shorter feedback duration for copy button flash. const { copyWithFeedback } = useCopyToClipboard(200) + useEffect(() => { + if (contentRef.current && !partial) { + const contentHeight = contentRef.current.scrollHeight + setShowExpandButton(contentHeight > MAX_COLLAPSED_HEIGHT) + } + }, [markdown, partial]) + if (!markdown || markdown.length === 0) { return null } @@ -21,47 +37,86 @@ export const Markdown = memo(({ markdown, partial }: { markdown?: string; partia onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} style={{ position: "relative" }}> -
- -
- {markdown && !partial && isHovering && ( + {isHovering && (
- - { - const success = await copyWithFeedback(markdown) - if (success) { - const button = document.activeElement as HTMLElement - if (button) { - button.style.background = "var(--vscode-button-background)" - setTimeout(() => { - button.style.background = "" - }, 200) + + {showExpandButton && !partial && ( + + { + // Set global flag to prevent auto-scrolling during expansion + markdownExpandingRef.current = true + setIsExpanded(!isExpanded) + // Clear flag after content has rendered and settled + setTimeout(() => { + markdownExpandingRef.current = false + }, 1000) + }}> + + + + )} + + {markdown && !partial && ( + + { + const success = await copyWithFeedback(markdown) + if (success) { + const button = document.activeElement as HTMLElement + if (button) { + button.style.background = "var(--vscode-button-background)" + setTimeout(() => { + button.style.background = "" + }, 200) + } } - } - }}> - - - + }}> + + + + )}
)} + +
+ +
) }) diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.rate-limit-wait.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.rate-limit-wait.spec.tsx new file mode 100644 index 0000000000..d8ef8fad20 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatRow.rate-limit-wait.spec.tsx @@ -0,0 +1,78 @@ +import React from "react" + +import { render, screen } from "@/utils/test-utils" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" +import { ChatRowContent } from "../ChatRow" + +// Mock i18n +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const map: Record = { + "chat:apiRequest.rateLimitWait": "Rate limiting", + } + return map[key] ?? key + }, + }), + Trans: ({ children }: { children?: React.ReactNode }) => <>{children}, + initReactI18next: { type: "3rdParty", init: () => {} }, +})) + +const queryClient = new QueryClient() + +function renderChatRow(message: any) { + return render( + + + {}} + onSuggestionClick={() => {}} + onBatchFileResponse={() => {}} + onFollowUpUnmount={() => {}} + isFollowUpAnswered={false} + /> + + , + ) +} + +describe("ChatRow - rate limit wait", () => { + it("renders a non-error progress row for api_req_rate_limit_wait", () => { + const message: any = { + type: "say", + say: "api_req_rate_limit_wait", + ts: Date.now(), + partial: true, + text: JSON.stringify({ seconds: 1 }), + } + + renderChatRow(message) + + expect(screen.getByText("Rate limiting")).toBeInTheDocument() + // Should show countdown, but should NOT show the error-details affordance. + expect(screen.getByText("1s")).toBeInTheDocument() + expect(screen.queryByText("Details")).toBeNull() + }) + + it("renders nothing when rate limit wait is complete", () => { + const message: any = { + type: "say", + say: "api_req_rate_limit_wait", + ts: Date.now(), + partial: false, + text: undefined, + } + + const { container } = renderChatRow(message) + + // The row should be hidden when rate limiting is complete + expect(screen.queryByText("Rate limiting")).toBeNull() + // Nothing should be rendered + expect(container.firstChild).toBeNull() + }) +}) diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index 0a95e5fa68..8a3da5cf1e 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -17,7 +17,6 @@ type ContextManagementSettingsProps = HTMLAttributes & { zgsmCodebaseIndexEnabled: boolean autoCondenseContext: boolean autoCondenseContextPercent: number - filterErrorCorrectionMessages?: boolean listApiConfigMeta: any[] maxOpenTabsContext: number maxWorkspaceFiles: number @@ -37,7 +36,6 @@ type ContextManagementSettingsProps = HTMLAttributes & { setCachedStateField: SetCachedStateField< | "autoCondenseContext" | "autoCondenseContextPercent" - | "filterErrorCorrectionMessages" | "maxOpenTabsContext" | "maxWorkspaceFiles" | "showRooIgnoredFiles" @@ -60,7 +58,6 @@ export const ContextManagementSettings = ({ zgsmCodebaseIndexEnabled, autoCondenseContext, autoCondenseContextPercent, - filterErrorCorrectionMessages, listApiConfigMeta, maxOpenTabsContext, maxWorkspaceFiles, @@ -517,23 +514,6 @@ export const ContextManagementSettings = ({ : t("settings:contextManagement.condensingThreshold.profileDescription")} - - {/* Error Correction Filtering */} -
- - setCachedStateField("filterErrorCorrectionMessages", e.target.checked) - } - data-testid="filter-error-correction-checkbox"> - - {t("settings:contextManagement.filterErrorCorrection.name")} - - -
- {t("settings:contextManagement.filterErrorCorrection.description")} -
-
)} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 43cd2ee4b1..98a6686f69 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -166,7 +166,6 @@ const SettingsView = forwardRef(({ onDone, t alwaysAllowWriteProtected, autoCondenseContext, autoCondenseContextPercent, - filterErrorCorrectionMessages, browserToolEnabled, browserViewportSize, enableCheckpoints, @@ -377,7 +376,6 @@ const SettingsView = forwardRef(({ onDone, t allowedMaxCost: allowedMaxCost ?? null, autoCondenseContext, autoCondenseContextPercent, - filterErrorCorrectionMessages: filterErrorCorrectionMessages ?? false, browserToolEnabled: browserToolEnabled ?? true, soundEnabled: soundEnabled ?? true, soundVolume: soundVolume ?? 0.5, @@ -821,7 +819,6 @@ const SettingsView = forwardRef(({ onDone, t void - filterErrorCorrectionMessages?: boolean - setFilterErrorCorrectionMessages: (value: boolean) => void includeDiagnosticMessages?: boolean setIncludeDiagnosticMessages: (value: boolean) => void maxDiagnosticMessages?: number @@ -213,7 +211,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode apiConfiguration: {}, version: "", autoCleanup: DEFAULT_AUTO_CLEANUP_SETTINGS, - filterErrorCorrectionMessages: false, // 默认禁用错误纠正消息过滤 clineMessages: [], taskHistory: [], shouldShowAnnouncement: false, @@ -673,9 +670,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setCondensingApiConfigId: (value) => setState((prevState) => ({ ...prevState, condensingApiConfigId: value })), autoCleanup: state.autoCleanup ?? DEFAULT_AUTO_CLEANUP_SETTINGS, setAutoCleanup: (value) => setState((prevState) => ({ ...prevState, autoCleanup: value })), - filterErrorCorrectionMessages: state.filterErrorCorrectionMessages ?? false, - setFilterErrorCorrectionMessages: (value) => - setState((prevState) => ({ ...prevState, filterErrorCorrectionMessages: value })), setCustomCondensingPrompt: (value) => setState((prevState) => ({ ...prevState, customCondensingPrompt: value })), setProfileThresholds: (value) => setState((prevState) => ({ ...prevState, profileThresholds: value })), diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 09251543bc..f1827756b4 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -153,6 +153,7 @@ "streaming": "API Request...", "cancelled": "API Request Cancelled", "streamingFailed": "API Streaming Failed", + "rateLimitWait": "Rate limiting", "errorTitle": "Provider Error {{code}}", "errorMessage": { "docs": "Docs", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 230d02b8ef..12f96010c0 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -722,10 +722,6 @@ "inheritDescription": "This profile inherits the global default threshold ({{threshold}}%)", "usesGlobal": "(uses global {{threshold}}%)" }, - "filterErrorCorrection": { - "name": "Filter error-correction message pairs", - "description": "When enabled, the system will automatically filter out model errors (like failed tool usage) and their correction responses from the context after the model successfully corrects itself. This reduces token usage and noise in the conversation history." - }, "includeCurrentTime": { "label": "Include current time in context", "description": "When enabled, the current time and timezone information will be included in the system prompt. Disable this if models are stopping work due to time concerns." diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 678c5a4e2c..a4142775b5 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -147,6 +147,7 @@ "streaming": "API请求...", "cancelled": "API请求已取消", "streamingFailed": "API流式传输失败", + "rateLimitWait": "请求频率限制", "errorTitle": "提供商错误 {{code}}", "errorMessage": { "docs": "文档", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 629fe89156..e7c568c1f5 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -665,10 +665,6 @@ "inheritDescription": "此配置文件继承全局默认阈值({{threshold}}%)", "usesGlobal": "(使用全局 {{threshold}}%)" }, - "filterErrorCorrection": { - "name": "过滤错误纠正消息对", - "description": "启用后,系统会在模型成功自我纠正后,自动从上下文中过滤掉模型错误(如工具使用失败)及其纠正响应。这可以减少 token 使用量和对话历史中的噪音。" - }, "maxImageFileSize": { "label": "最大图像文件大小", "mb": "MB", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 6382f32088..88a07b670d 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -153,6 +153,7 @@ "streaming": "正在處理 API 請求...", "cancelled": "API 請求已取消", "streamingFailed": "API 串流處理失敗", + "rateLimitWait": "速率限制", "errorTitle": "提供商錯誤 {{code}}", "errorMessage": { "docs": "文件", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 1df71c8c0a..7ef7beb79e 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -665,10 +665,6 @@ "inheritDescription": "此檔案繼承全域預設閾值({{threshold}}%)", "usesGlobal": "(使用全域 {{threshold}}%)" }, - "filterErrorCorrection": { - "name": "過濾錯誤糾正訊息對", - "description": "啟用後,系統會在模型成功自我糾正後,自動從上下文中過濾掉模型錯誤(如工具使用失敗)及其糾正回應。這可以減少 token 使用量和對話歷史中的噪音。" - }, "maxImageFileSize": { "label": "最大圖片檔案大小", "mb": "MB",