From 53d6bdf087a1493b9e69489be36577190f7bc8fe Mon Sep 17 00:00:00 2001 From: daewoongoh Date: Sun, 8 Nov 2026 07:24:56 +0900 Subject: [PATCH 1/4] feat(task): generate a session title from the first message When a new root task starts, one background single-shot completion turns the first user message into a short title in the language of the user. The request has a 15-second limit and falls back to the first 42 characters of the cleaned message on failure. The write lands inside the history store lock and only when the item has no title, so a title the user set by hand is never replaced. Subtasks and resumed tasks keep their existing names. Closes: #2333 --- src/core/task-persistence/TaskHistoryStore.ts | 26 ++ .../TaskHistoryStore.crossInstance.spec.ts | 68 +++++ src/core/task/Task.ts | 31 +++ .../task/__tests__/Task.session-title.spec.ts | 232 ++++++++++++++++++ src/core/task/__tests__/session-title.spec.ts | 197 +++++++++++++++ src/core/task/session-title.ts | 162 ++++++++++++ src/core/webview/ClineProvider.ts | 55 +++++ .../ClineProvider.taskHistory.spec.ts | 84 +++++++ 8 files changed, 855 insertions(+) create mode 100644 src/core/task/__tests__/Task.session-title.spec.ts create mode 100644 src/core/task/__tests__/session-title.spec.ts create mode 100644 src/core/task/session-title.ts 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..a673a8a04d 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() + + 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..863ba38deb 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" @@ -3110,6 +3111,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 +3189,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) { + 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..c9d6dbc2a1 --- /dev/null +++ b/src/core/task/__tests__/Task.session-title.spec.ts @@ -0,0 +1,232 @@ +// 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, + }) + } + + it("starts title generation in the background when a new root task starts", async () => { + const mockProvider = makeMockProvider() + const task = makeTask(mockProvider) + vi.spyOn(task as never, "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..437dd5a751 --- /dev/null +++ b/src/core/task/__tests__/session-title.spec.ts @@ -0,0 +1,197 @@ +// pnpm --filter roo-cline test core/task/__tests__/session-title.spec.ts + +import type { ApiHandler, CompletePromptOptions } 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 { + return { completePrompt: vi.fn(impl) } as unknown as ApiHandler +} + +/** 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..3e52d72f3e --- /dev/null +++ b/src/core/task/session-title.ts @@ -0,0 +1,162 @@ +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 { + return message + .replace(/[\s\S]*?<\/system-reminder>/gi, "") + .replace(/[\s\S]*$/i, "") +} + +/** + * 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..f8986da40b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2831,6 +2831,61 @@ 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..57cc338461 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -690,6 +690,90 @@ 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 () => { + const logSpy = vi.spyOn(provider, "log") + 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) From 840b344815c20935415a5727909bb99d72b3926d Mon Sep 17 00:00:00 2001 From: daewoongoh Date: Sun, 8 Nov 2026 08:13:29 +0900 Subject: [PATCH 2/4] fix(task): sanitize in linear time and skip session title in test mode --- src/core/task/Task.ts | 5 ++- src/core/task/__tests__/session-title.spec.ts | 11 ++++-- src/core/task/session-title.ts | 36 ++++++++++++++----- src/core/webview/ClineProvider.ts | 5 ++- 4 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 863ba38deb..09a00c88d5 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -287,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 @@ -3203,7 +3206,7 @@ export class Task extends EventEmitter implements TaskLike { } const provider = this.providerRef.deref() - if (!provider) { + if (!provider || provider.context.extensionMode === EXTENSION_MODE_TEST) { return } diff --git a/src/core/task/__tests__/session-title.spec.ts b/src/core/task/__tests__/session-title.spec.ts index 437dd5a751..15816e5ccb 100644 --- a/src/core/task/__tests__/session-title.spec.ts +++ b/src/core/task/__tests__/session-title.spec.ts @@ -46,7 +46,9 @@ describe("stripSystemReminderBlocks", () => { 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) + expect(createSessionTitleFallback("Fix the login bug").length).toBeLessThanOrEqual( + SESSION_TITLE_FALLBACK_LENGTH, + ) }) it("cuts the first 42 characters of a long message", () => { @@ -57,13 +59,16 @@ describe("createSessionTitleFallback", () => { }) 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") + 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" + const message = + "very long reminder text that alone would exceed the fallback limit anyway" expect(createSessionTitleFallback(message)).toBe("") const mixed = "hiddenFix the login bug" diff --git a/src/core/task/session-title.ts b/src/core/task/session-title.ts index 3e52d72f3e..2d86798e53 100644 --- a/src/core/task/session-title.ts +++ b/src/core/task/session-title.ts @@ -32,9 +32,25 @@ const TITLE_SYSTEM_PROMPT = * removed to the end of the message. */ export function stripSystemReminderBlocks(message: string): string { - return message - .replace(/[\s\S]*?<\/system-reminder>/gi, "") - .replace(/[\s\S]*$/i, "") + 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 } /** @@ -115,7 +131,10 @@ export async function generateSessionTitle( }) try { - const raw = await Promise.race([requestSingleShotCompletion(handler, prompt, controller, taskId), timeoutRejection]) + const raw = await Promise.race([ + requestSingleShotCompletion(handler, prompt, controller, taskId), + timeoutRejection, + ]) return sanitizeGeneratedTitle(raw) || fallback } catch { return fallback @@ -146,11 +165,10 @@ async function requestSingleShotCompletion( return singleShot.completePrompt(prompt, options) } - const stream: ApiStream = handler.createMessage( - prompt, - [{ role: "user", content: prompt }], - { taskId: taskId ?? "", abortSignal: controller.signal }, - ) + const stream: ApiStream = handler.createMessage(prompt, [{ role: "user", content: prompt }], { + taskId: taskId ?? "", + abortSignal: controller.signal, + }) let text = "" for await (const chunk of stream) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index f8986da40b..29f523714a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2867,7 +2867,10 @@ export class ClineProvider if (this.isViewLaunched) { const updatedItem = this.taskHistoryStore.get(taskId) if (updatedItem) { - await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem }) + await this.postMessageToWebview({ + type: "taskHistoryItemUpdated", + taskHistoryItem: updatedItem, + }) } } return true From 85ef95de0286847add833d36f7b3698b276b646e Mon Sep 17 00:00:00 2001 From: daewoongoh Date: Sun, 8 Nov 2026 08:43:02 +0900 Subject: [PATCH 3/4] test(task): remove unused spy in taskHistory test and force refresh reconcile --- .../__tests__/TaskHistoryStore.crossInstance.spec.ts | 2 +- src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts index a673a8a04d..135faa9a54 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts @@ -460,7 +460,7 @@ describe("TaskHistoryStore cross-instance safety", () => { // The user renames in window A; window B's background generation finishes later. await storeA.upsert({ ...item, title: "Renamed by user" }) - await storeB.reconcile() + 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") diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 57cc338461..509b13eae2 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -740,7 +740,6 @@ describe("ClineProvider Task History Synchronization", () => { }) it("gives up quietly after the retries are exhausted", async () => { - const logSpy = vi.spyOn(provider, "log") vi.spyOn(provider.taskHistoryStore, "setTitleIfEmpty").mockRejectedValue(new Error("still missing")) await expect( From 999bb2658b6cc630ab0250d732f288e7b81cf3e4 Mon Sep 17 00:00:00 2001 From: daewoongoh Date: Sun, 8 Nov 2026 09:11:20 +0900 Subject: [PATCH 4/4] test(task): fix check-types errors in session-title unit tests --- src/core/task/__tests__/Task.session-title.spec.ts | 10 +++++++++- src/core/task/__tests__/session-title.spec.ts | 8 +++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/core/task/__tests__/Task.session-title.spec.ts b/src/core/task/__tests__/Task.session-title.spec.ts index c9d6dbc2a1..26f5a12a3f 100644 --- a/src/core/task/__tests__/Task.session-title.spec.ts +++ b/src/core/task/__tests__/Task.session-title.spec.ts @@ -152,10 +152,18 @@ describe("Task session title generation", () => { }) } + 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(task as never, "initiateTaskLoop").mockResolvedValue(undefined) + vi.spyOn(getTaskTestAccess(task), "initiateTaskLoop").mockResolvedValue(undefined) await task["startTask"]("Fix the login bug") diff --git a/src/core/task/__tests__/session-title.spec.ts b/src/core/task/__tests__/session-title.spec.ts index 15816e5ccb..e0f34c7156 100644 --- a/src/core/task/__tests__/session-title.spec.ts +++ b/src/core/task/__tests__/session-title.spec.ts @@ -1,6 +1,6 @@ // pnpm --filter roo-cline test core/task/__tests__/session-title.spec.ts -import type { ApiHandler, CompletePromptOptions } from "../../../api" +import type { ApiHandler, CompletePromptOptions, SingleCompletionHandler } from "../../../api" import { SESSION_TITLE_FALLBACK_LENGTH, @@ -12,8 +12,10 @@ import { } from "../session-title" /** A handler that only implements the single-shot completion path. */ -function makeSingleShotHandler(impl: (prompt: string, options?: CompletePromptOptions) => Promise): ApiHandler { - return { completePrompt: vi.fn(impl) } as unknown as ApiHandler +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. */