Greptile #5975 P1: capture _cronPollGeneration before /api/sessions await and re-check in _applySessionListPayload before completion-unread marking so a delayed profile-A response cannot remount cron dots after switch to B. Also skip inactive-profile cron marks in single-profile mode. Tests cover the race, positive path, and collateral call-shape contracts.
189 lines
7.7 KiB
Python
189 lines
7.7 KiB
Python
"""Regression tests for large-session sidebar resilience.
|
|
|
|
The sidebar must fail visibly when the sessions API times out, must not let
|
|
optional project metadata blank the conversations list, and must not return
|
|
bulky session-detail fields in /api/sessions rows.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
REPO_ROOT = Path(__file__).parent.parent
|
|
|
|
|
|
def _sessions_js() -> str:
|
|
return (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def _workspace_js() -> str:
|
|
return (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def test_session_list_refresh_has_visible_failure_state_instead_of_console_only():
|
|
src = _sessions_js()
|
|
block_start = src.find("async function _runRenderSessionListRefresh")
|
|
assert block_start > 0
|
|
block_end = src.find("async function _drainRenderSessionListQueue", block_start)
|
|
assert block_end > block_start
|
|
block = src[block_start:block_end]
|
|
|
|
assert "console.warn('renderSessionList',e);" not in block
|
|
assert "_showSessionListLoadError" in block
|
|
assert "renderSessionListFromCache" in block
|
|
assert "session-list-error" in src
|
|
assert "Retry" in src
|
|
|
|
|
|
def test_sessions_and_projects_load_independently_so_projects_failure_cannot_blank_sidebar():
|
|
src = _sessions_js()
|
|
block_start = src.find("async function _runRenderSessionListRefresh")
|
|
assert block_start > 0
|
|
block_end = src.find("async function _drainRenderSessionListQueue", block_start)
|
|
assert block_end > block_start
|
|
block = src[block_start:block_end]
|
|
helper_start = src.find("async function _loadSidebarSessionListPayload")
|
|
assert helper_start > 0
|
|
helper_end = src.find("async function _drainRenderSessionListQueue", helper_start)
|
|
assert helper_end > helper_start
|
|
helper = src[helper_start:helper_end]
|
|
|
|
assert "Promise.all" not in block
|
|
assert "_loadSidebarSessionListPayload(sessionListQS, sessionRequestOpts)" in block
|
|
assert "const projectPromise = (async() => {" in helper
|
|
assert "return await api('/api/projects' + projectQS,{timeoutToast:false});" in helper
|
|
assert "console.warn('renderProjectsList',projectError);" in helper
|
|
assert "const projData = await projectPromise;" in helper
|
|
assert "_applySessionListPayload(sessData,projData,{unreadGen})" in block
|
|
|
|
|
|
def test_sessions_api_always_retries_transient_upstream_statuses_and_boot_keeps_longer_timeout():
|
|
# #5394: the sidebar session-list GET is idempotent, so retries + retryStatuses
|
|
# (502/503/504) must be set on EVERY refresh — not gated to cold boot — so a
|
|
# transient 502 during an nginx->backend restart on a warm refresh (profile
|
|
# switch, focus/visible/reconnect) is retried instead of leaving the sidebar
|
|
# stale. The larger boot timeout + timeout retry stay boot-only.
|
|
sessions_src = _sessions_js()
|
|
workspace_src = _workspace_js()
|
|
refresh_start = sessions_src.find("async function _runRenderSessionListRefresh")
|
|
assert refresh_start > 0
|
|
refresh_end = sessions_src.find("async function _loadSidebarSessionListPayload", refresh_start)
|
|
assert refresh_end > refresh_start
|
|
refresh = sessions_src[refresh_start:refresh_end]
|
|
helper_start = sessions_src.find("async function _loadSidebarSessionListPayload")
|
|
assert helper_start > 0
|
|
helper_end = sessions_src.find("async function _drainRenderSessionListQueue", helper_start)
|
|
assert helper_end > helper_start
|
|
helper = sessions_src[helper_start:helper_end]
|
|
|
|
# Always-on retry options live in the base opts object.
|
|
assert "const sessionRequestOpts={" in refresh
|
|
assert "retries:1," in refresh
|
|
assert "retryStatuses:[502,503,504]," in refresh
|
|
|
|
boot_gate = refresh.find("if(!_sessionListHasLoadedOnce){")
|
|
assert boot_gate > 0
|
|
# The retry options are declared BEFORE the boot-only gate (i.e. unconditional).
|
|
assert refresh.index("retries:1,") < boot_gate
|
|
assert refresh.index("retryStatuses:[502,503,504],") < boot_gate
|
|
|
|
# Boot-only path still carries the larger timeout + timeout retry.
|
|
assert "sessionRequestOpts.timeoutMs=_SESSION_LIST_BOOT_TIMEOUT_MS;" in refresh
|
|
assert refresh.index("sessionRequestOpts.timeoutMs=_SESSION_LIST_BOOT_TIMEOUT_MS;") > boot_gate
|
|
assert "sessionRequestOpts.retryTimeouts=true;" in refresh
|
|
assert refresh.index("sessionRequestOpts.retryTimeouts=true;") > boot_gate
|
|
|
|
assert "const sessData = await api('/api/sessions' + sessionListQS,sessionRequestOpts);" in helper
|
|
assert "api('/api/sessions' + sessionListQS,{timeoutToast:false})" not in helper
|
|
assert "retryTimeouts" in workspace_src
|
|
assert "retryStatuses" in workspace_src
|
|
|
|
|
|
def test_sessions_sidebar_response_item_drops_bulky_detail_fields(monkeypatch):
|
|
from api import routes
|
|
|
|
monkeypatch.setattr(routes, "_session_attention_summary", lambda sid: {"kind": "none"})
|
|
row = {
|
|
"session_id": "sid-heavy",
|
|
"title": "Visible title",
|
|
"display_title": "State DB title",
|
|
"_state_db_title": "State DB title",
|
|
"updated_at": 10,
|
|
"last_message_at": 11,
|
|
"message_count": 123,
|
|
"user_message_count": 61,
|
|
"has_pending_user_message": True,
|
|
"worktree_path": "/tmp/worktree",
|
|
"worktree_branch": "feature/sidebar",
|
|
"compression_anchor_summary": "X" * 50000,
|
|
"compression_anchor_details": {"huge": True},
|
|
"context_engine_state": {"expensive": True},
|
|
"gateway_routing_history": [{"hop": 1}],
|
|
"composer_draft": "draft body",
|
|
"pending_user_message": "private pending text",
|
|
"tool_calls": [{"id": "call"}],
|
|
"messages": [{"role": "user", "content": "not for sidebar"}],
|
|
}
|
|
|
|
item = routes._sidebar_session_response_item(row, redact_enabled=False)
|
|
|
|
assert item["session_id"] == "sid-heavy"
|
|
assert item["title"] == "Visible title"
|
|
assert item["display_title"] == "State DB title"
|
|
assert item["_state_db_title"] == "State DB title"
|
|
assert item["message_count"] == 123
|
|
assert item["has_pending_user_message"] is True
|
|
assert item["worktree_path"] == "/tmp/worktree"
|
|
assert item["worktree_branch"] == "feature/sidebar"
|
|
assert item["attention"] == {"kind": "none"}
|
|
for key in (
|
|
"compression_anchor_summary",
|
|
"compression_anchor_details",
|
|
"context_engine_state",
|
|
"gateway_routing_history",
|
|
"composer_draft",
|
|
"pending_user_message",
|
|
"tool_calls",
|
|
"messages",
|
|
):
|
|
assert key not in item
|
|
|
|
|
|
def test_sidebar_allowlist_preserves_fields_consumed_by_frontend():
|
|
from api import routes
|
|
|
|
required = {
|
|
"display_title",
|
|
"_state_db_title",
|
|
"has_pending_user_message",
|
|
"worktree_branch",
|
|
}
|
|
|
|
assert required <= routes._SIDEBAR_SESSION_RESPONSE_FIELDS
|
|
assert "pending_user_message" not in routes._SIDEBAR_SESSION_RESPONSE_FIELDS
|
|
|
|
|
|
def test_session_list_error_path_uses_same_generation_guard_as_success_path():
|
|
src = _sessions_js()
|
|
block_start = src.find("async function _runRenderSessionListRefresh")
|
|
assert block_start > 0
|
|
block_end = src.find("async function _drainRenderSessionListQueue", block_start)
|
|
assert block_end > block_start
|
|
block = src[block_start:block_end]
|
|
catch_start = block.find("}catch(e){")
|
|
assert catch_start > 0
|
|
catch_block = block[catch_start:]
|
|
|
|
assert "if (_gen !== _renderSessionListGen) return;" in catch_block
|
|
assert catch_block.index("if (_gen !== _renderSessionListGen) return;") < catch_block.index(
|
|
"_showSessionListLoadError(e);"
|
|
)
|
|
|
|
|
|
def test_json_helper_can_emit_compact_json_for_large_list_endpoints():
|
|
from api.helpers import _json_response_body
|
|
|
|
body = _json_response_body({"a": 1, "nested": {"b": 2}}, pretty=False).decode("utf-8")
|
|
|
|
assert body == '{"a":1,"nested":{"b":2}}'
|
|
assert "\n" not in body
|