Skip to content

Commit c49f685

Browse files
authored
Merge branch 'main' into create-logging-util
2 parents 41d18bd + 47bdab0 commit c49f685

18 files changed

+594
-52
lines changed

.gitignore

+3-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ docs/_site/
2323
# Dotenv
2424
.env.integration
2525

26+
#Local lint config
27+
.eslintrc.local.json
2628

2729
#Logging
28-
logs
30+
logs

package.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@
272272
"compile:integration": "tsc -p tsconfig.integration.json",
273273
"install:all": "npm install && cd webview-ui && npm install",
274274
"lint": "eslint src --ext ts && npm run lint --prefix webview-ui",
275+
"lint-fix": "eslint src --ext ts --fix && npm run lint-fix --prefix webview-ui",
275276
"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
276277
"pretest": "npm run compile && npm run compile:integration",
277278
"dev": "cd webview-ui && npm run dev",
@@ -283,7 +284,7 @@
283284
"publish": "npm run build && changeset publish && npm install --package-lock-only",
284285
"version-packages": "changeset version && npm install --package-lock-only",
285286
"vscode:prepublish": "npm run package",
286-
"vsix": "mkdir -p bin && npx vsce package --out bin",
287+
"vsix": "rimraf bin && mkdirp bin && npx vsce package --out bin",
287288
"watch": "npm-run-all -p watch:*",
288289
"watch:esbuild": "node esbuild.js --watch",
289290
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
@@ -350,6 +351,8 @@
350351
"@vscode/test-cli": "^0.0.9",
351352
"@vscode/test-electron": "^2.4.0",
352353
"esbuild": "^0.24.0",
354+
"mkdirp": "^3.0.1",
355+
"rimraf": "^6.0.1",
353356
"eslint": "^8.57.0",
354357
"husky": "^9.1.7",
355358
"jest": "^29.7.0",

src/core/Cline.ts

+218-11
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import { serializeError } from "serialize-error"
1111
import * as vscode from "vscode"
1212
import { ApiHandler, SingleCompletionHandler, buildApiHandler } from "../api"
1313
import { ApiStream } from "../api/transform/stream"
14-
import { DiffViewProvider } from "../integrations/editor/DiffViewProvider"
14+
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
15+
import { CheckpointService } from "../services/checkpoints/CheckpointService"
1516
import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
1617
import {
1718
extractTextFromFile,
@@ -93,12 +94,19 @@ export class Cline {
9394
private consecutiveMistakeCountForApplyDiff: Map<string, number> = new Map()
9495
private providerRef: WeakRef<ClineProvider>
9596
private abort: boolean = false
96-
didFinishAborting = false
97+
didFinishAbortingStream = false
9798
abandoned = false
9899
private diffViewProvider: DiffViewProvider
99100
private lastApiRequestTime?: number
101+
isInitialized = false
102+
103+
// checkpoints
104+
checkpointsEnabled: boolean = false
105+
private checkpointService?: CheckpointService
100106

101107
// streaming
108+
isWaitingForFirstChunk = false
109+
isStreaming = false
102110
private currentStreamingContentIndex = 0
103111
private assistantMessageContent: AssistantMessageContent[] = []
104112
private presentAssistantMessageLocked = false
@@ -114,6 +122,7 @@ export class Cline {
114122
apiConfiguration: ApiConfiguration,
115123
customInstructions?: string,
116124
enableDiff?: boolean,
125+
enableCheckpoints?: boolean,
117126
fuzzyMatchThreshold?: number,
118127
task?: string | undefined,
119128
images?: string[] | undefined,
@@ -134,6 +143,7 @@ export class Cline {
134143
this.fuzzyMatchThreshold = fuzzyMatchThreshold ?? 1.0
135144
this.providerRef = new WeakRef(provider)
136145
this.diffViewProvider = new DiffViewProvider(cwd)
146+
this.checkpointsEnabled = enableCheckpoints ?? false
137147

138148
if (historyItem) {
139149
this.taskId = historyItem.id
@@ -438,6 +448,7 @@ export class Cline {
438448
await this.providerRef.deref()?.postStateToWebview()
439449

440450
await this.say("text", task, images)
451+
this.isInitialized = true
441452

442453
let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
443454
await this.initiateTaskLoop([
@@ -477,12 +488,13 @@ export class Cline {
477488
await this.overwriteClineMessages(modifiedClineMessages)
478489
this.clineMessages = await this.getSavedClineMessages()
479490

480-
// need to make sure that the api conversation history can be resumed by the api, even if it goes out of sync with cline messages
481-
482-
let existingApiConversationHistory: Anthropic.Messages.MessageParam[] =
483-
await this.getSavedApiConversationHistory()
484-
485-
// Now present the cline messages to the user and ask if they want to resume
491+
// Now present the cline messages to the user and ask if they want to
492+
// resume (NOTE: we ran into a bug before where the
493+
// apiConversationHistory wouldn't be initialized when opening a old
494+
// task, and it was because we were waiting for resume).
495+
// This is important in case the user deletes messages without resuming
496+
// the task first.
497+
this.apiConversationHistory = await this.getSavedApiConversationHistory()
486498

487499
const lastClineMessage = this.clineMessages
488500
.slice()
@@ -506,6 +518,8 @@ export class Cline {
506518
askType = "resume_task"
507519
}
508520

521+
this.isInitialized = true
522+
509523
const { response, text, images } = await this.ask(askType) // calls poststatetowebview
510524
let responseText: string | undefined
511525
let responseImages: string[] | undefined
@@ -515,6 +529,11 @@ export class Cline {
515529
responseImages = images
516530
}
517531

532+
// Make sure that the api conversation history can be resumed by the API,
533+
// even if it goes out of sync with cline messages.
534+
let existingApiConversationHistory: Anthropic.Messages.MessageParam[] =
535+
await this.getSavedApiConversationHistory()
536+
518537
// v2.0 xml tags refactor caveat: since we don't use tools anymore, we need to replace all tool use blocks with a text block since the API disallows conversations with tool uses and no tool schema
519538
const conversationWithoutToolBlocks = existingApiConversationHistory.map((message) => {
520539
if (Array.isArray(message.content)) {
@@ -706,11 +725,14 @@ export class Cline {
706725
}
707726
}
708727

709-
abortTask() {
710-
this.abort = true // will stop any autonomously running promises
728+
async abortTask() {
729+
this.abort = true // Will stop any autonomously running promises.
711730
this.terminalManager.disposeAll()
712731
this.urlContentFetcher.closeBrowser()
713732
this.browserSession.closeBrowser()
733+
// Need to await for when we want to make sure directories/files are
734+
// reverted before re-starting the task from a checkpoint.
735+
await this.diffViewProvider.revertChanges()
714736
}
715737

716738
// Tools
@@ -927,8 +949,10 @@ export class Cline {
927949

928950
try {
929951
// awaiting first chunk to see if it will throw an error
952+
this.isWaitingForFirstChunk = true
930953
const firstChunk = await iterator.next()
931954
yield firstChunk.value
955+
this.isWaitingForFirstChunk = false
932956
} catch (error) {
933957
// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
934958
if (alwaysApproveResubmit) {
@@ -1003,6 +1027,9 @@ export class Cline {
10031027
}
10041028

10051029
const block = cloneDeep(this.assistantMessageContent[this.currentStreamingContentIndex]) // need to create copy bc while stream is updating the array, it could be updating the reference block properties too
1030+
1031+
let isCheckpointPossible = false
1032+
10061033
switch (block.type) {
10071034
case "text": {
10081035
if (this.didRejectTool || this.didAlreadyUseTool) {
@@ -1134,6 +1161,10 @@ export class Cline {
11341161
}
11351162
// once a tool result has been collected, ignore all other tool uses since we should only ever present one tool result per message
11361163
this.didAlreadyUseTool = true
1164+
1165+
// Flag a checkpoint as possible since we've used a tool
1166+
// which may have changed the file system.
1167+
isCheckpointPossible = true
11371168
}
11381169

11391170
const askApproval = async (type: ClineAsk, partialMessage?: string) => {
@@ -2655,6 +2686,10 @@ export class Cline {
26552686
break
26562687
}
26572688

2689+
if (isCheckpointPossible) {
2690+
await this.checkpointSave()
2691+
}
2692+
26582693
/*
26592694
Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present.
26602695
When you see the UI inactive during this, it means that a tool is breaking without presenting any UI. For example the write_to_file tool was breaking when relpath was undefined, and for invalid relpath it never presented UI.
@@ -2811,7 +2846,7 @@ export class Cline {
28112846
await this.saveClineMessages()
28122847

28132848
// signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature
2814-
this.didFinishAborting = true
2849+
this.didFinishAbortingStream = true
28152850
}
28162851

28172852
// reset streaming state
@@ -3197,6 +3232,178 @@ export class Cline {
31973232

31983233
return `<environment_details>\n${details.trim()}\n</environment_details>`
31993234
}
3235+
3236+
// Checkpoints
3237+
3238+
private async getCheckpointService() {
3239+
if (!this.checkpointService) {
3240+
this.checkpointService = await CheckpointService.create({
3241+
taskId: this.taskId,
3242+
baseDir: vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? "",
3243+
})
3244+
}
3245+
3246+
return this.checkpointService
3247+
}
3248+
3249+
public async checkpointDiff({
3250+
ts,
3251+
commitHash,
3252+
mode,
3253+
}: {
3254+
ts: number
3255+
commitHash: string
3256+
mode: "full" | "checkpoint"
3257+
}) {
3258+
if (!this.checkpointsEnabled) {
3259+
return
3260+
}
3261+
3262+
let previousCommitHash = undefined
3263+
3264+
if (mode === "checkpoint") {
3265+
const previousCheckpoint = this.clineMessages
3266+
.filter(({ say }) => say === "checkpoint_saved")
3267+
.sort((a, b) => b.ts - a.ts)
3268+
.find((message) => message.ts < ts)
3269+
3270+
previousCommitHash = previousCheckpoint?.text
3271+
}
3272+
3273+
try {
3274+
const service = await this.getCheckpointService()
3275+
const changes = await service.getDiff({ from: previousCommitHash, to: commitHash })
3276+
3277+
if (!changes?.length) {
3278+
vscode.window.showInformationMessage("No changes found.")
3279+
return
3280+
}
3281+
3282+
await vscode.commands.executeCommand(
3283+
"vscode.changes",
3284+
mode === "full" ? "Changes since task started" : "Changes since previous checkpoint",
3285+
changes.map((change) => [
3286+
vscode.Uri.file(change.paths.absolute),
3287+
vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${change.paths.relative}`).with({
3288+
query: Buffer.from(change.content.before ?? "").toString("base64"),
3289+
}),
3290+
vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${change.paths.relative}`).with({
3291+
query: Buffer.from(change.content.after ?? "").toString("base64"),
3292+
}),
3293+
]),
3294+
)
3295+
} catch (err) {
3296+
this.providerRef
3297+
.deref()
3298+
?.log(
3299+
`[checkpointDiff] Encountered unexpected error: $${err instanceof Error ? err.message : String(err)}`,
3300+
)
3301+
3302+
this.checkpointsEnabled = false
3303+
}
3304+
}
3305+
3306+
public async checkpointSave() {
3307+
if (!this.checkpointsEnabled) {
3308+
return
3309+
}
3310+
3311+
try {
3312+
const service = await this.getCheckpointService()
3313+
const commit = await service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`)
3314+
3315+
if (commit?.commit) {
3316+
await this.providerRef
3317+
.deref()
3318+
?.postMessageToWebview({ type: "currentCheckpointUpdated", text: service.currentCheckpoint })
3319+
3320+
await this.say("checkpoint_saved", commit.commit)
3321+
}
3322+
} catch (err) {
3323+
this.providerRef
3324+
.deref()
3325+
?.log(
3326+
`[checkpointSave] Encountered unexpected error: $${err instanceof Error ? err.message : String(err)}`,
3327+
)
3328+
3329+
this.checkpointsEnabled = false
3330+
}
3331+
}
3332+
3333+
public async checkpointRestore({
3334+
ts,
3335+
commitHash,
3336+
mode,
3337+
}: {
3338+
ts: number
3339+
commitHash: string
3340+
mode: "preview" | "restore"
3341+
}) {
3342+
if (!this.checkpointsEnabled) {
3343+
return
3344+
}
3345+
3346+
const index = this.clineMessages.findIndex((m) => m.ts === ts)
3347+
3348+
if (index === -1) {
3349+
return
3350+
}
3351+
3352+
try {
3353+
const service = await this.getCheckpointService()
3354+
await service.restoreCheckpoint(commitHash)
3355+
3356+
await this.providerRef
3357+
.deref()
3358+
?.postMessageToWebview({ type: "currentCheckpointUpdated", text: service.currentCheckpoint })
3359+
3360+
if (mode === "restore") {
3361+
await this.overwriteApiConversationHistory(
3362+
this.apiConversationHistory.filter((m) => !m.ts || m.ts < ts),
3363+
)
3364+
3365+
const deletedMessages = this.clineMessages.slice(index + 1)
3366+
3367+
const { totalTokensIn, totalTokensOut, totalCacheWrites, totalCacheReads, totalCost } = getApiMetrics(
3368+
combineApiRequests(combineCommandSequences(deletedMessages)),
3369+
)
3370+
3371+
await this.overwriteClineMessages(this.clineMessages.slice(0, index + 1))
3372+
3373+
// TODO: Verify that this is working as expected.
3374+
await this.say(
3375+
"api_req_deleted",
3376+
JSON.stringify({
3377+
tokensIn: totalTokensIn,
3378+
tokensOut: totalTokensOut,
3379+
cacheWrites: totalCacheWrites,
3380+
cacheReads: totalCacheReads,
3381+
cost: totalCost,
3382+
} satisfies ClineApiReqInfo),
3383+
)
3384+
}
3385+
3386+
// The task is already cancelled by the provider beforehand, but we
3387+
// need to re-init to get the updated messages.
3388+
//
3389+
// This was take from Cline's implementation of the checkpoints
3390+
// feature. The cline instance will hang if we don't cancel twice,
3391+
// so this is currently necessary, but it seems like a complicated
3392+
// and hacky solution to a problem that I don't fully understand.
3393+
// I'd like to revisit this in the future and try to improve the
3394+
// task flow and the communication between the webview and the
3395+
// Cline instance.
3396+
this.providerRef.deref()?.cancelTask()
3397+
} catch (err) {
3398+
this.providerRef
3399+
.deref()
3400+
?.log(
3401+
`[restoreCheckpoint] Encountered unexpected error: $${err instanceof Error ? err.message : String(err)}`,
3402+
)
3403+
3404+
this.checkpointsEnabled = false
3405+
}
3406+
}
32003407
}
32013408

32023409
function escapeRegExp(string: string): string {

0 commit comments

Comments
 (0)