Fix empty partial activity tail recency

This commit is contained in:
Frank Song
2026-05-28 15:30:49 +08:00
parent 5528e2c579
commit 9190ab4449
7 changed files with 145 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View 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"

View File

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

View File

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