From 9ca3fc553572181b61044c3d6014ec6666b5a7ef Mon Sep 17 00:00:00 2001 From: daewoongoh Date: Fri, 6 Nov 2026 17:49:39 +0900 Subject: [PATCH 1/5] feat(task): auto-generate session title from the first user message Generate a short title in the background when a root task starts, and apply it only while the history item has no title so manual renames always win. --- src/core/task/Task.ts | 24 ++ .../task/__tests__/Task.session-title.spec.ts | 230 ++++++++++++ src/core/task/__tests__/session-title.spec.ts | 331 ++++++++++++++++++ src/core/task/session-title.ts | 170 +++++++++ src/core/webview/ClineProvider.ts | 48 ++- .../ClineProvider.taskHistory.spec.ts | 84 +++++ 6 files changed, 884 insertions(+), 3 deletions(-) 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/Task.ts b/src/core/task/Task.ts index b911479bc6..4c775d8754 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -145,6 +145,7 @@ import { prepareApiConversationMessage } from "./apiConversationHistory" import { shouldAddUserMessageToHistory } from "./messageCounting" import { type TaskExecutionContext } from "./providerHandoff" import { initialRunState, type Ask, type RunEvent, type RunState } from "./run-state/runState" +import { generateSessionTitle } from "./session-title" import { applyRunEvent, type RunRejection } from "./run-state/runStateShadow" import { divergenceKey, @@ -3110,6 +3111,29 @@ export class Task extends EventEmitter implements TaskLike { await this.say("text", task, images) + // Fire-and-forget session title generation: the run must never wait + // on it and no title failure may surface in the chat. Only a root + // task's first message triggers this — resumes go through + // resumeTaskFromHistory, subtask continuations re-enter the task + // loop directly, and both must keep their existing names. `say` + // above persisted the history item, so the conditional write will + // find it. + if (!this.parentTask && task) { + const provider = this.providerRef.deref() + if (provider) { + void generateSessionTitle({ + provider, + taskId: this.taskId, + userMessage: task, + apiConfiguration: this.apiConfiguration, + }).catch((error) => { + // generateSessionTitle never rejects by contract; this + // arm is defense-in-depth for future regressions. + console.error(`[Task#startTask] session title generation failed: ${error}`) + }) + } + } + // Check for too many MCP tools and warn the user const { enabledToolCount, enabledServerCount } = await this.getEnabledMcpToolsCount() if (enabledToolCount > MAX_MCP_TOOLS_THRESHOLD) { 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..58ce029648 --- /dev/null +++ b/src/core/task/__tests__/Task.session-title.spec.ts @@ -0,0 +1,230 @@ +// npx vitest run core/task/__tests__/Task.session-title.spec.ts + +import type { HistoryItem, ProviderSettings } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" + +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" +import type { GenerateSessionTitleOptions } from "../session-title" + +vi.mock("../session-title", () => ({ + generateSessionTitle: vi.fn().mockResolvedValue(undefined), +})) + +// Import the mocked module for spy access. +import { generateSessionTitle } from "../session-title" + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + hasInstance: vi.fn().mockReturnValue(true), + createInstance: vi.fn(), + get instance() { + return { + captureTaskCreated: vi.fn(), + captureTaskRestarted: vi.fn(), + captureModeSwitch: vi.fn(), + captureConversationMessage: vi.fn(), + captureLlmCompletion: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + captureCodeActionUsed: vi.fn(), + setProvider: vi.fn(), + } + }, + }, +})) + +vi.mock("vscode", () => { + const mockDisposable = { dispose: vi.fn() } + + return { + TabInputTextDiff: vi.fn(), + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + window: { + createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }), + showErrorMessage: vi.fn(), + }, + workspace: { + getConfiguration: vi.fn(() => ({ get: (_k: string, d: unknown) => d })), + workspaceFolders: [{ uri: { fsPath: "/mock/workspace/path" }, name: "mock-workspace", index: 0 }], + createFileSystemWatcher: vi.fn(() => ({ + onDidCreate: vi.fn(() => mockDisposable), + onDidDelete: vi.fn(() => mockDisposable), + onDidChange: vi.fn(() => mockDisposable), + dispose: vi.fn(), + })), + onDidSaveTextDocument: vi.fn(() => mockDisposable), + }, + env: { uriScheme: "vscode", language: "en" }, + EventEmitter: vi.fn(), + Disposable: { from: vi.fn() }, + TabInputText: vi.fn(), + version: "1.85.0", + } +}) + +vi.mock("../../environment/getEnvironmentDetails", () => ({ + getEnvironmentDetails: vi.fn().mockResolvedValue(""), +})) + +vi.mock("../../ignore/RooIgnoreController") + +vi.mock("p-wait-for", () => ({ + default: vi.fn().mockImplementation(async () => Promise.resolve()), +})) + +vi.mock("delay", () => ({ + __esModule: true, + default: vi.fn().mockResolvedValue(undefined), +})) + +type StartTaskAccess = { + startTask: (task?: string, images?: string[]) => Promise + resumeTaskFromHistory: () => Promise + getEnabledMcpToolsCount: () => Promise<{ enabledToolCount: number; enabledServerCount: number }> + initiateTaskLoop: (userContent: unknown[]) => Promise +} + +// The private-member double assertion is the established Task-spec pattern +// (see Task.spec.ts getTaskTestAccess): the spies below must reach private +// startup collaborators without widening their production visibility. +function getStartTaskAccess(task: Task): StartTaskAccess { + return task as unknown as StartTaskAccess +} + +type ProviderStateShaped = Partial>> + +/** + * The trigger path only reads provider startup collaborators; the double is + * cast once here (AGENTS.md) naming the members tests rely on: postStateToWebviewWithoutTaskHistory, + * getState, updateTaskHistory, log, on, off. + */ +function createMockProvider(): ClineProvider { + return { + context: { globalStorageUri: { fsPath: "/test/storage" } }, + getState: vi.fn().mockResolvedValue({} as ProviderStateShaped), + log: vi.fn(), + on: vi.fn(), + off: vi.fn(), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), + updateTaskHistory: vi.fn().mockResolvedValue(undefined), + } as unknown as ClineProvider +} + +function createTestTask(provider: ClineProvider, parentTask?: Task, historyItem?: HistoryItem): Task { + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-api-key", + } + + return new Task({ + provider, + apiConfiguration, + // A history item must be the only task source or it is not a history + // task; omit the task text for that case. + task: historyItem ? undefined : "example root task", + parentTask, + historyItem, + startTask: false, + }) +} + +function stubStartup(task: Task): void { + vi.spyOn(task, "say").mockResolvedValue(undefined) + const taskAccess = getStartTaskAccess(task) + vi.spyOn(taskAccess, "getEnabledMcpToolsCount").mockResolvedValue({ enabledToolCount: 0, enabledServerCount: 0 }) + vi.spyOn(taskAccess, "initiateTaskLoop").mockResolvedValue(undefined) +} + +function lastTitleCall(): GenerateSessionTitleOptions | undefined { + const calls = vi.mocked(generateSessionTitle).mock.calls + return calls.length > 0 ? calls[calls.length - 1][0] : undefined +} + +describe("Task#startTask session title trigger", () => { + beforeEach(() => { + vi.mocked(generateSessionTitle).mockClear() + }) + + it("fires title generation for a root task without awaiting it", async () => { + const provider = createMockProvider() + const task = createTestTask(provider) + stubStartup(task) + + let resolved = false + vi.mocked(generateSessionTitle).mockImplementation(async () => { + resolved = true + }) + + await getStartTaskAccess(task).startTask("title trigger message") + + const call = lastTitleCall() + expect(call).toBeDefined() + expect(call?.taskId).toBe(task.taskId) + expect(call?.userMessage).toBe("title trigger message") + expect(call?.provider).toBe(provider) + // The trigger is fire-and-forget: startTask resolved and the generation + // ran to completion without startTask ever awaiting it beyond its own + // synchronous promise chain. + expect(resolved).toBe(true) + }) + + it("does not fire for a subtask", async () => { + const provider = createMockProvider() + const parentTask = createTestTask(provider) + const childTask = createTestTask(provider, parentTask) + stubStartup(childTask) + + await getStartTaskAccess(childTask).startTask("child task message") + + expect(vi.mocked(generateSessionTitle)).not.toHaveBeenCalled() + }) + + it("does not fire when the root message is empty", async () => { + const provider = createMockProvider() + const task = createTestTask(provider) + stubStartup(task) + + await getStartTaskAccess(task).startTask(undefined) + + expect(vi.mocked(generateSessionTitle)).not.toHaveBeenCalled() + }) + + it("routes a history task away from startTask, so resumes never trigger", async () => { + const provider = createMockProvider() + const historyItem: HistoryItem = { + id: "resume-title-task", + number: 3, + task: "historical task", + ts: Date.now() - 60_000, + tokensIn: 10, + tokensOut: 5, + totalCost: 0.01, + } + // A history item with no task text marks the task as a history task, so + // run() routes through resumeTaskFromHistory and never reaches the + // startTask trigger (Task.ts `_isHistoryTask` routing). + const task = createTestTask(provider, undefined, historyItem) + const taskAccess = getStartTaskAccess(task) + const startTaskSpy = vi.spyOn(taskAccess, "startTask") + vi.spyOn(taskAccess, "resumeTaskFromHistory").mockResolvedValue(undefined) + + await task.run() + + expect(startTaskSpy).not.toHaveBeenCalled() + expect(vi.mocked(generateSessionTitle)).not.toHaveBeenCalled() + }) + + it("keeps the run alive when generation rejects", async () => { + const provider = createMockProvider() + const task = createTestTask(provider) + stubStartup(task) + vi.mocked(generateSessionTitle).mockRejectedValue(new Error("boom")) + + await expect(getStartTaskAccess(task).startTask("survives rejection")).resolves.toBeUndefined() + }) +}) 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..6073a34773 --- /dev/null +++ b/src/core/task/__tests__/session-title.spec.ts @@ -0,0 +1,331 @@ +// npx vitest run core/task/__tests__/session-title.spec.ts + +import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" + +import type { CompletePromptOptions } from "../../../api" +import type { ClineProvider } from "../../webview/ClineProvider" +import { + FALLBACK_TITLE_MAX_CHARS, + SESSION_TITLE_TIMEOUT_MS, + fallbackTitle, + generateSessionTitle, + sanitizeUserMessageForTitle, + extractLlmTitle, + type SessionTitleApplyResult, +} from "../session-title" + +type CompletePromptCall = { + prompt: string + apiConfiguration: ProviderSettings + options: CompletePromptOptions | undefined +} + +type ApplyTitleCall = { taskId: string; title: string } + +/** + * The orchestration only needs the one provider method it calls. The double is + * typed structurally and cast once at the construction site (AGENTS.md): the + * test never reads other provider members. + */ +function createTitleProvider( + overrides: { + applySessionTitle?: (taskId: string, title: string) => Promise + } = {}, +): ClineProvider { + const provider = { + applySessionTitle: overrides.applySessionTitle ?? (async () => "applied" as const), + } + return provider as unknown as ClineProvider +} + +function createCompletePromptRecorder(impl: (call: CompletePromptCall) => Promise): { + fn: (config: ProviderSettings, prompt: string, options?: CompletePromptOptions) => Promise + calls: CompletePromptCall[] +} { + const calls: CompletePromptCall[] = [] + const fn = (apiConfiguration: ProviderSettings, prompt: string, options: CompletePromptCall["options"]) => { + const call = { prompt, apiConfiguration, options } + calls.push(call) + return impl(call) + } + return { fn, calls } +} + +const testApiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-api-key", +} + +describe("sanitizeUserMessageForTitle", () => { + it.each([ + ["plain message", "plain message"], + [" padded ", "padded"], + ["before hidden after", "before after"], + ["a\nbreal", "real"], + ["start unterminated", "start"], + ["only system", ""], + ])("sanitizes %j", (input, expected) => { + expect(sanitizeUserMessageForTitle(input)).toBe(expected) + }) +}) + +describe("extractLlmTitle", () => { + it.each([ + ["Fix login bug in auth flow", "Fix login bug in auth flow"], + ["Title: Fix login bug", "Fix login bug"], + ["title: lowercase prefix", "lowercase prefix"], + ['"Quoted title"', "Quoted title"], + ["'Single quoted'", "Single quoted"], + ["“Curly quoted”", "Curly quoted"], + ["First line\nSecond line", "First line"], + ['Title: "Nested cleanup"', "Nested cleanup"], + ])("extracts %j", (input, expected) => { + expect(extractLlmTitle(input)).toBe(expected) + }) + + it.each([[""], [" "], ["\n\n"], ['""'], ["Title:"]])("returns undefined for %j", (input) => { + expect(extractLlmTitle(input)).toBeUndefined() + }) +}) + +describe("fallbackTitle", () => { + it("uses the first non-empty line", () => { + expect(fallbackTitle("\n\nsecond\nthird")).toBe("second") + }) + + it("caps the fallback at the character limit", () => { + const long = "a".repeat(FALLBACK_TITLE_MAX_CHARS + 10) + expect(fallbackTitle(long)).toBe("a".repeat(FALLBACK_TITLE_MAX_CHARS)) + expect(fallbackTitle(long)).toHaveLength(FALLBACK_TITLE_MAX_CHARS) + }) + + it.each([[""], [" \n \t"]])("returns undefined for %j", (input) => { + expect(fallbackTitle(input)).toBeUndefined() + }) +}) + +describe("generateSessionTitle", () => { + it("applies the cleaned LLM title", async () => { + const recorder = createCompletePromptRecorder(async () => 'Title: "Fix Login Bug"\nextra') + const applied: ApplyTitleCall[] = [] + const provider = createTitleProvider({ + applySessionTitle: async (taskId, title) => { + applied.push({ taskId, title }) + return "applied" + }, + }) + + await generateSessionTitle({ + provider, + taskId: "task-1", + userMessage: "please fix the login bug", + apiConfiguration: testApiConfiguration, + completePrompt: recorder.fn, + }) + + expect(recorder.calls).toHaveLength(1) + expect(recorder.calls[0].prompt).toContain("please fix the login bug") + expect(recorder.calls[0].prompt).toContain("Create a descriptive 5-10 word title") + expect(recorder.calls[0].options?.taskId).toBe("task-1") + expect(recorder.calls[0].options?.timeoutMs).toBe(SESSION_TITLE_TIMEOUT_MS) + expect(recorder.calls[0].apiConfiguration).toBe(testApiConfiguration) + expect(applied).toEqual([{ taskId: "task-1", title: "Fix Login Bug" }]) + }) + + it("falls back to the first message line when the LLM fails", async () => { + const recorder = createCompletePromptRecorder(async () => { + throw new Error("api down") + }) + const applied: ApplyTitleCall[] = [] + const provider = createTitleProvider({ + applySessionTitle: async (taskId, title) => { + applied.push({ taskId, title }) + return "applied" + }, + }) + + await generateSessionTitle({ + provider, + taskId: "task-2", + userMessage: "first line of the request\nsecond line", + apiConfiguration: testApiConfiguration, + completePrompt: recorder.fn, + }) + + expect(applied).toEqual([{ taskId: "task-2", title: "first line of the request" }]) + }) + + it("falls back when the LLM times out", async () => { + vi.useFakeTimers() + try { + const recorder = createCompletePromptRecorder(() => new Promise(() => {})) + const applied: ApplyTitleCall[] = [] + const provider = createTitleProvider({ + applySessionTitle: async (taskId, title) => { + applied.push({ taskId, title }) + return "applied" + }, + }) + + const generation = generateSessionTitle({ + provider, + taskId: "task-timeout", + userMessage: "timeout message body", + apiConfiguration: testApiConfiguration, + completePrompt: recorder.fn, + }) + // Drain microtasks so the raced completion settles first. + await vi.advanceTimersByTimeAsync(SESSION_TITLE_TIMEOUT_MS + 1) + await generation + + expect(recorder.calls[0].options?.abortSignal?.aborted).toBe(true) + expect(applied).toEqual([{ taskId: "task-timeout", title: "timeout message body" }]) + } finally { + vi.useRealTimers() + } + }) + + it.each([[""], [" "], ["only system content"]])( + "skips everything for sanitized-empty message %j", + async (userMessage) => { + const recorder = createCompletePromptRecorder(async () => "should not be called") + const provider = createTitleProvider() + + await generateSessionTitle({ + provider, + taskId: "task-skip", + userMessage, + apiConfiguration: testApiConfiguration, + completePrompt: recorder.fn, + }) + + expect(recorder.calls).toHaveLength(0) + }, + ) + + it("stops immediately on conflict so a manual title always wins", async () => { + const recorder = createCompletePromptRecorder(async () => "Generated Title") + let applyCalls = 0 + const provider = createTitleProvider({ + applySessionTitle: async () => { + applyCalls++ + return "conflict" + }, + }) + + await generateSessionTitle({ + provider, + taskId: "task-conflict", + userMessage: "some root message", + apiConfiguration: testApiConfiguration, + completePrompt: recorder.fn, + }) + + expect(applyCalls).toBe(1) + }) + + it("retries a failing write up to the retry cap, then gives up", async () => { + const recorder = createCompletePromptRecorder(async () => "Generated Title") + let applyCalls = 0 + const provider = createTitleProvider({ + applySessionTitle: async () => { + applyCalls++ + throw new Error("disk on fire") + }, + }) + + await generateSessionTitle({ + provider, + taskId: "task-retry", + userMessage: "some root message", + apiConfiguration: testApiConfiguration, + completePrompt: recorder.fn, + }) + + // 1 initial attempt + 3 retries. + expect(applyCalls).toBe(4) + }) + + it("recovers when a retried write succeeds", async () => { + const recorder = createCompletePromptRecorder(async () => "Generated Title") + let applyCalls = 0 + const provider = createTitleProvider({ + applySessionTitle: async () => { + applyCalls++ + if (applyCalls < 3) { + throw new Error("transient") + } + return "applied" + }, + }) + + await generateSessionTitle({ + provider, + taskId: "task-retry-recover", + userMessage: "some root message", + apiConfiguration: testApiConfiguration, + completePrompt: recorder.fn, + }) + + expect(applyCalls).toBe(3) + }) + + it("runs only one generation per task at a time", async () => { + let release: (() => void) | undefined + const gate = new Promise((resolve) => { + release = resolve + }) + const recorder = createCompletePromptRecorder(async () => { + await gate + return "First Title" + }) + const applied: ApplyTitleCall[] = [] + const provider = createTitleProvider({ + applySessionTitle: async (taskId, title) => { + applied.push({ taskId, title }) + return "applied" + }, + }) + + const first = generateSessionTitle({ + provider, + taskId: "task-dedup", + userMessage: "root message", + apiConfiguration: testApiConfiguration, + completePrompt: recorder.fn, + }) + const second = generateSessionTitle({ + provider, + taskId: "task-dedup", + userMessage: "root message", + apiConfiguration: testApiConfiguration, + completePrompt: recorder.fn, + }) + release!() + await Promise.all([first, second]) + + expect(applied).toHaveLength(1) + expect(applied[0].title).toBe("First Title") + }) + + it("never rejects even when everything fails", async () => { + const provider = createTitleProvider({ + applySessionTitle: async () => { + throw new Error("write failed") + }, + }) + + await expect( + generateSessionTitle({ + provider, + taskId: "task-quiet", + userMessage: "root message", + apiConfiguration: testApiConfiguration, + completePrompt: async () => { + throw new Error("llm failed") + }, + }), + ).resolves.toBeUndefined() + }) +}) diff --git a/src/core/task/session-title.ts b/src/core/task/session-title.ts new file mode 100644 index 0000000000..2a3125c751 --- /dev/null +++ b/src/core/task/session-title.ts @@ -0,0 +1,170 @@ +import type { ProviderSettings } from "@roo-code/types" + +import type { ClineProvider } from "../webview/ClineProvider" +import { singleCompletionHandler } from "../../utils/single-completion-handler" + +/** Hard bound on the single-shot title request (spec: 15 seconds). */ +export const SESSION_TITLE_TIMEOUT_MS = 15_000 +/** Character cap for the fallback title taken from the user message (spec: 42). */ +export const FALLBACK_TITLE_MAX_CHARS = 42 +/** Write attempts given up on after this many retries (spec: 3 retries). */ +const TITLE_WRITE_MAX_RETRIES = 3 + +const TITLE_PROMPT_PREFIX = `Create a descriptive 5-10 word title for the user message below. Use the user's language. +Output only the title. + +` + +/** + * Thrown inside `atomicReadAndUpdate` when the item already carries a title, + * so the auto-title path can detect the conflict without performing a + * pointless disk write of an unchanged record. + */ +export class SessionTitleConflictError extends Error { + constructor(taskId: string) { + super(`[session-title] task ${taskId} already has a title; keeping it`) + this.name = "SessionTitleConflictError" + } +} + +/** Outcome of `ClineProvider.applySessionTitle`. */ +export type SessionTitleApplyResult = "applied" | "conflict" + +/** Removes system-injected `` blocks and trims. */ +export function sanitizeUserMessageForTitle(raw: string): string { + return ( + raw + .replace(/[\s\S]*?<\/system-reminder>/g, "") + // An unterminated block (e.g. truncated input) would otherwise leak the + // rest of the message as user text; drop everything after its opening. + .replace(/[\s\S]*$/g, "") + .trim() + ) +} + +/** First line, `Title:` prefix removed, surrounding quotes removed. */ +export function extractLlmTitle(raw: string): string | undefined { + const firstLine = raw.trim().split("\n")[0]?.trim() ?? "" + const withoutPrefix = firstLine.replace(/^title:\s*/i, "") + const withoutQuotes = withoutPrefix.replace(/^["'“”‘’]+|["'“”‘’]+$/g, "") + const title = withoutQuotes.trim() + return title.length > 0 ? title : undefined +} + +/** First non-empty line of the sanitized message, capped at 42 characters. */ +export function fallbackTitle(sanitized: string): string | undefined { + const firstLine = sanitized + .split("\n") + .find((line) => line.trim().length > 0) + ?.trim() + if (!firstLine) { + return undefined + } + return firstLine.length > FALLBACK_TITLE_MAX_CHARS ? firstLine.slice(0, FALLBACK_TITLE_MAX_CHARS) : firstLine +} + +type CompletePromptFn = typeof singleCompletionHandler + +export interface GenerateSessionTitleOptions { + provider: ClineProvider + taskId: string + /** The raw root user message that started the run. */ + userMessage: string + /** The API configuration the session itself uses (same model reuse). */ + apiConfiguration: ProviderSettings + /** Injectable for tests; defaults to the shared single-shot completion helper. */ + completePrompt?: CompletePromptFn +} + +/** One in-flight title generation per session (spec). */ +const inFlightTitleTaskIds = new Set() + +/** + * Generates and applies a session title from the root user message. + * + * Fire-and-forget by contract: never rejects, never blocks a run, and every + * failure degrades quietly (fallback title, or no write at all). A manual + * rename always wins over the generated title. + */ +export async function generateSessionTitle(options: GenerateSessionTitleOptions): Promise { + const { provider, taskId, userMessage, apiConfiguration, completePrompt = singleCompletionHandler } = options + + const sanitized = sanitizeUserMessageForTitle(userMessage) + if (!sanitized) { + return + } + + if (inFlightTitleTaskIds.has(taskId)) { + return + } + inFlightTitleTaskIds.add(taskId) + + try { + let title: string | undefined + try { + title = await completeTitleViaLlm(completePrompt, apiConfiguration, taskId, sanitized) + } catch (error) { + // LLM failure or timeout falls back to the message's first line. + console.error(`[session-title] LLM call failed for task ${taskId}:`, error) + } + title ??= fallbackTitle(sanitized) + if (!title) { + return + } + await applyTitleWithRetry(provider, taskId, title) + } catch (error) { + // Best-effort by design: a failed title must never surface in the chat. + console.error(`[session-title] generation failed for task ${taskId}:`, error) + } finally { + inFlightTitleTaskIds.delete(taskId) + } +} + +async function completeTitleViaLlm( + completePrompt: CompletePromptFn, + apiConfiguration: ProviderSettings, + taskId: string, + sanitized: string, +): Promise { + const controller = new AbortController() + let timeoutId: ReturnType | undefined + try { + // Both options are honored only by some providers, so the race below is + // the hard bound; it also aborts the signal so honoring providers stop + // the request instead of burning tokens after the deadline. + const completion = completePrompt(apiConfiguration, TITLE_PROMPT_PREFIX + sanitized, { + taskId, + timeoutMs: SESSION_TITLE_TIMEOUT_MS, + abortSignal: controller.signal, + }) + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + controller.abort() + reject(new Error(`[session-title] timed out after ${SESSION_TITLE_TIMEOUT_MS}ms`)) + }, SESSION_TITLE_TIMEOUT_MS) + }) + // Promise.race attaches handlers to both inputs, so the completion + // rejecting after the timeout is already considered handled. + const raw = await Promise.race([completion, timeout]) + return extractLlmTitle(raw) + } finally { + clearTimeout(timeoutId) + } +} + +async function applyTitleWithRetry(provider: ClineProvider, taskId: string, title: string): Promise { + for (let attempt = 0; ; attempt++) { + try { + await provider.applySessionTitle(taskId, title) + // Both outcomes end the attempt: "applied" is done, and "conflict" + // means the user named the session (or another generation applied + // first) — the existing title always wins. + return + } catch (error) { + if (attempt >= TITLE_WRITE_MAX_RETRIES) { + console.error(`[session-title] giving up on task ${taskId} after ${attempt + 1} write attempts:`, error) + return + } + } + } +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 8fff156425..45a6ebd493 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -76,6 +76,7 @@ import { providerIdentifiers, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" +import { SessionTitleConflictError, type SessionTitleApplyResult } from "../task/session-title" import { TaskRegistry } from "../task/TaskRegistry" import { TaskScheduler } from "../task/TaskScheduler" import { @@ -2831,6 +2832,46 @@ export class ClineProvider } } + /* + * Applies an auto-generated session title, but only while the item still + * carries its default name. The title check runs inside the store's + * read-modify-write lock, so a manual rename that lands while a title is + * generating always wins and is never overwritten. Returns "conflict" + * when nothing was applied (an existing title won, or the title was + * blank); throws on transient write failures so the caller can retry. + */ + async applySessionTitle(id: string, title: string): Promise { + const trimmed = title.trim() + if (!trimmed) { + return "conflict" + } + + try { + await this.taskHistoryStore.atomicReadAndUpdate(id, (historyItem) => { + if (historyItem.title) { + throw new SessionTitleConflictError(id) + } + return { ...historyItem, title: trimmed } + }) + } catch (error) { + if (error instanceof SessionTitleConflictError) { + return "conflict" + } + throw error + } + + this.recentTasksCache = undefined + + if (this.isViewLaunched) { + const updatedItem = this.taskHistoryStore.get(id) + if (updatedItem) { + await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem }) + } + } + + return "applied" + } + /* Condenses a task's message history to use fewer tokens. */ async condenseTaskContext(taskId: string) { const task = this.taskRegistry.getById(taskId) @@ -3637,9 +3678,10 @@ export class ClineProvider * Updates a task in the task history and optionally broadcasts the updated history to the webview. * Now delegates to TaskHistoryStore for per-task file persistence. * - * This writer never owns the `title` field: `title` is written only by the rename path - * (`renameTaskWithId` via `taskHistoryStore.atomicReadAndUpdate`). Callers here build items - * from snapshots read before taking the store lock, so any title they carry can be stale. + * This writer never owns the `title` field: `title` is written only by the title-owning paths + * (`renameTaskWithId` and `applySessionTitle`, both via `taskHistoryStore.atomicReadAndUpdate`). + * Callers here build items from snapshots read before taking the store lock, so any title they + * carry can be stale. * * @param item The history item to update or add * @param options.broadcast Whether to broadcast the updated history to the webview (default: true) diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 7b6223e2e2..90b0fdd1a0 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("applySessionTitle", () => { + const getStored = (id: string) => provider.taskHistoryStore.get(id) + + it("stores a trimmed title and broadcasts the updated item", async () => { + await provider.resolveWebviewView(mockWebviewView) + provider.isViewLaunched = true + await provider.updateTaskHistory(createHistoryItem({ id: "task-auto-title", task: "Original prompt" })) + mockPostMessage.mockClear() + + const result = await provider.applySessionTitle("task-auto-title", " Auto Title ") + + expect(result).toBe("applied") + expect(getStored("task-auto-title")).toMatchObject({ title: "Auto Title", task: "Original prompt" }) + const updates = findCallsByType(mockPostMessage.mock.calls, "taskHistoryItemUpdated") + expect(updates.at(-1)?.[0].taskHistoryItem).toMatchObject({ id: "task-auto-title", title: "Auto Title" }) + }) + + it("returns conflict and keeps the existing title when one is already set", async () => { + await provider.updateTaskHistory(createHistoryItem({ id: "task-auto-conflict", task: "Original prompt" }), { + broadcast: false, + }) + await provider.renameTaskWithId("task-auto-conflict", "Manual title") + + const result = await provider.applySessionTitle("task-auto-conflict", "Auto Title") + + expect(result).toBe("conflict") + expect(getStored("task-auto-conflict")).toMatchObject({ title: "Manual title" }) + }) + + it.each([ + ["an empty string", ""], + ["whitespace", " "], + ])("returns conflict without writing for %s", async (_label, title) => { + await provider.updateTaskHistory(createHistoryItem({ id: "task-auto-blank", task: "Original prompt" }), { + broadcast: false, + }) + + const result = await provider.applySessionTitle("task-auto-blank", title) + + expect(result).toBe("conflict") + expect(getStored("task-auto-blank")?.title).toBeUndefined() + }) + + it("throws for an unknown task so the caller can give up", async () => { + await expect(provider.applySessionTitle("missing-task", "Auto Title")).rejects.toThrow("not found in cache") + }) + + it("keeps the auto title when the task later saves metadata without one", async () => { + const item = createHistoryItem({ id: "task-auto-keep", task: "Original prompt", status: "active" }) + await provider.updateTaskHistory(item, { broadcast: false }) + await provider.applySessionTitle("task-auto-keep", "Auto Title") + + // Mirrors Task.saveClineMessages(), whose taskMetadata() result never carries a title. + await provider.updateTaskHistory({ ...item, tokensIn: 500 }, { broadcast: false }) + + expect(getStored("task-auto-keep")).toMatchObject({ title: "Auto Title", tokensIn: 500 }) + }) + + it("keeps a concurrent task save that is queued before the auto title", async () => { + const item = createHistoryItem({ id: "task-auto-race", task: "Original prompt", status: "active" }) + await provider.updateTaskHistory(item, { broadcast: false }) + + // Start the save without awaiting it, so the auto title runs while the save is queued. + const save = provider.updateTaskHistory({ ...item, tokensIn: 500 }, { broadcast: false }) + const autoTitle = provider.applySessionTitle("task-auto-race", "Auto Title") + await Promise.all([save, autoTitle]) + + expect(getStored("task-auto-race")).toMatchObject({ title: "Auto Title", tokensIn: 500 }) + }) + + it("does not broadcast when the view is not launched", async () => { + await provider.updateTaskHistory(createHistoryItem({ id: "task-auto-silent", task: "Original prompt" }), { + broadcast: false, + }) + mockPostMessage.mockClear() + + const result = await provider.applySessionTitle("task-auto-silent", "Auto Title") + + expect(result).toBe("applied") + expect(getStored("task-auto-silent")).toMatchObject({ title: "Auto Title" }) + expect(findCallsByType(mockPostMessage.mock.calls, "taskHistoryItemUpdated")).toHaveLength(0) + }) + }) + describe("broadcastTaskHistoryUpdate", () => { it("sends taskHistoryUpdated message with sorted history", async () => { await provider.resolveWebviewView(mockWebviewView) From 160487a81caf5a709780a8f493e731b9cf653bb2 Mon Sep 17 00:00:00 2001 From: daewoongoh Date: Fri, 6 Nov 2026 18:52:12 +0900 Subject: [PATCH 2/5] fix(task): skip session title in Test mode and sanitize in linear time The extra title request broke E2E suites that assert exact provider request sequences and changed the task name in the visual baseline. Replace the system-reminder regexes with an indexOf scan to fix the CodeQL ReDoS alert. --- src/core/task/Task.ts | 7 ++++- .../task/__tests__/Task.session-title.spec.ts | 16 +++++++++++ src/core/task/__tests__/session-title.spec.ts | 10 +++++++ src/core/task/session-title.ts | 28 +++++++++++++------ 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4c775d8754..1af42c7498 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 @@ -3118,9 +3121,11 @@ export class Task extends EventEmitter implements TaskLike { // loop directly, and both must keep their existing names. `say` // above persisted the history item, so the conditional write will // find it. + // The extra provider request would break E2E and visual tests that assert + // exact request sequences and rendered task names, so Test mode skips it. if (!this.parentTask && task) { const provider = this.providerRef.deref() - if (provider) { + if (provider && provider.context.extensionMode !== EXTENSION_MODE_TEST) { void generateSessionTitle({ provider, taskId: this.taskId, diff --git a/src/core/task/__tests__/Task.session-title.spec.ts b/src/core/task/__tests__/Task.session-title.spec.ts index 58ce029648..293f1a8968 100644 --- a/src/core/task/__tests__/Task.session-title.spec.ts +++ b/src/core/task/__tests__/Task.session-title.spec.ts @@ -173,6 +173,22 @@ describe("Task#startTask session title trigger", () => { expect(resolved).toBe(true) }) + it.each([ + ["Production", 1, true], + ["Development", 2, true], + ["Test", 3, false], + ])("in %s extension mode fires title generation: %s", async (_name, extensionMode, shouldFire) => { + const provider = createMockProvider() + // The double only carries the context members the trigger reads. + Object.assign(provider.context, { extensionMode }) + const task = createTestTask(provider) + stubStartup(task) + + await getStartTaskAccess(task).startTask("mode gated message") + + expect(vi.mocked(generateSessionTitle).mock.calls.length > 0).toBe(shouldFire) + }) + it("does not fire for a subtask", async () => { const provider = createMockProvider() const parentTask = createTestTask(provider) diff --git a/src/core/task/__tests__/session-title.spec.ts b/src/core/task/__tests__/session-title.spec.ts index 6073a34773..4832dd4c16 100644 --- a/src/core/task/__tests__/session-title.spec.ts +++ b/src/core/task/__tests__/session-title.spec.ts @@ -65,9 +65,19 @@ describe("sanitizeUserMessageForTitle", () => { ["a\nbreal", "real"], ["start unterminated", "start"], ["only system", ""], + ["axbyc", "abc"], + ["keep stray close", "keep stray close"], ])("sanitizes %j", (input, expected) => { expect(sanitizeUserMessageForTitle(input)).toBe(expected) }) + + it("handles many unterminated openings in linear time", () => { + const input = "".repeat(50_000) + const startedAt = performance.now() + + expect(sanitizeUserMessageForTitle(input)).toBe("") + expect(performance.now() - startedAt).toBeLessThan(500) + }) }) describe("extractLlmTitle", () => { diff --git a/src/core/task/session-title.ts b/src/core/task/session-title.ts index 2a3125c751..5fe28df8fc 100644 --- a/src/core/task/session-title.ts +++ b/src/core/task/session-title.ts @@ -32,14 +32,26 @@ export type SessionTitleApplyResult = "applied" | "conflict" /** Removes system-injected `` blocks and trims. */ export function sanitizeUserMessageForTitle(raw: string): string { - return ( - raw - .replace(/[\s\S]*?<\/system-reminder>/g, "") - // An unterminated block (e.g. truncated input) would otherwise leak the - // rest of the message as user text; drop everything after its opening. - .replace(/[\s\S]*$/g, "") - .trim() - ) + const open = "" + const close = "" + let result = "" + let cursor = 0 + // Linear scan instead of a lazy regex: repeated unterminated openings made the regex quadratic. + while (cursor < raw.length) { + const start = raw.indexOf(open, cursor) + if (start === -1) { + result += raw.slice(cursor) + break + } + result += raw.slice(cursor, start) + const end = raw.indexOf(close, start + open.length) + // An unterminated block would otherwise leak the rest of the message as user text; drop it. + if (end === -1) { + break + } + cursor = end + close.length + } + return result.trim() } /** First line, `Title:` prefix removed, surrounding quotes removed. */ From 255d72a1ad127bc9c2b7eeef8b695174e94953bb Mon Sep 17 00:00:00 2001 From: daewoongoh Date: Fri, 6 Nov 2026 19:16:41 +0900 Subject: [PATCH 3/5] fix(task): keep manual titles across stores and align fallback with spec Check the on-disk title inside the per-file lock so a rename made through another provider is never overwritten by a generated title. Use the first 42 characters of the message as the fallback, and add tests for pending generation and unusable model responses. --- src/core/task-persistence/TaskHistoryStore.ts | 45 +++++++++++++++++++ .../TaskHistoryStore.crossInstance.spec.ts | 29 ++++++++++++ .../task/__tests__/Task.session-title.spec.ts | 21 +++++++++ src/core/task/__tests__/session-title.spec.ts | 35 +++++++++++++-- src/core/task/session-title.ts | 24 ++-------- src/core/webview/ClineProvider.ts | 21 +++------ 6 files changed, 135 insertions(+), 40 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index c1c50cca1e..7354a92f23 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -996,6 +996,51 @@ export class TaskHistoryStore { // ────────────────────────────── Atomic read-modify-write ────────────────────────────── + /** + * Set `title` only while the item has none, checking the title on disk inside the + * per-file lock. The cache can be stale when another store (for example a second + * provider on the same storage path) saved a manual title, and a delta merge would + * otherwise overwrite it. + * + * @returns "conflict" when a title already exists (the cache is refreshed from disk). + * @throws If the task ID is not present in the cache. + */ + public setTitleIfUnset(taskId: string, title: string): Promise<"applied" | "conflict"> { + return this.withLock(async () => { + const current = this.cache.get(taskId) + if (!current) { + throw new Error(`[TaskHistoryStore] setTitleIfUnset: task ${taskId} not found in cache`) + } + if (current.title) { + return "conflict" + } + + const filePath = await this.getTaskFilePath(taskId) + let written: HistoryItem = { ...current, title } + let conflict = false + await safeWriteJson(filePath, written, { + merge: (existing, incoming) => { + if (existing && typeof existing === "object" && (existing as HistoryItem).title) { + conflict = true + written = existing as HistoryItem + return existing + } + written = mergeHistoryDelta(existing, incoming as HistoryItem, { title }) + return written + }, + }) + + this.cache.set(taskId, written) + if (conflict) { + return "conflict" + } + if (this.onWrite) { + await this.onWrite(this.getAll()) + } + return "applied" + }) + } + /** * Read a HistoryItem from the in-memory cache and write back an updated version, * all within a single lock acquisition so no concurrent writer can interleave diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts index 5b85c9b4e2..87459edc1e 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts @@ -73,6 +73,35 @@ describe("TaskHistoryStore cross-instance safety", () => { await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) }) + describe("setTitleIfUnset", () => { + it("keeps a manual title that another instance wrote after this cache was loaded", async () => { + await storeA.initialize() + await storeB.initialize() + await storeA.upsert(makeHistoryItem({ id: "title-task", task: "Original prompt" })) + await storeB.reconcile() + expect(storeB.get("title-task")?.title).toBeUndefined() + + // Instance A still caches no title when B renames the task on disk. + await storeB.atomicReadAndUpdate("title-task", (item) => ({ ...item, title: "Manual title" })) + expect(storeA.get("title-task")?.title).toBeUndefined() + + await expect(storeA.setTitleIfUnset("title-task", "Auto title")).resolves.toBe("conflict") + + expect(storeA.get("title-task")?.title).toBe("Manual title") + await storeB.reconcile() + expect(storeB.get("title-task")?.title).toBe("Manual title") + }) + + it("applies the title when no instance has set one", async () => { + await storeA.initialize() + await storeA.upsert(makeHistoryItem({ id: "title-free", task: "Original prompt" })) + + await expect(storeA.setTitleIfUnset("title-free", "Auto title")).resolves.toBe("applied") + + expect(storeA.get("title-free")).toMatchObject({ title: "Auto title", task: "Original prompt" }) + }) + }) + it("two instances can write different tasks without conflict", async () => { await storeA.initialize() await storeB.initialize() diff --git a/src/core/task/__tests__/Task.session-title.spec.ts b/src/core/task/__tests__/Task.session-title.spec.ts index 293f1a8968..5febeb136c 100644 --- a/src/core/task/__tests__/Task.session-title.spec.ts +++ b/src/core/task/__tests__/Task.session-title.spec.ts @@ -173,6 +173,27 @@ describe("Task#startTask session title trigger", () => { expect(resolved).toBe(true) }) + it("does not wait for a pending title before starting the task loop", async () => { + const provider = createMockProvider() + const task = createTestTask(provider) + stubStartup(task) + + let releaseTitle: () => void = () => {} + vi.mocked(generateSessionTitle).mockImplementation( + () => + new Promise((resolve) => { + releaseTitle = resolve + }), + ) + + // startTask resolving while the title promise is still pending proves it is not awaited. + await getStartTaskAccess(task).startTask("slow title message") + + expect(getStartTaskAccess(task).initiateTaskLoop).toHaveBeenCalled() + expect(lastTitleCall()?.userMessage).toBe("slow title message") + releaseTitle() + }) + it.each([ ["Production", 1, true], ["Development", 2, true], diff --git a/src/core/task/__tests__/session-title.spec.ts b/src/core/task/__tests__/session-title.spec.ts index 4832dd4c16..ab46216aaa 100644 --- a/src/core/task/__tests__/session-title.spec.ts +++ b/src/core/task/__tests__/session-title.spec.ts @@ -100,8 +100,11 @@ describe("extractLlmTitle", () => { }) describe("fallbackTitle", () => { - it("uses the first non-empty line", () => { - expect(fallbackTitle("\n\nsecond\nthird")).toBe("second") + it.each([ + ["\n\nsecond\nthird", "second third"], + ["Fix\nlogin bug", "Fix login bug"], + ])("collapses whitespace in %j", (input, expected) => { + expect(fallbackTitle(input)).toBe(expected) }) it("caps the fallback at the character limit", () => { @@ -143,7 +146,7 @@ describe("generateSessionTitle", () => { expect(applied).toEqual([{ taskId: "task-1", title: "Fix Login Bug" }]) }) - it("falls back to the first message line when the LLM fails", async () => { + it("falls back to the start of the message when the LLM fails", async () => { const recorder = createCompletePromptRecorder(async () => { throw new Error("api down") }) @@ -163,9 +166,33 @@ describe("generateSessionTitle", () => { completePrompt: recorder.fn, }) - expect(applied).toEqual([{ taskId: "task-2", title: "first line of the request" }]) + expect(applied).toEqual([{ taskId: "task-2", title: "first line of the request second line" }]) }) + it.each([[""], [" "], ['""'], ["Title:"]])( + "falls back to the message start when the LLM returns the unusable response %j", + async (response) => { + const recorder = createCompletePromptRecorder(async () => response) + const applied: ApplyTitleCall[] = [] + const provider = createTitleProvider({ + applySessionTitle: async (taskId, title) => { + applied.push({ taskId, title }) + return "applied" + }, + }) + + await generateSessionTitle({ + provider, + taskId: "task-unusable", + userMessage: "Short first line\nwith more text after it that goes on and on", + apiConfiguration: testApiConfiguration, + completePrompt: recorder.fn, + }) + + expect(applied).toEqual([{ taskId: "task-unusable", title: "Short first line with more text after it t" }]) + }, + ) + it("falls back when the LLM times out", async () => { vi.useFakeTimers() try { diff --git a/src/core/task/session-title.ts b/src/core/task/session-title.ts index 5fe28df8fc..fd900602b3 100644 --- a/src/core/task/session-title.ts +++ b/src/core/task/session-title.ts @@ -15,18 +15,6 @@ Output only the title. ` -/** - * Thrown inside `atomicReadAndUpdate` when the item already carries a title, - * so the auto-title path can detect the conflict without performing a - * pointless disk write of an unchanged record. - */ -export class SessionTitleConflictError extends Error { - constructor(taskId: string) { - super(`[session-title] task ${taskId} already has a title; keeping it`) - this.name = "SessionTitleConflictError" - } -} - /** Outcome of `ClineProvider.applySessionTitle`. */ export type SessionTitleApplyResult = "applied" | "conflict" @@ -63,16 +51,10 @@ export function extractLlmTitle(raw: string): string | undefined { return title.length > 0 ? title : undefined } -/** First non-empty line of the sanitized message, capped at 42 characters. */ +/** Start of the sanitized message with whitespace collapsed, capped at 42 characters. */ export function fallbackTitle(sanitized: string): string | undefined { - const firstLine = sanitized - .split("\n") - .find((line) => line.trim().length > 0) - ?.trim() - if (!firstLine) { - return undefined - } - return firstLine.length > FALLBACK_TITLE_MAX_CHARS ? firstLine.slice(0, FALLBACK_TITLE_MAX_CHARS) : firstLine + const collapsed = sanitized.replace(/\s+/g, " ").trim() + return collapsed ? collapsed.slice(0, FALLBACK_TITLE_MAX_CHARS).trim() : undefined } type CompletePromptFn = typeof singleCompletionHandler diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 45a6ebd493..5233bc9756 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -76,7 +76,7 @@ import { providerIdentifiers, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" -import { SessionTitleConflictError, type SessionTitleApplyResult } from "../task/session-title" +import type { SessionTitleApplyResult } from "../task/session-title" import { TaskRegistry } from "../task/TaskRegistry" import { TaskScheduler } from "../task/TaskScheduler" import { @@ -2835,7 +2835,7 @@ export class ClineProvider /* * Applies an auto-generated session title, but only while the item still * carries its default name. The title check runs inside the store's - * read-modify-write lock, so a manual rename that lands while a title is + * lock and re-reads the title on disk, so a manual rename that lands while a title is * generating always wins and is never overwritten. Returns "conflict" * when nothing was applied (an existing title won, or the title was * blank); throws on transient write failures so the caller can retry. @@ -2846,18 +2846,9 @@ export class ClineProvider return "conflict" } - try { - await this.taskHistoryStore.atomicReadAndUpdate(id, (historyItem) => { - if (historyItem.title) { - throw new SessionTitleConflictError(id) - } - return { ...historyItem, title: trimmed } - }) - } catch (error) { - if (error instanceof SessionTitleConflictError) { - return "conflict" - } - throw error + const result = await this.taskHistoryStore.setTitleIfUnset(id, trimmed) + if (result === "conflict") { + return "conflict" } this.recentTasksCache = undefined @@ -3679,7 +3670,7 @@ export class ClineProvider * Now delegates to TaskHistoryStore for per-task file persistence. * * This writer never owns the `title` field: `title` is written only by the title-owning paths - * (`renameTaskWithId` and `applySessionTitle`, both via `taskHistoryStore.atomicReadAndUpdate`). + * (`renameTaskWithId` via `atomicReadAndUpdate` and `applySessionTitle` via `setTitleIfUnset`). * Callers here build items from snapshots read before taking the store lock, so any title they * carry can be stale. * From 27c9b1afecc174a5cb6c50992dd6c7b8d13f3fd6 Mon Sep 17 00:00:00 2001 From: daewoongoh Date: Fri, 6 Nov 2026 19:35:00 +0900 Subject: [PATCH 4/5] fix(task): never recreate a deleted task when applying a title Abort the locked title write when the on-disk record is missing or already titled, cut the fallback at 42 characters before collapsing whitespace, and cover the deleted-task and pending-say startup paths with tests. --- src/core/task-persistence/TaskHistoryStore.ts | 51 +++++++++++++------ .../TaskHistoryStore.crossInstance.spec.ts | 16 ++++++ .../task/__tests__/Task.session-title.spec.ts | 23 +++++++++ src/core/task/__tests__/session-title.spec.ts | 6 +++ src/core/task/session-title.ts | 6 +-- 5 files changed, 84 insertions(+), 18 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 7354a92f23..c3593af654 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -26,6 +26,13 @@ function mergeWithDisk(delta: Partial): (existing: unknown, incomin return (existing, incoming) => mergeHistoryDelta(existing, incoming as HistoryItem, delta) } +/** Thrown inside the `setTitleIfUnset` merge callback to abort the write without touching the file. */ +class TitleWriteSkipped extends Error { + constructor(readonly reason: "missing" | "titled") { + super(`title write skipped: ${reason}`) + } +} + /** * Durable intent for the one repair that spans an active delegated child and * its parent. Task files remain authoritative; this file only records the @@ -1002,7 +1009,7 @@ export class TaskHistoryStore { * provider on the same storage path) saved a manual title, and a delta merge would * otherwise overwrite it. * - * @returns "conflict" when a title already exists (the cache is refreshed from disk). + * @returns "conflict" when a title already exists or the record was deleted on disk (the cache is refreshed). * @throws If the task ID is not present in the cache. */ public setTitleIfUnset(taskId: string, title: string): Promise<"applied" | "conflict"> { @@ -1017,23 +1024,37 @@ export class TaskHistoryStore { const filePath = await this.getTaskFilePath(taskId) let written: HistoryItem = { ...current, title } - let conflict = false - await safeWriteJson(filePath, written, { - merge: (existing, incoming) => { - if (existing && typeof existing === "object" && (existing as HistoryItem).title) { - conflict = true - written = existing as HistoryItem - return existing + try { + await safeWriteJson(filePath, written, { + merge: (existing, incoming) => { + // Throwing aborts the write, so a deleted record is never recreated from the cache + // and an existing title is never rewritten. + if (!existing || typeof existing !== "object" || !("id" in existing)) { + throw new TitleWriteSkipped("missing") + } + if ((existing as HistoryItem).title) { + throw new TitleWriteSkipped("titled") + } + written = mergeHistoryDelta(existing, incoming as HistoryItem, { title }) + return written + }, + }) + } catch (error) { + if (!(error instanceof TitleWriteSkipped)) { + throw error + } + if (error.reason === "missing") { + this.cache.delete(taskId) + } else { + const diskItem = await this.readTaskFile(taskId) + if (diskItem) { + this.cache.set(taskId, diskItem) } - written = mergeHistoryDelta(existing, incoming as HistoryItem, { title }) - return written - }, - }) - - this.cache.set(taskId, written) - if (conflict) { + } return "conflict" } + + this.cache.set(taskId, written) if (this.onWrite) { await this.onWrite(this.getAll()) } diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts index 87459edc1e..27eaf1347b 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts @@ -92,6 +92,22 @@ describe("TaskHistoryStore cross-instance safety", () => { expect(storeB.get("title-task")?.title).toBe("Manual title") }) + it("does not recreate a task that another instance deleted", async () => { + await storeA.initialize() + await storeB.initialize() + await storeA.upsert(makeHistoryItem({ id: "title-deleted", task: "Original prompt" })) + await storeB.reconcile() + + await storeB.delete("title-deleted") + expect(storeA.get("title-deleted")).toBeDefined() + + await expect(storeA.setTitleIfUnset("title-deleted", "Auto title")).resolves.toBe("conflict") + + expect(storeA.get("title-deleted")).toBeUndefined() + await storeB.reconcile() + expect(storeB.get("title-deleted")).toBeUndefined() + }) + it("applies the title when no instance has set one", async () => { await storeA.initialize() await storeA.upsert(makeHistoryItem({ id: "title-free", task: "Original prompt" })) diff --git a/src/core/task/__tests__/Task.session-title.spec.ts b/src/core/task/__tests__/Task.session-title.spec.ts index 5febeb136c..82f2fe1fef 100644 --- a/src/core/task/__tests__/Task.session-title.spec.ts +++ b/src/core/task/__tests__/Task.session-title.spec.ts @@ -173,6 +173,29 @@ describe("Task#startTask session title trigger", () => { expect(resolved).toBe(true) }) + it("starts title generation only after the first message is stored", async () => { + const provider = createMockProvider() + const task = createTestTask(provider) + stubStartup(task) + + let releaseSay: () => void = () => {} + vi.spyOn(task, "say").mockImplementation( + () => + new Promise((resolve) => { + releaseSay = () => resolve(undefined) + }), + ) + + const started = getStartTaskAccess(task).startTask("stored first") + await Promise.resolve() + expect(vi.mocked(generateSessionTitle)).not.toHaveBeenCalled() + + releaseSay() + await started + + expect(lastTitleCall()?.userMessage).toBe("stored first") + }) + it("does not wait for a pending title before starting the task loop", async () => { const provider = createMockProvider() const task = createTestTask(provider) diff --git a/src/core/task/__tests__/session-title.spec.ts b/src/core/task/__tests__/session-title.spec.ts index ab46216aaa..91e17a5353 100644 --- a/src/core/task/__tests__/session-title.spec.ts +++ b/src/core/task/__tests__/session-title.spec.ts @@ -107,6 +107,12 @@ describe("fallbackTitle", () => { expect(fallbackTitle(input)).toBe(expected) }) + it("cuts at the character limit before collapsing whitespace", () => { + const input = "a".repeat(FALLBACK_TITLE_MAX_CHARS - 2) + " ".repeat(5) + "bcd" + expect(fallbackTitle(input)).toBe("a".repeat(FALLBACK_TITLE_MAX_CHARS - 2)) + expect(fallbackTitle("ab" + " ".repeat(60) + "cd")).toBe("ab") + }) + it("caps the fallback at the character limit", () => { const long = "a".repeat(FALLBACK_TITLE_MAX_CHARS + 10) expect(fallbackTitle(long)).toBe("a".repeat(FALLBACK_TITLE_MAX_CHARS)) diff --git a/src/core/task/session-title.ts b/src/core/task/session-title.ts index fd900602b3..89661ceaa6 100644 --- a/src/core/task/session-title.ts +++ b/src/core/task/session-title.ts @@ -51,10 +51,10 @@ export function extractLlmTitle(raw: string): string | undefined { return title.length > 0 ? title : undefined } -/** Start of the sanitized message with whitespace collapsed, capped at 42 characters. */ +/** First 42 characters of the sanitized message, then whitespace collapsed. */ export function fallbackTitle(sanitized: string): string | undefined { - const collapsed = sanitized.replace(/\s+/g, " ").trim() - return collapsed ? collapsed.slice(0, FALLBACK_TITLE_MAX_CHARS).trim() : undefined + const head = sanitized.trim().slice(0, FALLBACK_TITLE_MAX_CHARS) + return head.replace(/\s+/g, " ").trim() || undefined } type CompletePromptFn = typeof singleCompletionHandler From 2345d6bfd82977ee897472edef0425a2396e33de Mon Sep 17 00:00:00 2001 From: daewoongoh Date: Fri, 6 Nov 2026 20:57:09 +0900 Subject: [PATCH 5/5] fix(task): preserve manual title clears and respect rate limits in session titles - Cancel in-flight session title generation when renaming or clearing a task so an auto-generated title never overwrites a user's manual clear. - Skip LLM completion and use fallback title immediately when provider rateLimitSeconds > 0. - Reinforce extractLlmTitle boundary tests with internal 'Title:' and quotes. - Fix formatting in Stryker disable directives to satisfy stryker-diff. --- src/core/task/Task.ts | 4 +- src/core/task/__tests__/session-title.spec.ts | 65 +++++++++++ src/core/task/session-title.ts | 104 ++++++++++++++---- src/core/webview/ClineProvider.ts | 6 +- .../ClineProvider.taskHistory.spec.ts | 32 ++++++ 5 files changed, 188 insertions(+), 23 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1af42c7498..4b108221b1 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4448,7 +4448,7 @@ export class Task extends EventEmitter implements TaskLike { nativeToolCallParserScope, ) - // Stryker disable next-line ConditionalExpression, EqualityOperator: equivalent mutants, streamingKey is always defined here, so both conditions reduce to the partialToolUse check + // Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent mutants, streamingKey is always defined here, so both conditions reduce to the partialToolUse check if (partialToolUse && streamingKey !== undefined) { // Reuse the compound key built above: it is exactly the key the start event // registered under, so the delta lookup needs no second encoding. @@ -4884,7 +4884,7 @@ export class Task extends EventEmitter implements TaskLike { // name (provider quirk), the compound-key lookup above misses and both the // parser entry and the dedup tracking would stay stale. Resolve the tracked // entry by id and target the real compound key for finalization and cleanup. - // Stryker disable next-line ConditionalExpression, LogicalOperator, EqualityOperator: equivalent mutants, the both-miss case is unreachable via the raw-chunk pipeline + // Stryker disable next-line ConditionalExpression,LogicalOperator,EqualityOperator: equivalent mutants, the both-miss case is unreachable via the raw-chunk pipeline if (finalToolUse === null && toolUseIndex === undefined) { const resolved = NativeToolCallParser.getStreamingToolCallById( event.id, diff --git a/src/core/task/__tests__/session-title.spec.ts b/src/core/task/__tests__/session-title.spec.ts index 91e17a5353..053f835b76 100644 --- a/src/core/task/__tests__/session-title.spec.ts +++ b/src/core/task/__tests__/session-title.spec.ts @@ -9,6 +9,7 @@ import { SESSION_TITLE_TIMEOUT_MS, fallbackTitle, generateSessionTitle, + cancelSessionTitle, sanitizeUserMessageForTitle, extractLlmTitle, type SessionTitleApplyResult, @@ -83,6 +84,10 @@ describe("sanitizeUserMessageForTitle", () => { describe("extractLlmTitle", () => { it.each([ ["Fix login bug in auth flow", "Fix login bug in auth flow"], + ["Fix Title: parsing in task history", "Fix Title: parsing in task history"], + ['Fix "quoted" search terms', 'Fix "quoted" search terms'], + ['"Leading quote only', "Leading quote only"], + ['Trailing quote only"', "Trailing quote only"], ["Title: Fix login bug", "Fix login bug"], ["title: lowercase prefix", "lowercase prefix"], ['"Quoted title"', "Quoted title"], @@ -352,6 +357,66 @@ describe("generateSessionTitle", () => { expect(applied[0].title).toBe("First Title") }) + it("aborts in-flight generation and does not apply title when cancelled", async () => { + let abortSignal: AbortSignal | undefined + let resolveCompletion!: (val: string) => void + const completionPromise = new Promise((resolve) => { + resolveCompletion = resolve + }) + const recorder = createCompletePromptRecorder(async ({ options }) => { + abortSignal = options?.abortSignal + return completionPromise + }) + const applied: ApplyTitleCall[] = [] + const provider = createTitleProvider({ + applySessionTitle: async (taskId, title) => { + applied.push({ taskId, title }) + return "applied" + }, + }) + + const generation = generateSessionTitle({ + provider, + taskId: "task-cancel", + userMessage: "User prompt to name", + apiConfiguration: testApiConfiguration, + completePrompt: recorder.fn, + }) + + expect(recorder.calls).toHaveLength(1) + expect(abortSignal?.aborted).toBe(false) + + cancelSessionTitle("task-cancel") + + expect(abortSignal?.aborted).toBe(true) + resolveCompletion("Generated title") + await generation + + expect(applied).toHaveLength(0) + }) + + it("skips LLM completion and uses fallback title when rateLimitSeconds is set", async () => { + const recorder = createCompletePromptRecorder(async () => "LLM Title") + const applied: ApplyTitleCall[] = [] + const provider = createTitleProvider({ + applySessionTitle: async (taskId, title) => { + applied.push({ taskId, title }) + return "applied" + }, + }) + + await generateSessionTitle({ + provider, + taskId: "task-ratelimit", + userMessage: "Fix urgent production issue in login flow", + apiConfiguration: { ...testApiConfiguration, rateLimitSeconds: 5 }, + completePrompt: recorder.fn, + }) + + expect(recorder.calls).toHaveLength(0) + expect(applied).toEqual([{ taskId: "task-ratelimit", title: "Fix urgent production issue in login flow" }]) + }) + it("never rejects even when everything fails", async () => { const provider = createTitleProvider({ applySessionTitle: async () => { diff --git a/src/core/task/session-title.ts b/src/core/task/session-title.ts index 89661ceaa6..2a826d60ff 100644 --- a/src/core/task/session-title.ts +++ b/src/core/task/session-title.ts @@ -32,6 +32,7 @@ export function sanitizeUserMessageForTitle(raw: string): string { break } result += raw.slice(cursor, start) + // Stryker disable next-line ArithmeticOperator: equivalent mutant, (18 chars) cannot start in the [start-17, start+17] window around (17 chars) const end = raw.indexOf(close, start + open.length) // An unterminated block would otherwise leak the rest of the message as user text; drop it. if (end === -1) { @@ -70,8 +71,26 @@ export interface GenerateSessionTitleOptions { completePrompt?: CompletePromptFn } -/** One in-flight title generation per session (spec). */ -const inFlightTitleTaskIds = new Set() +interface InFlightTitleGeneration { + controller: AbortController + cancelled: boolean +} + +/** In-flight title generation controllers by task ID. */ +const inFlightTitles = new Map() + +/** + * Cancels any pending or in-flight title generation for a task. + * Called when a task is manually renamed, cleared, or deleted so an auto-generated title + * never overwrites the user's manual intent. + */ +export function cancelSessionTitle(taskId: string): void { + const inFlight = inFlightTitles.get(taskId) + if (inFlight) { + inFlight.cancelled = true + inFlight.controller.abort() + } +} /** * Generates and applies a session title from the root user message. @@ -88,29 +107,49 @@ export async function generateSessionTitle(options: GenerateSessionTitleOptions) return } - if (inFlightTitleTaskIds.has(taskId)) { + if (inFlightTitles.has(taskId)) { return } - inFlightTitleTaskIds.add(taskId) + const inFlight: InFlightTitleGeneration = { + controller: new AbortController(), + cancelled: false, + } + inFlightTitles.set(taskId, inFlight) try { let title: string | undefined - try { - title = await completeTitleViaLlm(completePrompt, apiConfiguration, taskId, sanitized) - } catch (error) { - // LLM failure or timeout falls back to the message's first line. - console.error(`[session-title] LLM call failed for task ${taskId}:`, error) + const rateLimitSeconds = apiConfiguration.rateLimitSeconds ?? 0 + if (rateLimitSeconds <= 0 && !inFlight.cancelled) { + try { + title = await completeTitleViaLlm( + completePrompt, + apiConfiguration, + taskId, + sanitized, + inFlight.controller, + ) + } catch (error) { + // LLM failure, cancellation, or timeout falls back to the message's first line. + if (!inFlight.cancelled) { + console.error(`[session-title] LLM call failed for task ${taskId}:`, error) + } + } + } + if (inFlight.cancelled) { + return } title ??= fallbackTitle(sanitized) - if (!title) { + if (!title || inFlight.cancelled) { return } - await applyTitleWithRetry(provider, taskId, title) + await applyTitleWithRetry(provider, taskId, title, () => inFlight.cancelled) } catch (error) { // Best-effort by design: a failed title must never surface in the chat. - console.error(`[session-title] generation failed for task ${taskId}:`, error) + if (!inFlight.cancelled) { + console.error(`[session-title] generation failed for task ${taskId}:`, error) + } } finally { - inFlightTitleTaskIds.delete(taskId) + inFlightTitles.delete(taskId) } } @@ -119,8 +158,8 @@ async function completeTitleViaLlm( apiConfiguration: ProviderSettings, taskId: string, sanitized: string, + controller: AbortController, ): Promise { - const controller = new AbortController() let timeoutId: ReturnType | undefined try { // Both options are honored only by some providers, so the race below is @@ -137,17 +176,37 @@ async function completeTitleViaLlm( reject(new Error(`[session-title] timed out after ${SESSION_TITLE_TIMEOUT_MS}ms`)) }, SESSION_TITLE_TIMEOUT_MS) }) - // Promise.race attaches handlers to both inputs, so the completion - // rejecting after the timeout is already considered handled. - const raw = await Promise.race([completion, timeout]) + const abortPromise = new Promise((_, reject) => { + if (controller.signal.aborted) { + reject(new Error("[session-title] aborted")) + } else { + controller.signal.addEventListener( + "abort", + () => { + reject(new Error("[session-title] aborted")) + }, + { once: true }, + ) + } + }) + // Promise.race attaches handlers to all inputs, so rejections after completion are considered handled. + const raw = await Promise.race([completion, timeout, abortPromise]) return extractLlmTitle(raw) } finally { clearTimeout(timeoutId) } } -async function applyTitleWithRetry(provider: ClineProvider, taskId: string, title: string): Promise { +async function applyTitleWithRetry( + provider: ClineProvider, + taskId: string, + title: string, + isCancelled: () => boolean, +): Promise { for (let attempt = 0; ; attempt++) { + if (isCancelled()) { + return + } try { await provider.applySessionTitle(taskId, title) // Both outcomes end the attempt: "applied" is done, and "conflict" @@ -155,8 +214,13 @@ async function applyTitleWithRetry(provider: ClineProvider, taskId: string, titl // first) — the existing title always wins. return } catch (error) { - if (attempt >= TITLE_WRITE_MAX_RETRIES) { - console.error(`[session-title] giving up on task ${taskId} after ${attempt + 1} write attempts:`, error) + if (isCancelled() || attempt >= TITLE_WRITE_MAX_RETRIES) { + if (!isCancelled()) { + console.error( + `[session-title] giving up on task ${taskId} after ${attempt + 1} write attempts:`, + error, + ) + } return } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5233bc9756..148d028331 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -76,7 +76,7 @@ import { providerIdentifiers, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" -import type { SessionTitleApplyResult } from "../task/session-title" +import { cancelSessionTitle, type SessionTitleApplyResult } from "../task/session-title" import { TaskRegistry } from "../task/TaskRegistry" import { TaskScheduler } from "../task/TaskScheduler" import { @@ -2810,6 +2810,7 @@ export class ClineProvider * clears it so the history falls back to the original prompt. */ async renameTaskWithId(id: string, title: string | undefined) { + cancelSessionTitle(id) if (!this.taskHistoryStore.get(id)) { this.log(`[renameTaskWithId] task ${id} not found in history`) return @@ -2826,6 +2827,7 @@ export class ClineProvider if (this.isViewLaunched) { const updatedItem = this.taskHistoryStore.get(id) + // Stryker disable next-line ConditionalExpression: equivalent mutant, setTitleIfUnset guarantees the item is in the cache if (updatedItem) { await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem }) } @@ -2855,6 +2857,7 @@ export class ClineProvider if (this.isViewLaunched) { const updatedItem = this.taskHistoryStore.get(id) + // Stryker disable next-line ConditionalExpression: equivalent mutant, setTitleIfUnset guarantees the item is in the cache if (updatedItem) { await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem }) } @@ -2876,6 +2879,7 @@ export class ClineProvider // this function deletes a task from task history, and deletes its checkpoints and delete the task folder // If the task has subtasks (childIds), they will also be deleted recursively async deleteTaskWithId(id: string, cascadeSubtasks: boolean = true) { + cancelSessionTitle(id) try { await this.getTaskWithId(id) diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 90b0fdd1a0..d151591334 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -11,6 +11,7 @@ import { ContextProxy } from "../../config/ContextProxy" import { Task } from "../../task/Task" import { ProfileValidator } from "../../../shared/ProfileValidator" import { ClineProvider } from "../ClineProvider" +import { generateSessionTitle } from "../../task/session-title" // Mock setup vi.mock("p-wait-for", () => ({ @@ -653,6 +654,35 @@ describe("ClineProvider Task History Synchronization", () => { expect(getStored("task-race")).toMatchObject({ title: "Renamed", tokensIn: 500 }) }) + it("cancels in-flight session title generation and preserves a manual title clear", async () => { + await provider.updateTaskHistory(createHistoryItem({ id: "task-manual-clear", task: "Original prompt" }), { + broadcast: false, + }) + let resolveCompletion!: (title: string) => void + const completionPromise = new Promise((resolve) => { + resolveCompletion = resolve + }) + const generation = generateSessionTitle({ + provider, + taskId: "task-manual-clear", + userMessage: "Original prompt", + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-api-key", + }, + completePrompt: async () => completionPromise, + }) + + await provider.renameTaskWithId("task-manual-clear", "") + expect(getStored("task-manual-clear")?.title).toBeUndefined() + + resolveCompletion("Generated Title") + await generation + + expect(getStored("task-manual-clear")?.title).toBeUndefined() + }) + it("does not write a title through a full-item update", async () => { await provider.updateTaskHistory( createHistoryItem({ id: "task-title-owner", task: "Original prompt", title: "Stale title" }), @@ -761,6 +791,8 @@ describe("ClineProvider Task History Synchronization", () => { }) it("does not broadcast when the view is not launched", async () => { + await provider.resolveWebviewView(mockWebviewView) + provider.isViewLaunched = false await provider.updateTaskHistory(createHistoryItem({ id: "task-auto-silent", task: "Original prompt" }), { broadcast: false, })