diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d531217..cde2a35e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ ### Fixed +- **Imported external-agent sessions (Claude Code, Codex) no longer show a non-zero "WebUI sessions" count with an empty list.** The server session-count classifier treated a read-only external-agent import (which carries a real title) as non-CLI — counting it under `webui_session_count` — while the client renderer filed the same row in the CLI bucket. The result was a confusing "WebUI sessions (N)" chip over an empty list, with those sessions only reachable under the CLI tab. The server now classifies `external_agent`/`external-agent` sources as CLI, matching the client, so the tab counts agree with where rows render. WebUI, messaging, and delegated-subagent sessions are unaffected. Thanks @nesquena-hermes. (#5846, #5831) + +- **Long tool outputs can no longer blow up the model context after a compression event.** After the conversation is compressed, the protected-tail tool-result payloads sent to the model are now hard-capped to a token budget (applied once, only after a confirmed compression). The cap operates on the model context only (a deep copy of `context_messages`) — it never removes or reorders messages (so no tool_call/tool_result pairing is broken) and never touches the visible/persisted transcript, which keeps the full tool output. Thanks @franksong2702. (#5667, #4685) + +- **"Copy conversation link" now copies an openable URL instead of a `session://` reference.** The copy action produced the internal `session://` reference (not openable in a browser); it now copies an absolute `origin + /session/` URL — the same shape used elsewhere in the app, with tracking query params stripped. Thanks @djsavvy. (#5478) + - **The model picker now marks the active model with a "Selected" badge.** The currently-selected conversation model shows a distinct **Selected** pill (separate from the "Configured" state), and the picker opens with that model's provider group expanded and scrolled into view — so it's obvious at a glance which model is in use. Thanks @rodboev. (#5757, #5748) - **Transparent Stream no longer briefly renders a duplicate of the answer.** A live-token prose snapshot that was a strict prefix of the final answer escaped the existing dedupe guards (which only fired on near-complete matches) and rendered as a visible duplicate. A new provenance-gated guard suppresses only the ephemeral live-token accumulator row (role `prose` / kind `process_prose` / `token` source / `live-prose:*` id) when it's a strict shorter prefix of the settled answer and sits after the last tool row — so genuine intermediate narration is never suppressed. Thanks @rodboev. (#5758, #5749) diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 60e3c658..ec21b5cb 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -219,6 +219,17 @@ def is_cli_session_row(row: dict) -> bool: return False if source == "cli": return True + # External-agent imports (Claude Code, Codex, etc.) are read-only sessions + # that Hermes discovers on disk and lists alongside CLI/TUI sessions. The + # client renderer (static/sessions.js: _isCliSession) files them in the CLI + # bucket via the is_cli_session fallthrough, so the server session-count + # classifier MUST agree — otherwise the server counts them under + # webui_session_count while the client renders them under CLI, and the WebUI + # filter shows a non-zero count with an empty list (#5831). These carry a + # real title, so they'd otherwise fall through to the conservative + # default-title gate below and be misclassified as non-CLI. + if source in {"external_agent", "external-agent"}: + return True if ( source_tag in {"cli", "tui"} or raw_source in {"cli", "tui"} diff --git a/api/streaming.py b/api/streaming.py index faf16f0c..b4497e6b 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -4222,6 +4222,9 @@ def _deduplicate_context_messages(messages): msg['role'] = 'assistant' deduped.append(msg) continue + if _is_compressed_context_tool_result_summary_message(msg) and not msg.get('tool_call_id'): + deduped.append(msg) + continue key = _message_identity(msg) if key is not None and key in seen: continue @@ -4271,6 +4274,107 @@ def _assign_stable_message_ids(result_messages, *existing_arrays): return stamped +_POST_COMPRESSION_TOOL_RESULT_TOTAL_TOKENS = 4096 +_POST_COMPRESSION_TOOL_RESULT_MIN_SNIPPET_TOKENS = 256 +_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG = "_webui_pruned_tool_result_summary" +_POST_COMPRESSION_TOOL_RESULT_MARKER = "[WebUI compressed-context budget:" +_ROUGH_TOKEN_CHARS = 4 + + +def _positive_int_value(value, default: int = 0) -> int: + try: + parsed = int(value or 0) + except (TypeError, ValueError): + return default + return parsed if parsed > 0 else default + + +def _rough_text_token_count(text: str) -> int: + text = str(text or "") + if not text: + return 0 + return max(1, (len(text) + (_ROUGH_TOKEN_CHARS - 1)) // _ROUGH_TOKEN_CHARS) + + +def _post_compression_tool_result_budget(compressor) -> int: + budget = _positive_int_value( + getattr(compressor, 'tail_token_budget', None), + _POST_COMPRESSION_TOOL_RESULT_TOTAL_TOKENS, + ) + threshold = _positive_int_value(getattr(compressor, 'threshold_tokens', None), 0) + if threshold: + budget = min(budget, max(512, threshold // 2)) + return max(512, budget) + + +def _compressed_context_tool_result_summary(text: str, *, original_tokens: int, keep_tokens: int) -> str: + text = str(text or "") + keep_chars = max(0, int(keep_tokens or 0) * _ROUGH_TOKEN_CHARS) + note_prefix = f"{_POST_COMPRESSION_TOOL_RESULT_MARKER} omitted " + note_suffix = ( + f" (~{int(original_tokens or 0)} rough tokens) from this tool result; " + "the full output remains in the visible transcript/tool log.]" + ) + if keep_chars < (_POST_COMPRESSION_TOOL_RESULT_MIN_SNIPPET_TOKENS * _ROUGH_TOKEN_CHARS): + return f"{note_prefix}{len(text)} chars{note_suffix}" + note_len_for_budget = len(f"{note_prefix}{len(text)} of {len(text)} chars{note_suffix}") + snippet_limit = max(0, keep_chars - note_len_for_budget - 2) + snippet = text[:snippet_limit].rstrip() + if not snippet: + return f"{note_prefix}{len(text)} chars{note_suffix}" + omitted_chars = max(0, len(text) - len(snippet)) + note = f"{note_prefix}{omitted_chars} of {len(text)} chars{note_suffix}" + return f"{snippet}\n\n{note}" + + +def _is_compressed_context_tool_result_summary_message(msg) -> bool: + if not isinstance(msg, dict) or msg.get('role') != 'tool': + return False + return msg.get(_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG) is True + + +def _hard_prune_post_compression_tool_results(messages, *, compressor=None): + if not messages: + return messages, 0 + budget = _post_compression_tool_result_budget(compressor) + raw_tool_tokens = 0 + replacements = [] + for idx in range(len(messages) - 1, -1, -1): + msg = messages[idx] + if not isinstance(msg, dict) or msg.get('role') != 'tool': + continue + content = msg.get('content', '') + text = _raw_message_text(content) + if not text.strip(): + continue + token_count = _rough_text_token_count(text) + if _is_compressed_context_tool_result_summary_message(msg): + raw_tool_tokens += token_count + continue + remaining = max(0, budget - raw_tool_tokens) + if token_count <= remaining: + raw_tool_tokens += token_count + continue + replacements.append((idx, text, token_count, remaining)) + # Once a tool row exceeds the residual budget, older tool rows should + # not be allowed to spend that same residual budget again. + raw_tool_tokens = budget + + if not replacements: + return messages, 0 + + pruned = copy.deepcopy(list(messages)) + for idx, text, token_count, keep_tokens in replacements: + msg = pruned[idx] + msg['content'] = _compressed_context_tool_result_summary( + text, + original_tokens=token_count, + keep_tokens=keep_tokens, + ) + msg[_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG] = True + return pruned, len(replacements) + + def _prune_context_tool_results_after_compression(agent, context_messages): """Run the active compressor's cheap tool-result pruning on model context. @@ -4278,28 +4382,35 @@ def _prune_context_tool_results_after_compression(agent, context_messages): before producing the final answer. Those completed tail tool results are model-facing context, but they were produced after the compression pass and therefore did not go through the compressor's tool-output pruning. Apply the - same cheap pruning once more after a confirmed compression event. This keeps - the visible transcript untouched while preventing the next turn from seeing - raw post-compression tool dumps. + same cheap pruning once more after a confirmed compression event, then apply + a WebUI hard cap to retained tool-result payloads. This keeps the visible + transcript untouched while preventing the next turn from seeing raw + post-compression tool dumps that the compressor protected as recent tail. """ if not context_messages: return context_messages compressor = getattr(agent, 'context_compressor', None) prune = getattr(compressor, '_prune_old_tool_results', None) - if not callable(prune): - return context_messages - try: - pruned_messages, pruned_count = prune( - copy.deepcopy(context_messages), - protect_tail_count=getattr(compressor, 'protect_last_n', 20), - protect_tail_tokens=getattr(compressor, 'tail_token_budget', None), - ) - except Exception: - logger.debug("post-compression context tool-result pruning failed", exc_info=True) - return context_messages - if not pruned_count: - return context_messages - return _deduplicate_context_messages(pruned_messages) + pruned_messages = context_messages + if callable(prune): + try: + candidate_messages, pruned_count = prune( + copy.deepcopy(context_messages), + protect_tail_count=getattr(compressor, 'protect_last_n', 20), + protect_tail_tokens=getattr(compressor, 'tail_token_budget', None), + ) + if pruned_count: + pruned_messages = _deduplicate_context_messages(candidate_messages) + except Exception: + logger.debug("post-compression context tool-result pruning failed", exc_info=True) + + hard_pruned_messages, hard_pruned_count = _hard_prune_post_compression_tool_results( + pruned_messages, + compressor=compressor, + ) + if hard_pruned_count: + return _deduplicate_context_messages(hard_pruned_messages) + return pruned_messages def _restore_reasoning_metadata(previous_messages, updated_messages): @@ -4349,6 +4460,11 @@ def _restore_reasoning_metadata(previous_messages, updated_messages): # with their display counterpart. if prev_msg.get('id') is not None and cur_msg.get('id') is None: cur_msg['id'] = prev_msg['id'] + if ( + prev_msg.get(_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG) is True + and cur_msg.get(_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG) is not True + ): + cur_msg[_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG] = True if prev_msg.get('timestamp') and not cur_msg.get('timestamp'): cur_msg['timestamp'] = prev_msg['timestamp'] elif prev_msg.get('_ts') and not cur_msg.get('_ts') and not cur_msg.get('timestamp'): @@ -5092,6 +5208,7 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex previous_display = [ m for m in list(previous_display or []) if not _is_context_compression_marker(m) + and not _is_compressed_context_tool_result_summary_message(m) ] # Drop Hermes Agent internal verify-loop scaffolding (synthetic "premature # done" answer + the "[System: ...verification evidence...]" nudge) before @@ -5154,6 +5271,7 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex _message_identity(m) for m in previous_context if not _is_context_compression_marker(m) + and not _is_compressed_context_tool_result_summary_message(m) } _has_context_only_turns = bool(_context_id_set - _display_id_set) if _has_context_only_turns: @@ -5191,7 +5309,13 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex for _k in range(_cursor, _j): _ckey = context_keys[_k] _cmsg = previous_context[_k] - if _ckey is not None and _ckey not in _context_inserted and _ckey not in _display_id_set and not _is_context_compression_marker(_cmsg): + if ( + _ckey is not None + and _ckey not in _context_inserted + and _ckey not in _display_id_set + and not _is_context_compression_marker(_cmsg) + and not _is_compressed_context_tool_result_summary_message(_cmsg) + ): _backfilled.append(copy.deepcopy(_cmsg)) _context_inserted.add(_ckey) # Sync multiset: decrement keys consumed by advancing @@ -5212,7 +5336,13 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex for _k in range(_cursor, len(context_keys)): _ckey = context_keys[_k] _cmsg = previous_context[_k] - if _ckey is not None and _ckey not in _context_inserted and _ckey not in _display_id_set and not _is_context_compression_marker(_cmsg): + if ( + _ckey is not None + and _ckey not in _context_inserted + and _ckey not in _display_id_set + and not _is_context_compression_marker(_cmsg) + and not _is_compressed_context_tool_result_summary_message(_cmsg) + ): _backfilled.append(copy.deepcopy(_cmsg)) _context_inserted.add(_ckey) _cursor = len(context_keys) @@ -5225,7 +5355,13 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex _ckey = context_keys[_cursor] _cmsg = previous_context[_cursor] _cursor += 1 - if _ckey is not None and _ckey not in _context_inserted and _ckey not in _display_id_set and not _is_context_compression_marker(_cmsg): + if ( + _ckey is not None + and _ckey not in _context_inserted + and _ckey not in _display_id_set + and not _is_context_compression_marker(_cmsg) + and not _is_compressed_context_tool_result_summary_message(_cmsg) + ): _backfilled.append(copy.deepcopy(_cmsg)) _context_inserted.add(_ckey) if len(_backfilled) > len(previous_display): @@ -5315,7 +5451,10 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex candidates = candidates[:insert_at] + [current_user_msg] + candidates[insert_at:] for msg in candidates: - if _is_context_compression_marker(msg): + if ( + _is_context_compression_marker(msg) + or _is_compressed_context_tool_result_summary_message(msg) + ): continue key = _message_identity(msg) is_current_user_turn = _looks_like_current_user_turn(msg, msg_text) diff --git a/static/sessions.js b/static/sessions.js index c0fafcb4..a4b0ba20 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3976,7 +3976,7 @@ async function _copyTextToClipboard(text){ async function _copySessionLink(session){ const sid=session&&session.session_id; if(!sid) return; - const ref=_sessionInternalReferenceForSession(session); + const ref=(window.location.origin||'')+_sessionUrlForSid(sid); try{ await _copyTextToClipboard(ref); showToast(t('session_link_copied')); diff --git a/tests/test_auto_compression_card.py b/tests/test_auto_compression_card.py index 4d3641dd..4b50e53c 100644 --- a/tests/test_auto_compression_card.py +++ b/tests/test_auto_compression_card.py @@ -3,8 +3,14 @@ from pathlib import Path from api.compression_anchor import visible_messages_for_anchor from api.models import Session from api.streaming import ( + _POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG, + _compressed_context_tool_result_summary, _is_fallback_lifecycle_message, + _merge_display_messages_after_agent_result, + _message_text, _prune_context_tool_results_after_compression, + _restore_reasoning_metadata, + _sanitize_messages_for_api, ) @@ -73,6 +79,353 @@ def test_post_compression_context_prunes_tail_tool_results_with_active_compresso assert context_messages[1]["content"] == "x" * 5000 +def test_post_compression_context_hard_prunes_protected_tail_tool_payload(): + class NoopCompressor: + protect_last_n = 20 + tail_token_budget = 1024 + threshold_tokens = 4096 + + def _prune_old_tool_results(self, messages, protect_tail_count, protect_tail_tokens=None): + return messages, 0 + + compressor = NoopCompressor() + agent = type("Agent", (), {"context_compressor": compressor})() + recent_payload = ("recent protected payload\n" * 1800) + "RECENT_TOOL_PAYLOAD_END" + session = Session( + session_id="budgetproof1", + title="Budget proof", + workspace="/tmp", + model="gpt-4o", + messages=[ + {"role": "assistant", "content": "", "tool_calls": [{"id": "call_recent"}]}, + {"role": "tool", "tool_call_id": "call_recent", "content": recent_payload}, + {"role": "assistant", "content": "Final answer"}, + ], + context_messages=[ + {"role": "assistant", "content": "", "tool_calls": [{"id": "call_recent"}]}, + {"role": "tool", "tool_call_id": "call_recent", "content": recent_payload}, + {"role": "assistant", "content": "Final answer"}, + ], + ) + + session.context_messages = _prune_context_tool_results_after_compression( + agent, + session.context_messages, + ) + + visible_tool = session.messages[1]["content"] + context_tool = session.context_messages[1]["content"] + context_tool_tokens = (len(_message_text(context_tool)) + 3) // 4 + + assert visible_tool == recent_payload + assert "RECENT_TOOL_PAYLOAD_END" in visible_tool + assert context_tool != recent_payload + assert "RECENT_TOOL_PAYLOAD_END" not in context_tool + assert "WebUI compressed-context budget" in context_tool + assert session.context_messages[1][_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG] is True + assert context_tool_tokens <= compressor.tail_token_budget + + +def test_post_compression_context_note_only_replacement_consumes_residual_budget(): + class NoopCompressor: + protect_last_n = 20 + tail_token_budget = 1024 + threshold_tokens = 4096 + + def _prune_old_tool_results(self, messages, protect_tail_count, protect_tail_tokens=None): + return messages, 0 + + compressor = NoopCompressor() + agent = type("Agent", (), {"context_compressor": compressor})() + old_small_payload = "s" * 80 + old_oversize_payload = "h" * 2000 + recent_payload = "r" * 4000 + context_messages = [ + {"role": "tool", "tool_call_id": "call_old_small", "content": old_small_payload}, + {"role": "tool", "tool_call_id": "call_old_oversize", "content": old_oversize_payload}, + {"role": "tool", "tool_call_id": "call_recent", "content": recent_payload}, + ] + + pruned = _prune_context_tool_results_after_compression(agent, context_messages) + + assert pruned[2]["content"] == recent_payload + assert pruned[1]["content"] != old_oversize_payload + assert "WebUI compressed-context budget" in pruned[1]["content"] + assert pruned[1][_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG] is True + assert pruned[0]["content"] != old_small_payload + assert "WebUI compressed-context budget" in pruned[0]["content"] + assert pruned[0][_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG] is True + assert _POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG not in pruned[2] + assert context_messages[0]["content"] == old_small_payload + + +def test_post_compression_context_hard_prune_is_idempotent(): + class NoopCompressor: + protect_last_n = 20 + tail_token_budget = 1024 + threshold_tokens = 4096 + + def _prune_old_tool_results(self, messages, protect_tail_count, protect_tail_tokens=None): + return messages, 0 + + compressor = NoopCompressor() + agent = type("Agent", (), {"context_compressor": compressor})() + context_messages = [ + {"role": "tool", "tool_call_id": f"call_{idx}", "content": (f"tool {idx} payload\n" * 350)} + for idx in range(4) + ] + + once = _prune_context_tool_results_after_compression(agent, context_messages) + twice = _prune_context_tool_results_after_compression(agent, once) + + assert [msg["content"] for msg in twice] == [msg["content"] for msg in once] + assert any("WebUI compressed-context budget" in str(msg.get("content") or "") for msg in once) + assert [ + msg.get(_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG) + for msg in twice + ] == [ + msg.get(_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG) + for msg in once + ] + assert context_messages[0]["content"] != once[0]["content"] + + +def test_post_compression_context_hard_prune_preserves_old_summary_after_new_tool_rows(): + class NoopCompressor: + protect_last_n = 20 + tail_token_budget = 1024 + threshold_tokens = 4096 + + def _prune_old_tool_results(self, messages, protect_tail_count, protect_tail_tokens=None): + return messages, 0 + + compressor = NoopCompressor() + agent = type("Agent", (), {"context_compressor": compressor})() + context_messages = [ + {"role": "tool", "tool_call_id": f"call_{idx}", "content": (f"tool {idx} payload\n" * 350)} + for idx in range(4) + ] + + once = _prune_context_tool_results_after_compression(agent, context_messages) + old_summary = once[-1]["content"] + assert "\n\n[WebUI compressed-context budget:" in old_summary + assert once[-1][_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG] is True + assert f"of {len(context_messages[-1]['content'])} chars" in old_summary + + with_new_tool = once + [ + { + "role": "tool", + "tool_call_id": "call_new", + "content": "newer over-budget payload\n" * 700, + } + ] + twice = _prune_context_tool_results_after_compression(agent, with_new_tool) + + assert twice[-2]["content"] == old_summary + assert twice[-2][_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG] is True + assert f"of {len(context_messages[-1]['content'])} chars" in twice[-2]["content"] + + +def test_post_compression_context_hard_prune_counts_raw_tool_whitespace(): + class NoopCompressor: + protect_last_n = 20 + tail_token_budget = 1024 + threshold_tokens = 4096 + + def _prune_old_tool_results(self, messages, protect_tail_count, protect_tail_tokens=None): + return messages, 0 + + compressor = NoopCompressor() + agent = type("Agent", (), {"context_compressor": compressor})() + whitespace_padded_payload = ("x" + (" " * 79)) * 1000 + context_messages = [ + {"role": "tool", "tool_call_id": "call_padded", "content": whitespace_padded_payload}, + ] + + once = _prune_context_tool_results_after_compression(agent, context_messages) + + assert once[0]["content"] != whitespace_padded_payload + assert "WebUI compressed-context budget" in once[0]["content"] + assert once[0][_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG] is True + assert context_messages[0]["content"] == whitespace_padded_payload + + +def test_post_compression_context_hard_prune_keeps_idless_twin_summaries(): + class NoopCompressor: + protect_last_n = 20 + tail_token_budget = 512 + threshold_tokens = 1024 + + def _prune_old_tool_results(self, messages, protect_tail_count, protect_tail_tokens=None): + return messages, 0 + + compressor = NoopCompressor() + agent = type("Agent", (), {"context_compressor": compressor})() + payload = "same idless payload\n" * 300 + context_messages = [{"role": "tool", "content": payload} for _ in range(3)] + + once = _prune_context_tool_results_after_compression(agent, context_messages) + + assert len(once) == 3 + assert sum("WebUI compressed-context budget" in msg["content"] for msg in once) == 3 + assert sum(msg.get(_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG) is True for msg in once) == 3 + + +def test_post_compression_context_pruned_tool_summary_does_not_backfill_into_display(): + full_tool_output = "full visible output\n" * 200 + summary = _compressed_context_tool_result_summary( + full_tool_output, + original_tokens=(len(full_tool_output) + 3) // 4, + keep_tokens=0, + ) + previous_display = [ + {"role": "assistant", "content": "", "tool_calls": [{"id": "call_big"}]}, + {"role": "tool", "tool_call_id": "call_big", "content": full_tool_output}, + {"role": "assistant", "content": "Final answer"}, + ] + previous_context = [ + {"role": "assistant", "content": "", "tool_calls": [{"id": "call_big"}]}, + { + "role": "tool", + "tool_call_id": "call_big", + "content": summary, + _POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG: True, + }, + {"role": "assistant", "content": "Final answer"}, + ] + result_messages = previous_context + [ + {"role": "user", "content": "next question"}, + {"role": "assistant", "content": "next answer"}, + ] + + merged = _merge_display_messages_after_agent_result( + previous_display, + previous_context, + result_messages, + "next question", + ) + + merged_tool_contents = [msg["content"] for msg in merged if msg.get("role") == "tool"] + assert full_tool_output in merged_tool_contents + assert summary not in merged_tool_contents + + +def test_post_compression_context_raw_marker_collision_still_prunes(): + class NoopCompressor: + protect_last_n = 20 + tail_token_budget = 1024 + threshold_tokens = 4096 + + def _prune_old_tool_results(self, messages, protect_tail_count, protect_tail_tokens=None): + return messages, 0 + + compressor = NoopCompressor() + agent = type("Agent", (), {"context_compressor": compressor})() + raw_payload = ( + "tool echoed marker text: [WebUI compressed-context budget: not a generated summary]\n" + + ("raw collision payload\n" * 600) + + "RAW_COLLISION_END" + ) + context_messages = [ + {"role": "tool", "tool_call_id": "call_collision", "content": raw_payload}, + ] + + once = _prune_context_tool_results_after_compression(agent, context_messages) + twice = _prune_context_tool_results_after_compression(agent, once) + + assert once[0]["content"] != raw_payload + assert "RAW_COLLISION_END" not in once[0]["content"] + assert "WebUI compressed-context budget" in once[0]["content"] + assert once[0][_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG] is True + assert twice[0]["content"] == once[0]["content"] + assert context_messages[0]["content"] == raw_payload + + +def test_post_compression_context_exact_note_shape_without_flag_still_prunes(): + class NoopCompressor: + protect_last_n = 20 + tail_token_budget = 1024 + threshold_tokens = 4096 + + def _prune_old_tool_results(self, messages, protect_tail_count, protect_tail_tokens=None): + return messages, 0 + + compressor = NoopCompressor() + agent = type("Agent", (), {"context_compressor": compressor})() + generated_note = _compressed_context_tool_result_summary( + "prior generated payload", + original_tokens=512, + keep_tokens=0, + ) + raw_payload = ( + ("raw shape-collision payload\n" * 600) + + "RAW_SHAPE_COLLISION_END\n\n" + + generated_note + ) + context_messages = [ + {"role": "tool", "tool_call_id": "call_shape_collision", "content": raw_payload}, + ] + + once = _prune_context_tool_results_after_compression(agent, context_messages) + + assert once[0]["content"] != raw_payload + assert "RAW_SHAPE_COLLISION_END" not in once[0]["content"] + assert once[0][_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG] is True + assert context_messages[0]["content"] == raw_payload + + +def test_post_compression_context_unflagged_note_shape_can_stay_visible(): + visible_shape = _compressed_context_tool_result_summary( + "visible tool payload", + original_tokens=256, + keep_tokens=0, + ) + previous_display = [ + {"role": "assistant", "content": "", "tool_calls": [{"id": "call_visible"}]}, + {"role": "tool", "tool_call_id": "call_visible", "content": visible_shape}, + ] + previous_context = [ + {"role": "assistant", "content": "", "tool_calls": [{"id": "call_visible"}]}, + {"role": "tool", "tool_call_id": "call_visible", "content": visible_shape}, + ] + result_messages = previous_context + [ + {"role": "user", "content": "next question"}, + {"role": "assistant", "content": "next answer"}, + ] + + merged = _merge_display_messages_after_agent_result( + previous_display, + previous_context, + result_messages, + "next question", + ) + + assert visible_shape in [msg.get("content") for msg in merged if msg.get("role") == "tool"] + + +def test_post_compression_context_private_flag_restored_but_not_sent_to_provider(): + previous_context = [ + {"role": "assistant", "content": "", "tool_calls": [{"id": "call_pruned"}]}, + { + "role": "tool", + "tool_call_id": "call_pruned", + "content": _compressed_context_tool_result_summary( + "large generated payload", + original_tokens=512, + keep_tokens=0, + ), + _POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG: True, + }, + ] + api_safe = _sanitize_messages_for_api(previous_context) + + assert _POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG not in api_safe[1] + + restored = _restore_reasoning_metadata(previous_context, api_safe) + + assert restored[1][_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG] is True + + def test_auto_compression_running_sse_uses_active_session_running_card(): block = _compressing_listener_block() diff --git a/tests/test_issue2351_cli_session_source_filter.py b/tests/test_issue2351_cli_session_source_filter.py index a8dca6f8..438386c7 100644 --- a/tests/test_issue2351_cli_session_source_filter.py +++ b/tests/test_issue2351_cli_session_source_filter.py @@ -257,3 +257,51 @@ def test_tui_continuation_projection_uses_latest_tip_title(): assert projected[0]["title"] == "Podcast work #17" assert projected[0]["_lineage_root_id"] == "tui_parent" assert projected[0]["_lineage_tip_id"] == "tui_tip" + + +def test_external_agent_rows_classify_as_cli_matching_client_render(): + """Regression for #5831: external_agent (Claude Code) imports must count as CLI. + + The client renderer (static/sessions.js: _isCliSession) files an + external_agent/claude_code row into the CLI bucket via the is_cli_session + fallthrough. The server session-count classifier used to return False for + such a row (it carries a real title, so it fell through to the conservative + default-title gate), counting it under webui_session_count while the client + rendered it under CLI. Result: the WebUI filter showed a non-zero count with + an empty list. Server and client must agree — external_agent is CLI. + """ + from api.agent_sessions import is_cli_session_row + + claude_code_row = { + "session_id": "claude_code_e62d0c1a6e8d55839e298973", + "title": "Overview of AI Capabilities", + "source_tag": "claude_code", + "raw_source": "claude_code", + "session_source": "external_agent", + "source_label": "Claude Code", + "is_cli_session": True, + "read_only": True, + "message_count": 225, + } + # The bug: a real-titled external_agent row was classified non-CLI. + assert is_cli_session_row(claude_code_row) is True + # Hyphenated variant is treated identically. + assert is_cli_session_row({**claude_code_row, "session_source": "external-agent"}) is True + + +def test_external_agent_classification_does_not_overreach(): + """The external_agent → CLI branch must not reclassify unrelated sources.""" + from api.agent_sessions import is_cli_session_row + + # Genuine WebUI session stays non-CLI (webui bucket). + assert is_cli_session_row( + {"session_source": "webui", "is_cli_session": False, "title": "My chat"} + ) is False + # Messaging session stays non-CLI. + assert is_cli_session_row( + {"session_source": "messaging", "source_tag": "telegram", "is_cli_session": True, "title": "tg"} + ) is False + # Delegated subagent (#5307) stays non-CLI / view-only. + assert is_cli_session_row( + {"session_source": "subagent", "is_cli_session": True, "title": "delegated child"} + ) is False