fix(task): temporary fix for the ask error (#3471)

Co-authored-by: cte <cestreich@gmail.com>
This commit is contained in:
Xudong Zhang 2025-05-13 12:38:36 +08:00 committed by GitHub
parent 7f8362f2e1
commit f6cae4df05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 81 additions and 69 deletions

View file

@ -93,10 +93,14 @@ export function getCheckpointService(cline: Task) {
try {
provider?.postMessageToWebview({ type: "currentCheckpointUpdated", text: to })
cline.say("checkpoint_saved", to, undefined, undefined, { isFirst, from, to }).catch((err) => {
log("[Cline#getCheckpointService] caught unexpected error in say('checkpoint_saved')")
console.error(err)
})
cline
.say("checkpoint_saved", to, undefined, undefined, { isFirst, from, to }, undefined, {
isNonInteractive: true,
})
.catch((err) => {
log("[Cline#getCheckpointService] caught unexpected error in say('checkpoint_saved')")
console.error(err)
})
} catch (err) {
log("[Cline#getCheckpointService] caught unexpected error in on('checkpoint'), disabling checkpoints")
console.error(err)

View file

@ -164,7 +164,7 @@ export class Task extends EventEmitter<ClineEvents> {
consecutiveMistakeCount: number = 0
consecutiveMistakeLimit: number
consecutiveMistakeCountForApplyDiff: Map<string, number> = new Map()
private toolUsage: ToolUsage = {}
toolUsage: ToolUsage = {}
// Checkpoints
enableCheckpoints: boolean
@ -488,6 +488,9 @@ export class Task extends EventEmitter<ClineEvents> {
partial?: boolean,
checkpoint?: Record<string, unknown>,
progressStatus?: ToolProgressStatus,
options: {
isNonInteractive?: boolean
} = {},
): Promise<undefined> {
if (this.abort) {
throw new Error(`[Cline#say] task ${this.taskId}.${this.instanceId} aborted`)
@ -495,49 +498,71 @@ export class Task extends EventEmitter<ClineEvents> {
if (partial !== undefined) {
const lastMessage = this.clineMessages.at(-1)
const isUpdatingPreviousPartial =
lastMessage && lastMessage.partial && lastMessage.type === "say" && lastMessage.say === type
if (partial) {
if (isUpdatingPreviousPartial) {
// existing partial message, so update it
// Existing partial message, so update it.
lastMessage.text = text
lastMessage.images = images
lastMessage.partial = partial
lastMessage.progressStatus = progressStatus
this.updateClineMessage(lastMessage)
} else {
// this is a new partial message, so add it with partial state
// This is a new partial message, so add it with partial state.
const sayTs = Date.now()
this.lastMessageTs = sayTs
if (!options.isNonInteractive) {
this.lastMessageTs = sayTs
}
await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images, partial })
}
} else {
// New now have a complete version of a previously partial message.
// This is the complete version of a previously partial
// message, so replace the partial with the complete version.
if (isUpdatingPreviousPartial) {
// This is the complete version of a previously partial
// message, so replace the partial with the complete version.
this.lastMessageTs = lastMessage.ts
// lastMessage.ts = sayTs
if (!options.isNonInteractive) {
this.lastMessageTs = lastMessage.ts
}
lastMessage.text = text
lastMessage.images = images
lastMessage.partial = false
lastMessage.progressStatus = progressStatus
// Instead of streaming partialMessage events, we do a save
// and post like normal to persist to disk.
await this.saveClineMessages()
// More performant than an entire postStateToWebview.
// More performant than an entire `postStateToWebview`.
this.updateClineMessage(lastMessage)
} else {
// This is a new and complete message, so add it like normal.
const sayTs = Date.now()
this.lastMessageTs = sayTs
if (!options.isNonInteractive) {
this.lastMessageTs = sayTs
}
await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images })
}
}
} else {
// this is a new non-partial message, so add it like normal
// This is a new non-partial message, so add it like normal.
const sayTs = Date.now()
this.lastMessageTs = sayTs
// A "non-interactive" message is a message is one that the user
// does not need to respond to. We don't want these message types
// to trigger an update to `lastMessageTs` since they can be created
// asynchronously and could interrupt a pending ask.
if (!options.isNonInteractive) {
this.lastMessageTs = sayTs
}
await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images, checkpoint })
}
}
@ -555,8 +580,12 @@ export class Task extends EventEmitter<ClineEvents> {
// Start / Abort / Resume
private async startTask(task?: string, images?: string[]): Promise<void> {
// conversationHistory (for API) and clineMessages (for webview) need to be in sync
// if the extension process were killed, then on restart the clineMessages might not be empty, so we need to set it to [] when we create a new Cline client (otherwise webview would show stale messages from previous session)
// `conversationHistory` (for API) and `clineMessages` (for webview)
// need to be in sync.
// If the extension process were killed, then on restart the
// `clineMessages` might not be empty, so we need to set it to [] when
// we create a new Cline client (otherwise webview would show stale
// messages from previous session).
this.clineMessages = []
this.apiConversationHistory = []
await this.providerRef.deref()?.postStateToWebview()
@ -578,28 +607,25 @@ export class Task extends EventEmitter<ClineEvents> {
}
public async resumePausedTask(lastMessage: string) {
// release this Cline instance from paused state
// Release this Cline instance from paused state.
this.isPaused = false
this.emit("taskUnpaused")
// fake an answer from the subtask that it has completed running and this is the result of what it has done
// add the message to the chat history and to the webview ui
// Fake an answer from the subtask that it has completed running and
// this is the result of what it has done add the message to the chat
// history and to the webview ui.
try {
await this.say("subtask_result", lastMessage)
await this.addToApiConversationHistory({
role: "user",
content: [
{
type: "text",
text: `[new_task completed] Result: ${lastMessage}`,
},
],
content: [{ type: "text", text: `[new_task completed] Result: ${lastMessage}` }],
})
} catch (error) {
this.providerRef
.deref()
?.log(`Error failed to add reply from subtast into conversation of parent task, error: ${error}`)
throw error
}
}
@ -1629,17 +1655,9 @@ export class Task extends EventEmitter<ClineEvents> {
}
}
public getToolUsage() {
return this.toolUsage
}
// Getters
public get cwd() {
return this.workspacePath
}
public getFileContextTracker(): FileContextTracker {
return this.fileContextTracker
}
}

View file

@ -33,15 +33,15 @@ let mockInputContent = ""
jest.mock("../../../integrations/misc/extract-text", () => {
const actual = jest.requireActual("../../../integrations/misc/extract-text")
// Create a spy on the actual addLineNumbers function
// Create a spy on the actual addLineNumbers function.
const addLineNumbersSpy = jest.spyOn(actual, "addLineNumbers")
return {
...actual,
// Expose the spy so tests can access it
// Expose the spy so tests can access it.
__addLineNumbersSpy: addLineNumbersSpy,
extractTextFromFile: jest.fn().mockImplementation((_filePath) => {
// Use the actual addLineNumbers function
// Use the actual addLineNumbers function.
const content = mockInputContent
return Promise.resolve(actual.addLineNumbers(content))
}),
@ -87,7 +87,6 @@ describe("read_file tool with maxReadFileLine setting", () => {
const mockedIsBinaryFile = isBinaryFile as jest.MockedFunction<typeof isBinaryFile>
const mockedPathResolve = path.resolve as jest.MockedFunction<typeof path.resolve>
// Mock instances
const mockCline: any = {}
let mockProvider: any
let toolResult: ToolResponse | undefined
@ -95,31 +94,26 @@ describe("read_file tool with maxReadFileLine setting", () => {
beforeEach(() => {
jest.clearAllMocks()
// Setup path resolution
mockedPathResolve.mockReturnValue(absoluteFilePath)
// Setup mocks for file operations
mockedIsBinaryFile.mockResolvedValue(false)
// Set the default content for the mock
mockInputContent = fileContent
// Setup the extractTextFromFile mock implementation with the current mockInputContent
// Setup the extractTextFromFile mock implementation with the current
// mockInputContent.
mockedExtractTextFromFile.mockImplementation((_filePath) => {
const actual = jest.requireActual("../../../integrations/misc/extract-text")
return Promise.resolve(actual.addLineNumbers(mockInputContent))
})
// No need to setup the extractTextFromFile mock implementation here
// as it's already defined at the module level
// as it's already defined at the module level.
// Setup mock provider
mockProvider = {
getState: jest.fn(),
deref: jest.fn().mockReturnThis(),
}
// Setup Cline instance with mock methods
mockCline.cwd = "/"
mockCline.task = "Test"
mockCline.providerRef = mockProvider
@ -129,12 +123,14 @@ describe("read_file tool with maxReadFileLine setting", () => {
mockCline.say = jest.fn().mockResolvedValue(undefined)
mockCline.ask = jest.fn().mockResolvedValue(true)
mockCline.presentAssistantMessage = jest.fn()
mockCline.getFileContextTracker = jest.fn().mockReturnValue({
mockCline.fileContextTracker = {
trackFileContext: jest.fn().mockResolvedValue(undefined),
})
}
mockCline.recordToolUsage = jest.fn().mockReturnValue(undefined)
mockCline.recordToolError = jest.fn().mockReturnValue(undefined)
// Reset tool result
toolResult = undefined
})
@ -433,22 +429,16 @@ describe("read_file tool XML output structure", () => {
beforeEach(() => {
jest.clearAllMocks()
// Setup path resolution
mockedPathResolve.mockReturnValue(absoluteFilePath)
// Setup mocks for file operations
mockedIsBinaryFile.mockResolvedValue(false)
// Set the default content for the mock
mockInputContent = fileContent
// Setup mock provider
mockProvider = {
getState: jest.fn().mockResolvedValue({ maxReadFileLine: 500 }),
deref: jest.fn().mockReturnThis(),
}
// Setup Cline instance with mock methods
mockCline.cwd = "/"
mockCline.task = "Test"
mockCline.providerRef = mockProvider
@ -459,14 +449,14 @@ describe("read_file tool XML output structure", () => {
mockCline.ask = jest.fn().mockResolvedValue(true)
mockCline.presentAssistantMessage = jest.fn()
mockCline.sayAndCreateMissingParamError = jest.fn().mockResolvedValue("Missing required parameter")
// Add mock for getFileContextTracker method
mockCline.getFileContextTracker = jest.fn().mockReturnValue({
mockCline.fileContextTracker = {
trackFileContext: jest.fn().mockResolvedValue(undefined),
})
}
mockCline.recordToolUsage = jest.fn().mockReturnValue(undefined)
mockCline.recordToolError = jest.fn().mockReturnValue(undefined)
// Reset tool result
toolResult = undefined
})

View file

@ -164,7 +164,7 @@ export async function applyDiffTool(
// Track file edit operation
if (relPath) {
await cline.getFileContextTracker().trackFileContext(relPath, "roo_edited" as RecordSource)
await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource)
}
// Used to determine if we should wait for busy terminal to update before sending api request

View file

@ -46,7 +46,7 @@ export async function attemptCompletionTool(
await cline.say("completion_result", removeClosingTag("result", result), undefined, false)
telemetryService.captureTaskCompleted(cline.taskId)
cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage(), cline.getToolUsage())
cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage(), cline.toolUsage)
await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {})
}
@ -72,7 +72,7 @@ export async function attemptCompletionTool(
// Haven't sent a command message yet so first send completion_result then command.
await cline.say("completion_result", result, undefined, false)
telemetryService.captureTaskCompleted(cline.taskId)
cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage(), cline.getToolUsage())
cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage(), cline.toolUsage)
}
// Complete command message.
@ -97,7 +97,7 @@ export async function attemptCompletionTool(
} else {
await cline.say("completion_result", result, undefined, false)
telemetryService.captureTaskCompleted(cline.taskId)
cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage(), cline.getToolUsage())
cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage(), cline.toolUsage)
}
if (cline.parentTask) {

View file

@ -132,7 +132,7 @@ export async function insertContentTool(
// Track file edit operation
if (relPath) {
await cline.getFileContextTracker().trackFileContext(relPath, "roo_edited" as RecordSource)
await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource)
}
cline.didEditFile = true

View file

@ -64,7 +64,7 @@ export async function listCodeDefinitionNamesTool(
}
if (relPath) {
await cline.getFileContextTracker().trackFileContext(relPath, "read_tool" as RecordSource)
await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
}
pushToolResult(result)

View file

@ -245,7 +245,7 @@ export async function readFileTool(
// Track file read operation
if (relPath) {
await cline.getFileContextTracker().trackFileContext(relPath, "read_tool" as RecordSource)
await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
}
// Format the result into the required XML structure

View file

@ -215,7 +215,7 @@ export async function searchAndReplaceTool(
// Track file edit operation
if (relPath) {
await cline.getFileContextTracker().trackFileContext(relPath, "roo_edited" as RecordSource)
await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource)
}
cline.didEditFile = true

View file

@ -211,7 +211,7 @@ export async function writeToFileTool(
// Track file edit operation
if (relPath) {
await cline.getFileContextTracker().trackFileContext(relPath, "roo_edited" as RecordSource)
await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource)
}
cline.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request