Skip to content

Commit 1e4e3c2

Browse files
committed
feat: summarize initial prompt to generate task title in background
1 parent 1b9cb7d commit 1e4e3c2

7 files changed

Lines changed: 596 additions & 1 deletion

File tree

‎src/core/task/Task.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ import { validateAndFixToolResultIds } from "./validateToolResultIds"
143143
import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages"
144144
import { prepareApiConversationMessage } from "./apiConversationHistory"
145145
import { shouldAddUserMessageToHistory } from "./messageCounting"
146+
import { generateTaskTitle } from "./taskTitleGenerator"
146147
import { type TaskExecutionContext } from "./providerHandoff"
147148
import { abort, abortReason, initialRunState, type Ask, type RunEvent, type RunState } from "./run-state/runState"
148149
import { applyRunEvent, type RunRejection } from "./run-state/runStateShadow"
@@ -242,6 +243,9 @@ function queuedResponseForAsk(type: ClineAsk, text?: string): QueuedAskResolutio
242243
const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors
243244
const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors
244245

246+
// Literal, not the vscode enum: specs that mock "vscode" without ExtensionMode would throw on the read.
247+
const EXTENSION_MODE_TEST = 3
248+
245249
export interface ToolExecutionContext {
246250
toolCallId: string
247251
didReject: boolean
@@ -393,6 +397,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
393397
providerRef: WeakRef<ClineProvider>
394398
private readonly globalStoragePath: string
395399
currentRequestAbortController?: AbortController
400+
/**
401+
* Controller for background title summarization of the initial user prompt.
402+
* Aborted when the task is cancelled, disposed, or explicitly aborted.
403+
*/
404+
titleAbortController?: AbortController
396405
/**
397406
* Controller for the waiter on an in-flight `ensureModelFetched()` call (see
398407
* safeEnsureModelFetched). Aborting it detaches this task from the provider-side
@@ -3108,6 +3117,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
31083117

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

3120+
// Generate a concise task title in the background for new top-level tasks.
3121+
if (task && !this.parentTask) {
3122+
void this.generateTitleInBackground(task)
3123+
}
3124+
31113125
// Check for too many MCP tools and warn the user
31123126
const { enabledToolCount, enabledServerCount } = await this.getEnabledMcpToolsCount()
31133127
if (enabledToolCount > MAX_MCP_TOOLS_THRESHOLD) {
@@ -3181,6 +3195,66 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
31813195
}
31823196
}
31833197

3198+
/**
3199+
* Generates a concise title for the task in the background using the initial prompt.
3200+
* If the user cancels the task, or if generation fails/times out, the title is left
3201+
* unchanged so the original prompt remains the task title.
3202+
*/
3203+
private async generateTitleInBackground(taskText: string): Promise<void> {
3204+
if (this.abort || this.abandoned) {
3205+
return
3206+
}
3207+
3208+
const provider = this.providerRef.deref()
3209+
if (!provider || provider.context?.extensionMode === EXTENSION_MODE_TEST) {
3210+
return
3211+
}
3212+
3213+
const controller = new AbortController()
3214+
this.titleAbortController = controller
3215+
3216+
try {
3217+
const title = await generateTaskTitle(taskText, this.apiConfiguration, {
3218+
abortSignal: controller.signal,
3219+
timeoutMs: 10000,
3220+
taskId: this.taskId,
3221+
})
3222+
3223+
if (controller.signal.aborted || this.abort || this.abandoned || !title) {
3224+
return
3225+
}
3226+
3227+
// In tests or fast completion paths, wait briefly until the task is recorded in the store
3228+
for (let i = 0; i < 20; i++) {
3229+
if (controller.signal.aborted || this.abort || this.abandoned) {
3230+
return
3231+
}
3232+
if (provider.taskHistoryStore.get(this.taskId)) {
3233+
break
3234+
}
3235+
await delay(50)
3236+
}
3237+
3238+
// If the task already has a custom title set (e.g. user manually renamed it), do not overwrite
3239+
const existing = provider.taskHistoryStore.get(this.taskId)
3240+
if (existing?.title) {
3241+
return
3242+
}
3243+
3244+
if (controller.signal.aborted || this.abort || this.abandoned) {
3245+
return
3246+
}
3247+
3248+
await provider.renameTaskWithId(this.taskId, title)
3249+
} catch (error) {
3250+
console.error(`[Task#generateTitleInBackground] Failed for ${this.taskId}:`, error)
3251+
} finally {
3252+
if (this.titleAbortController === controller) {
3253+
this.titleAbortController = undefined
3254+
}
3255+
}
3256+
}
3257+
31843258
private async resumeTaskFromHistory() {
31853259
try {
31863260
const modifiedClineMessages = [...(await this.getSavedClineMessages())]
@@ -3534,6 +3608,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
35343608
this.metadataFetchAbortController.abort()
35353609
this.metadataFetchAbortController = undefined
35363610
}
3611+
if (this.titleAbortController) {
3612+
this.titleAbortController.abort()
3613+
this.titleAbortController = undefined
3614+
}
35373615
}
35383616

35393617
/**
@@ -3595,6 +3673,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
35953673
if (isAbandoned) {
35963674
this.abandoned = true
35973675
}
3676+
if (this.titleAbortController) {
3677+
this.titleAbortController.abort()
3678+
this.titleAbortController = undefined
3679+
}
35983680
this.driveRunEvent({ tag: "abortRequested", abandoned: isAbandoned })
35993681
}
36003682

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import type { ProviderSettings, HistoryItem } from "@roo-code/types"
3+
import { providerIdentifiers } from "@roo-code/types"
4+
5+
import { Task } from "../Task"
6+
import * as taskTitleGen from "../taskTitleGenerator"
7+
import { setAbortLatch } from "../../../test-utils/run-state"
8+
9+
vi.mock("../taskTitleGenerator", () => ({
10+
generateTaskTitle: vi.fn(),
11+
}))
12+
13+
type TaskTitleAccess = {
14+
generateTitleInBackground(taskText: string): Promise<void>
15+
startTask(task?: string, images?: string[]): Promise<void>
16+
cancelCurrentRequest(): void
17+
requestAbort(isAbandoned: boolean): void
18+
titleAbortController?: AbortController
19+
abort: boolean
20+
abandoned: boolean
21+
taskId: string
22+
instanceId: string
23+
apiConfiguration: ProviderSettings
24+
parentTask?: Task
25+
metadata: { task?: string; images?: string[] }
26+
}
27+
28+
const getTaskTitleAccess = (task: Task) => task as unknown as TaskTitleAccess
29+
30+
describe("Task title generation", () => {
31+
const mockApiConfig: ProviderSettings = {
32+
apiProvider: providerIdentifiers.anthropic,
33+
apiKey: "test-key",
34+
}
35+
36+
let mockHistoryStore: Map<string, HistoryItem>
37+
let mockRenameTaskWithId: ReturnType<typeof vi.fn>
38+
let mockProvider: {
39+
renameTaskWithId: ReturnType<typeof vi.fn>
40+
taskHistoryStore: {
41+
get: (id: string) => HistoryItem | undefined
42+
}
43+
postStateToWebviewWithoutTaskHistory: ReturnType<typeof vi.fn>
44+
}
45+
46+
beforeEach(() => {
47+
vi.clearAllMocks()
48+
mockHistoryStore = new Map()
49+
mockRenameTaskWithId = vi.fn().mockResolvedValue(undefined)
50+
mockProvider = {
51+
renameTaskWithId: mockRenameTaskWithId,
52+
taskHistoryStore: {
53+
get: (id: string) => mockHistoryStore.get(id),
54+
},
55+
postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined),
56+
}
57+
})
58+
59+
function createTestTask(options: { parentTask?: Task; initialTitle?: string } = {}) {
60+
const task = Object.create(Task.prototype) as Task
61+
setAbortLatch(task, false)
62+
const taskId = "task-title-1"
63+
mockHistoryStore.set(taskId, {
64+
id: taskId,
65+
ts: Date.now(),
66+
task: "Initial prompt message",
67+
title: options.initialTitle,
68+
} as HistoryItem)
69+
70+
Object.assign(task, {
71+
taskId,
72+
instanceId: "inst-1",
73+
apiConfiguration: mockApiConfig,
74+
providerRef: { deref: () => mockProvider },
75+
abandoned: false,
76+
parentTask: options.parentTask,
77+
metadata: { task: "Initial prompt message" },
78+
say: vi.fn().mockResolvedValue(undefined),
79+
getEnabledMcpToolsCount: vi.fn().mockResolvedValue({ enabledToolCount: 0, enabledServerCount: 0 }),
80+
markInitialized: vi.fn(),
81+
initiateTaskLoop: vi.fn().mockResolvedValue(undefined),
82+
driveRunEvent: vi.fn(),
83+
})
84+
85+
return task
86+
}
87+
88+
describe("generateTitleInBackground", () => {
89+
it("calls generateTaskTitle and updates task title via renameTaskWithId", async () => {
90+
vi.mocked(taskTitleGen.generateTaskTitle).mockResolvedValueOnce("Summarized Title")
91+
const task = createTestTask()
92+
93+
await getTaskTitleAccess(task).generateTitleInBackground("Initial prompt message")
94+
95+
expect(taskTitleGen.generateTaskTitle).toHaveBeenCalledWith(
96+
"Initial prompt message",
97+
mockApiConfig,
98+
expect.objectContaining({
99+
taskId: "task-title-1",
100+
timeoutMs: 10000,
101+
}),
102+
)
103+
expect(mockRenameTaskWithId).toHaveBeenCalledWith("task-title-1", "Summarized Title")
104+
})
105+
106+
it("does not call renameTaskWithId when generateTaskTitle returns undefined", async () => {
107+
vi.mocked(taskTitleGen.generateTaskTitle).mockResolvedValueOnce(undefined)
108+
const task = createTestTask()
109+
110+
await getTaskTitleAccess(task).generateTitleInBackground("Initial prompt message")
111+
112+
expect(mockRenameTaskWithId).not.toHaveBeenCalled()
113+
})
114+
115+
it("does not overwrite if the task already has a custom title set", async () => {
116+
vi.mocked(taskTitleGen.generateTaskTitle).mockResolvedValueOnce("Summarized Title")
117+
const task = createTestTask({ initialTitle: "User Custom Title" })
118+
119+
await getTaskTitleAccess(task).generateTitleInBackground("Initial prompt message")
120+
121+
expect(mockRenameTaskWithId).not.toHaveBeenCalled()
122+
})
123+
124+
it("does not call renameTaskWithId if task is aborted during generation", async () => {
125+
const task = createTestTask()
126+
vi.mocked(taskTitleGen.generateTaskTitle).mockImplementationOnce(async () => {
127+
setAbortLatch(task, true)
128+
return "Summarized Title"
129+
})
130+
131+
await getTaskTitleAccess(task).generateTitleInBackground("Initial prompt message")
132+
133+
expect(mockRenameTaskWithId).not.toHaveBeenCalled()
134+
})
135+
136+
it("does not call renameTaskWithId if task is abandoned during generation", async () => {
137+
const task = createTestTask()
138+
vi.mocked(taskTitleGen.generateTaskTitle).mockImplementationOnce(async () => {
139+
task.abandoned = true
140+
return "Summarized Title"
141+
})
142+
143+
await getTaskTitleAccess(task).generateTitleInBackground("Initial prompt message")
144+
145+
expect(mockRenameTaskWithId).not.toHaveBeenCalled()
146+
})
147+
148+
it("cleans up titleAbortController when finished", async () => {
149+
vi.mocked(taskTitleGen.generateTaskTitle).mockResolvedValueOnce("Summarized Title")
150+
const task = createTestTask()
151+
152+
await getTaskTitleAccess(task).generateTitleInBackground("Initial prompt message")
153+
154+
expect(getTaskTitleAccess(task).titleAbortController).toBeUndefined()
155+
})
156+
})
157+
158+
describe("cancellation and abort handling", () => {
159+
it("aborts titleAbortController in cancelCurrentRequest", () => {
160+
const task = createTestTask()
161+
const access = getTaskTitleAccess(task)
162+
const controller = new AbortController()
163+
const abortSpy = vi.spyOn(controller, "abort")
164+
access.titleAbortController = controller
165+
166+
access.cancelCurrentRequest()
167+
168+
expect(abortSpy).toHaveBeenCalled()
169+
expect(access.titleAbortController).toBeUndefined()
170+
})
171+
172+
it("aborts titleAbortController in requestAbort", () => {
173+
const task = createTestTask()
174+
const access = getTaskTitleAccess(task)
175+
const controller = new AbortController()
176+
const abortSpy = vi.spyOn(controller, "abort")
177+
access.titleAbortController = controller
178+
179+
access.requestAbort(false)
180+
181+
expect(abortSpy).toHaveBeenCalled()
182+
expect(access.titleAbortController).toBeUndefined()
183+
})
184+
})
185+
186+
describe("startTask background trigger", () => {
187+
it("starts background title generation for top-level task with prompt", async () => {
188+
const task = createTestTask()
189+
const access = getTaskTitleAccess(task)
190+
const titleBgSpy = vi.spyOn(access, "generateTitleInBackground").mockResolvedValue(undefined)
191+
192+
await access.startTask("Initial prompt message")
193+
194+
expect(titleBgSpy).toHaveBeenCalledWith("Initial prompt message")
195+
})
196+
197+
it("does not start title generation for child/subtasks", async () => {
198+
const parent = createTestTask()
199+
const child = createTestTask({ parentTask: parent })
200+
const access = getTaskTitleAccess(child)
201+
const titleBgSpy = vi.spyOn(access, "generateTitleInBackground").mockResolvedValue(undefined)
202+
203+
await access.startTask("Child subtask prompt")
204+
205+
expect(titleBgSpy).not.toHaveBeenCalled()
206+
})
207+
208+
it("does not start title generation when prompt is omitted", async () => {
209+
const task = createTestTask()
210+
const access = getTaskTitleAccess(task)
211+
const titleBgSpy = vi.spyOn(access, "generateTitleInBackground").mockResolvedValue(undefined)
212+
213+
await access.startTask(undefined)
214+
215+
expect(titleBgSpy).not.toHaveBeenCalled()
216+
})
217+
})
218+
})

0 commit comments

Comments
 (0)