From bfaef68e9a10bc952689687291ce4669d706b462 Mon Sep 17 00:00:00 2001 From: Joshua Villalta <93329405+vgoat21@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:07:04 +0100 Subject: [PATCH] feat(PLU-303): ai decomp progress logs --- reai_toolkit/app/components/tabs/chat_tab.py | 69 +++++++++++++++---- .../app/coordinators/ai_decomp_coordinator.py | 14 +++- .../app/coordinators/ai_decomp_render.py | 42 +++++++++++ .../services/ai_decomp/ai_decomp_service.py | 14 +++- reai_toolkit/hooks/reactive.py | 4 +- tests/unit/ai_decomp/test_render.py | 38 ++++++++++ 6 files changed, 164 insertions(+), 17 deletions(-) diff --git a/reai_toolkit/app/components/tabs/chat_tab.py b/reai_toolkit/app/components/tabs/chat_tab.py index 7b0cc34..814de6d 100644 --- a/reai_toolkit/app/components/tabs/chat_tab.py +++ b/reai_toolkit/app/components/tabs/chat_tab.py @@ -178,6 +178,48 @@ def keyPressEvent(self, event) -> None: super().keyPressEvent(event) +class _PanelBridge(QtCore.QObject): + def __init__(self, panel: "ChatPanel") -> None: + super().__init__() + self._panel = panel + + @Slot() + def history(self) -> None: + self._panel._on_history_clicked() + + @Slot() + def new_chat(self) -> None: + self._panel._on_new_chat_clicked() + + @Slot() + def approve(self) -> None: + self._panel._on_confirm_clicked(True) + + @Slot() + def reject(self) -> None: + self._panel._on_confirm_clicked(False) + + @Slot() + def send(self) -> None: + self._panel._on_send_clicked() + + @Slot() + def stop(self) -> None: + self._panel._on_stop_clicked() + + @Slot() + def flush(self) -> None: + self._panel._flush_render() + + @Slot(QtCore.QUrl) + def anchor(self, url) -> None: + self._panel._on_anchor_clicked(url) + + @Slot(QtWidgets.QListWidgetItem) + def history_item(self, item) -> None: + self._panel._on_history_item(item) + + class ChatPanel(kw.PluginForm): TITLE = "RevEng.AI — Agent Chat" @@ -208,6 +250,7 @@ def __init__(self, on_closed: Optional[Callable[[], None]] = None) -> None: self._send_btn: Optional[QtWidgets.QPushButton] = None self._stop_btn: Optional[QtWidgets.QPushButton] = None + self._bridge: Optional[_PanelBridge] = None self._relay: Optional[_StreamRelay] = None self._thread: Optional[QtCore.QThread] = None self._worker: Optional[ChatStreamWorker] = None @@ -266,7 +309,6 @@ def OnCreate(self, form) -> None: self._transcript = QtWidgets.QTextBrowser() self._transcript.setOpenLinks(False) self._transcript.setOpenExternalLinks(False) - self._transcript.anchorClicked.connect(self._on_anchor_clicked) root.addWidget(self._transcript, 1) self._confirm_bar = QtWidgets.QWidget() @@ -296,20 +338,9 @@ def OnCreate(self, form) -> None: input_row.addLayout(btn_col) root.addLayout(input_row) - history_btn.clicked.connect(self._on_history_clicked) - new_btn.clicked.connect(self._on_new_chat_clicked) - approve_btn.clicked.connect(lambda: self._on_confirm_clicked(True)) - reject_btn.clicked.connect(lambda: self._on_confirm_clicked(False)) - self._send_btn.clicked.connect(self._on_send_clicked) - self._stop_btn.clicked.connect(self._on_stop_clicked) - self._input.submit.connect(self._on_send_clicked) - self._history_list.itemActivated.connect(self._on_history_item) - self._history_list.itemClicked.connect(self._on_history_item) - self._render_timer = QtCore.QTimer(self._parent_window) self._render_timer.setSingleShot(True) self._render_timer.setInterval(100) - self._render_timer.timeout.connect(self._flush_render) self._relay = _StreamRelay() self._relay.on_event = self._handle_stream_event @@ -317,6 +348,19 @@ def OnCreate(self, form) -> None: self._relay.on_error = self._handle_stream_error self._relay.on_finished = self._handle_stream_finished + self._bridge = _PanelBridge(self) + self._transcript.anchorClicked.connect(self._bridge.anchor) + history_btn.clicked.connect(self._bridge.history) + new_btn.clicked.connect(self._bridge.new_chat) + approve_btn.clicked.connect(self._bridge.approve) + reject_btn.clicked.connect(self._bridge.reject) + self._send_btn.clicked.connect(self._bridge.send) + self._stop_btn.clicked.connect(self._bridge.stop) + self._input.submit.connect(self._bridge.send) + self._history_list.itemActivated.connect(self._bridge.history_item) + self._history_list.itemClicked.connect(self._bridge.history_item) + self._render_timer.timeout.connect(self._bridge.flush) + def OnClose(self, form) -> None: self.stop_stream_worker() if self._render_timer is not None: @@ -333,6 +377,7 @@ def OnClose(self, form) -> None: self._input = None self._parent_window = None self._render_timer = None + self._bridge = None def focus(self) -> None: if self._parent_window: diff --git a/reai_toolkit/app/coordinators/ai_decomp_coordinator.py b/reai_toolkit/app/coordinators/ai_decomp_coordinator.py index f37ce76..87b37ac 100644 --- a/reai_toolkit/app/coordinators/ai_decomp_coordinator.py +++ b/reai_toolkit/app/coordinators/ai_decomp_coordinator.py @@ -7,12 +7,14 @@ from revengai.models.decompilation_data import DecompilationData from revengai.models.summary_data import SummaryData from revengai.models.tokenised_data import TokenisedData +from revengai.models.workflow_progress import WorkflowProgress from reai_toolkit.app.app import App from reai_toolkit.app.components.tabs.ai_decomp_tab import AIDecompView from reai_toolkit.app.coordinators.ai_decomp_render import ( RenderModel, index_of_identifier, + render_progress, render_view_with_map, resolve_token, ) @@ -116,8 +118,18 @@ def _dispatch_task(self, ea: int) -> None: on_summary=lambda response: self._on_summary_complete(ea, response), on_comments=lambda response: self._on_comments_complete(ea, response), on_tokenised=lambda response: self._on_tokenised_complete(ea, response), + on_progress=lambda progress: self._on_progress(ea, progress), ) + def _on_progress(self, ea: int, progress: WorkflowProgress) -> None: + if ea != self._current_func_vaddr: + return + if self._current_decomp is not None: + return + if self._decomp_view is None: + return + self._decomp_view.update_view_content(render_progress(progress)) + def _on_decomp_complete( self, ea: int, response: GenericApiReturn[DecompilationData] ) -> None: @@ -307,4 +319,4 @@ def _rerender(self) -> None: def _on_pane_closed(self) -> None: self._decomp_view = None self.disable_function_tracking() - self.log.info("AI Decomp view closed, reference cleared.") + self.log.debug("AI Decomp view closed, reference cleared.") diff --git a/reai_toolkit/app/coordinators/ai_decomp_render.py b/reai_toolkit/app/coordinators/ai_decomp_render.py index fbf60c6..67513fc 100644 --- a/reai_toolkit/app/coordinators/ai_decomp_render.py +++ b/reai_toolkit/app/coordinators/ai_decomp_render.py @@ -8,8 +8,10 @@ from revengai.models.ai_decomp_function_mapping import AIDecompFunctionMapping from revengai.models.comments_data import CommentsData from revengai.models.decompilation_data import DecompilationData + from revengai.models.progress_message import ProgressMessage from revengai.models.summary_data import SummaryData from revengai.models.tokenised_data import TokenisedData + from revengai.models.workflow_progress import WorkflowProgress _IDENT_RE = re.compile(r"[A-Za-z_]\w*") @@ -92,6 +94,46 @@ def render_view_with_map( ) +def render_progress(progress: "WorkflowProgress") -> str: + lines: list[str] = ["// RevEng.AI — AI decompilation in progress…", "//"] + + steps_total = progress.steps_total or 0 + step_index = progress.step_index or 0 + step_name = progress.step or "" + status = progress.status or "" + + if steps_total > 0: + current = min(step_index + 1, steps_total) + lines.append(f"// Step {current}/{steps_total}: {step_name} [{status}]") + elif step_name: + lines.append(f"// {step_name} [{status}]") + else: + lines.append(f"// {status}") + + messages = progress.messages or [] + if messages: + lines.append("//") + for message in messages: + lines.append(f"// {_format_progress_message(message)}") + + return "\n".join(lines) + + +def _format_progress_message(message: "ProgressMessage") -> str: + stamp = _format_progress_time(getattr(message, "timestamp", None)) + prefix = f"{stamp} " if stamp else "" + return f"{prefix}[{message.level}] {message.text}" + + +def _format_progress_time(timestamp) -> str: + if timestamp is None: + return "" + try: + return timestamp.strftime("%H:%M:%S") + except (AttributeError, ValueError): + return "" + + def _format_summary_as_comment(summary: str) -> str: prefix = " * " max_comment_width: int = 100 diff --git a/reai_toolkit/app/services/ai_decomp/ai_decomp_service.py b/reai_toolkit/app/services/ai_decomp/ai_decomp_service.py index 0a871b7..fe48fab 100644 --- a/reai_toolkit/app/services/ai_decomp/ai_decomp_service.py +++ b/reai_toolkit/app/services/ai_decomp/ai_decomp_service.py @@ -86,6 +86,7 @@ def start_ai_decomp_task( on_summary: Callable[[GenericApiReturn[SummaryData]], None], on_comments: Callable[[GenericApiReturn[CommentsData]], None], on_tokenised: Callable[[GenericApiReturn[TokenisedData]], None] | None = None, + on_progress: Callable[[WorkflowProgress], None] | None = None, ) -> None: function_id = self._get_function_id(start_ea=ea) if function_id is None: @@ -107,6 +108,7 @@ def start_ai_decomp_task( on_summary, on_comments, on_tokenised, + on_progress, ), name=f"reai-aidecomp-{function_id}", daemon=True, @@ -303,11 +305,14 @@ def _run_task( on_summary: Callable[[GenericApiReturn[SummaryData]], None], on_comments: Callable[[GenericApiReturn[CommentsData]], None], on_tokenised: Callable[[GenericApiReturn[TokenisedData]], None] | None = None, + on_progress: Callable[[WorkflowProgress], None] | None = None, ) -> None: try: if stop_event.is_set(): return - decomp = self._run_decomp_phase(function_id, stop_event, on_decomp) + decomp = self._run_decomp_phase( + function_id, stop_event, on_decomp, on_progress + ) if decomp is None: return self._run_summary_phase(function_id, stop_event, on_summary) @@ -388,6 +393,7 @@ def _run_decomp_phase( function_id: int, stop_event: threading.Event, on_decomp: Callable[[GenericApiReturn[DecompilationData]], None], + on_progress: Callable[[WorkflowProgress], None] | None = None, ) -> DecompilationData | None: cached = self._decomp_cache.get(function_id) if cached is not None: @@ -436,6 +442,7 @@ def _run_decomp_phase( status_fn=self._fetch_decomp_status, requeue_fn=self._queue_decompilation, label="AI Decompilation", + on_progress=on_progress, ) if not polled_ok: if poll_err is not None: @@ -782,6 +789,7 @@ def _poll_workflow( status_fn: Callable[[int], tuple[WorkflowProgress | None, str | None]], requeue_fn: Callable[[int], tuple[bool, str | None]], label: str, + on_progress: Callable[[WorkflowProgress], None] | None = None, ) -> tuple[bool, str | None]: requeue_attempts = 0 while not stop_event.is_set(): @@ -789,10 +797,12 @@ def _poll_workflow( if status_err is not None or progress is None: return False, status_err or f"{label} status fetch returned nothing." - logger.info( + logger.debug( f"RevEng.AI: {label} progress for function id {function_id}: {progress.status}" ) + self._safe_dispatch(stop_event, on_progress, progress) + if progress.status == TaskStatus.COMPLETED: return True, None diff --git a/reai_toolkit/hooks/reactive.py b/reai_toolkit/hooks/reactive.py index 7f76656..860a02c 100644 --- a/reai_toolkit/hooks/reactive.py +++ b/reai_toolkit/hooks/reactive.py @@ -102,7 +102,7 @@ def hook(self) -> bool: if func is not None: self._last_func_start = func.start_ea self.coordinator.start_decompilation(ea=func.start_ea) - logger.info("[FunctionViewHooks] Hook registered.") + logger.debug("[FunctionViewHooks] Hook registered.") else: logger.error("[FunctionViewHooks] Failed to register.") return ok @@ -115,7 +115,7 @@ def unhook(self) -> None: if self._is_hooked: super().unhook() self._is_hooked = False - logger.info("[FunctionViewHooks] Hook removed.") + logger.debug("[FunctionViewHooks] Hook removed.") self._pending_ea = None self._last_func_start = None diff --git a/tests/unit/ai_decomp/test_render.py b/tests/unit/ai_decomp/test_render.py index 38be08e..d13ab3a 100644 --- a/tests/unit/ai_decomp/test_render.py +++ b/tests/unit/ai_decomp/test_render.py @@ -2,12 +2,15 @@ from revengai.models.comments_data import CommentsData from revengai.models.decompilation_data import DecompilationData from revengai.models.inline_comment import InlineComment +from revengai.models.progress_message import ProgressMessage from revengai.models.replacement_value import ReplacementValue from revengai.models.summary_data import SummaryData from revengai.models.tokenised_data import TokenisedData +from revengai.models.workflow_progress import WorkflowProgress from reai_toolkit.app.coordinators.ai_decomp_render import ( index_of_identifier, + render_progress, render_view, render_view_with_map, resolve_token, @@ -152,3 +155,38 @@ def test_resolve_token_unknown_returns_none(): def test_resolve_token_value_fallback_when_line_unaligned(): tokd = _tokenised(tok="", mapping=_mapping(unmatched_vars={"@@V_v5@@": _rv("v5")})) assert resolve_token(tokd, 1, 1, "v5") == ("@@V_v5@@", "variable") + + +def _pm(text, level="INFO", step="DECOMPILING", timestamp=None): + return ProgressMessage.model_construct( + level=level, step=step, text=text, timestamp=timestamp + ) + + +def _wp(status="RUNNING", step="DECOMPILING", step_index=1, steps_total=3, messages=None): + return WorkflowProgress.model_construct( + status=status, + step=step, + step_index=step_index, + steps_total=steps_total, + messages=messages or [], + ) + + +def test_render_progress_shows_step_and_status(): + text = render_progress(_wp()) + lines = text.split("\n") + assert lines[0].startswith("// RevEng.AI") + assert "// Step 2/3: DECOMPILING [RUNNING]" in lines + + +def test_render_progress_lists_messages_as_comment_lines(): + text = render_progress(_wp(messages=[_pm("fetching bytes"), _pm("done", level="WARN")])) + assert "// [INFO] fetching bytes" in text + assert "// [WARN] done" in text + assert all(line.startswith("//") for line in text.split("\n")) + + +def test_render_progress_without_steps_falls_back_to_status(): + text = render_progress(_wp(step="", step_index=0, steps_total=0, status="PENDING")) + assert "// PENDING" in text.split("\n")