@@ -11,7 +11,8 @@ import { serializeError } from "serialize-error"
1111import * as vscode from "vscode"
1212import { ApiHandler , SingleCompletionHandler , buildApiHandler } from "../api"
1313import { 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"
1516import { findToolName , formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
1617import {
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
32023409function escapeRegExp ( string : string ) : string {
0 commit comments