First pass at Title Summarizer

This commit is contained in:
Bruno Bergher 2025-10-02 20:12:13 +01:00
parent 13534cc8b7
commit 415e64fcd9
5 changed files with 420 additions and 0 deletions

View file

@ -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: [],
}

View file

@ -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<void> {
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<void> {
const task = this.getCurrentTask()

View file

@ -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()
})
})
})

View file

@ -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<string, any>
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<TitleSummarizerResult> {
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,
// })
// }
}
}

View file

@ -44,6 +44,7 @@ type SupportPromptType =
| "TERMINAL_FIX"
| "TERMINAL_EXPLAIN"
| "NEW_TASK"
| "SUMMARIZE_TITLE"
const supportPromptConfigs: Record<SupportPromptType, SupportPromptConfig> = {
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:
<task_description>
\${userInput}
</task_description>
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 = {