From 09edaab90144034f89d8d988ddaef4c1995beb15 Mon Sep 17 00:00:00 2001 From: Zenetusken Date: Sun, 2 Aug 2026 19:27:08 -0400 Subject: [PATCH] fix(webui+memory): close post-turn utility process groups The memory plugin's monologue_end extensions (_50_memorize_fragments, _51_memorize_solutions) log a utility item when the agent loop ends and fill it in from a background task. By then the turn's process group was already completed by the final response, so the Web UI opened a new process group for these items. That group contains no agent steps, so its title stayed on the "Processing..." placeholder, and no later event ever marked it complete, so its steps kept the in-flight shiny animation and the phase never received an END badge: a finished turn looked like it was still running ("Processing..." + flashing utility items with a "waiting for input" status bar). Backend: the extensions now mark the utility item with a finished kvp on every terminal update path (early returns, post-loop success marker, and the exception path), and keep these post-turn bookkeeping items off the status bar with update_progress="none" (creation, terminal updates, and the error-path warning, which could previously pin the progress line to the warning heading with progress_active=true until the next turn). Frontend: drawMessageUtil closes the last process group when a utility step arrives with kvps.finished (mirroring drawMessageInfo), and updateProcessGroupHeader plus the full-log classifier fall back to the last step's title for groups without agent steps instead of leaving the "Processing..." placeholder. Regression coverage: tests/test_post_turn_utility_group.py runs the real memorize() coroutines against stubbed agents (empty result, successful insert, and provider-error paths all emit exactly one terminal finished marker; warnings stay off the status bar) and extracts the real group header functions from messages.js into a Node fake-DOM harness (utility -only group titles from its last step, completes with END badge, shiny removed; mixed groups still prefer the last agent step). All 9 tests fail on the pre-fix sources and pass with the fix. --- .../monologue_end/_50_memorize_fragments.py | 36 +- .../monologue_end/_51_memorize_solutions.py | 34 +- tests/test_post_turn_utility_group.py | 459 ++++++++++++++++++ webui/js/messages.js | 38 +- 4 files changed, 539 insertions(+), 28 deletions(-) create mode 100644 tests/test_post_turn_utility_group.py diff --git a/plugins/_memory/extensions/python/monologue_end/_50_memorize_fragments.py b/plugins/_memory/extensions/python/monologue_end/_50_memorize_fragments.py index 59bd71227f..18163a6845 100644 --- a/plugins/_memory/extensions/python/monologue_end/_50_memorize_fragments.py +++ b/plugins/_memory/extensions/python/monologue_end/_50_memorize_fragments.py @@ -27,9 +27,13 @@ def execute(self, loop_data: LoopData = LoopData(), **kwargs): return # show full util message + # update_progress="none": this is a post-turn background bookkeeping + # item logged after the final response; it must not take over the + # status bar (monologue_end resets progress to "Waiting for input"). log_item = self.agent.context.log.log( type="util", heading="Memorizing new information...", + update_progress="none", ) # memorize in background @@ -73,25 +77,28 @@ async def memorize(self, loop_data: LoopData, log_item: LogItem, **kwargs): # Add validation and error handling for memories_json if not memories_json or not isinstance(memories_json, str): - log_item.update(heading="No response from utility model.") + log_item.update(heading="No response from utility model.", finished=True) return # Strip any whitespace that might cause issues memories_json = memories_json.strip() if not memories_json: - log_item.update(heading="Empty response from utility model.") + log_item.update(heading="Empty response from utility model.", finished=True) return try: memories = DirtyJson.parse_string(memories_json) except Exception as e: - log_item.update(heading=f"Failed to parse memories response: {str(e)}") + log_item.update( + heading=f"Failed to parse memories response: {str(e)}", + finished=True, + ) return # Validate that memories is a list or convertible to one if memories is None: - log_item.update(heading="No valid memories found in response.") + log_item.update(heading="No valid memories found in response.", finished=True) return # If memories is not a list, try to make it one @@ -99,11 +106,11 @@ async def memorize(self, loop_data: LoopData, log_item: LogItem, **kwargs): if isinstance(memories, (str, dict)): memories = [memories] else: - log_item.update(heading="Invalid memories format received.") + log_item.update(heading="Invalid memories format received.", finished=True) return if not isinstance(memories, list) or len(memories) == 0: - log_item.update(heading="No useful information to memorize.") + log_item.update(heading="No useful information to memorize.", finished=True) return raw_memories_count = len(memories) @@ -114,6 +121,7 @@ async def memorize(self, loop_data: LoopData, log_item: LogItem, **kwargs): log_item.update( heading="No durable information to memorize.", filtered_memories_count=filtered_memories_count, + finished=True, ) return @@ -216,11 +224,23 @@ async def memorize(self, loop_data: LoopData, log_item: LogItem, **kwargs): ) if rem: log_item.stream(result=f"\nReplaced {len(rem)} previous memories.") - + + # Terminal marker: the background job is done. The Web UI uses the + # finished kvp to close the process group holding this utility + # item; without it the post-turn group never completes and keeps + # rendering as an in-flight "Processing..." phase. + log_item.update(finished=True, update_progress="none") except Exception as e: + # Mark the utility item finished even on failure so its process + # group does not stay open, and keep the warning off the status + # bar (progress was already reset to "Waiting for input"). + log_item.update(finished=True, update_progress="none") err = errors.format_error(e) self.agent.context.log.log( - type="warning", heading="Memorize memories extension error", content=err + type="warning", + heading="Memorize memories extension error", + content=err, + update_progress="none", ) diff --git a/plugins/_memory/extensions/python/monologue_end/_51_memorize_solutions.py b/plugins/_memory/extensions/python/monologue_end/_51_memorize_solutions.py index 64ceeb6124..85644c8eed 100644 --- a/plugins/_memory/extensions/python/monologue_end/_51_memorize_solutions.py +++ b/plugins/_memory/extensions/python/monologue_end/_51_memorize_solutions.py @@ -25,9 +25,13 @@ def execute(self, loop_data: LoopData = LoopData(), **kwargs): return # show full util message + # update_progress="none": this is a post-turn background bookkeeping + # item logged after the final response; it must not take over the + # status bar (monologue_end resets progress to "Waiting for input"). log_item = self.agent.context.log.log( type="util", heading="Memorizing succesful solutions...", + update_progress="none", ) # memorize in background @@ -75,25 +79,28 @@ async def memorize(self, loop_data: LoopData, log_item: LogItem, **kwargs): # Add validation and error handling for solutions_json if not solutions_json or not isinstance(solutions_json, str): - log_item.update(heading="No response from utility model.") + log_item.update(heading="No response from utility model.", finished=True) return # Strip any whitespace that might cause issues solutions_json = solutions_json.strip() if not solutions_json: - log_item.update(heading="Empty response from utility model.") + log_item.update(heading="Empty response from utility model.", finished=True) return try: solutions = DirtyJson.parse_string(solutions_json) except Exception as e: - log_item.update(heading=f"Failed to parse solutions response: {str(e)}") + log_item.update( + heading=f"Failed to parse solutions response: {str(e)}", + finished=True, + ) return # Validate that solutions is a list or convertible to one if solutions is None: - log_item.update(heading="No valid solutions found in response.") + log_item.update(heading="No valid solutions found in response.", finished=True) return # If solutions is not a list, try to make it one @@ -101,11 +108,11 @@ async def memorize(self, loop_data: LoopData, log_item: LogItem, **kwargs): if isinstance(solutions, (str, dict)): solutions = [solutions] else: - log_item.update(heading="Invalid solutions format received.") + log_item.update(heading="Invalid solutions format received.", finished=True) return if not isinstance(solutions, list) or len(solutions) == 0: - log_item.update(heading="No successful solutions to memorize.") + log_item.update(heading="No successful solutions to memorize.", finished=True) return else: solutions_txt = "\n\n".join([str(solution) for solution in solutions]).strip() @@ -209,9 +216,22 @@ async def memorize(self, loop_data: LoopData, log_item: LogItem, **kwargs): if rem: log_item.stream(result=f"\nReplaced {len(rem)} previous solutions.") + # Terminal marker: the background job is done. The Web UI uses the + # finished kvp to close the process group holding this utility + # item; without it the post-turn group never completes and keeps + # rendering as an in-flight "Processing..." phase. + log_item.update(finished=True, update_progress="none") + except Exception as e: + # Mark the utility item finished even on failure so its process + # group does not stay open, and keep the warning off the status + # bar (progress was already reset to "Waiting for input"). + log_item.update(finished=True, update_progress="none") err = errors.format_error(e) self.agent.context.log.log( - type="warning", heading="Memorize solutions extension error", content=err + type="warning", + heading="Memorize solutions extension error", + content=err, + update_progress="none", ) diff --git a/tests/test_post_turn_utility_group.py b/tests/test_post_turn_utility_group.py new file mode 100644 index 0000000000..e9cd23eb06 --- /dev/null +++ b/tests/test_post_turn_utility_group.py @@ -0,0 +1,459 @@ +"""Regression tests for post-turn utility process-group completion. + +Root cause (2026-08-02): the memory plugin's monologue_end extensions +(_50_memorize_fragments, _51_memorize_solutions) log a utility item when the +agent loop ends and then fill it in from a background task. By that point the +turn's process group was already completed by the final response, so the Web +UI opened a new process group for these items. That group (a) contains no +agent steps, so its title stayed on the "Processing..." placeholder, and (b) +was never marked complete by any later event, so its steps kept the in-flight +"shiny" animation and the phase never received an END badge — a finished turn +looked like it was still running. + +Fix: the backend marks the utility item with a ``finished`` kvp on every +terminal update path (and keeps these post-turn items off the status bar with +``update_progress="none"``), and the Web UI closes the process group when a +utility step arrives with ``kvps.finished`` and falls back to the last step's +title for groups without agent steps. + +These tests run the real ``memorize()`` coroutines against stubbed +agents/log items and assert the terminal marker is emitted, and extract the +real ``updateProcessGroupHeader``/``isProcessGroupComplete``/ +``completeLastProcessGroup`` functions from ``webui/js/messages.js`` into a +Node harness with a minimal fake DOM to assert group completion and title +fallback. Both halves fail on the pre-fix implementation, making them +discriminating. +""" + +import asyncio +import importlib +import json +import shutil +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +MESSAGES_JS = PROJECT_ROOT / "webui" / "js" / "messages.js" + +NODE_AVAILABLE = shutil.which("node") is not None +requires_node = pytest.mark.skipif(not NODE_AVAILABLE, reason="node not available") + + +def _load_extension_module(name: str): + return importlib.import_module( + f"plugins._memory.extensions.python.monologue_end.{name}" + ) + + +class FakeLogItem: + """Records every update() call so tests can inspect terminal markers.""" + + def __init__(self): + self.updates: list[dict] = [] + self.streams: list[dict] = [] + + def update(self, **kwargs): + self.updates.append(kwargs) + + def stream(self, **kwargs): + self.streams.append(kwargs) + + +class FakeLog: + def __init__(self): + self.warnings: list[dict] = [] + + def log(self, **kwargs): + self.warnings.append(kwargs) + return FakeLogItem() + + +def _make_agent(utility_response=None, utility_error: Exception | None = None): + """Agent stub exposing only what memorize() touches.""" + + async def call_utility_model(**kwargs): + if utility_error is not None: + raise utility_error + return utility_response + + return SimpleNamespace( + history=[], + context=SimpleNamespace(log=FakeLog()), + read_prompt=lambda *a, **k: "system prompt", + concat_messages=lambda history: "chat text", + call_utility_model=call_utility_model, + ) + + +def _patch_plugin_config(monkeypatch, mod, consolidation: bool): + import helpers.plugins as plugins_mod + + monkeypatch.setattr( + plugins_mod, + "get_plugin_config", + lambda plugin, agent=None, **kwargs: { + "memory_memorize_enabled": True, + "memory_memorize_consolidation": consolidation, + "memory_memorize_replace_threshold": 0, + }, + ) + + +def _patch_memory_db(monkeypatch, mod): + """Replace Memory.get with an async stub DB (non-consolidation path).""" + inserted: list[str] = [] + + class FakeDB: + async def delete_documents_by_query(self, **kwargs): + return [] + + async def insert_text(self, text, metadata=None): + inserted.append(text) + return "id" + + async def fake_get(agent): + return FakeDB() + + monkeypatch.setattr(mod.Memory, "get", staticmethod(fake_get)) + return inserted + + +@pytest.mark.parametrize( + "module_name,empty_heading", + [ + ("_50_memorize_fragments", "No useful information to memorize."), + ("_51_memorize_solutions", "No successful solutions to memorize."), + ], +) +def test_empty_utility_result_marks_log_item_finished( + monkeypatch, module_name, empty_heading +): + """An empty utility-model result is a terminal path: it must emit the + finished kvp so the Web UI can close the post-turn process group.""" + mod = _load_extension_module(module_name) + _patch_plugin_config(monkeypatch, mod, consolidation=False) + _patch_memory_db(monkeypatch, mod) + + ext = mod.MemorizeMemories(agent=None) if module_name.startswith("_50") else mod.MemorizeSolutions(agent=None) + ext.agent = _make_agent(utility_response="[]") + log_item = FakeLogItem() + + asyncio.run(ext.memorize(loop_data=None, log_item=log_item)) + + assert log_item.updates, "memorize() never updated the log item" + final = log_item.updates[-1] + assert final.get("heading") == empty_heading + assert final.get("finished") is True, ( + "terminal update lacks finished=True; the Web UI process group " + "would stay open forever" + ) + + +@pytest.mark.parametrize( + "module_name,response", + [ + ("_50_memorize_fragments", '["The user prefers concise final reports.", "The project requires full test runs before release."]'), + ("_51_memorize_solutions", '[{"problem": "p", "solution": "s"}]'), + ], +) +def test_successful_memorization_marks_finished_after_inserts( + monkeypatch, module_name, response +): + """Successful memorization (non-consolidation path) must insert all + entries first and only then emit the terminal finished marker.""" + mod = _load_extension_module(module_name) + _patch_plugin_config(monkeypatch, mod, consolidation=False) + inserted = _patch_memory_db(monkeypatch, mod) + + cls = mod.MemorizeMemories if module_name.startswith("_50") else mod.MemorizeSolutions + ext = cls(agent=None) + ext.agent = _make_agent(utility_response=response) + log_item = FakeLogItem() + + asyncio.run(ext.memorize(loop_data=None, log_item=log_item)) + + assert inserted, "nothing was inserted into the memory DB" + finished_updates = [u for u in log_item.updates if u.get("finished") is True] + assert len(finished_updates) == 1, ( + f"expected exactly one terminal finished update, got {len(finished_updates)}" + ) + assert log_item.updates[-1].get("finished") is True + # the finished marker must not hijack the status bar + assert log_item.updates[-1].get("update_progress") == "none" + + +@pytest.mark.parametrize( + "module_name", + ["_50_memorize_fragments", "_51_memorize_solutions"], +) +def test_utility_model_error_still_closes_group_without_status_bar_hijack( + monkeypatch, module_name +): + """On failure the item must still be marked finished (no orphaned group) + and the warning must not take over the status bar after the turn ended.""" + mod = _load_extension_module(module_name) + _patch_plugin_config(monkeypatch, mod, consolidation=False) + _patch_memory_db(monkeypatch, mod) + + cls = mod.MemorizeMemories if module_name.startswith("_50") else mod.MemorizeSolutions + ext = cls(agent=None) + ext.agent = _make_agent(utility_error=RuntimeError("provider exploded")) + log_item = FakeLogItem() + + asyncio.run(ext.memorize(loop_data=None, log_item=log_item)) + + assert log_item.updates[-1].get("finished") is True + assert log_item.updates[-1].get("update_progress") == "none" + warnings = ext.agent.context.log.warnings + assert len(warnings) == 1 + assert warnings[0]["type"] == "warning" + assert warnings[0].get("update_progress") == "none" + + +@pytest.mark.parametrize( + "module_name,create_heading", + [ + ("_50_memorize_fragments", "Memorizing new information..."), + ("_51_memorize_solutions", "Memorizing succesful solutions..."), + ], +) +def test_execute_creates_log_item_off_status_bar(module_name, create_heading): + """The synchronous log item creation at monologue end must pass + update_progress="none" so post-turn bookkeeping never hijacks progress.""" + import inspect + + mod = _load_extension_module(module_name) + cls = mod.MemorizeMemories if module_name.startswith("_50") else mod.MemorizeSolutions + source = inspect.getsource(cls.execute) + log_call_pos = source.find("context.log.log(") + assert log_call_pos != -1 + call_src = source[log_call_pos : source.find(")", log_call_pos)] + assert 'update_progress="none"' in call_src + assert create_heading in call_src + + +# --------------------------------------------------------------------------- +# Frontend: updateProcessGroupHeader / completeLastProcessGroup behavior +# --------------------------------------------------------------------------- + + +def _extract_function(js: str, signature: str) -> str: + start = js.find(signature) + assert start != -1, f"{signature} not found in webui/js/messages.js" + body_start = js.find("{", start) + depth = 0 + for pos in range(body_start, len(js)): + char = js[pos] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return js[start : pos + 1] + raise AssertionError(f"{signature} body is unbalanced") + + +def _build_node_harness(tmp_path: Path) -> Path: + js = MESSAGES_JS.read_text(encoding="utf-8") + blocks = [ + _extract_function(js, "function truncateText("), + _extract_function(js, "export function cleanStepTitle("), + _extract_function(js, "function updateProcessGroupHeader("), + _extract_function(js, "function isProcessGroupComplete("), + _extract_function(js, "export function completeLastProcessGroup("), + ] + shipped = "\n\n".join(blocks) + harness = """ +// --- minimal fake DOM ------------------------------------------------------ +class FakeClassList { + constructor(owner) { this.owner = owner; this.set = new Set(); } + add(...cs) { cs.forEach((c) => this.set.add(c)); } + remove(...cs) { cs.forEach((c) => this.set.delete(c)); } + contains(c) { return this.set.has(c); } + toggle(c, force) { + const on = force === undefined ? !this.set.has(c) : Boolean(force); + on ? this.set.add(c) : this.set.delete(c); + } +} + +class FakeElement { + constructor(tag = "div") { + this.tag = tag; + this.children = []; + this.attrs = {}; + this.classList = new FakeClassList(this); + this.dataset = {}; + this.textContent = ""; + this.title = ""; + this.outerHTML = ""; + } + get className() { return [...this.classList.set].join(" "); } + setAttribute(k, v) { this.attrs[k] = String(v); } + getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; } + hasAttribute(k) { return k in this.attrs; } + _matches(sel) { + if (sel.startsWith(".")) return this.classList.contains(sel.slice(1)); + if (sel.startsWith("[")) { + const m = sel.match(/^\\[([^=\\]]+)(?:="([^"]*)")?\\]$/); + if (!m) return false; + const val = this.getAttribute(m[1]); + return m[2] === undefined ? val !== null : val === m[2]; + } + return false; + } + _all() { + const out = []; + const walk = (el) => { out.push(el); el.children.forEach(walk); }; + this.children.forEach(walk); + return out; + } + querySelector(sel) { + // support compound ".a .b" and ".a.b" selectors used by shipped code + if (sel.startsWith(":scope ")) sel = sel.slice(7); + const parts = sel.split(/\\s+/); + let candidates = [this]; + for (const part of parts) { + const next = []; + const classes = part.split(".").filter(Boolean); + const attrMatch = part.match(/\\.[^\\[]*(\\[.*)$/); + for (const el of candidates) { + for (const d of el._all()) { + let ok = classes.length + ? classes.every((c) => d.classList.contains(c)) + : d._matches(part); + if (ok && attrMatch) ok = d._matches(attrMatch[1]); + if (ok) next.push(d); + } + } + candidates = next; + } + return candidates[0] || null; + } + querySelectorAll(sel) { + // support ".a.b" and ".sel[attr="v"]" compound selectors + const attrMatch = sel.match(/^(\\.[^\\[]*)?(\\[.*)$/); + let base = sel; + let attr = null; + if (attrMatch && attrMatch[2]) { base = attrMatch[1] || ""; attr = attrMatch[2]; } + const classes = base.split(".").filter(Boolean); + return this._all().filter((d) => { + let ok = classes.length ? classes.every((c) => d.classList.contains(c)) : true; + if (ok && attr) ok = d._matches(attr); + return ok; + }); + } + appendChild(c) { this.children.push(c); return c; } +} + +function makeStep({ type, title, shiny = false }) { + const step = new FakeElement(); + step.classList.add("process-step"); + step.setAttribute("data-log-type", type); + step.setAttribute("data-step-code", type === "agent" ? "GEN" : "UTL"); + const titleEl = new FakeElement(); + titleEl.classList.add("step-title"); + if (shiny) titleEl.classList.add("shiny-text"); + titleEl.textContent = title; + step.appendChild(titleEl); + return step; +} + +function makeGroup(steps) { + const group = new FakeElement(); + group.classList.add("process-group"); + const header = new FakeElement(); + header.classList.add("process-group-header"); + const title = new FakeElement(); + title.classList.add("group-title"); + title.textContent = "Processing..."; + const badge = new FakeElement(); + badge.classList.add("step-badge"); + const metrics = new FakeElement(); + metrics.classList.add("group-metrics"); + header.appendChild(title); header.appendChild(badge); header.appendChild(metrics); + group.appendChild(header); + steps.forEach((s) => group.appendChild(s)); + return { group, title, badge }; +} + +// --- module-scope stubs the shipped functions rely on ---------------------- +var _lastGroup = null; +function getLastProcessGroup() { return _lastGroup; } +function getUserHour12() { return false; } +function getUserTimezone() { return "UTC"; } +function formatDateTime(iso) { return iso; } + +// --- shipped implementation under test ------------------------------------- +__SHIPPED__ + +// --- driver ---------------------------------------------------------------- +const results = {}; + +// Case 1: utility-only group (post-turn memory memorization) — title must +// fall back to the last step's heading instead of staying "Processing...". +{ + const steps = [ + makeStep({ type: "util", title: "Memorizing new information...", shiny: true }), + makeStep({ type: "util", title: "No useful information to memorize.", shiny: true }), + ]; + const { group, title, badge } = makeGroup(steps); + _lastGroup = group; + updateProcessGroupHeader(group); + results.utilTitle = title.textContent; + results.utilBadgeBeforeComplete = badge.outerHTML; + + // backend terminal update arrives with kvps.finished → drawMessageUtil + // calls completeLastProcessGroup() + completeLastProcessGroup(); + results.utilCompleted = isProcessGroupComplete(group); + results.utilBadgeAfterComplete = badge.outerHTML; + results.utilShinyRemaining = group.querySelectorAll(".step-title.shiny-text").length; +} + +// Case 2: regression guard — a group with agent steps still takes its title +// from the last agent step, not from a later utility step. +{ + const steps = [ + makeStep({ type: "agent", title: "A0: Reading storage code" }), + makeStep({ type: "util", title: "3 memories found" }), + ]; + const { group, title } = makeGroup(steps); + _lastGroup = group; + updateProcessGroupHeader(group); + results.mixedTitle = title.textContent; +} + +console.log(JSON.stringify(results)); +""" + harness = harness.replace("__SHIPPED__", shipped) + path = tmp_path / "group_header_harness.mjs" + path.write_text(harness, encoding="utf-8") + return path + + +@requires_node +def test_utility_only_group_completes_and_titles_from_last_step(tmp_path): + harness = _build_node_harness(tmp_path) + proc = subprocess.run( + ["node", str(harness)], capture_output=True, text=True, timeout=60 + ) + assert proc.returncode == 0, f"node harness failed:\n{proc.stderr}" + results = json.loads(proc.stdout.strip().splitlines()[-1]) + + # title fallback: last utility step heading, not the placeholder + assert results["utilTitle"] == "No useful information to memorize.", ( + "utility-only group title did not fall back to the last step heading" + ) + # completion: group closed, END badge set, shiny animation removed + assert results["utilCompleted"] is True + assert "END" in results["utilBadgeAfterComplete"] + assert results["utilShinyRemaining"] == 0 + # mixed groups still prefer the last agent step heading + assert results["mixedTitle"] == "A0: Reading storage code" diff --git a/webui/js/messages.js b/webui/js/messages.js index 580a6635f1..4b23a0cc1d 100644 --- a/webui/js/messages.js +++ b/webui/js/messages.js @@ -707,9 +707,9 @@ function updateProcessGroupPagingControls(history) { group.dataset.fullInfoSteps = String( allSteps.filter((message) => message?.type === "info").length, ); - const lastAgentMessage = allSteps.findLast( - (message) => message?.type === "agent", - ); + const lastAgentMessage = + allSteps.findLast((message) => message?.type === "agent") || + allSteps[allSteps.length - 1]; const fullTitle = cleanStepTitle(lastAgentMessage?.heading, 50); if (fullTitle) { const title = group.querySelector(".process-group-header .group-title"); @@ -2427,18 +2427,27 @@ export function drawMessageUtil({ ].filter(Boolean) : []; + // Post-turn bookkeeping utilities (e.g. memory memorization) carry a + // finished kvp set by the backend when the background job ends. Do not + // display it as data; use it to close the process group so a completed + // turn does not keep rendering an in-flight "Processing..." phase. + const displayKvps = { ...kvps }; + delete displayKvps.finished; + const result = drawProcessStep({ id, title, code: "UTL", classes: ["message-util"], - kvps, + kvps: displayKvps, content, actionButtons, log: arguments[0], allowCompletedGroup: false, }); + if (kvps?.finished) completeLastProcessGroup(); + result.dontScroll = !preferencesStore.showUtils; return result; } @@ -3180,15 +3189,18 @@ function updateProcessGroupHeader(group) { const agentSteps = Array.from(steps).filter( (step) => step.getAttribute("data-log-type") === "agent", ); - if (agentSteps.length > 0) { - const lastAgentStep = agentSteps[agentSteps.length - 1]; - const lastHeading = - lastAgentStep.querySelector(".step-title")?.textContent; - if (lastHeading) { - const cleanTitle = cleanStepTitle(lastHeading, 50); - if (cleanTitle) { - titleEl.textContent = cleanTitle; - } + // Groups made only of utility steps (post-turn memory memorization) + // never get an agent step; fall back to the last step's title so the + // header does not stay on the "Processing..." placeholder forever. + const titleStep = + agentSteps.length > 0 + ? agentSteps[agentSteps.length - 1] + : steps[steps.length - 1]; + const lastHeading = titleStep?.querySelector(".step-title")?.textContent; + if (lastHeading) { + const cleanTitle = cleanStepTitle(lastHeading, 50); + if (cleanTitle) { + titleEl.textContent = cleanTitle; } } }