Creator App Tests / PR Spam Gate (push) Skipped
Frontend Tests / PR Spam Gate (push) Skipped
NPM Format (website & console) / PR Spam Gate (push) Skipped
Pre-commit Checks / PR Spam Gate (push) Skipped
QwenPaw Creator Plugin Release / check-version (push) Canceled after 0s
QwenPaw Creator Plugin Release / upload-oss (push) Canceled after 0s
Tests / PR Spam Gate (push) Skipped
CodeQL / CodeQL (python) (push) Canceled after 0s
CodeQL / CodeQL (typescript) (push) Canceled after 0s
Creator App Tests / Creator Backend Tests (push) Canceled after 0s
Creator App Tests / Creator UI Build & Tests (push) Canceled after 0s
E2E Smoke Tests / Playwright UI Smoke (push) Canceled after 0s
Frontend Tests / Detect console changes (push) Canceled after 0s
NPM Format (website & console) / website format check (push) Canceled after 0s
NPM Format (website & console) / console format check (push) Canceled after 0s
Pre-commit Checks / Pre-commit (push) Canceled after 0s
QwenPaw Data Plugin Release / check-version (push) Canceled after 0s
Tests / Detect code changes (push) Canceled after 0s
Frontend Tests / Vitest Unit Tests (push) Canceled after 0s
Frontend Tests / Frontend Summary (push) Canceled after 0s
QwenPaw Data Plugin Release / upload-oss (push) Canceled after 0s
Tests / Maintainer Approval (push) Canceled after 0s
Tests / Unit Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - fallback (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p2 (push) Canceled after 0s
Tests / Coverage Report (push) Canceled after 0s
Tests / Test Summary (push) Canceled after 0s
2012 lines
58 KiB
Python
2012 lines
58 KiB
Python
# -*- coding: utf-8 -*-
|
|
# pylint: disable=redefined-outer-name,protected-access,unused-argument
|
|
"""Unit tests for :class:`MemorySpace` — the model's SQLite recall surface.
|
|
|
|
The security-critical guarantee is that the model, which runs arbitrary SQL
|
|
here, cannot escape the read-only attach of durable history. These tests pin
|
|
the SQLite-authorizer contract plus the recall ``scope`` semantics.
|
|
"""
|
|
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from qwenpaw.agents.context.scroll.history import HistoryStore
|
|
from qwenpaw.agents.context.scroll.memoryspace import (
|
|
MemorySpace,
|
|
fts_match_query,
|
|
sanitize_suffix,
|
|
)
|
|
from qwenpaw.agents.context.types import LogEntry
|
|
|
|
|
|
@pytest.fixture
|
|
def history_db(tmp_path: Path) -> Path:
|
|
"""A durable store with two agents across two sessions."""
|
|
h = HistoryStore(tmp_path / "history.db")
|
|
h.append(
|
|
session_id="s1",
|
|
agent_id="ag1",
|
|
dedup_key="m1",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content="tanks rolled in",
|
|
headline="battle",
|
|
),
|
|
)
|
|
h.append(
|
|
session_id="s2",
|
|
agent_id="ag1",
|
|
dedup_key="m2",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content="tanks regrouped later",
|
|
),
|
|
)
|
|
h.append(
|
|
session_id="s3",
|
|
agent_id="ag2",
|
|
dedup_key="m3",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content="tanks of another agent",
|
|
),
|
|
)
|
|
h.close()
|
|
return tmp_path / "history.db"
|
|
|
|
|
|
@pytest.fixture
|
|
def ms(history_db: Path) -> MemorySpace:
|
|
space = MemorySpace(
|
|
history_db_path=str(history_db),
|
|
session_id="s1",
|
|
agent_id="ag1",
|
|
)
|
|
yield space
|
|
space.close()
|
|
|
|
|
|
# -- the read-only-attach contract ------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"sql",
|
|
[
|
|
"ATTACH DATABASE ':memory:' AS other",
|
|
"DETACH DATABASE hist",
|
|
"INSERT INTO hist.conversation_history(session_id, kind) "
|
|
"VALUES ('x', 'k')",
|
|
"UPDATE hist.conversation_history SET content = 'tampered'",
|
|
"DELETE FROM hist.conversation_history",
|
|
"DROP TABLE hist.conversation_history",
|
|
],
|
|
)
|
|
def test_authorizer_blocks_escape_attempts(ms: MemorySpace, sql: str):
|
|
with pytest.raises(sqlite3.Error):
|
|
ms.sql_exec(sql)
|
|
# And the durable data is untouched.
|
|
assert (
|
|
ms.sql_query(
|
|
"SELECT COUNT(*) AS n FROM hist.conversation_history",
|
|
)[
|
|
0
|
|
]["n"]
|
|
== 3
|
|
)
|
|
|
|
|
|
def test_scratch_is_read_write(ms: MemorySpace):
|
|
ms.sql_exec("CREATE TABLE notes(x INTEGER)")
|
|
ms.sql_exec("INSERT INTO notes VALUES (42)")
|
|
assert ms.sql_query("SELECT x FROM notes")[0]["x"] == 42
|
|
assert "notes" in ms.tables()
|
|
|
|
|
|
def test_hist_is_readable(ms: MemorySpace):
|
|
rows = ms.sql_query(
|
|
"SELECT content FROM hist.conversation_history ORDER BY seq",
|
|
)
|
|
assert rows[0]["content"] == "tanks rolled in"
|
|
|
|
|
|
# -- recall scope semantics --------------------------------------------------
|
|
|
|
|
|
def test_search_default_is_this_agent_cross_session(ms: MemorySpace):
|
|
contents = {r["content"] for r in ms.search("tanks")}
|
|
# Both of ag1's turns (s1 + s2), none of ag2's — isolation by default.
|
|
assert "tanks rolled in" in contents
|
|
assert "tanks regrouped later" in contents
|
|
assert "tanks of another agent" not in contents
|
|
|
|
|
|
def test_search_uses_seq_as_stable_bm25_tie_breaker(tmp_path: Path):
|
|
h = HistoryStore(tmp_path / "history.db")
|
|
expected_seqs = []
|
|
for index in range(3):
|
|
expected_seqs.append(
|
|
h.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key=f"equal-{index}",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content="identical ranking text",
|
|
),
|
|
),
|
|
)
|
|
h.close()
|
|
space = MemorySpace(
|
|
history_db_path=str(tmp_path / "history.db"),
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
)
|
|
|
|
try:
|
|
first = space.search("identical ranking", k=3)
|
|
second = space.search("identical ranking", k=3)
|
|
finally:
|
|
space.close()
|
|
|
|
assert [row["seq"] for row in first] == expected_seqs
|
|
assert [row["seq"] for row in second] == expected_seqs
|
|
|
|
|
|
def test_search_excludes_recall_tool_own_turns(tmp_path: Path):
|
|
"""The recall tool's own source/output must not surface as search hits, or
|
|
a query matches the agent's earlier queries (self-pollution)."""
|
|
h = HistoryStore(tmp_path / "history.db")
|
|
h.append(
|
|
session_id="s1",
|
|
agent_id="ag1",
|
|
dedup_key="real",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content="the car needs service after 10000 miles",
|
|
),
|
|
)
|
|
# The agent's own recall call (its Python source) and its printed output —
|
|
# both carry the searched keywords and both must be excluded.
|
|
h.append(
|
|
session_id="s1",
|
|
agent_id="ag1",
|
|
dedup_key="recall_call",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
name="recall_history_python",
|
|
content='ms.search("car service")',
|
|
),
|
|
)
|
|
h.append(
|
|
session_id="s1",
|
|
agent_id="ag1",
|
|
dedup_key="recall_out",
|
|
entry=LogEntry(
|
|
kind="tool_result",
|
|
role="assistant",
|
|
name="recall_history",
|
|
content="stdout: searching for car service ...",
|
|
tool_call_id="t1",
|
|
),
|
|
)
|
|
h.close()
|
|
space = MemorySpace(
|
|
history_db_path=str(tmp_path / "history.db"),
|
|
session_id="s1",
|
|
agent_id="ag1",
|
|
)
|
|
try:
|
|
hits = space.search("car service")
|
|
contents = [r["content"] for r in hits]
|
|
assert contents == ["the car needs service after 10000 miles"]
|
|
finally:
|
|
space.close()
|
|
|
|
|
|
def test_search_excludes_the_active_turn(tmp_path: Path):
|
|
"""The current request and its in-progress reply must not surface as
|
|
hits: they are already in the live window, and a second recall round
|
|
would otherwise top-k-match the previous round's quoted findings
|
|
(echo loop). Earlier turns of the SAME session stay searchable."""
|
|
h = HistoryStore(tmp_path / "history.db")
|
|
rows = [
|
|
("old_u", "context_msg", "user", "tanks question from earlier"),
|
|
("old_a", "model_turn", "assistant", "tanks were parked at base"),
|
|
# The ACTIVE turn: the latest user request + the reply being written.
|
|
("cur_u", "context_msg", "user", "tanks question retried"),
|
|
("cur_a", "model_turn", "assistant", "tanks quote from last recall"),
|
|
]
|
|
for key, kind, role, content in rows:
|
|
h.append(
|
|
session_id="s1",
|
|
agent_id="ag1",
|
|
dedup_key=key,
|
|
entry=LogEntry(kind=kind, role=role, content=content),
|
|
)
|
|
h.append( # another session is untouched by the exclusion
|
|
session_id="s2",
|
|
agent_id="ag1",
|
|
dedup_key="other",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content="tanks moved in another session",
|
|
),
|
|
)
|
|
h.close()
|
|
space = MemorySpace(
|
|
history_db_path=str(tmp_path / "history.db"),
|
|
session_id="s1",
|
|
agent_id="ag1",
|
|
)
|
|
try:
|
|
legacy_expected = {
|
|
"tanks question from earlier",
|
|
"tanks were parked at base",
|
|
"tanks moved in another session",
|
|
}
|
|
hits = space.search("tanks", k=10)
|
|
assert {r["content"] for r in hits} == {
|
|
"tanks question from earlier",
|
|
"tanks moved in another session",
|
|
}
|
|
old_turn = next(
|
|
row["turn"] for row in hits if row["session_id"] == "s1"
|
|
)
|
|
assert {row["content"] for row in old_turn} == {
|
|
"tanks question from earlier",
|
|
"tanks were parked at base",
|
|
}
|
|
legacy_hits = space.search(
|
|
"tanks",
|
|
k=10,
|
|
include_turn=False,
|
|
)
|
|
assert {r["content"] for r in legacy_hits} == legacy_expected
|
|
# The LIKE fallback applies the same exclusion.
|
|
like = space._search_like("tanks", [("agent_id", "ag1")], None, 10)
|
|
got = {r["content"] for r in like if r["kind"] != "_notice"}
|
|
assert got == legacy_expected
|
|
finally:
|
|
space.close()
|
|
|
|
|
|
def test_search_returns_and_deduplicates_complete_turn(tmp_path: Path):
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
user_seq = history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="u1",
|
|
entry=LogEntry(
|
|
kind="context_msg",
|
|
role="user",
|
|
content="order question for a compass",
|
|
),
|
|
)
|
|
assistant_seq = history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="a1",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content="order answer is north",
|
|
),
|
|
)
|
|
tool_seq = history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="t1",
|
|
entry=LogEntry(
|
|
kind="tool_result",
|
|
role="assistant",
|
|
name="lookup",
|
|
content="order receipt confirmed",
|
|
tool_call_id="call-1",
|
|
),
|
|
)
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="u2",
|
|
entry=LogEntry(
|
|
kind="context_msg",
|
|
role="user",
|
|
content="unrelated active request",
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
)
|
|
|
|
try:
|
|
hits = space.search("order", k=10)
|
|
user_hits = space.search("compass", k=10)
|
|
finally:
|
|
space.close()
|
|
|
|
assert len(hits) == 1
|
|
assert hits[0]["matched_seqs"] == [
|
|
user_seq,
|
|
assistant_seq,
|
|
tool_seq,
|
|
]
|
|
assert hits[0]["turn_start_seq"] == user_seq
|
|
assert hits[0]["turn_end_seq"] == tool_seq
|
|
assert [row["content"] for row in hits[0]["turn"]] == [
|
|
"order question for a compass",
|
|
"order answer is north",
|
|
"order receipt confirmed",
|
|
]
|
|
assert len(user_hits) == 1
|
|
assert user_hits[0]["match_seq"] == user_seq
|
|
assert user_hits[0]["turn"] == hits[0]["turn"]
|
|
|
|
|
|
def test_search_cjk_substring_returns_complete_turn(tmp_path: Path):
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
user_seq = history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="u1",
|
|
entry=LogEntry(
|
|
kind="context_msg",
|
|
role="user",
|
|
content="紫水晶河马在周二跳舞",
|
|
),
|
|
)
|
|
assistant_seq = history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="a1",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content="我记住了这个暗号",
|
|
),
|
|
)
|
|
tool_seq = history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="t1",
|
|
entry=LogEntry(
|
|
kind="tool_result",
|
|
role="assistant",
|
|
name="lookup",
|
|
content="工具结果",
|
|
tool_call_id="call-1",
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
)
|
|
|
|
try:
|
|
hits = space.search("紫水晶河马", session_id="archive", k=10)
|
|
legacy_hits = space.search(
|
|
"紫水晶河马",
|
|
session_id="archive",
|
|
k=10,
|
|
include_turn=False,
|
|
)
|
|
finally:
|
|
space.close()
|
|
|
|
assert len(hits) == 1
|
|
assert hits[0]["match_seq"] == user_seq
|
|
assert hits[0]["turn_start_seq"] == user_seq
|
|
assert hits[0]["turn_end_seq"] == tool_seq
|
|
assert hits[0]["turn_complete"] is True
|
|
assert [row["seq"] for row in hits[0]["turn"]] == [
|
|
user_seq,
|
|
assistant_seq,
|
|
tool_seq,
|
|
]
|
|
assert [row["content"] for row in hits[0]["turn"]] == [
|
|
"紫水晶河马在周二跳舞",
|
|
"我记住了这个暗号",
|
|
"工具结果",
|
|
]
|
|
assert [row["seq"] for row in legacy_hits] == [user_seq]
|
|
|
|
|
|
def test_search_cjk_multiple_terms_are_and_combined(tmp_path: Path):
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
user_seq = history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="u1",
|
|
entry=LogEntry(
|
|
kind="context_msg",
|
|
role="user",
|
|
content="项目的截止日期是周二",
|
|
),
|
|
)
|
|
assistant_seq = history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="a1",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content="好的,我记住了",
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
)
|
|
|
|
try:
|
|
hits = space.search(
|
|
"项目 截止日期",
|
|
session_id="archive",
|
|
k=10,
|
|
)
|
|
finally:
|
|
space.close()
|
|
|
|
assert len(hits) == 1
|
|
assert hits[0]["match_seq"] == user_seq
|
|
assert hits[0]["turn_complete"] is True
|
|
assert [row["seq"] for row in hits[0]["turn"]] == [
|
|
user_seq,
|
|
assistant_seq,
|
|
]
|
|
|
|
|
|
def test_search_cjk_uppercase_or_matches_either_term(tmp_path: Path):
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
project_seq = history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="project",
|
|
entry=LogEntry(
|
|
kind="context_msg",
|
|
role="user",
|
|
content="项目状态",
|
|
),
|
|
)
|
|
deadline_seq = history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="deadline",
|
|
entry=LogEntry(
|
|
kind="context_msg",
|
|
role="user",
|
|
content="截止日期",
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
)
|
|
|
|
try:
|
|
hits = space.search(
|
|
"项目 OR 截止日期",
|
|
session_id="archive",
|
|
k=10,
|
|
)
|
|
matching_rows = space.search(
|
|
"项目 OR 截止日期",
|
|
session_id="archive",
|
|
k=10,
|
|
include_turn=False,
|
|
)
|
|
finally:
|
|
space.close()
|
|
|
|
assert {hit["match_seq"] for hit in hits} == {
|
|
project_seq,
|
|
deadline_seq,
|
|
}
|
|
assert {row["content"] for row in matching_rows} == {
|
|
"项目状态",
|
|
"截止日期",
|
|
}
|
|
|
|
|
|
def test_search_like_metacharacters_are_literal(tmp_path: Path):
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
for index, content in enumerate(
|
|
(
|
|
"项目_编号",
|
|
"项目X编号",
|
|
"项目%进度",
|
|
"项目任意长度进度",
|
|
"项目\\路径",
|
|
),
|
|
):
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key=f"row-{index}",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content=content,
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
)
|
|
|
|
try:
|
|
underscore = space.search(
|
|
"项目_编号",
|
|
session_id="archive",
|
|
include_turn=False,
|
|
)
|
|
percent = space.search(
|
|
"项目%进度",
|
|
session_id="archive",
|
|
include_turn=False,
|
|
)
|
|
backslash = space.search(
|
|
"项目\\路径",
|
|
session_id="archive",
|
|
include_turn=False,
|
|
)
|
|
finally:
|
|
space.close()
|
|
|
|
assert [row["content"] for row in underscore] == ["项目_编号"]
|
|
assert [row["content"] for row in percent] == ["项目%进度"]
|
|
assert [row["content"] for row in backslash] == ["项目\\路径"]
|
|
|
|
|
|
def test_search_loads_duplicate_hit_turn_once(
|
|
tmp_path: Path,
|
|
monkeypatch,
|
|
):
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="user",
|
|
entry=LogEntry(
|
|
kind="context_msg",
|
|
role="user",
|
|
content="find the repeated records",
|
|
),
|
|
)
|
|
matched = []
|
|
for index in range(100):
|
|
matched.append(
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key=f"match-{index}",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content=f"duplicate-needle result {index}",
|
|
),
|
|
),
|
|
)
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="next-user",
|
|
entry=LogEntry(
|
|
kind="context_msg",
|
|
role="user",
|
|
content="current unrelated request",
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
)
|
|
load_calls = 0
|
|
original = space._load_turn
|
|
|
|
def counted_load(*args, **kwargs):
|
|
nonlocal load_calls
|
|
load_calls += 1
|
|
return original(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(space, "_load_turn", counted_load)
|
|
try:
|
|
rows = space.search("duplicate-needle", k=100)
|
|
finally:
|
|
space.close()
|
|
|
|
turns = [row for row in rows if row["kind"] != "_notice"]
|
|
assert len(turns) == 1
|
|
assert turns[0]["matched_seqs"] == matched
|
|
assert load_calls == 1
|
|
|
|
|
|
def test_search_pages_until_it_finds_k_distinct_turns(tmp_path: Path):
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="first-user",
|
|
entry=LogEntry(
|
|
kind="context_msg",
|
|
role="user",
|
|
content="first question",
|
|
),
|
|
)
|
|
for index in range(80):
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key=f"first-hit-{index}",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content=f"needle {index}",
|
|
),
|
|
)
|
|
second_start = history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="second-user",
|
|
entry=LogEntry(
|
|
kind="context_msg",
|
|
role="user",
|
|
content="second question",
|
|
),
|
|
)
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="second-hit",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content="needle " + ("lower ranked filler " * 40),
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
)
|
|
try:
|
|
rows = space.search("needle", k=2)
|
|
finally:
|
|
space.close()
|
|
|
|
turns = [row for row in rows if row["kind"] != "_notice"]
|
|
assert len(turns) == 2
|
|
assert second_start in {row["turn_start_seq"] for row in turns}
|
|
assert not [row for row in rows if row["kind"] == "_notice"]
|
|
|
|
|
|
def test_search_marks_partial_when_raw_hit_cap_hides_turns(tmp_path: Path):
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="first-user",
|
|
entry=LogEntry(
|
|
kind="context_msg",
|
|
role="user",
|
|
content="first question",
|
|
),
|
|
)
|
|
for index in range(20):
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key=f"first-hit-{index}",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content=f"needle {index}",
|
|
),
|
|
)
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="second-user",
|
|
entry=LogEntry(
|
|
kind="context_msg",
|
|
role="user",
|
|
content="second question",
|
|
),
|
|
)
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="second-hit",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content="needle " + ("lower ranked filler " * 40),
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
row_cap=10,
|
|
)
|
|
try:
|
|
rows = space.search("needle", k=2)
|
|
finally:
|
|
space.close()
|
|
|
|
turns = [row for row in rows if row["kind"] != "_notice"]
|
|
notices = [row for row in rows if row["kind"] == "_notice"]
|
|
assert len(turns) == 1
|
|
assert len(notices) == 1
|
|
assert "maximum 10 matching rows" in notices[0]["content"]
|
|
|
|
|
|
def test_search_turn_expansion_has_total_row_budget(tmp_path: Path):
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
turn_seqs = [
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="user",
|
|
entry=LogEntry(
|
|
kind="context_msg",
|
|
role="user",
|
|
content="budget question",
|
|
),
|
|
),
|
|
]
|
|
for index in range(10):
|
|
turn_seqs.append(
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key=f"row-{index}",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content=f"budget-needle row {index}",
|
|
),
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
search_turn_max_rows=3,
|
|
)
|
|
try:
|
|
rows = space.search("budget-needle", k=10)
|
|
finally:
|
|
space.close()
|
|
|
|
result = next(row for row in rows if row["kind"] != "_notice")
|
|
turn = result["turn"]
|
|
assert len([row for row in turn if not row.get("_truncated")]) == 3
|
|
assert turn[-1]["_truncated"] is True
|
|
assert result["turn_end_seq"] == turn_seqs[-1]
|
|
assert result["turn_loaded_end_seq"] == turn_seqs[2]
|
|
assert result["turn_complete"] is False
|
|
notices = [row for row in rows if row["kind"] == "_notice"]
|
|
assert len(notices) == 1
|
|
assert "total turn row budget" in notices[0]["content"]
|
|
|
|
|
|
def test_search_turn_expansion_has_total_byte_budget(tmp_path: Path):
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
user_seq = history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="user",
|
|
entry=LogEntry(
|
|
kind="context_msg",
|
|
role="user",
|
|
content="small byte budget question",
|
|
),
|
|
)
|
|
actual_end = history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="large",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content="byte-budget-needle " + ("x" * 2000),
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
search_turn_max_bytes=600,
|
|
)
|
|
try:
|
|
rows = space.search("byte-budget-needle")
|
|
finally:
|
|
space.close()
|
|
|
|
result = next(row for row in rows if row["kind"] != "_notice")
|
|
turn = result["turn"]
|
|
assert len([row for row in turn if not row.get("_truncated")]) == 1
|
|
assert turn[-1]["_truncated"] is True
|
|
assert result["turn_end_seq"] == actual_end
|
|
assert result["turn_loaded_end_seq"] == user_seq
|
|
assert result["turn_complete"] is False
|
|
notices = [row for row in rows if row["kind"] == "_notice"]
|
|
assert len(notices) == 1
|
|
assert "total turn byte budget" in notices[0]["content"]
|
|
|
|
|
|
def test_search_execution_timeout_is_enforced(history_db: Path):
|
|
space = MemorySpace(
|
|
history_db_path=history_db,
|
|
session_id="s1",
|
|
agent_id="ag1",
|
|
search_max_seconds=0,
|
|
)
|
|
try:
|
|
with pytest.raises(TimeoutError, match="execution timeout"):
|
|
space.search("tanks")
|
|
finally:
|
|
space.close()
|
|
|
|
|
|
def test_search_groups_imported_kind_by_user_role(tmp_path: Path):
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
user_seq = history.append(
|
|
session_id="beam-batch",
|
|
agent_id="ag1",
|
|
dedup_key="beam-u1",
|
|
entry=LogEntry(
|
|
kind="beam_chat_turn", # type: ignore[arg-type]
|
|
role="user",
|
|
content="How many Jira tasks did I log?",
|
|
created_at="2024-11-05T00:00:00+00:00",
|
|
),
|
|
)
|
|
assistant_seq = history.append(
|
|
session_id="beam-batch",
|
|
agent_id="ag1",
|
|
dedup_key="beam-a1",
|
|
entry=LogEntry(
|
|
kind="beam_chat_turn", # type: ignore[arg-type]
|
|
role="assistant",
|
|
content="You logged 18 Jira tasks.",
|
|
created_at="2024-11-05T00:00:00+00:00",
|
|
),
|
|
)
|
|
history.append(
|
|
session_id="beam-batch",
|
|
agent_id="ag1",
|
|
dedup_key="beam-u2",
|
|
entry=LogEntry(
|
|
kind="beam_chat_turn", # type: ignore[arg-type]
|
|
role="user",
|
|
content="A separate benchmark question",
|
|
created_at="2024-11-06T00:00:00+00:00",
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="probe",
|
|
agent_id="ag1",
|
|
)
|
|
|
|
try:
|
|
assistant_hit = space.search(
|
|
"18 Jira",
|
|
kind="beam_chat_turn",
|
|
)
|
|
user_hit = space.search(
|
|
"How many",
|
|
kind="beam_chat_turn",
|
|
)
|
|
finally:
|
|
space.close()
|
|
|
|
assert len(assistant_hit) == 1
|
|
assert assistant_hit[0]["turn_start_seq"] == user_seq
|
|
assert assistant_hit[0]["turn_end_seq"] == assistant_seq
|
|
assert [row["content"] for row in assistant_hit[0]["turn"]] == [
|
|
"How many Jira tasks did I log?",
|
|
"You logged 18 Jira tasks.",
|
|
]
|
|
assert user_hit[0]["turn"] == assistant_hit[0]["turn"]
|
|
|
|
|
|
def test_search_turn_does_not_cross_session_or_agent(tmp_path: Path):
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
for session_id, agent_id, suffix in (
|
|
("s1", "ag1", "one"),
|
|
("s2", "ag1", "two"),
|
|
("s1", "ag2", "three"),
|
|
):
|
|
history.append(
|
|
session_id=session_id,
|
|
agent_id=agent_id,
|
|
dedup_key=f"u-{suffix}",
|
|
entry=LogEntry(
|
|
kind="context_msg",
|
|
role="user",
|
|
content=f"needle request {suffix}",
|
|
),
|
|
)
|
|
history.append(
|
|
session_id=session_id,
|
|
agent_id=agent_id,
|
|
dedup_key=f"a-{suffix}",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content=f"answer {suffix}",
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
)
|
|
|
|
try:
|
|
hits = space.search("needle", all_agents=True, k=10)
|
|
finally:
|
|
space.close()
|
|
|
|
assert len(hits) == 3
|
|
for hit in hits:
|
|
assert {
|
|
(row["session_id"], row["agent_id"]) for row in hit["turn"]
|
|
} == {
|
|
(hit["session_id"], hit["agent_id"]),
|
|
}
|
|
|
|
|
|
def test_active_turn_floor_is_computed_once_per_instance(
|
|
ms: MemorySpace,
|
|
monkeypatch,
|
|
):
|
|
"""The MAX(seq) scan behind the active-turn exclusion is memoized: a
|
|
single search consults the floor twice (FTS path + LIKE fallback) and the
|
|
read-only history can't change under the instance, so it must run at most
|
|
once — the cost that accrues on large histories in the recall subprocess.
|
|
"""
|
|
calls = {"n": 0}
|
|
real = ms._compute_active_turn_floor
|
|
|
|
def counting():
|
|
calls["n"] += 1
|
|
return real()
|
|
|
|
monkeypatch.setattr(ms, "_compute_active_turn_floor", counting)
|
|
|
|
# Consult it across every path that would otherwise re-query.
|
|
ms.search("tanks", k=5)
|
|
ms._search_like("tanks", [("agent_id", "ag1")], None, 5)
|
|
ms._active_turn_floor()
|
|
|
|
assert calls["n"] == 1
|
|
|
|
|
|
def test_active_turn_floor_ignores_continuation_stubs(tmp_path: Path):
|
|
"""A loop-continuation stub row (user-role, tagged) must not move the
|
|
active-turn floor: the floor anchors on the REAL request that started
|
|
the turn, so the whole still-live extended turn stays excluded from
|
|
search instead of leaking back in as echo."""
|
|
h = HistoryStore(tmp_path / "history.db")
|
|
rows = [
|
|
("old", "model_turn", "assistant", "tanks parked at base", None),
|
|
("req", "context_msg", "user", "tanks question", None),
|
|
("a1", "model_turn", "assistant", "tanks quote from recall", None),
|
|
(
|
|
"stub",
|
|
"context_msg",
|
|
"user",
|
|
"Continue working on the task.",
|
|
{"qwenpaw_tag": "loop_continuation"},
|
|
),
|
|
("a2", "model_turn", "assistant", "tanks continued reply", None),
|
|
]
|
|
for key, kind, role, content, metadata in rows:
|
|
h.append(
|
|
session_id="s1",
|
|
agent_id="ag1",
|
|
dedup_key=key,
|
|
entry=LogEntry(
|
|
kind=kind,
|
|
role=role,
|
|
content=content,
|
|
metadata=metadata or {},
|
|
),
|
|
)
|
|
h.close()
|
|
space = MemorySpace(
|
|
history_db_path=str(tmp_path / "history.db"),
|
|
session_id="s1",
|
|
agent_id="ag1",
|
|
)
|
|
try:
|
|
# Floor = the real request's seq (NOT the stub's): everything from
|
|
# the request onward is active-turn and excluded from search.
|
|
hits = {r["content"] for r in space.search("tanks", k=10)}
|
|
assert hits == {"tanks parked at base"}
|
|
finally:
|
|
space.close()
|
|
|
|
|
|
def test_search_rows_carry_session_id(ms: MemorySpace):
|
|
# Cross-session/agent search is only useful if a hit says which session it
|
|
# came from — the model needs ``session_id`` to follow up (it used to guess
|
|
# the key and crash with KeyError).
|
|
rows = {r["content"]: r for r in ms.search("tanks", all_agents=True)}
|
|
assert rows["tanks rolled in"]["session_id"] == "s1"
|
|
assert rows["tanks regrouped later"]["session_id"] == "s2"
|
|
|
|
|
|
def test_fts_match_query_passes_boolean_operators():
|
|
# Bare UPPERCASE AND/OR/NOT are FTS5 operators (so the model can cast a
|
|
# wide net); every other token is a quoted literal; a plain query is AND.
|
|
assert fts_match_query("tank OR aquarium") == '"tank" OR "aquarium"'
|
|
assert fts_match_query("plain words") == '"plain" "words"'
|
|
# lowercase 'or' is a search term, not an operator
|
|
assert fts_match_query("salt or pepper") == '"salt" "or" "pepper"'
|
|
# punctuation operators are still neutralised
|
|
assert fts_match_query("F-15") == '"F" "15"'
|
|
|
|
|
|
def test_search_or_widens_beyond_a_single_term(tmp_path: Path):
|
|
h = HistoryStore(tmp_path / "history.db")
|
|
h.append(
|
|
session_id="s1",
|
|
agent_id="ag1",
|
|
dedup_key="a",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="user",
|
|
content="cleaned the goldfish tank",
|
|
),
|
|
)
|
|
h.append(
|
|
session_id="s1",
|
|
agent_id="ag1",
|
|
dedup_key="b",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="user",
|
|
content="bought an aquarium filter",
|
|
),
|
|
)
|
|
h.close()
|
|
space = MemorySpace(
|
|
history_db_path=str(tmp_path / "history.db"),
|
|
session_id="s1",
|
|
agent_id="ag1",
|
|
)
|
|
try:
|
|
# OR matches EITHER term (2 rows); the AND form would match neither.
|
|
assert len(space.search("tank OR aquarium")) == 2
|
|
assert len(space.search("tank aquarium")) == 0
|
|
finally:
|
|
space.close()
|
|
|
|
|
|
def test_search_all_agents_spans_the_workspace(ms: MemorySpace):
|
|
contents = {r["content"] for r in ms.search("tanks", all_agents=True)}
|
|
assert "tanks of another agent" in contents
|
|
assert len(contents) == 3
|
|
|
|
|
|
def test_search_pins_to_an_explicit_session(ms: MemorySpace):
|
|
# ms is on s1, but an explicit session_id targets a different one.
|
|
contents = {r["content"] for r in ms.search("tanks", session_id="s2")}
|
|
assert contents == {"tanks regrouped later"}
|
|
|
|
|
|
def test_search_pins_to_an_explicit_agent(ms: MemorySpace):
|
|
# The default agent scope hides ag2; pin to it to read its history.
|
|
contents = {r["content"] for r in ms.search("tanks", agent_id="ag2")}
|
|
assert contents == {"tanks of another agent"}
|
|
|
|
|
|
def test_explicit_target_takes_precedence(ms: MemorySpace):
|
|
# An explicit session_id wins even against all_agents=True.
|
|
contents = {
|
|
r["content"]
|
|
for r in ms.search("tanks", all_agents=True, session_id="s1")
|
|
}
|
|
assert contents == {"tanks rolled in"}
|
|
|
|
|
|
def test_search_filters_by_created_at_calendar_dates(tmp_path: Path):
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
rows = [
|
|
(
|
|
"u1",
|
|
"context_msg",
|
|
"user",
|
|
"Jira sprint alpha",
|
|
"2024-11-05T08:00:00+08:00",
|
|
),
|
|
(
|
|
"a1",
|
|
"model_turn",
|
|
"assistant",
|
|
"logged 18 tasks",
|
|
"2024-11-05T08:01:00+08:00",
|
|
),
|
|
(
|
|
"u2",
|
|
"context_msg",
|
|
"user",
|
|
"Jira sprint beta",
|
|
"2024-11-06T23:00:00-08:00",
|
|
),
|
|
(
|
|
"a2",
|
|
"model_turn",
|
|
"assistant",
|
|
"logged 21 tasks",
|
|
"2024-11-06T23:01:00-08:00",
|
|
),
|
|
(
|
|
"u3",
|
|
"context_msg",
|
|
"user",
|
|
"unrelated current boundary",
|
|
"2024-11-07T00:00:00Z",
|
|
),
|
|
]
|
|
for key, kind, role, content, created_at in rows:
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key=key,
|
|
entry=LogEntry(
|
|
kind=kind,
|
|
role=role,
|
|
content=content,
|
|
created_at=created_at,
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
)
|
|
|
|
try:
|
|
exact = space.search("tasks", created_on="2024-11-05")
|
|
date_only = space.search("", created_on="2024-11-06")
|
|
date_range = space.search(
|
|
"Jira",
|
|
created_from="2024-11-05",
|
|
created_to="2024-11-06",
|
|
)
|
|
space._fts_ok = False
|
|
like = space.search("tasks", created_on="2024-11-05")
|
|
finally:
|
|
space.close()
|
|
|
|
assert len(exact) == 1
|
|
assert exact[0]["content"] == "logged 18 tasks"
|
|
assert exact[0]["created_at"].startswith("2024-11-05")
|
|
assert {row["content"] for row in exact[0]["turn"]} == {
|
|
"Jira sprint alpha",
|
|
"logged 18 tasks",
|
|
}
|
|
assert len(date_only) == 1
|
|
assert date_only[0]["turn_start_seq"] == 3
|
|
assert len(date_range) == 2
|
|
assert {row["content"] for row in like if row["seq"] >= 0} == {
|
|
"logged 18 tasks",
|
|
}
|
|
|
|
|
|
def test_date_only_search_keeps_null_content_rows(tmp_path: Path):
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
target_seq = history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="structured",
|
|
entry=LogEntry(
|
|
kind="context_msg",
|
|
role="user",
|
|
content=None,
|
|
blocks=[{"type": "text", "text": "structured history"}],
|
|
created_at="2024-11-05T08:00:00Z",
|
|
),
|
|
)
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="next-day",
|
|
entry=LogEntry(
|
|
kind="context_msg",
|
|
role="user",
|
|
content="next boundary",
|
|
created_at="2024-11-06T08:00:00Z",
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
)
|
|
space._fts_ok = False
|
|
try:
|
|
matching_rows = space.search(
|
|
"",
|
|
created_on="2024-11-05",
|
|
include_turn=False,
|
|
)
|
|
turns = space.search("", created_on="2024-11-05")
|
|
finally:
|
|
space.close()
|
|
|
|
assert [row["seq"] for row in matching_rows] == [target_seq]
|
|
assert matching_rows[0]["content"] is None
|
|
assert len(turns) == 1
|
|
assert turns[0]["turn_start_seq"] == target_seq
|
|
assert turns[0]["turn"][0]["content"] is None
|
|
assert "structured history" in turns[0]["turn"][0]["blocks"]
|
|
assert all(row["kind"] != "_notice" for row in turns)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"kwargs",
|
|
[
|
|
{"created_on": "2024-02-30"},
|
|
{
|
|
"created_on": "2024-11-05",
|
|
"created_from": "2024-11-01",
|
|
},
|
|
{
|
|
"created_from": "2024-11-06",
|
|
"created_to": "2024-11-05",
|
|
},
|
|
],
|
|
)
|
|
def test_search_rejects_invalid_created_at_filters(
|
|
ms: MemorySpace,
|
|
kwargs: dict,
|
|
):
|
|
with pytest.raises(ValueError):
|
|
ms.search("tanks", **kwargs)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"kwargs",
|
|
[
|
|
{"created_on": "9999-12-31"},
|
|
{"created_from": "9999-12-31", "created_to": "9999-12-31"},
|
|
],
|
|
)
|
|
def test_search_accepts_date_max_without_overflow(
|
|
tmp_path: Path,
|
|
kwargs: dict,
|
|
):
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
target_seq = history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="max-date",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content="last representable day",
|
|
created_at="9999-12-31T23:59:59.999999+14:00",
|
|
),
|
|
)
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="previous-date",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content="previous representable day",
|
|
created_at="9999-12-30T23:59:59Z",
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
)
|
|
try:
|
|
rows = space.search(
|
|
"representable",
|
|
include_turn=False,
|
|
**kwargs,
|
|
)
|
|
finally:
|
|
space.close()
|
|
|
|
assert [row["seq"] for row in rows] == [target_seq]
|
|
|
|
|
|
def test_row_cap_truncates_with_marker(history_db: Path):
|
|
space = MemorySpace(
|
|
history_db_path=str(history_db),
|
|
session_id="s1",
|
|
agent_id="ag1",
|
|
row_cap=2,
|
|
)
|
|
try:
|
|
rows = space.sql_query("SELECT seq FROM hist.conversation_history")
|
|
assert rows[-1].get("_truncated") is True
|
|
assert len([r for r in rows if "_truncated" not in r]) == 2
|
|
finally:
|
|
space.close()
|
|
|
|
|
|
def _hits(rows: list[dict]) -> set:
|
|
"""Result contents minus the LIKE-degraded notice row."""
|
|
return {r["content"] for r in rows if r["kind"] != "_notice"}
|
|
|
|
|
|
def test_like_fallback_respects_scope(ms: MemorySpace):
|
|
"""Force the no-FTS path; scope + explicit targeting still hold."""
|
|
ms._fts_ok = False
|
|
contents = _hits(ms.search("tanks"))
|
|
assert "tanks of another agent" not in contents
|
|
assert "tanks rolled in" in contents
|
|
# explicit targeting works on the LIKE path too
|
|
pinned = _hits(ms.search("tanks", agent_id="ag2"))
|
|
assert pinned == {"tanks of another agent"}
|
|
|
|
|
|
def test_like_fallback_preserves_uppercase_or(ms: MemorySpace):
|
|
"""The public OR contract also holds when FTS5 is unavailable."""
|
|
ms._fts_ok = False
|
|
|
|
assert _hits(ms.search("rolled OR regrouped")) == {
|
|
"tanks rolled in",
|
|
"tanks regrouped later",
|
|
}
|
|
|
|
|
|
def test_like_fallback_emits_degradation_notice(ms: MemorySpace):
|
|
"""Without FTS5 the model must be told search degraded to a LIKE scan, so
|
|
it stops using OR/boolean grammar that silently matches nothing."""
|
|
ms._fts_ok = False
|
|
rows = ms.search("tanks")
|
|
assert rows[0]["kind"] == "_notice"
|
|
assert "FTS5" in rows[0]["content"]
|
|
# The notice shares the row schema, so a content-iterating loop is safe.
|
|
assert set(rows[0].keys()) >= {"seq", "kind", "role", "content"}
|
|
|
|
|
|
def test_no_notice_when_fts_available(ms: MemorySpace):
|
|
"""The notice is FTS-unavailable-only — a normal FTS build never sees it,
|
|
even when a query degrades to LIKE for lack of word tokens."""
|
|
# All-punctuation query falls back to LIKE, but FTS5 *is* available here.
|
|
rows = ms.search("!!!")
|
|
assert all(r["kind"] != "_notice" for r in rows)
|
|
|
|
|
|
# -- strict date arithmetic -------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("start", "end", "expected"),
|
|
[
|
|
("2024-11-01", "2024-12-16", 45),
|
|
("2024-12-16", "2024-11-01", -45),
|
|
("2024-02-28", "2024-03-01", 2),
|
|
("2023-02-28", "2023-03-01", 1),
|
|
(
|
|
"2024-11-01T23:59:59.123456+08:00",
|
|
"2024-12-16T00:00:00Z",
|
|
45,
|
|
),
|
|
(
|
|
"2024-11-01T00:00-07:00",
|
|
"2024-10-31T23:59+14:00",
|
|
-1,
|
|
),
|
|
],
|
|
)
|
|
def test_days_between_is_signed_and_accepts_iso_timestamps(
|
|
ms: MemorySpace,
|
|
start: str,
|
|
end: str,
|
|
expected: int,
|
|
):
|
|
assert ms.days_between(start, end) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("start", "end", "expected"),
|
|
[
|
|
("2024-11-01", "2024-11-01", 1),
|
|
("2024-11-01", "2024-11-02", 2),
|
|
("2024-11-02", "2024-11-01", -2),
|
|
],
|
|
)
|
|
def test_days_between_inclusive_preserves_direction(
|
|
ms: MemorySpace,
|
|
start: str,
|
|
end: str,
|
|
expected: int,
|
|
):
|
|
assert ms.days_between(start, end, inclusive=True) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
[
|
|
"2023-02-29",
|
|
"2024-02-30",
|
|
"2024/11/01",
|
|
"prefix 2024-11-01",
|
|
"2024-11-01 12:30:00Z",
|
|
"2024-11-01T12:30:00+24:00",
|
|
"2024-11-01T12:30:00 PST",
|
|
" 2024-11-01",
|
|
],
|
|
)
|
|
def test_days_between_rejects_invalid_or_non_strict_dates(
|
|
ms: MemorySpace,
|
|
value: str,
|
|
):
|
|
with pytest.raises(ValueError, match="invalid ISO date or timestamp"):
|
|
ms.days_between(value, "2024-12-16")
|
|
|
|
|
|
def test_days_between_rejects_unsupported_types(ms: MemorySpace):
|
|
with pytest.raises(TypeError, match="must be an ISO string"):
|
|
ms.days_between(20241101, "2024-12-16")
|
|
|
|
|
|
# -- intent-named recall helpers --------------------------------------------
|
|
|
|
|
|
def test_expand_returns_full_turns_in_span(ms: MemorySpace):
|
|
rows = ms.expand(1, 99)
|
|
assert [r["content"] for r in rows] == ["tanks rolled in"]
|
|
|
|
|
|
def test_expand_includes_legacy_unowned_rows(history_db: Path):
|
|
history = HistoryStore(history_db)
|
|
legacy_seq = history.append(
|
|
session_id="legacy",
|
|
agent_id=None,
|
|
dedup_key="legacy-null-agent",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content="legacy unowned turn",
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=str(history_db),
|
|
session_id="legacy",
|
|
agent_id="ag1",
|
|
)
|
|
try:
|
|
rows = space.expand(legacy_seq, legacy_seq)
|
|
finally:
|
|
space.close()
|
|
|
|
assert [row["content"] for row in rows] == ["legacy unowned turn"]
|
|
|
|
|
|
def test_recall_tool_is_agent_scoped_by_default(history_db: Path, tmp_path):
|
|
h = HistoryStore(history_db)
|
|
h.append(
|
|
session_id="s9",
|
|
agent_id="ag2",
|
|
dedup_key="tcX",
|
|
entry=LogEntry(
|
|
kind="tool_result",
|
|
role="assistant",
|
|
content="other agent tool",
|
|
tool_call_id="shared",
|
|
),
|
|
)
|
|
h.close()
|
|
space = MemorySpace(
|
|
history_db_path=str(history_db),
|
|
session_id="s1",
|
|
agent_id="ag1",
|
|
)
|
|
try:
|
|
# ag1 has no 'shared' tcid → empty; widening reaches ag2's row.
|
|
assert not space.recall_tool("shared")
|
|
assert len(space.recall_tool("shared", all_agents=True)) == 1
|
|
finally:
|
|
space.close()
|
|
|
|
|
|
# -- session / agent discovery ----------------------------------------------
|
|
|
|
|
|
def test_sessions_lists_this_agents_conversations(ms: MemorySpace):
|
|
rows = {r["session_id"]: r for r in ms.sessions()}
|
|
# ag1 ran in s1 and s2; ag2's s3 is hidden by the default agent scope.
|
|
assert set(rows) == {"s1", "s2"}
|
|
assert rows["s1"]["turns"] == 1
|
|
|
|
|
|
def test_sessions_all_agents_spans_the_workspace(ms: MemorySpace):
|
|
ids = {r["session_id"] for r in ms.sessions(all_agents=True)}
|
|
assert ids == {"s1", "s2", "s3"}
|
|
|
|
|
|
def test_session_is_agent_scoped_by_default(ms: MemorySpace):
|
|
# ag2's s3 is hidden by the default agent scope: session ids are not
|
|
# globally unique (main/local/cron:<job> recur across agents), so the
|
|
# default must not leak another agent's conversation. all_agents widens.
|
|
assert ms.session("s3") == []
|
|
rows = ms.session("s3", all_agents=True)
|
|
assert [r["content"] for r in rows] == ["tanks of another agent"]
|
|
|
|
|
|
def test_agents_is_workspace_wide(ms: MemorySpace):
|
|
rows = {r["agent_id"]: r for r in ms.agents()}
|
|
assert set(rows) == {"ag1", "ag2"}
|
|
assert rows["ag1"]["sessions"] == 2 # s1 + s2
|
|
|
|
|
|
def test_sanitize_suffix():
|
|
assert sanitize_suffix(None) == "scratch"
|
|
assert sanitize_suffix("a-b.c/d") == "a_b_c_d"
|
|
assert sanitize_suffix("ok_123") == "ok_123"
|
|
|
|
|
|
# -- SQL values are bound, not f-string-concatenated ------------------------
|
|
|
|
|
|
def test_recall_values_with_sql_metacharacters_are_bound(tmp_path: Path):
|
|
"""Recall must bind ``session_id``/``agent_id``/``tool_call_id`` as SQL
|
|
parameters, never f-string them in. A value carrying a single quote (e.g.
|
|
``O'Brien's task``) would otherwise break the WHERE clause or open an
|
|
injection; here it must round-trip cleanly and match only its own row."""
|
|
quoted_session = "O'Brien's task"
|
|
quoted_agent = "ag'1"
|
|
quoted_tcid = "tc'1"
|
|
h = HistoryStore(tmp_path / "history.db")
|
|
h.append(
|
|
session_id=quoted_session,
|
|
agent_id=quoted_agent,
|
|
dedup_key="m1",
|
|
entry=LogEntry(
|
|
kind="tool_result",
|
|
role="assistant",
|
|
content="briefing for the quoted session",
|
|
tool_call_id=quoted_tcid,
|
|
),
|
|
)
|
|
# A decoy under a different agent the scoped recall must NOT return.
|
|
h.append(
|
|
session_id=quoted_session,
|
|
agent_id="ag2",
|
|
dedup_key="m2",
|
|
entry=LogEntry(
|
|
kind="model_turn",
|
|
role="assistant",
|
|
content="other agent same session name",
|
|
),
|
|
)
|
|
h.close()
|
|
space = MemorySpace(
|
|
history_db_path=str(tmp_path / "history.db"),
|
|
session_id=quoted_session,
|
|
agent_id=quoted_agent,
|
|
)
|
|
try:
|
|
# session(): the path ekzhu flagged — agent-scoped, value bound.
|
|
rows = space.session(quoted_session)
|
|
assert [r["content"] for r in rows] == [
|
|
"briefing for the quoted session",
|
|
]
|
|
# recall_tool(): tool_call_id bound, not concatenated.
|
|
rows = space.recall_tool(quoted_tcid)
|
|
assert [r["content"] for r in rows] == [
|
|
"briefing for the quoted session",
|
|
]
|
|
# search() with an explicit quoted agent_id pin — both MATCH arg and
|
|
# the lineage filter are bound.
|
|
rows = space.search("briefing", agent_id=quoted_agent)
|
|
assert [r["content"] for r in rows] == [
|
|
"briefing for the quoted session",
|
|
]
|
|
# LIKE fallback path takes the same bound (col, value) filters.
|
|
# (Drop the leading FTS-unavailable notice row the LIKE path adds.)
|
|
space._fts_ok = False
|
|
rows = space.search("briefing", agent_id=quoted_agent)
|
|
assert [r["content"] for r in rows if r["kind"] != "_notice"] == [
|
|
"briefing for the quoted session",
|
|
]
|
|
finally:
|
|
space.close()
|
|
|
|
|
|
# -- saved tool-output search -----------------------------------------------
|
|
|
|
|
|
def _saved_tool_notice(path: Path, *, quoted: bool = False) -> str:
|
|
rendered_path = f'"{path}"' if quoted else str(path)
|
|
return (
|
|
"[tool output truncated]\n"
|
|
"If more content is needed, call `read_file` with "
|
|
f"file_path={rendered_path} start_line=1 to read more."
|
|
)
|
|
|
|
|
|
def test_saved_tool_paths_accept_quoted_and_legacy_paths_with_spaces(
|
|
tmp_path: Path,
|
|
):
|
|
artifact_dir = tmp_path / "tool results with spaces"
|
|
artifact_dir.mkdir()
|
|
quoted_file = artifact_dir / "quoted result.txt"
|
|
quoted_file.write_text("quoted\n", encoding="utf-8")
|
|
legacy_file = artifact_dir / "legacy result.txt"
|
|
legacy_file.write_text("legacy\n", encoding="utf-8")
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
)
|
|
|
|
try:
|
|
paths = space._saved_tool_paths(
|
|
_saved_tool_notice(quoted_file, quoted=True)
|
|
+ "\n"
|
|
+ _saved_tool_notice(legacy_file),
|
|
)
|
|
finally:
|
|
space.close()
|
|
|
|
assert paths == [quoted_file.resolve(), legacy_file.resolve()]
|
|
|
|
|
|
def test_saved_tool_paths_prefer_structured_artifact_metadata(tmp_path: Path):
|
|
artifact = tmp_path / "metadata-only-result.txt"
|
|
artifact.write_text("structured artifact\n", encoding="utf-8")
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
)
|
|
|
|
try:
|
|
paths = space._saved_tool_paths(
|
|
"preview without a legacy path notice",
|
|
{
|
|
"qwenpaw_truncation": {
|
|
"0": {
|
|
"file_path": str(artifact),
|
|
},
|
|
},
|
|
},
|
|
)
|
|
finally:
|
|
space.close()
|
|
|
|
assert paths == [artifact.resolve()]
|
|
|
|
|
|
def test_saved_tool_search_checks_each_multiblock_artifact(tmp_path: Path):
|
|
decoy_file = tmp_path / "first-block.txt"
|
|
decoy_file.write_text("nothing relevant\n", encoding="utf-8")
|
|
target_file = tmp_path / "second-block.txt"
|
|
target_file.write_text("the deepneedle is here\n", encoding="utf-8")
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="multi-block-result",
|
|
entry=LogEntry(
|
|
kind="tool_result",
|
|
role="assistant",
|
|
content=(
|
|
_saved_tool_notice(decoy_file)
|
|
+ "\n\n"
|
|
+ _saved_tool_notice(target_file)
|
|
),
|
|
tool_call_id="multi-block-call",
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
)
|
|
|
|
try:
|
|
rows = space.search("deepneedle", k=1)
|
|
finally:
|
|
space.close()
|
|
|
|
assert len(rows) == 1
|
|
assert rows[0]["kind"] == "tool_result"
|
|
assert f"file_path={target_file}" in rows[0]["content"]
|
|
assert "deepneedle" in rows[0]["content"]
|
|
|
|
|
|
def test_saved_tool_search_preserves_uppercase_or(tmp_path: Path):
|
|
artifact = tmp_path / "saved-tool-output.txt"
|
|
artifact.write_text("项目状态\n", encoding="utf-8")
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="saved-result",
|
|
entry=LogEntry(
|
|
kind="tool_result",
|
|
role="assistant",
|
|
content=_saved_tool_notice(artifact),
|
|
tool_call_id="saved-call",
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
)
|
|
|
|
try:
|
|
rows = space.search("项目 OR 截止日期", k=10)
|
|
finally:
|
|
space.close()
|
|
|
|
assert len(rows) == 1
|
|
assert rows[0]["kind"] == "tool_result"
|
|
assert f"file_path={artifact}" in rows[0]["content"]
|
|
assert "项目状态" in rows[0]["content"]
|
|
|
|
|
|
def test_recall_tool_annotates_each_multiblock_artifact(tmp_path: Path):
|
|
first_file = tmp_path / "first-block.txt"
|
|
first_file.write_text("first block\n", encoding="utf-8")
|
|
second_file = tmp_path / "second-block.txt"
|
|
second_file.write_text("second block\n", encoding="utf-8")
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="multi-block-result",
|
|
entry=LogEntry(
|
|
kind="tool_result",
|
|
role="assistant",
|
|
content=(
|
|
_saved_tool_notice(first_file)
|
|
+ "\n\n"
|
|
+ _saved_tool_notice(second_file)
|
|
),
|
|
tool_call_id="multi-block-call",
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
)
|
|
|
|
try:
|
|
rows = space.recall_tool("multi-block-call")
|
|
finally:
|
|
space.close()
|
|
|
|
artifacts = [
|
|
row["content"] for row in rows if row["kind"] == "_saved_tool_output"
|
|
]
|
|
assert artifacts == [
|
|
"Full saved tool output is available at "
|
|
f"file_path={str(first_file)!r} start_line=1.",
|
|
"Full saved tool output is available at "
|
|
f"file_path={str(second_file)!r} start_line=1.",
|
|
]
|
|
|
|
|
|
def test_recall_tool_returns_preview_when_artifact_expired(tmp_path: Path):
|
|
artifact = tmp_path / "expired-result.txt"
|
|
artifact.write_text("complete output\n", encoding="utf-8")
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="expired-result",
|
|
entry=LogEntry(
|
|
kind="tool_result",
|
|
role="assistant",
|
|
content="bounded preview",
|
|
tool_call_id="expired-call",
|
|
metadata={
|
|
"qwenpaw_truncation": {
|
|
"0": {
|
|
"file_path": str(artifact),
|
|
"start_line": 1,
|
|
},
|
|
},
|
|
},
|
|
),
|
|
)
|
|
history.close()
|
|
artifact.unlink()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
)
|
|
|
|
try:
|
|
rows = space.recall_tool("expired-call")
|
|
finally:
|
|
space.close()
|
|
|
|
assert rows[0]["kind"] == "_saved_tool_output_unavailable"
|
|
assert "ARTIFACT_UNAVAILABLE" in rows[0]["content"]
|
|
assert rows[1]["kind"] == "tool_result"
|
|
assert rows[1]["content"] == "bounded preview"
|
|
|
|
|
|
def test_saved_tool_search_pages_past_first_200_candidates(tmp_path: Path):
|
|
target_file = tmp_path / "target.txt"
|
|
target_file.write_text("the deepneedle is here\n", encoding="utf-8")
|
|
decoy_file = tmp_path / "decoy.txt"
|
|
decoy_file.write_text("nothing relevant\n", encoding="utf-8")
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key="oldest-target",
|
|
entry=LogEntry(
|
|
kind="tool_result",
|
|
role="assistant",
|
|
content=_saved_tool_notice(target_file),
|
|
tool_call_id="target-call",
|
|
),
|
|
)
|
|
for index in range(200):
|
|
history.append(
|
|
session_id="archive",
|
|
agent_id="ag1",
|
|
dedup_key=f"newer-decoy-{index}",
|
|
entry=LogEntry(
|
|
kind="tool_result",
|
|
role="assistant",
|
|
content=_saved_tool_notice(decoy_file),
|
|
tool_call_id=f"decoy-{index}",
|
|
),
|
|
)
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
)
|
|
|
|
try:
|
|
rows = space.search("deepneedle", k=1)
|
|
finally:
|
|
space.close()
|
|
|
|
assert len(rows) == 1
|
|
assert rows[0]["kind"] == "tool_result"
|
|
assert "tool_call_id=target-call" in rows[0]["content"]
|
|
assert "deepneedle" in rows[0]["content"]
|
|
|
|
|
|
def test_saved_tool_file_search_streams_without_read_text(
|
|
tmp_path: Path,
|
|
monkeypatch,
|
|
):
|
|
artifact = tmp_path / "large.txt"
|
|
artifact.write_text(
|
|
"before\nneedle match\nafter\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
def fail_read_text(*args, **kwargs):
|
|
raise AssertionError("saved artifact search must stream")
|
|
|
|
monkeypatch.setattr(Path, "read_text", fail_read_text)
|
|
|
|
matches = MemorySpace._file_line_matches(artifact, [["needle"]])
|
|
|
|
assert matches == [
|
|
{
|
|
"line": 2,
|
|
"excerpt": "1: before\n2: needle match\n3: after",
|
|
},
|
|
]
|
|
|
|
|
|
def test_saved_tool_file_search_keeps_and_terms_on_same_line(
|
|
tmp_path: Path,
|
|
):
|
|
artifact = tmp_path / "and-terms.txt"
|
|
artifact.write_text(
|
|
"foo\nbar\nfoo bar\n",
|
|
encoding="utf-8",
|
|
)
|
|
needle_groups = MemorySpace._query_needle_groups("foo bar")
|
|
|
|
matches = MemorySpace._file_line_matches(
|
|
artifact,
|
|
needle_groups,
|
|
context=0,
|
|
)
|
|
|
|
assert matches == [{"line": 3, "excerpt": "3: foo bar"}]
|
|
|
|
|
|
def test_attach_saved_tool_preserves_preview_when_scan_budget_exhausts(
|
|
tmp_path: Path,
|
|
):
|
|
artifact = tmp_path / "large.txt"
|
|
artifact.write_text("x" * 100 + " needle\n", encoding="utf-8")
|
|
history = HistoryStore(tmp_path / "history.db")
|
|
history.close()
|
|
space = MemorySpace(
|
|
history_db_path=tmp_path / "history.db",
|
|
session_id="current",
|
|
agent_id="ag1",
|
|
saved_tool_scan_max_bytes=32,
|
|
)
|
|
|
|
try:
|
|
rows = space._attach_saved_tool_file_matches(
|
|
[
|
|
{
|
|
"seq": 1,
|
|
"kind": "tool_result",
|
|
"role": "assistant",
|
|
"name": "read_file",
|
|
"content": (
|
|
"bounded preview retained in history\n"
|
|
+ _saved_tool_notice(artifact)
|
|
),
|
|
},
|
|
],
|
|
"needle",
|
|
)
|
|
finally:
|
|
space.close()
|
|
|
|
notices = [row for row in rows if row["kind"] == "_notice"]
|
|
assert len(notices) == 1
|
|
assert "Results are partial" in notices[0]["content"]
|
|
previews = [row for row in rows if row["kind"] == "tool_result"]
|
|
assert len(previews) == 1
|
|
assert "bounded preview retained in history" in previews[0]["content"]
|