diff --git a/src/api/providers/__tests__/anthropic-vertex.spec.ts b/src/api/providers/__tests__/anthropic-vertex.spec.ts index 1a1e3c87c5..37e7ed3cac 100644 --- a/src/api/providers/__tests__/anthropic-vertex.spec.ts +++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts @@ -1203,8 +1203,8 @@ describe("VertexHandler", () => { ) }) - it("should include tools even when toolProtocol is set to xml (user preference now ignored)", async () => { - // XML protocol deprecation: user preference is now ignored when model supports native tools + it("should not include tools when toolProtocol is set to xml (user preference takes precedence)", async () => { + // When toolProtocol is set to xml, user preference takes precedence over model capabilities handler = new AnthropicVertexHandler({ apiModelId: "claude-3-5-sonnet-v2@20241022", vertexProjectId: "test-project", @@ -1245,14 +1245,10 @@ describe("VertexHandler", () => { // Just consume } - // Native is forced when supportsNativeTools===true, so tools should still be included + // XML protocol means no tools should be included in the request expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - name: "get_weather", - }), - ]), + expect.not.objectContaining({ + tools: expect.anything(), }), { signal: undefined }, ) diff --git a/src/api/providers/utils/response-render-config.ts b/src/api/providers/utils/response-render-config.ts index a9c8724158..80a013a61d 100644 --- a/src/api/providers/utils/response-render-config.ts +++ b/src/api/providers/utils/response-render-config.ts @@ -7,15 +7,15 @@ export const renderModes = { interval: 10, }, fast: { - limit: 1, + limit: 0, interval: 25, }, medium: { - limit: 5, + limit: 0, interval: 50, }, slow: { - limit: 10, + limit: 0, interval: 100, }, } diff --git a/src/api/providers/zgsm.ts b/src/api/providers/zgsm.ts index 683da01291..c831945c27 100644 --- a/src/api/providers/zgsm.ts +++ b/src/api/providers/zgsm.ts @@ -36,8 +36,10 @@ import { handleOpenAIError } from "./utils/openai-error-handler" import { getModels } from "./fetchers/modelCache" import { ClineApiReqCancelReason } from "../../shared/ExtensionMessage" import { getEditorType } from "../../utils/getEditorType" +import { ChatCompletionChunk } from "openai/resources/index.mjs" const autoModeModelId = "Auto" +const isDev = process.env.NODE_ENV === "development" export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions @@ -153,115 +155,134 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl ) } - // 5. Handle streaming and non-streaming requests - if (this.options.openAiStreamingEnabled ?? true) { - const convertedMessages = this.convertMessages( - systemPrompt, - messages, - deepseekReasoner, - ark, - enabledLegacyFormat, - modelInfo, - ) + try { + // 5. Handle streaming and non-streaming requests + if (this.options.openAiStreamingEnabled ?? true) { + const convertedMessages = this.convertMessages( + systemPrompt, + messages, + deepseekReasoner, + ark, + enabledLegacyFormat, + modelInfo, + ) - const requestOptions = this.buildStreamingRequestOptions( - modelId, - convertedMessages, - deepseekReasoner, - isGrokXAI, - reasoning, - modelInfo, - metadata, - ) - requestOptions.extra_body.prompt_mode = fromWorkflow ? (metadata?.zgsmCodeMode ?? "vibe") : "vibe" - const isAuto = this.options.zgsmModelId === autoModeModelId - let stream: any - let selectedLLM: string | undefined = this.options.zgsmModelId - let selectReason: string | undefined - try { - this.logger.info(`[RequestID ${modelId}]:`, requestId) + const requestOptions = this.buildStreamingRequestOptions( + modelId, + convertedMessages, + deepseekReasoner, + isGrokXAI, + reasoning, + modelInfo, + metadata, + ) + requestOptions.extra_body.prompt_mode = fromWorkflow ? (metadata?.zgsmCodeMode ?? "vibe") : "vibe" + const isAuto = this.options.zgsmModelId === autoModeModelId + let stream: any + let selectedLLM: string | undefined = this.options.zgsmModelId + let selectReason: string | undefined + try { + this.logger.info(`[RequestID ${modelId}]:`, requestId) + + if (metadata?.onRequestHeadersReady && typeof metadata.onRequestHeadersReady === "function") { + metadata.onRequestHeadersReady(_headers) + } - if (metadata?.onRequestHeadersReady && typeof metadata.onRequestHeadersReady === "function") { - metadata.onRequestHeadersReady(_headers) + const { data, response } = await this.client.chat.completions + .create( + requestOptions, + Object.assign(isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, { + headers: _headers, + signal: this.abortController.signal, + }), + ) + .withResponse() + this.logger.info(`[ResponseID ${modelId}]:`, response.headers.get("x-request-id")) + if (isAuto) { + selectedLLM = response.headers.get("x-select-llm") || "" + selectReason = response.headers.get("x-select-reason") || "" + + const userInputHeader = isDev ? response.headers.get("x-user-input") : null + if (userInputHeader) { + const decodedUserInput = Buffer.from(userInputHeader, "base64").toString("utf-8") + this.logger.info(`[x-user-input]: ${decodedUserInput}`) + } + } + + stream = data + } catch (error) { + throw handleOpenAIError(error, this.providerName) } - const { data, response } = await this.client.chat.completions - .create( + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + isDev && this.logger.info(`[ResponseID ${modelId} sse render start]:`, requestId) + + // 6. Optimize stream processing - use batch processing and buffer + yield* this.handleOptimizedStream( + stream, + modelInfo, + isAuto, + selectedLLM, + selectReason, + requestId, + metadata?.toolProtocol === "native", + ) + } else { + // Non-streaming processing + const requestOptions = this.buildNonStreamingRequestOptions( + modelId, + systemPrompt, + messages, + deepseekReasoner, + enabledLegacyFormat, + modelInfo, + metadata, + ) + let response + requestOptions.extra_body.prompt_mode = fromWorkflow ? "strict" : "vibe" + try { + this.logger.info(`[RequestID]:`, requestId) + response = await this.client.chat.completions.create( requestOptions, Object.assign(isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, { headers: _headers, signal: this.abortController.signal, }), ) - .withResponse() - this.logger.info(`[ResponseID ${modelId}]:`, response.headers.get("x-request-id")) - if (isAuto) { - selectedLLM = response.headers.get("x-select-llm") || "" - selectReason = response.headers.get("x-select-reason") || "" - const isDev = process.env.NODE_ENV === "development" - - const userInputHeader = isDev ? response.headers.get("x-user-input") : null - if (userInputHeader) { - const decodedUserInput = Buffer.from(userInputHeader, "base64").toString("utf-8") - this.logger.info(`[x-user-input]: ${decodedUserInput}`) - } + this.logger.info(`[ResponseId]:`, response._request_id) + } catch (error) { + throw handleOpenAIError(error, this.providerName) } - stream = data - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - - // 6. Optimize stream processing - use batch processing and buffer - yield* this.handleOptimizedStream(stream, modelInfo, isAuto, selectedLLM, selectReason) - } else { - // Non-streaming processing - const requestOptions = this.buildNonStreamingRequestOptions( - modelId, - systemPrompt, - messages, - deepseekReasoner, - enabledLegacyFormat, - modelInfo, - metadata, - ) - let response - requestOptions.extra_body.prompt_mode = fromWorkflow ? "strict" : "vibe" - try { - this.logger.info(`[RequestID]:`, requestId) - response = await this.client.chat.completions.create( - requestOptions, - Object.assign(isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, { - headers: _headers, - signal: this.abortController.signal, - }), - ) - this.logger.info(`[ResponseId]:`, response._request_id) - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - - const message = response.choices?.[0]?.message - - if (message?.tool_calls) { - for (const toolCall of message.tool_calls) { - if (toolCall.type === "function") { - yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.function.name, - arguments: toolCall.function.arguments, + const message = response.choices?.[0]?.message + + if (message?.tool_calls) { + for (const toolCall of message.tool_calls) { + if (toolCall.type === "function") { + yield { + type: "tool_call", + id: toolCall.id, + name: toolCall.function.name, + arguments: toolCall.function.arguments, + } } } } - } - yield { - type: "text", - text: message.content || "", - } + yield { + type: "text", + text: message.content || "", + } - yield this.processUsageMetrics(response.usage, modelInfo) + yield this.processUsageMetrics(response.usage, modelInfo) + } + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + isDev && this.logger.error(`[createMessage] ${err}`) + throw err + } finally { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + isDev && this.logger.info(`[ResponseID ${modelId} sse createMessage end]:`, requestId) } } @@ -452,6 +473,8 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl isAuto?: boolean, selectedLLM?: string, selectReason?: string, + requestId?: string, + isNative?: boolean, ): ApiStream { const matcher = new XmlMatcher( "think", @@ -469,7 +492,6 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl const contentBuffer: string[] = [] let time = Date.now() let isPrinted = false - const isDev = process.env.NODE_ENV === "development" // Yield selected LLM info if available (for Auto model mode) if (isAuto) { @@ -481,10 +503,20 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl } } + const lastDeltaInfo = { + activeToolCallIds, + } as { + delta?: ChatCompletionChunk.Choice["delta"] + finishReason?: ChatCompletionChunk.Choice["finish_reason"] + activeToolCallIds?: Set + } + // chunk for await (const chunk of stream) { const delta = chunk.choices?.[0]?.delta ?? {} const finishReason = chunk.choices?.[0]?.finish_reason + lastDeltaInfo.finishReason = finishReason + lastDeltaInfo.delta = delta // Cache content for batch processing if (delta.content) { contentBuffer.push(delta.content) @@ -503,6 +535,13 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl yield processedChunk } contentBuffer.length = 0 // Clear buffer + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + isDev && + this.logger.info( + `[ResponseID ${this.options.zgsmModelId} sse rendering]:`, + requestId, + batchedContent, + ) time = now } } @@ -515,7 +554,7 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl } } - yield* this.processToolCalls(delta, finishReason, activeToolCallIds) + yield* this.processToolCalls(delta, finishReason, activeToolCallIds, contentBuffer.length > 0) // Cache usage information if (chunk.usage) { @@ -526,9 +565,18 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl // Process remaining content if (contentBuffer.length > 0) { const remainingContent = contentBuffer.join("") + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + isDev && + this.logger.info( + `[ResponseID ${this.options.zgsmModelId} sse render end]:`, + requestId, + remainingContent, + ) for (const processedChunk of matcher.update(remainingContent)) { yield processedChunk } + contentBuffer.length = 0 // Clear buffer + yield* this.processToolCalls(lastDeltaInfo.delta, lastDeltaInfo.finishReason, activeToolCallIds) } // Output final results @@ -553,10 +601,15 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl delta: OpenAI.Chat.Completions.ChatCompletionChunk.Choice.Delta | undefined, finishReason: string | null | undefined, activeToolCallIds: Set, + skip: boolean = false, ): Generator< | { type: "tool_call_partial"; index: number; id?: string; name?: string; arguments?: string } | { type: "tool_call_end"; id: string } > { + if (skip) { + return + } + if (delta?.tool_calls) { for (const toolCall of delta.tool_calls) { if (toolCall.id) { @@ -607,9 +660,9 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl const defaultInfo = this.modelInfo const info = this.options.useZgsmCustomConfig ? { - ...NATIVE_TOOL_DEFAULTS, - ...(this.options.zgsmAiCustomModelInfo ?? defaultInfo) - } + ...NATIVE_TOOL_DEFAULTS, + ...(this.options.zgsmAiCustomModelInfo ?? defaultInfo), + } : defaultInfo const params = getModelParams({ format: "zgsm", modelId: id, model: info, settings: this.options }) return { id, info, ...params } diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 054c701448..df6614090d 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -1,6 +1,6 @@ import path from "path" import type { FileEntry, LineRange } from "@roo-code/types" -import { isNativeProtocol, ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types" +import { isNativeProtocol, ANTHROPIC_DEFAULT_MAX_TOKENS, DEFAULT_FILE_READ_CHARACTER_LIMIT } from "@roo-code/types" import { Task } from "../task/Task" import { ClineSayTool } from "../../shared/ExtensionMessage" @@ -330,10 +330,23 @@ export class ReadFileTool extends BaseTool<"read_file"> { const state = await task.providerRef.deref()?.getState() const { maxReadFileLine = -1, + maxReadCharacterLimit = DEFAULT_FILE_READ_CHARACTER_LIMIT, maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, } = state ?? {} + // Calculate per-file limits when reading multiple files + // Similar to mentions/index.ts strategy to prevent context window overflow + const approvedFilesCount = fileResults.filter((f) => f.status === "approved").length + const [perFileMaxLine, perFileMaxChar] = [ + maxReadFileLine > 0 && approvedFilesCount > 1 + ? Math.max(250, Math.ceil(maxReadFileLine / approvedFilesCount)) + : maxReadFileLine, + maxReadCharacterLimit > 0 && approvedFilesCount > 1 + ? Math.max(20_000, Math.ceil(maxReadCharacterLimit / approvedFilesCount)) + : maxReadCharacterLimit, + ] + for (const fileResult of fileResults) { if (fileResult.status !== "approved") continue @@ -435,7 +448,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { continue } } - + // todo: 截断文件内容 保证不要超过限制 if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { const rangeResults: string[] = [] const nativeRangeResults: string[] = [] @@ -482,11 +495,11 @@ export class ReadFileTool extends BaseTool<"read_file"> { continue } - if (maxReadFileLine > 0 && totalLines > maxReadFileLine) { - const content = addLineNumbers(await readLines(fullPath, maxReadFileLine - 1, 0)) - const lineRangeAttr = ` lines="1-${maxReadFileLine}"` + if (perFileMaxLine > 0 && totalLines > perFileMaxLine) { + const content = addLineNumbers(await readLines(fullPath, perFileMaxLine - 1, 0)) + const lineRangeAttr = ` lines="1-${perFileMaxLine}"` let xmlInfo = `\n${content}\n` - let nativeInfo = `Lines 1-${maxReadFileLine}:\n${content}\n` + let nativeInfo = `Lines 1-${perFileMaxLine}:\n${content}\n` try { const defResult = await parseSourceCodeDefinitionsForFile( @@ -494,12 +507,12 @@ export class ReadFileTool extends BaseTool<"read_file"> { task.rooIgnoreController, ) if (defResult) { - const truncatedDefs = truncateDefinitionsToLineLimit(defResult, maxReadFileLine) + const truncatedDefs = truncateDefinitionsToLineLimit(defResult, perFileMaxLine) xmlInfo += `${truncatedDefs}\n` nativeInfo += `\nCode Definitions:\n${truncatedDefs}\n` } - const notice = `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines` + const notice = `Showing only ${perFileMaxLine} of ${totalLines} total lines. Use line_range if you need to read more lines` xmlInfo += `${notice}\n` nativeInfo += `\nNote: ${notice}` @@ -552,7 +565,16 @@ export class ReadFileTool extends BaseTool<"read_file"> { content = addLineNumbers(result.content) - if (!result.complete) { + // Apply character limit if specified and content exceeds it + if (perFileMaxChar > 0 && content.length > perFileMaxChar) { + const truncated = content.substring(0, perFileMaxChar) + const truncatedLines = truncated.split("\n").length + content = truncated + const charLimitNotice = `Content truncated to ${perFileMaxChar} characters (${truncatedLines} lines). Use line_range to read specific sections.` + const lineRangeAttr = truncatedLines > 0 ? ` lines="1-${truncatedLines}"` : "" + xmlInfo = `\n${content}\n${charLimitNotice}\n` + nativeInfo = `Lines 1-${truncatedLines}:\n${content}\n\nNote: ${charLimitNotice}` + } else if (!result.complete) { // File was truncated const notice = `File truncated: showing ${result.lineCount} lines (${result.tokenCount} tokens) due to context budget. Use line_range to read specific sections.` const lineRangeAttr = result.lineCount > 0 ? ` lines="1-${result.lineCount}"` : "" @@ -565,18 +587,28 @@ export class ReadFileTool extends BaseTool<"read_file"> { ? `Lines 1-${result.lineCount}:\n${content}\n\nNote: ${notice}` : `Note: ${notice}` } else { - // Full file read - const lineRangeAttr = ` lines="1-${result.lineCount}"` - xmlInfo = - result.lineCount > 0 - ? `\n${content}\n` - : `` - - if (result.lineCount === 0) { - xmlInfo += `File is empty\n` - nativeInfo = "Note: File is empty" + // Full file read - also check character limit + if (perFileMaxChar > 0 && content.length > perFileMaxChar) { + const truncated = content.substring(0, perFileMaxChar) + const truncatedLines = truncated.split("\n").length + content = truncated + const charLimitNotice = `Content truncated to ${perFileMaxChar} characters (${truncatedLines} lines).` + const lineRangeAttr = truncatedLines > 0 ? ` lines="1-${truncatedLines}"` : "" + xmlInfo = `\n${content}\n${charLimitNotice}\n` + nativeInfo = `Lines 1-${truncatedLines}:\n${content}\n\nNote: ${charLimitNotice}` } else { - nativeInfo = `Lines 1-${result.lineCount}:\n${content}` + const lineRangeAttr = ` lines="1-${result.lineCount}"` + xmlInfo = + result.lineCount > 0 + ? `\n${content}\n` + : `` + + if (result.lineCount === 0) { + xmlInfo += `File is empty\n` + nativeInfo = "Note: File is empty" + } else { + nativeInfo = `Lines 1-${result.lineCount}:\n${content}` + } } } } diff --git a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts index f876cf9211..f41b38bfe4 100644 --- a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts @@ -85,6 +85,7 @@ describe("applyDiffTool experiment routing", () => { deref: vi.fn().mockReturnValue(mockProvider), }, cwd: "/test", + taskToolProtocol: "native", // Set task-level protocol to native diffStrategy: { applyDiff: vi.fn(), getProgressStatus: vi.fn(), @@ -102,7 +103,7 @@ describe("applyDiffTool experiment routing", () => { maxTokens: 4096, contextWindow: 128000, supportsPromptCache: false, - supportsNativeTools: false, + supportsNativeTools: true, }, }), }, diff --git a/src/core/tools/__tests__/multiApplyDiffTool.spec.ts b/src/core/tools/__tests__/multiApplyDiffTool.spec.ts index 0910550dd8..0466ef6df4 100644 --- a/src/core/tools/__tests__/multiApplyDiffTool.spec.ts +++ b/src/core/tools/__tests__/multiApplyDiffTool.spec.ts @@ -122,6 +122,11 @@ describe("multiApplyDiffTool", () => { }) describe("Native protocol delegation", () => { + beforeEach(() => { + // Set up native protocol for this test suite + mockCline.taskToolProtocol = "native" + }) + it("should delegate to applyDiffToolClass.handle for XML args format", async () => { mockBlock = { params: { @@ -304,6 +309,11 @@ new content }) describe("Edge cases for diff content", () => { + beforeEach(() => { + // Set up native protocol for this test suite + mockCline.taskToolProtocol = "native" + }) + it("should handle empty diff by delegating to class-based tool", async () => { mockBlock = { params: { diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index d8b53c39cb..49f94ca566 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -238,7 +238,10 @@ function createMockCline(): any { }), apiConfiguration: { apiProvider: "anthropic", + toolProtocol: "native", }, + // Set taskToolProtocol to ensure native protocol is used + taskToolProtocol: "native", // CRITICAL: Always ensure image support is enabled api: { getModel: vi.fn().mockReturnValue({ @@ -248,7 +251,7 @@ function createMockCline(): any { contextWindow: 200000, maxTokens: 4096, supportsPromptCache: false, - supportsNativeTools: false, + supportsNativeTools: true, }, }), }, @@ -336,6 +339,7 @@ describe("read_file tool with maxReadFileLine setting", () => { path?: string start_line?: string end_line?: string + toolProtocol?: "xml" | "native" } = {}, ): Promise { // Configure mocks based on test scenario @@ -370,7 +374,7 @@ describe("read_file tool with maxReadFileLine setting", () => { toolResult = result }, removeClosingTag: (_: ToolParamName, content?: string) => content ?? "", - toolProtocol: "xml", + toolProtocol: options.toolProtocol ?? "native", }) return toolResult diff --git a/src/utils/__tests__/resolveToolProtocol.spec.ts b/src/utils/__tests__/resolveToolProtocol.spec.ts index 47acd2b200..1e1b0aef0f 100644 --- a/src/utils/__tests__/resolveToolProtocol.spec.ts +++ b/src/utils/__tests__/resolveToolProtocol.spec.ts @@ -6,20 +6,20 @@ import type { Anthropic } from "@anthropic-ai/sdk" describe("resolveToolProtocol", () => { /** - * XML Protocol Deprecation: - * - * XML tool protocol has been fully deprecated. All models now use Native - * tool calling. User preferences and model defaults are ignored. + * Tool Protocol Resolution: * * Precedence: - * 1. Locked Protocol (for resumed tasks that used XML) - * 2. Native (always, for all new tasks) + * 1. Locked Protocol (for resumed tasks - highest priority) + * 2. User Preference (toolProtocol setting) + * 3. Model Native Tools Support (supportsNativeTools) + * 4. Model Default Protocol (defaultToolProtocol) + * 5. Fallback to XML */ - describe("Locked Protocol (Precedence Level 0 - Highest Priority)", () => { + describe("Locked Protocol (Precedence Level 1 - Highest Priority)", () => { it("should return lockedProtocol when provided", () => { const settings: ProviderSettings = { - toolProtocol: "xml", // Ignored + toolProtocol: "xml", apiProvider: "openai-native", } // lockedProtocol overrides everything @@ -29,7 +29,7 @@ describe("resolveToolProtocol", () => { it("should return XML lockedProtocol for resumed tasks that used XML", () => { const settings: ProviderSettings = { - toolProtocol: "native", // Ignored + toolProtocol: "native", apiProvider: "anthropic", } // lockedProtocol forces XML for backward compatibility @@ -37,68 +37,62 @@ describe("resolveToolProtocol", () => { expect(result).toBe(TOOL_PROTOCOL.XML) }) - it("should fall through to Native when lockedProtocol is undefined", () => { + it("should fall through to user preference when lockedProtocol is undefined", () => { const settings: ProviderSettings = { - toolProtocol: "xml", // Ignored + toolProtocol: "xml", apiProvider: "anthropic", } - // undefined lockedProtocol should return native + // undefined lockedProtocol should use user preference const result = resolveToolProtocol(settings, undefined, undefined) expect(result).toBe(TOOL_PROTOCOL.XML) }) }) - describe("Native Protocol Always Used For New Tasks", () => { - it("should always use native for new tasks", () => { + describe("User Preference (Precedence Level 2)", () => { + it("should use user preference when no locked protocol", () => { const settings: ProviderSettings = { + toolProtocol: "xml", apiProvider: "anthropic", } const result = resolveToolProtocol(settings) expect(result).toBe(TOOL_PROTOCOL.XML) }) - it("should use native even when user preference is XML (user prefs ignored)", () => { + it("should use user preference even when model supports native tools", () => { const settings: ProviderSettings = { - toolProtocol: "xml", // User wants XML - ignored + toolProtocol: "xml", apiProvider: "openai-native", } - const result = resolveToolProtocol(settings) + const modelInfo: ModelInfo = { + maxTokens: 4096, + contextWindow: 128000, + supportsPromptCache: false, + supportsNativeTools: true, + } + const result = resolveToolProtocol(settings, modelInfo) expect(result).toBe(TOOL_PROTOCOL.XML) }) - it("should use native for OpenAI compatible provider", () => { + it("should use native when user sets it explicitly", () => { const settings: ProviderSettings = { - apiProvider: "openai", + toolProtocol: "native", + apiProvider: "anthropic", } - const result = resolveToolProtocol(settings, openAiModelInfoSaneDefaults) + const result = resolveToolProtocol(settings) expect(result).toBe(TOOL_PROTOCOL.NATIVE) }) }) - describe("Edge Cases", () => { - it("should handle missing provider name gracefully", () => { - const settings: ProviderSettings = {} - const result = resolveToolProtocol(settings) - expect(result).toBe(TOOL_PROTOCOL.XML) // Always native now - }) - - it("should handle undefined model info gracefully", () => { + describe("Model Native Tools Support (Precedence Level 3)", () => { + it("should use native for OpenAI compatible provider when no user preference", () => { const settings: ProviderSettings = { - apiProvider: "openai-native", + apiProvider: "openai", } - const result = resolveToolProtocol(settings, undefined) - expect(result).toBe(TOOL_PROTOCOL.XML) // Always native now - }) - - it("should handle empty settings", () => { - const settings: ProviderSettings = {} - const result = resolveToolProtocol(settings) - expect(result).toBe(TOOL_PROTOCOL.XML) // Always native now + const result = resolveToolProtocol(settings, openAiModelInfoSaneDefaults) + expect(result).toBe(TOOL_PROTOCOL.NATIVE) }) - }) - describe("Real-world Scenarios", () => { - it("should use Native for OpenAI models", () => { + it("should use Native for OpenAI models when no user preference", () => { const settings: ProviderSettings = { apiProvider: "openai-native", } @@ -112,7 +106,7 @@ describe("resolveToolProtocol", () => { expect(result).toBe(TOOL_PROTOCOL.NATIVE) }) - it("should use Native for Claude models", () => { + it("should use Native for Claude models when no user preference", () => { const settings: ProviderSettings = { apiProvider: "anthropic", } @@ -125,34 +119,53 @@ describe("resolveToolProtocol", () => { const result = resolveToolProtocol(settings, modelInfo) expect(result).toBe(TOOL_PROTOCOL.NATIVE) }) + }) - it("should honor locked protocol for resumed tasks that used XML", () => { + describe("Edge Cases", () => { + it("should fallback to XML when no preference or model info", () => { + const settings: ProviderSettings = {} + const result = resolveToolProtocol(settings) + expect(result).toBe(TOOL_PROTOCOL.XML) + }) + + it("should fallback to XML when model info is undefined", () => { const settings: ProviderSettings = { - apiProvider: "anthropic", + apiProvider: "openai-native", } - // Task was started when XML was used, so it's locked to XML - const result = resolveToolProtocol(settings, undefined, "xml") + const result = resolveToolProtocol(settings, undefined) + expect(result).toBe(TOOL_PROTOCOL.XML) + }) + + it("should fallback to XML for empty settings", () => { + const settings: ProviderSettings = {} + const result = resolveToolProtocol(settings) expect(result).toBe(TOOL_PROTOCOL.XML) }) }) - describe("Backward Compatibility - User Preferences Ignored", () => { - it("should use user preference for XML", () => { + describe("Real-world Scenarios", () => { + it("should honor locked protocol for resumed tasks that used XML", () => { const settings: ProviderSettings = { - toolProtocol: "xml", // User explicitly wants XML - ignored - apiProvider: "openai-native", + apiProvider: "anthropic", } - const result = resolveToolProtocol(settings) - expect(result).toBe(TOOL_PROTOCOL.XML) // Native is always used + // Task was started when XML was used, so it's locked to XML + const result = resolveToolProtocol(settings, undefined, "xml") + expect(result).toBe(TOOL_PROTOCOL.XML) }) - it("should return native regardless of user preference", () => { + it("should respect user preference over model capabilities", () => { const settings: ProviderSettings = { - toolProtocol: "native", // User preference - ignored but happens to match + toolProtocol: "xml", apiProvider: "anthropic", } - const result = resolveToolProtocol(settings) - expect(result).toBe(TOOL_PROTOCOL.NATIVE) + const modelInfo: ModelInfo = { + maxTokens: 8192, + contextWindow: 200000, + supportsPromptCache: true, + supportsNativeTools: true, + } + const result = resolveToolProtocol(settings, modelInfo) + expect(result).toBe(TOOL_PROTOCOL.XML) }) }) }) diff --git a/src/utils/resolveToolProtocol.ts b/src/utils/resolveToolProtocol.ts index b095d00eb2..383e4c7b79 100644 --- a/src/utils/resolveToolProtocol.ts +++ b/src/utils/resolveToolProtocol.ts @@ -39,18 +39,22 @@ export function resolveToolProtocol( return lockedProtocol } + // 2. User preference - second highest priority if (_providerSettings.toolProtocol) { return _providerSettings.toolProtocol } + // 3. Model supports native tools if (_modelInfo?.supportsNativeTools === true) { return TOOL_PROTOCOL.NATIVE } + // 4. Model default tool protocol if (_modelInfo?.defaultToolProtocol) { return _modelInfo.defaultToolProtocol } + // 5. Final fallback return TOOL_PROTOCOL.XML } diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index d9597becaf..7e3960c3f4 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -16,6 +16,8 @@ import { HardDriveUpload, FoldVertical, Globe, + Code, + Wrench, } from "lucide-react" import prettyBytes from "pretty-bytes" @@ -203,6 +205,17 @@ const TaskHeader = ({ className="flex items-center justify-between text-sm text-muted-foreground/70" onClick={(e) => e.stopPropagation()}>
+ {currentTaskItem?.toolProtocol && ( + + {currentTaskItem.toolProtocol === "native" ? ( + + ) : ( + + )} + + )} + | )} + {/* Tool Protocol display */} + {!!currentTaskItem?.toolProtocol && ( + + + {t("chat:task.toolProtocol")} + + + + {currentTaskItem.toolProtocol} + + + + )} {t("chat:task.tokens")} diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index e466fa70ac..5b4461770f 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -10,6 +10,7 @@ "tokens": "Tokens", "cache": "Cache", "apiCost": "API Cost", + "toolProtocol": "Tool Protocol", "size": "Size", "condenseContext": "Intelligently condense context", "contextWindow": "Context Length", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 457b48e44e..434b818e43 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -10,6 +10,7 @@ "tokens": "Token 用量", "cache": "缓存", "apiCost": "API 费用", + "toolProtocol": "工具协议", "size": "大小", "contextWindow": "上下文长度", "closeAndStart": "关闭任务并开始新任务", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 3b9cf14a85..e7e8643fcb 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -10,6 +10,7 @@ "tokens": "Token", "cache": "快取", "apiCost": "API 費用", + "toolProtocol": "工具協議", "size": "大小", "condenseContext": "智慧壓縮內容", "contextWindow": "上下文長度",