|
|
@@ -1,4 +1,8 @@
|
|
|
import sqlite3
|
|
|
+from concurrent.futures import ThreadPoolExecutor
|
|
|
+from threading import Barrier
|
|
|
+
|
|
|
+import pytest
|
|
|
|
|
|
from agent_lab.domain.events import ToolCallEvent
|
|
|
from agent_lab.domain.messages import ChatMessage, TokenUsage
|
|
|
@@ -182,3 +186,173 @@ def test_sqlite_store_migrates_legacy_messages_with_empty_tool_calls(tmp_path):
|
|
|
tool_calls_column = next(column for column in migrated if column[1] == "tool_calls_json")
|
|
|
assert tool_calls_column[3] == 1
|
|
|
assert tool_calls_column[4] == "'[]'"
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.parametrize(
|
|
|
+ ("persisted_config", "requested_mode", "persisted_mode"),
|
|
|
+ [
|
|
|
+ ({"tool_invocation_mode": "chat_agent_tools"}, "dual_agent", "chat_agent_tools"),
|
|
|
+ ({}, "chat_agent_tools", "dual_agent"),
|
|
|
+ ],
|
|
|
+)
|
|
|
+def test_sqlite_store_rejects_session_mode_mismatch_without_mutation(
|
|
|
+ tmp_path,
|
|
|
+ persisted_config,
|
|
|
+ requested_mode,
|
|
|
+ persisted_mode,
|
|
|
+):
|
|
|
+ store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
|
|
|
+ session_id = store.create_session(
|
|
|
+ title="mode locked",
|
|
|
+ config=persisted_config,
|
|
|
+ session_id="mode-locked",
|
|
|
+ )
|
|
|
+ store.start_turn(session_id, turn_index=1, user_message="past user")
|
|
|
+ store.append_message(
|
|
|
+ session_id,
|
|
|
+ turn_index=1,
|
|
|
+ message=ChatMessage(role="user", content="past user"),
|
|
|
+ )
|
|
|
+ before_session = store.get_session(session_id)
|
|
|
+ before_messages = store.list_messages(session_id)
|
|
|
+
|
|
|
+ with pytest.raises(
|
|
|
+ ValueError,
|
|
|
+ match=(
|
|
|
+ "session tool invocation mode mismatch: "
|
|
|
+ f"persisted={persisted_mode} requested={requested_mode}"
|
|
|
+ ),
|
|
|
+ ):
|
|
|
+ store.ensure_session(
|
|
|
+ session_id,
|
|
|
+ title="ignored",
|
|
|
+ config={"tool_invocation_mode": requested_mode, "changed": True},
|
|
|
+ )
|
|
|
+
|
|
|
+ assert store.get_session(session_id) == before_session
|
|
|
+ assert store.list_messages(session_id) == before_messages
|
|
|
+
|
|
|
+
|
|
|
+def test_sqlite_store_append_messages_rolls_back_the_whole_protocol_group(tmp_path):
|
|
|
+ database_path = tmp_path / "agent_lab.sqlite3"
|
|
|
+ store = SQLiteSessionStore(database_path)
|
|
|
+ session_id = store.create_session(title="atomic group", config={})
|
|
|
+ store.start_turn(session_id, turn_index=1, user_message="use tool")
|
|
|
+ connection = sqlite3.connect(database_path)
|
|
|
+ connection.execute(
|
|
|
+ """
|
|
|
+ CREATE TRIGGER reject_tool_message
|
|
|
+ BEFORE INSERT ON messages
|
|
|
+ WHEN NEW.role = 'tool'
|
|
|
+ BEGIN
|
|
|
+ SELECT RAISE(ABORT, 'tool blocked');
|
|
|
+ END
|
|
|
+ """
|
|
|
+ )
|
|
|
+ connection.commit()
|
|
|
+ connection.close()
|
|
|
+ tool_call = ToolCallEvent(
|
|
|
+ id="atomic-call",
|
|
|
+ name="mock_search",
|
|
|
+ arguments={"query": "atomic"},
|
|
|
+ raw_arguments='{"query":"atomic"}',
|
|
|
+ )
|
|
|
+
|
|
|
+ with pytest.raises(sqlite3.IntegrityError, match="tool blocked"):
|
|
|
+ store.append_messages(
|
|
|
+ session_id,
|
|
|
+ turn_index=1,
|
|
|
+ messages=[
|
|
|
+ ChatMessage(role="assistant", content="", tool_calls=[tool_call]),
|
|
|
+ ChatMessage(
|
|
|
+ role="tool",
|
|
|
+ content='{"ok":true}',
|
|
|
+ name="mock_search",
|
|
|
+ tool_call_id="atomic-call",
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ )
|
|
|
+
|
|
|
+ assert store.list_messages(session_id) == []
|
|
|
+
|
|
|
+
|
|
|
+def test_sqlite_store_migration_is_safe_across_concurrent_initialization(
|
|
|
+ tmp_path,
|
|
|
+ monkeypatch,
|
|
|
+):
|
|
|
+ database_path = tmp_path / "concurrent-legacy.sqlite3"
|
|
|
+ connection = sqlite3.connect(database_path)
|
|
|
+ connection.execute(
|
|
|
+ """
|
|
|
+ CREATE TABLE messages (
|
|
|
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
+ session_id TEXT NOT NULL,
|
|
|
+ turn_index INTEGER NOT NULL,
|
|
|
+ role TEXT NOT NULL,
|
|
|
+ content TEXT NOT NULL,
|
|
|
+ name TEXT,
|
|
|
+ tool_call_id TEXT,
|
|
|
+ created_at TEXT NOT NULL
|
|
|
+ )
|
|
|
+ """
|
|
|
+ )
|
|
|
+ connection.execute(
|
|
|
+ """
|
|
|
+ INSERT INTO messages
|
|
|
+ (session_id, turn_index, role, content, name, tool_call_id, created_at)
|
|
|
+ VALUES ('legacy', 1, 'assistant', 'visible', NULL, NULL, 'now')
|
|
|
+ """
|
|
|
+ )
|
|
|
+ connection.commit()
|
|
|
+ connection.close()
|
|
|
+ worker_count = 8
|
|
|
+ barrier = Barrier(worker_count)
|
|
|
+ real_connect = sqlite3.connect
|
|
|
+
|
|
|
+ class CoordinatedConnection:
|
|
|
+ def __init__(self, connection):
|
|
|
+ self._connection = connection
|
|
|
+
|
|
|
+ @property
|
|
|
+ def row_factory(self):
|
|
|
+ return self._connection.row_factory
|
|
|
+
|
|
|
+ @row_factory.setter
|
|
|
+ def row_factory(self, value):
|
|
|
+ self._connection.row_factory = value
|
|
|
+
|
|
|
+ def execute(self, sql, *args):
|
|
|
+ cursor = self._connection.execute(sql, *args)
|
|
|
+ if (
|
|
|
+ sql.strip() == "PRAGMA table_info(messages)"
|
|
|
+ and not self._connection.in_transaction
|
|
|
+ ):
|
|
|
+ rows = cursor.fetchall()
|
|
|
+ barrier.wait(timeout=5)
|
|
|
+ return rows
|
|
|
+ return cursor
|
|
|
+
|
|
|
+ def __getattr__(self, name):
|
|
|
+ return getattr(self._connection, name)
|
|
|
+
|
|
|
+ def coordinated_connect(*args, **kwargs):
|
|
|
+ return CoordinatedConnection(real_connect(*args, **kwargs))
|
|
|
+
|
|
|
+ monkeypatch.setattr(
|
|
|
+ "agent_lab.infrastructure.sqlite_store.sqlite3.connect",
|
|
|
+ coordinated_connect,
|
|
|
+ )
|
|
|
+
|
|
|
+ def initialize_store() -> list[dict]:
|
|
|
+ store = SQLiteSessionStore(database_path)
|
|
|
+ barrier.wait()
|
|
|
+ return store.list_messages("legacy")
|
|
|
+
|
|
|
+ with ThreadPoolExecutor(max_workers=worker_count) as executor:
|
|
|
+ results = list(executor.map(lambda _: initialize_store(), range(worker_count)))
|
|
|
+
|
|
|
+ assert all(messages[0]["tool_calls"] == [] for messages in results)
|
|
|
+ columns = real_connect(database_path).execute(
|
|
|
+ "PRAGMA table_info(messages)"
|
|
|
+ ).fetchall()
|
|
|
+ assert [column[1] for column in columns].count("tool_calls_json") == 1
|