Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@
import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages"
import { prepareApiConversationMessage } from "./apiConversationHistory"
import { shouldAddUserMessageToHistory } from "./messageCounting"
import { generateTaskTitle } from "./taskTitleGenerator"
import { type TaskExecutionContext } from "./providerHandoff"
import { initialRunState, type Ask, type RunEvent, type RunState } from "./run-state/runState"
import { applyRunEvent, type RunRejection } from "./run-state/runStateShadow"
Expand Down Expand Up @@ -394,6 +395,11 @@
private readonly globalStoragePath: string
abort: boolean = false
currentRequestAbortController?: AbortController
/**
* Controller for background title summarization of the initial user prompt.
* Aborted when the task is cancelled, disposed, or explicitly aborted.
*/
titleAbortController?: AbortController
/**
* Controller for the waiter on an in-flight `ensureModelFetched()` call (see
* safeEnsureModelFetched). Aborting it detaches this task from the provider-side
Expand Down Expand Up @@ -3110,6 +3116,11 @@

await this.say("text", task, images)

// Generate a concise task title in the background for new top-level tasks.
if (task && !this.parentTask) {
void this.generateTitleInBackground(task)
}

// Check for too many MCP tools and warn the user
const { enabledToolCount, enabledServerCount } = await this.getEnabledMcpToolsCount()
if (enabledToolCount > MAX_MCP_TOOLS_THRESHOLD) {
Expand Down Expand Up @@ -3183,6 +3194,66 @@
}
}

/**
* Generates a concise title for the task in the background using the initial prompt.
* If the user cancels the task, or if generation fails/times out, the title is left
* unchanged so the original prompt remains the task title.
*/
private async generateTitleInBackground(taskText: string): Promise<void> {
if (this.abort || this.abandoned) {

Check warning on line 3203 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:3203: 2 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: false). See the job summary for the complete list and resolution guidance.
return
}

const controller = new AbortController()
this.titleAbortController = controller

try {
const title = await generateTaskTitle(taskText, this.apiConfiguration, {
abortSignal: controller.signal,
timeoutMs: 10000,
taskId: this.taskId,
})

if (controller.signal.aborted || this.abort || this.abandoned || !title) {

Check warning on line 3217 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:3217: 4 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: false). See the job summary for the complete list and resolution guidance.
return
}

const provider = this.providerRef.deref()
if (!provider) {

Check warning on line 3222 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:3222: Survived ConditionalExpression mutant (replacement: false). See the job summary for the complete list and resolution guidance.
return
}

// In tests or fast completion paths, wait briefly until the task is recorded in the store
for (let i = 0; i < 20; i++) {

Check warning on line 3227 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:3227: 4 mutation test gaps; example: NoCoverage UpdateOperator mutant (replacement: i--). See the job summary for the complete list and resolution guidance.
if (controller.signal.aborted || this.abort || this.abandoned) {

Check warning on line 3228 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:3228: 4 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: false). See the job summary for the complete list and resolution guidance.
return
}
if (provider.taskHistoryStore.get(this.taskId)) {

Check warning on line 3231 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:3231: 2 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
break
}
await delay(50)
}

// If the task already has a custom title set (e.g. user manually renamed it), do not overwrite
const existing = provider.taskHistoryStore.get(this.taskId)
if (existing?.title) {

Check warning on line 3239 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:3239: Survived OptionalChaining mutant (replacement: existing.title). See the job summary for the complete list and resolution guidance.
return
}

if (controller.signal.aborted || this.abort || this.abandoned) {
return
}

await provider.renameTaskWithId(this.taskId, title)
} catch (error) {
console.error(`[Task#generateTitleInBackground] Failed for ${this.taskId}:`, error)
} finally {
if (this.titleAbortController === controller) {
this.titleAbortController = undefined
}
}
}

private async resumeTaskFromHistory() {
try {
const modifiedClineMessages = [...(await this.getSavedClineMessages())]
Expand Down Expand Up @@ -3536,6 +3607,10 @@
this.metadataFetchAbortController.abort()
this.metadataFetchAbortController = undefined
}
if (this.titleAbortController) {
this.titleAbortController.abort()
this.titleAbortController = undefined
}
}

/**
Expand Down Expand Up @@ -3591,6 +3666,10 @@
this.abandoned = true
}
this.abort = true
if (this.titleAbortController) {
this.titleAbortController.abort()
this.titleAbortController = undefined
}
this.driveRunEvent({ tag: "abortRequested", abandoned: isAbandoned })
}

Expand Down
217 changes: 217 additions & 0 deletions src/core/task/__tests__/Task.titleGeneration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import type { ProviderSettings, HistoryItem } from "@roo-code/types"
import { providerIdentifiers } from "@roo-code/types"

import { Task } from "../Task"
import * as taskTitleGen from "../taskTitleGenerator"

vi.mock("../taskTitleGenerator", () => ({
generateTaskTitle: vi.fn(),
}))

type TaskTitleAccess = {
generateTitleInBackground(taskText: string): Promise<void>
startTask(task?: string, images?: string[]): Promise<void>
cancelCurrentRequest(): void
requestAbort(isAbandoned: boolean): void
titleAbortController?: AbortController
abort: boolean
abandoned: boolean
taskId: string
instanceId: string
apiConfiguration: ProviderSettings
parentTask?: Task
metadata: { task?: string; images?: string[] }
}

const getTaskTitleAccess = (task: Task) => task as unknown as TaskTitleAccess

describe("Task title generation", () => {
const mockApiConfig: ProviderSettings = {
apiProvider: providerIdentifiers.anthropic,
apiKey: "test-key",
}

let mockHistoryStore: Map<string, HistoryItem>
let mockRenameTaskWithId: ReturnType<typeof vi.fn>
let mockProvider: {
renameTaskWithId: ReturnType<typeof vi.fn>
taskHistoryStore: {
get: (id: string) => HistoryItem | undefined
}
postStateToWebviewWithoutTaskHistory: ReturnType<typeof vi.fn>
}

beforeEach(() => {
vi.clearAllMocks()
mockHistoryStore = new Map()
mockRenameTaskWithId = vi.fn().mockResolvedValue(undefined)
mockProvider = {
renameTaskWithId: mockRenameTaskWithId,
taskHistoryStore: {
get: (id: string) => mockHistoryStore.get(id),
},
postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined),
}
})

function createTestTask(options: { parentTask?: Task; initialTitle?: string } = {}) {
const task = Object.create(Task.prototype) as Task
const taskId = "task-title-1"
mockHistoryStore.set(taskId, {
id: taskId,
ts: Date.now(),
task: "Initial prompt message",
title: options.initialTitle,
} as HistoryItem)

Object.assign(task, {
taskId,
instanceId: "inst-1",
apiConfiguration: mockApiConfig,
providerRef: { deref: () => mockProvider },
abort: false,
abandoned: false,
parentTask: options.parentTask,
metadata: { task: "Initial prompt message" },
say: vi.fn().mockResolvedValue(undefined),
getEnabledMcpToolsCount: vi.fn().mockResolvedValue({ enabledToolCount: 0, enabledServerCount: 0 }),
markInitialized: vi.fn(),
initiateTaskLoop: vi.fn().mockResolvedValue(undefined),
driveRunEvent: vi.fn(),
})

return task
}

describe("generateTitleInBackground", () => {
it("calls generateTaskTitle and updates task title via renameTaskWithId", async () => {
vi.mocked(taskTitleGen.generateTaskTitle).mockResolvedValueOnce("Summarized Title")
const task = createTestTask()

await getTaskTitleAccess(task).generateTitleInBackground("Initial prompt message")

expect(taskTitleGen.generateTaskTitle).toHaveBeenCalledWith(
"Initial prompt message",
mockApiConfig,
expect.objectContaining({
taskId: "task-title-1",
timeoutMs: 10000,
}),
)
expect(mockRenameTaskWithId).toHaveBeenCalledWith("task-title-1", "Summarized Title")
})

it("does not call renameTaskWithId when generateTaskTitle returns undefined", async () => {
vi.mocked(taskTitleGen.generateTaskTitle).mockResolvedValueOnce(undefined)
const task = createTestTask()

await getTaskTitleAccess(task).generateTitleInBackground("Initial prompt message")

expect(mockRenameTaskWithId).not.toHaveBeenCalled()
})

it("does not overwrite if the task already has a custom title set", async () => {
vi.mocked(taskTitleGen.generateTaskTitle).mockResolvedValueOnce("Summarized Title")
const task = createTestTask({ initialTitle: "User Custom Title" })

await getTaskTitleAccess(task).generateTitleInBackground("Initial prompt message")

expect(mockRenameTaskWithId).not.toHaveBeenCalled()
})

it("does not call renameTaskWithId if task is aborted during generation", async () => {
const task = createTestTask()
vi.mocked(taskTitleGen.generateTaskTitle).mockImplementationOnce(async () => {
task.abort = true
return "Summarized Title"
})

await getTaskTitleAccess(task).generateTitleInBackground("Initial prompt message")

expect(mockRenameTaskWithId).not.toHaveBeenCalled()
})

it("does not call renameTaskWithId if task is abandoned during generation", async () => {
const task = createTestTask()
vi.mocked(taskTitleGen.generateTaskTitle).mockImplementationOnce(async () => {
task.abandoned = true
return "Summarized Title"
})

await getTaskTitleAccess(task).generateTitleInBackground("Initial prompt message")

expect(mockRenameTaskWithId).not.toHaveBeenCalled()
})

it("cleans up titleAbortController when finished", async () => {
vi.mocked(taskTitleGen.generateTaskTitle).mockResolvedValueOnce("Summarized Title")
const task = createTestTask()

await getTaskTitleAccess(task).generateTitleInBackground("Initial prompt message")

expect(getTaskTitleAccess(task).titleAbortController).toBeUndefined()
})
})

describe("cancellation and abort handling", () => {
it("aborts titleAbortController in cancelCurrentRequest", () => {
const task = createTestTask()
const access = getTaskTitleAccess(task)
const controller = new AbortController()
const abortSpy = vi.spyOn(controller, "abort")
access.titleAbortController = controller

access.cancelCurrentRequest()

expect(abortSpy).toHaveBeenCalled()
expect(access.titleAbortController).toBeUndefined()
})

it("aborts titleAbortController in requestAbort", () => {
const task = createTestTask()
const access = getTaskTitleAccess(task)
const controller = new AbortController()
const abortSpy = vi.spyOn(controller, "abort")
access.titleAbortController = controller

access.requestAbort(false)

expect(abortSpy).toHaveBeenCalled()
expect(access.titleAbortController).toBeUndefined()
})
})

describe("startTask background trigger", () => {
it("starts background title generation for top-level task with prompt", async () => {
const task = createTestTask()
const access = getTaskTitleAccess(task)
const titleBgSpy = vi.spyOn(access, "generateTitleInBackground").mockResolvedValue(undefined)

await access.startTask("Initial prompt message")

expect(titleBgSpy).toHaveBeenCalledWith("Initial prompt message")
})

it("does not start title generation for child/subtasks", async () => {
const parent = createTestTask()
const child = createTestTask({ parentTask: parent })
const access = getTaskTitleAccess(child)
const titleBgSpy = vi.spyOn(access, "generateTitleInBackground").mockResolvedValue(undefined)

await access.startTask("Child subtask prompt")

expect(titleBgSpy).not.toHaveBeenCalled()
})

it("does not start title generation when prompt is omitted", async () => {
const task = createTestTask()
const access = getTaskTitleAccess(task)
const titleBgSpy = vi.spyOn(access, "generateTitleInBackground").mockResolvedValue(undefined)

await access.startTask(undefined)

expect(titleBgSpy).not.toHaveBeenCalled()
})
})
})
Loading
Loading