11# -*- coding: utf-8 -*-
22
33import json
4+ import os
45import typing
56from typing import Any , Dict , List , Optional , Tuple , Union
67
4041 AGENT_SYS_PROMPT ,
4142 CODE_REVIEW_PROMPT ,
4243 CODE_REVIEW_SYS_PROMPT ,
44+ RESOLVE_PROMPT ,
45+ RESOLVE_SYS_PROMPT ,
4346)
4447from qgitc .preferences import Preferences
4548from qgitc .submoduleexecutor import SubmoduleExecutor
@@ -115,7 +118,11 @@ def __init__(self, parent=None, embedded=False):
115118 # Code review diff collection (staged/local changes)
116119 self ._codeReviewExecutor : Optional [SubmoduleExecutor ] = None
117120 self ._codeReviewDiffs : List [str ] = []
118- self ._codeReviewScene : str = None
121+ self ._extraContext : str = None
122+
123+ # Sync helper flows can optionally auto-run a tightly-scoped set of
124+ # WRITE tools without user confirmation.
125+ self ._allowWriteTools : bool = False
119126
120127 self ._isInitialized = False
121128 QTimer .singleShot (100 , self ._onDelayInit )
@@ -136,10 +143,10 @@ def event(self, event: QEvent):
136143 return True
137144 if event .type () == CodeReviewSceneEvent .Type :
138145 if event .scene_line :
139- if not self ._codeReviewScene :
140- self ._codeReviewScene = event .scene_line
146+ if not self ._extraContext :
147+ self ._extraContext = event .scene_line
141148 else :
142- self ._codeReviewScene += "\n " + event .scene_line
149+ self ._extraContext += "\n " + event .scene_line
143150 return True
144151 return super ().event (event )
145152
@@ -281,7 +288,7 @@ def queryClose(self):
281288 self ._codeReviewExecutor .cancel ()
282289 self ._codeReviewExecutor = None
283290 self ._codeReviewDiffs .clear ()
284- self ._codeReviewScene = None
291+ self ._extraContext = None
285292
286293 def sizeHint (self ):
287294 if self ._embedded :
@@ -412,7 +419,7 @@ def _doRequest(self, prompt: str, chatMode: AiChatMode, sysPrompt: str = None, c
412419 titleSeed = (params .sys_prompt + "\n " +
413420 prompt ) if params .sys_prompt else prompt
414421
415- codeReviewContext : Optional [ str ] = None
422+ extraContext = self . _extraContext
416423
417424 if chatMode == AiChatMode .Agent :
418425 params .tools = AgentToolRegistry .openai_tools ()
@@ -430,9 +437,9 @@ def _doRequest(self, prompt: str, chatMode: AiChatMode, sysPrompt: str = None, c
430437 params .tool_choice = "auto"
431438 params .sys_prompt = sysPrompt or CODE_REVIEW_SYS_PROMPT
432439
433- scene = (self ._codeReviewScene or "" ).strip (
440+ scene = (self ._extraContext or "" ).strip (
434441 ) or "(no scene metadata)"
435- codeReviewContext = "CODE REVIEW SCENE (metadata)\n " + scene
442+ extraContext = "CODE REVIEW SCENE (metadata)\n " + scene
436443 params .prompt = CODE_REVIEW_PROMPT .format (
437444 diff = params .prompt ,
438445 language = ApplicationBase .instance ().uiLanguage ())
@@ -443,12 +450,12 @@ def _doRequest(self, prompt: str, chatMode: AiChatMode, sysPrompt: str = None, c
443450 contextText = provider .buildContextText (
444451 selectedIds ) if provider is not None else ""
445452
446- if chatMode == AiChatMode . CodeReview and codeReviewContext :
453+ if extraContext :
447454 merged = (contextText or "" ).strip ()
448455 if merged :
449- merged += "\n \n " + codeReviewContext
456+ merged += "\n \n " + extraContext
450457 else :
451- merged = codeReviewContext
458+ merged = extraContext
452459 contextText = merged
453460
454461 if contextText :
@@ -531,7 +538,13 @@ def _doMessageReady(self, model: AiModelBase, response: AiResponse, collapsed=Fa
531538 tool = AgentToolRegistry .tool_by_name (
532539 toolName ) if toolName else None
533540
534- if toolName and tool and tool .tool_type == ToolType .READ_ONLY :
541+ # Auto-run only READ_ONLY tools by default. For certain sync helper
542+ # flows we optionally auto-run *WRITE* tools, but never DANGEROUS.
543+ if toolName and tool and (
544+ tool .tool_type == ToolType .READ_ONLY or (
545+ self ._allowWriteTools and tool .tool_type == ToolType .WRITE
546+ )
547+ ):
535548 if autoGroupId is None :
536549 autoGroupId = self ._nextAutoGroupId
537550 self ._nextAutoGroupId += 1
@@ -935,7 +948,7 @@ def _appendRepoFileSummary(repoLabel: str, sha1: str, repoDiff: str):
935948 curHistory = self ._historyPanel .currentHistory ()
936949 if not curHistory or curHistory .messages :
937950 self ._createNewConversation ()
938- self ._codeReviewScene = scene
951+ self ._extraContext = scene
939952 self ._doRequest (diff , AiChatMode .CodeReview )
940953
941954 def _makeFileList (self , files : List [str ]) -> str :
@@ -957,7 +970,7 @@ def codeReviewForStagedFiles(self, submodules):
957970 """Start a code review for staged/local changes across submodules."""
958971 self ._ensureCodeReviewExecutor ()
959972 self ._codeReviewDiffs .clear ()
960- self ._codeReviewScene = "type: staged changes (index)\n "
973+ self ._extraContext = "type: staged changes (index)\n "
961974 self ._codeReviewExecutor .submit (submodules , self ._fetchStagedDiff )
962975
963976 def _ensureCodeReviewExecutor (self ):
@@ -1171,7 +1184,7 @@ def _loadMessagesFromHistory(self, messages: List[Dict], addToChatBot=True):
11711184 if role == AiRole .Assistant and isinstance (toolCalls , list ) and toolCalls :
11721185 toolCallResult , hasMoreMessages = self ._collectToolCallResult (
11731186 i + 1 , messages )
1174-
1187+
11751188 for tc in toolCalls :
11761189 if not isinstance (tc , dict ):
11771190 continue
@@ -1293,7 +1306,7 @@ def _clearCurrentChat(self):
12931306 self ._autoToolGroups .clear ()
12941307 self ._nextAutoGroupId = 1
12951308 self ._codeReviewDiffs .clear ()
1296- self ._codeReviewScene = None
1309+ self ._extraContext = None
12971310 self .messages .clear ()
12981311
12991312 def _setEmbeddedRecentListVisible (self , visible : bool ):
@@ -1345,3 +1358,101 @@ def _getToolIcon(toolType: int) -> str:
13451358 @property
13461359 def contextPanel (self ) -> AiChatContextPanel :
13471360 return self ._contextPanel
1361+
1362+ def resolveConflictSync (self , repoDir : str , sha1 : str , path : str , conflictText : str ) -> Tuple [bool , Optional [str ]]:
1363+ """Resolve a conflicted file given an excerpt of the working tree file.
1364+
1365+ Returns (ok, reason). On success, ok=True and reason=None.
1366+ """
1367+ self ._waitForInitialization ()
1368+ model = self .currentChatModel ()
1369+ if not model :
1370+ return False , "no_model"
1371+
1372+ context = (
1373+ f"sha1: { sha1 } \n "
1374+ f"conflicted_file: { path } "
1375+ )
1376+ self ._extraContext = context
1377+
1378+ prompt = RESOLVE_PROMPT .format (
1379+ conflict = conflictText ,
1380+ )
1381+
1382+ self ._allowWriteTools = True
1383+
1384+ loop = QEventLoop ()
1385+ done : Dict [str , object ] = {"ok" : False , "reason" : None }
1386+
1387+ timer = QTimer ()
1388+ timer .setSingleShot (True )
1389+ timer .start (5 * 60 * 1000 )
1390+
1391+ oldHistoryCount = len (model .history )
1392+
1393+ def _lastAssistantTextSince (startIndex : int ) -> str :
1394+ # Find the latest assistant message text after startIndex.
1395+ history = model .history [startIndex :]
1396+ for h in reversed (history ):
1397+ if h .role == AiRole .Assistant :
1398+ if h .message :
1399+ return h .message
1400+ return ""
1401+
1402+ def _finalizeIfIdle ():
1403+ # Timeout?
1404+ if timer .remainingTime () <= 0 :
1405+ done ["ok" ] = False
1406+ done ["reason" ] = "Assistant response timed out"
1407+ model .requestInterruption ()
1408+ loop .quit ()
1409+ return
1410+
1411+ # If the assistant explicitly reported failure, honor it.
1412+ response = _lastAssistantTextSince (oldHistoryCount )
1413+ if "QGITC_RESOLVE_FAILED:" in response :
1414+ parts = response .split ("QGITC_RESOLVE_FAILED:" , 1 )
1415+ reason = parts [1 ].strip () if len (parts ) > 1 else ""
1416+ done ["ok" ] = False
1417+ done ["reason" ] = reason or "Assistant reported failure"
1418+ loop .quit ()
1419+ return
1420+
1421+ if "QGITC_RESOLVE_OK" not in response :
1422+ return
1423+
1424+ # Verify the working tree file is conflict-marker-free.
1425+ try :
1426+ absPath = os .path .join (repoDir , path )
1427+ with open (absPath , "r" , encoding = "utf-8" , errors = "replace" ) as f :
1428+ merged = f .read ()
1429+ except Exception as e :
1430+ done ["ok" ] = False
1431+ done ["reason" ] = f"read_back_failed: { e } "
1432+ loop .quit ()
1433+ return
1434+
1435+ if "<<<<<<<" in merged or "=======" in merged or ">>>>>>>" in merged :
1436+ done ["ok" ] = False
1437+ done ["reason" ] = "conflict_markers_remain"
1438+ loop .quit ()
1439+ return
1440+
1441+ done ["ok" ] = True
1442+ done ["reason" ] = None
1443+ loop .quit ()
1444+
1445+ timer .timeout .connect (_finalizeIfIdle )
1446+
1447+ # Re-check completion whenever the model finishes a generation step or
1448+ # a tool finishes (tools may trigger another continue-only generation).
1449+ model .finished .connect (_finalizeIfIdle )
1450+
1451+ self ._doRequest (prompt , AiChatMode .Agent , RESOLVE_SYS_PROMPT )
1452+ loop .exec ()
1453+
1454+ timer .stop ()
1455+ model .finished .disconnect (_finalizeIfIdle )
1456+ self ._allowWriteTools = False
1457+
1458+ return bool (done .get ("ok" , False )), done .get ("reason" )
0 commit comments