feat(responses): pass conversation context and stream thinks in real time
- delegate_to_* now receives a trimmed conversation history (last ~6 turns, 500 chars/turn) as context on both live direct-delegation paths (streaming and steward non-streaming), via new build_delegation_context helper - _stream_direct_delegation restructured as an async generator: the butler 'start' think message streams BEFORE the expert runs and the success/error message right after it finishes, instead of all messages arriving after the research completed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,11 +15,69 @@ from src.agents.delegation import (
|
||||
DelegationResult,
|
||||
DelegationTask,
|
||||
_detect_action_type,
|
||||
build_delegation_context,
|
||||
delegate_to_librarian,
|
||||
get_think_message,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestBuildDelegationContext:
|
||||
"""Tests for trimming conversation history into expert context."""
|
||||
|
||||
def test_empty_history_returns_empty(self):
|
||||
assert build_delegation_context(None) == ""
|
||||
assert build_delegation_context([]) == ""
|
||||
|
||||
def test_recent_turns_are_formatted(self):
|
||||
history = [
|
||||
{"role": "user", "content": "Tell me about Docker"},
|
||||
{"role": "assistant", "content": "Docker is a container runtime."},
|
||||
]
|
||||
|
||||
context = build_delegation_context(history)
|
||||
|
||||
assert "Recent conversation:" in context
|
||||
assert "user: Tell me about Docker" in context
|
||||
assert "assistant: Docker is a container runtime." in context
|
||||
|
||||
def test_only_last_max_turns_kept(self):
|
||||
history = [
|
||||
{"role": "user", "content": f"message {i}"} for i in range(10)
|
||||
]
|
||||
|
||||
context = build_delegation_context(history, max_turns=6)
|
||||
|
||||
assert "message 3" not in context
|
||||
assert "message 4" in context
|
||||
assert "message 9" in context
|
||||
|
||||
def test_long_turns_are_truncated(self):
|
||||
history = [{"role": "user", "content": "x" * 2000}]
|
||||
|
||||
context = build_delegation_context(history, max_chars_per_turn=500)
|
||||
|
||||
assert "x" * 500 in context
|
||||
assert "x" * 501 not in context
|
||||
|
||||
def test_structured_content_parts_tolerated(self):
|
||||
history = [
|
||||
{"role": "user", "content": [{"type": "text", "text": "hello there"}]}
|
||||
]
|
||||
|
||||
context = build_delegation_context(history)
|
||||
|
||||
assert "hello there" in context
|
||||
|
||||
def test_non_dict_entries_skipped(self):
|
||||
history = ["garbage", {"role": "user", "content": "real message"}]
|
||||
|
||||
context = build_delegation_context(history)
|
||||
|
||||
assert "real message" in context
|
||||
assert "garbage" not in context
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDelegationTask:
|
||||
"""Tests for the DelegationTask dataclass."""
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Tests for real-time think message streaming and context plumbing in
|
||||
the direct delegation paths.
|
||||
|
||||
_stream_direct_delegation must be an async generator that yields the
|
||||
"start" think message BEFORE the expert runs (so 'Allow me to consult
|
||||
the archives, sir.' streams while research is in flight), and both
|
||||
direct delegation paths must pass trimmed conversation history as
|
||||
expert context.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agents.delegation import DelegationResult
|
||||
from src.responses.streaming import (
|
||||
ReasoningSummaryDelta,
|
||||
ReasoningSummaryDone,
|
||||
StreamingCoordinator,
|
||||
)
|
||||
|
||||
HISTORY = [
|
||||
{"role": "user", "content": "Tell me about my homelab wiki"},
|
||||
{"role": "assistant", "content": "It documents your services, sir."},
|
||||
]
|
||||
|
||||
|
||||
def _librarian_result(output: str = "Findings.") -> DelegationResult:
|
||||
return DelegationResult(
|
||||
expert_name="librarian",
|
||||
task="task",
|
||||
success=True,
|
||||
output=output,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestStreamDirectDelegation:
|
||||
"""Real-time streaming behavior of _stream_direct_delegation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_think_streams_before_research_runs(self):
|
||||
coordinator = StreamingCoordinator()
|
||||
tracker = AsyncMock()
|
||||
results: dict = {}
|
||||
|
||||
with patch(
|
||||
"src.agents.delegation.delegate_to_librarian",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_librarian_result(),
|
||||
) as mock_delegate:
|
||||
gen = coordinator._stream_direct_delegation(
|
||||
user_message="Search for Docker info",
|
||||
recommendation=SimpleNamespace(
|
||||
recommended_capabilities=["librarian"]
|
||||
),
|
||||
tracker=tracker,
|
||||
conversation_id="conv_1",
|
||||
conversation_history=HISTORY,
|
||||
results=results,
|
||||
)
|
||||
|
||||
# First event: the start think message, BEFORE any research
|
||||
first = await gen.__anext__()
|
||||
assert isinstance(first, ReasoningSummaryDelta)
|
||||
assert first.delta.strip() != ""
|
||||
assert mock_delegate.await_count == 0, (
|
||||
"start think message must stream before the expert runs"
|
||||
)
|
||||
|
||||
second = await gen.__anext__()
|
||||
assert isinstance(second, ReasoningSummaryDone)
|
||||
assert mock_delegate.await_count == 0
|
||||
|
||||
# Third event: completion message - research has now run
|
||||
third = await gen.__anext__()
|
||||
assert isinstance(third, ReasoningSummaryDelta)
|
||||
assert mock_delegate.await_count == 1
|
||||
|
||||
remaining = [event async for event in gen]
|
||||
assert any(isinstance(e, ReasoningSummaryDone) for e in remaining)
|
||||
|
||||
# Results dict is populated for Phase 2 synthesis
|
||||
assert results["expert_results"] == {"librarian": "Findings."}
|
||||
assert results["tools_called"] == ["delegate_to_librarian"]
|
||||
assert len(results["think_messages"]) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_history_passed_as_context(self):
|
||||
coordinator = StreamingCoordinator()
|
||||
tracker = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"src.agents.delegation.delegate_to_librarian",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_librarian_result(),
|
||||
) as mock_delegate:
|
||||
events = [
|
||||
event
|
||||
async for event in coordinator._stream_direct_delegation(
|
||||
user_message="And what services does it list?",
|
||||
recommendation=SimpleNamespace(
|
||||
recommended_capabilities=["librarian"]
|
||||
),
|
||||
tracker=tracker,
|
||||
conversation_id="conv_1",
|
||||
conversation_history=HISTORY,
|
||||
results={},
|
||||
)
|
||||
]
|
||||
|
||||
assert events, "generator must yield think events"
|
||||
context = mock_delegate.await_args.kwargs["context"]
|
||||
assert "Tell me about my homelab wiki" in context
|
||||
assert "It documents your services, sir." in context
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_delegation_streams_error_think(self):
|
||||
coordinator = StreamingCoordinator()
|
||||
tracker = AsyncMock()
|
||||
results: dict = {}
|
||||
|
||||
failed = DelegationResult(
|
||||
expert_name="librarian",
|
||||
task="task",
|
||||
success=False,
|
||||
output="I'm afraid the archives proved difficult to access.",
|
||||
error="The Librarian was unable to complete the task.",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.agents.delegation.delegate_to_librarian",
|
||||
new_callable=AsyncMock,
|
||||
return_value=failed,
|
||||
):
|
||||
events = [
|
||||
event
|
||||
async for event in coordinator._stream_direct_delegation(
|
||||
user_message="Search for Docker info",
|
||||
recommendation=SimpleNamespace(
|
||||
recommended_capabilities=["librarian"]
|
||||
),
|
||||
tracker=tracker,
|
||||
conversation_id="conv_1",
|
||||
results=results,
|
||||
)
|
||||
]
|
||||
|
||||
assert results["tools_called"] == []
|
||||
# Expert result carries the curated user-safe sentence
|
||||
assert "archives" in results["expert_results"]["librarian"]
|
||||
deltas = [e.delta for e in events if isinstance(e, ReasoningSummaryDelta)]
|
||||
assert len(deltas) == 2 # start + error think messages
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestServiceDelegationContext:
|
||||
"""The non-streaming direct delegation path passes trimmed history."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_delegation_with_results_passes_context(self):
|
||||
from src.responses.service import _direct_delegation_with_results
|
||||
|
||||
tracker = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"src.agents.delegation.delegate_to_librarian",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_librarian_result(),
|
||||
) as mock_delegate:
|
||||
results = await _direct_delegation_with_results(
|
||||
user_message="And what services does it list?",
|
||||
recommendation=SimpleNamespace(
|
||||
recommended_capabilities=["librarian"]
|
||||
),
|
||||
tracker=tracker,
|
||||
conversation_id="conv_1",
|
||||
conversation_history=HISTORY,
|
||||
)
|
||||
|
||||
context = mock_delegate.await_args.kwargs["context"]
|
||||
assert "Tell me about my homelab wiki" in context
|
||||
assert results["expert_results"]["librarian"] == "Findings."
|
||||
Reference in New Issue
Block a user