@@ -981,6 +981,18 @@ export class AIProviderService implements Disposable {
981
981
return result === 'cancelled' ? result : result != null ? { ...result } : undefined ;
982
982
}
983
983
984
+ /**
985
+ * Generates a rebase using AI to organize code changes into logical commits.
986
+ *
987
+ * This method includes automatic retry logic that validates the AI response and
988
+ * continues the conversation if the response has issues like:
989
+ * - Missing hunks that were in the original diff
990
+ * - Extra hunks that weren't in the original diff
991
+ * - Duplicate hunks used multiple times
992
+ *
993
+ * The method will retry up to 3 times, providing specific feedback to the AI
994
+ * about what was wrong with the previous response.
995
+ */
984
996
async generateRebase (
985
997
repo : Repository ,
986
998
baseRef : string ,
@@ -1030,6 +1042,121 @@ export class AIProviderService implements Disposable {
1030
1042
}
1031
1043
}
1032
1044
1045
+ const rq = await this . sendRebaseRequestWithRetry ( repo , baseRef , headRef , source , result , options ) ;
1046
+
1047
+ if ( rq === 'cancelled' ) return rq ;
1048
+
1049
+ if ( rq == null ) return undefined ;
1050
+
1051
+ return {
1052
+ ...rq ,
1053
+ ...result ,
1054
+ } ;
1055
+ }
1056
+
1057
+ private async sendRebaseRequestWithRetry (
1058
+ repo : Repository ,
1059
+ baseRef : string ,
1060
+ headRef : string ,
1061
+ source : Source ,
1062
+ result : Mutable < AIRebaseResult > ,
1063
+ options ?: {
1064
+ cancellation ?: CancellationToken ;
1065
+ context ?: string ;
1066
+ generating ?: Deferred < AIModel > ;
1067
+ progress ?: ProgressOptions ;
1068
+ generateCommits ?: boolean ;
1069
+ } ,
1070
+ ) : Promise < AIRequestResult | 'cancelled' | undefined > {
1071
+ let conversationMessages : AIChatMessage [ ] = [ ] ;
1072
+ let attempt = 0 ;
1073
+ const maxAttempts = 4 ;
1074
+
1075
+ // First attempt - setup diff and hunk map
1076
+ const firstAttemptResult = await this . sendRebaseFirstAttempt ( repo , baseRef , headRef , source , result , options ) ;
1077
+
1078
+ if ( firstAttemptResult === 'cancelled' || firstAttemptResult == null ) {
1079
+ return firstAttemptResult ;
1080
+ }
1081
+
1082
+ conversationMessages = firstAttemptResult . conversationMessages ;
1083
+ let rq = firstAttemptResult . response ;
1084
+
1085
+ while ( attempt < maxAttempts ) {
1086
+ const validationResult = this . validateRebaseResponse ( rq , result . hunkMap , options ) ;
1087
+ if ( validationResult . isValid ) {
1088
+ result . commits = validationResult . commits ;
1089
+ return rq ;
1090
+ }
1091
+
1092
+ Logger . warn (
1093
+ undefined ,
1094
+ 'AIProviderService' ,
1095
+ 'sendRebaseRequestWithRetry' ,
1096
+ `Validation failed on attempt ${ attempt + 1 } : ${ validationResult . errorMessage } ` ,
1097
+ ) ;
1098
+
1099
+ // If this was the last attempt, throw the error
1100
+ if ( attempt === maxAttempts - 1 ) {
1101
+ throw new Error ( validationResult . errorMessage ) ;
1102
+ }
1103
+
1104
+ // Prepare retry message for conversation
1105
+ conversationMessages . push (
1106
+ { role : 'assistant' , content : rq . content } ,
1107
+ { role : 'user' , content : validationResult . retryPrompt } ,
1108
+ ) ;
1109
+
1110
+ attempt ++ ;
1111
+
1112
+ // Send retry request
1113
+ const currentAttempt = attempt ;
1114
+ const retryResult = await this . sendRequest (
1115
+ 'generate-rebase' ,
1116
+ async ( ) => Promise . resolve ( conversationMessages ) ,
1117
+ m =>
1118
+ `Generating ${ options ?. generateCommits ? 'commits' : 'rebase' } with ${ m . name } ... (attempt ${
1119
+ currentAttempt + 1
1120
+ } )`,
1121
+ source ,
1122
+ m => ( {
1123
+ key : 'ai/generate' ,
1124
+ data : {
1125
+ type : 'rebase' ,
1126
+ 'model.id' : m . id ,
1127
+ 'model.provider.id' : m . provider . id ,
1128
+ 'model.provider.name' : m . provider . name ,
1129
+ 'retry.count' : currentAttempt ,
1130
+ } ,
1131
+ } ) ,
1132
+ options ,
1133
+ ) ;
1134
+
1135
+ if ( retryResult === 'cancelled' || retryResult == null ) {
1136
+ return retryResult ;
1137
+ }
1138
+
1139
+ rq = retryResult ;
1140
+ }
1141
+
1142
+ return undefined ;
1143
+ }
1144
+
1145
+ private async sendRebaseFirstAttempt (
1146
+ repo : Repository ,
1147
+ baseRef : string ,
1148
+ headRef : string ,
1149
+ source : Source ,
1150
+ result : Mutable < AIRebaseResult > ,
1151
+ options ?: {
1152
+ cancellation ?: CancellationToken ;
1153
+ context ?: string ;
1154
+ generating ?: Deferred < AIModel > ;
1155
+ progress ?: ProgressOptions ;
1156
+ generateCommits ?: boolean ;
1157
+ } ,
1158
+ ) : Promise < { response : AIRequestResult ; conversationMessages : AIChatMessage [ ] } | 'cancelled' | undefined > {
1159
+ let storedPrompt = '' ;
1033
1160
const rq = await this . sendRequest (
1034
1161
'generate-rebase' ,
1035
1162
async ( model , reporting , cancellation , maxInputTokens , retries ) => {
@@ -1086,6 +1213,9 @@ export class AIProviderService implements Disposable {
1086
1213
) ;
1087
1214
if ( cancellation . isCancellationRequested ) throw new CancellationError ( ) ;
1088
1215
1216
+ // Store the prompt for later use in conversation messages
1217
+ storedPrompt = prompt ;
1218
+
1089
1219
const messages : AIChatMessage [ ] = [ { role : 'user' , content : prompt } ] ;
1090
1220
return messages ;
1091
1221
} ,
@@ -1108,47 +1238,141 @@ export class AIProviderService implements Disposable {
1108
1238
1109
1239
if ( rq == null ) return undefined ;
1110
1240
1241
+ return {
1242
+ response : rq ,
1243
+ conversationMessages : [ { role : 'user' , content : storedPrompt } ] ,
1244
+ } ;
1245
+ }
1246
+
1247
+ private validateRebaseResponse (
1248
+ rq : AIRequestResult ,
1249
+ inputHunkMap : { index : number ; hunkHeader : string } [ ] ,
1250
+ options ?: {
1251
+ generateCommits ?: boolean ;
1252
+ } ,
1253
+ ) :
1254
+ | { isValid : false ; errorMessage : string ; retryPrompt : string }
1255
+ | { isValid : true ; commits : AIRebaseResult [ 'commits' ] } {
1256
+ // if it is wrapped in markdown, we need to strip it
1257
+ const content = rq . content . replace ( / ^ \s * ` ` ` j s o n \s * / , '' ) . replace ( / \s * ` ` ` $ / , '' ) ;
1258
+
1259
+ let commits : AIRebaseResult [ 'commits' ] ;
1111
1260
try {
1112
- // if it is wrapped in markdown, we need to strip it
1113
- const content = rq . content . replace ( / ^ \s * ` ` ` j s o n \s * / , '' ) . replace ( / \s * ` ` ` $ / , '' ) ;
1114
1261
// Parse the JSON content from the result
1115
- result . commits = JSON . parse ( content ) as AIRebaseResult [ 'commits' ] ;
1262
+ commits = JSON . parse ( content ) as AIRebaseResult [ 'commits' ] ;
1263
+ } catch {
1264
+ const errorMessage = `Unable to parse ${ options ?. generateCommits ? 'commits' : 'rebase' } result` ;
1265
+ const retryPrompt = dedent ( `
1266
+ Your previous response could not be parsed as valid JSON. Please ensure your response is a valid JSON array of commits with the correct structure.
1267
+
1268
+ Here was your previous response:
1269
+ ${ rq . content }
1116
1270
1117
- const inputHunkIndices = result . hunkMap . map ( h => h . index ) ;
1118
- const outputHunkIndices = new Set ( result . commits . flatMap ( c => c . hunks . map ( h => h . hunk ) ) ) ;
1271
+ Please provide a valid JSON array of commits following this structure:
1272
+ [
1273
+ {
1274
+ "message": "commit message",
1275
+ "explanation": "detailed explanation",
1276
+ "hunks": [{"hunk": 1}, {"hunk": 2}]
1277
+ }
1278
+ ]
1279
+ ` ) ;
1280
+
1281
+ return {
1282
+ isValid : false ,
1283
+ errorMessage : errorMessage ,
1284
+ retryPrompt : retryPrompt ,
1285
+ } ;
1286
+ }
1119
1287
1120
- // Find any missing or extra hunks
1288
+ // Validate the structure and hunk assignments
1289
+ try {
1290
+ const inputHunkIndices = inputHunkMap . map ( h => h . index ) ;
1291
+ const allOutputHunks = commits . flatMap ( c => c . hunks . map ( h => h . hunk ) ) ;
1292
+ const outputHunkIndices = new Map ( allOutputHunks . map ( ( hunk , index ) => [ hunk , index ] ) ) ;
1121
1293
const missingHunks = inputHunkIndices . filter ( i => ! outputHunkIndices . has ( i ) ) ;
1122
- const extraHunks = [ ...outputHunkIndices ] . filter ( i => ! inputHunkIndices . includes ( i ) ) ;
1123
- if ( missingHunks . length > 0 || extraHunks . length > 0 ) {
1124
- let hunksMessage = '' ;
1294
+
1295
+ if ( missingHunks . length > 0 || allOutputHunks . length > inputHunkIndices . length ) {
1296
+ const errorParts : string [ ] = [ ] ;
1297
+ const retryParts : string [ ] = [ ] ;
1298
+
1125
1299
if ( missingHunks . length > 0 ) {
1126
1300
const pluralize = missingHunks . length > 1 ? 's' : '' ;
1127
- hunksMessage += ` ${ missingHunks . length } missing hunk${ pluralize } .` ;
1301
+ errorParts . push ( `${ missingHunks . length } missing hunk${ pluralize } ` ) ;
1302
+ retryParts . push ( `You missed hunk${ pluralize } ${ missingHunks . join ( ', ' ) } in your response` ) ;
1128
1303
}
1304
+ const extraHunks = [ ...outputHunkIndices . keys ( ) ] . filter ( i => ! inputHunkIndices . includes ( i ) ) ;
1129
1305
if ( extraHunks . length > 0 ) {
1130
1306
const pluralize = extraHunks . length > 1 ? 's' : '' ;
1131
- hunksMessage += ` ${ extraHunks . length } extra hunk${ pluralize } .` ;
1307
+ errorParts . push ( `${ extraHunks . length } extra hunk${ pluralize } ` ) ;
1308
+ retryParts . push (
1309
+ `You included hunk${ pluralize } ${ extraHunks . join ( ', ' ) } which ${
1310
+ extraHunks . length > 1 ? 'were' : 'was'
1311
+ } not in the original diff`,
1312
+ ) ;
1313
+ }
1314
+ const duplicateHunks = allOutputHunks . filter ( ( hunk , index ) => outputHunkIndices . get ( hunk ) ! !== index ) ;
1315
+ const uniqueDuplicates = [ ...new Set ( duplicateHunks ) ] ;
1316
+ if ( uniqueDuplicates . length > 0 ) {
1317
+ const pluralize = uniqueDuplicates . length > 1 ? 's' : '' ;
1318
+ errorParts . push ( `${ uniqueDuplicates . length } duplicate hunk${ pluralize } ` ) ;
1319
+ retryParts . push ( `You used hunk${ pluralize } ${ uniqueDuplicates . join ( ', ' ) } multiple times` ) ;
1132
1320
}
1133
1321
1134
- throw new Error (
1135
- `Invalid response in generating ${
1136
- options ?. generateCommits ? 'commits' : 'rebase'
1137
- } result.${ hunksMessage } Try again or select a different AI model.`,
1138
- ) ;
1139
- }
1140
- } catch ( ex ) {
1141
- debugger ;
1142
- if ( ex ?. message ?. includes ( 'Invalid response in generating' ) ) {
1143
- throw ex ;
1322
+ const errorMessage = `Invalid response in generating ${
1323
+ options ?. generateCommits ? 'commits' : 'rebase'
1324
+ } result. ${ errorParts . join ( ', ' ) } .`;
1325
+
1326
+ const retryPrompt = dedent ( `
1327
+ Your previous response had issues: ${ retryParts . join ( ', ' ) } .
1328
+
1329
+ Please provide a corrected JSON response that:
1330
+ 1. Includes ALL hunks from 1 to ${ Math . max ( ...inputHunkIndices ) } exactly once
1331
+ 2. Does not include any hunk numbers outside this range
1332
+ 3. Does not use any hunk more than once
1333
+
1334
+ Here was your previous response:
1335
+ ${ rq . content }
1336
+
1337
+ Please provide the corrected JSON array of commits:
1338
+ ` ) ;
1339
+
1340
+ return {
1341
+ isValid : false ,
1342
+ errorMessage : errorMessage ,
1343
+ retryPrompt : retryPrompt ,
1344
+ } ;
1144
1345
}
1145
- throw new Error ( `Unable to parse ${ options ?. generateCommits ? 'commits' : 'rebase' } result` ) ;
1146
- }
1147
1346
1148
- return {
1149
- ...rq ,
1150
- ...result ,
1151
- } ;
1347
+ // If validation passes, return the commits
1348
+ return { isValid : true , commits : commits } ;
1349
+ } catch {
1350
+ // Handle any errors during hunk validation (e.g., malformed commit structure)
1351
+ const errorMessage = `Invalid commit structure in ${
1352
+ options ?. generateCommits ? 'commits' : 'rebase'
1353
+ } result`;
1354
+ const retryPrompt = dedent ( `
1355
+ Your previous response has an invalid commit structure. Each commit must have "message", "explanation", and "hunks" properties, where "hunks" is an array of objects with "hunk" numbers.
1356
+
1357
+ Here was your previous response:
1358
+ ${ rq . content }
1359
+
1360
+ Please provide a valid JSON array of commits following this structure:
1361
+ [
1362
+ {
1363
+ "message": "commit message",
1364
+ "explanation": "detailed explanation",
1365
+ "hunks": [{"hunk": 1}, {"hunk": 2}]
1366
+ }
1367
+ ]
1368
+ ` ) ;
1369
+
1370
+ return {
1371
+ isValid : false ,
1372
+ errorMessage : errorMessage ,
1373
+ retryPrompt : retryPrompt ,
1374
+ } ;
1375
+ }
1152
1376
}
1153
1377
1154
1378
private async sendRequest < T extends AIActionType > (
@@ -1654,22 +1878,32 @@ export class AIProviderService implements Disposable {
1654
1878
const alreadyCompleted = this . container . storage . get ( `gk:promo:${ userId } :ai:allAccess:dismissed` , false ) ;
1655
1879
if ( notificationShown || alreadyCompleted ) return ;
1656
1880
1657
- const hasAdvancedOrHigher = subscription . plan &&
1881
+ const hasAdvancedOrHigher =
1882
+ subscription . plan &&
1658
1883
( compareSubscriptionPlans ( subscription . plan . actual . id , 'advanced' ) >= 0 ||
1659
- compareSubscriptionPlans ( subscription . plan . effective . id , 'advanced' ) >= 0 ) ;
1884
+ compareSubscriptionPlans ( subscription . plan . effective . id , 'advanced' ) >= 0 ) ;
1660
1885
1661
1886
let body = 'All Access Week - now until July 11th!' ;
1662
- const detail = hasAdvancedOrHigher ? 'Opt in now to get unlimited GitKraken AI until July 11th!' : 'Opt in now to try all Advanced GitLens features with unlimited GitKraken AI for FREE until July 11th!' ;
1887
+ const detail = hasAdvancedOrHigher
1888
+ ? 'Opt in now to get unlimited GitKraken AI until July 11th!'
1889
+ : 'Opt in now to try all Advanced GitLens features with unlimited GitKraken AI for FREE until July 11th!' ;
1663
1890
1664
1891
if ( ! usingGkProvider ) {
1665
1892
body += ` ${ detail } ` ;
1666
1893
}
1667
1894
1668
- const optInButton : MessageItem = usingGkProvider ? { title : 'Opt in for Unlimited AI' } : { title : 'Opt in and Switch to GitKraken AI' } ;
1895
+ const optInButton : MessageItem = usingGkProvider
1896
+ ? { title : 'Opt in for Unlimited AI' }
1897
+ : { title : 'Opt in and Switch to GitKraken AI' } ;
1669
1898
const dismissButton : MessageItem = { title : 'No, Thanks' , isCloseAffordance : true } ;
1670
1899
1671
1900
// Show the notification
1672
- const result = await window . showInformationMessage ( body , { modal : usingGkProvider , detail : detail } , optInButton , dismissButton ) ;
1901
+ const result = await window . showInformationMessage (
1902
+ body ,
1903
+ { modal : usingGkProvider , detail : detail } ,
1904
+ optInButton ,
1905
+ dismissButton ,
1906
+ ) ;
1673
1907
1674
1908
// Mark notification as shown regardless of user action
1675
1909
void this . container . storage . store ( `gk:promo:${ userId } :ai:allAccess:notified` , true ) ;
@@ -1692,7 +1926,10 @@ export class AIProviderService implements Disposable {
1692
1926
await configuration . updateEffective ( 'ai.model' , 'gitkraken' ) ;
1693
1927
await configuration . updateEffective ( `ai.gitkraken.model` , defaultModel . id ) ;
1694
1928
} else {
1695
- await configuration . updateEffective ( 'ai.model' , `gitkraken:${ defaultModel . id } ` as SupportedAIModels ) ;
1929
+ await configuration . updateEffective (
1930
+ 'ai.model' ,
1931
+ `gitkraken:${ defaultModel . id } ` as SupportedAIModels ,
1932
+ ) ;
1696
1933
}
1697
1934
1698
1935
this . _onDidChangeModel . fire ( { model : defaultModel } ) ;
@@ -1701,8 +1938,6 @@ export class AIProviderService implements Disposable {
1701
1938
}
1702
1939
}
1703
1940
1704
-
1705
-
1706
1941
async function showConfirmAIProviderToS ( storage : Storage ) : Promise < boolean > {
1707
1942
const confirmed = storage . get ( `confirm:ai:tos` , false ) || storage . getWorkspace ( `confirm:ai:tos` , false ) ;
1708
1943
if ( confirmed ) return true ;
0 commit comments