* fix: prevent kanban rows from hiding CLI sessions Keep worker-heavy Kanban history out of the bounded interactive session window and recover it through a separate capped pass. Add regression coverage and update query-pass contract tests. * docs: describe imported session projection bounds --------- Co-authored-by: zicochaos <23367003+zicochaos@users.noreply.github.com> Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
224 lines
7.8 KiB
Python
224 lines
7.8 KiB
Python
"""ACP-sourced state.db sessions must be visible in the sidebar.
|
|
|
|
The gateway's ACP adapter (Agent Client Protocol — Zed, external device
|
|
bridges such as a Rabbit R1) persists sessions to state.db with
|
|
``source='acp'``. Before this fix, 'acp' normalised to session_source
|
|
'other', which fell through BOTH sidebar buckets:
|
|
|
|
- ``sidebar_source=webui`` skips the state.db projection entirely, and an
|
|
ACP session has no WebUI sidecar, so it never appeared there.
|
|
- ``sidebar_source=cli`` keeps only CLI-classified rows
|
|
(``_is_cli_session_for_settings``), which dropped 'other' rows.
|
|
|
|
ACP is a local interactive agent client like the CLI/TUI, so it is now
|
|
classified into the CLI family.
|
|
"""
|
|
|
|
import sqlite3
|
|
import time
|
|
import unittest.mock
|
|
|
|
import api.models as models
|
|
from api.agent_sessions import (
|
|
is_cli_session_row,
|
|
is_cli_session_row_visible,
|
|
normalize_agent_session_source,
|
|
)
|
|
|
|
|
|
def test_acp_normalizes_to_cli_family():
|
|
meta = normalize_agent_session_source('acp')
|
|
assert meta['session_source'] == 'cli'
|
|
assert meta['raw_source'] == 'acp'
|
|
assert meta['source_label'] == 'ACP'
|
|
|
|
|
|
def test_acp_row_is_cli_classified():
|
|
row = {'id': 'e91ffb31-654c-4b62-9ca1-6ecc16423899', 'source': 'acp'}
|
|
assert is_cli_session_row({**row, **normalize_agent_session_source('acp')}) is True
|
|
# Raw rows (no prior normalization) must classify the same way — several
|
|
# callers pass sidecar/state rows that only carry source metadata.
|
|
assert is_cli_session_row(row) is True
|
|
|
|
|
|
def test_acp_row_with_messages_stays_visible_even_when_ended():
|
|
"""Ended/untitled ACP rows are user-driven conversations, never framework noise."""
|
|
row = {
|
|
'id': 'acp-ended',
|
|
'source': 'acp',
|
|
'title': 'Acp Session', # default-shaped title
|
|
'message_count': 4,
|
|
'actual_message_count': 4,
|
|
'actual_user_message_count': 1, # below CLI_MIN_UNTITLED_USER_MESSAGE_COUNT
|
|
'ended_at': 1751000000.0,
|
|
'end_reason': 'client_disconnect',
|
|
}
|
|
assert is_cli_session_row_visible({**row, **normalize_agent_session_source('acp')}) is True
|
|
|
|
|
|
def test_empty_acp_row_stays_hidden():
|
|
"""Zero-message ACP rows (connect/reconnect stubs) must not clutter the sidebar."""
|
|
row = {
|
|
'id': 'acp-empty',
|
|
'source': 'acp',
|
|
'title': None,
|
|
'message_count': 0,
|
|
'actual_message_count': 0,
|
|
'actual_user_message_count': 0,
|
|
}
|
|
assert is_cli_session_row_visible({**row, **normalize_agent_session_source('acp')}) is False
|
|
|
|
|
|
def test_acp_row_without_user_turns_stays_hidden():
|
|
"""An ACP row holding only assistant/tool/system messages is not user-driven.
|
|
|
|
A replayed or aborted turn can leave an ended ACP connection with a
|
|
positive message_count but zero user turns — it must not surface in the
|
|
sidebar (Greptile review on #5939).
|
|
"""
|
|
row = {
|
|
'id': 'acp-no-user-turns',
|
|
'source': 'acp',
|
|
'title': 'Replayed segment',
|
|
'message_count': 3,
|
|
'actual_message_count': 3,
|
|
'actual_user_message_count': 0,
|
|
'ended_at': 1751000000.0,
|
|
'end_reason': 'client_disconnect',
|
|
}
|
|
assert is_cli_session_row_visible({**row, **normalize_agent_session_source('acp')}) is False
|
|
|
|
|
|
# --- state.db projection (mirrors tests/test_issue3586_cli_session_source_label.py) ---
|
|
|
|
def _make_state_db(path, sessions):
|
|
conn = sqlite3.connect(str(path))
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
source TEXT,
|
|
session_source TEXT,
|
|
title TEXT,
|
|
model TEXT,
|
|
started_at REAL NOT NULL,
|
|
message_count INTEGER DEFAULT 0,
|
|
parent_session_id TEXT,
|
|
ended_at REAL,
|
|
end_reason TEXT
|
|
);
|
|
CREATE INDEX idx_sessions_started ON sessions(started_at);
|
|
CREATE TABLE messages (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT,
|
|
role TEXT,
|
|
content TEXT,
|
|
timestamp REAL
|
|
);
|
|
CREATE INDEX idx_messages_session ON messages(session_id, timestamp);
|
|
"""
|
|
)
|
|
base = time.time() - len(sessions)
|
|
for i, sess in enumerate(sessions):
|
|
sid = sess['id']
|
|
started = base + i
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO sessions
|
|
(id, source, session_source, title, model, started_at, message_count,
|
|
parent_session_id, ended_at, end_reason)
|
|
VALUES (?, ?, NULL, ?, 'deepseek/deepseek-chat', ?, 2, NULL, NULL, NULL)
|
|
""",
|
|
(sid, sess['source'], sess.get('title', sid), started),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, 'user', 'hello', ?)",
|
|
(f"msg_{sid}_0", sid, started),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, 'user', 'follow-up', ?)",
|
|
(f"msg_{sid}_1", sid, started + 0.1),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, 'assistant', 'hi', ?)",
|
|
(f"msg_{sid}_2", sid, started + 0.2),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def _call_uncached(tmp_path, sessions):
|
|
db = tmp_path / 'state.db'
|
|
_make_state_db(db, sessions)
|
|
with (
|
|
unittest.mock.patch('api.models.get_claude_code_sessions', return_value=[]),
|
|
unittest.mock.patch('api.models.ensure_cron_project', return_value='cron-project-id'),
|
|
):
|
|
return models._load_cli_sessions_uncached(tmp_path, db, None)
|
|
|
|
|
|
def test_acp_session_appears_in_projection_as_cli(tmp_path):
|
|
"""An ACP state.db row must surface in the sidebar projection, CLI-classified."""
|
|
sessions = [
|
|
{
|
|
'id': 'e91ffb31-654c-4b62-9ca1-6ecc16423899',
|
|
'source': 'acp',
|
|
'title': 'Raumanalyse mit Schimmelfleck-Beurteilung',
|
|
},
|
|
]
|
|
results = _call_uncached(tmp_path, sessions)
|
|
row = next(
|
|
(s for s in results if s['session_id'] == 'e91ffb31-654c-4b62-9ca1-6ecc16423899'),
|
|
None,
|
|
)
|
|
assert row is not None, "acp session should appear in the sidebar projection"
|
|
assert row['is_cli_session'] is True
|
|
assert row['session_source'] == 'cli'
|
|
assert row['source_label'] == 'ACP'
|
|
|
|
|
|
def test_newer_kanban_rows_do_not_evict_cli_sessions_from_projection(tmp_path):
|
|
"""Kanban has a bounded window separate from interactive conversations."""
|
|
kanban_count = models.KANBAN_PROJECT_CHIP_LIMIT + 5
|
|
sessions = [
|
|
{
|
|
'id': 'older-cli-session',
|
|
'source': 'cli',
|
|
'title': 'CLI conversation',
|
|
},
|
|
*[
|
|
{
|
|
'id': f'newer-kanban-{i:03d}',
|
|
'source': 'kanban',
|
|
'title': f'Kanban worker {i:03d}',
|
|
}
|
|
for i in range(kanban_count)
|
|
],
|
|
]
|
|
|
|
results = _call_uncached(tmp_path, sessions)
|
|
result_ids = {row['session_id'] for row in results}
|
|
kanban_rows = [row for row in results if row.get('source_tag') == 'kanban']
|
|
|
|
assert 'older-cli-session' in result_ids
|
|
assert len(kanban_rows) == models.KANBAN_PROJECT_CHIP_LIMIT
|
|
assert {row['session_id'] for row in kanban_rows} == {
|
|
f'newer-kanban-{i:03d}'
|
|
for i in range(kanban_count - models.KANBAN_PROJECT_CHIP_LIMIT, kanban_count)
|
|
}
|
|
|
|
from api.routes import _dedupe_cli_sidebar_sessions_for_api
|
|
|
|
hidden = _dedupe_cli_sidebar_sessions_for_api(
|
|
results,
|
|
set(),
|
|
show_kanban_sessions=False,
|
|
)
|
|
visible = _dedupe_cli_sidebar_sessions_for_api(
|
|
results,
|
|
set(),
|
|
show_kanban_sessions=True,
|
|
)
|
|
assert not any(row.get('source_tag') == 'kanban' for row in hidden)
|
|
assert sum(row.get('source_tag') == 'kanban' for row in visible) == models.KANBAN_PROJECT_CHIP_LIMIT
|