Merge remote-tracking branch 'origin/master' into paperclip/pr-5732
This commit is contained in:
commit
d9e45f1d07
6 changed files with 579 additions and 22 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
181
api/streaming.py
181
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)
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue