From b1aa1cfa4dccae9755edd5a20e960d74d0dd7cfc Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 17 Apr 2026 23:52:45 -0700 Subject: [PATCH] =?UTF-8?q?fix(title):=20auto-title=20extraction=20for=20t?= =?UTF-8?q?ool-heavy=20first=20turns=20=E2=80=94=20closes=20#639=20(PR=20#?= =?UTF-8?q?640=20by=20@franksong2702)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-title extractor now uses _looks_invalid_generated_title() to distinguish tool-call preambles from substantive agentic replies. Fixes _is_provisional_title() whitespace normalization. 5 regression tests added. Independent review by @nesquena (a553b2b+a0ca9fe). --- CHANGELOG.md | 6 ++ api/streaming.py | 30 ++++++-- static/index.html | 2 +- tests/test_sprint41.py | 163 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa8bc1f..f907ce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,12 @@ - **Sidebar nav icon hit targets are now correctly aligned** — added `display:flex; align-items:center; justify-content:center` to `.nav-tab` so clicking the icon itself (not below it) activates the tab. (Closes #636) - **Safari iOS input auto-zoom fixed** — bumped `textarea#msg` base font-size from 14px to 16px, which prevents Safari from zooming the viewport on input focus (Safari zooms when font-size < 16px). Visual difference is negligible. (Closes #630) +## [v0.50.81] — 2026-04-18 + +### Fixed +- **Auto-title extraction improved for tool-heavy first turns** — sessions where the agent's first response involved tool calls (e.g. memory lookups, file reads) were generating poor titles because the title extractor skipped all assistant messages with `tool_calls`, even when those messages contained substantive visible text. The extractor now picks the first pure (non-tool-call) assistant reply as the title source, using `_looks_invalid_generated_title()` to distinguish meta-reasoning preambles from real agentic replies. Also fixes `_is_provisional_title()` to normalize whitespace before comparing, so CJK text truncated at 64 characters correctly re-triggers title updates. (Closes #639, PR #640 by @franksong2702) + + ## [v0.50.76] — 2026-04-17 ### Fixed diff --git a/api/streaming.py b/api/streaming.py index 63b6472..c861f01 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -125,17 +125,33 @@ def _message_text(value) -> str: def _first_exchange_snippets(messages): - """Return (first_user_text, first_assistant_text) snippets for title generation.""" + """Return (first_user_text, first_assistant_text) snippets for title generation. + + Prefer the first substantive assistant answer in the opening exchange, + skipping empty placeholders and assistant tool-call preambles. + """ user_text = '' asst_text = '' for m in messages or []: if not isinstance(m, dict): continue role = m.get('role') - if role == 'user' and not user_text: - user_text = _message_text(m.get('content')) - elif role == 'assistant' and not asst_text: + if role == 'user': candidate = _message_text(m.get('content')) + if not user_text and candidate: + user_text = candidate + continue + if user_text and candidate: + break + elif role == 'assistant' and user_text: + candidate = _message_text(m.get('content')) + # Skip tool-call preambles *only* when content is empty or looks + # like meta-reasoning ("Let me check my memory first.", "The user + # is asking...", etc.). Assistant rows that carry tool_calls but + # also contain a substantive answer text are kept — those are + # agentic first-turn plans that are legitimate title candidates. + if m.get('tool_calls') and (not candidate or _looks_invalid_generated_title(candidate)): + continue if candidate: asst_text = candidate if user_text and asst_text: @@ -148,7 +164,11 @@ def _is_provisional_title(current_title: str, messages) -> bool: derived = title_from(messages, '') or '' if not derived: return False - return (str(current_title or '').strip() == derived[:64]) + current = re.sub(r'\s+', ' ', str(current_title or '')).strip() + candidate = re.sub(r'\s+', ' ', str(derived[:64] or '')).strip() + if not current or not candidate: + return False + return current == candidate or candidate.startswith(current) def _title_prompts(user_text: str, assistant_text: str) -> tuple[str, list[str]]: diff --git a/static/index.html b/static/index.html index af7e0b3..0506d63 100644 --- a/static/index.html +++ b/static/index.html @@ -591,7 +591,7 @@
System
Instance version and access controls.
- v0.50.80 + v0.50.81
diff --git a/tests/test_sprint41.py b/tests/test_sprint41.py index 19e112e..7ca4106 100644 --- a/tests/test_sprint41.py +++ b/tests/test_sprint41.py @@ -213,6 +213,169 @@ class TestIssue495TitleStreaming(unittest.TestCase): "messages.js should close SSE connection on stream_end (not immediately on done)", ) + def test_title_snippet_uses_visible_assistant_reply_after_tools(self): + """Tool-heavy opening turns should use the final visible assistant reply.""" + from api.streaming import _first_exchange_snippets + + user_msg = { + "role": "user", + "content": "Please look up the earlier context and then summarize it.", + } + preamble_asst = { + "role": "assistant", + "content": "Let me check my memory first.", + "tool_calls": [ + { + "id": "call-1", + "function": { + "name": "memory", + "arguments": '{"action":"search"}', + }, + } + ], + } + tool_result = { + "role": "tool", + "tool_call_id": "call-1", + "content": '{"result":"background info"}', + } + final_asst = { + "role": "assistant", + "content": "Here is the substantive answer after the tool work.", + } + + user_text, assistant_text = _first_exchange_snippets( + [user_msg, preamble_asst, tool_result, final_asst] + ) + + self.assertEqual(user_text, user_msg["content"][:500]) + self.assertEqual(assistant_text, final_asst["content"][:500]) + + def test_title_snippet_keeps_short_substantive_assistant_reply(self): + """Short but real assistant answers should still be eligible for titles.""" + from api.streaming import _first_exchange_snippets + + messages = [ + {"role": "user", "content": "Can you help me rename this session?"}, + {"role": "assistant", "content": "Sure."}, + ] + + user_text, assistant_text = _first_exchange_snippets(messages) + + self.assertEqual(user_text, "Can you help me rename this session?") + self.assertEqual(assistant_text, "Sure.") + + def test_provisional_title_detection_ignores_whitespace_noise(self): + """Temporary first-message titles should still match with whitespace normalization.""" + from api.streaming import _is_provisional_title, title_from + + messages = [ + { + "role": "user", + "content": "过去两个礼拜发生了一些事情。最重要的一点就是我加入了一个 Hermes Web UI 的项目。\n\n因为我开始使用 Hermes 这个 agent 以后,就逐渐不再使用 OpenClaw了。", + }, + {"role": "assistant", "content": "Sure, let me help."}, + ] + + derived = title_from(messages, "") + current = derived[:63] # Simulate the provisional title the UI writes immediately. + + self.assertNotEqual(current, derived[:64]) + self.assertTrue( + _is_provisional_title(current, messages), + "Whitespace-normalized provisional titles should still be recognized", + ) + + def test_title_snippet_keeps_tool_call_with_substantive_text(self): + """An assistant row with tool_calls AND a substantive answer text + must still be used as the first-exchange snippet — it's not a + preamble, it's an agentic first-turn plan.""" + from api.streaming import _first_exchange_snippets + + user_msg = { + "role": "user", + "content": "Can you schedule a reminder for the Q3 kickoff meeting?", + } + # Assistant row with both a real answer AND a tool_call + agentic_asst = { + "role": "assistant", + "content": "I'll schedule the Q3 kickoff reminder for next Monday at 9am.", + "tool_calls": [ + { + "id": "call-1", + "function": { + "name": "cronjob", + "arguments": '{"action":"create","when":"mon 9am"}', + }, + } + ], + } + + user_text, assistant_text = _first_exchange_snippets([user_msg, agentic_asst]) + + self.assertEqual(user_text, user_msg["content"][:500]) + self.assertEqual( + assistant_text, + agentic_asst["content"][:500], + "Substantive answer text on a tool_call row must be preserved", + ) + + def test_title_snippet_skips_tool_call_preamble_only_rows(self): + """Tool-call rows whose content is empty or meta-reasoning preamble + ('Let me check my memory first.') must still be skipped — those are + orchestration scaffolding, not title material.""" + from api.streaming import _first_exchange_snippets + + user_msg = { + "role": "user", + "content": "Summarize my notes from last week.", + } + empty_preamble = { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call-1", + "function": { + "name": "memory", + "arguments": '{"action":"search"}', + }, + } + ], + } + meta_preamble = { + "role": "assistant", + "content": "Let me check my memory first.", + "tool_calls": [ + { + "id": "call-2", + "function": { + "name": "memory", + "arguments": '{"action":"search","q":"last week"}', + }, + } + ], + } + tool_result = { + "role": "tool", + "tool_call_id": "call-2", + "content": '{"result":"background info"}', + } + final_asst = { + "role": "assistant", + "content": "Here's a summary of your notes from last week.", + } + + _, assistant_text = _first_exchange_snippets( + [user_msg, empty_preamble, meta_preamble, tool_result, final_asst] + ) + + self.assertEqual( + assistant_text, + final_asst["content"][:500], + "Empty and meta-reasoning preamble rows must be skipped", + ) + if __name__ == "__main__": unittest.main()