Skip to content

Commit fd17bc4

Browse files
author
easonLiangWorldedtech
committed
rebuild unit u7 in the corrected order
1 parent 22b6dec commit fd17bc4

11 files changed

Lines changed: 516 additions & 23 deletions

‎src/core/task/Task.ts‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ import { restoreTodoListForTask } from "../tools/UpdateTodoListTool"
113113
import { FileContextTracker } from "../context-tracking/FileContextTracker"
114114
import { ObservationRegistry } from "./observationRegistry"
115115
import { RooIgnoreController } from "../ignore/RooIgnoreController"
116+
import { ObservationRegistry } from "./observationRegistry"
116117
import { RooProtectedController } from "../protect/RooProtectedController"
117118
import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message"
118119
import { NativeToolCallParser } from "../assistant-message/NativeToolCallParser"
@@ -297,6 +298,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
297298
readonly parentTask: Task | undefined = undefined
298299
readonly taskNumber: number
299300
readonly workspacePath: string
301+
readonly observationRegistry = new ObservationRegistry()
300302

301303
/**
302304
* The mode associated with this task. Persisted across sessions
@@ -407,6 +409,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
407409
abandoned = false
408410
abortReason?: ClineApiReqCancelReason
409411
isInitialized = false
412+
isPaused: boolean = false
410413

411414
// API
412415
apiConfiguration: ProviderSettings
@@ -1820,14 +1823,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
18201823
const isAutoAnswered = approval.decision === "approve" || approval.decision === "deny"
18211824
const autoApprovalDecision = isAutoAnswered ? approval.decision : undefined
18221825

1823-
// Re-check: an abort during the getState/checkAutoApproval awaits must not post an ask row.
1824-
if (this.abort) {
1825-
if (queuedMessage) {
1826-
this.messageQueueService.releaseMessage(queuedMessage.id)
1827-
}
1828-
throw new Error(`[RooCode#ask] task ${this.taskId}.${this.instanceId} aborted`)
1829-
}
1830-
18311826
if (partial !== undefined) {
18321827
const lastMessage = this.clineMessages.at(-1)
18331828

@@ -4692,7 +4687,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
46924687
this.consecutiveNoToolUseCount = 0
46934688
}
46944689

4695-
if (this.userMessageContent.length > 0) {
4690+
// Push to stack if there's content OR if we're paused waiting for a subtask.
4691+
// When paused, we push an empty item so the loop continues to the pause check.
4692+
if (this.userMessageContent.length > 0 || this.isPaused) {
46964693
stack.push({
46974694
userContent: [...this.userMessageContent], // Create a copy to avoid mutation issues
46984695
includeFileDetails: false, // Subsequent iterations don't need file details

‎src/core/tools/ApplyDiffTool.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,8 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> {
173173
return
174174
}
175175

176-
// Save directly without showing diff view or opening the file
176+
// Save directly without showing diff view or opening the file. The diff is
177+
// applied to an existing file, so edit-guard semantics require a prior read.
177178
task.diffViewProvider.editType = "modify"
178179
task.diffViewProvider.originalContent = originalContent
179180
await task.diffViewProvider.saveDirectly(
@@ -182,6 +183,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> {
182183
false,
183184
diagnosticsEnabled,
184185
writeDelayMs,
186+
"edit",
185187
)
186188
} else {
187189
// Original behavior with diff view
@@ -221,7 +223,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> {
221223
}
222224

223225
// Call saveChanges to update the DiffViewProvider properties
224-
await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs)
226+
await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit")
225227
}
226228

227229
// Track file edit operation

‎src/core/tools/EditFileTool.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -436,17 +436,20 @@ export class EditFileTool extends BaseTool<"edit_file"> {
436436

437437
// Save the changes
438438
if (isPreventFocusDisruptionEnabled) {
439-
// Direct file write without diff view or opening the file
439+
// Direct file write without diff view or opening the file. In-place edits
440+
// use edit-guard semantics (a prior read is required); new-file creation
441+
// keeps create-guard semantics.
440442
await task.diffViewProvider.saveDirectly(
441443
relPath,
442444
newContent,
443445
isNewFile,
444446
diagnosticsEnabled,
445447
writeDelayMs,
448+
isNewFile ? "create" : "edit",
446449
)
447450
} else {
448451
// Call saveChanges to update the DiffViewProvider properties
449-
await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs)
452+
await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, isNewFile ? "create" : "edit")
450453
}
451454

452455
// Track file edit operation

‎src/core/tools/EditTool.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,19 @@ export class EditTool extends BaseTool<"edit"> {
211211

212212
// Save the changes
213213
if (isPreventFocusDisruptionEnabled) {
214-
// Direct file write without diff view or opening the file
215-
await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs)
214+
// Direct file write without diff view or opening the file. This tool only
215+
// edits existing files, so edit-guard semantics require a prior read.
216+
await task.diffViewProvider.saveDirectly(
217+
relPath,
218+
newContent,
219+
false,
220+
diagnosticsEnabled,
221+
writeDelayMs,
222+
"edit",
223+
)
216224
} else {
217225
// Call saveChanges to update the DiffViewProvider properties
218-
await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs)
226+
await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit")
219227
}
220228

221229
// Track file edit operation

‎src/core/tools/SearchReplaceTool.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,11 +207,19 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> {
207207

208208
// Save the changes
209209
if (isPreventFocusDisruptionEnabled) {
210-
// Direct file write without diff view or opening the file
211-
await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs)
210+
// Direct file write without diff view or opening the file. This tool only
211+
// edits existing files, so edit-guard semantics require a prior read.
212+
await task.diffViewProvider.saveDirectly(
213+
relPath,
214+
newContent,
215+
false,
216+
diagnosticsEnabled,
217+
writeDelayMs,
218+
"edit",
219+
)
212220
} else {
213221
// Call saveChanges to update the DiffViewProvider properties
214-
await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs)
222+
await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit")
215223
}
216224

217225
// Track file edit operation

‎src/core/tools/WriteToFileTool.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,16 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
133133
return
134134
}
135135

136-
await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs)
136+
// Guarded publish: this write carries the complete file content, so it uses
137+
// create-guard semantics (unobserved targets may only be created when absent).
138+
await task.diffViewProvider.saveDirectly(
139+
relPath,
140+
newContent,
141+
false,
142+
diagnosticsEnabled,
143+
writeDelayMs,
144+
"create",
145+
)
137146
} else {
138147
if (!task.diffViewProvider.isEditing) {
139148
const partialMessage = JSON.stringify(sharedMessageProps)
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// npx vitest run core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts
2+
3+
import type { MockedFunction } from "vitest"
4+
5+
import { fileExistsAtPath } from "../../../utils/fs"
6+
import type { Task } from "../../task/Task"
7+
import { ApplyDiffTool } from "../ApplyDiffTool"
8+
9+
vi.mock("fs/promises", () => ({
10+
default: {
11+
readFile: vi.fn().mockResolvedValue("original file content\n"),
12+
},
13+
}))
14+
15+
vi.mock("../../../utils/fs", () => ({
16+
fileExistsAtPath: vi.fn().mockResolvedValue(true),
17+
}))
18+
19+
vi.mock("../../prompts/responses", () => ({
20+
formatResponse: {
21+
toolError: vi.fn((msg: string) => `Error: ${msg}`),
22+
rooIgnoreError: vi.fn((filePath: string) => `Access denied: ${filePath}`),
23+
createPrettyPatch: vi.fn(() => "mock-diff"),
24+
},
25+
}))
26+
27+
vi.mock("../../diff/stats", () => ({
28+
sanitizeUnifiedDiff: vi.fn((diff: string) => diff),
29+
computeDiffStats: vi.fn(() => ({ additions: 1, deletions: 1 })),
30+
}))
31+
32+
describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => {
33+
const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction<typeof fileExistsAtPath>
34+
35+
let tool: ApplyDiffTool
36+
let mockTask: Pick<
37+
Task,
38+
| "cwd"
39+
| "consecutiveMistakeCount"
40+
| "consecutiveMistakeCountForApplyDiff"
41+
| "recordToolError"
42+
| "rooIgnoreController"
43+
| "rooProtectedController"
44+
| "say"
45+
| "processQueuedMessages"
46+
| "didEditFile"
47+
| "api"
48+
| "diffStrategy"
49+
| "diffViewProvider"
50+
| "providerRef"
51+
| "fileContextTracker"
52+
>
53+
let mockSaveDirectly: MockedFunction<(...args: unknown[]) => Promise<unknown>>
54+
let mockSaveChanges: MockedFunction<(...args: unknown[]) => Promise<unknown>>
55+
let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise<boolean>>
56+
let mockHandleError: MockedFunction<(...args: unknown[]) => Promise<void>>
57+
let mockPushToolResult: MockedFunction<(...args: unknown[]) => void>
58+
59+
beforeEach(() => {
60+
vi.clearAllMocks()
61+
62+
mockedFileExistsAtPath.mockResolvedValue(true)
63+
64+
mockSaveDirectly = vi.fn().mockResolvedValue({
65+
newProblemsMessage: "",
66+
userEdits: undefined,
67+
finalContent: "new content",
68+
})
69+
mockSaveChanges = vi.fn().mockResolvedValue({
70+
newProblemsMessage: "",
71+
userEdits: undefined,
72+
finalContent: "new content",
73+
})
74+
75+
// Structural stubs for the guarded-write path: the real DiffViewProvider is
76+
// out of scope here, so vi.fn() doubles stand in for the members the tool
77+
// touches (the saveDirectly double also records the writeKind plumbing).
78+
const diffViewProviderStub = {
79+
editType: undefined as "create" | "modify" | undefined,
80+
originalContent: undefined as string | undefined,
81+
saveDirectly: mockSaveDirectly,
82+
saveChanges: mockSaveChanges,
83+
open: vi.fn().mockResolvedValue(undefined),
84+
update: vi.fn().mockResolvedValue(undefined),
85+
scrollToFirstDiff: vi.fn(),
86+
pushToolWriteResult: vi.fn().mockResolvedValue("Saved file"),
87+
reset: vi.fn().mockResolvedValue(undefined),
88+
}
89+
mockTask = {
90+
cwd: "/workspace/project",
91+
consecutiveMistakeCount: 0,
92+
consecutiveMistakeCountForApplyDiff: new Map(),
93+
recordToolError: vi.fn(),
94+
rooIgnoreController: {
95+
validateAccess: vi.fn().mockReturnValue(true),
96+
} as unknown as Task["rooIgnoreController"],
97+
rooProtectedController: {
98+
isWriteProtected: vi.fn().mockReturnValue(false),
99+
} as unknown as Task["rooProtectedController"],
100+
say: vi.fn().mockResolvedValue(undefined),
101+
processQueuedMessages: vi.fn(),
102+
didEditFile: false,
103+
api: {
104+
getModel: () => ({ id: "claude-sonnet-4-5" }),
105+
} as unknown as Task["api"],
106+
diffStrategy: {
107+
applyDiff: vi.fn().mockResolvedValue({ success: true, content: "modified file content\n" }),
108+
} as unknown as Task["diffStrategy"],
109+
diffViewProvider: diffViewProviderStub as unknown as Task["diffViewProvider"],
110+
providerRef: {
111+
deref: vi.fn().mockReturnValue({
112+
getState: vi.fn().mockResolvedValue({
113+
diagnosticsEnabled: true,
114+
writeDelayMs: 1000,
115+
// Exercise the focus-disruption (saveDirectly) save path.
116+
experiments: { preventFocusDisruption: true },
117+
}),
118+
}),
119+
} as unknown as Task["providerRef"],
120+
fileContextTracker: {
121+
trackFileContext: vi.fn().mockResolvedValue(undefined),
122+
} as unknown as Task["fileContextTracker"],
123+
}
124+
125+
mockAskApproval = vi.fn().mockResolvedValue(true)
126+
mockHandleError = vi.fn().mockResolvedValue(undefined)
127+
mockPushToolResult = vi.fn()
128+
129+
tool = new ApplyDiffTool()
130+
})
131+
132+
it("publishes through the guarded saveDirectly with edit kind", async () => {
133+
await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, {
134+
askApproval: mockAskApproval,
135+
handleError: mockHandleError,
136+
pushToolResult: mockPushToolResult,
137+
})
138+
139+
expect(mockSaveDirectly).toHaveBeenCalledWith(
140+
"src/thing.ts",
141+
"modified file content\n",
142+
false,
143+
true,
144+
1000,
145+
"edit",
146+
)
147+
expect(mockPushToolResult).toHaveBeenCalledWith("Saved file")
148+
expect(mockTask.didEditFile).toBe(true)
149+
expect(mockHandleError).not.toHaveBeenCalled()
150+
})
151+
152+
it("surfaces the unobserved edit remediation as a tool error", async () => {
153+
const guardError = new Error("File not read yet -- read the file, then retry.")
154+
mockSaveDirectly.mockRejectedValue(guardError)
155+
156+
await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, {
157+
askApproval: mockAskApproval,
158+
handleError: mockHandleError,
159+
pushToolResult: mockPushToolResult,
160+
})
161+
162+
expect(mockHandleError).toHaveBeenCalledWith("applying diff", guardError)
163+
expect(vi.mocked(mockTask.diffViewProvider.reset)).toHaveBeenCalled()
164+
expect(mockTask.didEditFile).toBe(false)
165+
expect(mockPushToolResult).not.toHaveBeenCalledWith("Saved file")
166+
})
167+
168+
it("passes the edit kind to the diff-view save path", async () => {
169+
// Without focus disruption the tool saves through the diff view, and the
170+
// kind it passes must carry its intent: a targeted edit, not the default
171+
// full-file replacement.
172+
// The settings double is rebuilt for this test so the focus-disruption
173+
// experiment is off and the tool takes the diff-view save path.
174+
mockTask.providerRef = {
175+
deref: () => ({
176+
getState: vi.fn().mockResolvedValue({
177+
diagnosticsEnabled: true,
178+
writeDelayMs: 1000,
179+
experiments: {},
180+
}),
181+
}),
182+
} as unknown as Task["providerRef"]
183+
184+
await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, {
185+
askApproval: mockAskApproval,
186+
handleError: mockHandleError,
187+
pushToolResult: mockPushToolResult,
188+
})
189+
190+
expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit")
191+
expect(mockSaveDirectly).not.toHaveBeenCalled()
192+
expect(mockPushToolResult).toHaveBeenCalledWith("Saved file")
193+
expect(mockHandleError).not.toHaveBeenCalled()
194+
})
195+
})

0 commit comments

Comments
 (0)