honcho/tests/test_feedback.py

435 lines
15 KiB
Python

"""Tests for the developer feedback channel (Phase 4 of Agentic FDE)."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models
from src.feedback import (
INTERVIEW_QUESTIONS,
FeedbackChange,
FeedbackLLMResponse,
build_feedback_prompt,
config_is_empty,
is_simple_greeting,
process_feedback,
)
from src.schemas import (
ConfigChange,
FeedbackRequest,
FeedbackResponse,
IntrospectionReport,
IntrospectionSignals,
IntrospectionSuggestion,
WorkspaceAgentConfig,
)
class TestFeedbackSchemas:
"""Test the feedback-related Pydantic schemas."""
def test_feedback_request_basic(self):
"""Test basic FeedbackRequest creation."""
request = FeedbackRequest(message="Hello")
assert request.message == "Hello"
assert request.include_introspection is False
def test_feedback_request_with_introspection(self):
"""Test FeedbackRequest with introspection enabled."""
request = FeedbackRequest(
message="Configure my workspace", include_introspection=True
)
assert request.message == "Configure my workspace"
assert request.include_introspection is True
def test_feedback_request_validation(self):
"""Test FeedbackRequest validation."""
# Empty message should fail
with pytest.raises(ValidationError):
FeedbackRequest(message="")
# Very long message should fail
with pytest.raises(ValidationError):
FeedbackRequest(message="x" * 10001)
def test_config_change(self):
"""Test ConfigChange schema."""
change = ConfigChange(
field="deriver_rules",
previous_value="old rule",
new_value="new rule",
)
assert change.field == "deriver_rules"
assert change.previous_value == "old rule"
assert change.new_value == "new rule"
def test_config_change_dialectic(self):
"""Test ConfigChange for dialectic_rules."""
change = ConfigChange(
field="dialectic_rules",
previous_value="",
new_value="Be concise",
)
assert change.field == "dialectic_rules"
def test_feedback_response(self):
"""Test FeedbackResponse schema."""
response = FeedbackResponse(
message="Configuration updated",
understood_intent="Add focus on emotions",
changes_made=[
ConfigChange(
field="deriver_rules",
previous_value="",
new_value="Focus on emotions",
)
],
current_config=WorkspaceAgentConfig(deriver_rules="Focus on emotions"),
)
assert response.message == "Configuration updated"
assert len(response.changes_made) == 1
assert response.current_config.deriver_rules == "Focus on emotions"
class TestHelperFunctions:
"""Test helper functions for feedback processing."""
def test_is_simple_greeting_true_cases(self):
"""Test cases that should be detected as simple greetings."""
greetings = [
"hello",
"Hello",
"HELLO",
"hi",
"Hi there",
"hey",
"Hey!",
"good morning",
"Good afternoon",
"howdy",
"help",
"Help me",
"how do i configure",
"configure",
"setup",
"get started",
]
for greeting in greetings:
assert is_simple_greeting(
greeting
), f"Expected '{greeting}' to be a greeting"
def test_is_simple_greeting_false_cases(self):
"""Test cases that should NOT be detected as simple greetings."""
non_greetings = [
"I'm building a journaling app and want to focus on emotions",
"The deriver should extract technical facts only",
"x" * 101, # Too long
"Please configure the workspace to focus on user preferences",
]
for msg in non_greetings:
assert not is_simple_greeting(msg), f"Expected '{msg}' to NOT be a greeting"
def test_config_is_empty_true(self):
"""Test detecting empty configuration."""
config = WorkspaceAgentConfig()
assert config_is_empty(config)
config = WorkspaceAgentConfig(deriver_rules="", dialectic_rules="")
assert config_is_empty(config)
config = WorkspaceAgentConfig(deriver_rules=" ", dialectic_rules=" ")
assert config_is_empty(config)
def test_config_is_empty_false(self):
"""Test detecting non-empty configuration."""
config = WorkspaceAgentConfig(deriver_rules="Some rule")
assert not config_is_empty(config)
config = WorkspaceAgentConfig(dialectic_rules="Another rule")
assert not config_is_empty(config)
class TestBuildFeedbackPrompt:
"""Test the prompt building function."""
def test_basic_prompt(self):
"""Test basic prompt without introspection."""
prompt = build_feedback_prompt(
message="Focus on emotions",
current_config=WorkspaceAgentConfig(),
)
assert "Focus on emotions" in prompt
assert "(empty - using defaults)" in prompt
assert "Developer Message" in prompt
def test_prompt_with_existing_config(self):
"""Test prompt includes existing configuration."""
config = WorkspaceAgentConfig(
deriver_rules="Extract technical facts",
dialectic_rules="Be concise",
)
prompt = build_feedback_prompt(
message="Add emotion tracking",
current_config=config,
)
assert "Extract technical facts" in prompt
assert "Be concise" in prompt
def test_prompt_with_introspection(self):
"""Test prompt includes introspection report when provided."""
import datetime
report = IntrospectionReport(
workspace_name="test",
generated_at=datetime.datetime.now(datetime.timezone.utc),
performance_summary="Good performance overall",
identified_issues=["High abstention rate"],
suggestions=[
IntrospectionSuggestion(
target="deriver_rules",
current_value="",
suggested_value="Focus more",
rationale="Would reduce abstentions",
confidence="high",
)
],
signals=IntrospectionSignals(),
)
prompt = build_feedback_prompt(
message="Help me improve",
current_config=WorkspaceAgentConfig(),
introspection_report=report,
)
assert "Good performance overall" in prompt
assert "High abstention rate" in prompt
assert "Would reduce abstentions" in prompt
class TestProcessFeedback:
"""Test the main feedback processing function."""
@pytest.mark.asyncio
async def test_interview_mode_trigger(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test that interview mode is triggered for empty config + greeting."""
workspace, _ = sample_data
request = FeedbackRequest(message="Hello")
response = await process_feedback(db_session, workspace.name, request)
assert INTERVIEW_QUESTIONS in response.message
assert "First-time setup" in response.understood_intent
assert len(response.changes_made) == 0
@pytest.mark.asyncio
async def test_interview_mode_not_triggered_with_config(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test that interview mode is NOT triggered when config exists."""
workspace, _ = sample_data
# Set up existing config
config = WorkspaceAgentConfig(deriver_rules="Existing rule")
await crud.set_workspace_agent_config(db_session, workspace.name, config)
# Mock the LLM call (structured output)
mock_response = MagicMock()
mock_response.content = FeedbackLLMResponse(
message="I see you already have rules set up.",
understood_intent="Greeting with existing config",
changes=[],
)
with patch(
"src.feedback.honcho_llm_call",
new_callable=AsyncMock,
return_value=mock_response,
):
request = FeedbackRequest(message="Hello")
response = await process_feedback(db_session, workspace.name, request)
# Should NOT be interview mode
assert INTERVIEW_QUESTIONS not in response.message
@pytest.mark.asyncio
async def test_config_update(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test that config changes are applied correctly."""
workspace, _ = sample_data
# Mock the LLM call to return a config change (structured output)
mock_response = MagicMock()
mock_response.content = FeedbackLLMResponse(
message="I've configured the workspace to focus on emotions.",
understood_intent="Configure deriver for emotion tracking",
changes=[
FeedbackChange(
field="deriver_rules",
new_value="Focus on emotional content and feelings",
)
],
)
with patch(
"src.feedback.honcho_llm_call",
new_callable=AsyncMock,
return_value=mock_response,
):
request = FeedbackRequest(
message="I'm building a journaling app, focus on emotions"
)
response = await process_feedback(db_session, workspace.name, request)
assert len(response.changes_made) == 1
assert response.changes_made[0].field == "deriver_rules"
assert "emotion" in response.changes_made[0].new_value.lower()
# Verify config was actually saved
saved_config = await crud.get_workspace_agent_config(
db_session, workspace.name
)
assert "emotion" in saved_config.deriver_rules.lower()
@pytest.mark.asyncio
async def test_question_no_changes(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test that questions don't result in config changes."""
workspace, _ = sample_data
# Set up existing config
config = WorkspaceAgentConfig(deriver_rules="Existing rule")
await crud.set_workspace_agent_config(db_session, workspace.name, config)
# Mock LLM to return answer without changes (structured output)
mock_response = MagicMock()
mock_response.content = FeedbackLLMResponse(
message="Your current deriver rule is: 'Existing rule'",
understood_intent="Question about current config",
changes=[],
)
with patch(
"src.feedback.honcho_llm_call",
new_callable=AsyncMock,
return_value=mock_response,
):
request = FeedbackRequest(message="What are my current rules?")
response = await process_feedback(db_session, workspace.name, request)
assert len(response.changes_made) == 0
# Config should be unchanged
assert response.current_config.deriver_rules == "Existing rule"
@pytest.mark.asyncio
async def test_llm_error_handling(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test graceful handling of LLM errors."""
workspace, _ = sample_data
# Set initial config
config = WorkspaceAgentConfig(deriver_rules="Existing rule")
await crud.set_workspace_agent_config(db_session, workspace.name, config)
with patch(
"src.feedback.honcho_llm_call",
new_callable=AsyncMock,
side_effect=Exception("LLM service unavailable"),
):
request = FeedbackRequest(message="Update my config")
response = await process_feedback(db_session, workspace.name, request)
# Should return error message
assert "error" in response.message.lower()
assert len(response.changes_made) == 0
# Config should be unchanged
assert response.current_config.deriver_rules == "Existing rule"
@pytest.mark.asyncio
async def test_invalid_response_type(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test handling of unexpected response type from LLM."""
workspace, _ = sample_data
# Set initial config
config = WorkspaceAgentConfig(deriver_rules="Existing rule")
await crud.set_workspace_agent_config(db_session, workspace.name, config)
# Mock returns something unexpected (string instead of FeedbackLLMResponse)
mock_response = MagicMock()
mock_response.content = "This is not a FeedbackLLMResponse"
with patch(
"src.feedback.honcho_llm_call",
new_callable=AsyncMock,
return_value=mock_response,
):
request = FeedbackRequest(message="Update my config")
response = await process_feedback(db_session, workspace.name, request)
# Should return error message (caught by generic exception handler)
assert "error" in response.message.lower()
assert len(response.changes_made) == 0
@pytest.mark.asyncio
async def test_incremental_update(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test that new rules are added to existing rules."""
workspace, _ = sample_data
# Set initial config
config = WorkspaceAgentConfig(deriver_rules="Track emotions")
await crud.set_workspace_agent_config(db_session, workspace.name, config)
# Mock LLM to append new rule (structured output)
mock_response = MagicMock()
mock_response.content = FeedbackLLMResponse(
message="Added goal tracking to existing rules.",
understood_intent="Add goal tracking while preserving emotion tracking",
changes=[
FeedbackChange(
field="deriver_rules",
new_value="Track emotions\nAlso track goals and aspirations",
)
],
)
with patch(
"src.feedback.honcho_llm_call",
new_callable=AsyncMock,
return_value=mock_response,
):
request = FeedbackRequest(message="Also track goals")
response = await process_feedback(db_session, workspace.name, request)
assert len(response.changes_made) == 1
# Should contain both old and new rules
assert "emotions" in response.changes_made[0].new_value.lower()
assert "goals" in response.changes_made[0].new_value.lower()
# Note: API endpoint tests are in tests/routes/test_feedback.py