Fix empty partial activity tail recency
This commit is contained in:
@@ -3,6 +3,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Empty partial activity rows preserved from cancelled turns no longer define
|
||||
sidebar recency, anchor the initial paginated message window, or get restored
|
||||
after newer completed turns. Long sessions with old activity-only partials
|
||||
after recent replies now stay grouped by their latest real message and open on
|
||||
the recent readable transcript. (#3057)
|
||||
|
||||
## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -369,12 +369,35 @@ def _message_timestamp(message):
|
||||
return None
|
||||
|
||||
|
||||
def _is_empty_partial_activity_message(message):
|
||||
"""Return True for cancelled/recovered activity rows with no reply text."""
|
||||
if not isinstance(message, dict):
|
||||
return False
|
||||
if message.get('role') != 'assistant' or not message.get('_partial'):
|
||||
return False
|
||||
content = message.get('content', '')
|
||||
if isinstance(content, str):
|
||||
return not content.strip()
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
if part.get('type') == 'text' and str(part.get('text') or part.get('content') or '').strip():
|
||||
return False
|
||||
continue
|
||||
if str(part or '').strip():
|
||||
return False
|
||||
return True
|
||||
return not str(content or '').strip()
|
||||
|
||||
|
||||
def _last_message_timestamp(messages):
|
||||
if not isinstance(messages, list):
|
||||
return None
|
||||
for message in reversed(messages):
|
||||
if isinstance(message, dict) and message.get('role') == 'tool':
|
||||
continue
|
||||
if _is_empty_partial_activity_message(message):
|
||||
continue
|
||||
ts = _message_timestamp(message)
|
||||
if ts:
|
||||
return ts
|
||||
|
||||
@@ -2167,13 +2167,15 @@ def _message_counts_as_renderable_for_window(message) -> bool:
|
||||
"""Return true when a paginated window should include this transcript row.
|
||||
|
||||
Tool result rows are rendered through their assistant anchor or hidden as raw
|
||||
tool output. A tail page containing only tool rows makes the frontend set
|
||||
``S.messages`` to a non-empty array while the visible transcript and topbar
|
||||
count stay empty. Anchor small tail windows on the newest non-tool row so
|
||||
long sessions do not open to a blank chat with only transient metadata.
|
||||
tool output. Empty partial activity rows can be preserved after cancellation
|
||||
to keep thinking/tool details inspectable, but they are not reply text. A
|
||||
tail page containing only transient metadata makes the frontend open to
|
||||
collapsed activity while newer real replies sit behind "load older messages".
|
||||
"""
|
||||
if not isinstance(message, dict):
|
||||
return False
|
||||
if _is_empty_partial_activity_message(message):
|
||||
return False
|
||||
role = str(message.get("role") or "").strip().lower()
|
||||
return bool(role and role != "tool")
|
||||
|
||||
@@ -2551,6 +2553,7 @@ from api.models import (
|
||||
get_state_db_session_summary,
|
||||
merge_session_messages_append_only,
|
||||
_session_message_merge_key,
|
||||
_is_empty_partial_activity_message,
|
||||
prune_session_from_index,
|
||||
ensure_cron_project,
|
||||
is_cron_session,
|
||||
|
||||
@@ -43,7 +43,11 @@ from api.metering import meter
|
||||
from api.run_journal import RunJournalWriter
|
||||
from api.turn_journal import append_turn_journal_event_for_stream
|
||||
from api.usage import prompt_cache_hit_percent
|
||||
from api.models import get_state_db_session_messages, reconciled_state_db_messages_for_session
|
||||
from api.models import (
|
||||
_is_empty_partial_activity_message,
|
||||
get_state_db_session_messages,
|
||||
reconciled_state_db_messages_for_session,
|
||||
)
|
||||
|
||||
# Global lock for os.environ writes. Per-session locks (_agent_lock) prevent
|
||||
# concurrent runs of the SAME session, but two DIFFERENT sessions can still
|
||||
@@ -2448,6 +2452,8 @@ def _restore_display_reasoning_metadata(previous_messages, updated_messages):
|
||||
safe_indices = {idx for idx, _ in prev_safe}
|
||||
inserted_reasoning_only = 0
|
||||
for prev_idx, prev_msg in enumerate(previous_messages):
|
||||
if _is_empty_partial_activity_message(prev_msg):
|
||||
continue
|
||||
if prev_idx in safe_indices or not _is_reasoning_only_assistant_message(prev_msg):
|
||||
continue
|
||||
safe_pos = sum(1 for idx, _ in prev_safe if idx < prev_idx) + inserted_reasoning_only
|
||||
|
||||
53
tests/test_empty_partial_activity_restore.py
Normal file
53
tests/test_empty_partial_activity_restore.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from api.streaming import _restore_display_reasoning_metadata
|
||||
|
||||
|
||||
def test_restore_display_reasoning_skips_empty_partial_activity_rows():
|
||||
previous = [
|
||||
{"role": "user", "content": "old turn", "timestamp": 1},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"_partial": True,
|
||||
"timestamp": 2,
|
||||
"reasoning": "cancelled thinking",
|
||||
"_partial_tool_calls": [{"name": "terminal", "done": True}],
|
||||
},
|
||||
{"role": "user", "content": "new turn", "timestamp": 3},
|
||||
{"role": "assistant", "content": "new answer", "timestamp": 4},
|
||||
]
|
||||
updated = [
|
||||
{"role": "user", "content": "old turn"},
|
||||
{"role": "user", "content": "new turn"},
|
||||
{"role": "assistant", "content": "new answer"},
|
||||
]
|
||||
|
||||
restored = _restore_display_reasoning_metadata(previous, updated)
|
||||
|
||||
assert [m.get("content") for m in restored] == [
|
||||
"old turn",
|
||||
"new turn",
|
||||
"new answer",
|
||||
]
|
||||
assert not any(m.get("_partial") for m in restored)
|
||||
|
||||
|
||||
def test_restore_display_reasoning_keeps_non_partial_thinking_rows():
|
||||
previous = [
|
||||
{"role": "user", "content": "old turn", "timestamp": 1},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"timestamp": 2,
|
||||
"reasoning": "visible thinking card",
|
||||
},
|
||||
{"role": "assistant", "content": "old answer", "timestamp": 3},
|
||||
]
|
||||
updated = [
|
||||
{"role": "user", "content": "old turn"},
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
]
|
||||
|
||||
restored = _restore_display_reasoning_metadata(previous, updated)
|
||||
|
||||
assert restored[1]["reasoning"] == "visible thinking card"
|
||||
assert restored[2]["content"] == "old answer"
|
||||
@@ -83,6 +83,31 @@ def test_compact_exposes_last_message_at_from_message_timestamp():
|
||||
assert compact["last_message_at"] == 200.0
|
||||
|
||||
|
||||
def test_compact_ignores_empty_partial_activity_for_last_message_at():
|
||||
s = Session(
|
||||
session_id="sess_partial_tail",
|
||||
title="Partial tail",
|
||||
updated_at=300.0,
|
||||
messages=[
|
||||
{"role": "user", "content": "today question", "timestamp": 200.0},
|
||||
{"role": "assistant", "content": "today answer", "timestamp": 201.0},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"_partial": True,
|
||||
"timestamp": 100.0,
|
||||
"reasoning": "old cancelled thinking",
|
||||
"_partial_tool_calls": [{"name": "terminal", "done": True}],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
compact = s.compact()
|
||||
|
||||
assert compact["updated_at"] == 300.0
|
||||
assert compact["last_message_at"] == 201.0
|
||||
|
||||
|
||||
def test_session_load_allows_hyphenated_safe_ids_but_rejects_traversal():
|
||||
sid = "api-182894de593468b6"
|
||||
s = _make_session(sid, "API session", updated_at=100)
|
||||
|
||||
@@ -16,6 +16,28 @@ def test_initial_msg_limit_skips_trailing_tool_only_rows():
|
||||
assert offset == 0
|
||||
|
||||
|
||||
def test_initial_msg_limit_skips_trailing_empty_partial_activity_rows():
|
||||
messages = [
|
||||
{"role": "user", "content": "today question", "timestamp": 200},
|
||||
{"role": "assistant", "content": "today answer", "timestamp": 201},
|
||||
] + [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"_partial": True,
|
||||
"timestamp": 100,
|
||||
"reasoning": f"old cancelled thinking {idx}",
|
||||
"_partial_tool_calls": [{"name": "terminal", "done": True}],
|
||||
}
|
||||
for idx in range(40)
|
||||
]
|
||||
|
||||
window, offset = _message_window_for_display(messages, msg_limit=5)
|
||||
|
||||
assert [m["content"] for m in window] == ["today question", "today answer"]
|
||||
assert offset == 0
|
||||
|
||||
|
||||
def test_msg_limit_keeps_raw_tail_when_it_has_renderable_rows():
|
||||
messages = [
|
||||
{"role": "user", "content": f"u{idx}"} if idx % 2 == 0 else {"role": "assistant", "content": f"a{idx}"}
|
||||
|
||||
Reference in New Issue
Block a user