fix(ui): stabilize workplace chat scrolling
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from opc.plugins.office_ui.chat_store import ChatStore
|
||||
|
||||
|
||||
class ChatStoreProgressFoldingTests(unittest.TestCase):
|
||||
def test_stream_fold_preserves_first_timestamp(self) -> None:
|
||||
first = {
|
||||
"timestamp": 1_700_000_000.0,
|
||||
"type": "thinking",
|
||||
"summary": "Thinking",
|
||||
"detail": "Need ",
|
||||
"turn_id": "runtime-1:1",
|
||||
"item_id": "runtime-1:1:thinking",
|
||||
"seq": 1,
|
||||
}
|
||||
deltas = [
|
||||
{
|
||||
"timestamp": 1_700_000_000.1,
|
||||
"type": "thinking",
|
||||
"summary": "Thinking",
|
||||
"detail": "more ",
|
||||
"turn_id": "runtime-1:1",
|
||||
"item_id": "runtime-1:1:thinking",
|
||||
"seq": 2,
|
||||
},
|
||||
{
|
||||
"timestamp": 1_700_000_000.2,
|
||||
"type": "thinking",
|
||||
"summary": "Thinking",
|
||||
"detail": "context",
|
||||
"turn_id": "runtime-1:1",
|
||||
"item_id": "runtime-1:1:thinking",
|
||||
"seq": 3,
|
||||
},
|
||||
]
|
||||
|
||||
folded = ChatStore._fold_progress_entries([first], deltas)
|
||||
|
||||
self.assertEqual(len(folded), 1)
|
||||
self.assertEqual(folded[0]["timestamp"], first["timestamp"])
|
||||
self.assertEqual(folded[0]["detail"], "Need more context")
|
||||
self.assertEqual(folded[0]["seq"], 3)
|
||||
# Folding builds a replacement row and must not mutate the persisted
|
||||
# value supplied by the caller.
|
||||
self.assertEqual(first["detail"], "Need ")
|
||||
|
||||
def test_stream_fold_within_one_batch_keeps_creation_timestamp(self) -> None:
|
||||
folded = ChatStore._fold_progress_entries(
|
||||
[],
|
||||
[
|
||||
{
|
||||
"timestamp": 10.0,
|
||||
"type": "assistant",
|
||||
"summary": "Part one",
|
||||
"detail": "Part one ",
|
||||
"turn_id": "runtime-2:1",
|
||||
"stream_id": "runtime-2:1:assistant",
|
||||
"seq": 1,
|
||||
},
|
||||
{
|
||||
"timestamp": 11.0,
|
||||
"type": "assistant",
|
||||
"summary": "part two",
|
||||
"detail": "part two",
|
||||
"turn_id": "runtime-2:1",
|
||||
"stream_id": "runtime-2:1:assistant",
|
||||
"seq": 2,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(len(folded), 1)
|
||||
self.assertEqual(folded[0]["timestamp"], 10.0)
|
||||
self.assertEqual(folded[0]["detail"], "Part one part two")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -679,7 +679,11 @@ async def test_session_detail_routes_by_request_project_id() -> None:
|
||||
chat_store = SimpleNamespace(
|
||||
create_session_channel=AsyncMock(return_value={"channel_id": "session:task-b"}),
|
||||
backfill_messages=AsyncMock(return_value=[]),
|
||||
get_channel_messages_page=AsyncMock(return_value=[]),
|
||||
get_channel_messages_page_info=AsyncMock(return_value={
|
||||
"messages": [],
|
||||
"has_more": False,
|
||||
"total_count": 0,
|
||||
}),
|
||||
get_channel_messages=AsyncMock(return_value=[]),
|
||||
)
|
||||
handler = WSHandler(engine_a, MagicMock(), chat_store, _ui_event_adapter())
|
||||
@@ -708,7 +712,8 @@ async def test_session_detail_routes_by_request_project_id() -> None:
|
||||
"Project B Session",
|
||||
project_id="project-b",
|
||||
)
|
||||
assert chat_store.get_channel_messages_page.await_args.kwargs["project_id"] == "project-b"
|
||||
assert chat_store.get_channel_messages_page_info.await_args.kwargs["project_id"] == "project-b"
|
||||
assert chat_store.get_channel_messages_page_info.await_args.kwargs["detail_level"] == "summary"
|
||||
|
||||
|
||||
@_async_test
|
||||
|
||||
@@ -21,10 +21,9 @@ from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from opc.core.attachment_store import AttachmentRef, AttachmentStore
|
||||
from opc.core.models import DelegationRun, ExecutionCheckpoint, Task, TaskStatus
|
||||
from opc.database.store import _SQLiteConnectionAdapter
|
||||
from opc.layer2_organization import comms as file_comms
|
||||
from opc.plugins.office_ui.event_adapter import EventAdapter
|
||||
from opc.plugins.office_ui.chat_store import ChatStore
|
||||
@@ -207,8 +206,8 @@ def _make_engine(store: StubStore | None = None, memory: StubMemory | None = Non
|
||||
|
||||
async def _make_chat_store() -> ChatStore:
|
||||
"""Create an in-memory ChatStore for testing."""
|
||||
db = await aiosqlite.connect(":memory:")
|
||||
cs = ChatStore(db)
|
||||
db = _SQLiteConnectionAdapter(":memory:")
|
||||
cs = ChatStore(db) # type: ignore[arg-type]
|
||||
await cs.initialize()
|
||||
return cs
|
||||
|
||||
@@ -4444,6 +4443,97 @@ class TestWSHandlerSessionDetail(unittest.IsolatedAsyncioTestCase):
|
||||
["oldest"],
|
||||
)
|
||||
|
||||
async def test_session_detail_pages_transcript_and_ui_only_messages_together(self) -> None:
|
||||
ws = MagicMock()
|
||||
ws.send_json = AsyncMock()
|
||||
base_time = datetime.now()
|
||||
|
||||
task = Task(
|
||||
id="mixed-page-task-1",
|
||||
title="Mixed source page",
|
||||
project_id="test-project",
|
||||
session_id="mixed-page-session-1",
|
||||
)
|
||||
await self.store.save_task(task)
|
||||
self.store._transcripts["mixed-page-session-1"] = [
|
||||
{
|
||||
"message": SimpleNamespace(
|
||||
message_id="transcript-old",
|
||||
role="assistant",
|
||||
agent_id="agent-reviewer",
|
||||
created_at=base_time,
|
||||
summary_flag=False,
|
||||
metadata={"kind": "top_level_reply"},
|
||||
),
|
||||
"parts": [SimpleNamespace(part_type="text", payload={"text": "Persisted reply"})],
|
||||
},
|
||||
]
|
||||
channel_id = "session:mixed-page-task-1"
|
||||
await self.chat_store.create_session_channel(
|
||||
task.id,
|
||||
task.title,
|
||||
project_id="test-project",
|
||||
)
|
||||
await self.chat_store.insert_message(
|
||||
channel_id,
|
||||
"system",
|
||||
"OPC",
|
||||
"Approval required",
|
||||
metadata={
|
||||
"source": "ui",
|
||||
"detail_visibility": "summary",
|
||||
"kind": "ui_only_notice",
|
||||
},
|
||||
message_id="ui-only-mid",
|
||||
project_id="test-project",
|
||||
created_at=base_time.timestamp() + 1,
|
||||
)
|
||||
await self.chat_store.insert_message(
|
||||
channel_id,
|
||||
"system",
|
||||
"OPC",
|
||||
"Legacy execution notice",
|
||||
metadata={"source": "ui", "detail_visibility": "summary"},
|
||||
message_id="ui-only-new",
|
||||
project_id="test-project",
|
||||
created_at=base_time.timestamp() + 2,
|
||||
)
|
||||
|
||||
await self.handler._handle_session_detail(
|
||||
ws,
|
||||
{"project_id": "test-project", "task_id": task.id, "limit": 2},
|
||||
)
|
||||
|
||||
first_payload = ws.send_json.await_args_list[0].args[0]["payload"]
|
||||
self.assertEqual(first_payload["message_count"], 3)
|
||||
self.assertEqual(first_payload["loaded_count"], 2)
|
||||
self.assertTrue(first_payload["has_more"])
|
||||
self.assertEqual(
|
||||
[message["message_id"] for message in first_payload["messages"]],
|
||||
["ui-only-mid", "ui-only-new"],
|
||||
)
|
||||
|
||||
oldest_loaded = first_payload["messages"][0]
|
||||
await self.handler._handle_session_detail(
|
||||
ws,
|
||||
{
|
||||
"project_id": "test-project",
|
||||
"task_id": task.id,
|
||||
"limit": 2,
|
||||
"before_created_at": oldest_loaded["created_at"],
|
||||
"before_message_id": oldest_loaded["message_id"],
|
||||
},
|
||||
)
|
||||
|
||||
second_payload = ws.send_json.await_args_list[1].args[0]["payload"]
|
||||
self.assertEqual(second_payload["message_count"], 3)
|
||||
self.assertEqual(second_payload["loaded_count"], 1)
|
||||
self.assertFalse(second_payload["has_more"])
|
||||
self.assertEqual(
|
||||
[message["message_id"] for message in second_payload["messages"]],
|
||||
["transcript-old"],
|
||||
)
|
||||
|
||||
async def test_session_detail_returns_silently_when_shutdown_closes_chat_db(self) -> None:
|
||||
ws = MagicMock()
|
||||
ws.send_json = AsyncMock()
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from opc.core.models import SessionMessageRecord, SessionPartRecord, SessionRecord
|
||||
from opc.core.transcript_visibility import transcript_metadata_visible
|
||||
from opc.database.store import OPCStore, _SQLiteConnectionAdapter
|
||||
from opc.plugins.office_ui.chat_store import (
|
||||
ChatStore,
|
||||
_MessageMatchIndex,
|
||||
_MessageMatchState,
|
||||
)
|
||||
from opc.plugins.office_ui.snapshot_builder import build_transcript_ui_messages
|
||||
from opc.plugins.office_ui.ws_handler import WSHandler
|
||||
|
||||
|
||||
class TranscriptStorePaginationTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_summary_page_filters_full_detail_rows_before_limit(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = OPCStore(Path(tmpdir) / "tasks.db")
|
||||
await store.initialize()
|
||||
try:
|
||||
session_id = "summary-pagination-session"
|
||||
task_id = "summary-pagination-task"
|
||||
base = datetime(2026, 7, 13, 12, 0, 0)
|
||||
await store.save_session(SessionRecord(
|
||||
session_id=session_id,
|
||||
project_id="test-project",
|
||||
title="Summary pagination",
|
||||
created_at=base,
|
||||
updated_at=base,
|
||||
))
|
||||
|
||||
async def save(
|
||||
message_id: str,
|
||||
offset: int,
|
||||
kind: str,
|
||||
*,
|
||||
company_final_turn: bool = False,
|
||||
summary_flag: bool = False,
|
||||
) -> None:
|
||||
metadata = {"kind": kind}
|
||||
if company_final_turn:
|
||||
metadata["company_final_turn"] = True
|
||||
created_at = base + timedelta(seconds=offset)
|
||||
await store.save_session_message(SessionMessageRecord(
|
||||
message_id=message_id,
|
||||
session_id=session_id,
|
||||
task_id=task_id,
|
||||
role="assistant",
|
||||
agent_id="agent-reviewer",
|
||||
summary_flag=summary_flag,
|
||||
metadata=metadata,
|
||||
created_at=created_at,
|
||||
))
|
||||
await store.save_session_part(SessionPartRecord(
|
||||
part_id=f"part-{message_id}",
|
||||
message_id=message_id,
|
||||
session_id=session_id,
|
||||
part_type="text",
|
||||
payload={"text": f"content:{message_id}"},
|
||||
created_at=created_at,
|
||||
))
|
||||
|
||||
await save("summary-old", 0, "top_level_reply")
|
||||
hidden_kinds = (
|
||||
"runtime_v2_user_turn",
|
||||
"runtime_v2_intermediate_assistant",
|
||||
"runtime_v2_company_assistant",
|
||||
"runtime_v2_tool_output",
|
||||
)
|
||||
# More than 8 * page size: post-LIMIT filtering used to return
|
||||
# an empty page even though summary-old remained reachable.
|
||||
for index in range(24):
|
||||
await save(f"full-only-{index:02d}", index + 1, hidden_kinds[index % len(hidden_kinds)])
|
||||
await save("assistant-final", 25, "runtime_v2_assistant")
|
||||
await save(
|
||||
"company-final",
|
||||
26,
|
||||
"runtime_v2_company_assistant",
|
||||
company_final_turn=True,
|
||||
)
|
||||
await save("canonical-result", 27, "child_result")
|
||||
await save("compaction-summary", 28, "top_level_reply", summary_flag=True)
|
||||
|
||||
latest = await store.get_session_transcript_page(
|
||||
session_id,
|
||||
limit=2,
|
||||
detail_level="summary",
|
||||
)
|
||||
self.assertEqual(latest["total_count"], 4)
|
||||
self.assertTrue(latest["has_more"])
|
||||
self.assertEqual(
|
||||
[item["message"].message_id for item in latest["messages"]],
|
||||
["company-final", "canonical-result"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[message["message_id"] for message in build_transcript_ui_messages(
|
||||
latest["messages"],
|
||||
channel_id=f"session:{task_id}",
|
||||
task_id=task_id,
|
||||
detail_level="summary",
|
||||
)],
|
||||
["company-final", "canonical-result"],
|
||||
)
|
||||
|
||||
older = await store.get_session_transcript_page(
|
||||
session_id,
|
||||
limit=2,
|
||||
before_created_at=base + timedelta(seconds=26),
|
||||
before_message_id="company-final",
|
||||
detail_level="summary",
|
||||
)
|
||||
self.assertEqual(older["total_count"], 4)
|
||||
self.assertFalse(older["has_more"])
|
||||
self.assertEqual(
|
||||
[item["message"].message_id for item in older["messages"]],
|
||||
["summary-old", "assistant-final"],
|
||||
)
|
||||
|
||||
full = await store.get_session_transcript_page(
|
||||
session_id,
|
||||
limit=2,
|
||||
detail_level="full",
|
||||
)
|
||||
self.assertEqual(full["total_count"], 28)
|
||||
self.assertTrue(full["has_more"])
|
||||
finally:
|
||||
await store.close()
|
||||
|
||||
async def test_rendered_page_reads_past_empty_and_collapsed_raw_rows(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = OPCStore(Path(tmpdir) / "tasks.db")
|
||||
await store.initialize()
|
||||
try:
|
||||
session_id = "rendered-pagination-session"
|
||||
task_id = "rendered-pagination-task"
|
||||
base = datetime(2026, 7, 13, 13, 0, 0)
|
||||
await store.save_session(SessionRecord(
|
||||
session_id=session_id,
|
||||
project_id="test-project",
|
||||
title="Rendered pagination",
|
||||
created_at=base,
|
||||
updated_at=base,
|
||||
))
|
||||
|
||||
async def save(
|
||||
message_id: str,
|
||||
offset: int,
|
||||
kind: str,
|
||||
content: str | None,
|
||||
) -> None:
|
||||
created_at = base + timedelta(seconds=offset)
|
||||
await store.save_session_message(SessionMessageRecord(
|
||||
message_id=message_id,
|
||||
session_id=session_id,
|
||||
task_id=task_id,
|
||||
role="assistant",
|
||||
agent_id="agent-reviewer",
|
||||
metadata={"kind": kind},
|
||||
created_at=created_at,
|
||||
))
|
||||
if content is not None:
|
||||
await store.save_session_part(SessionPartRecord(
|
||||
part_id=f"part-{message_id}",
|
||||
message_id=message_id,
|
||||
session_id=session_id,
|
||||
part_type="text",
|
||||
payload={"text": content},
|
||||
created_at=created_at,
|
||||
))
|
||||
|
||||
await save("visible-old", 0, "top_level_reply", "older unique")
|
||||
await save("duplicate-low", 1, "top_level_reply", "same result")
|
||||
await save("duplicate-high", 2, "child_result", "same result")
|
||||
await save("empty-latest", 3, "top_level_reply", None)
|
||||
|
||||
handler = WSHandler.__new__(WSHandler)
|
||||
handler.engine = SimpleNamespace(store=store)
|
||||
page, total_count, has_more = await handler._load_session_transcript_page(
|
||||
SimpleNamespace(id=task_id, session_id=session_id),
|
||||
limit=2,
|
||||
detail_level="summary",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[message["message_id"] for message in page],
|
||||
["visible-old", "duplicate-high"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[message["content"] for message in page],
|
||||
["older unique", "same result"],
|
||||
)
|
||||
self.assertGreaterEqual(total_count, 2)
|
||||
self.assertFalse(has_more)
|
||||
finally:
|
||||
await store.close()
|
||||
|
||||
def test_renderer_and_store_share_company_final_visibility(self) -> None:
|
||||
self.assertFalse(transcript_metadata_visible(
|
||||
{"kind": "runtime_v2_company_assistant"},
|
||||
detail_level="summary",
|
||||
))
|
||||
self.assertTrue(transcript_metadata_visible(
|
||||
{"kind": "runtime_v2_company_assistant", "company_final_turn": True},
|
||||
detail_level="summary",
|
||||
))
|
||||
self.assertTrue(transcript_metadata_visible(
|
||||
{"kind": "runtime_v2_assistant"},
|
||||
detail_level="summary",
|
||||
))
|
||||
|
||||
|
||||
class ChatStorePaginationTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _legacy_dedupe(
|
||||
store: ChatStore,
|
||||
messages: list[dict[str, object]],
|
||||
) -> list[dict[str, object]]:
|
||||
"""Reference implementation retained only for equivalence testing."""
|
||||
deduped: list[dict[str, object]] = []
|
||||
for message in sorted(messages, key=store._message_timestamp):
|
||||
match_index = next(
|
||||
(
|
||||
index
|
||||
for index in range(len(deduped) - 1, -1, -1)
|
||||
if store._messages_semantically_match(deduped[index], message)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if match_index is None:
|
||||
deduped.append(message)
|
||||
else:
|
||||
deduped[match_index] = store._merge_duplicate_messages(
|
||||
deduped[match_index],
|
||||
message,
|
||||
)
|
||||
return deduped
|
||||
|
||||
def test_indexed_dedupe_matches_legacy_semantics(self) -> None:
|
||||
randomizer = random.Random(20260713)
|
||||
messages: list[dict[str, object]] = []
|
||||
content_variants = (
|
||||
"Repeated result",
|
||||
"Repeated result\n\nVerification: passed",
|
||||
"Unique body ",
|
||||
"**Narrative heading**: " + ("long body " * 20),
|
||||
)
|
||||
result_kinds = (
|
||||
"child_result",
|
||||
"company_role_result",
|
||||
"top_level_reply",
|
||||
"",
|
||||
)
|
||||
for index in range(600):
|
||||
metadata: dict[str, object] = {}
|
||||
if randomizer.random() < 0.55:
|
||||
metadata["source"] = "engine"
|
||||
result_kind = randomizer.choice(result_kinds)
|
||||
if result_kind:
|
||||
metadata["transcript_kind"] = result_kind
|
||||
# Exercise identity merges which can replace the semantic bucket of
|
||||
# an already-indexed row.
|
||||
if messages and randomizer.random() < 0.12:
|
||||
identity_source = randomizer.choice(messages)
|
||||
metadata["ui_message_id"] = identity_source["message_id"]
|
||||
content = randomizer.choice(content_variants)
|
||||
if content == "Unique body ":
|
||||
content += str(index % 31)
|
||||
messages.append({
|
||||
"message_id": f"random-{index:04d}",
|
||||
"channel_id": f"session:{randomizer.randrange(2)}",
|
||||
"sender": "user" if randomizer.random() < 0.18 else "assistant",
|
||||
"sender_name": "OPC",
|
||||
"content": content,
|
||||
# Include zero/negative sentinel values because the historical
|
||||
# matcher deliberately treats a zero timestamp as unbounded.
|
||||
"created_at": float(randomizer.randrange(-6, 45)) / 3.0,
|
||||
"reply_to_id": f"reply-{randomizer.randrange(4)}",
|
||||
"mentions": [],
|
||||
"metadata": metadata,
|
||||
})
|
||||
randomizer.shuffle(messages)
|
||||
|
||||
store = ChatStore(None) # type: ignore[arg-type]
|
||||
expected = self._legacy_dedupe(store, messages)
|
||||
actual = store._dedupe_messages(messages)
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_indexed_dedupe_normalizes_long_content_once_per_row(self) -> None:
|
||||
class CountingChatStore(ChatStore):
|
||||
normalize_calls = 0
|
||||
|
||||
@classmethod
|
||||
def _normalize_duplicate_content(cls, content: object) -> str:
|
||||
cls.normalize_calls += 1
|
||||
return ChatStore._normalize_duplicate_content(content)
|
||||
|
||||
store = CountingChatStore(None) # type: ignore[arg-type]
|
||||
long_content = "x" * 8192
|
||||
message_count = 4000
|
||||
messages = [
|
||||
{
|
||||
"message_id": f"scale-{index:05d}",
|
||||
"channel_id": "session:scale",
|
||||
"sender": "assistant",
|
||||
"sender_name": "OPC",
|
||||
"content": long_content,
|
||||
"created_at": float(index),
|
||||
"reply_to_id": None,
|
||||
"mentions": [],
|
||||
# Without an engine source these equal-content rows deliberately
|
||||
# do not merge; the legacy reverse scan normalized O(n^2) pairs.
|
||||
"metadata": {},
|
||||
}
|
||||
for index in range(message_count)
|
||||
]
|
||||
|
||||
deduped = store._dedupe_messages(messages)
|
||||
self.assertEqual(len(deduped), message_count)
|
||||
self.assertEqual(CountingChatStore.normalize_calls, message_count)
|
||||
|
||||
def test_timed_backfill_index_does_not_scan_out_of_window_rows(self) -> None:
|
||||
store = ChatStore(None) # type: ignore[arg-type]
|
||||
row_count = 1000
|
||||
|
||||
def message(prefix: str, index: int, timestamp: float) -> dict[str, object]:
|
||||
return {
|
||||
"message_id": f"{prefix}-{index:04d}",
|
||||
"channel_id": "session:timed-backfill-scale",
|
||||
"sender": "assistant",
|
||||
"sender_name": "OPC",
|
||||
"content": "Ordinary engine update",
|
||||
"created_at": timestamp,
|
||||
"reply_to_id": "same-turn",
|
||||
"mentions": [],
|
||||
# Intentionally not a result surface: only the exact 2-second
|
||||
# ordinary-message window may match these rows.
|
||||
"metadata": {"source": "engine"},
|
||||
}
|
||||
|
||||
existing = [
|
||||
message("existing", index, 100_001.0 + index * 10.0)
|
||||
for index in range(row_count)
|
||||
]
|
||||
incoming = [
|
||||
message("incoming", index, 1.0 + index * 10.0)
|
||||
for index in range(row_count)
|
||||
]
|
||||
|
||||
# Prepared-state reference of the former reversed scan. Every incoming
|
||||
# row misses and is appended, causing 1000 + ... + 1999 comparisons.
|
||||
legacy_rows = list(existing)
|
||||
legacy_states = [
|
||||
_MessageMatchState.from_message(store, item)
|
||||
for item in legacy_rows
|
||||
]
|
||||
legacy_matches: list[int | None] = []
|
||||
legacy_checks = 0
|
||||
for item in incoming:
|
||||
candidate = _MessageMatchState.from_message(store, item)
|
||||
match_index: int | None = None
|
||||
for index in range(len(legacy_states) - 1, -1, -1):
|
||||
legacy_checks += 1
|
||||
if legacy_states[index].matches(
|
||||
candidate,
|
||||
duplicate_window=store._DUPLICATE_WINDOW_SECONDS,
|
||||
):
|
||||
match_index = index
|
||||
break
|
||||
legacy_matches.append(match_index)
|
||||
if match_index is None:
|
||||
legacy_rows.append(item)
|
||||
legacy_states.append(candidate)
|
||||
|
||||
indexed_rows = list(existing)
|
||||
timed_index = _MessageMatchIndex(store, indexed_rows)
|
||||
indexed_matches: list[int | None] = []
|
||||
indexed_checks = 0
|
||||
original_matches = _MessageMatchState.matches
|
||||
|
||||
def counted_matches(
|
||||
existing_state: _MessageMatchState,
|
||||
candidate_state: _MessageMatchState,
|
||||
*,
|
||||
duplicate_window: float,
|
||||
) -> bool:
|
||||
nonlocal indexed_checks
|
||||
indexed_checks += 1
|
||||
return original_matches(
|
||||
existing_state,
|
||||
candidate_state,
|
||||
duplicate_window=duplicate_window,
|
||||
)
|
||||
|
||||
_MessageMatchState.matches = counted_matches
|
||||
try:
|
||||
for item in incoming:
|
||||
candidate = timed_index.prepare(item)
|
||||
match_index = timed_index.latest_match(
|
||||
item,
|
||||
prepared_state=candidate,
|
||||
)
|
||||
indexed_matches.append(match_index)
|
||||
if match_index is None:
|
||||
timed_index.append(item, prepared_state=candidate)
|
||||
finally:
|
||||
_MessageMatchState.matches = original_matches
|
||||
|
||||
self.assertEqual(indexed_matches, legacy_matches)
|
||||
self.assertEqual(legacy_checks, 1_499_500)
|
||||
self.assertEqual(indexed_checks, 0)
|
||||
|
||||
def test_timed_index_preserves_float_rounding_at_window_boundary(self) -> None:
|
||||
store = ChatStore(None) # type: ignore[arg-type]
|
||||
|
||||
def message(message_id: str, timestamp: float) -> dict[str, object]:
|
||||
return {
|
||||
"message_id": message_id,
|
||||
"channel_id": "session:float-window-boundary",
|
||||
"sender": "assistant",
|
||||
"sender_name": "OPC",
|
||||
"content": "Boundary update",
|
||||
"created_at": timestamp,
|
||||
"reply_to_id": "same-turn",
|
||||
"mentions": [],
|
||||
"metadata": {"source": "engine"},
|
||||
}
|
||||
|
||||
existing = message("existing", -1e-300)
|
||||
candidate = message("candidate", 2.0)
|
||||
self.assertTrue(store._messages_semantically_match(existing, candidate))
|
||||
|
||||
rows = [existing]
|
||||
index = _MessageMatchIndex(store, rows)
|
||||
self.assertEqual(index.latest_match(candidate), 0)
|
||||
|
||||
def test_backfill_semantic_matches_remain_one_to_one(self) -> None:
|
||||
asyncio.run(self._exercise_backfill_semantic_matches_one_to_one())
|
||||
|
||||
async def _exercise_backfill_semantic_matches_one_to_one(self) -> None:
|
||||
tmpdir = tempfile.TemporaryDirectory()
|
||||
db = _SQLiteConnectionAdapter(str(Path(tmpdir.name) / "ui-state.db"))
|
||||
store = ChatStore(db) # type: ignore[arg-type]
|
||||
await store.initialize()
|
||||
channel_id = "session:backfill-scale"
|
||||
project_id = "test-project"
|
||||
content = "Canonical result " + ("detail " * 1000)
|
||||
try:
|
||||
for index in range(200):
|
||||
await store.insert_message(
|
||||
channel_id,
|
||||
"assistant",
|
||||
"OPC",
|
||||
content,
|
||||
metadata={
|
||||
"source": "engine",
|
||||
"transcript_kind": "child_result",
|
||||
},
|
||||
message_id=f"existing-result-{index:03d}",
|
||||
project_id=project_id,
|
||||
created_at=float(index + 1),
|
||||
)
|
||||
|
||||
backfill = [
|
||||
{
|
||||
"message_id": f"backfill-result-{index:03d}",
|
||||
"channel_id": channel_id,
|
||||
"sender": "assistant",
|
||||
"sender_name": "OPC",
|
||||
"content": content,
|
||||
"created_at": float(index + 1000),
|
||||
"metadata": {
|
||||
"source": "engine",
|
||||
"transcript_kind": "child_result",
|
||||
},
|
||||
}
|
||||
for index in range(201)
|
||||
]
|
||||
inserted = await store.backfill_messages(
|
||||
channel_id,
|
||||
backfill,
|
||||
project_id=project_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
[message["message_id"] for message in inserted],
|
||||
["backfill-result-200"],
|
||||
)
|
||||
cursor = await db.execute(
|
||||
"SELECT COUNT(*) FROM messages WHERE channel_id = ? AND project_id = ?",
|
||||
(channel_id, project_id),
|
||||
)
|
||||
self.assertEqual((await cursor.fetchone())[0], 201)
|
||||
finally:
|
||||
await db.close()
|
||||
tmpdir.cleanup()
|
||||
|
||||
def test_summary_cache_page_filters_before_raw_fetch_limit(self) -> None:
|
||||
asyncio.run(self._exercise_summary_cache_page())
|
||||
|
||||
async def _exercise_summary_cache_page(self) -> None:
|
||||
tmpdir = tempfile.TemporaryDirectory()
|
||||
db = _SQLiteConnectionAdapter(str(Path(tmpdir.name) / "ui-state.db"))
|
||||
store = ChatStore(db) # type: ignore[arg-type]
|
||||
await store.initialize()
|
||||
channel_id = "session:summary-cache-task"
|
||||
project_id = "test-project"
|
||||
|
||||
async def insert(message_id: str, timestamp: float, visibility: str) -> None:
|
||||
await store.insert_message(
|
||||
channel_id,
|
||||
"agent-reviewer",
|
||||
"Reviewer",
|
||||
f"content:{message_id}",
|
||||
metadata={"detail_visibility": visibility},
|
||||
message_id=message_id,
|
||||
project_id=project_id,
|
||||
created_at=timestamp,
|
||||
)
|
||||
|
||||
try:
|
||||
await insert("summary-old", 1.0, "summary")
|
||||
for index in range(24):
|
||||
await insert(f"full-only-{index:02d}", float(index + 2), "full")
|
||||
await insert("summary-new", 26.0, "summary")
|
||||
|
||||
page = await store.get_channel_messages_page(
|
||||
channel_id,
|
||||
limit=2,
|
||||
detail_level="summary",
|
||||
project_id=project_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
[message["message_id"] for message in page],
|
||||
["summary-old", "summary-new"],
|
||||
)
|
||||
self.assertEqual(
|
||||
await store.get_channel_visible_message_count(
|
||||
channel_id,
|
||||
project_id=project_id,
|
||||
detail_level="summary",
|
||||
),
|
||||
2,
|
||||
)
|
||||
self.assertEqual(
|
||||
await store.get_channel_visible_message_count(
|
||||
channel_id,
|
||||
project_id=project_id,
|
||||
detail_level="full",
|
||||
),
|
||||
26,
|
||||
)
|
||||
|
||||
older = await store.get_channel_messages_page(
|
||||
channel_id,
|
||||
limit=2,
|
||||
before_timestamp=26.0,
|
||||
before_message_id="summary-new",
|
||||
detail_level="summary",
|
||||
project_id=project_id,
|
||||
)
|
||||
self.assertEqual([message["message_id"] for message in older], ["summary-old"])
|
||||
finally:
|
||||
await db.close()
|
||||
tmpdir.cleanup()
|
||||
|
||||
def test_cache_page_dedupes_before_paging_and_keeps_ui_only_rows(self) -> None:
|
||||
asyncio.run(self._exercise_cache_page_with_ui_only_rows())
|
||||
|
||||
async def _exercise_cache_page_with_ui_only_rows(self) -> None:
|
||||
tmpdir = tempfile.TemporaryDirectory()
|
||||
db = _SQLiteConnectionAdapter(str(Path(tmpdir.name) / "ui-state.db"))
|
||||
store = ChatStore(db) # type: ignore[arg-type]
|
||||
await store.initialize()
|
||||
channel_id = "session:mixed-cache-task"
|
||||
project_id = "test-project"
|
||||
|
||||
async def insert(
|
||||
message_id: str,
|
||||
timestamp: float,
|
||||
content: str,
|
||||
metadata: dict[str, object],
|
||||
) -> None:
|
||||
await store.insert_message(
|
||||
channel_id,
|
||||
"assistant",
|
||||
"OPC",
|
||||
content,
|
||||
metadata=metadata,
|
||||
message_id=message_id,
|
||||
project_id=project_id,
|
||||
created_at=timestamp,
|
||||
)
|
||||
|
||||
try:
|
||||
# These two UI-owned rows have no authoritative transcript row, but
|
||||
# must still contribute to the page cursor, total, and has_more.
|
||||
await insert(
|
||||
"approval-card",
|
||||
1.0,
|
||||
"",
|
||||
{
|
||||
"checkpoint_id": "checkpoint-1",
|
||||
"checkpoint_type": "tool_approval",
|
||||
"checkpoint_status": "pending",
|
||||
},
|
||||
)
|
||||
await insert(
|
||||
"legacy-notice",
|
||||
2.0,
|
||||
"Legacy execution notice",
|
||||
{"kind": "legacy_notice"},
|
||||
)
|
||||
|
||||
# More than the old ``limit * 8`` lookahead collapses to one final
|
||||
# result surface. Raw-row pagination therefore used to hide both
|
||||
# older UI-only rows and incorrectly report the end of history.
|
||||
for index in range(24):
|
||||
await insert(
|
||||
f"result-surface-{index:02d}",
|
||||
float(index + 3),
|
||||
"Canonical child result",
|
||||
{
|
||||
"source": "engine",
|
||||
"transcript_kind": "child_result",
|
||||
"detail_visibility": "summary",
|
||||
},
|
||||
)
|
||||
await insert(
|
||||
"latest-message",
|
||||
27.0,
|
||||
"Latest committed reply",
|
||||
{"detail_visibility": "summary"},
|
||||
)
|
||||
for index in range(20):
|
||||
await insert(
|
||||
f"full-only-{index:02d}",
|
||||
float(index + 28),
|
||||
f"Runtime row {index}",
|
||||
{"detail_visibility": "full"},
|
||||
)
|
||||
|
||||
page = await store.get_channel_messages_page_info(
|
||||
channel_id,
|
||||
limit=2,
|
||||
detail_level="summary",
|
||||
project_id=project_id,
|
||||
)
|
||||
self.assertEqual(page["total_count"], 4)
|
||||
self.assertTrue(page["has_more"])
|
||||
self.assertEqual(
|
||||
[message["message_id"] for message in page["messages"]],
|
||||
["result-surface-00", "latest-message"],
|
||||
)
|
||||
|
||||
result_message = page["messages"][0]
|
||||
older = await store.get_channel_messages_page_info(
|
||||
channel_id,
|
||||
limit=2,
|
||||
before_timestamp=result_message["created_at"],
|
||||
before_message_id=result_message["message_id"],
|
||||
detail_level="summary",
|
||||
project_id=project_id,
|
||||
)
|
||||
self.assertFalse(older["has_more"])
|
||||
self.assertEqual(older["total_count"], 4)
|
||||
self.assertEqual(
|
||||
[message["message_id"] for message in older["messages"]],
|
||||
["approval-card", "legacy-notice"],
|
||||
)
|
||||
|
||||
compatible = await store.get_channel_messages_page(
|
||||
channel_id,
|
||||
limit=2,
|
||||
detail_level="summary",
|
||||
project_id=project_id,
|
||||
)
|
||||
self.assertEqual(compatible, page["messages"])
|
||||
finally:
|
||||
await db.close()
|
||||
tmpdir.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user