diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index f968698e4e..e5489fd6b2 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -167,7 +167,6 @@ export const globalSettingsSchema = z.object({ ttsSpeed: z.number().optional(), soundEnabled: z.boolean().optional(), soundVolume: z.number().optional(), - taskHeaderHighlightEnabled: z.boolean().optional(), maxOpenTabsContext: z.number().optional(), maxWorkspaceFiles: z.number().optional(), @@ -230,6 +229,12 @@ export const globalSettingsSchema = z.object({ */ showQuestionsOneByOne: z.boolean().optional(), + /** + * Whether to highlight the task header in the chat view. + * @default false + */ + taskHeaderHighlightEnabled: z.boolean().optional(), + /** * Path to worktree to auto-open after switching workspaces. * Used by the worktree feature to open the Roo Code sidebar in a new window. @@ -367,7 +372,6 @@ export const EVALS_SETTINGS: RooCodeSettings = { ttsSpeed: 1, soundEnabled: false, soundVolume: 0.5, - taskHeaderHighlightEnabled: false, terminalShellIntegrationTimeout: 30000, terminalCommandDelay: 0, @@ -400,6 +404,7 @@ export const EVALS_SETTINGS: RooCodeSettings = { customModes: [], showQuestionsOneByOne: false, + taskHeaderHighlightEnabled: false, } export const EVALS_TIMEOUT = 5 * 60 * 1_000 diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index f2cddee819..a3dac0f652 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -285,7 +285,6 @@ export type ExtensionState = Pick< | "ttsSpeed" | "soundEnabled" | "soundVolume" - | "taskHeaderHighlightEnabled" | "terminalOutputPreviewSize" | "terminalShellIntegrationTimeout" | "terminalShellIntegrationDisabled" diff --git a/src/__tests__/removeClineFromStack-delegation.spec.ts b/src/__tests__/removeClineFromStack-delegation.spec.ts new file mode 100644 index 0000000000..a72f580d6f --- /dev/null +++ b/src/__tests__/removeClineFromStack-delegation.spec.ts @@ -0,0 +1,281 @@ +// npx vitest run __tests__/removeClineFromStack-delegation.spec.ts + +import { describe, it, expect, vi } from "vitest" +import { ClineProvider } from "../core/webview/ClineProvider" + +describe("ClineProvider.removeClineFromStack() delegation awareness", () => { + /** + * Helper to build a minimal mock provider with a single task on the stack. + * The task's parentTaskId and taskId are configurable. + */ + function buildMockProvider(opts: { + childTaskId: string + parentTaskId?: string + parentHistoryItem?: Record + getTaskWithIdError?: Error + }) { + const childTask = { + taskId: opts.childTaskId, + instanceId: "inst-1", + parentTaskId: opts.parentTaskId, + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } + + const updateTaskHistory = vi.fn().mockResolvedValue([]) + const getTaskWithId = opts.getTaskWithIdError + ? vi.fn().mockRejectedValue(opts.getTaskWithIdError) + : vi.fn().mockImplementation(async (id: string) => { + if (id === opts.parentTaskId && opts.parentHistoryItem) { + return { historyItem: { ...opts.parentHistoryItem } } + } + throw new Error("Task not found") + }) + + const provider = { + clineStack: [childTask] as any[], + taskEventListeners: new Map(), + log: vi.fn(), + getTaskWithId, + updateTaskHistory, + } + + return { provider, childTask, updateTaskHistory, getTaskWithId } + } + + it("repairs parent metadata (delegated → active) when a delegated child is removed", async () => { + const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({ + childTaskId: "child-1", + parentTaskId: "parent-1", + parentHistoryItem: { + id: "parent-1", + task: "Parent task", + ts: 1000, + number: 1, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "delegated", + awaitingChildId: "child-1", + delegatedToId: "child-1", + childIds: ["child-1"], + }, + }) + + await (ClineProvider.prototype as any).removeClineFromStack.call(provider) + + // Stack should be empty after pop + expect(provider.clineStack).toHaveLength(0) + + // Parent lookup should have been called + expect(getTaskWithId).toHaveBeenCalledWith("parent-1") + + // Parent metadata should be repaired + expect(updateTaskHistory).toHaveBeenCalledTimes(1) + const updatedParent = updateTaskHistory.mock.calls[0][0] + expect(updatedParent).toEqual( + expect.objectContaining({ + id: "parent-1", + status: "active", + awaitingChildId: undefined, + }), + ) + + // Log the repair + expect(provider.log).toHaveBeenCalledWith(expect.stringContaining("Repaired parent parent-1 metadata")) + }) + + it("does NOT modify parent metadata when the task has no parentTaskId (non-delegated)", async () => { + const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({ + childTaskId: "standalone-1", + // No parentTaskId — this is a top-level task + }) + + await (ClineProvider.prototype as any).removeClineFromStack.call(provider) + + // Stack should be empty + expect(provider.clineStack).toHaveLength(0) + + // No parent lookup or update should happen + expect(getTaskWithId).not.toHaveBeenCalled() + expect(updateTaskHistory).not.toHaveBeenCalled() + }) + + it("does NOT modify parent metadata when awaitingChildId does not match the popped child", async () => { + const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({ + childTaskId: "child-1", + parentTaskId: "parent-1", + parentHistoryItem: { + id: "parent-1", + task: "Parent task", + ts: 1000, + number: 1, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "delegated", + awaitingChildId: "child-OTHER", // different child + delegatedToId: "child-OTHER", + childIds: ["child-OTHER"], + }, + }) + + await (ClineProvider.prototype as any).removeClineFromStack.call(provider) + + // Parent was looked up but should NOT be updated + expect(getTaskWithId).toHaveBeenCalledWith("parent-1") + expect(updateTaskHistory).not.toHaveBeenCalled() + }) + + it("does NOT modify parent metadata when parent status is not 'delegated'", async () => { + const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({ + childTaskId: "child-1", + parentTaskId: "parent-1", + parentHistoryItem: { + id: "parent-1", + task: "Parent task", + ts: 1000, + number: 1, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "completed", // already completed + awaitingChildId: "child-1", + childIds: ["child-1"], + }, + }) + + await (ClineProvider.prototype as any).removeClineFromStack.call(provider) + + expect(getTaskWithId).toHaveBeenCalledWith("parent-1") + expect(updateTaskHistory).not.toHaveBeenCalled() + }) + + it("catches and logs errors during parent metadata repair without blocking the pop", async () => { + const { provider, childTask, updateTaskHistory, getTaskWithId } = buildMockProvider({ + childTaskId: "child-1", + parentTaskId: "parent-1", + getTaskWithIdError: new Error("Storage unavailable"), + }) + + // Should NOT throw + await (ClineProvider.prototype as any).removeClineFromStack.call(provider) + + // Stack should still be empty (pop was not blocked) + expect(provider.clineStack).toHaveLength(0) + + // The abort should still have been called + expect(childTask.abortTask).toHaveBeenCalledWith(true) + + // Error should be logged as non-fatal + expect(provider.log).toHaveBeenCalledWith( + expect.stringContaining("Failed to repair parent metadata for parent-1 (non-fatal)"), + ) + + // No update should have been attempted + expect(updateTaskHistory).not.toHaveBeenCalled() + }) + + it("handles empty stack gracefully", async () => { + const provider = { + clineStack: [] as any[], + taskEventListeners: new Map(), + log: vi.fn(), + getTaskWithId: vi.fn(), + updateTaskHistory: vi.fn(), + } + + // Should not throw + await (ClineProvider.prototype as any).removeClineFromStack.call(provider) + + expect(provider.clineStack).toHaveLength(0) + expect(provider.getTaskWithId).not.toHaveBeenCalled() + expect(provider.updateTaskHistory).not.toHaveBeenCalled() + }) + + it("skips delegation repair when skipDelegationRepair option is true", async () => { + const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({ + childTaskId: "child-1", + parentTaskId: "parent-1", + parentHistoryItem: { + id: "parent-1", + task: "Parent task", + ts: 1000, + number: 1, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "delegated", + awaitingChildId: "child-1", + delegatedToId: "child-1", + childIds: ["child-1"], + }, + }) + + // Call with skipDelegationRepair: true (as delegateParentAndOpenChild would) + await (ClineProvider.prototype as any).removeClineFromStack.call(provider, { skipDelegationRepair: true }) + + // Stack should be empty after pop + expect(provider.clineStack).toHaveLength(0) + + // Parent lookup should NOT have been called — repair was skipped entirely + expect(getTaskWithId).not.toHaveBeenCalled() + expect(updateTaskHistory).not.toHaveBeenCalled() + }) + + it("does NOT reset grandparent during A→B→C nested delegation transition", async () => { + // Scenario: A delegated to B, B is now delegating to C. + // delegateParentAndOpenChild() pops B via removeClineFromStack({ skipDelegationRepair: true }). + // Grandparent A should remain "delegated" — its metadata must not be repaired. + const grandparentHistory = { + id: "task-A", + task: "Grandparent task", + ts: 1000, + number: 1, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "delegated", + awaitingChildId: "task-B", + delegatedToId: "task-B", + childIds: ["task-B"], + } + + const taskB = { + taskId: "task-B", + instanceId: "inst-B", + parentTaskId: "task-A", + emit: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + } + + const getTaskWithId = vi.fn().mockImplementation(async (id: string) => { + if (id === "task-A") { + return { historyItem: { ...grandparentHistory } } + } + throw new Error("Task not found") + }) + const updateTaskHistory = vi.fn().mockResolvedValue([]) + + const provider = { + clineStack: [taskB] as any[], + taskEventListeners: new Map(), + log: vi.fn(), + getTaskWithId, + updateTaskHistory, + } + + // Simulate what delegateParentAndOpenChild does: pop B with skipDelegationRepair + await (ClineProvider.prototype as any).removeClineFromStack.call(provider, { skipDelegationRepair: true }) + + // B was popped + expect(provider.clineStack).toHaveLength(0) + + // Grandparent A should NOT have been looked up or modified + expect(getTaskWithId).not.toHaveBeenCalled() + expect(updateTaskHistory).not.toHaveBeenCalled() + + // Grandparent A's metadata remains intact (delegated, awaitingChildId: task-B) + // The caller (delegateParentAndOpenChild) will update A to point to C separately. + }) +}) diff --git a/src/api/providers/__tests__/lite-llm.spec.ts b/src/api/providers/__tests__/lite-llm.spec.ts index 9f3a641cb3..83c1cfd4da 100644 --- a/src/api/providers/__tests__/lite-llm.spec.ts +++ b/src/api/providers/__tests__/lite-llm.spec.ts @@ -1,56 +1,69 @@ -import OpenAI from "openai" -import { Anthropic } from "@anthropic-ai/sdk" +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) -import { LiteLLMHandler } from "../lite-llm" -import { ApiHandlerOptions } from "../../../shared/api" -import { litellmDefaultModelId, litellmDefaultModelInfo } from "@roo-code/types" - -// Mock vscode first to avoid import errors -vi.mock("vscode", () => ({})) - -// Mock OpenAI -const mockCreate = vi.fn() - -vi.mock("openai", () => { +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - default: vi.fn().mockImplementation(() => ({ - chat: { - completions: { - create: mockCreate, - }, - }, - })), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) -// Mock model fetching +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + return vi.fn((modelId: string) => ({ + modelId, + provider: "litellm", + })) + }), +})) + +vi.mock("vscode", () => ({})) + vi.mock("../fetchers/modelCache", () => ({ getModels: vi.fn().mockImplementation(() => { return Promise.resolve({ - [litellmDefaultModelId]: litellmDefaultModelInfo, - "gpt-5": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - gpt5: { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "GPT-5": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gpt-5-turbo": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gpt5-preview": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gpt-5o": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gpt-5.1": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gpt-5-mini": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gpt-4": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "claude-3-opus": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "llama-3": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gpt-4-turbo": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - // Gemini models for thought signature injection tests - "gemini-3-pro": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gemini-3-flash": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gemini-2.5-pro": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "google/gemini-3-pro": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "vertex_ai/gemini-3-pro": { ...litellmDefaultModelInfo, maxTokens: 8192 }, + "claude-3-7-sonnet-20250219": { + maxTokens: 8192, + contextWindow: 200000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 3.0, + outputPrice: 15.0, + }, + "gpt-4": { + maxTokens: 8192, + contextWindow: 128000, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 5.0, + outputPrice: 15.0, + }, + "custom-model": { + maxTokens: 4096, + contextWindow: 32000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 1.0, + outputPrice: 2.0, + }, }) }), getModelsFromCache: vi.fn().mockReturnValue(undefined), })) +import type { Anthropic } from "@anthropic-ai/sdk" + +import { litellmDefaultModelId, litellmDefaultModelInfo } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" + +import { LiteLLMHandler } from "../lite-llm" +import { getModels, getModelsFromCache } from "../fetchers/modelCache" + describe("LiteLLMHandler", () => { let handler: LiteLLMHandler let mockOptions: ApiHandlerOptions @@ -65,859 +78,463 @@ describe("LiteLLMHandler", () => { handler = new LiteLLMHandler(mockOptions) }) - describe("prompt caching", () => { - it("should add cache control headers when litellmUsePromptCache is enabled", async () => { - const optionsWithCache: ApiHandlerOptions = { + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(LiteLLMHandler) + expect(handler.getModel().id).toBe(litellmDefaultModelId) + }) + + it("should use default model ID if not provided", () => { + const handlerWithoutModel = new LiteLLMHandler({ ...mockOptions, - litellmUsePromptCache: true, - } - handler = new LiteLLMHandler(optionsWithCache) + litellmModelId: undefined, + }) + expect(handlerWithoutModel.getModel().id).toBe(litellmDefaultModelId) + }) - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there!" }, - { role: "user", content: "How are you?" }, - ] + it("should use default base URL if not provided", () => { + const handlerWithoutBaseUrl = new LiteLLMHandler({ + ...mockOptions, + litellmBaseUrl: undefined, + }) + expect(handlerWithoutBaseUrl).toBeInstanceOf(LiteLLMHandler) + }) - // Mock the stream response - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "I'm doing well!" } }], - usage: { - prompt_tokens: 100, - completion_tokens: 50, - cache_creation_input_tokens: 20, - cache_read_input_tokens: 30, - }, - } - }, - } + it("should use default API key if not provided", () => { + const handlerWithoutKey = new LiteLLMHandler({ + ...mockOptions, + litellmApiKey: undefined, + }) + expect(handlerWithoutKey).toBeInstanceOf(LiteLLMHandler) + }) + }) - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), + describe("getModel", () => { + it("should return default model info when no models are cached", () => { + const model = handler.getModel() + expect(model.id).toBe(litellmDefaultModelId) + expect(model.info).toEqual(litellmDefaultModelInfo) + }) + + it("should return fetched model info after fetchModel is called", async () => { + // Trigger fetchModel via createMessage setup + const mockFullStream = (async function* () { + yield { type: "text-delta" as const, id: "1", text: "Hello" } + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), }) - const generator = handler.createMessage(systemPrompt, messages) + const generator = handler.createMessage("system", [{ role: "user", content: "Hello" }]) const results = [] for await (const chunk of generator) { results.push(chunk) } - // Verify that create was called with cache control headers - const createCall = mockCreate.mock.calls[0][0] + // After createMessage, models should be populated + const model = handler.getModel() + expect(model.id).toBe(litellmDefaultModelId) + expect(model.info.maxTokens).toBe(8192) + }) - // Check system message has cache control in the proper format - expect(createCall.messages[0]).toMatchObject({ - role: "system", - content: [ - { - type: "text", - text: systemPrompt, - cache_control: { type: "ephemeral" }, - }, - ], + it("should fall back to cache when models are not fetched", () => { + const cachedModels = { + [litellmDefaultModelId]: { + maxTokens: 4096, + contextWindow: 100000, + supportsImages: false, + supportsPromptCache: false, + }, + } + vi.mocked(getModelsFromCache).mockReturnValue(cachedModels as any) + + const model = handler.getModel() + expect(model.id).toBe(litellmDefaultModelId) + expect(model.info.maxTokens).toBe(4096) + }) + + it("should use custom model ID from options", () => { + const customHandler = new LiteLLMHandler({ + ...mockOptions, + litellmModelId: "custom-model", + }) + // Before fetch, returns default info since models not loaded + const model = customHandler.getModel() + expect(model.id).toBe("custom-model") + }) + }) + + describe("createMessage", () => { + it("should fetch models before creating a message", async () => { + const mockFullStream = (async function* () { + yield { type: "text-delta" as const, id: "1", text: "Hello!" } + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), }) - // Check that the last two user messages have cache control - const userMessageIndices = createCall.messages - .map((msg: any, idx: number) => (msg.role === "user" ? idx : -1)) - .filter((idx: number) => idx !== -1) - - const lastUserIdx = userMessageIndices[userMessageIndices.length - 1] - const secondLastUserIdx = userMessageIndices[userMessageIndices.length - 2] - - // Check last user message has proper structure with cache control - expect(createCall.messages[lastUserIdx]).toMatchObject({ - role: "user", - content: [ - { - type: "text", - text: "How are you?", - cache_control: { type: "ephemeral" }, - }, - ], - }) - - // Check second last user message (first user message in this case) - if (secondLastUserIdx !== -1) { - expect(createCall.messages[secondLastUserIdx]).toMatchObject({ - role: "user", - content: [ - { - type: "text", - text: "Hello", - cache_control: { type: "ephemeral" }, - }, - ], - }) + const generator = handler.createMessage("You are a helpful assistant", [{ role: "user", content: "Hello" }]) + const results = [] + for await (const chunk of generator) { + results.push(chunk) } - // Verify usage includes cache tokens - const usageChunk = results.find((chunk) => chunk.type === "usage") + expect(getModels).toHaveBeenCalledWith({ + provider: "litellm", + apiKey: "test-key", + baseUrl: "http://localhost:4000", + }) + }) + + it("should stream text content", async () => { + const mockFullStream = (async function* () { + yield { type: "text-delta" as const, id: "1", text: "Hello" } + yield { type: "text-delta" as const, id: "1", text: " world!" } + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + }) + + const generator = handler.createMessage("system prompt", [{ role: "user", content: "Hi" }]) + const results = [] + for await (const chunk of generator) { + results.push(chunk) + } + + const textChunks = results.filter((r) => r.type === "text") + expect(textChunks).toHaveLength(2) + expect(textChunks[0]).toEqual({ type: "text", text: "Hello" }) + expect(textChunks[1]).toEqual({ type: "text", text: " world!" }) + }) + + it("should yield usage metrics at the end", async () => { + const mockFullStream = (async function* () { + yield { type: "text-delta" as const, id: "1", text: "Hello" } + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 30, + reasoningTokens: 10, + }, + }), + }) + + const generator = handler.createMessage("system prompt", [{ role: "user", content: "Hi" }]) + const results = [] + for await (const chunk of generator) { + results.push(chunk) + } + + const usageChunk = results.find((r) => r.type === "usage") expect(usageChunk).toMatchObject({ type: "usage", inputTokens: 100, outputTokens: 50, - cacheWriteTokens: 20, cacheReadTokens: 30, + reasoningTokens: 10, }) }) - }) - describe("GPT-5 model handling", () => { - it("should use max_completion_tokens instead of max_tokens for GPT-5 models", async () => { - const optionsWithGPT5: ApiHandlerOptions = { - ...mockOptions, - litellmModelId: "gpt-5", - } - handler = new LiteLLMHandler(optionsWithGPT5) + it("should pass system prompt and messages to streamText", async () => { + const mockFullStream = (async function* () { + yield { type: "text-delta" as const, id: "1", text: "Response" } + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + }) const systemPrompt = "You are a helpful assistant" const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] - // Mock the stream response - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "Hello!" } }], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - }, - } - }, + const generator = handler.createMessage(systemPrompt, messages) + for await (const _chunk of generator) { + // consume } - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.system).toBe(systemPrompt) + expect(callArgs.model).toBeDefined() + }) + + it("should pass temperature from options", async () => { + const handlerWithTemp = new LiteLLMHandler({ + ...mockOptions, + modelTemperature: 0.7, }) - const generator = handler.createMessage(systemPrompt, messages) + const mockFullStream = (async function* () { + yield { type: "text-delta" as const, id: "1", text: "Hello" } + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + }) + + const generator = handlerWithTemp.createMessage("system", [{ role: "user", content: "Hi" }]) + for await (const _chunk of generator) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.temperature).toBe(0.7) + }) + + it("should pass maxOutputTokens from model info", async () => { + const mockFullStream = (async function* () { + yield { type: "text-delta" as const, id: "1", text: "Hello" } + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + }) + + const generator = handler.createMessage("system", [{ role: "user", content: "Hi" }]) + for await (const _chunk of generator) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.maxOutputTokens).toBeDefined() + }) + + it("should handle tool calls in stream", async () => { + const mockFullStream = (async function* () { + yield { type: "tool-input-start" as const, id: "call_123", toolName: "test_tool" } + yield { type: "tool-input-delta" as const, id: "call_123", delta: '{"key":"value"}' } + yield { type: "tool-input-end" as const, id: "call_123" } + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + }) + + const generator = handler.createMessage("system", [{ role: "user", content: "Hi" }]) const results = [] for await (const chunk of generator) { results.push(chunk) } - // Verify that create was called with max_completion_tokens instead of max_tokens - const createCall = mockCreate.mock.calls[0][0] + const toolStartChunks = results.filter((r) => r.type === "tool_call_start") + expect(toolStartChunks).toHaveLength(1) + expect(toolStartChunks[0]).toMatchObject({ + type: "tool_call_start", + id: "call_123", + name: "test_tool", + }) - // Should have max_completion_tokens, not max_tokens - expect(createCall.max_completion_tokens).toBeDefined() - expect(createCall.max_tokens).toBeUndefined() + const toolDeltaChunks = results.filter((r) => r.type === "tool_call_delta") + expect(toolDeltaChunks).toHaveLength(1) + + const toolEndChunks = results.filter((r) => r.type === "tool_call_end") + expect(toolEndChunks).toHaveLength(1) }) - it("should use max_completion_tokens for various GPT-5 model variations", async () => { - const gpt5Variations = [ - "gpt-5", - "gpt5", - "GPT-5", - "gpt-5-turbo", - "gpt5-preview", - "gpt-5o", - "gpt-5.1", - "gpt-5-mini", - ] + it("should handle reasoning content in stream", async () => { + const mockFullStream = (async function* () { + yield { type: "reasoning" as const, text: "Let me think..." } + yield { type: "text-delta" as const, id: "1", text: "The answer is 42" } + })() - for (const modelId of gpt5Variations) { - vi.clearAllMocks() + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + }) - const optionsWithGPT5: ApiHandlerOptions = { - ...mockOptions, - litellmModelId: modelId, - } - handler = new LiteLLMHandler(optionsWithGPT5) - - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test" }] - - // Mock the stream response - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "Response" } }], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - }, - } - }, - } - - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), - }) - - const generator = handler.createMessage(systemPrompt, messages) - for await (const chunk of generator) { - // Consume the generator - } - - // Verify that create was called with max_completion_tokens for this model variation - const createCall = mockCreate.mock.calls[0][0] - - expect(createCall.max_completion_tokens).toBeDefined() - expect(createCall.max_tokens).toBeUndefined() + const generator = handler.createMessage("system", [{ role: "user", content: "Hi" }]) + const results = [] + for await (const chunk of generator) { + results.push(chunk) } + + const reasoningChunks = results.filter((r) => r.type === "reasoning") + expect(reasoningChunks).toHaveLength(1) + expect(reasoningChunks[0]).toEqual({ type: "reasoning", text: "Let me think..." }) }) - it("should still use max_tokens for non-GPT-5 models", async () => { - const nonGPT5Models = ["gpt-4", "claude-3-opus", "llama-3", "gpt-4-turbo"] + it("should handle errors from streamText", async () => { + const error = new Error("API Error") + const mockFullStream = (async function* () { + yield { type: "text-delta" as const, id: "1", text: "" } + throw error + })() - for (const modelId of nonGPT5Models) { - vi.clearAllMocks() + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) - const options: ApiHandlerOptions = { - ...mockOptions, - litellmModelId: modelId, + const generator = handler.createMessage("system", [{ role: "user", content: "Hi" }]) + await expect(async () => { + for await (const _chunk of generator) { + // consume } - handler = new LiteLLMHandler(options) + }).rejects.toThrow() + }) + }) - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test" }] + describe("completePrompt", () => { + it("should return text from generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Completed response", + }) - // Mock the stream response - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "Response" } }], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - }, - } - }, - } - - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), - }) - - const generator = handler.createMessage(systemPrompt, messages) - for await (const chunk of generator) { - // Consume the generator - } - - // Verify that create was called with max_tokens for non-GPT-5 models - const createCall = mockCreate.mock.calls[0][0] - - expect(createCall.max_tokens).toBeDefined() - expect(createCall.max_completion_tokens).toBeUndefined() - } + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("Completed response") }) - it("should use max_completion_tokens in completePrompt for GPT-5 models", async () => { - const optionsWithGPT5: ApiHandlerOptions = { - ...mockOptions, - litellmModelId: "gpt-5", - } - handler = new LiteLLMHandler(optionsWithGPT5) - - mockCreate.mockResolvedValue({ - choices: [{ message: { content: "Test response" } }], + it("should pass prompt to generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Response", }) await handler.completePrompt("Test prompt") - // Verify that create was called with max_completion_tokens - const createCall = mockCreate.mock.calls[0][0] - - expect(createCall.max_completion_tokens).toBeDefined() - expect(createCall.max_tokens).toBeUndefined() + expect(mockGenerateText).toHaveBeenCalledTimes(1) + const callArgs = mockGenerateText.mock.calls[0][0] + expect(callArgs.prompt).toBe("Test prompt") + expect(callArgs.model).toBeDefined() }) - it("should not set any max token fields when maxTokens is undefined (GPT-5 streaming)", async () => { - const optionsWithGPT5: ApiHandlerOptions = { + it("should pass maxOutputTokens to generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Response", + }) + + await handler.completePrompt("Test prompt") + + const callArgs = mockGenerateText.mock.calls[0][0] + expect(callArgs.maxOutputTokens).toBeDefined() + }) + + it("should fetch models before completing prompt", async () => { + mockGenerateText.mockResolvedValue({ + text: "Response", + }) + + await handler.completePrompt("Test prompt") + + expect(getModels).toHaveBeenCalledWith({ + provider: "litellm", + apiKey: "test-key", + baseUrl: "http://localhost:4000", + }) + }) + }) + + describe("isAiSdkProvider", () => { + it("should return true", () => { + expect(handler.isAiSdkProvider()).toBe(true) + }) + }) + + describe("model resolution with custom model IDs", () => { + it("should resolve model from fetched models", async () => { + const customHandler = new LiteLLMHandler({ ...mockOptions, - litellmModelId: "gpt-5", - } - handler = new LiteLLMHandler(optionsWithGPT5) - - // Force fetchModel to return undefined maxTokens - vi.spyOn(handler as any, "fetchModel").mockResolvedValue({ - id: "gpt-5", - info: { ...litellmDefaultModelInfo, maxTokens: undefined }, + litellmModelId: "gpt-4", }) - // Mock the stream response - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "Hello!" } }], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - }, - } - }, - } + const mockFullStream = (async function* () { + yield { type: "text-delta" as const, id: "1", text: "Hello" } + })() - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), }) - const generator = handler.createMessage("You are a helpful assistant", [ - { role: "user", content: "Hello" } as unknown as Anthropic.Messages.MessageParam, - ]) + const generator = customHandler.createMessage("system", [{ role: "user", content: "Hi" }]) for await (const _chunk of generator) { // consume } - // Should not include either token field - const createCall = mockCreate.mock.calls[0][0] - expect(createCall.max_tokens).toBeUndefined() - expect(createCall.max_completion_tokens).toBeUndefined() + const model = customHandler.getModel() + expect(model.id).toBe("gpt-4") + expect(model.info.contextWindow).toBe(128000) }) - it("should not set any max token fields when maxTokens is undefined (GPT-5 completePrompt)", async () => { - const optionsWithGPT5: ApiHandlerOptions = { + it("should fall back to default info for unknown models", () => { + const unknownHandler = new LiteLLMHandler({ ...mockOptions, - litellmModelId: "gpt-5", - } - handler = new LiteLLMHandler(optionsWithGPT5) - - // Force fetchModel to return undefined maxTokens - vi.spyOn(handler as any, "fetchModel").mockResolvedValue({ - id: "gpt-5", - info: { ...litellmDefaultModelInfo, maxTokens: undefined }, + litellmModelId: "unknown-model", }) - mockCreate.mockResolvedValue({ - choices: [{ message: { content: "Ok" } }], - }) - - await handler.completePrompt("Test prompt") - - const createCall = mockCreate.mock.calls[0][0] - expect(createCall.max_tokens).toBeUndefined() - expect(createCall.max_completion_tokens).toBeUndefined() + const model = unknownHandler.getModel() + expect(model.id).toBe("unknown-model") + expect(model.info).toEqual(litellmDefaultModelInfo) }) }) - describe("Gemini thought signature injection", () => { - describe("isGeminiModel detection", () => { - it("should detect Gemini 3 models", () => { - const handler = new LiteLLMHandler(mockOptions) - const isGeminiModel = (handler as any).isGeminiModel.bind(handler) + describe("usage metrics", () => { + it("should handle usage without cache details", async () => { + const mockFullStream = (async function* () { + yield { type: "text-delta" as const, id: "1", text: "Hello" } + })() - expect(isGeminiModel("gemini-3-pro")).toBe(true) - expect(isGeminiModel("gemini-3-flash")).toBe(true) - expect(isGeminiModel("gemini-3-pro-preview")).toBe(true) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ + inputTokens: 50, + outputTokens: 25, + }), }) - it("should detect Gemini 2.5 models", () => { - const handler = new LiteLLMHandler(mockOptions) - const isGeminiModel = (handler as any).isGeminiModel.bind(handler) + const generator = handler.createMessage("system", [{ role: "user", content: "Hi" }]) + const results = [] + for await (const chunk of generator) { + results.push(chunk) + } - expect(isGeminiModel("gemini-2.5-pro")).toBe(true) - expect(isGeminiModel("gemini-2.5-flash")).toBe(true) - }) - - it("should detect Gemini models with spaces (LiteLLM model groups)", () => { - const handler = new LiteLLMHandler(mockOptions) - const isGeminiModel = (handler as any).isGeminiModel.bind(handler) - - // LiteLLM model groups often use space-separated names with title case - expect(isGeminiModel("Gemini 3 Pro")).toBe(true) - expect(isGeminiModel("Gemini 3 Flash")).toBe(true) - expect(isGeminiModel("gemini 3 pro")).toBe(true) - expect(isGeminiModel("Gemini 2.5 Pro")).toBe(true) - expect(isGeminiModel("gemini 2.5 flash")).toBe(true) - }) - - it("should detect provider-prefixed Gemini models", () => { - const handler = new LiteLLMHandler(mockOptions) - const isGeminiModel = (handler as any).isGeminiModel.bind(handler) - - expect(isGeminiModel("google/gemini-3-pro")).toBe(true) - expect(isGeminiModel("vertex_ai/gemini-3-pro")).toBe(true) - expect(isGeminiModel("vertex/gemini-2.5-pro")).toBe(true) - // Space-separated variants with provider prefix - expect(isGeminiModel("google/gemini 3 pro")).toBe(true) - expect(isGeminiModel("vertex_ai/gemini 2.5 pro")).toBe(true) - }) - - it("should not detect non-Gemini models", () => { - const handler = new LiteLLMHandler(mockOptions) - const isGeminiModel = (handler as any).isGeminiModel.bind(handler) - - expect(isGeminiModel("gpt-4")).toBe(false) - expect(isGeminiModel("claude-3-opus")).toBe(false) - expect(isGeminiModel("gemini-1.5-pro")).toBe(false) - expect(isGeminiModel("gemini-2.0-flash")).toBe(false) + const usageChunk = results.find((r) => r.type === "usage") + expect(usageChunk).toMatchObject({ + type: "usage", + inputTokens: 50, + outputTokens: 25, }) }) - describe("injectThoughtSignatureForGemini", () => { - // Base64 encoded "skip_thought_signature_validator" - const dummySignature = Buffer.from("skip_thought_signature_validator").toString("base64") + it("should handle zero token usage", async () => { + const mockFullStream = (async function* () { + yield { type: "text-delta" as const, id: "1", text: "" } + })() - it("should inject provider_specific_fields.thought_signature for assistant messages with tool_calls", () => { - const handler = new LiteLLMHandler(mockOptions) - const injectThoughtSignature = (handler as any).injectThoughtSignatureForGemini.bind(handler) - - const messages = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: "", - tool_calls: [ - { id: "call_123", type: "function", function: { name: "test_tool", arguments: "{}" } }, - ], - }, - { role: "tool", tool_call_id: "call_123", content: "result" }, - ] - - const result = injectThoughtSignature(messages) - - // The first tool call should have provider_specific_fields.thought_signature injected - expect(result[1].tool_calls[0].provider_specific_fields).toBeDefined() - expect(result[1].tool_calls[0].provider_specific_fields.thought_signature).toBe(dummySignature) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ + inputTokens: 0, + outputTokens: 0, + }), }) - it("should not inject if assistant message has no tool_calls", () => { - const handler = new LiteLLMHandler(mockOptions) - const injectThoughtSignature = (handler as any).injectThoughtSignatureForGemini.bind(handler) - - const messages = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there!" }, - ] - - const result = injectThoughtSignature(messages) - - // No changes should be made - expect(result[1].tool_calls).toBeUndefined() - }) - - it("should always overwrite existing thought_signature", () => { - const handler = new LiteLLMHandler(mockOptions) - const injectThoughtSignature = (handler as any).injectThoughtSignatureForGemini.bind(handler) - - const existingSignature = "existing_signature_base64" - - const messages = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: "", - tool_calls: [ - { - id: "call_123", - type: "function", - function: { name: "test_tool", arguments: "{}" }, - provider_specific_fields: { thought_signature: existingSignature }, - }, - ], - }, - ] - - const result = injectThoughtSignature(messages) - - // Should overwrite with dummy signature (always inject to ensure compatibility) - expect(result[1].tool_calls[0].provider_specific_fields.thought_signature).toBe(dummySignature) - }) - - it("should inject signature into ALL tool calls for parallel calls", () => { - const handler = new LiteLLMHandler(mockOptions) - const injectThoughtSignature = (handler as any).injectThoughtSignatureForGemini.bind(handler) - - const messages = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: "", - tool_calls: [ - { id: "call_first", type: "function", function: { name: "tool1", arguments: "{}" } }, - { id: "call_second", type: "function", function: { name: "tool2", arguments: "{}" } }, - { id: "call_third", type: "function", function: { name: "tool3", arguments: "{}" } }, - ], - }, - ] - - const result = injectThoughtSignature(messages) - - // ALL tool calls should have the signature - expect(result[1].tool_calls[0].provider_specific_fields.thought_signature).toBe(dummySignature) - expect(result[1].tool_calls[1].provider_specific_fields.thought_signature).toBe(dummySignature) - expect(result[1].tool_calls[2].provider_specific_fields.thought_signature).toBe(dummySignature) - }) - - it("should preserve existing provider_specific_fields when adding thought_signature", () => { - const handler = new LiteLLMHandler(mockOptions) - const injectThoughtSignature = (handler as any).injectThoughtSignatureForGemini.bind(handler) - - const messages = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: "", - tool_calls: [ - { - id: "call_123", - type: "function", - function: { name: "test_tool", arguments: "{}" }, - provider_specific_fields: { other_field: "value" }, - }, - ], - }, - ] - - const result = injectThoughtSignature(messages) - - // Should have both existing field and new thought_signature - expect(result[1].tool_calls[0].provider_specific_fields.other_field).toBe("value") - expect(result[1].tool_calls[0].provider_specific_fields.thought_signature).toBe(dummySignature) - }) - }) - - describe("createMessage integration with Gemini models", () => { - // Base64 encoded "skip_thought_signature_validator" - const dummySignature = Buffer.from("skip_thought_signature_validator").toString("base64") - - it("should inject thought signatures for Gemini 3 models with native tools", async () => { - const optionsWithGemini: ApiHandlerOptions = { - ...mockOptions, - litellmModelId: "gemini-3-pro", - } - handler = new LiteLLMHandler(optionsWithGemini) - - // Mock fetchModel to return a Gemini model - vi.spyOn(handler as any, "fetchModel").mockResolvedValue({ - id: "gemini-3-pro", - info: { ...litellmDefaultModelInfo, maxTokens: 8192 }, - }) - - const systemPrompt = "You are a helpful assistant" - // Simulate conversation history with a tool call from a previous model (Claude) - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: [ - { type: "text", text: "I'll help you with that." }, - { type: "tool_use", id: "toolu_123", name: "read_file", input: { path: "test.txt" } }, - ], - }, - { - role: "user", - content: [{ type: "tool_result", tool_use_id: "toolu_123", content: "file contents" }], - }, - { role: "user", content: "Thanks!" }, - ] - - // Mock the stream response - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "You're welcome!" } }], - usage: { - prompt_tokens: 100, - completion_tokens: 20, - }, - } - }, - } - - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), - }) - - // Provide tools and native protocol to trigger the injection - const metadata = { - tools: [ - { - type: "function", - function: { name: "read_file", description: "Read a file", parameters: {} }, - }, - ], - } - - const generator = handler.createMessage(systemPrompt, messages, metadata as any) - for await (const _chunk of generator) { - // Consume the generator - } - - // Verify that the assistant message with tool_calls has thought_signature injected - const createCall = mockCreate.mock.calls[0][0] - const assistantMessage = createCall.messages.find( - (msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0, - ) - - expect(assistantMessage).toBeDefined() - // First tool call should have the thought signature - expect(assistantMessage.tool_calls[0].provider_specific_fields).toBeDefined() - expect(assistantMessage.tool_calls[0].provider_specific_fields.thought_signature).toBe(dummySignature) - }) - - it("should not inject thought signatures for non-Gemini models", async () => { - const optionsWithGPT4: ApiHandlerOptions = { - ...mockOptions, - litellmModelId: "gpt-4", - } - handler = new LiteLLMHandler(optionsWithGPT4) - - vi.spyOn(handler as any, "fetchModel").mockResolvedValue({ - id: "gpt-4", - info: { ...litellmDefaultModelInfo, maxTokens: 8192 }, - }) - - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: [ - { type: "text", text: "I'll help you with that." }, - { type: "tool_use", id: "toolu_123", name: "read_file", input: { path: "test.txt" } }, - ], - }, - { - role: "user", - content: [{ type: "tool_result", tool_use_id: "toolu_123", content: "file contents" }], - }, - ] - - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "Response" } }], - usage: { prompt_tokens: 100, completion_tokens: 20 }, - } - }, - } - - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), - }) - - const metadata = { - tools: [ - { - type: "function", - function: { name: "read_file", description: "Read a file", parameters: {} }, - }, - ], - } - - const generator = handler.createMessage(systemPrompt, messages, metadata as any) - for await (const _chunk of generator) { - // Consume - } - - // Verify that thought_signature was NOT injected for non-Gemini model - const createCall = mockCreate.mock.calls[0][0] - const assistantMessage = createCall.messages.find( - (msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0, - ) - - expect(assistantMessage).toBeDefined() - // Tool calls should not have provider_specific_fields added - expect(assistantMessage.tool_calls[0].provider_specific_fields).toBeUndefined() - }) - }) - }) - - describe("tool ID normalization", () => { - it("should truncate tool IDs longer than 64 characters", async () => { - const optionsWithBedrock: ApiHandlerOptions = { - ...mockOptions, - litellmModelId: "bedrock/anthropic.claude-3-sonnet", - } - handler = new LiteLLMHandler(optionsWithBedrock) - - vi.spyOn(handler as any, "fetchModel").mockResolvedValue({ - id: "bedrock/anthropic.claude-3-sonnet", - info: { ...litellmDefaultModelInfo, maxTokens: 8192 }, - }) - - // Create a tool ID longer than 64 characters - const longToolId = "toolu_" + "a".repeat(70) // 76 characters total - - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: [ - { type: "text", text: "I'll help you with that." }, - { type: "tool_use", id: longToolId, name: "read_file", input: { path: "test.txt" } }, - ], - }, - { - role: "user", - content: [{ type: "tool_result", tool_use_id: longToolId, content: "file contents" }], - }, - ] - - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "Response" } }], - usage: { prompt_tokens: 100, completion_tokens: 20 }, - } - }, + const generator = handler.createMessage("system", [{ role: "user", content: "Hi" }]) + const results = [] + for await (const chunk of generator) { + results.push(chunk) } - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), + const usageChunk = results.find((r) => r.type === "usage") + expect(usageChunk).toMatchObject({ + type: "usage", + inputTokens: 0, + outputTokens: 0, }) - - const generator = handler.createMessage(systemPrompt, messages) - for await (const _chunk of generator) { - // Consume - } - - // Verify that tool IDs are truncated to 64 characters or less - const createCall = mockCreate.mock.calls[0][0] - const assistantMessage = createCall.messages.find( - (msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0, - ) - const toolMessage = createCall.messages.find((msg: any) => msg.role === "tool") - - expect(assistantMessage).toBeDefined() - expect(assistantMessage.tool_calls[0].id.length).toBeLessThanOrEqual(64) - - expect(toolMessage).toBeDefined() - expect(toolMessage.tool_call_id.length).toBeLessThanOrEqual(64) - }) - - it("should not modify tool IDs that are already within 64 characters", async () => { - const optionsWithBedrock: ApiHandlerOptions = { - ...mockOptions, - litellmModelId: "bedrock/anthropic.claude-3-sonnet", - } - handler = new LiteLLMHandler(optionsWithBedrock) - - vi.spyOn(handler as any, "fetchModel").mockResolvedValue({ - id: "bedrock/anthropic.claude-3-sonnet", - info: { ...litellmDefaultModelInfo, maxTokens: 8192 }, - }) - - // Create a tool ID within 64 characters - const shortToolId = "toolu_01ABC123" // Well under 64 characters - - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: [ - { type: "text", text: "I'll help you with that." }, - { type: "tool_use", id: shortToolId, name: "read_file", input: { path: "test.txt" } }, - ], - }, - { - role: "user", - content: [{ type: "tool_result", tool_use_id: shortToolId, content: "file contents" }], - }, - ] - - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "Response" } }], - usage: { prompt_tokens: 100, completion_tokens: 20 }, - } - }, - } - - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), - }) - - const generator = handler.createMessage(systemPrompt, messages) - for await (const _chunk of generator) { - // Consume - } - - // Verify that tool IDs are unchanged - const createCall = mockCreate.mock.calls[0][0] - const assistantMessage = createCall.messages.find( - (msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0, - ) - const toolMessage = createCall.messages.find((msg: any) => msg.role === "tool") - - expect(assistantMessage).toBeDefined() - expect(assistantMessage.tool_calls[0].id).toBe(shortToolId) - - expect(toolMessage).toBeDefined() - expect(toolMessage.tool_call_id).toBe(shortToolId) - }) - - it("should maintain uniqueness with hash suffix when truncating", async () => { - const optionsWithBedrock: ApiHandlerOptions = { - ...mockOptions, - litellmModelId: "bedrock/anthropic.claude-3-sonnet", - } - handler = new LiteLLMHandler(optionsWithBedrock) - - vi.spyOn(handler as any, "fetchModel").mockResolvedValue({ - id: "bedrock/anthropic.claude-3-sonnet", - info: { ...litellmDefaultModelInfo, maxTokens: 8192 }, - }) - - // Create two tool IDs that differ only near the end - const longToolId1 = "toolu_" + "a".repeat(60) + "_suffix1" - const longToolId2 = "toolu_" + "a".repeat(60) + "_suffix2" - - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: [ - { type: "text", text: "I'll help." }, - { type: "tool_use", id: longToolId1, name: "read_file", input: { path: "test1.txt" } }, - { type: "tool_use", id: longToolId2, name: "read_file", input: { path: "test2.txt" } }, - ], - }, - { - role: "user", - content: [ - { type: "tool_result", tool_use_id: longToolId1, content: "file1 contents" }, - { type: "tool_result", tool_use_id: longToolId2, content: "file2 contents" }, - ], - }, - ] - - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "Response" } }], - usage: { prompt_tokens: 100, completion_tokens: 20 }, - } - }, - } - - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), - }) - - const generator = handler.createMessage(systemPrompt, messages) - for await (const _chunk of generator) { - // Consume - } - - // Verify that truncated tool IDs are unique (hash suffix ensures this) - const createCall = mockCreate.mock.calls[0][0] - const assistantMessage = createCall.messages.find( - (msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0, - ) - - expect(assistantMessage).toBeDefined() - expect(assistantMessage.tool_calls).toHaveLength(2) - - const id1 = assistantMessage.tool_calls[0].id - const id2 = assistantMessage.tool_calls[1].id - - // Both should be truncated to 64 characters - expect(id1.length).toBeLessThanOrEqual(64) - expect(id2.length).toBeLessThanOrEqual(64) - - // They should be different (hash suffix ensures uniqueness) - expect(id1).not.toBe(id2) }) }) }) diff --git a/src/api/providers/lite-llm.ts b/src/api/providers/lite-llm.ts index cf8d16a112..1566ea9ba0 100644 --- a/src/api/providers/lite-llm.ts +++ b/src/api/providers/lite-llm.ts @@ -1,331 +1,112 @@ -import OpenAI from "openai" -import { Anthropic } from "@anthropic-ai/sdk" // Keep for type usage only +/** + * LiteLLM provider handler using Vercel AI SDK. + * + * This handler uses @ai-sdk/openai-compatible to communicate with LiteLLM proxy servers. + * LiteLLM follows the OpenAI API format, making it compatible with the OpenAI-compatible provider. + * Models are dynamically fetched from the LiteLLM server via /v1/model/info. + */ -import { litellmDefaultModelId, litellmDefaultModelInfo } from "@roo-code/types" +import { Anthropic } from "@anthropic-ai/sdk" +import { LanguageModel } from "ai" -import { calculateApiCostOpenAI } from "../../shared/cost" +import { litellmDefaultModelId, litellmDefaultModelInfo, type ModelInfo, type ModelRecord } from "@roo-code/types" -import { ApiHandlerOptions } from "../../shared/api" +import { type ApiHandlerOptions, getModelMaxOutputTokens } from "../../shared/api" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" -import { convertToOpenAiMessages } from "../transform/openai-format" -import { sanitizeOpenAiCallId } from "../../utils/tool-id" +import { OpenAICompatibleHandler } from "./openai-compatible" +import { getModels, getModelsFromCache } from "./fetchers/modelCache" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { RouterProvider } from "./router-provider" -/** - * LiteLLM provider handler - * - * This handler uses the LiteLLM API to proxy requests to various LLM providers. - * It follows the OpenAI API format for compatibility. - */ -export class LiteLLMHandler extends RouterProvider implements SingleCompletionHandler { +export class LiteLLMHandler extends OpenAICompatibleHandler implements SingleCompletionHandler { + private models: ModelRecord = {} + constructor(options: ApiHandlerOptions) { - super({ - options, - name: "litellm", - baseURL: `${options.litellmBaseUrl || "http://localhost:4000"}`, + const modelId = options.litellmModelId || litellmDefaultModelId + + super(options, { + providerName: "LiteLLM", + baseURL: options.litellmBaseUrl || "http://localhost:4000", apiKey: options.litellmApiKey || "dummy-key", - modelId: options.litellmModelId, - defaultModelId: litellmDefaultModelId, - defaultModelInfo: litellmDefaultModelInfo, + modelId, + modelInfo: litellmDefaultModelInfo, + temperature: options.modelTemperature ?? 0, + modelMaxTokens: options.modelMaxTokens, }) } - private isGpt5(modelId: string): boolean { - // Match gpt-5, gpt5, and variants like gpt-5o, gpt-5-turbo, gpt5-preview, gpt-5.1 - // Avoid matching gpt-50, gpt-500, etc. - return /\bgpt-?5(?!\d)/i.test(modelId) + private async fetchModel() { + this.models = await getModels({ + provider: "litellm", + apiKey: this.config.apiKey, + baseUrl: this.config.baseURL, + }) + return this.getModel() } - /** - * Detect if the model is a Gemini model that requires thought signature handling. - * Gemini 3 models validate thought signatures for tool/function calling steps. - */ - private isGeminiModel(modelId: string): boolean { - // Match various Gemini model patterns: - // - gemini-3-pro, gemini-3-flash, gemini-3-* - // - gemini 3 pro, Gemini 3 Pro (space-separated, case-insensitive) - // - gemini/gemini-3-*, google/gemini-3-* - // - vertex_ai/gemini-3-*, vertex/gemini-3-* - // Also match Gemini 2.5+ models which use similar validation - const lowerModelId = modelId.toLowerCase() + override getModel(): { id: string; info: ModelInfo } { + const id = this.config.modelId || litellmDefaultModelId + + if (this.models[id]) { + return { id, info: this.models[id] } + } + + const cachedModels = getModelsFromCache("litellm") + if (cachedModels?.[id]) { + this.models = cachedModels + return { id, info: cachedModels[id] } + } + + return { id: this.config.modelId || litellmDefaultModelId, info: litellmDefaultModelInfo } + } + + protected override getLanguageModel(): LanguageModel { + const { id } = this.getModel() + return this.provider(id) + } + + protected override getMaxOutputTokens(): number | undefined { + const { id, info } = this.getModel() return ( - // Match hyphenated versions: gemini-3, gemini-2.5 - lowerModelId.includes("gemini-3") || - lowerModelId.includes("gemini-2.5") || - // Match space-separated versions: "gemini 3", "gemini 2.5" - // This handles model names like "Gemini 3 Pro" from LiteLLM model groups - lowerModelId.includes("gemini 3") || - lowerModelId.includes("gemini 2.5") || - // Also match provider-prefixed versions - /\b(gemini|google|vertex_ai|vertex)\/gemini[-\s](3|2\.5)/i.test(modelId) + getModelMaxOutputTokens({ + modelId: id, + model: info, + settings: this.options, + format: "openai", + }) ?? undefined ) } - /** - * Inject thought signatures for Gemini models via provider_specific_fields. - * This is required when switching from other models to Gemini to satisfy API validation - * for function calls that weren't generated by Gemini (and thus lack thought signatures). - * - * Per LiteLLM documentation: - * - Thought signatures are stored in provider_specific_fields.thought_signature of tool calls - * - The dummy signature base64("skip_thought_signature_validator") bypasses validation - * - * We inject the dummy signature on EVERY tool call unconditionally to ensure Gemini - * doesn't complain about missing/corrupted signatures when conversation history - * contains tool calls from other models (like Claude). - */ - private injectThoughtSignatureForGemini( - openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[], - ): OpenAI.Chat.ChatCompletionMessageParam[] { - // Base64 encoded "skip_thought_signature_validator" as per LiteLLM docs - const dummySignature = Buffer.from("skip_thought_signature_validator").toString("base64") - - return openAiMessages.map((msg) => { - if (msg.role === "assistant") { - const toolCalls = (msg as any).tool_calls as any[] | undefined - - // Only process if there are tool calls - if (toolCalls && toolCalls.length > 0) { - // Inject dummy signature into ALL tool calls' provider_specific_fields - // This ensures Gemini doesn't reject tool calls from other models - const updatedToolCalls = toolCalls.map((tc) => ({ - ...tc, - provider_specific_fields: { - ...(tc.provider_specific_fields || {}), - thought_signature: dummySignature, - }, - })) - - return { - ...msg, - tool_calls: updatedToolCalls, - } - } - } - return msg - }) - } - override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const { id: modelId, info } = await this.fetchModel() - - const openAiMessages = convertToOpenAiMessages(messages, { - normalizeToolCallId: sanitizeOpenAiCallId, - }) - - // Prepare messages with cache control if enabled and supported - let systemMessage: OpenAI.Chat.ChatCompletionMessageParam - let enhancedMessages: OpenAI.Chat.ChatCompletionMessageParam[] - - if (this.options.litellmUsePromptCache && info.supportsPromptCache) { - // Create system message with cache control in the proper format - systemMessage = { - role: "system", - content: [ - { - type: "text", - text: systemPrompt, - cache_control: { type: "ephemeral" }, - } as any, - ], - } - - // Find the last two user messages to apply caching - const userMsgIndices = openAiMessages.reduce( - (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), - [] as number[], - ) - const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 - const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 - - // Apply cache_control to the last two user messages - enhancedMessages = openAiMessages.map((message, index) => { - if ((index === lastUserMsgIndex || index === secondLastUserMsgIndex) && message.role === "user") { - // Handle both string and array content types - if (typeof message.content === "string") { - return { - ...message, - content: [ - { - type: "text", - text: message.content, - cache_control: { type: "ephemeral" }, - } as any, - ], - } - } else if (Array.isArray(message.content)) { - // Apply cache control to the last content item in the array - return { - ...message, - content: message.content.map((content, contentIndex) => - contentIndex === message.content.length - 1 - ? ({ - ...content, - cache_control: { type: "ephemeral" }, - } as any) - : content, - ), - } - } - } - return message - }) - } else { - // No cache control - use simple format - systemMessage = { role: "system", content: systemPrompt } - enhancedMessages = openAiMessages - } - - // Required by some providers; others default to max tokens allowed - let maxTokens: number | undefined = info.maxTokens ?? undefined - - // Check if this is a GPT-5 model that requires max_completion_tokens instead of max_tokens - const isGPT5Model = this.isGpt5(modelId) - - // For Gemini models with native protocol: inject fake reasoning.encrypted block for tool calls - // This is required when switching from other models to Gemini to satisfy API validation. - // Gemini 3 models validate thought signatures for function calls, and when conversation - // history contains tool calls from other models (like Claude), they lack the required - // signatures. The "skip_thought_signature_validator" value bypasses this validation. - const isGemini = this.isGeminiModel(modelId) - let processedMessages = enhancedMessages - if (isGemini) { - processedMessages = this.injectThoughtSignatureForGemini(enhancedMessages) - } - - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { - model: modelId, - messages: [systemMessage, ...processedMessages], - stream: true, - stream_options: { - include_usage: true, - }, - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - } - - // GPT-5 models require max_completion_tokens instead of the deprecated max_tokens parameter - if (isGPT5Model && maxTokens) { - requestOptions.max_completion_tokens = maxTokens - } else if (maxTokens) { - requestOptions.max_tokens = maxTokens - } - - if (this.supportsTemperature(modelId)) { - requestOptions.temperature = this.options.modelTemperature ?? 0 - } - - try { - const { data: completion } = await this.client.chat.completions.create(requestOptions).withResponse() - - let lastUsage - - for await (const chunk of completion) { - const delta = chunk.choices[0]?.delta - const usage = chunk.usage as LiteLLMUsage - - if (delta?.content) { - yield { type: "text", text: delta.content } - } - - // Handle tool calls in stream - emit partial chunks for NativeToolCallParser - if (delta?.tool_calls) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - if (usage) { - lastUsage = usage - } - } - - if (lastUsage) { - // Extract cache-related information if available - // LiteLLM may use different field names for cache tokens - const cacheWriteTokens = - lastUsage.cache_creation_input_tokens || (lastUsage as any).prompt_cache_miss_tokens || 0 - const cacheReadTokens = - lastUsage.prompt_tokens_details?.cached_tokens || - (lastUsage as any).cache_read_input_tokens || - (lastUsage as any).prompt_cache_hit_tokens || - 0 - - const { totalCost } = calculateApiCostOpenAI( - info, - lastUsage.prompt_tokens || 0, - lastUsage.completion_tokens || 0, - cacheWriteTokens, - cacheReadTokens, - ) - - const usageData: ApiStreamUsageChunk = { - type: "usage", - inputTokens: lastUsage.prompt_tokens || 0, - outputTokens: lastUsage.completion_tokens || 0, - cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, - cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, - totalCost, - } - - yield usageData - } - } catch (error) { - if (error instanceof Error) { - throw new Error(`LiteLLM streaming error: ${error.message}`) - } - throw error - } + await this.fetchModel() + yield* super.createMessage(systemPrompt, messages, metadata) } - async completePrompt(prompt: string): Promise { - const { id: modelId, info } = await this.fetchModel() + override async completePrompt(prompt: string): Promise { + await this.fetchModel() + return super.completePrompt(prompt) + } - // Check if this is a GPT-5 model that requires max_completion_tokens instead of max_tokens - const isGPT5Model = this.isGpt5(modelId) - - try { - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { - model: modelId, - messages: [{ role: "user", content: prompt }], - } - - if (this.supportsTemperature(modelId)) { - requestOptions.temperature = this.options.modelTemperature ?? 0 - } - - // GPT-5 models require max_completion_tokens instead of the deprecated max_tokens parameter - if (isGPT5Model && info.maxTokens) { - requestOptions.max_completion_tokens = info.maxTokens - } else if (info.maxTokens) { - requestOptions.max_tokens = info.maxTokens - } - - const response = await this.client.chat.completions.create(requestOptions) - return response.choices[0]?.message.content || "" - } catch (error) { - if (error instanceof Error) { - throw new Error(`LiteLLM completion error: ${error.message}`) - } - throw error + protected override processUsageMetrics(usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + raw?: Record + }): ApiStreamUsageChunk { + return { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadTokens: usage.details?.cachedInputTokens, + reasoningTokens: usage.details?.reasoningTokens, } } } - -// LiteLLM usage may include an extra field for Anthropic use cases. -interface LiteLLMUsage extends OpenAI.CompletionUsage { - cache_creation_input_tokens?: number -} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index f6af3d5c76..4607d05121 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -456,7 +456,7 @@ export class ClineProvider // Removes and destroys the top Cline instance (the current finished task), // activating the previous one (resuming the parent task). - async removeClineFromStack() { + async removeClineFromStack(options?: { skipDelegationRepair?: boolean }) { if (this.clineStack.length === 0) { return } @@ -465,6 +465,11 @@ export class ClineProvider let task = this.clineStack.pop() if (task) { + // Capture delegation metadata before abort/dispose, since abortTask(true) + // is async and the task reference is cleared afterwards. + const childTaskId = task.taskId + const parentTaskId = task.parentTaskId + task.emit(RooCodeEventName.TaskUnfocused) try { @@ -488,6 +493,37 @@ export class ClineProvider // Make sure no reference kept, once promises end it will be // garbage collected. task = undefined + + // Delegation-aware parent metadata repair: + // If the popped task was a delegated child, repair the parent's metadata + // so it transitions from "delegated" back to "active" and becomes resumable + // from the task history list. + // Skip when called from delegateParentAndOpenChild() during nested delegation + // transitions (A→B→C), where the caller intentionally replaces the active + // child and will update the parent to point at the new child. + if (parentTaskId && childTaskId && !options?.skipDelegationRepair) { + try { + const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) + + if (parentHistory.status === "delegated" && parentHistory.awaitingChildId === childTaskId) { + await this.updateTaskHistory({ + ...parentHistory, + status: "active", + awaitingChildId: undefined, + }) + this.log( + `[ClineProvider#removeClineFromStack] Repaired parent ${parentTaskId} metadata: delegated → active (child ${childTaskId} removed)`, + ) + } + } catch (err) { + // Non-fatal: log but do not block the pop operation. + this.log( + `[ClineProvider#removeClineFromStack] Failed to repair parent metadata for ${parentTaskId} (non-fatal): ${ + err instanceof Error ? err.message : String(err) + }`, + ) + } + } } } @@ -2080,7 +2116,6 @@ export class ClineProvider historyPreviewCollapsed, reasoningBlockCollapsed, enterBehavior, - taskHeaderHighlightEnabled, cloudUserInfo, cloudIsAuthenticated, sharingEnabled, @@ -2226,7 +2261,6 @@ export class ClineProvider historyPreviewCollapsed: historyPreviewCollapsed ?? false, reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, enterBehavior: enterBehavior ?? "send", - taskHeaderHighlightEnabled: taskHeaderHighlightEnabled ?? false, cloudUserInfo, cloudIsAuthenticated: cloudIsAuthenticated ?? false, cloudAuthSkipModel: this.context.globalState.get("roo-auth-skip-model") ?? false, @@ -3270,7 +3304,7 @@ export class ClineProvider // This ensures we never have >1 tasks open at any time during delegation. // Await abort completion to ensure clean disposal and prevent unhandled rejections. try { - await this.removeClineFromStack() + await this.removeClineFromStack({ skipDelegationRepair: true }) } catch (error) { this.log( `[delegateParentAndOpenChild] Error during parent disposal (non-fatal): ${ diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index ff516425c4..d5424b7422 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -68,8 +68,7 @@ const TaskHeader = ({ todos, }: TaskHeaderProps) => { const { t } = useTranslation() - const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive, taskHeaderHighlightEnabled } = - useExtensionState() + const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive } = useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false) @@ -77,34 +76,19 @@ const TaskHeader = ({ autoOpenOnAuth: false, }) - // Determine if this is a subtask (has a parent) - const isSubtask = !!parentTaskId - - // Find the last message that isn't a resume action (shared by isTaskComplete and highlightClass) - const lastRelevantMessage = useMemo(() => { - const msgs = clineMessages || [] - const idx = findLastIndex(msgs, (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task")) - return idx !== -1 ? msgs[idx] : undefined - }, [clineMessages]) - // Check if the task is complete by looking at the last relevant message (skipping resume messages) - const isTaskComplete = lastRelevantMessage?.ask === "completion_result" - - // Compute highlight CSS class: green for task complete, yellow for user attention needed - const highlightClass = useMemo(() => { - if (!taskHeaderHighlightEnabled || isSubtask) return undefined - if (!lastRelevantMessage || lastRelevantMessage.partial) return undefined - - if (lastRelevantMessage.ask === "completion_result") { - return "task-header-highlight-green" - } - - if (lastRelevantMessage.ask) { - return "task-header-highlight-yellow" - } - - return undefined - }, [taskHeaderHighlightEnabled, isSubtask, lastRelevantMessage]) + const isTaskComplete = + clineMessages && clineMessages.length > 0 + ? (() => { + const lastRelevantIndex = findLastIndex( + clineMessages, + (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"), + ) + return lastRelevantIndex !== -1 + ? clineMessages[lastRelevantIndex]?.ask === "completion_result" + : false + })() + : false useEffect(() => { const timer = setTimeout(() => { @@ -157,6 +141,9 @@ const TaskHeader = ({ const hasTodos = todos && Array.isArray(todos) && todos.length > 0 + // Determine if this is a subtask (has a parent) + const isSubtask = !!parentTaskId + const handleBackToParent = () => { if (parentTaskId) { vscode.postMessage({ type: "showTaskWithId", text: parentTaskId }) @@ -187,14 +174,12 @@ const TaskHeader = ({ )}
{ // Don't expand if clicking on todos section diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 7414d7a9c5..c4ebe06973 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -40,7 +40,6 @@ let mockExtensionState: { apiConfiguration: ProviderSettings currentTaskItem: { id: string } | null clineMessages: any[] - taskHeaderHighlightEnabled?: boolean } = { apiConfiguration: { apiProvider: "anthropic", @@ -49,7 +48,6 @@ let mockExtensionState: { } as ProviderSettings, currentTaskItem: { id: "test-task-id" }, clineMessages: [], - taskHeaderHighlightEnabled: false, } // Mock the ExtensionStateContext @@ -217,7 +215,6 @@ describe("TaskHeader", () => { } as ProviderSettings, currentTaskItem: { id: "test-task-id" }, clineMessages: [], - taskHeaderHighlightEnabled: false, } }) @@ -426,175 +423,6 @@ describe("TaskHeader", () => { }) }) - describe("Task header highlight", () => { - const completionMessages = [ - { - type: "ask", - ask: "completion_result", - ts: Date.now(), - text: "Task completed!", - }, - ] - - beforeEach(() => { - mockExtensionState = { - apiConfiguration: { - apiProvider: "anthropic", - apiKey: "test-api-key", - apiModelId: "claude-3-opus-20240229", - } as ProviderSettings, - currentTaskItem: { id: "test-task-id" }, - clineMessages: [], - taskHeaderHighlightEnabled: false, - } - }) - - it("should apply green highlight class when task is complete and highlight is enabled", () => { - mockExtensionState = { - ...mockExtensionState, - clineMessages: completionMessages, - taskHeaderHighlightEnabled: true, - } - - renderTaskHeader() - - const container = screen.getByTestId("task-header-container") - expect(container.classList.contains("task-header-highlight-green")).toBe(true) - expect(container.classList.contains("task-header-highlight-yellow")).toBe(false) - }) - - it("should apply yellow highlight class when task needs user attention and highlight is enabled", () => { - mockExtensionState = { - ...mockExtensionState, - clineMessages: [ - { - type: "ask", - ask: "tool", - ts: Date.now(), - text: "Need permission to use tool", - }, - ], - taskHeaderHighlightEnabled: true, - } - - renderTaskHeader() - - const container = screen.getByTestId("task-header-container") - expect(container.classList.contains("task-header-highlight-yellow")).toBe(true) - expect(container.classList.contains("task-header-highlight-green")).toBe(false) - }) - - it("should not apply highlight when highlight is disabled", () => { - mockExtensionState = { - ...mockExtensionState, - clineMessages: completionMessages, - taskHeaderHighlightEnabled: false, - } - - renderTaskHeader() - - const container = screen.getByTestId("task-header-container") - expect(container.classList.contains("task-header-highlight-green")).toBe(false) - expect(container.classList.contains("task-header-highlight-yellow")).toBe(false) - }) - - it("should not apply highlight when task is a subtask", () => { - mockExtensionState = { - ...mockExtensionState, - clineMessages: completionMessages, - taskHeaderHighlightEnabled: true, - } - - renderTaskHeader({ parentTaskId: "parent-task-123" }) - - const container = screen.getByTestId("task-header-container") - expect(container.classList.contains("task-header-highlight-green")).toBe(false) - expect(container.classList.contains("task-header-highlight-yellow")).toBe(false) - }) - - it("should not apply highlight when last message is partial", () => { - mockExtensionState = { - ...mockExtensionState, - clineMessages: [ - { - type: "ask", - ask: "completion_result", - ts: Date.now(), - text: "Task completed!", - partial: true, - }, - ], - taskHeaderHighlightEnabled: true, - } - - renderTaskHeader() - - const container = screen.getByTestId("task-header-container") - expect(container.classList.contains("task-header-highlight-green")).toBe(false) - expect(container.classList.contains("task-header-highlight-yellow")).toBe(false) - }) - - it("should not apply highlight when no clineMessages exist", () => { - mockExtensionState = { - ...mockExtensionState, - clineMessages: [], - taskHeaderHighlightEnabled: true, - } - - renderTaskHeader() - - const container = screen.getByTestId("task-header-container") - expect(container.classList.contains("task-header-highlight-green")).toBe(false) - expect(container.classList.contains("task-header-highlight-yellow")).toBe(false) - }) - - it("should not apply highlight when last relevant message has no ask type", () => { - mockExtensionState = { - ...mockExtensionState, - clineMessages: [{ type: "say", say: "text", ts: Date.now(), text: "Working..." }], - taskHeaderHighlightEnabled: true, - } - - renderTaskHeader() - - const container = screen.getByTestId("task-header-container") - expect(container.classList.contains("task-header-highlight-green")).toBe(false) - expect(container.classList.contains("task-header-highlight-yellow")).toBe(false) - }) - - it("should apply green class when completion_result is followed by resume messages", () => { - mockExtensionState = { - ...mockExtensionState, - clineMessages: [ - { - type: "ask", - ask: "completion_result", - ts: Date.now() - 2000, - text: "Task completed!", - }, - { - type: "ask", - ask: "resume_completed_task", - ts: Date.now() - 1000, - text: "Resume completed task?", - }, - { - type: "ask", - ask: "resume_task", - ts: Date.now(), - text: "Resume task?", - }, - ], - taskHeaderHighlightEnabled: true, - } - - renderTaskHeader() - - const container = screen.getByTestId("task-header-container") - expect(container.classList.contains("task-header-highlight-green")).toBe(true) - }) - }) - describe("Context window percentage calculation", () => { // The percentage should be calculated as: // contextTokens / (contextWindow - reservedForOutput) * 100 diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index db6f27e04e..dbac92538c 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -124,11 +124,6 @@ type SettingsViewProps = { targetSection?: string } -const withCachedStateDefaults = (state: ExtensionStateContextType): ExtensionStateContextType => ({ - ...state, - taskHeaderHighlightEnabled: state.taskHeaderHighlightEnabled ?? false, -}) - const SettingsView = forwardRef(({ onDone, targetSection }, ref) => { const { t } = useAppTranslation() @@ -152,7 +147,7 @@ const SettingsView = forwardRef(({ onDone, t const prevApiConfigName = useRef(currentApiConfigName) const confirmDialogHandler = useRef<() => void>() - const [cachedState, setCachedState] = useState(() => withCachedStateDefaults(extensionState)) + const [cachedState, setCachedState] = useState(() => extensionState) const { alwaysAllowReadOnly, @@ -230,7 +225,7 @@ const SettingsView = forwardRef(({ onDone, t return } - setCachedState((prevCachedState) => withCachedStateDefaults({ ...prevCachedState, ...extensionState })) + setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState })) prevApiConfigName.current = currentApiConfigName setChangeDetected(false) }, [currentApiConfigName, extensionState]) @@ -238,7 +233,7 @@ const SettingsView = forwardRef(({ onDone, t // Bust the cache when settings are imported. useEffect(() => { if (settingsImportedAt) { - setCachedState((prevCachedState) => withCachedStateDefaults({ ...prevCachedState, ...extensionState })) + setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState })) setChangeDetected(false) } }, [settingsImportedAt, extensionState]) @@ -435,7 +430,6 @@ const SettingsView = forwardRef(({ onDone, t includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true, reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, enterBehavior: enterBehavior ?? "send", - taskHeaderHighlightEnabled, includeCurrentTime: includeCurrentTime ?? true, includeCurrentCost: includeCurrentCost ?? true, showQuestionsOneByOne: showQuestionsOneByOne ?? false, @@ -477,7 +471,7 @@ const SettingsView = forwardRef(({ onDone, t (confirm: boolean) => { if (confirm) { // Discard changes: Reset state and flag - setCachedState(withCachedStateDefaults(extensionState)) // Revert to original state + setCachedState(extensionState) // Revert to original state setChangeDetected(false) // Reset change flag confirmDialogHandler.current?.() // Execute the pending action (e.g., tab switch) } @@ -928,10 +922,10 @@ const SettingsView = forwardRef(({ onDone, t {/* UI Section */} {renderTab === "ui" && ( )} diff --git a/webview-ui/src/components/settings/UISettings.tsx b/webview-ui/src/components/settings/UISettings.tsx index 7e7066b66a..9f65c350c6 100644 --- a/webview-ui/src/components/settings/UISettings.tsx +++ b/webview-ui/src/components/settings/UISettings.tsx @@ -71,26 +71,6 @@ export const UISettings = ({
- {/* Task Header Highlight Setting */} - -
- - handleTaskHeaderHighlightChange((event.target as HTMLInputElement).checked) - } - data-testid="task-header-highlight-checkbox"> - {t("settings:ui.taskCompleteColor.label")} - -
- {t("settings:ui.taskCompleteColor.description")} -
-
-
- {/* Collapse Thinking Messages Setting */} - handleReasoningBlockCollapsedChange((event.target as HTMLInputElement).checked) - } + onChange={(e: any) => handleReasoningBlockCollapsedChange(e.target.checked)} data-testid="collapse-thinking-checkbox"> {t("settings:ui.collapseThinking.label")} @@ -119,9 +97,7 @@ export const UISettings = ({
- handleEnterBehaviorChange((event.target as HTMLInputElement).checked) - } + onChange={(e: any) => handleEnterBehaviorChange(e.target.checked)} data-testid="enter-behavior-checkbox"> {t("settings:ui.requireCtrlEnterToSend.label", { primaryMod })} @@ -150,6 +126,24 @@ export const UISettings = ({
+ + {/* Task Header Highlight Setting */} + +
+ handleTaskHeaderHighlightChange(e.target.checked)} + data-testid="task-header-highlight-checkbox"> + {t("settings:ui.taskHeaderHighlight.label")} + +
+ {t("settings:ui.taskHeaderHighlight.description")} +
+
+
diff --git a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx index 3a61ec2782..90d1ae5d23 100644 --- a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx @@ -2,33 +2,6 @@ import { render, fireEvent, waitFor } from "@testing-library/react" import { describe, it, expect, vi } from "vitest" import { UISettings } from "../UISettings" -// Mock useAppTranslation -vi.mock("@/i18n/TranslationContext", () => ({ - useAppTranslation: () => ({ - t: (key: string) => key, - }), -})) - -// Mock telemetry -vi.mock("@/utils/TelemetryClient", () => ({ - telemetryClient: { capture: vi.fn() }, -})) - -// Mock SearchableSetting to render children directly -vi.mock("../SearchableSetting", () => ({ - SearchableSetting: ({ children }: { children: React.ReactNode }) =>
{children}
, -})) - -// Mock SectionHeader to render children -vi.mock("../SectionHeader", () => ({ - SectionHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, -})) - -// Mock Section to render children -vi.mock("../Section", () => ({ - Section: ({ children }: { children: React.ReactNode }) =>
{children}
, -})) - describe("UISettings", () => { const defaultProps = { reasoningBlockCollapsed: false, @@ -70,55 +43,4 @@ describe("UISettings", () => { rerender() expect(checkbox.checked).toBe(true) }) - - describe("Task header highlight", () => { - it("renders checkbox unchecked when taskHeaderHighlightEnabled is false", () => { - const { getByTestId } = render() - const checkbox = getByTestId("task-header-highlight-checkbox") as HTMLInputElement - expect(checkbox).toBeTruthy() - expect(checkbox.checked).toBe(false) - }) - - it("renders checkbox checked when taskHeaderHighlightEnabled is true", () => { - const { getByTestId } = render() - const checkbox = getByTestId("task-header-highlight-checkbox") as HTMLInputElement - expect(checkbox.checked).toBe(true) - }) - - it("calls setCachedStateField with true when toggling on", async () => { - const setCachedStateField = vi.fn() - const { getByTestId } = render( - , - ) - - const checkbox = getByTestId("task-header-highlight-checkbox") - fireEvent.click(checkbox) - - await waitFor(() => { - expect(setCachedStateField).toHaveBeenCalledWith("taskHeaderHighlightEnabled", true) - }) - }) - - it("calls setCachedStateField with false when toggling off", async () => { - const setCachedStateField = vi.fn() - const { getByTestId } = render( - , - ) - - const checkbox = getByTestId("task-header-highlight-checkbox") - fireEvent.click(checkbox) - - await waitFor(() => { - expect(setCachedStateField).toHaveBeenCalledWith("taskHeaderHighlightEnabled", false) - }) - }) - }) }) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 10856e4170..99f5510c76 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -139,7 +139,6 @@ export interface ExtensionStateContextType extends ExtensionState { setReasoningBlockCollapsed: (value: boolean) => void enterBehavior?: "send" | "newline" setEnterBehavior: (value: "send" | "newline") => void - taskHeaderHighlightEnabled?: boolean autoCondenseContext: boolean setAutoCondenseContext: (value: boolean) => void autoCondenseContextPercent: number @@ -241,7 +240,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode historyPreviewCollapsed: false, // Initialize the new state (default to expanded) reasoningBlockCollapsed: true, // Default to collapsed enterBehavior: "send", // Default: Enter sends, Shift+Enter creates newline - taskHeaderHighlightEnabled: false, cloudUserInfo: null, cloudIsAuthenticated: false, cloudOrganizations: [], diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 5b8598403d..35dec2166f 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -1018,10 +1018,6 @@ } }, "ui": { - "taskCompleteColor": { - "label": "Ressaltat de la capçalera de tasca", - "description": "Ressalta la capçalera de la tasca amb un indicador de color quan les tasques es completen o necessiten la teva atenció, ajudant-te a detectar ràpidament quan cal actuar" - }, "collapseThinking": { "label": "Replega els missatges de pensament per defecte", "description": "Quan estigui activat, els blocs de pensament es replegaran per defecte fins que interactuïs amb ells" diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index db5604d245..65a4788895 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -1018,10 +1018,6 @@ } }, "ui": { - "taskCompleteColor": { - "label": "Aufgaben-Header-Hervorhebung", - "description": "Hebt den Aufgaben-Header mit einem Farbindikator hervor, wenn Aufgaben abgeschlossen werden oder deine Aufmerksamkeit erfordern, damit du schnell erkennst, wann eine Aktion nötig ist" - }, "collapseThinking": { "label": "Gedankenblöcke standardmäßig ausblenden", "description": "Wenn aktiviert, werden Gedankenblöcke standardmäßig ausgeblendet, bis du mit ihnen interagierst" diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index fd448227ec..bd68be16af 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -157,10 +157,6 @@ "footer": "Create your own skills with the Skill Writer mode, available in the Modes Marketplace." }, "ui": { - "taskCompleteColor": { - "label": "Task Header Highlight", - "description": "Highlights the task header with a color indicator when tasks complete or need your attention, helping you quickly spot when action is required" - }, "collapseThinking": { "label": "Collapse Thinking messages by default", "description": "When enabled, thinking blocks will be collapsed by default until you interact with them" diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 5a3bf2143a..c761787ece 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -1018,10 +1018,6 @@ } }, "ui": { - "taskCompleteColor": { - "label": "Resaltado del encabezado de tarea", - "description": "Resalta el encabezado de la tarea con un indicador de color cuando las tareas se completan o necesitan tu atención, ayudándote a detectar rápidamente cuándo se requiere acción" - }, "collapseThinking": { "label": "Colapsar mensajes de pensamiento por defecto", "description": "Cuando está activado, los bloques de pensamiento se colapsarán por defecto hasta que interactúes con ellos" diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index b6f9077217..598dd1d663 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -1018,10 +1018,6 @@ } }, "ui": { - "taskCompleteColor": { - "label": "Surlignage de l'en-tête de tâche", - "description": "Surligne l'en-tête de la tâche avec un indicateur de couleur lorsque les tâches sont terminées ou nécessitent ton attention, t'aidant à repérer rapidement quand une action est requise" - }, "collapseThinking": { "label": "Réduire les messages de réflexion par défaut", "description": "Si activé, les blocs de réflexion seront réduits par défaut jusqu'à ce que vous interagissiez avec eux" diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 8dbdf58465..fe7c3ea879 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -1018,10 +1018,6 @@ } }, "ui": { - "taskCompleteColor": { - "label": "टास्क हेडर हाइलाइट", - "description": "जब टास्क पूरे होते हैं या तुम्हारे ध्यान की ज़रूरत होती है, तो टास्क हेडर को रंग संकेतक से हाइलाइट करता है, ताकि तुम जल्दी से पहचान सको कि कब कार्रवाई ज़रूरी है" - }, "collapseThinking": { "label": "सोच संदेशों को डिफ़ॉल्ट रूप से संक्षिप्त करें", "description": "सक्षम होने पर, सोच ब्लॉक आपके द्वारा उनके साथ इंटरैक्ट करने तक डिफ़ॉल्ट रूप से संक्षिप्त रहेंगे" diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 3163f12bdc..4a75fca7cd 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -1018,10 +1018,6 @@ } }, "ui": { - "taskCompleteColor": { - "label": "Sorotan Header Tugas", - "description": "Menyoroti header tugas dengan indikator warna saat tugas selesai atau membutuhkan perhatianmu, membantumu dengan cepat mengetahui kapan tindakan diperlukan" - }, "collapseThinking": { "label": "Ciutkan pesan Berpikir secara default", "description": "Jika diaktifkan, blok berpikir akan diciutkan secara default sampai Anda berinteraksi dengannya" diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index a9130a2bb1..ea3ddd27de 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -1018,10 +1018,6 @@ } }, "ui": { - "taskCompleteColor": { - "label": "Evidenziazione intestazione attività", - "description": "Evidenzia l'intestazione dell'attività con un indicatore di colore quando le attività vengono completate o richiedono la tua attenzione, aiutandoti a individuare rapidamente quando è necessaria un'azione" - }, "collapseThinking": { "label": "Comprimi i messaggi di pensiero per impostazione predefinita", "description": "Se abilitato, i blocchi di pensiero verranno compressi per impostazione predefinita finché non interagisci con essi" diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 42cc95422e..a227af893a 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -1018,10 +1018,6 @@ } }, "ui": { - "taskCompleteColor": { - "label": "タスクヘッダーのハイライト", - "description": "タスクが完了したり注意が必要なときに、タスクヘッダーをカラーインジケーターでハイライトし、アクションが必要なタイミングをすばやく把握できるようにします" - }, "collapseThinking": { "label": "デフォルトで思考メッセージを折りたたむ", "description": "有効にすると、操作するまで思考ブロックがデフォルトで折りたたまれます" diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index db8c5993c7..fecdad8a72 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -1018,10 +1018,6 @@ } }, "ui": { - "taskCompleteColor": { - "label": "작업 헤더 하이라이트", - "description": "작업이 완료되거나 주의가 필요할 때 작업 헤더를 색상 표시로 강조하여 조치가 필요한 시점을 빠르게 파악할 수 있도록 합니다" - }, "collapseThinking": { "label": "기본적으로 생각 메시지 접기", "description": "활성화하면 상호 작용할 때까지 생각 블록이 기본적으로 접힙니다" diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 4a97500b03..25a41c0de1 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -1018,10 +1018,6 @@ } }, "ui": { - "taskCompleteColor": { - "label": "Taakkop markering", - "description": "Markeert de taakkop met een kleurindicator wanneer taken voltooid zijn of je aandacht nodig hebben, zodat je snel kunt zien wanneer actie vereist is" - }, "collapseThinking": { "label": "Denkberichten standaard samenvouwen", "description": "Indien ingeschakeld, worden denkblokken standaard samengevouwen totdat je ermee interageert" diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 0cbbc4e38d..15c5125c7e 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -1018,10 +1018,6 @@ } }, "ui": { - "taskCompleteColor": { - "label": "Podświetlenie nagłówka zadania", - "description": "Podświetla nagłówek zadania kolorowym wskaźnikiem, gdy zadania zostaną ukończone lub wymagają twojej uwagi, pomagając szybko zauważyć, kiedy wymagane jest działanie" - }, "collapseThinking": { "label": "Domyślnie zwijaj komunikaty o myśleniu", "description": "Gdy włączone, bloki myślenia będą domyślnie zwinięte, dopóki nie wejdziesz z nimi w interakcję" diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 19941d1019..93b3160ff7 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -1018,10 +1018,6 @@ } }, "ui": { - "taskCompleteColor": { - "label": "Destaque do cabeçalho da tarefa", - "description": "Destaca o cabeçalho da tarefa com um indicador de cor quando as tarefas são concluídas ou precisam da sua atenção, ajudando você a identificar rapidamente quando uma ação é necessária" - }, "collapseThinking": { "label": "Recolher mensagens de pensamento por padrão", "description": "Quando ativado, os blocos de pensamento serão recolhidos por padrão até que você interaja com eles" diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 1d3ac46354..3e83eaa3c3 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -1018,10 +1018,6 @@ } }, "ui": { - "taskCompleteColor": { - "label": "Подсветка заголовка задачи", - "description": "Подсвечивает заголовок задачи цветовым индикатором при завершении задач или когда требуется твоё внимание, помогая быстро заметить, когда необходимо действие" - }, "collapseThinking": { "label": "Сворачивать сообщения о размышлениях по умолчанию", "description": "Если включено, блоки с размышлениями будут свернуты по умолчанию, пока вы не начнете с ними взаимодействовать" diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 07ad4a867a..b23ec07e4c 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -1018,10 +1018,6 @@ } }, "ui": { - "taskCompleteColor": { - "label": "Görev Başlığı Vurgulama", - "description": "Görevler tamamlandığında veya dikkatine ihtiyaç duyulduğunda görev başlığını renk göstergesiyle vurgular, eylem gerektiğinde hızlıca fark etmene yardımcı olur" - }, "collapseThinking": { "label": "Düşünme mesajlarını varsayılan olarak daralt", "description": "Etkinleştirildiğinde, düşünme blokları siz onlarla etkileşime girene kadar varsayılan olarak daraltılır" diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index f29137aa8a..c54d121e6e 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -1018,10 +1018,6 @@ } }, "ui": { - "taskCompleteColor": { - "label": "Đánh dấu tiêu đề tác vụ", - "description": "Đánh dấu tiêu đề tác vụ bằng chỉ báo màu khi tác vụ hoàn thành hoặc cần sự chú ý của bạn, giúp bạn nhanh chóng nhận biết khi nào cần hành động" - }, "collapseThinking": { "label": "Thu gọn tin nhắn Suy nghĩ theo mặc định", "description": "Khi được bật, các khối suy nghĩ sẽ được thu gọn theo mặc định cho đến khi bạn tương tác với chúng" diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 6239d0a76d..3f8de2759f 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -1018,10 +1018,6 @@ } }, "ui": { - "taskCompleteColor": { - "label": "任务标题高亮", - "description": "当任务完成或需要你关注时,通过颜色指示器高亮任务标题,帮助你快速发现需要操作的时机" - }, "collapseThinking": { "label": "默认折叠「思考」消息", "description": "启用后,「思考」块将默认折叠,直到您与其交互" diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 11cc2320c3..1692ea1d31 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -104,10 +104,6 @@ "footer": "使用斜線命令快速存取經常使用的提示詞和工作流程。" }, "ui": { - "taskCompleteColor": { - "label": "工作標題醒目提示", - "description": "當工作完成或需要你注意時,以顏色指示器醒目提示工作標題,幫助你快速發現需要採取行動的時機" - }, "collapseThinking": { "label": "預設折疊「思考」訊息", "description": "啟用後,「思考」塊將預設折疊,直到您與其互動" diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index 619c94ab09..ebb9a2fb90 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -590,30 +590,3 @@ input[cmdk-input]:focus { padding: 8px; margin: -8px; } - -/* Task header highlight overrides - force all children to use contrasting text. - Uses hardcoded background colors instead of theme-dependent --vscode-charts-* - variables to guarantee WCAG AA 4.5:1 contrast with text colors. */ -.task-header-highlight-green { - --vscode-foreground: white; - background-color: #15803d !important; /* green-700, WCAG AA ~6.5:1 with white */ -} -.task-header-highlight-green, -.task-header-highlight-green * { - color: white !important; -} -.task-header-highlight-green svg { - stroke: white !important; -} - -.task-header-highlight-yellow { - --vscode-foreground: black; - background-color: #ca8a04 !important; /* yellow-600, WCAG AA ~6.2:1 with black */ -} -.task-header-highlight-yellow, -.task-header-highlight-yellow * { - color: black !important; -} -.task-header-highlight-yellow svg { - stroke: black !important; -}