Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 64 additions & 5 deletions src/core/tools/ApplyPatchTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getReadablePath } from "../../utils/path"
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
import { Task } from "../task/Task"
import { checkpointSave } from "../checkpoints"
import { checkAutoApproval } from "../auto-approval"
import { formatResponse } from "../prompts/responses"
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
import { fileExistsAtPath } from "../../utils/fs"
Expand Down Expand Up @@ -159,6 +160,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> {
} else if (change.type === "delete") {
// Delete file
const deleteResult = await this.handleDeleteFile(
change,
absolutePath,
relPath,
task,
Expand Down Expand Up @@ -210,18 +212,31 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> {
// the patch (the simplest correct design for multi-file patches),
// all referencing the single checkpoint above. A no-op update
// contributes no entry because nothing was written. `movePath`,
// when present, is the file's final location. diffStats is
// omitted: the per-file approval diffs are computed inside the
// handlers and are not retained after the patch completes.
void checkpointSave(
// when present, is the file's final location. B3a: the per-file
// approval diff/stats and auto-approval state, retained by the
// handlers, feed the per-step change card.
// Awaited: a later write must not interleave with this patch's
// staging/commit/journal/change-card work. checkpointSave never
// rejects (service call wrapped in try/catch upstream).
await checkpointSave(
task,
false,
true,
successfulChanges.map((change) => ({
path: change.movePath ?? change.path,
operation: change.type === "add" ? "create" : change.type,
...(change.diffStats
? {
diffStats: {
additions: change.diffStats.added,
deletions: change.diffStats.removed,
},
}
: {}),
...(change.diff ? { diff: change.diff } : {}),
...(change.autoApproved ? { autoApproved: true } : {}),
})),
).catch(() => {})
)
}
}
} catch (error) {
Expand Down Expand Up @@ -288,6 +303,21 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> {
diffStats,
} satisfies ClineSayTool)

// B3a: retain the approval diff/stats and auto-approval state so the
// post-loop checkpoint hook can build the per-step change card.
change.diff = sanitizedDiff
change.diffStats = diffStats
change.autoApproved =
(
await checkAutoApproval({
state,
cwd: task.cwd,
ask: "tool",
text: completeMessage,
isProtected: isWriteProtected,
})
).decision === "approve"

// Show diff view if focus disruption prevention is disabled
if (!isPreventFocusDisruptionEnabled) {
await task.diffViewProvider.open(relPath)
Expand Down Expand Up @@ -326,6 +356,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> {
}

private async handleDeleteFile(
change: ApplyPatchFileChange,
absolutePath: string,
relPath: string,
task: Task,
Expand Down Expand Up @@ -361,6 +392,19 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> {
isProtected: isWriteProtected,
} satisfies ClineSayTool)

// B3a: auto-approval state feeds the per-step change card (deletes have
// no diff to thread).
change.autoApproved =
(
await checkAutoApproval({
state: await task.providerRef.deref()?.getState(),
cwd: task.cwd,
ask: "tool",
text: completeMessage,
isProtected: isWriteProtected,
})
).decision === "approve"

const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected)

if (!didApprove) {
Expand Down Expand Up @@ -460,6 +504,21 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> {
diffStats,
} satisfies ClineSayTool)

// B3a: retain the approval diff/stats and auto-approval state so the
// post-loop checkpoint hook can build the per-step change card.
change.diff = sanitizedDiff
change.diffStats = diffStats
change.autoApproved =
(
await checkAutoApproval({
state,
cwd: task.cwd,
ask: "tool",
text: completeMessage,
isProtected: isWriteProtected,
})
).decision === "approve"

// Show diff view if focus disruption prevention is disabled
if (!isPreventFocusDisruptionEnabled) {
await task.diffViewProvider.open(relPath)
Expand Down
24 changes: 21 additions & 3 deletions src/core/tools/EditFileTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { fileExistsAtPath } from "../../utils/fs"
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats"
import { checkpointSave } from "../../core/checkpoints"
import { checkAutoApproval } from "../auto-approval"
import type { ToolUse } from "../../shared/tools"

import { BaseTool, ToolCallbacks } from "./BaseTool"
Expand Down Expand Up @@ -468,12 +469,29 @@ export class EditFileTool extends BaseTool<"edit_file"> {
if (perWriteCheckpoints) {
// B2: the change-journal entry for this edit is appended inside
// checkpointSave (the hook stays a single call site), keyed by the
// checkpoint commit that call produces.
void checkpointSave(task, false, true, {
// checkpoint commit that call produces. B3a threads the approval
// diff (for the change card) and whether the step was auto-
// approved (auto-approved steps always get the compact card).
const autoApproved =
(
await checkAutoApproval({
state,
cwd: task.cwd,
ask: "tool",
text: completeMessage,
isProtected: isWriteProtected,
})
).decision === "approve"
// Awaited: a later tool block must not interleave with this edit's
// staging/commit/journal/change-card work. checkpointSave never
// rejects (service call wrapped in try/catch upstream).
await checkpointSave(task, false, true, {
path: relPath,
operation: isNewFile ? "create" : "update",
diffStats: diffStats ? { additions: diffStats.added, deletions: diffStats.removed } : undefined,
}).catch(() => {})
...(sanitizedDiff ? { diff: sanitizedDiff } : {}),
...(autoApproved ? { autoApproved: true } : {}),
})
}

await task.diffViewProvider.reset()
Expand Down
31 changes: 31 additions & 0 deletions src/core/tools/EditTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
import { fileExistsAtPath } from "../../utils/fs"
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats"
import { checkpointSave } from "../../core/checkpoints"
import { checkAutoApproval } from "../auto-approval"
import type { ToolUse } from "../../shared/tools"

import { BaseTool, ToolCallbacks } from "./BaseTool"
Expand Down Expand Up @@ -167,6 +169,7 @@ export class EditTool extends BaseTool<"edit"> {
const state = await provider?.getState()
const diagnosticsEnabled = state?.diagnosticsEnabled ?? true
const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS
const perWriteCheckpoints = state?.perWriteCheckpoints ?? true
const isPreventFocusDisruptionEnabled = experiments.isEnabled(
state?.experiments ?? {},
EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION,
Expand Down Expand Up @@ -229,6 +232,34 @@ export class EditTool extends BaseTool<"edit"> {
const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false)
pushToolResult(message)

if (perWriteCheckpoints) {
// B2: the change-journal entry for this edit is appended inside
// checkpointSave (the hook stays a single call site), keyed by the
// checkpoint commit that call produces. B3a threads the approval
// diff (for the change card) and whether the step was auto-
// approved (auto-approved steps always get the compact card).
const autoApproved =
(
await checkAutoApproval({
state,
cwd: task.cwd,
ask: "tool",
text: completeMessage,
isProtected: isWriteProtected,
})
).decision === "approve"
// Awaited: a later tool block must not interleave with this edit's
// staging/commit/journal/change-card work. checkpointSave never
// rejects (service call wrapped in try/catch upstream).
await checkpointSave(task, false, true, {
path: relPath,
operation: "update",
diffStats: diffStats ? { additions: diffStats.added, deletions: diffStats.removed } : undefined,
...(sanitizedDiff ? { diff: sanitizedDiff } : {}),
...(autoApproved ? { autoApproved: true } : {}),
})
}

await task.diffViewProvider.reset()
this.resetPartialState()

Expand Down
32 changes: 32 additions & 0 deletions src/core/tools/SearchReplaceTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
import { fileExistsAtPath } from "../../utils/fs"
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats"
import { checkpointSave } from "../../core/checkpoints"
import { checkAutoApproval } from "../auto-approval"
import type { ToolUse } from "../../shared/tools"

import { BaseTool, ToolCallbacks } from "./BaseTool"
Expand Down Expand Up @@ -163,6 +165,7 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> {
const state = await provider?.getState()
const diagnosticsEnabled = state?.diagnosticsEnabled ?? true
const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS
const perWriteCheckpoints = state?.perWriteCheckpoints ?? true
const isPreventFocusDisruptionEnabled = experiments.isEnabled(
state?.experiments ?? {},
EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION,
Expand Down Expand Up @@ -225,6 +228,35 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> {
const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false)
pushToolResult(message)

if (perWriteCheckpoints) {
// B2: the change-journal entry for this search-and-replace is appended
// inside checkpointSave (the hook stays a single call site), keyed
// by the checkpoint commit that call produces. B3a threads the
// approval diff (for the change card) and whether the step was auto-
// approved (auto-approved steps always get the compact card).
const autoApproved =
(
await checkAutoApproval({
state,
cwd: task.cwd,
ask: "tool",
text: completeMessage,
isProtected: isWriteProtected,
})
).decision === "approve"
// Awaited: a later tool block must not interleave with this
// search-and-replace's staging/commit/journal/change-card work.
// checkpointSave never rejects (service call wrapped in try/catch
// upstream).
await checkpointSave(task, false, true, {
path: relPath,
operation: "update",
diffStats: diffStats ? { additions: diffStats.added, deletions: diffStats.removed } : undefined,
...(sanitizedDiff ? { diff: sanitizedDiff } : {}),
...(autoApproved ? { autoApproved: true } : {}),
})
}

await task.diffViewProvider.reset()
this.resetPartialState()

Expand Down
31 changes: 27 additions & 4 deletions src/core/tools/WriteToFileTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { unescapeHtmlEntities } from "../../utils/text-normalization"
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff, type DiffStats } from "../diff/stats"
import { checkpointSave } from "../checkpoints"
import { checkAutoApproval } from "../auto-approval"
import type { ToolUse } from "../../shared/tools"

import { BaseTool, ToolCallbacks } from "./BaseTool"
Expand Down Expand Up @@ -111,8 +112,14 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
)

// B2: the approval-diff stats for the write, shared by both the
// approval message and the change-journal entry below.
// approval message and the change-journal entry below. B3a also
// reuses the sanitized unified diff itself for the per-step change
// card (never recomputed).
let approvalDiffStats: DiffStats | null = null
// Stryker disable next-line StringLiteral : pre-branch placeholder, both branches assign before first use (unobservable initializer)
let approvalDiff = ""
// Stryker disable next-line StringLiteral : pre-branch placeholder, both branches assign before first use (unobservable initializer)
let completeMessage = ""

if (isPreventFocusDisruptionEnabled) {
task.diffViewProvider.editType = fileExists ? "modify" : "create"
Expand All @@ -128,7 +135,8 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
: convertNewFileToUnifiedDiff(newContent, relPath)
unified = sanitizeUnifiedDiff(unified)
approvalDiffStats = computeDiffStats(unified)
const completeMessage = JSON.stringify({
approvalDiff = unified
completeMessage = JSON.stringify({
...sharedMessageProps,
content: unified,
diffStats: approvalDiffStats || undefined,
Expand Down Expand Up @@ -161,7 +169,8 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
: convertNewFileToUnifiedDiff(newContent, relPath)
unified = sanitizeUnifiedDiff(unified)
approvalDiffStats = computeDiffStats(unified)
const completeMessage = JSON.stringify({
approvalDiff = unified
completeMessage = JSON.stringify({
...sharedMessageProps,
content: unified,
diffStats: approvalDiffStats || undefined,
Expand Down Expand Up @@ -192,13 +201,27 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
// checkpointSave (the hook stays a single call site), keyed by the
// checkpoint commit that call produces. Await so the checkpoint
// (staging + commit) finishes before the next queued write starts;
// otherwise two writes can collapse into one commit.
// otherwise two writes can collapse into one commit. B3a threads
// the approval diff (for the change card) and whether the step was
// auto-approved (auto-approved steps always get the compact card).
const autoApproved =
(
await checkAutoApproval({
state,
cwd: task.cwd,
ask: "tool",
text: completeMessage,
isProtected: isWriteProtected,
})
).decision === "approve"
await checkpointSave(task, false, true, {
path: relPath,
operation: fileExists ? "update" : "create",
diffStats: approvalDiffStats
? { additions: approvalDiffStats.added, deletions: approvalDiffStats.removed }
: undefined,
...(approvalDiff ? { diff: approvalDiff } : {}),
...(autoApproved ? { autoApproved: true } : {}),
}).catch(() => {})
}

Expand Down
Loading
Loading