diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index a56a00fc355a..08dfe83c02c5 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -146,6 +146,8 @@ export const globalSettingsSchema = z.object({ customSupportPrompts: customSupportPromptsSchema.optional(), enhancementApiConfigId: z.string().optional(), includeTaskHistoryInEnhance: z.boolean().optional(), + autoSummarizeLongTitles: z.boolean().optional(), + titleSummarizationThreshold: z.number().min(50).optional(), historyPreviewCollapsed: z.boolean().optional(), reasoningBlockCollapsed: z.boolean().optional(), profileThresholds: z.record(z.string(), z.number()).optional(), @@ -318,6 +320,9 @@ export const EVALS_SETTINGS: RooCodeSettings = { mode: "code", // "architect", + autoSummarizeLongTitles: true, + titleSummarizationThreshold: 150, + customModes: [], } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2c20d0939c2a..f50137dfedba 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -94,6 +94,7 @@ import type { ClineMessage } from "@roo-code/types" import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence" import { getNonce } from "./getNonce" import { getUri } from "./getUri" +import { TitleSummarizer } from "./titleSummarizer" /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -2570,9 +2571,71 @@ export class ClineProvider `[createTask] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, ) + // Trigger asynchronous title summarization for long task messages + const autoSummarize = this.contextProxy.getValue("autoSummarizeLongTitles") ?? true + const threshold = this.contextProxy.getValue("titleSummarizationThreshold") ?? 150 + + if (autoSummarize && text && text.length > threshold) { + this.summarizeTaskTitle(task.taskId, text).catch((error: unknown) => { + this.log(`Failed to summarize task title: ${error instanceof Error ? error.message : String(error)}`) + }) + } + return task } + /** + * Summarize long task titles asynchronously and update the task history + * @param taskId - The ID of the task to update + * @param originalText - The original task text to summarize + */ + private async summarizeTaskTitle(taskId: string, originalText: string): Promise { + try { + // Get the current state to check for API configuration + const state = await this.getState() + + // Get the configurable threshold + const threshold = this.contextProxy.getValue("titleSummarizationThreshold") ?? 150 + + // Try to summarize the title + const result = await TitleSummarizer.summarizeTitle({ + text: originalText, + apiConfiguration: state.apiConfiguration, + customSupportPrompts: state.customSupportPrompts, + enhancementApiConfigId: state.enhancementApiConfigId, + listApiConfigMeta: state.listApiConfigMeta, + providerSettingsManager: this.providerSettingsManager, + maxLength: threshold, + }) + + // If summarization succeeded and produced a shorter title, update the task history + if (result.success && result.summarizedTitle && result.summarizedTitle.length < originalText.length) { + const history = this.getGlobalState("taskHistory") ?? [] + const taskHistoryItem = history.find((item) => item.id === taskId) + + if (taskHistoryItem) { + // Update the task field with the summarized version + taskHistoryItem.task = result.summarizedTitle + + // Update the history + await this.updateTaskHistory(taskHistoryItem) + + // Capture telemetry + TitleSummarizer.captureTelemetry(taskId, originalText.length, result.summarizedTitle.length) + + this.log( + `Task title summarized from ${originalText.length} to ${result.summarizedTitle.length} characters`, + ) + } + } else if (!result.success && result.error) { + this.log(`Title summarization failed: ${result.error}`) + } + } catch (error) { + // Silently fail - title summarization is not critical + this.log(`Title summarization error: ${error instanceof Error ? error.message : String(error)}`) + } + } + public async cancelTask(): Promise { const task = this.getCurrentTask() diff --git a/src/core/webview/__tests__/titleSummarizer.spec.ts b/src/core/webview/__tests__/titleSummarizer.spec.ts new file mode 100644 index 000000000000..02d3278e5b30 --- /dev/null +++ b/src/core/webview/__tests__/titleSummarizer.spec.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { TitleSummarizer } from "../titleSummarizer" +import type { ProviderSettings, ProviderSettingsEntry } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" +import { singleCompletionHandler } from "../../../utils/single-completion-handler" + +// Mock TelemetryService +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureEvent: vi.fn(), + }, + }, +})) + +// Mock singleCompletionHandler +vi.mock("../../../utils/single-completion-handler", () => ({ + singleCompletionHandler: vi.fn(), +})) + +describe("TitleSummarizer", () => { + const mockApiConfiguration: ProviderSettings = { + apiProvider: "anthropic", + apiKey: "test-key", + apiModelId: "claude-3-opus-20240229", + } + + const mockListApiConfigMeta: ProviderSettingsEntry[] = [ + { + id: "default", + name: "Default", + apiProvider: "anthropic", + }, + { + id: "enhancement", + name: "Enhancement", + apiProvider: "openai", + }, + ] + + const mockProviderSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + id: "enhancement", + name: "Enhancement", + apiProvider: "openai", + openAiApiKey: "test-openai-key", + openAiModelId: "gpt-4", + }), + } as any // Mock the ProviderSettingsManager type + + beforeEach(() => { + vi.clearAllMocks() + // Set default mock behavior + vi.mocked(singleCompletionHandler).mockResolvedValue("Short concise title") + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("summarizeTitle", () => { + it("should successfully summarize a long title", async () => { + const longText = + "I need help implementing a comprehensive user authentication system with OAuth2 support for Google, Facebook, and GitHub providers, including secure session management, password reset functionality, email verification, two-factor authentication, and proper error handling with rate limiting to prevent brute force attacks" + + const result = await TitleSummarizer.summarizeTitle({ + text: longText, + apiConfiguration: mockApiConfiguration, + maxLength: 150, + }) + + expect(result.success).toBe(true) + expect(result.summarizedTitle).toBe("Short concise title") + expect(result.summarizedTitle!.length).toBeLessThan(longText.length) + }) + + it("should return original text if it's already short", async () => { + const shortText = "Fix bug in login" + + const result = await TitleSummarizer.summarizeTitle({ + text: shortText, + apiConfiguration: mockApiConfiguration, + maxLength: 150, + }) + + expect(result.success).toBe(true) + // Text is already shorter than max length, so it returns as-is + expect(result.summarizedTitle).toBe(shortText) + }) + + it("should use enhancement API configuration when provided", async () => { + const longText = + "This is a very long title that definitely needs summarization to be more concise and readable for users, making it easier to understand the main point of the task at hand" + + const result = await TitleSummarizer.summarizeTitle({ + text: longText, + apiConfiguration: mockApiConfiguration, + enhancementApiConfigId: "enhancement", + listApiConfigMeta: mockListApiConfigMeta, + providerSettingsManager: mockProviderSettingsManager, + maxLength: 150, + }) + + // The function will call providerSettingsManager.getProfile if all conditions are met + expect(mockProviderSettingsManager.getProfile).toHaveBeenCalledWith({ id: "enhancement" }) + expect(result.success).toBe(true) + expect(result.summarizedTitle).toBe("Short concise title") + }) + + it("should handle API errors gracefully", async () => { + const longText = + "This is a very long text that definitely needs summarization to be more concise and readable for users, making it easier to understand the main point of the task at hand" + vi.mocked(singleCompletionHandler).mockRejectedValueOnce(new Error("API Error")) + + const result = await TitleSummarizer.summarizeTitle({ + text: longText, + apiConfiguration: mockApiConfiguration, + maxLength: 150, + }) + + expect(result.success).toBe(false) + expect(result.error).toBe("API Error") + expect(result.summarizedTitle).toBe(longText) + }) + + it("should handle missing API configuration", async () => { + const result = await TitleSummarizer.summarizeTitle({ + text: "Some text", + apiConfiguration: undefined as any, + maxLength: 150, + }) + + expect(result.success).toBe(false) + expect(result.error).toBe("No API configuration available") + expect(result.summarizedTitle).toBe("Some text") + }) + + it("should respect custom maxLength parameter", async () => { + const shortText = "Short text" + + const result = await TitleSummarizer.summarizeTitle({ + text: shortText, + apiConfiguration: mockApiConfiguration, + maxLength: 100, + }) + + expect(result.success).toBe(true) + // Text is already shorter than maxLength, returns as-is + expect(result.summarizedTitle).toBe(shortText) + }) + + it("should use custom support prompts when provided", async () => { + const customPrompts = { + SUMMARIZE_TITLE: "Custom summarization prompt: {{userInput}} (max 150 chars)", + } + + // Short text doesn't need summarization + const result = await TitleSummarizer.summarizeTitle({ + text: "Text to summarize", + apiConfiguration: mockApiConfiguration, + customSupportPrompts: customPrompts, + maxLength: 150, + }) + + expect(result.success).toBe(true) + expect(result.summarizedTitle).toBe("Text to summarize") + }) + + it("should handle empty response from API", async () => { + const longText = + "This is a very long text that definitely needs summarization to be more concise and readable for users, making it easier to understand the main point of the task at hand" + vi.mocked(singleCompletionHandler).mockResolvedValueOnce("") + + const result = await TitleSummarizer.summarizeTitle({ + text: longText, + apiConfiguration: mockApiConfiguration, + maxLength: 150, + }) + + expect(result.success).toBe(false) + expect(result.error).toBe("Received empty summarized title") + expect(result.summarizedTitle).toBe(longText) + }) + + it("should trim whitespace from summarized title", async () => { + const longText = + "This is a very long text that definitely needs summarization to be more concise and readable for users, making it easier to understand the main point of the task at hand" + vi.mocked(singleCompletionHandler).mockResolvedValueOnce(" Trimmed title \n") + + const result = await TitleSummarizer.summarizeTitle({ + text: longText, + apiConfiguration: mockApiConfiguration, + maxLength: 150, + }) + + expect(result.success).toBe(true) + expect(result.summarizedTitle).toBe("Trimmed title") + }) + }) + + describe("captureTelemetry", () => { + it("should not capture telemetry events (currently disabled)", () => { + const taskId = "test-task-123" + const originalLength = 250 + const summarizedLength = 100 + + TitleSummarizer.captureTelemetry(taskId, originalLength, summarizedLength) + + // Since telemetry is commented out in the implementation, it should not be called + expect(TelemetryService.instance.captureEvent).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/core/webview/titleSummarizer.ts b/src/core/webview/titleSummarizer.ts new file mode 100644 index 000000000000..9a323d52d773 --- /dev/null +++ b/src/core/webview/titleSummarizer.ts @@ -0,0 +1,125 @@ +import { ProviderSettings, ClineMessage, TelemetryEventName } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" +import { supportPrompt } from "../../shared/support-prompt" +import { singleCompletionHandler } from "../../utils/single-completion-handler" +import { ProviderSettingsManager } from "../config/ProviderSettingsManager" +import { ClineProvider } from "./ClineProvider" + +export interface TitleSummarizerOptions { + text: string + apiConfiguration: ProviderSettings + customSupportPrompts?: Record + listApiConfigMeta?: Array<{ id: string; name?: string }> + enhancementApiConfigId?: string + providerSettingsManager?: ProviderSettingsManager + maxLength?: number +} + +export interface TitleSummarizerResult { + success: boolean + summarizedTitle?: string + error?: string +} + +/** + * Summarizes long task titles using AI, similar to message enhancement + */ +export class TitleSummarizer { + /** + * Summarizes a task title using the configured AI provider + * @param options Configuration options for title summarization + * @returns Summarized title result with success status + */ + static async summarizeTitle(options: TitleSummarizerOptions): Promise { + try { + const { + text, + apiConfiguration, + customSupportPrompts, + listApiConfigMeta, + enhancementApiConfigId, + providerSettingsManager, + maxLength = 150, + } = options + + // Check if title needs summarization + if (text.length <= maxLength) { + return { + success: true, + summarizedTitle: text, + } + } + + // Determine which API configuration to use + let configToUse: ProviderSettings = apiConfiguration + + // Try to get enhancement config first, fall back to current config + if ( + enhancementApiConfigId && + listApiConfigMeta?.find(({ id }) => id === enhancementApiConfigId) && + providerSettingsManager + ) { + const { name: _, ...providerSettings } = await providerSettingsManager.getProfile({ + id: enhancementApiConfigId, + }) + + if (providerSettings.apiProvider) { + configToUse = providerSettings + } + } + + // Create the summarization prompt using the support prompt system + const summarizationPrompt = supportPrompt.create( + "SUMMARIZE_TITLE", + { userInput: text }, + customSupportPrompts, + ) + + // Call the single completion handler to get the summarized title + const summarizedTitle = await singleCompletionHandler(configToUse, summarizationPrompt) + + // Validate the summarized title + if (!summarizedTitle || summarizedTitle.trim().length === 0) { + throw new Error("Received empty summarized title") + } + + // Ensure the summarized title doesn't exceed the max length + const trimmedTitle = summarizedTitle.trim() + if (trimmedTitle.length > maxLength) { + // If the AI didn't respect the length limit, truncate manually + return { + success: true, + summarizedTitle: trimmedTitle.substring(0, maxLength - 3) + "...", + } + } + + return { + success: true, + summarizedTitle: trimmedTitle, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + summarizedTitle: options.text, // Return original text as fallback + } + } + } + + /** + * Captures telemetry for title summarization + * @param taskId Optional task ID for telemetry tracking + * @param originalLength Length of the original title + * @param summarizedLength Length of the summarized title + */ + static captureTelemetry(taskId?: string, originalLength?: number, summarizedLength?: number): void { + // TODO: Add telemetry event for title summarization when available + // if (TelemetryService.hasInstance()) { + // TelemetryService.instance.captureEvent(TelemetryEventName.TASK_TITLE_SUMMARIZED, { + // ...(taskId && { taskId }), + // originalLength: originalLength ?? 0, + // summarizedLength: summarizedLength ?? 0, + // }) + // } + } +} diff --git a/src/shared/support-prompt.ts b/src/shared/support-prompt.ts index 51f4310fc2e3..8b33c1159c33 100644 --- a/src/shared/support-prompt.ts +++ b/src/shared/support-prompt.ts @@ -44,6 +44,7 @@ type SupportPromptType = | "TERMINAL_FIX" | "TERMINAL_EXPLAIN" | "NEW_TASK" + | "SUMMARIZE_TITLE" const supportPromptConfigs: Record = { ENHANCE: { @@ -174,6 +175,19 @@ Please provide: NEW_TASK: { template: `\${userInput}`, }, + SUMMARIZE_TITLE: { + template: `Create a clear, concise title (under 150 characters) that captures the essence of this task: + + +\${userInput} + + +IMPORTANT: +- Keep it under 150 characters +- Be specific and descriptive +- No quotes or special formatting +- Reply with ONLY the title, nothing else`, + }, } as const export const supportPrompt = {