chore: uptick
This commit is contained in:
@@ -212,6 +212,82 @@ class TestMessageStorage:
|
||||
messages = db.get_messages("s1")
|
||||
assert messages[0]["tool_calls"] == tool_calls
|
||||
|
||||
def test_multimodal_list_content_round_trip(self, db):
|
||||
"""Multimodal ``content`` (list of parts) must survive the SQLite
|
||||
round-trip. sqlite3 cannot bind Python lists directly, so the DB
|
||||
layer JSON-encodes structured content on write and decodes on read.
|
||||
|
||||
Regression test for the "Error binding parameter 3: type 'list' is
|
||||
not supported" crash users hit when pasting screenshots into the
|
||||
TUI (issue #17522).
|
||||
"""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
content = [
|
||||
{"type": "text", "text": "describe this screenshot"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,iVBORw0KG..."},
|
||||
},
|
||||
]
|
||||
|
||||
# Write must not raise
|
||||
db.append_message("s1", role="user", content=content)
|
||||
|
||||
# get_messages decodes back to the original list
|
||||
msgs = db.get_messages("s1")
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0]["content"] == content
|
||||
|
||||
# get_messages_as_conversation decodes back to the original list
|
||||
conv = db.get_messages_as_conversation("s1")
|
||||
assert len(conv) == 1
|
||||
assert conv[0] == {"role": "user", "content": content}
|
||||
|
||||
def test_dict_content_round_trip(self, db):
|
||||
"""Dict-shaped content (e.g. provider wrappers) also round-trips."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
content = {"parts": [{"text": "hi"}]}
|
||||
|
||||
db.append_message("s1", role="user", content=content)
|
||||
msgs = db.get_messages("s1")
|
||||
assert msgs[0]["content"] == content
|
||||
|
||||
def test_string_content_unchanged_by_encoding(self, db):
|
||||
"""Plain strings must not be wrapped — FTS search and legacy
|
||||
consumers depend on raw-string storage for text content.
|
||||
"""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message("s1", role="user", content="plain text")
|
||||
|
||||
# Peek at the raw column to confirm no encoding was applied
|
||||
with db._lock:
|
||||
row = db._conn.execute(
|
||||
"SELECT content FROM messages WHERE session_id = ?", ("s1",)
|
||||
).fetchone()
|
||||
assert row["content"] == "plain text"
|
||||
|
||||
def test_replace_messages_handles_multimodal_content(self, db):
|
||||
"""`replace_messages` (used by /retry, /undo, /compress) must also
|
||||
handle list content without crashing."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
content = [
|
||||
{"type": "text", "text": "look at this"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA"}},
|
||||
]
|
||||
|
||||
db.replace_messages(
|
||||
"s1",
|
||||
[
|
||||
{"role": "user", "content": content},
|
||||
{"role": "assistant", "content": "I see a screenshot."},
|
||||
],
|
||||
)
|
||||
|
||||
msgs = db.get_messages("s1")
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0]["content"] == content
|
||||
assert msgs[1]["content"] == "I see a screenshot."
|
||||
|
||||
def test_get_messages_as_conversation(self, db):
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message("s1", role="user", content="Hello")
|
||||
@@ -323,6 +399,27 @@ class TestMessageStorage:
|
||||
assert msg["reasoning"] == "Thinking about what to say"
|
||||
assert msg["reasoning_details"] == details
|
||||
|
||||
def test_finish_reason_restored_by_get_messages_as_conversation(self, db):
|
||||
"""finish_reason on assistant messages must survive conversation replay.
|
||||
|
||||
Without this, /branch copies and other transcript round-trips silently
|
||||
drop the provider's stop signal.
|
||||
"""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message(
|
||||
"s1",
|
||||
role="assistant",
|
||||
content="Done",
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
db.append_message("s1", role="user", content="next")
|
||||
|
||||
conv = db.get_messages_as_conversation("s1")
|
||||
assert conv[0]["role"] == "assistant"
|
||||
assert conv[0]["finish_reason"] == "tool_calls"
|
||||
# Non-assistant rows should not have a finish_reason key added.
|
||||
assert "finish_reason" not in conv[1]
|
||||
|
||||
def test_reasoning_content_persisted_and_restored(self, db):
|
||||
"""reasoning_content must survive session replay as its own field."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
@@ -1719,6 +1816,97 @@ class TestListSessionsRich:
|
||||
# No messages, so last_active falls back to started_at
|
||||
assert sessions[0]["last_active"] == sessions[0]["started_at"]
|
||||
|
||||
def test_order_by_last_active_surfaces_recently_touched_older_session_first(self, db):
|
||||
t0 = 1709500000.0
|
||||
db.create_session("old", "cli")
|
||||
db.create_session("new", "cli")
|
||||
|
||||
with db._lock:
|
||||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "old"))
|
||||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0 + 10, "new"))
|
||||
|
||||
db.append_message("old", "user", "old first")
|
||||
db.append_message("new", "user", "new first")
|
||||
db.append_message("old", "assistant", "old touched later")
|
||||
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"UPDATE messages SET timestamp=? WHERE session_id=? AND role=? AND content=?",
|
||||
(t0 + 1, "old", "user", "old first"),
|
||||
)
|
||||
db._conn.execute(
|
||||
"UPDATE messages SET timestamp=? WHERE session_id=? AND role=? AND content=?",
|
||||
(t0 + 11, "new", "user", "new first"),
|
||||
)
|
||||
db._conn.execute(
|
||||
"UPDATE messages SET timestamp=? WHERE session_id=? AND role=? AND content=?",
|
||||
(t0 + 20, "old", "assistant", "old touched later"),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
assert [s["id"] for s in db.list_sessions_rich(limit=5)] == ["new", "old"]
|
||||
assert [
|
||||
s["id"] for s in db.list_sessions_rich(limit=5, order_by_last_active=True)
|
||||
] == ["old", "new"]
|
||||
|
||||
def test_order_by_last_active_uses_compression_tip_activity(self, db):
|
||||
"""A compression root whose tip was touched recently must rank above
|
||||
a newer uncompressed session, even when that tip activity lives in a
|
||||
different row and the outer LIMIT could otherwise cut it.
|
||||
|
||||
This is the case that forced SQL-level chain walking: a naive "cap
|
||||
the SQL fetch at limit*K" optimization would drop the old root off
|
||||
the SQL page before post-projection could promote it.
|
||||
"""
|
||||
t0 = 1709500000.0
|
||||
db.create_session("root1", "cli")
|
||||
with db._lock:
|
||||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "root1"))
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET ended_at=?, end_reason=? WHERE id=?",
|
||||
(t0 + 100, "compression", "root1"),
|
||||
)
|
||||
db.append_message("root1", "user", "old ask")
|
||||
|
||||
# Continuation tip created after root ended; last activity much later.
|
||||
db.create_session("tip1", "cli", parent_session_id="root1")
|
||||
with db._lock:
|
||||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0 + 101, "tip1"))
|
||||
db.append_message("tip1", "user", "latest message")
|
||||
|
||||
# Bunch of newer, uncompressed sessions — fresher start_at but older
|
||||
# last activity than the tip. Explicitly pin message timestamps so
|
||||
# they don't pick up wall-clock from append_message.
|
||||
for i in range(5):
|
||||
sid = f"newer{i}"
|
||||
db.create_session(sid, "cli")
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at=? WHERE id=?",
|
||||
(t0 + 500 + i, sid),
|
||||
)
|
||||
db.append_message(sid, "user", f"msg {i}")
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"UPDATE messages SET timestamp=? WHERE session_id=? AND content=?",
|
||||
(t0 + 500 + i, sid, f"msg {i}"),
|
||||
)
|
||||
|
||||
# Tip activity timestamp is the latest thing in the DB.
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"UPDATE messages SET timestamp=? WHERE session_id=? AND content=?",
|
||||
(t0 + 10_000, "tip1", "latest message"),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
# limit=1 is the stress test: the old root must win the single slot.
|
||||
top = db.list_sessions_rich(limit=1, order_by_last_active=True)
|
||||
assert len(top) == 1
|
||||
# Projection surfaces the tip's id in the root's slot.
|
||||
assert top[0]["id"] == "tip1"
|
||||
assert top[0]["_lineage_root_id"] == "root1"
|
||||
|
||||
def test_rich_list_includes_title(self, db):
|
||||
db.create_session("s1", "cli")
|
||||
db.set_session_title("s1", "refactoring auth")
|
||||
|
||||
Reference in New Issue
Block a user