feat: rewind-on-interrupt — clean conversation state on abort/resume

- Add rewindToLastAskPoint() to truncate clineMessages and sanitize API histories
- Add two-phase completion flow (completion_result → resume_completed_task)
- Extract normalizeContentBlocks() utility to src/shared/messages.ts
- Replace 'as' casts with type predicates and 'satisfies' checks
- Remove synthetic 'interrupted' tool_result injection
- Neutral fallback message in validateToolResultIds
- Comprehensive test coverage: 65 tests across 5 files
- Shared test fixtures in rewind-test-setup.ts
This commit is contained in:
Hannes Rudolph 2026-02-06 19:53:12 -07:00
parent d7714e4e07
commit db8f472fff
10 changed files with 1551 additions and 131 deletions

View file

@ -63,6 +63,7 @@ import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
// shared
import { findLastIndex } from "../../shared/array"
import { normalizeContentBlocks } from "../../shared/messages"
import { combineApiRequests } from "../../shared/combineApiRequests"
import { combineCommandSequences } from "../../shared/combineCommandSequences"
import { t } from "../../i18n"
@ -2105,6 +2106,32 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}
/**
* Find the tool_use ID for a pending `attempt_completion` call in the
* API conversation history. Returns undefined if not found.
*
* This detects the case where the user accepted completion and the
* process restarted before the follow-up could be resolved the
* history ends with an assistant message containing an
* `attempt_completion` tool_use without a matching tool_result.
*/
private getPendingAttemptCompletionToolUseId(): string | undefined {
const history = this.apiConversationHistory
if (history.length === 0) return undefined
const lastMsg = history[history.length - 1]
if (lastMsg.role !== "assistant") return undefined
const content = normalizeContentBlocks(lastMsg.content)
const toolUse = content.find(
(block): block is Anthropic.Messages.ToolUseBlockParam =>
block.type === "tool_use" && block.name === "attempt_completion",
)
return toolUse?.id
}
private async resumeTaskFromHistory() {
if (this.enableBridge) {
try {
@ -2167,15 +2194,32 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// the task first.
this.apiConversationHistory = await this.getSavedApiConversationHistory()
// No explicit overwriteApiConversationHistory() call here — the history
// was just loaded from disk by getSavedApiConversationHistory(), so
// re-persisting it would be a no-op. overwriteApiConversationHistory()
// has no validation side-effects; validateAndFixToolResultIds only runs
// inside addToApiConversationHistory(). For the interrupted path,
// rewindToLastAskPoint() will persist any truncation it performs.
const lastClineMessage = this.clineMessages
.slice()
.reverse()
.find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task")) // Could be multiple resume tasks.
// Check if this is a completed task that has a pending attempt_completion
// tool_use without a tool_result. This happens when the user accepted
// completion (yesButtonClicked) and the process restarted before the
// follow-up ask could be resolved.
const pendingAttemptCompletionToolUseId = this.getPendingAttemptCompletionToolUseId()
let askType: ClineAsk
if (lastClineMessage?.ask === "completion_result") {
if (lastClineMessage?.ask === "completion_result" || pendingAttemptCompletionToolUseId) {
askType = "resume_completed_task"
} else {
// Rewind conversation state to the last valid ask point.
// Only rewind for non-completed tasks — completed tasks may have
// a pending attempt_completion tool_use that we need to preserve.
await this.rewindToLastAskPoint()
askType = "resume_task"
}
@ -2192,136 +2236,56 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
responseImages = images
}
// Make sure that the api conversation history can be resumed by the API,
// even if it goes out of sync with cline messages.
let existingApiConversationHistory: ApiMessage[] = await this.getSavedApiConversationHistory()
// Build the new user content for the next API call.
let newUserContent: Anthropic.Messages.ContentBlockParam[] = []
// Tool blocks are always preserved; native tool calling only.
// If this is a completed task with a pending attempt_completion tool_use,
// route the user's message as a tool_result instead of a bare text block.
// This ensures the API contract is maintained after a process restart.
if (pendingAttemptCompletionToolUseId && responseText) {
const feedbackText = `<user_message>\n${responseText}\n</user_message>`
newUserContent.push({
type: "tool_result",
tool_use_id: pendingAttemptCompletionToolUseId,
content: feedbackText,
} satisfies Anthropic.ToolResultBlockParam)
// if the last message is an assistant message, we need to check if there's tool use since every tool use has to have a tool response
// if there's no tool use and only a text block, then we can just add a user message
// (note this isn't relevant anymore since we use custom tool prompts instead of tool use blocks, but this is here for legacy purposes in case users resume old tasks)
// if the last message is a user message, we can need to get the assistant message before it to see if it made tool calls, and if so, fill in the remaining tool responses with 'interrupted'
let modifiedOldUserContent: Anthropic.Messages.ContentBlockParam[] // either the last message if its user message, or the user message before the last (assistant) message
let modifiedApiConversationHistory: ApiMessage[] // need to remove the last user message to replace with new modified user message
if (existingApiConversationHistory.length > 0) {
const lastMessage = existingApiConversationHistory[existingApiConversationHistory.length - 1]
if (lastMessage.role === "assistant") {
const content = Array.isArray(lastMessage.content)
? lastMessage.content
: [{ type: "text", text: lastMessage.content }]
const hasToolUse = content.some((block) => block.type === "tool_use")
if (hasToolUse) {
const toolUseBlocks = content.filter(
(block) => block.type === "tool_use",
) as Anthropic.Messages.ToolUseBlock[]
const toolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks.map((block) => ({
type: "tool_result",
tool_use_id: block.id,
content: "Task was interrupted before this tool call could be completed.",
}))
modifiedApiConversationHistory = [...existingApiConversationHistory] // no changes
modifiedOldUserContent = [...toolResponses]
} else {
modifiedApiConversationHistory = [...existingApiConversationHistory]
modifiedOldUserContent = []
}
} else if (lastMessage.role === "user") {
const previousAssistantMessage: ApiMessage | undefined =
existingApiConversationHistory[existingApiConversationHistory.length - 2]
const existingUserContent: Anthropic.Messages.ContentBlockParam[] = Array.isArray(lastMessage.content)
? lastMessage.content
: [{ type: "text", text: lastMessage.content }]
if (previousAssistantMessage && previousAssistantMessage.role === "assistant") {
const assistantContent = Array.isArray(previousAssistantMessage.content)
? previousAssistantMessage.content
: [{ type: "text", text: previousAssistantMessage.content }]
const toolUseBlocks = assistantContent.filter(
(block) => block.type === "tool_use",
) as Anthropic.Messages.ToolUseBlock[]
if (toolUseBlocks.length > 0) {
const existingToolResults = existingUserContent.filter(
(block) => block.type === "tool_result",
) as Anthropic.ToolResultBlockParam[]
const missingToolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks
.filter(
(toolUse) => !existingToolResults.some((result) => result.tool_use_id === toolUse.id),
)
.map((toolUse) => ({
type: "tool_result",
tool_use_id: toolUse.id,
content: "Task was interrupted before this tool call could be completed.",
}))
modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1) // removes the last user message
modifiedOldUserContent = [...existingUserContent, ...missingToolResponses]
} else {
modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1)
modifiedOldUserContent = [...existingUserContent]
}
} else {
modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1)
modifiedOldUserContent = [...existingUserContent]
}
} else {
throw new Error("Unexpected: Last message is not a user or assistant message")
if (responseImages && responseImages.length > 0) {
newUserContent.push(...formatResponse.imageBlocks(responseImages))
}
} else {
throw new Error("Unexpected: No existing API conversation history")
}
let newUserContent: Anthropic.Messages.ContentBlockParam[] = [...modifiedOldUserContent]
const agoText = ((): string => {
const timestamp = lastClineMessage?.ts ?? Date.now()
const now = Date.now()
const diff = now - timestamp
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) {
return `${days} day${days > 1 ? "s" : ""} ago`
if (responseText) {
newUserContent.push({
type: "text",
text: `<user_message>\n${responseText}\n</user_message>`,
})
}
if (hours > 0) {
return `${hours} hour${hours > 1 ? "s" : ""} ago`
}
if (minutes > 0) {
return `${minutes} minute${minutes > 1 ? "s" : ""} ago`
}
return "just now"
})()
if (responseText) {
newUserContent.push({
type: "text",
text: `<user_message>\n${responseText}\n</user_message>`,
})
}
if (responseImages && responseImages.length > 0) {
newUserContent.push(...formatResponse.imageBlocks(responseImages))
if (responseImages && responseImages.length > 0) {
newUserContent.push(...formatResponse.imageBlocks(responseImages))
}
}
// Ensure we have at least some content to send to the API.
// If newUserContent is empty, add a minimal resumption message.
if (newUserContent.length === 0) {
newUserContent.push({
type: "text",
text: "[TASK RESUMPTION] Resuming task...",
})
if (pendingAttemptCompletionToolUseId) {
newUserContent.push({
type: "tool_result",
tool_use_id: pendingAttemptCompletionToolUseId,
content: JSON.stringify({
status: "completed",
message: "The user is satisfied with the result.",
}),
} satisfies Anthropic.ToolResultBlockParam)
} else {
newUserContent.push({
type: "text",
text: "The user has resumed this task.",
})
}
}
await this.overwriteApiConversationHistory(modifiedApiConversationHistory)
// Task resuming from history item.
await this.initiateTaskLoop(newUserContent)
}
@ -2349,6 +2313,140 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.debouncedEmitTokenUsage.flush()
}
/**
* Rewind the conversation state to the last completed ask point.
*
* This implements the "Rewind on Interrupt" model: instead of injecting
* synthetic "interrupted" messages into the conversation, we truncate both
* `clineMessages` and `apiConversationHistory` back to the last valid
* checkpoint where the system was waiting for user input.
*
* The method:
* 1. Finds the last non-partial ask message in clineMessages (the rewind target)
* 2. Truncates clineMessages to include up to and including that ask
* 3. Removes any trailing incomplete assistant message from apiConversationHistory
* 4. Removes orphaned user messages that don't have matching tool_results
* 5. Persists both histories to disk
*
* This ensures the conversation can be cleanly resumed without the model
* seeing fabricated "interrupted" context.
*/
private async rewindToLastAskPoint(): Promise<boolean> {
try {
// Find the last completed (non-partial) ask message that represents
// a real checkpoint where the system was waiting for user input.
// Skip non-blocking asks (command_output) and resume asks since
// they aren't real interaction points.
const lastAskIndex = findLastIndex(
this.clineMessages,
(m) =>
m.type === "ask" &&
!m.partial &&
m.ask !== "command_output" &&
m.ask !== "resume_task" &&
m.ask !== "resume_completed_task",
)
if (lastAskIndex === -1) {
console.log(`[Task#${this.taskId}] rewindToLastAskPoint: no ask point found, nothing to rewind`)
return false
}
const lastAsk = this.clineMessages[lastAskIndex]
console.log(
`[Task#${this.taskId}] rewindToLastAskPoint: rewinding to ask "${lastAsk.ask}" at index ${lastAskIndex} (ts=${lastAsk.ts})`,
)
// Truncate clineMessages to include up to and including the ask point.
// Also keep any non-interactive messages (like api_req_started cost info)
// that are siblings of this ask in the same turn.
this.clineMessages = this.clineMessages.slice(0, lastAskIndex + 1)
// Now fix up the API conversation history.
// The goal is to ensure the history ends with either:
// (a) a complete user→assistant→user exchange (all tool_uses have tool_results), or
// (b) a user message (waiting for assistant response)
//
// We need to remove any trailing assistant message that has tool_use
// blocks without corresponding tool_results (the interrupted response).
const history = this.apiConversationHistory
if (history.length > 0) {
const lastMsg = history[history.length - 1]
if (lastMsg.role === "assistant") {
// Check if this assistant message has tool_use blocks
const content = normalizeContentBlocks(lastMsg.content)
const hasToolUse = content.some(
(block: Anthropic.Messages.ContentBlockParam) => block.type === "tool_use",
)
if (hasToolUse) {
// This assistant message has tool calls that were never answered.
// Remove it entirely - the model will regenerate on resume.
history.pop()
console.log(
`[Task#${this.taskId}] rewindToLastAskPoint: removed trailing assistant message with unanswered tool_use`,
)
}
// If assistant message is text-only, it's fine to keep
} else if (lastMsg.role === "user") {
// Check if previous assistant message has tool_use blocks
// that this user message should have tool_results for
if (history.length >= 2) {
const prevAssistant = history[history.length - 2]
if (prevAssistant.role === "assistant") {
const assistantContent = normalizeContentBlocks(prevAssistant.content)
const toolUseBlocks = assistantContent.filter(
(block): block is Anthropic.Messages.ToolUseBlockParam => block.type === "tool_use",
)
if (toolUseBlocks.length > 0) {
const userContent = normalizeContentBlocks(lastMsg.content)
const toolResultIds = new Set(
userContent
.filter(
(block): block is Anthropic.Messages.ToolResultBlockParam =>
block.type === "tool_result",
)
.map((block) => block.tool_use_id),
)
const allToolUsesAnswered = toolUseBlocks.every((tu) => toolResultIds.has(tu.id))
if (!allToolUsesAnswered) {
// User message has incomplete tool_results.
// Remove both the user message and the assistant message.
history.pop() // remove incomplete user message
history.pop() // remove assistant message with unanswered tools
console.log(
`[Task#${this.taskId}] rewindToLastAskPoint: removed incomplete user+assistant messages`,
)
}
}
}
}
}
}
// Persist both histories
await this.overwriteClineMessages(this.clineMessages)
await this.overwriteApiConversationHistory(history)
console.log(
`[Task#${this.taskId}] rewindToLastAskPoint: rewind complete. ` +
`clineMessages: ${this.clineMessages.length}, apiHistory: ${history.length}`,
)
return true
} catch (error) {
console.error(`[Task#${this.taskId}] rewindToLastAskPoint failed:`, error)
return false
}
}
public async abortTask(isAbandoned = false) {
// Aborting task
@ -2374,12 +2472,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error)
// Don't rethrow - we want abort to always succeed
}
// Save the countdown message in the automatic retry or other content.
try {
// Save the countdown message in the automatic retry or other content.
await this.saveClineMessages()
} catch (error) {
console.error(`Error saving messages during abort for task ${this.taskId}.${this.instanceId}:`, error)
// Rewind conversation state to the last valid ask point.
// This ensures that on resume, the model doesn't see synthetic
// "interrupted" messages — instead it sees a clean conversation
// ending at the last checkpoint where user input was expected.
if (!isAbandoned) {
try {
await this.rewindToLastAskPoint()
} catch (error) {
console.error(`[Task#${this.taskId}.${this.instanceId}] Error during rewind on abort:`, error)
}
}
}

View file

@ -0,0 +1,90 @@
/**
* Shared test fixtures for the rewind test suites.
*
* NOTE: Every test file that uses these helpers MUST declare its own
* `vi.mock()` blocks vitest hoists mocks to the top of the *test* file,
* so they cannot be extracted into a shared module.
*/
import * as os from "os"
import * as path from "path"
import * as vscode from "vscode"
import type { ProviderSettings } from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { Task } from "../../Task"
import { ClineProvider } from "../../../webview/ClineProvider"
import { ContextProxy } from "../../../config/ContextProxy"
/** Default API configuration used across rewind tests. */
export const DEFAULT_API_CONFIG: ProviderSettings = {
apiProvider: "anthropic",
apiModelId: "claude-3-5-sonnet-20241022",
apiKey: "test-api-key",
}
/** Ensure the TelemetryService singleton is initialised (idempotent). */
export function initializeTelemetry(): void {
if (!TelemetryService.hasInstance()) {
TelemetryService.createInstance([])
}
}
/** Build a minimal mock `vscode.ExtensionContext`. */
export function createMockExtensionContext(): vscode.ExtensionContext {
const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") }
return {
globalState: {
get: vi.fn().mockReturnValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
keys: vi.fn().mockReturnValue([]),
},
globalStorageUri: storageUri,
workspaceState: {
get: vi.fn().mockReturnValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
keys: vi.fn().mockReturnValue([]),
},
secrets: {
get: vi.fn().mockResolvedValue(undefined),
store: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
},
extensionUri: { fsPath: "/mock/extension/path" },
extension: { packageJSON: { version: "1.0.0" } },
} as unknown as vscode.ExtensionContext
}
/** Build a mock `OutputChannel`. */
export function createMockOutputChannel() {
return {
name: "test",
appendLine: vi.fn(),
append: vi.fn(),
replace: vi.fn(),
clear: vi.fn(),
show: vi.fn(),
hide: vi.fn(),
dispose: vi.fn(),
}
}
/** Build a `ClineProvider` backed by mocks. */
export function createMockProvider(context: vscode.ExtensionContext): any {
return new ClineProvider(context, createMockOutputChannel(), "sidebar", new ContextProxy(context)) as any
}
/**
* Create a `Task` that does NOT auto-start its loop.
* Pass an explicit `apiConfig` or fall back to {@link DEFAULT_API_CONFIG}.
*/
export function createTask(provider: any, apiConfig: ProviderSettings = DEFAULT_API_CONFIG): Task {
return new Task({
provider,
apiConfiguration: apiConfig,
task: "test task",
startTask: false,
})
}

View file

@ -0,0 +1,496 @@
// npx vitest run core/task/__tests__/rewind-integration.spec.ts
import { Anthropic } from "@anthropic-ai/sdk"
import type { ClineMessage } from "@roo-code/types"
import {
initializeTelemetry,
createMockExtensionContext,
createMockProvider,
createTask,
} from "./fixtures/rewind-test-setup"
// ---------------------------------------------------------------------------
// vi.mock() declarations — must live in the test file (vitest hoists them).
// ---------------------------------------------------------------------------
vi.mock("delay", () => ({
__esModule: true,
default: vi.fn().mockResolvedValue(undefined),
}))
vi.mock("uuid", async (importOriginal) => {
const actual = await importOriginal<typeof import("uuid")>()
return {
...actual,
v7: vi.fn(() => "00000000-0000-7000-8000-000000000000"),
}
})
vi.mock("execa", () => ({
execa: vi.fn(),
}))
vi.mock("fs/promises", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, any>
const mockFunctions = {
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockImplementation((filePath) => {
if (filePath.includes("ui_messages.json")) {
return Promise.resolve(JSON.stringify([]))
}
if (filePath.includes("api_conversation_history.json")) {
return Promise.resolve(JSON.stringify([]))
}
return Promise.resolve("[]")
}),
unlink: vi.fn().mockResolvedValue(undefined),
rmdir: vi.fn().mockResolvedValue(undefined),
}
return {
...actual,
...mockFunctions,
default: mockFunctions,
}
})
vi.mock("p-wait-for", () => ({
default: vi.fn().mockImplementation(async () => Promise.resolve()),
}))
vi.mock("vscode", () => {
const mockDisposable = { dispose: vi.fn() }
const mockEventEmitter = { event: vi.fn(), fire: vi.fn() }
return {
TabInputTextDiff: vi.fn(),
CodeActionKind: {
QuickFix: { value: "quickfix" },
RefactorRewrite: { value: "refactor.rewrite" },
},
window: {
createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }),
visibleTextEditors: [],
tabGroups: {
all: [],
close: vi.fn(),
onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })),
},
showErrorMessage: vi.fn(),
},
workspace: {
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(),
})),
fs: {
stat: vi.fn().mockResolvedValue({ type: 1 }),
},
onDidSaveTextDocument: vi.fn(() => mockDisposable),
getConfiguration: vi.fn(() => ({ get: (key: string, defaultValue: any) => defaultValue })),
},
env: {
uriScheme: "vscode",
language: "en",
},
EventEmitter: vi.fn().mockImplementation(() => mockEventEmitter),
Disposable: {
from: vi.fn(),
},
TabInputText: vi.fn(),
}
})
vi.mock("../../mentions", () => ({
parseMentions: vi.fn().mockImplementation((text) => {
return Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] })
}),
openMention: vi.fn(),
getLatestTerminalOutput: vi.fn(),
}))
vi.mock("../../../integrations/misc/extract-text", () => ({
extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"),
}))
vi.mock("../../environment/getEnvironmentDetails", () => ({
getEnvironmentDetails: vi.fn().mockResolvedValue(""),
}))
vi.mock("../../ignore/RooIgnoreController")
vi.mock("../../condense", async (importOriginal) => {
const actual = (await importOriginal()) as any
return {
...actual,
summarizeConversation: vi.fn().mockResolvedValue({
messages: [{ role: "user", content: [{ type: "text", text: "continued" }], ts: Date.now() }],
summary: "summary",
cost: 0,
newContextTokens: 1,
}),
}
})
vi.mock("../../../utils/storage", () => ({
getTaskDirectoryPath: vi
.fn()
.mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)),
getSettingsDirectoryPath: vi
.fn()
.mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)),
}))
vi.mock("../../../utils/fs", () => ({
fileExistsAtPath: vi.fn().mockReturnValue(false),
}))
// ---------------------------------------------------------------------------
describe("rewind-integration", () => {
let mockProvider: any
beforeEach(() => {
initializeTelemetry()
const ctx = createMockExtensionContext()
mockProvider = createMockProvider(ctx)
})
it("abort → resume produces clean state", async () => {
const task = createTask(mockProvider)
const now = Date.now()
// Set up clineMessages with several asks and an in-progress tool
task.clineMessages = [
{ ts: now - 6000, type: "say", say: "text", text: "user task" },
{ ts: now - 5000, type: "ask", ask: "followup", text: "what should I do?" },
{ ts: now - 4000, type: "say", say: "user_feedback", text: "do it" },
{ ts: now - 3000, type: "ask", ask: "tool", text: "approve write_to_file" },
{ ts: now - 2000, type: "say", say: "text", text: "streaming partial response..." },
{ ts: now - 1000, type: "say", say: "api_req_started", text: "{}" },
]
// Set up apiConversationHistory with an interrupted assistant message
task.apiConversationHistory = [
{
role: "user",
content: [{ type: "text", text: "do something" }],
ts: now - 7000,
},
{
role: "assistant",
content: [{ type: "text", text: "What should I do?" }],
ts: now - 6000,
},
{
role: "user",
content: [{ type: "text", text: "do it" }],
ts: now - 5000,
},
{
role: "assistant",
content: [
{ type: "text", text: "I'll edit the file" },
{
type: "tool_use",
id: "tool-write-1",
name: "write_to_file",
input: { path: "test.ts", content: "hello" },
},
],
ts: now - 4000,
},
] as any[]
// Abort the task (calls rewindToLastAskPoint internally)
await (task as any).abortTask()
// After abort + rewind, clineMessages should be truncated to the last valid ask
// The last qualifying ask is "tool" at index 3
expect(task.clineMessages).toHaveLength(4)
expect(task.clineMessages[3].ask).toBe("tool")
// apiConversationHistory: the trailing assistant message with unanswered tool_use
// should be removed. Should have 3 messages: user, assistant(text-only), user
expect(task.apiConversationHistory).toHaveLength(3)
expect(task.apiConversationHistory[2].role).toBe("user")
})
it("completed task: getPendingAttemptCompletionToolUseId returns tool_use ID", () => {
const task = createTask(mockProvider)
const now = Date.now()
// Set up apiConversationHistory where the last assistant message has attempt_completion
task.apiConversationHistory = [
{
role: "user",
content: [{ type: "text", text: "build a hello world app" }],
ts: now - 5000,
},
{
role: "assistant",
content: [
{ type: "text", text: "I've built the app." },
{
type: "tool_use",
id: "toolu_completion_123",
name: "attempt_completion",
input: { result: "Here is the completed app." },
},
],
ts: now - 4000,
},
] as any[]
const pendingId: string | undefined = (task as any).getPendingAttemptCompletionToolUseId()
expect(pendingId).toBe("toolu_completion_123")
})
it("double rewind is idempotent", async () => {
const task = createTask(mockProvider)
const now = Date.now()
task.clineMessages = [
{ ts: now - 5000, type: "say", say: "text", text: "user task" },
{ ts: now - 4000, type: "ask", ask: "tool", text: "approve write_to_file" },
{ ts: now - 3000, type: "say", say: "text", text: "streaming response..." },
{ ts: now - 2000, type: "say", say: "api_req_started", text: "{}" },
]
task.apiConversationHistory = [
{
role: "user",
content: [{ type: "text", text: "do something" }],
ts: now - 6000,
},
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "write_to_file",
input: { path: "test.ts", content: "hello" },
},
],
ts: now - 5000,
},
] as any[]
// First rewind
const result1: boolean = await (task as any).rewindToLastAskPoint()
expect(result1).toBe(true)
const clineMessagesAfterFirst: ClineMessage[] = [...task.clineMessages]
const apiHistoryAfterFirst: Anthropic.MessageParam[] = [...task.apiConversationHistory]
// Second rewind
const result2: boolean = await (task as any).rewindToLastAskPoint()
expect(result2).toBe(true)
// State should be identical after both rewinds
expect(task.clineMessages).toEqual(clineMessagesAfterFirst)
expect(task.apiConversationHistory).toEqual(apiHistoryAfterFirst)
})
it("rewind with no qualifying ask points returns false", async () => {
const task = createTask(mockProvider)
const now = Date.now()
// Only has command_output asks (non-qualifying) and partial asks
task.clineMessages = [
{ ts: now - 5000, type: "say", say: "text", text: "user task" },
{ ts: now - 4000, type: "ask", ask: "command_output", text: "some output" },
{ ts: now - 3000, type: "ask", ask: "command_output", text: "more output" },
{ ts: now - 2000, type: "ask", ask: "tool", text: "partial ask", partial: true },
]
task.apiConversationHistory = []
const result: boolean = await (task as any).rewindToLastAskPoint()
expect(result).toBe(false)
})
// -----------------------------------------------------------------------
// Task C — completion → restart → resume scenarios
// -----------------------------------------------------------------------
it("completion → restart → resume with follow-up text routes as tool_result", async () => {
const task = createTask(mockProvider)
const now = Date.now()
// Simulate persisted state after an attempt_completion was presented.
const savedClineMessages: ClineMessage[] = [
{ ts: now - 5000, type: "say", say: "text", text: "user task" },
{ ts: now - 2000, type: "ask", ask: "completion_result", text: "Here is the completed app." },
]
const savedApiHistory = [
{
role: "user" as const,
content: [{ type: "text" as const, text: "build a hello world app" }],
ts: now - 5000,
},
{
role: "assistant" as const,
content: [
{ type: "text" as const, text: "I've built the app." },
{
type: "tool_use" as const,
id: "toolu_completion_abc",
name: "attempt_completion",
input: { result: "Here is the completed app." },
},
],
ts: now - 4000,
},
]
// Mock the persistence/loading methods so resumeTaskFromHistory can run
// without real disk I/O.
vi.spyOn(task as any, "getSavedClineMessages").mockImplementation(async () =>
savedClineMessages.map((m) => ({ ...m })),
)
vi.spyOn(task as any, "getSavedApiConversationHistory").mockResolvedValue(
savedApiHistory.map((m) => ({ ...m, content: [...(m.content as any[])] })),
)
vi.spyOn(task as any, "overwriteClineMessages").mockImplementation(async (msgs: any) => {
task.clineMessages = msgs
})
// Simulate user providing follow-up text via "New Task" / message response
const askSpy = vi.spyOn(task as any, "ask").mockResolvedValue({
response: "messageResponse",
text: "looks good, thanks!",
images: [],
})
vi.spyOn(task as any, "say").mockResolvedValue(undefined)
const loopSpy = vi.spyOn(task as any, "initiateTaskLoop").mockResolvedValue(undefined)
await (task as any).resumeTaskFromHistory()
// Should present as resume_completed_task (not resume_task)
expect(askSpy).toHaveBeenCalledWith("resume_completed_task")
// The follow-up should be routed as a tool_result for the pending
// attempt_completion tool_use — NOT as a bare text block.
expect(loopSpy).toHaveBeenCalledTimes(1)
const sentContent = loopSpy.mock.calls[0][0] as any[]
expect(sentContent).toHaveLength(1)
expect(sentContent[0].type).toBe("tool_result")
expect(sentContent[0].tool_use_id).toBe("toolu_completion_abc")
expect(sentContent[0].content).toContain("looks good, thanks!")
})
it("completion → restart → resume without follow-up text injects completed status", async () => {
const task = createTask(mockProvider)
const now = Date.now()
const savedClineMessages: ClineMessage[] = [
{ ts: now - 5000, type: "say", say: "text", text: "user task" },
{ ts: now - 2000, type: "ask", ask: "completion_result", text: "Done." },
]
const savedApiHistory = [
{
role: "user" as const,
content: [{ type: "text" as const, text: "build it" }],
ts: now - 5000,
},
{
role: "assistant" as const,
content: [
{ type: "text" as const, text: "Done." },
{
type: "tool_use" as const,
id: "toolu_completion_xyz",
name: "attempt_completion",
input: { result: "Done." },
},
],
ts: now - 4000,
},
]
vi.spyOn(task as any, "getSavedClineMessages").mockImplementation(async () =>
savedClineMessages.map((m) => ({ ...m })),
)
vi.spyOn(task as any, "getSavedApiConversationHistory").mockResolvedValue(
savedApiHistory.map((m) => ({ ...m, content: [...(m.content as any[])] })),
)
vi.spyOn(task as any, "overwriteClineMessages").mockImplementation(async (msgs: any) => {
task.clineMessages = msgs
})
// Simulate user clicking "continue" without providing any text
vi.spyOn(task as any, "ask").mockResolvedValue({
response: "yesButtonClicked",
text: undefined,
images: undefined,
})
vi.spyOn(task as any, "say").mockResolvedValue(undefined)
const loopSpy = vi.spyOn(task as any, "initiateTaskLoop").mockResolvedValue(undefined)
await (task as any).resumeTaskFromHistory()
// Should inject a {"status":"completed"} tool_result as fallback
expect(loopSpy).toHaveBeenCalledTimes(1)
const sentContent = loopSpy.mock.calls[0][0] as any[]
expect(sentContent).toHaveLength(1)
expect(sentContent[0].type).toBe("tool_result")
expect(sentContent[0].tool_use_id).toBe("toolu_completion_xyz")
const parsedContent = JSON.parse(sentContent[0].content)
expect(parsedContent.status).toBe("completed")
})
it("attempt_completion alongside another tool_use in same assistant message", () => {
const task = createTask(mockProvider)
const now = Date.now()
// Edge case: assistant message contains both a read_file tool_use and
// an attempt_completion tool_use. getPendingAttemptCompletionToolUseId
// should still detect the attempt_completion.
task.apiConversationHistory = [
{
role: "user",
content: [{ type: "text", text: "build and verify the app" }],
ts: now - 5000,
},
{
role: "assistant",
content: [
{ type: "text", text: "I'll verify and complete." },
{
type: "tool_use",
id: "toolu_read_999",
name: "read_file",
input: { path: "package.json" },
},
{
type: "tool_use",
id: "toolu_completion_888",
name: "attempt_completion",
input: { result: "App is ready." },
},
],
ts: now - 4000,
},
] as any[]
const pendingId: string | undefined = (task as any).getPendingAttemptCompletionToolUseId()
expect(pendingId).toBe("toolu_completion_888")
})
})

View file

@ -0,0 +1,493 @@
// npx vitest run core/task/__tests__/rewindToLastAskPoint.spec.ts
import {
initializeTelemetry,
createMockExtensionContext,
createMockProvider,
createTask,
} from "./fixtures/rewind-test-setup"
// ---------------------------------------------------------------------------
// vi.mock() declarations — must live in the test file (vitest hoists them).
// ---------------------------------------------------------------------------
vi.mock("delay", () => ({
__esModule: true,
default: vi.fn().mockResolvedValue(undefined),
}))
vi.mock("uuid", async (importOriginal) => {
const actual = await importOriginal<typeof import("uuid")>()
return {
...actual,
v7: vi.fn(() => "00000000-0000-7000-8000-000000000000"),
}
})
vi.mock("execa", () => ({
execa: vi.fn(),
}))
vi.mock("fs/promises", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, any>
const mockFunctions = {
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockImplementation((filePath) => {
if (filePath.includes("ui_messages.json")) {
return Promise.resolve(JSON.stringify([]))
}
if (filePath.includes("api_conversation_history.json")) {
return Promise.resolve(JSON.stringify([]))
}
return Promise.resolve("[]")
}),
unlink: vi.fn().mockResolvedValue(undefined),
rmdir: vi.fn().mockResolvedValue(undefined),
}
return {
...actual,
...mockFunctions,
default: mockFunctions,
}
})
vi.mock("p-wait-for", () => ({
default: vi.fn().mockImplementation(async () => Promise.resolve()),
}))
vi.mock("vscode", () => {
const mockDisposable = { dispose: vi.fn() }
const mockEventEmitter = { event: vi.fn(), fire: vi.fn() }
return {
TabInputTextDiff: vi.fn(),
CodeActionKind: {
QuickFix: { value: "quickfix" },
RefactorRewrite: { value: "refactor.rewrite" },
},
window: {
createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }),
visibleTextEditors: [],
tabGroups: {
all: [],
close: vi.fn(),
onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })),
},
showErrorMessage: vi.fn(),
},
workspace: {
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(),
})),
fs: {
stat: vi.fn().mockResolvedValue({ type: 1 }),
},
onDidSaveTextDocument: vi.fn(() => mockDisposable),
getConfiguration: vi.fn(() => ({ get: (key: string, defaultValue: any) => defaultValue })),
},
env: {
uriScheme: "vscode",
language: "en",
},
EventEmitter: vi.fn().mockImplementation(() => mockEventEmitter),
Disposable: {
from: vi.fn(),
},
TabInputText: vi.fn(),
}
})
vi.mock("../../mentions", () => ({
parseMentions: vi.fn().mockImplementation((text) => {
return Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] })
}),
openMention: vi.fn(),
getLatestTerminalOutput: vi.fn(),
}))
vi.mock("../../../integrations/misc/extract-text", () => ({
extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"),
}))
vi.mock("../../environment/getEnvironmentDetails", () => ({
getEnvironmentDetails: vi.fn().mockResolvedValue(""),
}))
vi.mock("../../ignore/RooIgnoreController")
vi.mock("../../condense", async (importOriginal) => {
const actual = (await importOriginal()) as any
return {
...actual,
summarizeConversation: vi.fn().mockResolvedValue({
messages: [{ role: "user", content: [{ type: "text", text: "continued" }], ts: Date.now() }],
summary: "summary",
cost: 0,
newContextTokens: 1,
}),
}
})
vi.mock("../../../utils/storage", () => ({
getTaskDirectoryPath: vi
.fn()
.mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)),
getSettingsDirectoryPath: vi
.fn()
.mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)),
}))
vi.mock("../../../utils/fs", () => ({
fileExistsAtPath: vi.fn().mockReturnValue(false),
}))
// ---------------------------------------------------------------------------
describe("rewindToLastAskPoint", () => {
let mockProvider: any
beforeEach(() => {
initializeTelemetry()
const ctx = createMockExtensionContext()
mockProvider = createMockProvider(ctx)
})
it("should return false when no ask point exists", async () => {
const task = createTask(mockProvider)
// Empty clineMessages
task.clineMessages = []
const result = await (task as any).rewindToLastAskPoint()
expect(result).toBe(false)
})
it("should truncate clineMessages to last ask point", async () => {
const task = createTask(mockProvider)
const now = Date.now()
task.clineMessages = [
{ ts: now - 5000, type: "say", say: "text", text: "user task" },
{ ts: now - 4000, type: "ask", ask: "tool", text: "approve write_to_file" },
{ ts: now - 3000, type: "say", say: "text", text: "streaming response..." },
{ ts: now - 2000, type: "say", say: "api_req_started", text: "{}" },
]
task.apiConversationHistory = []
const overwriteClineSpy = vi.spyOn(task as any, "overwriteClineMessages")
const overwriteApiSpy = vi.spyOn(task as any, "overwriteApiConversationHistory")
const result = await (task as any).rewindToLastAskPoint()
expect(result).toBe(true)
// Should truncate to include the ask at index 1
expect(task.clineMessages).toHaveLength(2)
expect(task.clineMessages[1].ask).toBe("tool")
// Verify persistence was triggered with the correct truncated data
expect(overwriteClineSpy).toHaveBeenCalledWith(task.clineMessages)
expect(overwriteApiSpy).toHaveBeenCalledWith(task.apiConversationHistory)
})
it("should skip non-blocking asks (command_output)", async () => {
const task = createTask(mockProvider)
const now = Date.now()
task.clineMessages = [
{ ts: now - 5000, type: "say", say: "text", text: "user task" },
{ ts: now - 4000, type: "ask", ask: "followup", text: "what do you want?" },
{ ts: now - 3000, type: "say", say: "user_feedback", text: "this response" },
{ ts: now - 2000, type: "ask", ask: "command_output", text: "output data" },
]
task.apiConversationHistory = []
const result = await (task as any).rewindToLastAskPoint()
expect(result).toBe(true)
// Should rewind to the "followup" ask, skipping command_output
expect(task.clineMessages).toHaveLength(2)
expect(task.clineMessages[1].ask).toBe("followup")
})
it("should skip resume_task and resume_completed_task asks", async () => {
const task = createTask(mockProvider)
const now = Date.now()
task.clineMessages = [
{ ts: now - 5000, type: "say", say: "text", text: "user task" },
{ ts: now - 4000, type: "ask", ask: "completion_result", text: "done" },
{ ts: now - 3000, type: "ask", ask: "resume_completed_task", text: "" },
]
task.apiConversationHistory = []
const result = await (task as any).rewindToLastAskPoint()
expect(result).toBe(true)
// Should rewind to the "completion_result" ask, skipping resume_completed_task
expect(task.clineMessages).toHaveLength(2)
expect(task.clineMessages[1].ask).toBe("completion_result")
})
it("should remove trailing assistant message with unanswered tool_use", async () => {
const task = createTask(mockProvider)
const now = Date.now()
task.clineMessages = [
{ ts: now - 3000, type: "say", say: "text", text: "user task" },
{ ts: now - 2000, type: "ask", ask: "tool", text: "approve write_to_file" },
]
task.apiConversationHistory = [
{
role: "user",
content: [{ type: "text", text: "do something" }],
ts: now - 4000,
},
{
role: "assistant",
content: [
{ type: "text", text: "I'll edit the file" },
{
type: "tool_use",
id: "tool-1",
name: "write_to_file",
input: { path: "test.ts", content: "hello" },
},
],
ts: now - 3000,
},
] as any[]
const overwriteClineSpy = vi.spyOn(task as any, "overwriteClineMessages")
const overwriteApiSpy = vi.spyOn(task as any, "overwriteApiConversationHistory")
const result = await (task as any).rewindToLastAskPoint()
expect(result).toBe(true)
// Should remove the assistant message with unanswered tool_use
expect(task.apiConversationHistory).toHaveLength(1)
expect(task.apiConversationHistory[0].role).toBe("user")
// Verify persistence was triggered with the correct truncated data
expect(overwriteClineSpy).toHaveBeenCalledWith(task.clineMessages)
expect(overwriteApiSpy).toHaveBeenCalledWith(task.apiConversationHistory)
})
it("should keep assistant message with text-only content", async () => {
const task = createTask(mockProvider)
const now = Date.now()
task.clineMessages = [
{ ts: now - 3000, type: "say", say: "text", text: "user task" },
{ ts: now - 2000, type: "ask", ask: "followup", text: "what next?" },
]
task.apiConversationHistory = [
{
role: "user",
content: [{ type: "text", text: "do something" }],
ts: now - 4000,
},
{
role: "assistant",
content: [{ type: "text", text: "What would you like me to do?" }],
ts: now - 3000,
},
] as any[]
const result = await (task as any).rewindToLastAskPoint()
expect(result).toBe(true)
// Should keep the text-only assistant message
expect(task.apiConversationHistory).toHaveLength(2)
expect(task.apiConversationHistory[1].role).toBe("assistant")
})
it("should remove incomplete user message and its assistant message", async () => {
const task = createTask(mockProvider)
const now = Date.now()
task.clineMessages = [
{ ts: now - 3000, type: "say", say: "text", text: "user task" },
{ ts: now - 2000, type: "ask", ask: "tool", text: "approve next op" },
]
// Scenario: assistant made 2 tool calls, user only answered 1
task.apiConversationHistory = [
{
role: "user",
content: [{ type: "text", text: "do something" }],
ts: now - 5000,
},
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "test.ts" },
},
{
type: "tool_use",
id: "tool-2",
name: "write_to_file",
input: { path: "test.ts", content: "hello" },
},
],
ts: now - 4000,
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-1",
content: "file contents",
},
// tool-2 tool_result is missing
],
ts: now - 3000,
},
] as any[]
const overwriteClineSpy = vi.spyOn(task as any, "overwriteClineMessages")
const overwriteApiSpy = vi.spyOn(task as any, "overwriteApiConversationHistory")
const result = await (task as any).rewindToLastAskPoint()
expect(result).toBe(true)
// Should remove both the incomplete user message and the assistant message
expect(task.apiConversationHistory).toHaveLength(1)
expect(task.apiConversationHistory[0].role).toBe("user")
// Verify persistence was triggered with the correct truncated data
expect(overwriteClineSpy).toHaveBeenCalledWith(task.clineMessages)
expect(overwriteApiSpy).toHaveBeenCalledWith(task.apiConversationHistory)
})
it("should not remove user message when all tool_results are present", async () => {
const task = createTask(mockProvider)
const now = Date.now()
task.clineMessages = [
{ ts: now - 3000, type: "say", say: "text", text: "user task" },
{ ts: now - 2000, type: "ask", ask: "tool", text: "approve next op" },
]
task.apiConversationHistory = [
{
role: "user",
content: [{ type: "text", text: "do something" }],
ts: now - 5000,
},
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "test.ts" },
},
],
ts: now - 4000,
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-1",
content: "file contents",
},
],
ts: now - 3000,
},
] as any[]
const result = await (task as any).rewindToLastAskPoint()
expect(result).toBe(true)
// All tool_results present - should keep everything
expect(task.apiConversationHistory).toHaveLength(3)
})
it("should handle empty apiConversationHistory gracefully", async () => {
const task = createTask(mockProvider)
const now = Date.now()
task.clineMessages = [
{ ts: now - 3000, type: "say", say: "text", text: "user task" },
{ ts: now - 2000, type: "ask", ask: "followup", text: "question" },
]
task.apiConversationHistory = []
const result = await (task as any).rewindToLastAskPoint()
expect(result).toBe(true)
// clineMessages should still be truncated
expect(task.clineMessages).toHaveLength(2)
// apiConversationHistory should remain empty
expect(task.apiConversationHistory).toHaveLength(0)
})
it("should skip partial ask messages", async () => {
const task = createTask(mockProvider)
const now = Date.now()
task.clineMessages = [
{ ts: now - 5000, type: "say", say: "text", text: "user task" },
{ ts: now - 4000, type: "ask", ask: "tool", text: "approve this" },
{ ts: now - 3000, type: "ask", ask: "command", text: "run this", partial: true },
]
task.apiConversationHistory = []
const result = await (task as any).rewindToLastAskPoint()
expect(result).toBe(true)
// Should skip the partial ask and rewind to the completed "tool" ask
expect(task.clineMessages).toHaveLength(2)
expect(task.clineMessages[1].ask).toBe("tool")
})
it("should return false when overwriteClineMessages throws an error", async () => {
const task = createTask(mockProvider)
const now = Date.now()
task.clineMessages = [
{ ts: now - 5000, type: "say", say: "text", text: "user task" },
{ ts: now - 4000, type: "ask", ask: "tool", text: "approve write_to_file" },
{ ts: now - 3000, type: "say", say: "text", text: "streaming response..." },
]
task.apiConversationHistory = []
// Make overwriteClineMessages throw to simulate a disk write failure
const overwriteSpy = vi
.spyOn(task as any, "overwriteClineMessages")
.mockRejectedValue(new Error("disk write failed"))
const result = await (task as any).rewindToLastAskPoint()
expect(result).toBe(false)
overwriteSpy.mockRestore()
})
})

View file

@ -613,7 +613,7 @@ describe("validateAndFixToolResultIds", () => {
expect(resultContent.length).toBe(2)
// The missing tool_result is prepended
expect(resultContent[0].tool_use_id).toBe("tool-2")
expect(resultContent[0].content).toBe("Tool execution was interrupted before completion.")
expect(resultContent[0].content).toBe("No result available for this tool call.")
// The original is fixed
expect(resultContent[1].tool_use_id).toBe("tool-1")
})
@ -652,7 +652,7 @@ describe("validateAndFixToolResultIds", () => {
expect(resultContent[0].type).toBe("tool_result")
expect((resultContent[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-123")
expect((resultContent[0] as Anthropic.ToolResultBlockParam).content).toBe(
"Tool execution was interrupted before completion.",
"No result available for this tool call.",
)
// Original text block should be preserved
expect(resultContent[1].type).toBe("text")
@ -738,7 +738,7 @@ describe("validateAndFixToolResultIds", () => {
expect(resultContent.length).toBe(2)
// Missing tool_result for tool-2 should be prepended
expect(resultContent[0].tool_use_id).toBe("tool-2")
expect(resultContent[0].content).toBe("Tool execution was interrupted before completion.")
expect(resultContent[0].content).toBe("No result available for this tool call.")
// Existing tool_result should be preserved
expect(resultContent[1].tool_use_id).toBe("tool-1")
expect(resultContent[1].content).toBe("Content for tool 1")
@ -769,7 +769,7 @@ describe("validateAndFixToolResultIds", () => {
expect(resultContent.length).toBe(1)
expect(resultContent[0].type).toBe("tool_result")
expect(resultContent[0].tool_use_id).toBe("tool-1")
expect(resultContent[0].content).toBe("Tool execution was interrupted before completion.")
expect(resultContent[0].content).toBe("No result available for this tool call.")
})
})

View file

@ -220,7 +220,7 @@ export function validateAndFixToolResultIds(
const missingToolResults: Anthropic.ToolResultBlockParam[] = stillMissingToolUseIds.map((toolUse) => ({
type: "tool_result" as const,
tool_use_id: toolUse.id,
content: "Tool execution was interrupted before completion.",
content: "No result available for this tool call.",
}))
// Insert missing tool_results at the beginning of the content array

View file

@ -137,16 +137,40 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
const { response, text, images } = await task.ask("completion_result", "", false)
if (response === "yesButtonClicked") {
// User accepted completion. Don't push a tool_result yet.
// Instead, wait for a potential follow-up message by asking
// resume_completed_task. This keeps the task alive so any
// subsequent user message routes as a tool_result for this
// attempt_completion tool_use (not as a bare user message).
const resume = await task.ask("resume_completed_task")
if (resume.response === "messageResponse" && resume.text) {
// User came back with a follow-up message.
// Push it as the tool_result for attempt_completion.
await task.say("user_feedback", resume.text, resume.images)
const feedbackText = `<user_message>\n${resume.text}\n</user_message>`
pushToolResult(formatResponse.toolResult(feedbackText, resume.images))
return
}
// User didn't provide text (or task was aborted).
// Push a "completed" tool_result to close the tool contract.
pushToolResult(
JSON.stringify({
status: "completed",
message: "The user is satisfied with the result.",
}),
)
return
}
// User provided feedback - push tool result to continue the conversation
// User provided feedback directly (typed text in the completion UI)
await task.say("user_feedback", text ?? "", images)
const feedbackText = `<user_message>\n${text}\n</user_message>`
pushToolResult(formatResponse.toolResult(feedbackText, images))
} catch (error) {
await handleError("inspecting site", error as Error)
await handleError("completing task", error as Error)
}
}

View file

@ -1,4 +1,5 @@
import { TodoItem } from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { AttemptCompletionToolUse } from "../../../shared/tools"
@ -6,6 +7,8 @@ import { AttemptCompletionToolUse } from "../../../shared/tools"
vi.mock("../../prompts/responses", () => ({
formatResponse: {
toolError: vi.fn((msg: string) => `Error: ${msg}`),
toolResult: vi.fn((text: string, images?: string[]) => text),
imageBlocks: vi.fn((images?: string[]) => []),
},
}))
@ -39,6 +42,10 @@ describe("attemptCompletionTool", () => {
let mockGetConfiguration: ReturnType<typeof vi.fn>
beforeEach(() => {
if (!TelemetryService.hasInstance()) {
TelemetryService.createInstance([])
}
mockPushToolResult = vi.fn()
mockAskApproval = vi.fn()
mockHandleError = vi.fn()
@ -469,4 +476,158 @@ describe("attemptCompletionTool", () => {
})
})
})
describe("yesButtonClicked completion acceptance", () => {
it("should wait for follow-up after yesButtonClicked and push 'completed' if no text", async () => {
const block: AttemptCompletionToolUse = {
type: "tool_use",
name: "attempt_completion",
params: { result: "Task completed successfully" },
nativeArgs: { result: "Task completed successfully" },
partial: false,
}
mockTask.todoList = undefined
mockTask.didToolFailInCurrentTurn = false
// First ask returns yesButtonClicked (completion_result),
// second ask returns yesButtonClicked with no text (resume_completed_task)
mockTask.ask = vi
.fn()
.mockResolvedValueOnce({ response: "yesButtonClicked", text: "", images: [] })
.mockResolvedValueOnce({ response: "yesButtonClicked", text: "", images: [] })
const callbacks: AttemptCompletionCallbacks = {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
askFinishSubTaskApproval: mockAskFinishSubTaskApproval,
toolDescription: mockToolDescription,
}
await attemptCompletionTool.handle(mockTask as Task, block, callbacks)
// handleError should NOT have been called
expect(mockHandleError).not.toHaveBeenCalled()
// ask should have been called twice: completion_result + resume_completed_task
expect(mockTask.ask).toHaveBeenCalledTimes(2)
expect(mockTask.ask).toHaveBeenNthCalledWith(1, "completion_result", "", false)
expect(mockTask.ask).toHaveBeenNthCalledWith(2, "resume_completed_task")
// Should push a "completed" tool_result
expect(mockPushToolResult).toHaveBeenCalledTimes(1)
const toolResultArg = mockPushToolResult.mock.calls[0][0]
const parsed = JSON.parse(toolResultArg)
expect(parsed.status).toBe("completed")
})
it("should push follow-up text as tool_result when user returns with a message", async () => {
const block: AttemptCompletionToolUse = {
type: "tool_use",
name: "attempt_completion",
params: { result: "Task completed" },
nativeArgs: { result: "Task completed" },
partial: false,
}
mockTask.todoList = undefined
mockTask.didToolFailInCurrentTurn = false
// First ask returns yesButtonClicked (completion_result),
// second ask returns messageResponse with follow-up text (resume_completed_task)
mockTask.ask = vi
.fn()
.mockResolvedValueOnce({ response: "yesButtonClicked", text: "", images: [] })
.mockResolvedValueOnce({ response: "messageResponse", text: "Actually, also fix Y", images: [] })
const callbacks: AttemptCompletionCallbacks = {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
askFinishSubTaskApproval: mockAskFinishSubTaskApproval,
toolDescription: mockToolDescription,
}
await attemptCompletionTool.handle(mockTask as Task, block, callbacks)
// Should push a tool_result with the follow-up text
expect(mockPushToolResult).toHaveBeenCalledTimes(1)
const toolResultArg = mockPushToolResult.mock.calls[0][0]
expect(toolResultArg).toContain("Actually, also fix Y")
})
it("should push tool_result with user feedback when user provides direct feedback", async () => {
const block: AttemptCompletionToolUse = {
type: "tool_use",
name: "attempt_completion",
params: { result: "Task completed" },
nativeArgs: { result: "Task completed" },
partial: false,
}
mockTask.todoList = undefined
mockTask.didToolFailInCurrentTurn = false
// Simulate user providing feedback directly (messageResponse on first ask)
mockTask.ask = vi
.fn()
.mockResolvedValue({ response: "messageResponse", text: "Please also fix X", images: [] })
const callbacks: AttemptCompletionCallbacks = {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
askFinishSubTaskApproval: mockAskFinishSubTaskApproval,
toolDescription: mockToolDescription,
}
await attemptCompletionTool.handle(mockTask as Task, block, callbacks)
// Only one ask call - user provided feedback directly
expect(mockTask.ask).toHaveBeenCalledTimes(1)
// Should push a tool_result with the user's feedback
expect(mockPushToolResult).toHaveBeenCalledTimes(1)
const toolResultArg = mockPushToolResult.mock.calls[0][0]
expect(toolResultArg).toContain("Please also fix X")
})
it("should fall through to error handler when resume_completed_task ask throws (abort)", async () => {
const block: AttemptCompletionToolUse = {
type: "tool_use",
name: "attempt_completion",
params: { result: "Task completed" },
nativeArgs: { result: "Task completed" },
partial: false,
}
mockTask.todoList = undefined
mockTask.didToolFailInCurrentTurn = false
// First ask returns yesButtonClicked (completion_result),
// second ask throws (simulating task abort during resume_completed_task)
mockTask.ask = vi
.fn()
.mockResolvedValueOnce({ response: "yesButtonClicked", text: "", images: [] })
.mockRejectedValueOnce(new Error("Task was aborted"))
const callbacks: AttemptCompletionCallbacks = {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
askFinishSubTaskApproval: mockAskFinishSubTaskApproval,
toolDescription: mockToolDescription,
}
await attemptCompletionTool.handle(mockTask as Task, block, callbacks)
// ask should have been called twice: completion_result + resume_completed_task (which threw)
expect(mockTask.ask).toHaveBeenCalledTimes(2)
// handleError should have been called with "completing task"
expect(mockHandleError).toHaveBeenCalledTimes(1)
expect(mockHandleError).toHaveBeenCalledWith("completing task", expect.any(Error))
})
})
})

View file

@ -0,0 +1,40 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { normalizeContentBlocks } from "../messages"
describe("normalizeContentBlocks", () => {
it("returns the array unchanged when content is already an array", () => {
const blocks: Anthropic.Messages.ContentBlockParam[] = [
{ type: "text", text: "hello" },
{ type: "tool_use", id: "tool-1", name: "read_file", input: { path: "a.ts" } },
]
const result = normalizeContentBlocks(blocks)
expect(result).toBe(blocks) // same reference
})
it("wraps a plain string in a single text block", () => {
const result = normalizeContentBlocks("hello world")
expect(result).toEqual([{ type: "text", text: "hello world" }])
})
it("wraps an empty string in a text block with empty text", () => {
const result = normalizeContentBlocks("")
expect(result).toEqual([{ type: "text", text: "" }])
})
it("returns an empty array unchanged", () => {
const empty: Anthropic.Messages.ContentBlockParam[] = []
const result = normalizeContentBlocks(empty)
expect(result).toBe(empty)
})
it("preserves tool_result blocks in the array", () => {
const blocks: Anthropic.Messages.ContentBlockParam[] = [
{ type: "tool_result", tool_use_id: "tool-1", content: "result" },
]
const result = normalizeContentBlocks(blocks)
expect(result).toBe(blocks)
expect(result[0].type).toBe("tool_result")
})
})

14
src/shared/messages.ts Normal file
View file

@ -0,0 +1,14 @@
import { Anthropic } from "@anthropic-ai/sdk"
/**
* Normalize a message's `content` field into an array of content blocks.
*
* Anthropic's `MessageParam.content` can be either a plain string or an
* array of typed content blocks. This helper standardises the value to
* always be an array, wrapping a plain string in a single text block.
*/
export function normalizeContentBlocks(
content: string | Anthropic.Messages.ContentBlockParam[],
): Anthropic.Messages.ContentBlockParam[] {
return Array.isArray(content) ? content : [{ type: "text" as const, text: content ?? "" }]
}