Merge remote-tracking branch 'origin/master' into paperclip/pr-5732

This commit is contained in:
Frank Song 2026-07-10 05:27:46 +08:00
commit d9e45f1d07
6 changed files with 579 additions and 22 deletions

View file

@ -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/<id>` 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)

View file

@ -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"}

View file

@ -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)

View file

@ -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'));

View file

@ -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()

View file

@ -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