Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ def __init__(self, session_id: str=None, title: str='Untitled',
context_messages=None,
compression_anchor_visible_idx=None,
compression_anchor_message_key=None,
compression_anchor_summary=None,
context_length=None, threshold_tokens=None,
last_prompt_tokens=None,
gateway_routing=None, gateway_routing_history=None,
Expand Down Expand Up @@ -361,6 +362,7 @@ def __init__(self, session_id: str=None, title: str='Untitled',
self.context_messages = context_messages if isinstance(context_messages, list) else []
self.compression_anchor_visible_idx = compression_anchor_visible_idx
self.compression_anchor_message_key = compression_anchor_message_key
self.compression_anchor_summary = compression_anchor_summary
self.context_length = context_length
self.threshold_tokens = threshold_tokens
self.last_prompt_tokens = last_prompt_tokens
Expand Down Expand Up @@ -411,6 +413,7 @@ def save(self, touch_updated_at: bool = True, skip_index: bool = False) -> None:
'personality', 'active_stream_id',
'pending_user_message', 'pending_attachments', 'pending_started_at',
'compression_anchor_visible_idx', 'compression_anchor_message_key',
'compression_anchor_summary',
'context_length', 'threshold_tokens', 'last_prompt_tokens',
'gateway_routing', 'gateway_routing_history', 'llm_title_generated',
'parent_session_id',
Expand Down Expand Up @@ -572,6 +575,7 @@ def compact(self, include_runtime=False, active_stream_ids=None) -> dict:
'personality': self.personality,
'compression_anchor_visible_idx': self.compression_anchor_visible_idx,
'compression_anchor_message_key': self.compression_anchor_message_key,
'compression_anchor_summary': self.compression_anchor_summary,
'context_length': self.context_length,
'threshold_tokens': self.threshold_tokens,
'last_prompt_tokens': self.last_prompt_tokens,
Expand Down
38 changes: 38 additions & 0 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7505,6 +7505,38 @@ def _anchor_message_key(m):
return None
return {"role": role, "ts": ts, "text": norm, "attachments": attach_count}

def _compression_summary_from_messages(messages):
text = None
for m in reversed(messages or []):
if not isinstance(m, dict):
continue
role = str(m.get("role") or "").lower()
if role != "assistant":
continue
if not isinstance(m.get("content"), str):
continue
content = str(m.get("content") or "").strip()
if not content:
continue
norm = re.sub(r"\s+", " ", content).strip()
if (
"context compaction" in norm.lower()
or "context compression" in norm.lower()
):
return norm
return None

def _compact_summary_text(raw_text):
if not isinstance(raw_text, str):
return None
txt = raw_text.strip()
if not txt:
return None
txt = re.sub(r"\s+", " ", txt)
if len(txt) > 320:
txt = f"{txt[:314]}…"
return txt

try:
require(body, "session_id")
except ValueError as e:
Expand Down Expand Up @@ -7691,6 +7723,12 @@ def _summarize_manual_compression(
visible_after = _visible_messages_for_anchor(compressed)
s.compression_anchor_visible_idx = max(0, len(visible_after) - 1) if visible_after else None
s.compression_anchor_message_key = _anchor_message_key(visible_after[-1]) if visible_after else None
summary_text = None
if isinstance(summary, dict):
summary_text = summary.get("reference_message") or summary.get("token_line") or summary.get("headline")
s.compression_anchor_summary = _compact_summary_text(
summary_text or _compression_summary_from_messages(compressed) or ""
)
s.save()

session_payload = redact_session_data(
Expand Down
92 changes: 92 additions & 0 deletions api/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -1536,6 +1536,87 @@ def _is_context_compression_marker(msg):
)


def _compact_summary_text(raw_text: str | None, limit: int = 320) -> str | None:
"""Normalize a text blob used in compression summary cards."""
if not isinstance(raw_text, str):
return None
txt = raw_text.strip()
if not txt:
return None
txt = re.sub(r"\s+", " ", txt).strip()
if len(txt) > limit:
txt = f"{txt[: limit - 6]}…"
return txt


def _compression_anchor_message_key(message):
if not isinstance(message, dict):
return None
role = str(message.get('role') or '')
if not role or role == 'tool':
return None
content = message.get('content', '')
text = _message_text(content)
if len(text) > 160:
text = text[:160]
ts = message.get('_ts') or message.get('timestamp')
attachments = message.get('attachments')
attach_count = len(attachments) if isinstance(attachments, list) else 0
if not text and not attach_count and not ts:
return None
return {'role': role, 'ts': ts, 'text': text, 'attachments': attach_count}


def _visible_messages_for_compression_anchor(messages):
out = []
for m in messages or []:
if not isinstance(m, dict):
continue
role = m.get('role')
if not role or role == 'tool':
continue
content = m.get('content', '')
has_attachments = bool(m.get('attachments'))
has_tool_calls = bool(isinstance(m.get('tool_calls'), list) and m.get('tool_calls'))
has_tool_use = False
has_reasoning = bool(m.get('reasoning'))
if isinstance(content, list):
text = '\n'.join(
str(p.get('text') or p.get('content') or '')
for p in content
if isinstance(p, dict)
and p.get('type') in {'text', 'input_text', 'output_text'}
).strip()
for part in content:
if not isinstance(part, dict):
continue
if part.get('type') == 'tool_use':
has_tool_use = True
if not text:
has_reasoning = has_reasoning or any(
isinstance(part, dict)
and part.get('type') in {'thinking', 'reasoning'}
for part in content
)
else:
text = str(content or '').strip()
if text or has_attachments or has_tool_calls or has_tool_use or has_reasoning:
out.append(m)
return out


def _compression_summary_from_messages(messages):
for m in reversed(messages or []):
if not isinstance(m, dict):
continue
if not _is_context_compression_marker(m):
continue
text = _message_text(m.get('content'))
if text:
return text
return None


def _find_current_user_turn(messages, msg_text):
needle = " ".join(str(msg_text or '').split())
fallback = None
Expand Down Expand Up @@ -3001,6 +3082,17 @@ def _periodic_checkpoint():
_compressed = True
# Notify the frontend that compression happened
if _compressed:
visible_after = _visible_messages_for_compression_anchor(s.messages)
s.compression_anchor_visible_idx = (
max(0, len(visible_after) - 1) if visible_after else None
)
s.compression_anchor_message_key = (
_compression_anchor_message_key(visible_after[-1]) if visible_after else None
)
s.compression_anchor_summary = _compact_summary_text(
_compression_summary_from_messages(s.messages)
or _compression_summary_from_messages(s.context_messages)
)
put('compressed', {
'message': 'Context auto-compressed to continue the conversation',
})
Expand Down
9 changes: 7 additions & 2 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -4759,6 +4759,9 @@ function renderMessages(options){
const sessionCompressionAnchorKey=(
S.session && S.session.compression_anchor_message_key && typeof S.session.compression_anchor_message_key==='object'
) ? S.session.compression_anchor_message_key : null;
const sessionCompressionSummary=(
S.session && typeof S.session.compression_anchor_summary==='string'
) ? S.session.compression_anchor_summary.trim() : '';
const preservedCompressionTaskMessages=_latestPreservedCompressionTaskListMessages(S.messages);
const vis=S.messages.filter(m=>{
if(!m||!m.role||m.role==='tool')return false;
Expand All @@ -4775,8 +4778,10 @@ function renderMessages(options){
inner.innerHTML='';
const compressionNode=compressionState?_compressionCardsNode(compressionState):null;
const referenceMessage=S.messages.find(m=>_isContextCompactionMessage(m));
const referenceText=referenceMessage?msgContent(referenceMessage)||String(referenceMessage.content||''):'';
const referenceNode=(!compressionState && referenceMessage && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey))
const referenceText=referenceMessage
? msgContent(referenceMessage)||String(referenceMessage.content||'')
: sessionCompressionSummary;
const referenceNode=(!compressionState && !!referenceText && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey || sessionCompressionSummary))
? (()=>{const row=document.createElement('div');row.innerHTML=`<div class="compression-turn"><div class="compression-turn-blocks">${_compressionReferenceCardHtml(referenceText,false)}${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}</div></div>`;return row.firstElementChild;})()
: null;
let preservedCompressionTaskCardsAttached=!!referenceNode;
Expand Down
10 changes: 10 additions & 0 deletions tests/test_auto_compression_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,16 @@ def test_preserved_task_list_renders_through_compression_card_path():
assert "_contextCompactionMessageHtml(m, tsTitle, preservedForThisCard)" in src


def test_context_anchor_reference_uses_session_summary_fallback():
src = _read("static/ui.js")

assert "sessionCompressionSummary" in src
assert "const sessionCompressionSummary" in src
assert "referenceText=referenceMessage" in src
assert ": sessionCompressionSummary" in src
assert "!!referenceText && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey || sessionCompressionSummary)" in src


def test_preserved_task_list_attaches_once_per_render():
src = _read("static/ui.js")

Expand Down
10 changes: 9 additions & 1 deletion tests/test_sprint46.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from api.models import Session
from api.config import SESSION_DIR
from api.routes import _handle_session_compress
from api.routes import _handle_session_compress, get_session
from tests._pytest_port import BASE


Expand Down Expand Up @@ -141,6 +141,14 @@ def test_session_compress_roundtrip(monkeypatch, cleanup_test_sessions):
{"role": "user", "content": "one"},
{"role": "assistant", "content": "four"},
]
assert payload["session"]["compression_anchor_summary"] is not None
assert payload["session"]["compression_anchor_visible_idx"] == 1
assert isinstance(payload["session"]["compression_anchor_message_key"], dict)
assert payload["session"]["compression_anchor_message_key"].get("role") == "assistant"
loaded = get_session(sid)
assert loaded.compression_anchor_summary == payload["session"]["compression_anchor_summary"]
assert loaded.compression_anchor_visible_idx == payload["session"]["compression_anchor_visible_idx"]
assert loaded.compression_anchor_message_key == payload["session"]["compression_anchor_message_key"]
assert _FakeAgent.last_instance is not None
assert _FakeAgent.last_instance.context_compressor.calls[0]["focus_topic"] == "database schema"

Expand Down
Loading