diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index c1c50cca1e..ff3a169316 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -1024,6 +1024,32 @@ export class TaskHistoryStore { }) } + /** + * Set the title of a history item only when it has no title yet, inside a + * single lock acquisition (in-process write lock plus the per-task file + * lock in `safeWriteJson`). Used by the background session-title flow so a + * generated title can never overwrite a title the user set by hand. + * + * @returns The updated item when a title was written, or `undefined` when + * the item already has a title (which is left untouched). + * @throws If the task ID is not present in the cache. + */ + public setTitleIfEmpty(taskId: string, title: string): Promise { + return this.withLock(async () => { + const current = this.cache.get(taskId) + if (!current) { + throw new Error(`[TaskHistoryStore] setTitleIfEmpty: task ${taskId} not found in cache`) + } + if (current.title && current.title.trim()) { + return undefined + } + // Deep-copy so the write cannot mutate cached state before persistence. + const updated = { ...structuredClone(current), title } + await this.upsertCore(updated) + return updated + }) + } + /** * Update two related HistoryItems within a single in-process lock acquisition. * Both updaters run synchronously (no I/O, no lock re-entry). Both writes diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts index 5b85c9b4e2..135faa9a54 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts @@ -418,4 +418,72 @@ describe("TaskHistoryStore cross-instance safety", () => { // B wrote last, so B's value wins. expect(final.totalCost).toBe(2.0) }) + + describe("setTitleIfEmpty (background session title)", () => { + it("writes the title when the item has none", async () => { + await storeA.initialize() + const item = makeHistoryItem({ id: "title-empty" }) + await storeA.upsert(item) + + const written = await storeA.setTitleIfEmpty("title-empty", "Generated title") + + expect(written).toMatchObject({ id: "title-empty", title: "Generated title" }) + expect(storeA.get("title-empty")?.title).toBe("Generated title") + }) + + it("keeps a title the user set by hand", async () => { + await storeA.initialize() + const item = makeHistoryItem({ id: "title-manual" }) + await storeA.upsert(item) + await storeA.upsert({ ...item, title: "My own title" }) + + await expect(storeA.setTitleIfEmpty("title-manual", "Generated title")).resolves.toBeUndefined() + + expect(storeA.get("title-manual")?.title).toBe("My own title") + }) + + it("throws when the task is not in the cache", async () => { + await storeA.initialize() + + await expect(storeA.setTitleIfEmpty("missing-task", "Generated title")).rejects.toThrow( + "task missing-task not found in cache", + ) + }) + + it("protects a manual title written by another instance", async () => { + await storeA.initialize() + await storeB.initialize() + + const item = makeHistoryItem({ id: "shared-rename" }) + await storeA.upsert(item) + await storeB.reconcile() + + // The user renames in window A; window B's background generation finishes later. + await storeA.upsert({ ...item, title: "Renamed by user" }) + await storeB.reconcile({ forceRefresh: true }) + + await expect(storeB.setTitleIfEmpty("shared-rename", "Generated title")).resolves.toBeUndefined() + expect(storeB.get("shared-rename")?.title).toBe("Renamed by user") + + const filePath = path.join(tmpDir, "tasks", "shared-rename", GlobalFileNames.historyItem) + const final = JSON.parse(await fs.readFile(filePath, "utf8")) as HistoryItem + expect(final.title).toBe("Renamed by user") + }) + + it("lands a generated title from another instance on the shared task file", async () => { + await storeA.initialize() + await storeB.initialize() + + const item = makeHistoryItem({ id: "shared-generated" }) + await storeA.upsert(item) + await storeB.reconcile() + + const written = await storeB.setTitleIfEmpty("shared-generated", "Generated title") + expect(written).toBeDefined() + + // The write goes through the per-task file lock, so the peer sees it. + await storeA.invalidate("shared-generated") + expect(storeA.get("shared-generated")?.title).toBe("Generated title") + }) + }) }) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index b911479bc6..09a00c88d5 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -7,6 +7,7 @@ import EventEmitter from "events" import { AskIgnoredError } from "./AskIgnoredError" import { RateLimitClock, createRateLimitClock } from "./RateLimitClock" +import { generateSessionTitle } from "./session-title" import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" @@ -286,6 +287,9 @@ export class PendingActionSettlementError extends Error { } } +// Literal, not the vscode enum: specs that mock "vscode" without ExtensionMode would throw on the read. +const EXTENSION_MODE_TEST = 3 + export class Task extends EventEmitter implements TaskLike { readonly taskId: string readonly rootTaskId?: string @@ -3110,6 +3114,11 @@ export class Task extends EventEmitter implements TaskLike { await this.say("text", task, images) + // Generate a session title in the background (fire-and-forget) so the + // task list shows a short title without delaying the task. Subtasks and + // resumed tasks keep their existing names. + this.generateSessionTitleInBackground(task ?? "") + // Check for too many MCP tools and warn the user const { enabledToolCount, enabledServerCount } = await this.getEnabledMcpToolsCount() if (enabledToolCount > MAX_MCP_TOOLS_THRESHOLD) { @@ -3183,6 +3192,31 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Fire-and-forget session title generation for a new root task. + * + * Runs after the first user message is stored. A generated title is written + * only when the history item has no title yet, so a title the user set by + * hand is never replaced. Subtasks and resumed tasks keep their existing + * names. Failures are logged and never shown in the chat. + */ + private generateSessionTitleInBackground(firstMessage: string): void { + if (!firstMessage || this.parentTaskId || this._isHistoryTask) { + return + } + + const provider = this.providerRef.deref() + if (!provider || provider.context.extensionMode === EXTENSION_MODE_TEST) { + return + } + + void generateSessionTitle(this.api, firstMessage, { taskId: this.taskId }) + .then((title) => provider.applyGeneratedSessionTitle(this.taskId, title)) + .catch((error) => { + console.error(`[Task#generateSessionTitleInBackground] failed for ${this.taskId}:`, error) + }) + } + private async resumeTaskFromHistory() { try { const modifiedClineMessages = [...(await this.getSavedClineMessages())] diff --git a/src/core/task/__tests__/Task.session-title.spec.ts b/src/core/task/__tests__/Task.session-title.spec.ts new file mode 100644 index 0000000000..26f5a12a3f --- /dev/null +++ b/src/core/task/__tests__/Task.session-title.spec.ts @@ -0,0 +1,240 @@ +// pnpm --filter roo-cline test core/task/__tests__/Task.session-title.spec.ts + +import * as os from "os" +import * as path from "path" + +import type { GlobalState, HistoryItem, ProviderSettings } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" +import { TelemetryService } from "@roo-code/telemetry" + +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" + +const { mockGenerateSessionTitle, mockSaveTaskMessages, mockSaveApiMessages } = vi.hoisted(() => ({ + mockGenerateSessionTitle: vi.fn(), + mockSaveTaskMessages: vi.fn().mockResolvedValue(undefined), + mockSaveApiMessages: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("../session-title", () => ({ + generateSessionTitle: mockGenerateSessionTitle, +})) + +// vscode is globally aliased to __mocks__/vscode.js by vitest.config.ts. +vi.mock("delay", () => ({ __esModule: true, default: vi.fn().mockResolvedValue(undefined) })) +vi.mock("execa", () => ({ execa: vi.fn() })) +vi.mock("p-wait-for", () => ({ default: vi.fn().mockResolvedValue(undefined) })) + +vi.mock("../../task-persistence", async (importOriginal) => { + const mod = await importOriginal() + return { + ...mod, + saveApiMessages: mockSaveApiMessages, + saveTaskMessages: mockSaveTaskMessages, + TaskHistoryStore: vi.fn().mockImplementation(function () { + return { + initialize: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + get: vi.fn(), + getAll: vi.fn().mockReturnValue([]), + upsert: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue(undefined), + deleteMany: vi.fn().mockResolvedValue(undefined), + reconcile: vi.fn().mockResolvedValue(undefined), + initialized: Promise.resolve(), + } + }), + } +}) + +vi.mock("../../mentions", () => ({ + parseMentions: vi + .fn() + .mockImplementation((text) => + Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] }), + ), + openMention: vi.fn(), + getLatestTerminalOutput: vi.fn(), +})) + +vi.mock("../../mentions/processUserContentMentions", () => ({ + processUserContentMentions: vi.fn().mockImplementation(async ({ userContent }: { userContent: unknown[] }) => ({ + content: userContent, + mode: undefined, + })), +})) + +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue(""), +})) + +vi.mock("../../environment/getEnvironmentDetails", () => ({ + getEnvironmentDetails: vi.fn().mockResolvedValue(""), +})) + +vi.mock("../../ignore/RooIgnoreController") + +vi.mock("../../../utils/storage", () => ({ + getTaskDirectoryPath: vi + .fn() + .mockImplementation((base: string, id: string) => Promise.resolve(`${base}/tasks/${id}`)), + getSettingsDirectoryPath: vi.fn().mockImplementation((base: string) => Promise.resolve(`${base}/settings`)), +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockReturnValue(false), +})) + +vi.mock("../../../i18n", () => ({ + t: (key: string) => key, +})) + +function makeMockProvider() { + return { + log: vi.fn(), + taskHistoryStore: { get: () => undefined }, + updateTaskHistory: vi.fn().mockResolvedValue([]), + applyGeneratedSessionTitle: vi.fn().mockResolvedValue(true), + getState: vi.fn().mockResolvedValue({}), + getSkillsManager: vi.fn().mockReturnValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), + flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + context: { + globalStorageUri: { fsPath: path.join(os.tmpdir(), "test-storage-session-title") }, + globalState: { + get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, + workspaceState: { + get: vi.fn().mockImplementation(() => undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, + secrets: { + get: vi.fn().mockResolvedValue(undefined), + store: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }, + extensionUri: { fsPath: "/mock/extension" }, + extension: { packageJSON: { version: "1.0.0" } }, + }, + } +} + +const mockApiConfig: ProviderSettings = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-api-key", +} + +describe("Task session title generation", () => { + beforeEach(() => { + vi.clearAllMocks() + mockSaveTaskMessages.mockResolvedValue(undefined) + mockGenerateSessionTitle.mockResolvedValue("Login bug fix") + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + }) + + function makeTask(provider: unknown, overrides: Record = {}): Task { + return new Task({ + provider: provider as unknown as ClineProvider, + apiConfiguration: mockApiConfig, + task: "Fix the login bug", + startTask: false, + ...overrides, + }) + } + + type TaskTestAccess = { + initiateTaskLoop: (userContent: unknown[]) => Promise + } + + function getTaskTestAccess(task: Task): TaskTestAccess { + return task as unknown as TaskTestAccess + } + + it("starts title generation in the background when a new root task starts", async () => { + const mockProvider = makeMockProvider() + const task = makeTask(mockProvider) + vi.spyOn(getTaskTestAccess(task), "initiateTaskLoop").mockResolvedValue(undefined) + + await task["startTask"]("Fix the login bug") + + expect(mockGenerateSessionTitle).toHaveBeenCalledWith(task.api, "Fix the login bug", { taskId: task.taskId }) + // Fire-and-forget: the write happens in the background, not awaited by startTask. + await vi.waitFor(() => { + expect(mockProvider.applyGeneratedSessionTitle).toHaveBeenCalledWith(task.taskId, "Login bug fix") + }) + }) + + it("passes the generated title through the provider write path", async () => { + mockGenerateSessionTitle.mockResolvedValue(" Generated title ") + const mockProvider = makeMockProvider() + const task = makeTask(mockProvider) + + task["generateSessionTitleInBackground"]("Fix the login bug") + + await vi.waitFor(() => { + expect(mockProvider.applyGeneratedSessionTitle).toHaveBeenCalledWith(task.taskId, " Generated title ") + }) + }) + + it("does not generate a title for a subtask", () => { + const mockProvider = makeMockProvider() + const parentTask = { taskId: "parent-1" } as Task + const task = makeTask(mockProvider, { task: "child task", parentTask }) + + expect(task.parentTaskId).toBe("parent-1") + + task["generateSessionTitleInBackground"]("child task message") + + expect(mockGenerateSessionTitle).not.toHaveBeenCalled() + expect(mockProvider.applyGeneratedSessionTitle).not.toHaveBeenCalled() + }) + + it("does not generate a title for a task resumed from history", () => { + const mockProvider = makeMockProvider() + const historyItem = { id: "resumed-1", task: "Fix the login bug" } as HistoryItem + const task = makeTask(mockProvider, { task: undefined, images: undefined, historyItem }) + + expect(task["_isHistoryTask"]).toBe(true) + + task["generateSessionTitleInBackground"]("Fix the login bug") + + expect(mockGenerateSessionTitle).not.toHaveBeenCalled() + expect(mockProvider.applyGeneratedSessionTitle).not.toHaveBeenCalled() + }) + + it("does not generate a title for an empty first message", () => { + const mockProvider = makeMockProvider() + const task = makeTask(mockProvider) + + task["generateSessionTitleInBackground"]("") + + expect(mockGenerateSessionTitle).not.toHaveBeenCalled() + expect(mockProvider.applyGeneratedSessionTitle).not.toHaveBeenCalled() + }) + + it("never lets a generation failure escape to the chat", async () => { + mockGenerateSessionTitle.mockRejectedValue(new Error("provider down")) + const mockProvider = makeMockProvider() + const task = makeTask(mockProvider) + + expect(() => task["generateSessionTitleInBackground"]("Fix the login bug")).not.toThrow() + + await vi.waitFor(() => { + expect(mockGenerateSessionTitle).toHaveBeenCalled() + }) + // Let the rejection settle inside the background chain. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockProvider.applyGeneratedSessionTitle).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/task/__tests__/session-title.spec.ts b/src/core/task/__tests__/session-title.spec.ts new file mode 100644 index 0000000000..e0f34c7156 --- /dev/null +++ b/src/core/task/__tests__/session-title.spec.ts @@ -0,0 +1,204 @@ +// pnpm --filter roo-cline test core/task/__tests__/session-title.spec.ts + +import type { ApiHandler, CompletePromptOptions, SingleCompletionHandler } from "../../../api" + +import { + SESSION_TITLE_FALLBACK_LENGTH, + SESSION_TITLE_TIMEOUT_MS, + createSessionTitleFallback, + generateSessionTitle, + sanitizeGeneratedTitle, + stripSystemReminderBlocks, +} from "../session-title" + +/** A handler that only implements the single-shot completion path. */ +function makeSingleShotHandler( + impl: (prompt: string, options?: CompletePromptOptions) => Promise, +): ApiHandler & SingleCompletionHandler { + return { completePrompt: vi.fn(impl) } as unknown as ApiHandler & SingleCompletionHandler +} + +/** A handler without `completePrompt`: the stream path is the only option. */ +function makeStreamHandler(chunks: Array<{ type: string; text?: string }>): ApiHandler { + async function* stream() { + for (const chunk of chunks) { + yield chunk as never + } + } + return { createMessage: vi.fn(() => stream()) } as unknown as ApiHandler +} + +describe("stripSystemReminderBlocks", () => { + it("removes complete system-reminder blocks", () => { + const message = "Fix the bug\nsome context\nplease" + expect(stripSystemReminderBlocks(message)).toBe("Fix the bug\n\nplease") + }) + + it("removes every block, not only the first", () => { + const message = "aFix itb" + expect(stripSystemReminderBlocks(message)).toBe("Fix it") + }) + + it("removes an unclosed block to the end of the message", () => { + const message = "Fix the bug\ntruncated paste without a closing tag" + expect(stripSystemReminderBlocks(message)).toBe("Fix the bug\n") + }) +}) + +describe("createSessionTitleFallback", () => { + it("returns the message unchanged when it is within the length limit", () => { + expect(createSessionTitleFallback("Fix the login bug")).toBe("Fix the login bug") + expect(createSessionTitleFallback("Fix the login bug").length).toBeLessThanOrEqual( + SESSION_TITLE_FALLBACK_LENGTH, + ) + }) + + it("cuts the first 42 characters of a long message", () => { + const long = "a".repeat(100) + const title = createSessionTitleFallback(long) + expect(title).toHaveLength(SESSION_TITLE_FALLBACK_LENGTH) + expect(title).toBe("a".repeat(42)) + }) + + it("collapses whitespace and trims the cut at a word boundary", () => { + const title = createSessionTitleFallback( + "hello\tworld\nthis is a long message that goes past the limit for sure", + ) + expect(title).toBe("hello world this is a long message that go") + expect(title).not.toMatch(/\s$/) + }) + + it("ignores system-reminder blocks when building the fallback", () => { + const message = + "very long reminder text that alone would exceed the fallback limit anyway" + expect(createSessionTitleFallback(message)).toBe("") + + const mixed = "hiddenFix the login bug" + expect(createSessionTitleFallback(mixed)).toBe("Fix the login bug") + }) +}) + +describe("sanitizeGeneratedTitle", () => { + it.each([ + ['"Fix the login bug"', "Fix the login bug"], + ["'Fix it'", "Fix it"], + ["**Fix it**", "Fix it"], + ["`Fix it`", "Fix it"], + [" Fix the bug ", "Fix the bug"], + ["reasoningFix the bug", "Fix the bug"], + ])("cleans %s to %s", (raw, expected) => { + expect(sanitizeGeneratedTitle(raw)).toBe(expected) + }) +}) + +describe("generateSessionTitle", () => { + it("returns the generated title from the single-shot path", async () => { + const handler = makeSingleShotHandler(async () => " Fix the login bug ") + const completePrompt = handler.completePrompt as ReturnType + + await expect(generateSessionTitle(handler, "Fix the login bug")).resolves.toBe("Fix the login bug") + + const [prompt, options] = completePrompt.mock.calls[0] + expect(prompt).toContain("User message:") + expect(prompt).toContain("Fix the login bug") + expect(options?.abortSignal).toBeInstanceOf(AbortSignal) + expect(options?.taskId).toBeUndefined() + }) + + it("passes the task id for provider-side tracking", async () => { + const handler = makeSingleShotHandler(async () => "A title") + await generateSessionTitle(handler, "Fix the login bug", { taskId: "task-1" }) + expect((handler.completePrompt as ReturnType).mock.calls[0][1]?.taskId).toBe("task-1") + }) + + it("asks for five to ten words in the language of the user", async () => { + const handler = makeSingleShotHandler(async () => "로그인 버그 수정") + await generateSessionTitle(handler, "로그인 버그를 수정해줘") + const prompt = (handler.completePrompt as ReturnType).mock.calls[0][0] as string + expect(prompt).toContain("five to ten words") + expect(prompt).toContain("language of the user message") + }) + + it("drains the createMessage stream when the provider has no single-shot path", async () => { + const handler = makeStreamHandler([ + { type: "text", text: "Fix " }, + { type: "reasoning", text: "hidden" }, + { type: "text", text: "the login bug" }, + ]) + + await expect(generateSessionTitle(handler, "Fix the login bug")).resolves.toBe("Fix the login bug") + + const createMessage = handler.createMessage as ReturnType + const [systemPrompt, messages, metadata] = createMessage.mock.calls[0] + expect(systemPrompt).toContain("five to ten words") + expect(messages).toEqual([{ role: "user", content: expect.stringContaining("Fix the login bug") }]) + expect(metadata.taskId).toBe("") + expect(metadata.abortSignal).toBeInstanceOf(AbortSignal) + }) + + it("returns the 42-character fallback when the request fails", async () => { + const handler = makeSingleShotHandler(async () => { + throw new Error("provider down") + }) + + const message = "This first message is long enough to be cut at the fallback length limit for sure" + await expect(generateSessionTitle(handler, message)).resolves.toBe(createSessionTitleFallback(message)) + }) + + it("returns the fallback when the model returns nothing usable", async () => { + const handler = makeSingleShotHandler(async () => '""') + await expect(generateSessionTitle(handler, "Fix the login bug")).resolves.toBe("Fix the login bug") + }) + + it("uses the fallback after the time limit even when the provider ignores the abort signal", async () => { + const handler = makeSingleShotHandler((_prompt, options) => { + // A request that never settles and never observes the signal. + void options?.abortSignal + return new Promise(() => {}) + }) + + const start = Date.now() + await expect(generateSessionTitle(handler, "Fix the login bug", { timeoutMs: 25 })).resolves.toBe( + "Fix the login bug", + ) + expect(Date.now() - start).toBeLessThan(SESSION_TITLE_TIMEOUT_MS) + }) + + it("aborts the request when the external signal fires", async () => { + const handler = makeSingleShotHandler( + (_prompt, options) => + new Promise((_resolve, reject) => { + options?.abortSignal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }) + }), + ) + const controller = new AbortController() + + const pending = generateSessionTitle(handler, "Fix the login bug", { signal: controller.signal }) + setTimeout(() => controller.abort(), 5) + + await expect(pending).resolves.toBe("Fix the login bug") + }) + + it("returns an empty string for an empty or reminder-only message without a request", async () => { + const handler = makeSingleShotHandler(async () => "should not be called") + + await expect(generateSessionTitle(handler, "")).resolves.toBe("") + await expect(generateSessionTitle(handler, "only a reminder")).resolves.toBe( + "", + ) + + expect(handler.completePrompt as ReturnType).not.toHaveBeenCalled() + }) + + it("never throws, whatever the provider does", async () => { + const handler = makeSingleShotHandler(async () => { + throw new Error("boom") + }) + await expect(generateSessionTitle(handler, "Fix the login bug")).resolves.toBeTypeOf("string") + }) + + it("keeps the default time limit at 15 seconds", () => { + expect(SESSION_TITLE_TIMEOUT_MS).toBe(15_000) + expect(SESSION_TITLE_FALLBACK_LENGTH).toBe(42) + }) +}) diff --git a/src/core/task/session-title.ts b/src/core/task/session-title.ts new file mode 100644 index 0000000000..2d86798e53 --- /dev/null +++ b/src/core/task/session-title.ts @@ -0,0 +1,180 @@ +import type { ApiHandler, CompletePromptOptions, SingleCompletionHandler } from "../../api" +import type { ApiStream } from "../../api/transform/stream" + +/** + * Session title generation + * + * When a new root task starts, the first user message is turned into a short + * title (5 to 10 words, in the language of the user) with one single-shot + * completion request. The request runs in the background: it never blocks the + * task and its errors are never shown in the chat. When generation fails or + * takes longer than 15 seconds, the first 42 characters of the cleaned message + * become the fallback title. + */ + +/** Hard time limit for the title generation request. */ +export const SESSION_TITLE_TIMEOUT_MS = 15_000 + +/** Length limit for the fallback title taken from the first message. */ +export const SESSION_TITLE_FALLBACK_LENGTH = 42 + +/** Cap on the message text sent to the model, to keep the request small. */ +const TITLE_MESSAGE_MAX_CHARS = 2_000 + +const TITLE_SYSTEM_PROMPT = + "You generate short titles for chat sessions. Write one title of five to ten words " + + "in the language of the user message. Keep the most important words from the message. " + + "Reply with the title only: no quotes, no period, no other text." + +/** + * Removes `` blocks from a message so they never shape the + * title. A block without a closing tag (for example a truncated paste) is + * removed to the end of the message. + */ +export function stripSystemReminderBlocks(message: string): string { + const open = "" + const close = "" + let result = "" + let cursor = 0 + const lower = message.toLowerCase() + while (cursor < message.length) { + const start = lower.indexOf(open, cursor) + if (start === -1) { + result += message.slice(cursor) + break + } + result += message.slice(cursor, start) + const end = lower.indexOf(close, start + open.length) + if (end === -1) { + break + } + cursor = end + close.length + } + return result +} + +/** + * Builds the fallback title: the first 42 characters of the cleaned message, + * on one line. Returns an empty string when nothing usable remains. + */ +export function createSessionTitleFallback(message: string, maxLength = SESSION_TITLE_FALLBACK_LENGTH): string { + const cleaned = stripSystemReminderBlocks(message).replace(/\s+/g, " ").trim() + if (!cleaned) { + return "" + } + return cleaned.length <= maxLength ? cleaned : cleaned.slice(0, maxLength).trimEnd() +} + +/** + * Cleans model output: drops reasoning tags, collapses whitespace, and strips + * wrapping quotes or emphasis markers so the title reads as plain text. + */ +export function sanitizeGeneratedTitle(raw: string): string { + return raw + .replace(/[\s\S]*?<\/think>/gi, "") + .replace(/<\/?think>/g, "") + .replace(/\s+/g, " ") + .trim() + .replace(/^["'`*]+|["'`*]+$/g, "") + .trim() +} + +export interface GenerateSessionTitleOptions { + /** Time limit for the request. Defaults to {@link SESSION_TITLE_TIMEOUT_MS}. */ + timeoutMs?: number + /** Task ID for provider-side tracking of the completion. */ + taskId?: string + /** Optional external abort signal (for example the task's abort). */ + signal?: AbortSignal +} + +/** + * Generates a session title from the first user message. + * + * This function never throws: when the request fails, returns nothing useful, + * or exceeds the time limit, the fallback title is returned instead. An empty + * first message returns an empty string, which callers treat as "no title". + */ +export async function generateSessionTitle( + handler: ApiHandler, + firstMessage: string, + options: GenerateSessionTitleOptions = {}, +): Promise { + const { timeoutMs = SESSION_TITLE_TIMEOUT_MS, taskId, signal } = options + + const fallback = createSessionTitleFallback(firstMessage) + if (!fallback) { + return "" + } + + const message = stripSystemReminderBlocks(firstMessage).replace(/\s+/g, " ").trim() + const prompt = `${TITLE_SYSTEM_PROMPT}\n\nUser message:\n${message.slice(0, TITLE_MESSAGE_MAX_CHARS)}` + + // The controller enforces the time limit and forwards an external abort to + // the underlying request. The race below bounds how long this function can + // wait, even when a provider ignores the abort signal. + const controller = new AbortController() + const abort = () => controller.abort() + signal?.addEventListener("abort", abort, { once: true }) + + const timer = setTimeout(() => controller.abort(), timeoutMs) + const timeoutRejection = new Promise((_, reject) => { + if (controller.signal.aborted) { + reject(new Error("session title generation was aborted or timed out")) + return + } + controller.signal.addEventListener( + "abort", + () => reject(new Error("session title generation was aborted or timed out")), + { once: true }, + ) + }) + + try { + const raw = await Promise.race([ + requestSingleShotCompletion(handler, prompt, controller, taskId), + timeoutRejection, + ]) + return sanitizeGeneratedTitle(raw) || fallback + } catch { + return fallback + } finally { + clearTimeout(timer) + signal?.removeEventListener("abort", abort) + } +} + +/** + * Runs one completion for the title prompt. Providers that implement + * `SingleCompletionHandler` use their single-shot path; every other provider + * drains a one-message `createMessage` stream. No provider file needs to + * change for either path. + */ +async function requestSingleShotCompletion( + handler: ApiHandler, + prompt: string, + controller: AbortController, + taskId?: string, +): Promise { + const singleShot = handler as Partial + if (typeof singleShot.completePrompt === "function") { + const options: CompletePromptOptions = { abortSignal: controller.signal } + if (taskId) { + options.taskId = taskId + } + return singleShot.completePrompt(prompt, options) + } + + const stream: ApiStream = handler.createMessage(prompt, [{ role: "user", content: prompt }], { + taskId: taskId ?? "", + abortSignal: controller.signal, + }) + + let text = "" + for await (const chunk of stream) { + if (chunk.type === "text") { + text += chunk.text + } + } + return text +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6cec3bdbe7..29f523714a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2831,6 +2831,64 @@ export class ClineProvider } } + /** + * Applies a generated session title to a history item when it has no title + * yet. The write lands inside the store lock (see + * `taskHistoryStore.setTitleIfEmpty`), so a title the user set by hand is + * never overwritten, even when it arrives while the title is in flight. + * + * The history item may not be in the store yet when the background + * generation finishes (the task persists it with its first save), so the + * write is retried a few times before it gives up. Failures are logged and + * never shown in the chat. + * + * @returns true when a generated title was written. + */ + async applyGeneratedSessionTitle( + taskId: string, + title: string, + options: { retries?: number; retryDelayMs?: number } = {}, + ): Promise { + const { retries = 3, retryDelayMs = 1_000 } = options + const trimmed = title?.trim() + if (!trimmed) { + return false + } + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const written = await this.taskHistoryStore.setTitleIfEmpty(taskId, trimmed) + if (!written) { + // The item already has a title. It is kept, whatever set it. + return false + } + this.recentTasksCache = undefined + + if (this.isViewLaunched) { + const updatedItem = this.taskHistoryStore.get(taskId) + if (updatedItem) { + await this.postMessageToWebview({ + type: "taskHistoryItemUpdated", + taskHistoryItem: updatedItem, + }) + } + } + return true + } catch (error) { + // The item is usually present by the time generation finishes; an + // early attempt can miss it. Log and retry, then give up quietly. + console.error( + `[applyGeneratedSessionTitle] attempt ${attempt}/${retries} failed for task ${taskId}:`, + error, + ) + if (attempt < retries) { + await delay(retryDelayMs) + } + } + } + return false + } + /* Condenses a task's message history to use fewer tokens. */ async condenseTaskContext(taskId: string) { const task = this.taskRegistry.getById(taskId) diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 7b6223e2e2..509b13eae2 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -690,6 +690,89 @@ describe("ClineProvider Task History Synchronization", () => { }) }) + describe("applyGeneratedSessionTitle", () => { + const getStored = (id: string) => provider.taskHistoryStore.get(id) + + it("writes the generated title when the item has none and broadcasts it", async () => { + await provider.resolveWebviewView(mockWebviewView) + provider.isViewLaunched = true + await provider.updateTaskHistory(createHistoryItem({ id: "task-generated", task: "Original prompt" }), { + broadcast: false, + }) + mockPostMessage.mockClear() + + await expect(provider.applyGeneratedSessionTitle("task-generated", " Generated title ")).resolves.toBe( + true, + ) + + expect(getStored("task-generated")).toMatchObject({ title: "Generated title", task: "Original prompt" }) + const updates = findCallsByType(mockPostMessage.mock.calls, "taskHistoryItemUpdated") + expect(updates.at(-1)?.[0].taskHistoryItem).toMatchObject({ + id: "task-generated", + title: "Generated title", + }) + }) + + it("never overwrites a title the user set by hand", async () => { + await provider.updateTaskHistory(createHistoryItem({ id: "task-manual", task: "Original prompt" }), { + broadcast: false, + }) + await provider.renameTaskWithId("task-manual", "My own title") + + await expect(provider.applyGeneratedSessionTitle("task-manual", "Generated title")).resolves.toBe(false) + + expect(getStored("task-manual")).toMatchObject({ title: "My own title" }) + }) + + it("retries when the item is not in the store yet and writes once it appears", async () => { + await provider.updateTaskHistory(createHistoryItem({ id: "task-late", task: "Original prompt" }), { + broadcast: false, + }) + // Simulate the race where generation finishes before the first save: + // the first write attempt misses the item, the next one succeeds. + const setTitleIfEmpty = vi.spyOn(provider.taskHistoryStore, "setTitleIfEmpty") + setTitleIfEmpty.mockRejectedValueOnce(new Error("task task-late not found in cache")) + + await expect(provider.applyGeneratedSessionTitle("task-late", "Generated title")).resolves.toBe(true) + + expect(setTitleIfEmpty).toHaveBeenCalledTimes(2) + expect(getStored("task-late")).toMatchObject({ title: "Generated title" }) + }) + + it("gives up quietly after the retries are exhausted", async () => { + vi.spyOn(provider.taskHistoryStore, "setTitleIfEmpty").mockRejectedValue(new Error("still missing")) + + await expect( + provider.applyGeneratedSessionTitle("task-never", "Generated title", { retries: 2, retryDelayMs: 0 }), + ).resolves.toBe(false) + + expect(getStored("task-never")).toBeUndefined() + }) + + it("ignores a blank title", async () => { + await provider.updateTaskHistory(createHistoryItem({ id: "task-blank", task: "Original prompt" }), { + broadcast: false, + }) + + await expect(provider.applyGeneratedSessionTitle("task-blank", " ")).resolves.toBe(false) + + expect(getStored("task-blank")?.title).toBeUndefined() + }) + + it("does not broadcast when the view is not launched", async () => { + provider.isViewLaunched = false + await provider.updateTaskHistory(createHistoryItem({ id: "task-noview", task: "Original prompt" }), { + broadcast: false, + }) + mockPostMessage.mockClear() + + await expect(provider.applyGeneratedSessionTitle("task-noview", "Generated title")).resolves.toBe(true) + + expect(getStored("task-noview")).toMatchObject({ title: "Generated title" }) + expect(mockPostMessage).not.toHaveBeenCalled() + }) + }) + describe("broadcastTaskHistoryUpdate", () => { it("sends taskHistoryUpdated message with sorted history", async () => { await provider.resolveWebviewView(mockWebviewView)