diff --git a/CHANGELOG.md b/CHANGELOG.md index 77df598c..9583ad01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,8 @@ ### Fixed +- **Delegated subagent sessions in the sidebar now show a meaningful title instead of "Subagent Session".** Generic delegated child sessions previously all displayed the placeholder "Subagent Session" in the conversations list, making them indistinguishable. Their sidebar title is now derived (view-only) from the child's first user message during the existing batched sidebar-metadata pass — without mutating the stored session title, so the read-only boundary is preserved. Only generic "Subagent Session" placeholders are affected; custom titles are left untouched. Thanks @rodboev. (#6056, #6033) + - **Manual "Check for updates" now works even when automatic update checks are turned off.** Previously, disabling automatic update checks in Preferences → System also disabled the manual "check now" button, leaving no way to update from the WebUI without re-enabling the (noisy) automatic banner. The explicit manual force-check now bypasses the disabled auto-check toggle; the automatic path still honors the setting. Thanks @webtecnica. (#6088, #6082) - **No more spurious "No response from provider" after a tool-limit turn that did answer.** When a turn hit the per-turn tool-iteration limit (`max_iterations_reached`) but the model had already produced a valid final answer, the settlement classifier still appended a "No response from provider" error card after the good reply — a regression of the earlier false-`no_response` fix, because the suppression path admitted only `_soft_partial_terminal_failure` and deliberately excluded `_tool_limit_reached`. The suppression now also covers `_tool_limit_reached`, but *only* when the transcript already holds a final answer; a tool-limit turn with no final answer still fails honestly, and explicit errors / cancellations / interruptions stay outside the suppression. Thanks @coyaSONG. (#6081, #6079) diff --git a/api/models.py b/api/models.py index e30e52b4..b092c621 100644 --- a/api/models.py +++ b/api/models.py @@ -5256,13 +5256,16 @@ def _read_state_db_sidebar_overrides( has_messages_table = cur.fetchone() is not None messages_has_session_id = False messages_has_timestamp = False + messages_has_title_fields = False if has_messages_table: cur.execute("PRAGMA table_info(messages)") message_cols = {str(row[1]) for row in cur.fetchall()} messages_has_session_id = 'session_id' in message_cols messages_has_timestamp = 'timestamp' in message_cols + messages_has_title_fields = {'session_id', 'role', 'content', 'timestamp'}.issubset(message_cols) overrides: dict[str, dict] = {} + delegated_title_ids: set[str] = set() ids = list(wanted) chunk_size = 500 for i in range(0, len(ids), chunk_size): @@ -5283,6 +5286,12 @@ def _read_state_db_sidebar_overrides( if state_title: entry['_state_db_title'] = state_title state_source = str(row['source'] or '').strip().lower() + if ( + state_source == 'subagent' + and sid in count_wanted + and ' '.join(state_title.split()) == 'Subagent Session' + ): + delegated_title_ids.add(sid) if state_source: entry['_state_db_source'] = state_source source_meta = normalize_agent_session_source(state_source) @@ -5327,6 +5336,29 @@ def _read_state_db_sidebar_overrides( entry['_state_db_last_message_at'] = float(row['last_message_at'] or 0) except (TypeError, ValueError): pass + if messages_has_title_fields and delegated_title_ids: + seen_user_messages: set[str] = set() + delegated_title_ids = list(delegated_title_ids) + for i in range(0, len(delegated_title_ids), chunk_size): + chunk = delegated_title_ids[i:i + chunk_size] + placeholders = ','.join('?' * len(chunk)) + cur.execute( + f""" + SELECT session_id, role, content, timestamp + FROM messages + WHERE session_id IN ({placeholders}) AND role = 'user' + ORDER BY session_id, timestamp ASC + """, + chunk, + ) + for row in cur.fetchall(): + sid = str(row['session_id']) + if sid in seen_user_messages: + continue + display_title = title_from([dict(row)], fallback='') + if display_title: + seen_user_messages.add(sid) + overrides.setdefault(sid, {})['_state_db_display_title'] = display_title return overrides except Exception: return {} @@ -5381,6 +5413,7 @@ def _apply_sidebar_state_db_override_metadata(sessions: list[dict], metadata: di state_db_source_label = entry.pop('_state_db_source_label', None) state_db_message_count = entry.pop('_state_db_message_count', None) state_db_last_message_at = entry.pop('_state_db_last_message_at', None) + state_db_display_title = entry.pop('_state_db_display_title', None) if state_db_source == 'webui': session['source_tag'] = state_db_source_tag session['raw_source'] = state_db_raw_source @@ -5440,6 +5473,12 @@ def _apply_sidebar_state_db_override_metadata(sessions: list[dict], metadata: di ): session['_state_db_title'] = state_db_title session['display_title'] = state_db_title + if ( + state_db_display_title + and state_db_source == 'subagent' + and ' '.join(str(title or '').split()) == 'Subagent Session' + ): + session['display_title'] = state_db_display_title def _enrich_sidebar_lineage_metadata(sessions: list[dict]) -> None: @@ -5711,6 +5750,8 @@ def title_from(messages, fallback: str='Untitled'): for m in messages: if m.get('role') == 'user': c = m.get('content', '') + if c is None: + continue if isinstance(c, list): c = ' '.join(p.get('text', '') for p in c if isinstance(p, dict) and p.get('type') == 'text') text = _strip_attached_files_marker(str(c)) diff --git a/api/routes.py b/api/routes.py index 1a96e748..80b08b4b 100644 --- a/api/routes.py +++ b/api/routes.py @@ -9632,10 +9632,30 @@ def _sidebar_session_response_item(session: dict, *, redact_enabled: bool | None } if isinstance(item.get("title"), str): item["title"] = _redact_text(item["title"], _enabled=redact_enabled) + _redact_sidebar_title_fields(item, redact_enabled) item["attention"] = _session_attention_summary(str(item.get("session_id") or "")) return item +def _redact_sidebar_title_fields(item: dict, redact_enabled: bool | None = None) -> None: + """Redact every user-content-derived title field on a sidebar/search row in place. + + `title` is redacted by the callers directly (they special-case it), but + `display_title`, `_state_db_title`, and `parent_title` can ALSO carry raw + user-message-derived text — e.g. #6056 derives a delegated subagent's + `display_title` from its first user message, and `parent_title` copies a + parent session's (possibly derived) title. Without this a credential-shaped + value in a delegated goal would surface in the sidebar / search results even + with `api_redact_enabled=True`. Shared by `_sidebar_session_response_item` + (`/api/sessions`) and every `/api/sessions/search` response branch so the two + endpoints can never drift on which fields get redacted. + """ + for field in ("display_title", "_state_db_title", "parent_title"): + value = item.get(field) + if isinstance(value, str): + item[field] = _redact_text(value, _enabled=redact_enabled) + + # ── Login page locale strings ───────────────────────────────────────────────── # Add entries here to support more languages on the login page. # The key must match the 'language' setting value (from static/i18n.js LOCALES). @@ -16567,12 +16587,21 @@ def _handle_sessions_search(handler, parsed): depth = max(0, int(qs.get("depth", ["5"])[0])) except (ValueError, TypeError): depth = 5 + # Read the redaction setting ONCE for the whole response (mirrors the + # /api/sessions read-once optimization, #4662) and thread it through every + # branch + the shared title-field redactor so search rows redact the same + # fields as the sidebar list. + try: + _search_redact_enabled = bool(load_settings().get("api_redact_enabled", True)) + except Exception: + _search_redact_enabled = True # fail safe: redact when settings unreadable if not q: safe_sessions = [] for s in sessions: item = dict(s) if isinstance(item.get("title"), str): - item["title"] = _redact_text(item["title"]) + item["title"] = _redact_text(item["title"], _enabled=_search_redact_enabled) + _redact_sidebar_title_fields(item, _search_redact_enabled) safe_sessions.append(item) return j(handler, { "sessions": safe_sessions, @@ -16585,7 +16614,8 @@ def _handle_sessions_search(handler, parsed): if title_match: item = dict(s, match_type="title") if isinstance(item.get("title"), str): - item["title"] = _redact_text(item["title"]) + item["title"] = _redact_text(item["title"], _enabled=_search_redact_enabled) + _redact_sidebar_title_fields(item, _search_redact_enabled) results.append(item) continue if content_search: @@ -16598,9 +16628,10 @@ def _handle_sessions_search(handler, parsed): item = dict(s, match_type="content") preview = _session_search_preview(c, q) if preview: - item["match_preview"] = _redact_text(preview) + item["match_preview"] = _redact_text(preview, _enabled=_search_redact_enabled) if isinstance(item.get("title"), str): - item["title"] = _redact_text(item["title"]) + item["title"] = _redact_text(item["title"], _enabled=_search_redact_enabled) + _redact_sidebar_title_fields(item, _search_redact_enabled) results.append(item) break except (KeyError, Exception): diff --git a/tests/test_issue4662_sidebar_redaction_read_once.py b/tests/test_issue4662_sidebar_redaction_read_once.py index 5951fed2..cb0c2ab8 100644 --- a/tests/test_issue4662_sidebar_redaction_read_once.py +++ b/tests/test_issue4662_sidebar_redaction_read_once.py @@ -65,3 +65,66 @@ def test_sidebar_payload_no_redaction_when_disabled(monkeypatch): resp = routes._session_list_payload_to_response(payload) assert resp["sessions"][0]["title"] == "plain title" assert calls["n"] <= 1, f"settings read {calls['n']}x with redaction disabled; expected <=1" + + +def test_sidebar_payload_redacts_display_and_parent_titles(monkeypatch): + """#6056 derives a delegated subagent's display_title from raw user-message + content, so display_title / _state_db_title / parent_title must go through the + SAME redaction as title — a credential in a delegated goal must not leak to + the sidebar even though only `title` was redacted before.""" + monkeypatch.setattr("api.config.load_settings", lambda: {"api_redact_enabled": True}) + monkeypatch.setattr("api.helpers.load_settings", lambda: {"api_redact_enabled": True}, raising=False) + monkeypatch.setattr(routes, "_session_list_cache_overlay_runtime_rows", lambda rows: rows) + + secret = "sk-" + ("A" * 44) + payload = {"sessions": [{ + "session_id": "s1", + "title": "clean", + "display_title": f"debug {secret}", + "_state_db_title": f"goal {secret}", + "parent_title": f"parent {secret}", + }], "cli_count": 0} + resp = routes._session_list_payload_to_response(payload) + row = resp["sessions"][0] + for field in ("display_title", "_state_db_title", "parent_title"): + assert secret not in str(row.get(field, "")), ( + f"credential leaked into sidebar {field}: {row.get(field)!r}" + ) + + +def test_redact_sidebar_title_fields_helper(): + """The shared helper redacts every user-content-derived title field, honors the + enabled flag, and never raises on missing/non-str fields.""" + secret = "sk-" + ("B" * 44) + item = { + "title": "kept-by-caller", + "display_title": f"a {secret}", + "_state_db_title": f"b {secret}", + "parent_title": f"c {secret}", + "session_id": "s1", + } + routes._redact_sidebar_title_fields(item, True) + for field in ("display_title", "_state_db_title", "parent_title"): + assert secret not in item[field], f"{field} not redacted" + # Disabled → pass through unchanged. + item2 = {"display_title": f"a {secret}"} + routes._redact_sidebar_title_fields(item2, False) + assert item2["display_title"] == f"a {secret}" + # Missing / non-str fields must not raise. + routes._redact_sidebar_title_fields({"display_title": None, "parent_title": 42}, True) + + +def test_sessions_search_branches_redact_derived_titles(): + """Every /api/sessions/search response branch must call the shared title-field + redactor so search rows can't leak a derived display_title the sidebar hides. + Source-guard: the #6056 class-fix is easy to regress by adding a 4th branch.""" + import inspect + src = inspect.getsource(routes._handle_sessions_search) + # 3 response branches (empty-query list, title-match, content-match) each build + # an `item` and must pass it through _redact_sidebar_title_fields. + assert src.count("_redact_sidebar_title_fields(item") >= 3, ( + "a /api/sessions/search branch builds a row without redacting the derived " + "title fields (display_title/_state_db_title/parent_title)" + ) + # And it must read the setting once, not per-_redact_text call. + assert "_search_redact_enabled" in src diff --git a/tests/test_session_lineage_metadata_api.py b/tests/test_session_lineage_metadata_api.py index d0ab29d4..22635e57 100644 --- a/tests/test_session_lineage_metadata_api.py +++ b/tests/test_session_lineage_metadata_api.py @@ -58,6 +58,27 @@ def _ensure_state_db(path): return conn +def _ensure_messages_table(conn): + conn.execute( + """ + CREATE TABLE messages ( + session_id TEXT, + role TEXT, + content TEXT, + timestamp REAL + ) + """ + ) + + +def _insert_state_message(conn, sid, *, role, content, timestamp): + conn.execute( + "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)", + (sid, role, content, timestamp), + ) + conn.commit() + + def _insert_state_row(conn, sid, *, title=None, parent=None, ended_at=None, end_reason=None, started_at=None, source='webui', session_source=None): conn.execute( """ @@ -391,6 +412,194 @@ def test_generic_webui_title_gets_read_only_state_db_display_title(_isolate): conn.close() +def test_generic_subagent_title_gets_goal_display_title(_isolate): + conn = _ensure_state_db(_isolate) + _ensure_messages_table(conn) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_api_subagent_goal", title="Subagent Session", updated_at=t0) + _insert_state_row( + conn, + "lineage_api_subagent_goal", + title="Subagent Session", + source="subagent", + started_at=t0, + ) + _insert_state_message( + conn, + "lineage_api_subagent_goal", + role="user", + content="Find the root cause of the failing sidebar test", + timestamp=t0 + 1, + ) + + row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_goal"] + + assert row["title"] == "Subagent Session" + assert row["display_title"] == "Find the root cause of the failing sidebar test" + finally: + conn.close() + + +def test_custom_subagent_title_stays_authoritative(_isolate): + conn = _ensure_state_db(_isolate) + _ensure_messages_table(conn) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_api_subagent_custom", title="Investigate auth", updated_at=t0) + _insert_state_row( + conn, + "lineage_api_subagent_custom", + title="Investigate auth", + source="subagent", + started_at=t0, + ) + _insert_state_message( + conn, + "lineage_api_subagent_custom", + role="user", + content="A different goal", + timestamp=t0 + 1, + ) + + row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_custom"] + + assert row["title"] == "Investigate auth" + assert "display_title" not in row + finally: + conn.close() + + +def test_generic_subagent_title_falls_back_without_first_user_message(_isolate): + conn = _ensure_state_db(_isolate) + _ensure_messages_table(conn) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_api_subagent_empty", title="Subagent Session", updated_at=t0) + _insert_state_row( + conn, + "lineage_api_subagent_empty", + title="Subagent Session", + source="subagent", + started_at=t0, + ) + _insert_state_message( + conn, + "lineage_api_subagent_empty", + role="assistant", + content="Only assistant output", + timestamp=t0 + 1, + ) + + row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_empty"] + + assert row["title"] == "Subagent Session" + assert "display_title" not in row + finally: + conn.close() + + +def test_generic_subagent_title_skips_null_first_user_message(_isolate): + conn = _ensure_state_db(_isolate) + _ensure_messages_table(conn) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_api_subagent_null_first", title="Subagent Session", updated_at=t0) + _insert_state_row( + conn, + "lineage_api_subagent_null_first", + title="Subagent Session", + source="subagent", + started_at=t0, + ) + _insert_state_message( + conn, + "lineage_api_subagent_null_first", + role="user", + content=None, + timestamp=t0 + 1, + ) + _insert_state_message( + conn, + "lineage_api_subagent_null_first", + role="user", + content="Recover the next usable delegated title", + timestamp=t0 + 2, + ) + + row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_null_first"] + + assert row["title"] == "Subagent Session" + assert row["display_title"] == "Recover the next usable delegated title" + finally: + conn.close() + + +def test_generic_subagent_title_respects_sidebar_override_cap(_isolate, monkeypatch): + conn = _ensure_state_db(_isolate) + _ensure_messages_table(conn) + older = time.time() - 200 + newer = time.time() - 100 + try: + monkeypatch.setenv("HERMES_WEBUI_STATE_DB_OVERRIDE_TOP_N", "1") + _save_webui_session("lineage_api_subagent_old", title="Subagent Session", updated_at=older) + _save_webui_session("lineage_api_subagent_new", title="Subagent Session", updated_at=newer) + _insert_state_row( + conn, + "lineage_api_subagent_old", + title="Subagent Session", + source="subagent", + started_at=older, + ) + _insert_state_row( + conn, + "lineage_api_subagent_new", + title="Subagent Session", + source="subagent", + started_at=newer, + ) + _insert_state_message( + conn, + "lineage_api_subagent_old", + role="user", + content="Older delegated title", + timestamp=older + 1, + ) + _insert_state_message( + conn, + "lineage_api_subagent_new", + role="user", + content="Newest delegated title", + timestamp=newer + 1, + ) + + rows = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)} + + assert rows["lineage_api_subagent_new"]["display_title"] == "Newest delegated title" + assert "display_title" not in rows["lineage_api_subagent_old"] + finally: + conn.close() +def test_generic_subagent_title_falls_back_without_messages_table(_isolate): + conn = _ensure_state_db(_isolate) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_api_subagent_no_messages", title="Subagent Session", updated_at=t0) + _insert_state_row( + conn, + "lineage_api_subagent_no_messages", + title="Subagent Session", + source="subagent", + started_at=t0, + ) + + row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_no_messages"] + + assert row["title"] == "Subagent Session" + assert "display_title" not in row + finally: + conn.close() + + def test_state_db_display_title_does_not_override_custom_json_title(_isolate): """Manual/custom JSON titles stay authoritative even when state.db differs.""" conn = _ensure_state_db(_isolate)