@@ -11,7 +11,8 @@ import { serializeError } from "serialize-error"
11
11
import * as vscode from "vscode"
12
12
import { ApiHandler , SingleCompletionHandler , buildApiHandler } from "../api"
13
13
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"
15
16
import { findToolName , formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
16
17
import {
17
18
extractTextFromFile ,
@@ -93,12 +94,19 @@ export class Cline {
93
94
private consecutiveMistakeCountForApplyDiff : Map < string , number > = new Map ( )
94
95
private providerRef : WeakRef < ClineProvider >
95
96
private abort : boolean = false
96
- didFinishAborting = false
97
+ didFinishAbortingStream = false
97
98
abandoned = false
98
99
private diffViewProvider : DiffViewProvider
99
100
private lastApiRequestTime ?: number
101
+ isInitialized = false
102
+
103
+ // checkpoints
104
+ checkpointsEnabled : boolean = false
105
+ private checkpointService ?: CheckpointService
100
106
101
107
// streaming
108
+ isWaitingForFirstChunk = false
109
+ isStreaming = false
102
110
private currentStreamingContentIndex = 0
103
111
private assistantMessageContent : AssistantMessageContent [ ] = [ ]
104
112
private presentAssistantMessageLocked = false
@@ -114,6 +122,7 @@ export class Cline {
114
122
apiConfiguration : ApiConfiguration ,
115
123
customInstructions ?: string ,
116
124
enableDiff ?: boolean ,
125
+ enableCheckpoints ?: boolean ,
117
126
fuzzyMatchThreshold ?: number ,
118
127
task ?: string | undefined ,
119
128
images ?: string [ ] | undefined ,
@@ -134,6 +143,7 @@ export class Cline {
134
143
this . fuzzyMatchThreshold = fuzzyMatchThreshold ?? 1.0
135
144
this . providerRef = new WeakRef ( provider )
136
145
this . diffViewProvider = new DiffViewProvider ( cwd )
146
+ this . checkpointsEnabled = enableCheckpoints ?? false
137
147
138
148
if ( historyItem ) {
139
149
this . taskId = historyItem . id
@@ -438,6 +448,7 @@ export class Cline {
438
448
await this . providerRef . deref ( ) ?. postStateToWebview ( )
439
449
440
450
await this . say ( "text" , task , images )
451
+ this . isInitialized = true
441
452
442
453
let imageBlocks : Anthropic . ImageBlockParam [ ] = formatResponse . imageBlocks ( images )
443
454
await this . initiateTaskLoop ( [
@@ -477,12 +488,13 @@ export class Cline {
477
488
await this . overwriteClineMessages ( modifiedClineMessages )
478
489
this . clineMessages = await this . getSavedClineMessages ( )
479
490
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 ( )
486
498
487
499
const lastClineMessage = this . clineMessages
488
500
. slice ( )
@@ -506,6 +518,8 @@ export class Cline {
506
518
askType = "resume_task"
507
519
}
508
520
521
+ this . isInitialized = true
522
+
509
523
const { response, text, images } = await this . ask ( askType ) // calls poststatetowebview
510
524
let responseText : string | undefined
511
525
let responseImages : string [ ] | undefined
@@ -515,6 +529,11 @@ export class Cline {
515
529
responseImages = images
516
530
}
517
531
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
+
518
537
// 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
519
538
const conversationWithoutToolBlocks = existingApiConversationHistory . map ( ( message ) => {
520
539
if ( Array . isArray ( message . content ) ) {
@@ -706,11 +725,14 @@ export class Cline {
706
725
}
707
726
}
708
727
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.
711
730
this . terminalManager . disposeAll ( )
712
731
this . urlContentFetcher . closeBrowser ( )
713
732
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 ( )
714
736
}
715
737
716
738
// Tools
@@ -927,8 +949,10 @@ export class Cline {
927
949
928
950
try {
929
951
// awaiting first chunk to see if it will throw an error
952
+ this . isWaitingForFirstChunk = true
930
953
const firstChunk = await iterator . next ( )
931
954
yield firstChunk . value
955
+ this . isWaitingForFirstChunk = false
932
956
} catch ( error ) {
933
957
// 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.
934
958
if ( alwaysApproveResubmit ) {
@@ -1003,6 +1027,9 @@ export class Cline {
1003
1027
}
1004
1028
1005
1029
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
+
1006
1033
switch ( block . type ) {
1007
1034
case "text" : {
1008
1035
if ( this . didRejectTool || this . didAlreadyUseTool ) {
@@ -1134,6 +1161,10 @@ export class Cline {
1134
1161
}
1135
1162
// once a tool result has been collected, ignore all other tool uses since we should only ever present one tool result per message
1136
1163
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
1137
1168
}
1138
1169
1139
1170
const askApproval = async ( type : ClineAsk , partialMessage ?: string ) => {
@@ -2655,6 +2686,10 @@ export class Cline {
2655
2686
break
2656
2687
}
2657
2688
2689
+ if ( isCheckpointPossible ) {
2690
+ await this . checkpointSave ( )
2691
+ }
2692
+
2658
2693
/*
2659
2694
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.
2660
2695
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 {
2811
2846
await this . saveClineMessages ( )
2812
2847
2813
2848
// 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
2815
2850
}
2816
2851
2817
2852
// reset streaming state
@@ -3197,6 +3232,178 @@ export class Cline {
3197
3232
3198
3233
return `<environment_details>\n${ details . trim ( ) } \n</environment_details>`
3199
3234
}
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
+ }
3200
3407
}
3201
3408
3202
3409
function escapeRegExp ( string : string ) : string {
0 commit comments