Skip to content

Commit 3d1afb8

Browse files
Keep legacy history pagination addressable
1 parent cbacaeb commit 3d1afb8

5 files changed

Lines changed: 106 additions & 55 deletions

File tree

src/opensquilla/application/session_history.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616

1717
from collections.abc import Sequence
1818
from dataclasses import dataclass
19-
from typing import Any, Protocol, cast
19+
from typing import Protocol
2020

2121
from opensquilla.history_cursor import (
22+
HISTORY_CURSOR_MAX_INTEGER,
2223
HistoryCursor,
2324
HistoryCursorInvalidatedError,
2425
)
@@ -146,13 +147,19 @@ def cursor_for_entry(entry: object) -> HistoryCursor | None:
146147
"""Return the stable integer cursor used by the history Port."""
147148

148149
created_at = getattr(entry, "created_at", None)
149-
stable_id = getattr(entry, "id", None) or getattr(entry, "message_id", None)
150-
if created_at in {None, ""} or stable_id in {None, ""}:
151-
return None
152-
try:
153-
return int(cast(Any, created_at)), int(cast(Any, stable_id))
154-
except (TypeError, ValueError):
150+
stable_id = getattr(entry, "id", None)
151+
if (
152+
not isinstance(created_at, int)
153+
or isinstance(created_at, bool)
154+
or not isinstance(stable_id, int)
155+
or isinstance(stable_id, bool)
156+
or created_at < 0
157+
or stable_id < 0
158+
or created_at > HISTORY_CURSOR_MAX_INTEGER
159+
or stable_id > HISTORY_CURSOR_MAX_INTEGER
160+
):
155161
return None
162+
return created_at, stable_id
156163

157164

158165
def paginate_transcript(

src/opensquilla/session/storage.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14128,7 +14128,9 @@ async def get_canonical_transcript_page(
1412814128
one SQLite read snapshot. ``before`` keeps its historical precedence
1412914129
over ``after`` when both cursors exist. A supplied cursor must identify
1413014130
an anchor in this session; missing, foreign, or deleted anchors fail
14131-
closed instead of being treated as an unpositioned latest read.
14131+
closed instead of being treated as an unpositioned latest read. Legacy
14132+
archived rows without an original integer id are not keyset-addressable
14133+
and are omitted while canonical coverage reports the archive incomplete.
1413214134
"""
1413314135
page_size = max(1, int(limit))
1413414136
fetch_size = page_size + 1
@@ -14235,6 +14237,7 @@ async def get_canonical_transcript_page(
1423514237
schema_version
1423614238
FROM compacted_transcript_entries
1423714239
WHERE session_id = ?
14240+
AND original_entry_id IS NOT NULL
1423814241
AND EXISTS (SELECT 1 FROM cursor_anchor)
1423914242
{archived_cursor_clause}
1424014243
ORDER BY
@@ -14288,13 +14291,6 @@ async def get_canonical_transcript_page(
1428814291
entries = entries[:page_size]
1428914292
if not ascending:
1429014293
entries.reverse()
14291-
if has_more and entries:
14292-
continuation_entry = entries[-1] if ascending else entries[0]
14293-
# Legacy compacted rows can lack their original transcript id. The
14294-
# archive is already reported incomplete; do not advertise another
14295-
# keyset page when its boundary cannot form the numeric cursor.
14296-
if continuation_entry.id is None:
14297-
has_more = False
1429814294
return entries, has_more
1429914295

1430014296
@_serialized_read

tests/test_application/test_session_history.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
HistoryPage,
1313
SessionHistoryApplication,
1414
SessionHistoryQuery,
15+
cursor_for_entry,
1516
paginate_transcript,
1617
)
1718
from opensquilla.history_cursor import HistoryCursorInvalidatedError
@@ -209,6 +210,15 @@ def test_paginate_transcript_preserves_latest_window_and_valid_cursors() -> None
209210
assert backward_more is True
210211

211212

213+
def test_cursor_for_entry_uses_only_bounded_integer_transcript_ids() -> None:
214+
assert cursor_for_entry(SimpleNamespace(id=0, message_id="999", created_at=42)) == (
215+
42,
216+
0,
217+
)
218+
assert cursor_for_entry(SimpleNamespace(id=None, message_id="7", created_at=42)) is None
219+
assert cursor_for_entry(SimpleNamespace(id=1 << 63, message_id="1", created_at=42)) is None
220+
221+
212222
@pytest.mark.parametrize("direction", ["before", "after"])
213223
def test_paginate_transcript_rejects_missing_cursor(direction: str) -> None:
214224
kwargs = {direction: (99, 99)}

tests/test_gateway/test_rpc_chat_history.py

Lines changed: 26 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -160,48 +160,34 @@ async def test_chat_history_returns_pagination_metadata_with_legacy_messages() -
160160

161161

162162
@pytest.mark.asyncio
163-
async def test_chat_history_does_not_publish_unusable_legacy_null_id_cursor(
164-
tmp_path,
165-
) -> None:
166-
storage = SessionStorage(str(tmp_path / "history-legacy-null-id.db"))
167-
await storage.connect()
168-
manager = SessionManager(storage, inject_time_prefix=False)
163+
async def test_chat_history_does_not_publish_unusable_legacy_null_id_cursor() -> None:
169164
session_key = "agent:main:webchat:legacy-null-id"
170-
node = await manager.create(session_key)
171-
try:
172-
await storage.conn.executemany(
173-
"""
174-
INSERT INTO compacted_transcript_entries (
175-
session_id, session_key, compaction_id, compaction_index,
176-
original_entry_id, message_id, role, content, created_at, archived_at
177-
) VALUES (?, ?, ?, ?, NULL, ?, 'user', ?, ?, ?)
178-
""",
179-
[
180-
(
181-
node.session_id,
182-
session_key,
183-
"legacy-compaction",
184-
0,
185-
f"legacy-{index}",
186-
f"legacy message {index}",
187-
index,
188-
index,
189-
)
190-
for index in (1, 2)
191-
],
192-
)
193-
await storage.conn.commit()
165+
legacy_entry = TranscriptEntry(
166+
id=None,
167+
session_id="legacy-session",
168+
session_key=session_key,
169+
message_id="legacy-2",
170+
role="user",
171+
content="legacy message 2",
172+
created_at=2,
173+
)
174+
manager = _FakePagedSessionManager(
175+
[legacy_entry],
176+
page={
177+
"entries": [legacy_entry],
178+
"has_more": True,
179+
"canonical_complete": False,
180+
},
181+
)
194182

195-
result = await _handle_chat_history(
196-
{"sessionKey": session_key, "limit": 1},
197-
RpcContext(
198-
conn_id="test",
199-
principal=SimpleNamespace(role="operator"),
200-
session_manager=manager,
201-
),
202-
)
203-
finally:
204-
await storage.close()
183+
result = await _handle_chat_history(
184+
{"sessionKey": session_key, "limit": 1},
185+
RpcContext(
186+
conn_id="test",
187+
principal=SimpleNamespace(role="operator"),
188+
session_manager=manager,
189+
),
190+
)
205191

206192
assert [message["message_id"] for message in result["messages"]] == ["legacy-2"]
207193
assert result["canonical_complete"] is False

tests/test_session/test_manager.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4070,6 +4070,58 @@ async def test_canonical_transcript_page_crosses_multiple_compaction_boundaries(
40704070
assert [entry.message_id for entry in forward] == [entry.message_id for entry in loaded]
40714071

40724072

4073+
@pytest.mark.asyncio
4074+
async def test_canonical_page_skips_null_legacy_ids_without_hiding_valid_rows(manager):
4075+
node = await manager.create("agent:main:webchat:legacy-null-page")
4076+
await manager._storage.conn.executemany(
4077+
"""
4078+
INSERT INTO compacted_transcript_entries (
4079+
session_id, session_key, compaction_id, compaction_index,
4080+
original_entry_id, message_id, role, content, created_at, archived_at
4081+
) VALUES (?, ?, ?, ?, ?, ?, 'user', ?, ?, ?)
4082+
""",
4083+
[
4084+
(
4085+
node.session_id,
4086+
node.session_key,
4087+
"legacy-compaction",
4088+
0,
4089+
original_id,
4090+
f"legacy-{created_at}",
4091+
f"legacy message {created_at}",
4092+
created_at,
4093+
created_at,
4094+
)
4095+
for created_at, original_id in ((1, 1), (2, None), (3, 3))
4096+
],
4097+
)
4098+
await manager._storage.conn.commit()
4099+
4100+
latest = await manager.get_canonical_transcript_page(node.session_key, limit=1)
4101+
before = await manager.get_canonical_transcript_page(
4102+
node.session_key,
4103+
limit=1,
4104+
before=(3, 3),
4105+
)
4106+
after = await manager.get_canonical_transcript_page(
4107+
node.session_key,
4108+
limit=1,
4109+
after=(1, 1),
4110+
)
4111+
full = await manager.get_canonical_transcript_page(node.session_key, limit=2)
4112+
4113+
assert [(entry.created_at, entry.id) for entry in latest.entries] == [(3, 3)]
4114+
assert latest.has_more is True
4115+
assert latest.canonical_complete is False
4116+
assert [(entry.created_at, entry.id) for entry in before.entries] == [(1, 1)]
4117+
assert before.has_more is False
4118+
assert [(entry.created_at, entry.id) for entry in after.entries] == [(3, 3)]
4119+
assert after.has_more is False
4120+
assert [(entry.created_at, entry.id) for entry in full.entries] == [(1, 1), (3, 3)]
4121+
assert full.has_more is False
4122+
assert full.canonical_complete is False
4123+
4124+
40734125
@pytest.mark.asyncio
40744126
async def test_canonical_transcript_page_preserves_turn_context(manager):
40754127
"""Paged canonical reads must keep turn_context on active and archived rows."""

0 commit comments

Comments
 (0)