Skip to content

Commit da836b4

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

7 files changed

Lines changed: 592 additions & 1 deletion

File tree

‎src/core/task/Task.ts‎

Lines changed: 79 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 { initialRunState, type Ask, type RunEvent, type RunState } from "./run-state/runState"
148149
import { applyRunEvent, type RunRejection } from "./run-state/runStateShadow"
@@ -394,6 +395,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
394395
private readonly globalStoragePath: string
395396
abort: boolean = false
396397
currentRequestAbortController?: AbortController
398+
/**
399+
* Controller for background title summarization of the initial user prompt.
400+
* Aborted when the task is cancelled, disposed, or explicitly aborted.
401+
*/
402+
titleAbortController?: AbortController
397403
/**
398404
* Controller for the waiter on an in-flight `ensureModelFetched()` call (see
399405
* safeEnsureModelFetched). Aborting it detaches this task from the provider-side
@@ -3110,6 +3116,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
31103116

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

3119+
// Generate a concise task title in the background for new top-level tasks.
3120+
if (task && !this.parentTask) {
3121+
void this.generateTitleInBackground(task)
3122+
}
3123+
31133124
// Check for too many MCP tools and warn the user
31143125
const { enabledToolCount, enabledServerCount } = await this.getEnabledMcpToolsCount()
31153126
if (enabledToolCount > MAX_MCP_TOOLS_THRESHOLD) {
@@ -3183,6 +3194,66 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
31833194
}
31843195
}
31853196

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

35413616
/**
@@ -3591,6 +3666,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
35913666
this.abandoned = true
35923667
}
35933668
this.abort = true
3669+
if (this.titleAbortController) {
3670+
this.titleAbortController.abort()
3671+
this.titleAbortController = undefined
3672+
}
35943673
this.driveRunEvent({ tag: "abortRequested", abandoned: isAbandoned })
35953674
}
35963675

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

0 commit comments

Comments
 (0)