1223 lines
39 KiB
Python
1223 lines
39 KiB
Python
"""Integration tests for the workspace-level chat feature.
|
|
|
|
Tests cover:
|
|
- Route-level: POST /workspaces/{workspace_id}/chat endpoint
|
|
- Tool handlers: workspace-specific tool handlers and executor
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
from collections.abc import Callable
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timedelta, timezone
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from nanoid import generate as generate_nanoid
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src import crud, models
|
|
from src.dialectic.chat import workspace_chat, workspace_chat_stream
|
|
from src.models import Peer, Workspace
|
|
from src.utils.agent_tools import (
|
|
ToolContext,
|
|
_handle_get_observation_context, # pyright: ignore[reportPrivateUsage]
|
|
_handle_get_peer_card_by_name, # pyright: ignore[reportPrivateUsage]
|
|
_handle_get_reasoning_chain, # pyright: ignore[reportPrivateUsage]
|
|
_handle_get_workspace_stats, # pyright: ignore[reportPrivateUsage]
|
|
_handle_search_memory_workspace, # pyright: ignore[reportPrivateUsage]
|
|
create_workspace_tool_executor,
|
|
)
|
|
from src.utils.scopes import SCOPE_KIND, scope_peer_name
|
|
|
|
# =============================================================================
|
|
# Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
def _tool_text(result: object) -> str:
|
|
"""Unwrap ToolResult (today's handler contract) or pass through str."""
|
|
content = getattr(result, "content", None)
|
|
return content if isinstance(content, str) else str(result)
|
|
|
|
|
|
@pytest.fixture
|
|
async def workspace_test_data(
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
) -> Any:
|
|
"""Create comprehensive test data with multiple peers and observations.
|
|
|
|
Sets up a workspace with:
|
|
- 3 peers (peer1 observes peer2, peer1 observes peer3)
|
|
- 1 session with messages from all peers
|
|
- Documents (observations) across different peer pairs
|
|
"""
|
|
workspace, peer1 = sample_data
|
|
|
|
# Create additional peers
|
|
peer2 = models.Peer(name=str(generate_nanoid()), workspace_name=workspace.name)
|
|
peer3 = models.Peer(name=str(generate_nanoid()), workspace_name=workspace.name)
|
|
db_session.add_all([peer2, peer3])
|
|
await db_session.flush()
|
|
|
|
# Create session
|
|
session = models.Session(name=str(generate_nanoid()), workspace_name=workspace.name)
|
|
db_session.add(session)
|
|
await db_session.flush()
|
|
|
|
# Create collections (peer1 observes peer2, peer1 observes peer3)
|
|
collection1 = models.Collection(
|
|
workspace_name=workspace.name,
|
|
observer=peer1.name,
|
|
observed=peer2.name,
|
|
)
|
|
collection2 = models.Collection(
|
|
workspace_name=workspace.name,
|
|
observer=peer1.name,
|
|
observed=peer3.name,
|
|
)
|
|
db_session.add_all([collection1, collection2])
|
|
await db_session.flush()
|
|
|
|
# Create messages
|
|
now = datetime.now(timezone.utc)
|
|
messages: list[models.Message] = []
|
|
for i in range(6):
|
|
peer_name = [peer1.name, peer2.name, peer3.name][i % 3]
|
|
msg = models.Message(
|
|
workspace_name=workspace.name,
|
|
session_name=session.name,
|
|
peer_name=peer_name,
|
|
content=f"Test message {i} from {peer_name}",
|
|
seq_in_session=i + 1,
|
|
token_count=10,
|
|
created_at=now - timedelta(minutes=6 - i),
|
|
)
|
|
db_session.add(msg)
|
|
messages.append(msg)
|
|
await db_session.flush()
|
|
for msg in messages:
|
|
await db_session.refresh(msg)
|
|
|
|
# Create documents for peer1->peer2 observations
|
|
docs_peer2: list[models.Document] = []
|
|
for content in [
|
|
"User likes coffee and programming",
|
|
"User works remotely from home",
|
|
]:
|
|
doc = models.Document(
|
|
workspace_name=workspace.name,
|
|
observer=peer1.name,
|
|
observed=peer2.name,
|
|
content=content,
|
|
embedding=[0.1] * 1536,
|
|
session_name=session.name,
|
|
level="explicit",
|
|
metadata={
|
|
"message_ids": [messages[0].id],
|
|
"message_created_at": str(messages[0].created_at),
|
|
},
|
|
)
|
|
db_session.add(doc)
|
|
docs_peer2.append(doc)
|
|
|
|
# Create documents for peer1->peer3 observations
|
|
docs_peer3: list[models.Document] = []
|
|
for content in [
|
|
"User prefers mornings for deep work",
|
|
"User enjoys hiking on weekends",
|
|
]:
|
|
doc = models.Document(
|
|
workspace_name=workspace.name,
|
|
observer=peer1.name,
|
|
observed=peer3.name,
|
|
content=content,
|
|
embedding=[0.2] * 1536,
|
|
session_name=session.name,
|
|
level="explicit",
|
|
metadata={
|
|
"message_ids": [messages[1].id],
|
|
"message_created_at": str(messages[1].created_at),
|
|
},
|
|
)
|
|
db_session.add(doc)
|
|
docs_peer3.append(doc)
|
|
|
|
await db_session.flush()
|
|
for doc in docs_peer2 + docs_peer3:
|
|
await db_session.refresh(doc)
|
|
|
|
# Commit so data is visible to independent tracked_db sessions used by
|
|
# workspace-level tool handlers.
|
|
await db_session.commit()
|
|
|
|
yield workspace, peer1, peer2, peer3, session, messages, docs_peer2, docs_peer3
|
|
|
|
await db_session.rollback()
|
|
|
|
|
|
@pytest.fixture
|
|
def make_workspace_ctx(
|
|
workspace_test_data: Any,
|
|
) -> Callable[..., ToolContext]:
|
|
"""Factory fixture to create ToolContext."""
|
|
workspace, *_ = workspace_test_data
|
|
shared_lock = asyncio.Lock()
|
|
|
|
def _make_ctx(
|
|
*,
|
|
session_name: str | None = None,
|
|
include_observation_ids: bool = True,
|
|
session_allowlist: list[str] | None = None,
|
|
) -> ToolContext:
|
|
return ToolContext(
|
|
observer="",
|
|
observed="",
|
|
current_messages=None,
|
|
workspace_name=workspace.name,
|
|
session_name=session_name,
|
|
include_observation_ids=include_observation_ids,
|
|
history_token_limit=8192,
|
|
db_lock=shared_lock,
|
|
session_allowlist=session_allowlist,
|
|
)
|
|
|
|
return _make_ctx
|
|
|
|
|
|
# =============================================================================
|
|
# Route Tests: POST /workspaces/{workspace_id}/chat
|
|
# =============================================================================
|
|
|
|
|
|
class TestWorkspaceChatEndpoint:
|
|
"""Tests for the workspace chat API endpoint."""
|
|
|
|
def test_workspace_chat_basic(
|
|
self,
|
|
client: Any,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Basic non-streaming workspace chat returns DialecticResponse."""
|
|
test_workspace, _ = sample_data
|
|
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/chat",
|
|
json={
|
|
"query": "What do you know about the peers in this workspace?",
|
|
"stream": False,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "content" in data
|
|
assert data["content"] == "Test workspace chat response"
|
|
|
|
def test_workspace_chat_with_session_id(
|
|
self,
|
|
client: Any,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Workspace chat accepts optional session_id parameter."""
|
|
test_workspace, _ = sample_data
|
|
session_id = str(generate_nanoid())
|
|
|
|
# Create a session first
|
|
create_response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/sessions",
|
|
json={"name": session_id},
|
|
)
|
|
assert create_response.status_code in (200, 201)
|
|
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/chat",
|
|
json={
|
|
"query": "Tell me about recent conversations",
|
|
"session_id": session_id,
|
|
"stream": False,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "content" in data
|
|
|
|
def test_workspace_chat_with_reasoning_level(
|
|
self,
|
|
client: Any,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Workspace chat accepts reasoning_level parameter."""
|
|
test_workspace, _ = sample_data
|
|
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/chat",
|
|
json={
|
|
"query": "Analyze common themes across all peers",
|
|
"stream": False,
|
|
"reasoning_level": "low",
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "content" in data
|
|
|
|
def test_workspace_chat_streaming(
|
|
self,
|
|
client: Any,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Streaming workspace chat returns SSE-formatted events."""
|
|
test_workspace, _ = sample_data
|
|
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/chat",
|
|
json={
|
|
"query": "What patterns do you see across the workspace?",
|
|
"stream": True,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
assert "text/event-stream" in response.headers.get("content-type", "")
|
|
|
|
# Parse SSE events
|
|
events: list[Any] = []
|
|
for line in response.text.strip().split("\n\n"):
|
|
if line.startswith("data: "):
|
|
event_data = json.loads(line[6:])
|
|
events.append(event_data)
|
|
|
|
# Should have content events and a final done event
|
|
assert len(events) >= 2
|
|
content_events = [e for e in events if not e.get("done")]
|
|
done_events = [e for e in events if e.get("done")]
|
|
assert len(content_events) >= 1
|
|
assert len(done_events) == 1
|
|
|
|
# Content events should have delta.content
|
|
for event in content_events:
|
|
assert "delta" in event
|
|
assert "content" in event["delta"]
|
|
|
|
def test_workspace_chat_empty_query_rejected(
|
|
self,
|
|
client: Any,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Empty query should be rejected by validation."""
|
|
test_workspace, _ = sample_data
|
|
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/chat",
|
|
json={
|
|
"query": "",
|
|
"stream": False,
|
|
},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
def test_workspace_chat_missing_query_rejected(
|
|
self,
|
|
client: Any,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Missing query field should be rejected."""
|
|
test_workspace, _ = sample_data
|
|
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/chat",
|
|
json={"stream": False},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
def test_workspace_chat_null_content_response(
|
|
self,
|
|
client: Any,
|
|
sample_data: tuple[Workspace, Peer],
|
|
mock_llm_call_functions: dict[str, Any],
|
|
):
|
|
"""When workspace_chat returns None, response content should be None."""
|
|
test_workspace, _ = sample_data
|
|
mock_llm_call_functions["workspace_chat"].side_effect = None
|
|
mock_llm_call_functions["workspace_chat"].return_value = None
|
|
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/chat",
|
|
json={
|
|
"query": "Some query",
|
|
"stream": False,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["content"] is None
|
|
|
|
def test_workspace_chat_defaults(
|
|
self,
|
|
client: Any,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Endpoint works with only the required query field."""
|
|
test_workspace, _ = sample_data
|
|
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/chat",
|
|
json={"query": "Hello workspace"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "content" in data
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workspace_chat_releases_preflight_session_before_agent_answer(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
active_sessions = 0
|
|
|
|
@asynccontextmanager
|
|
async def fake_tracked_db(_: str | None = None, **_kwargs: Any):
|
|
nonlocal active_sessions
|
|
active_sessions += 1
|
|
try:
|
|
yield object()
|
|
finally:
|
|
active_sessions -= 1
|
|
|
|
async def fake_get_session(*args: Any, **kwargs: Any) -> object:
|
|
_ = (args, kwargs)
|
|
assert active_sessions == 1
|
|
return SimpleNamespace(id="session-id")
|
|
|
|
async def fake_answer(_self: Any, query: str, **_kwargs: Any) -> str:
|
|
assert query == "What changed?"
|
|
assert active_sessions == 0
|
|
return "ok"
|
|
|
|
async def fake_get_workspace(*args: Any, **kwargs: Any) -> object:
|
|
_ = (args, kwargs)
|
|
assert active_sessions == 1
|
|
return SimpleNamespace(name="workspace")
|
|
|
|
monkeypatch.setattr("src.dialectic.chat.tracked_db", fake_tracked_db)
|
|
monkeypatch.setattr("src.dialectic.chat.crud.get_workspace", fake_get_workspace)
|
|
monkeypatch.setattr("src.dialectic.chat.crud.get_session", fake_get_session)
|
|
monkeypatch.setattr(
|
|
"src.dialectic.chat.WorkspaceDialecticAgent.answer", fake_answer
|
|
)
|
|
|
|
result = await workspace_chat("workspace", "session", "What changed?")
|
|
|
|
assert result == "ok"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workspace_chat_stream_releases_preflight_session_before_stream(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
active_sessions = 0
|
|
|
|
@asynccontextmanager
|
|
async def fake_tracked_db(_: str | None = None, **_kwargs: Any):
|
|
nonlocal active_sessions
|
|
active_sessions += 1
|
|
try:
|
|
yield object()
|
|
finally:
|
|
active_sessions -= 1
|
|
|
|
async def fake_get_session(*args: Any, **kwargs: Any) -> object:
|
|
_ = (args, kwargs)
|
|
assert active_sessions == 1
|
|
return SimpleNamespace(id="session-id")
|
|
|
|
async def fake_answer_stream(_self: Any, query: str, **_kwargs: Any):
|
|
assert query == "Stream it"
|
|
assert active_sessions == 0
|
|
yield "chunk-1"
|
|
assert active_sessions == 0
|
|
yield "chunk-2"
|
|
|
|
async def fake_get_workspace(*args: Any, **kwargs: Any) -> object:
|
|
_ = (args, kwargs)
|
|
assert active_sessions == 1
|
|
return SimpleNamespace(name="workspace")
|
|
|
|
monkeypatch.setattr("src.dialectic.chat.tracked_db", fake_tracked_db)
|
|
monkeypatch.setattr("src.dialectic.chat.crud.get_workspace", fake_get_workspace)
|
|
monkeypatch.setattr("src.dialectic.chat.crud.get_session", fake_get_session)
|
|
monkeypatch.setattr(
|
|
"src.dialectic.chat.WorkspaceDialecticAgent.answer_stream",
|
|
fake_answer_stream,
|
|
)
|
|
|
|
chunks = [
|
|
chunk
|
|
async for chunk in workspace_chat_stream("workspace", "session", "Stream it")
|
|
]
|
|
|
|
assert chunks == ["chunk-1", "chunk-2"]
|
|
|
|
|
|
# =============================================================================
|
|
# Tool Handler Tests: Workspace-Specific Handlers
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestSearchMemoryWorkspace:
|
|
"""Tests for _handle_search_memory_workspace (representation-scoped)."""
|
|
|
|
async def test_requires_observer_and_observed(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
):
|
|
"""Returns error when observer/observed params are missing."""
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = _tool_text(
|
|
await _handle_search_memory_workspace(ctx, {"query": "coffee preferences"})
|
|
)
|
|
assert "ERROR" in result
|
|
assert "observer" in result
|
|
|
|
async def test_missing_observer_returns_error(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
):
|
|
"""Returns error when only observed is provided."""
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = _tool_text(
|
|
await _handle_search_memory_workspace(
|
|
ctx, {"query": "test", "observed": "someone"}
|
|
)
|
|
)
|
|
assert "ERROR" in result
|
|
|
|
async def test_missing_observed_returns_error(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
):
|
|
"""Returns error when only observer is provided."""
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = _tool_text(
|
|
await _handle_search_memory_workspace(
|
|
ctx, {"query": "test", "observer": "someone"}
|
|
)
|
|
)
|
|
assert "ERROR" in result
|
|
|
|
async def test_returns_observations_for_specific_pair(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
workspace_test_data: Any,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""Returns observations scoped to a specific observer/observed pair."""
|
|
monkeypatch.setattr("src.config.settings.VECTOR_STORE.MIGRATED", False)
|
|
_, peer1, peer2, _, _, _, _, _ = workspace_test_data
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = _tool_text(
|
|
await _handle_search_memory_workspace(
|
|
ctx,
|
|
{
|
|
"query": "coffee preferences",
|
|
"observer": peer1.name,
|
|
"observed": peer2.name,
|
|
},
|
|
)
|
|
)
|
|
|
|
assert "Found" in result
|
|
assert "observations" in result.lower()
|
|
# Should be scoped to peer1->peer2
|
|
assert f"{peer1.name}->{peer2.name}" in result
|
|
|
|
async def test_does_not_return_observations_from_other_pairs(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
workspace_test_data: Any,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""Does not leak observations from other peer pairs."""
|
|
monkeypatch.setattr("src.config.settings.VECTOR_STORE.MIGRATED", False)
|
|
_, peer1, _, peer3, _, _, _, _ = workspace_test_data
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = _tool_text(
|
|
await _handle_search_memory_workspace(
|
|
ctx,
|
|
{
|
|
"query": "coffee",
|
|
"observer": peer1.name,
|
|
"observed": peer3.name,
|
|
},
|
|
)
|
|
)
|
|
|
|
# peer3 observations are about hiking/mornings, not coffee
|
|
# Should either find the hiking/mornings ones or none
|
|
assert isinstance(result, str)
|
|
|
|
async def test_falls_back_to_message_search(
|
|
self,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Falls back to message search when no observations exist for the pair."""
|
|
workspace, _ = sample_data
|
|
|
|
session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=workspace.name
|
|
)
|
|
db_session.add(session)
|
|
await db_session.flush()
|
|
|
|
observer = models.Peer(
|
|
name=str(generate_nanoid()), workspace_name=workspace.name
|
|
)
|
|
observed = models.Peer(
|
|
name=str(generate_nanoid()), workspace_name=workspace.name
|
|
)
|
|
db_session.add_all([observer, observed])
|
|
await db_session.flush()
|
|
|
|
msg = models.Message(
|
|
workspace_name=workspace.name,
|
|
session_name=session.name,
|
|
peer_name=observed.name,
|
|
content="I really like programming in Python",
|
|
seq_in_session=1,
|
|
token_count=10,
|
|
created_at=datetime.now(timezone.utc),
|
|
)
|
|
db_session.add(msg)
|
|
await db_session.flush()
|
|
|
|
ctx = ToolContext(
|
|
observer="",
|
|
observed="",
|
|
current_messages=None,
|
|
workspace_name=workspace.name,
|
|
session_name=session.name,
|
|
include_observation_ids=False,
|
|
history_token_limit=8192,
|
|
db_lock=asyncio.Lock(),
|
|
)
|
|
|
|
result = _tool_text(
|
|
await _handle_search_memory_workspace(
|
|
ctx,
|
|
{
|
|
"query": "programming",
|
|
"observer": observer.name,
|
|
"observed": observed.name,
|
|
},
|
|
)
|
|
)
|
|
|
|
assert isinstance(result, str)
|
|
assert "No observations" in result or "Found" in result
|
|
|
|
async def test_respects_top_k(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
workspace_test_data: Any,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""Respects the top_k parameter, capped at 40."""
|
|
monkeypatch.setattr("src.config.settings.VECTOR_STORE.MIGRATED", False)
|
|
_, peer1, peer2, _, _, _, _, _ = workspace_test_data
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = _tool_text(
|
|
await _handle_search_memory_workspace(
|
|
ctx,
|
|
{
|
|
"query": "test",
|
|
"top_k": 2,
|
|
"observer": peer1.name,
|
|
"observed": peer2.name,
|
|
},
|
|
)
|
|
)
|
|
|
|
assert isinstance(result, str)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestGetWorkspaceStats:
|
|
"""Tests for _handle_get_workspace_stats."""
|
|
|
|
async def test_returns_stats(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
workspace_test_data: Any,
|
|
):
|
|
"""Returns workspace statistics."""
|
|
_ = workspace_test_data
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = await _handle_get_workspace_stats(ctx, {})
|
|
|
|
assert "Workspace stats" in result
|
|
assert "Peers: 3" in result
|
|
assert "Sessions: 1" in result
|
|
assert "Messages: 6" in result
|
|
assert "Date range" in result
|
|
|
|
async def test_lists_most_active_peers(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
workspace_test_data: Any,
|
|
):
|
|
"""Includes the most active peers with message counts."""
|
|
_, peer1, peer2, peer3, *_ = workspace_test_data
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = await _handle_get_workspace_stats(ctx, {})
|
|
|
|
assert "Most active peers" in result
|
|
assert peer1.name in result
|
|
assert peer2.name in result
|
|
assert peer3.name in result
|
|
assert "messages" in result
|
|
|
|
async def test_empty_workspace(
|
|
self,
|
|
db_session: AsyncSession,
|
|
):
|
|
"""Returns zero counts for an empty workspace."""
|
|
workspace = models.Workspace(name=str(generate_nanoid()))
|
|
db_session.add(workspace)
|
|
await db_session.flush()
|
|
|
|
ctx = ToolContext(
|
|
observer="",
|
|
observed="",
|
|
current_messages=None,
|
|
workspace_name=workspace.name,
|
|
session_name=None,
|
|
include_observation_ids=False,
|
|
history_token_limit=8192,
|
|
db_lock=asyncio.Lock(),
|
|
)
|
|
|
|
result = await _handle_get_workspace_stats(ctx, {})
|
|
|
|
assert "Peers: 0" in result
|
|
assert "Messages: 0" in result
|
|
|
|
async def test_excludes_scope_peers(
|
|
self,
|
|
db_session: AsyncSession,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
workspace_test_data: Any,
|
|
):
|
|
workspace, *_ = workspace_test_data
|
|
db_session.add(
|
|
models.Peer(
|
|
name=scope_peer_name("therapy"),
|
|
workspace_name=workspace.name,
|
|
internal_metadata={"kind": SCOPE_KIND},
|
|
configuration={"observe_me": False},
|
|
)
|
|
)
|
|
await db_session.commit()
|
|
|
|
result = await _handle_get_workspace_stats(make_workspace_ctx(), {})
|
|
|
|
assert "Peers: 3" in result
|
|
assert "scope.therapy" not in result
|
|
|
|
async def test_empty_session_allowlist_is_zero(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
workspace_test_data: Any,
|
|
):
|
|
_ = workspace_test_data
|
|
result = await _handle_get_workspace_stats(
|
|
make_workspace_ctx(session_allowlist=[]), {}
|
|
)
|
|
|
|
assert "Peers: 0" in result
|
|
assert "Sessions: 0" in result
|
|
assert "Messages: 0" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestGetPeerCardByName:
|
|
"""Tests for _handle_get_peer_card_by_name."""
|
|
|
|
async def test_returns_peer_card(
|
|
self,
|
|
db_session: AsyncSession,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
workspace_test_data: Any,
|
|
):
|
|
"""Returns peer card when it exists."""
|
|
workspace, peer1, peer2, *_ = workspace_test_data
|
|
|
|
# Create a peer card
|
|
await crud.set_peer_card(
|
|
db_session,
|
|
workspace_name=workspace.name,
|
|
observer=peer1.name,
|
|
observed=peer2.name,
|
|
peer_card=["Name: Alice", "Location: NYC"],
|
|
)
|
|
|
|
ctx = make_workspace_ctx()
|
|
result = await _handle_get_peer_card_by_name(
|
|
ctx, {"observer": peer1.name, "observed": peer2.name}
|
|
)
|
|
|
|
assert "Peer card" in result
|
|
assert "Name: Alice" in result
|
|
assert "Location: NYC" in result
|
|
|
|
async def test_returns_not_found(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
workspace_test_data: Any,
|
|
):
|
|
"""Returns appropriate message when peer card doesn't exist."""
|
|
_, peer1, peer2, *_ = workspace_test_data
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = await _handle_get_peer_card_by_name(
|
|
ctx, {"observer": peer1.name, "observed": peer2.name}
|
|
)
|
|
|
|
assert "No peer card" in result
|
|
|
|
async def test_session_allowlist_refuses(
|
|
self,
|
|
db_session: AsyncSession,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
workspace_test_data: Any,
|
|
):
|
|
"""A peer card is a cross-session aggregate, so a scoped query must not
|
|
get one — otherwise `scope` leaks facts derived outside its sessions."""
|
|
workspace, peer1, peer2, _peer3, session, *_ = workspace_test_data
|
|
|
|
await crud.set_peer_card(
|
|
db_session,
|
|
workspace_name=workspace.name,
|
|
observer=peer1.name,
|
|
observed=peer2.name,
|
|
peer_card=["Secret: derived from an out-of-scope session"],
|
|
)
|
|
|
|
ctx = make_workspace_ctx(session_allowlist=[session.name])
|
|
result = await _handle_get_peer_card_by_name(
|
|
ctx, {"observer": peer1.name, "observed": peer2.name}
|
|
)
|
|
|
|
assert "Secret" not in result
|
|
assert "unavailable for session-scoped queries" in result
|
|
|
|
async def test_unknown_peer_is_answered_not_raised(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
workspace_test_data: Any,
|
|
):
|
|
"""The agent supplies peer names from its own routing, so a name that
|
|
doesn't exist is an expected turn, not an unhandled exception."""
|
|
_, peer1, *_ = workspace_test_data
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = await _handle_get_peer_card_by_name(
|
|
ctx, {"observer": "no-such-peer", "observed": peer1.name}
|
|
)
|
|
|
|
assert "No peer named 'no-such-peer'" in result
|
|
|
|
async def test_missing_params_returns_error(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
):
|
|
"""Returns error when observer/observed params are missing."""
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = await _handle_get_peer_card_by_name(ctx, {})
|
|
|
|
assert "ERROR" in result
|
|
|
|
async def test_missing_observer_returns_error(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
):
|
|
"""Returns error when only observed is provided."""
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = await _handle_get_peer_card_by_name(ctx, {"observed": "someone"})
|
|
|
|
assert "ERROR" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestGetObservationContextWorkspace:
|
|
"""Tests for get_observation_context under the workspace executor.
|
|
|
|
The workspace loadout routes this straight to the shared handler: its
|
|
observer="" sentinel already normalizes to None ("no perspective
|
|
scoping") at the crud boundary."""
|
|
|
|
async def test_retrieves_messages_by_id(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
workspace_test_data: Any,
|
|
):
|
|
"""Retrieves messages by their public IDs."""
|
|
_, _, _, _, _, messages, _, _ = workspace_test_data
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = await _handle_get_observation_context(
|
|
ctx, {"message_ids": [messages[0].public_id]}
|
|
)
|
|
|
|
assert "Retrieved" in result or "No messages found" in result
|
|
|
|
async def test_nonexistent_message_ids(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
):
|
|
"""Returns appropriate message for nonexistent IDs."""
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = await _handle_get_observation_context(
|
|
ctx, {"message_ids": ["nonexistent_id"]}
|
|
)
|
|
|
|
assert "No messages found" in result
|
|
|
|
async def test_respects_session_scope(
|
|
self,
|
|
db_session: AsyncSession,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
workspace_test_data: Any,
|
|
):
|
|
"""Session-scoped context lookup should not leak snippets from other sessions."""
|
|
workspace, _peer1, peer2, _peer3, session, messages, *_ = workspace_test_data
|
|
|
|
other_session = models.Session(
|
|
name=str(generate_nanoid()),
|
|
workspace_name=workspace.name,
|
|
)
|
|
db_session.add(other_session)
|
|
await db_session.flush()
|
|
|
|
leaked_message = models.Message(
|
|
workspace_name=workspace.name,
|
|
session_name=other_session.name,
|
|
peer_name=peer2.name,
|
|
content="LEAKED_FROM_OTHER_SESSION",
|
|
seq_in_session=messages[0].seq_in_session,
|
|
token_count=10,
|
|
created_at=datetime.now(timezone.utc),
|
|
)
|
|
db_session.add(leaked_message)
|
|
await db_session.commit()
|
|
|
|
ctx = make_workspace_ctx(session_name=session.name)
|
|
result = await _handle_get_observation_context(
|
|
ctx, {"message_ids": [messages[0].public_id]}
|
|
)
|
|
|
|
assert "LEAKED_FROM_OTHER_SESSION" not in result
|
|
assert messages[0].content in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestGetReasoningChainWorkspace:
|
|
"""Tests for _handle_get_reasoning_chain."""
|
|
|
|
async def test_returns_observation_chain(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
workspace_test_data: Any,
|
|
):
|
|
"""Returns an observation and its chain."""
|
|
_, _, _, _, _, _, docs_peer2, _ = workspace_test_data
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = await _handle_get_reasoning_chain(
|
|
ctx, {"observation_id": docs_peer2[0].id}
|
|
)
|
|
|
|
assert "Observation" in result
|
|
assert docs_peer2[0].content in result
|
|
|
|
async def test_nonexistent_observation_returns_error(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
):
|
|
"""Returns error for nonexistent observation ID."""
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = await _handle_get_reasoning_chain(
|
|
ctx, {"observation_id": "nonexistent_id"}
|
|
)
|
|
|
|
assert "ERROR" in result
|
|
|
|
async def test_missing_observation_id_returns_error(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
):
|
|
"""Returns error when observation_id is missing."""
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = await _handle_get_reasoning_chain(ctx, {})
|
|
|
|
assert "ERROR" in result
|
|
|
|
async def test_invalid_direction_returns_error(
|
|
self,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
workspace_test_data: Any,
|
|
):
|
|
"""Returns error for invalid direction parameter."""
|
|
_, _, _, _, _, _, docs_peer2, _ = workspace_test_data
|
|
ctx = make_workspace_ctx()
|
|
|
|
result = await _handle_get_reasoning_chain(
|
|
ctx,
|
|
{"observation_id": docs_peer2[0].id, "direction": "invalid"},
|
|
)
|
|
|
|
assert "ERROR" in result
|
|
|
|
async def test_deductive_observation_shows_premises(
|
|
self,
|
|
db_session: AsyncSession,
|
|
make_workspace_ctx: Callable[..., ToolContext],
|
|
workspace_test_data: Any,
|
|
):
|
|
"""Deductive observation shows premises in chain."""
|
|
workspace, peer1, peer2, _, _, _, docs_peer2, _ = workspace_test_data
|
|
|
|
# Create a deductive document with source_ids
|
|
deductive_doc = models.Document(
|
|
workspace_name=workspace.name,
|
|
observer=peer1.name,
|
|
observed=peer2.name,
|
|
content="User is probably a morning person who codes",
|
|
embedding=[0.3] * 1536,
|
|
level="deductive",
|
|
source_ids=[docs_peer2[0].id, docs_peer2[1].id],
|
|
)
|
|
db_session.add(deductive_doc)
|
|
await db_session.commit()
|
|
await db_session.refresh(deductive_doc)
|
|
|
|
ctx = make_workspace_ctx()
|
|
result = await _handle_get_reasoning_chain(
|
|
ctx, {"observation_id": deductive_doc.id, "direction": "premises"}
|
|
)
|
|
|
|
assert "Observation" in result
|
|
assert "Premises" in result
|
|
|
|
|
|
# =============================================================================
|
|
# Tool Executor Tests
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestWorkspaceToolExecutor:
|
|
"""Tests for create_workspace_tool_executor."""
|
|
|
|
async def test_returns_callable(
|
|
self,
|
|
db_session: AsyncSession, # pyright: ignore[reportUnusedParameter]
|
|
workspace_test_data: Any,
|
|
):
|
|
"""create_workspace_tool_executor returns an async callable."""
|
|
workspace, *_ = workspace_test_data
|
|
|
|
executor = await create_workspace_tool_executor(
|
|
workspace_name=workspace.name,
|
|
)
|
|
|
|
assert callable(executor)
|
|
|
|
async def test_routes_workspace_tools(
|
|
self,
|
|
db_session: AsyncSession, # pyright: ignore[reportUnusedParameter]
|
|
workspace_test_data: Any,
|
|
):
|
|
"""Workspace-specific tools are routed to workspace handlers."""
|
|
workspace, *_ = workspace_test_data
|
|
|
|
executor = await create_workspace_tool_executor(
|
|
workspace_name=workspace.name,
|
|
)
|
|
|
|
stats_result = await executor("get_workspace_stats", {})
|
|
assert isinstance(stats_result, str)
|
|
assert "Workspace stats" in stats_result
|
|
assert "Most active peers" in stats_result
|
|
|
|
async def test_falls_through_to_standard_handlers(
|
|
self,
|
|
db_session: AsyncSession, # pyright: ignore[reportUnusedParameter]
|
|
workspace_test_data: Any,
|
|
):
|
|
"""Non-workspace tools fall through to standard handlers."""
|
|
workspace, _, _, _, session, *_ = workspace_test_data
|
|
|
|
executor = await create_workspace_tool_executor(
|
|
workspace_name=workspace.name,
|
|
session_name=session.name,
|
|
)
|
|
|
|
# grep_messages is a standard handler, should fall through
|
|
result = await executor("grep_messages", {"text": "Test message"})
|
|
|
|
assert isinstance(result, str)
|
|
|
|
async def test_unknown_tool_returns_error(
|
|
self,
|
|
db_session: AsyncSession, # pyright: ignore[reportUnusedParameter]
|
|
workspace_test_data: Any,
|
|
):
|
|
"""Unknown tool name returns error."""
|
|
workspace, *_ = workspace_test_data
|
|
|
|
executor = await create_workspace_tool_executor(
|
|
workspace_name=workspace.name,
|
|
)
|
|
|
|
result = await executor("nonexistent_tool", {})
|
|
|
|
assert "Unknown tool" in result
|
|
|
|
async def test_handles_exceptions_gracefully(
|
|
self,
|
|
db_session: AsyncSession, # pyright: ignore[reportUnusedParameter]
|
|
workspace_test_data: Any,
|
|
):
|
|
"""Executor returns error strings instead of raising exceptions."""
|
|
workspace, *_ = workspace_test_data
|
|
|
|
executor = await create_workspace_tool_executor(
|
|
workspace_name=workspace.name,
|
|
)
|
|
|
|
# Missing required observer/observed/query parameters
|
|
result = await executor("search_memory", {})
|
|
|
|
assert isinstance(result, str)
|
|
assert "ERROR" in result
|
|
|
|
async def test_get_peer_card_via_executor(
|
|
self,
|
|
db_session: AsyncSession, # pyright: ignore[reportUnusedParameter]
|
|
workspace_test_data: Any,
|
|
):
|
|
"""get_peer_card routes through workspace handler with params."""
|
|
workspace, peer1, peer2, *_ = workspace_test_data
|
|
|
|
executor = await create_workspace_tool_executor(
|
|
workspace_name=workspace.name,
|
|
)
|
|
|
|
result = await executor(
|
|
"get_peer_card",
|
|
{"observer": peer1.name, "observed": peer2.name},
|
|
)
|
|
|
|
assert isinstance(result, str)
|
|
# Should be from workspace handler (accepts observer/observed params)
|
|
assert "peer card" in result.lower() or "No peer card" in result
|
|
|
|
|
|
# =============================================================================
|
|
# Regression: workspace-flat message visibility without a pinned session
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestWorkspaceMessageToolsUnpinned:
|
|
"""The workspace executor's observer='' sentinel must read as
|
|
'no perspective scoping' (None) at the crud boundary. Under #882's
|
|
resolve_session_scope, an empty STRING is looked up as a real peer with
|
|
no session memberships and denies every result — so these tests run the
|
|
message tools with NO session_name, the primary workspace-chat shape."""
|
|
|
|
async def test_grep_messages_finds_content_without_session(
|
|
self,
|
|
db_session: AsyncSession, # pyright: ignore[reportUnusedParameter]
|
|
workspace_test_data: Any,
|
|
):
|
|
workspace, *_ = workspace_test_data
|
|
|
|
executor = await create_workspace_tool_executor(
|
|
workspace_name=workspace.name,
|
|
)
|
|
result = await executor("grep_messages", {"text": "Test message"})
|
|
|
|
assert isinstance(result, str)
|
|
assert "No messages found" not in result
|
|
assert "Test message" in result
|
|
|
|
async def test_date_range_finds_content_without_session(
|
|
self,
|
|
db_session: AsyncSession, # pyright: ignore[reportUnusedParameter]
|
|
workspace_test_data: Any,
|
|
):
|
|
workspace, *_ = workspace_test_data
|
|
|
|
executor = await create_workspace_tool_executor(
|
|
workspace_name=workspace.name,
|
|
)
|
|
result = await executor("get_messages_by_date_range", {"limit": 10})
|
|
|
|
assert isinstance(result, str)
|
|
assert "Found" in result
|
|
assert "No messages found" not in result
|
|
|
|
async def test_session_allowlist_is_honored_when_set(
|
|
self,
|
|
db_session: AsyncSession, # pyright: ignore[reportUnusedParameter]
|
|
workspace_test_data: Any,
|
|
):
|
|
"""An allowlist naming no real session yields no results."""
|
|
workspace, *_ = workspace_test_data
|
|
|
|
executor = await create_workspace_tool_executor(
|
|
workspace_name=workspace.name,
|
|
session_allowlist=["no-such-session"],
|
|
)
|
|
result = await executor("grep_messages", {"text": "Test message"})
|
|
|
|
assert isinstance(result, str)
|
|
assert "No messages found" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workspace_prefetch_failure_degrades_to_none(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Prefetch errors must not fail the request (parity with the base
|
|
agent's try/except): the agent proceeds with no prefetched block."""
|
|
from src.dialectic.workspace import WorkspaceDialecticAgent
|
|
|
|
async def boom(*args: Any, **kwargs: Any) -> Any:
|
|
_ = (args, kwargs)
|
|
raise RuntimeError("stats query exploded")
|
|
|
|
monkeypatch.setattr("src.dialectic.workspace.crud.get_workspace_stats", boom)
|
|
|
|
agent = WorkspaceDialecticAgent(workspace_name="w")
|
|
result = await agent._prefetch_relevant_observations("q") # pyright: ignore[reportPrivateUsage]
|
|
|
|
assert result is None
|