Files
agent-desktop/tests/unit/channels/test_console.py
T

928 lines
28 KiB
Python

# -*- coding: utf-8 -*-
"""
Console Channel Unit Tests - Simple Channel Template
This serves as the reference implementation for testing simple channels.
For complex channels with external dependencies (HTTP, WebSocket), see
test_dingtalk.py for advanced patterns.
Key patterns demonstrated:
1. Basic initialization testing
2. Output capture (for console-based channels)
3. Lifecycle testing (start/stop)
4. Simple mocking (no external dependencies)
"""
# pylint: disable=redefined-outer-name,reimported,protected-access
# pylint: disable=unused-argument
from __future__ import annotations
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from pineagents.app.channels.renderer import ChannelDisplayConfig
from pineagents.app.channels.console.channel import ConsoleChannel
class _FakeDumpEvent:
def __init__(self, payload):
self._payload = payload
for key, value in payload.items():
setattr(self, key, value)
def model_dump(self, mode="json"):
del mode
return self._payload
def model_dump_json(self):
return json.dumps(self._payload, ensure_ascii=True)
class TestConsoleChannelUnit:
"""
Unit tests for ConsoleChannel.
These complement the contract tests by verifying internal behavior,
such as enabled/disabled state and output formatting.
"""
@pytest.fixture
def mock_process(self):
"""Create mock process handler."""
async def mock_handler(*_args, **_kwargs):
event = MagicMock()
event.object = "message"
event.status = "completed"
yield event
return AsyncMock(side_effect=mock_handler)
@pytest.fixture
def channel(self, mock_process):
"""Create ConsoleChannel instance."""
return ConsoleChannel(
process=mock_process,
enabled=True,
bot_prefix="[BOT] ",
display_config=ChannelDisplayConfig(
show_tool_calls=True,
show_tool_results=True,
),
)
def test_init_stores_enabled_flag(self, mock_process):
"""Constructor should store the enabled flag."""
from pineagents.app.channels.console.channel import ConsoleChannel
ch = ConsoleChannel(
process=mock_process,
enabled=False,
bot_prefix="[TEST] ",
)
assert ch.enabled is False
assert ch.bot_prefix == "[TEST] "
def test_sse_headline_strip_covers_delta_fields(self):
"""Raw SSE payload cleanup must hide streamed headline deltas."""
payload = {
"object": "response",
"delta": "<!-- ⟦ streamed headline should be hidden ⟧ -->",
"output": [
{
"content": [
{
"type": "text",
"text": (
"visible\n"
"<!-- ⟦ completed headline hidden too ⟧ -->"
),
},
],
},
],
}
data = ConsoleChannel._strip_event_headlines(
_FakeDumpEvent(payload),
"{}",
)
assert "streamed headline" not in data
assert "completed headline" not in data
assert "visible" in data
def test_sse_headline_strip_tracks_split_delta_line(self):
"""Later headline chunks stay hidden without repeating the opener."""
stream_states = {}
chunks = (
"visible\n⟦ model discovery |",
" status: fixed; next: test",
" | anchors: TC-1 ⟧",
)
rendered = []
for text in chunks:
payload = {
"object": "content",
"delta": True,
"msg_id": "message-1",
"index": 0,
"text": text,
}
data = ConsoleChannel._strip_event_headlines(
_FakeDumpEvent(payload),
"{}",
stream_states,
)
rendered.append(data)
assert "visible" in rendered[0]
assert all("model discovery" not in item for item in rendered)
assert all("status: fixed" not in item for item in rendered)
assert all("anchors: TC-1" not in item for item in rendered)
assert not stream_states
def test_sse_serializer_hides_split_delta_line(self, channel):
"""The public SSE serializer carries suppression between deltas."""
stream_states = {}
chunks = (
"visible\n⟦ model discovery |",
" status: fixed; next: test",
" | anchors: TC-1 ⟧",
)
rendered = []
for text in chunks:
event = _FakeDumpEvent(
{
"object": "content",
"delta": True,
"msg_id": "message-1",
"index": 0,
"text": text,
},
)
rendered.append(
channel._serialize_event_for_sse(
event,
stream_states,
),
)
assert "visible" in rendered[0]
assert all("model discovery" not in item for item in rendered)
assert all("status: fixed" not in item for item in rendered)
assert all("anchors: TC-1" not in item for item in rendered)
assert not stream_states
def test_sse_serializer_buffers_split_opening_marker(self, channel):
stream_states = {}
chunks = (
"answer\n<!",
"-- ⟦ hidden",
" headline ⟧ -->",
)
visible = []
for text in chunks:
event = _FakeDumpEvent(
{
"object": "content",
"delta": True,
"msg_id": "message-1",
"index": 0,
"text": text,
},
)
data = channel._serialize_event_for_sse(event, stream_states)
visible.append(json.loads(data)["text"])
assert "".join(visible) == "answer\n"
assert not stream_states
@pytest.mark.parametrize("suffix", ("<", "<!", "<!--"))
def test_sse_serializer_flushes_unconfirmed_marker_prefix(
self,
channel,
suffix,
):
stream_states = {}
event = _FakeDumpEvent(
{
"object": "content",
"delta": True,
"msg_id": "message-1",
"index": 0,
"text": "ordinary comparison ends in " + suffix,
},
)
data = channel._serialize_event_for_sse(event, stream_states)
flushed = channel._flush_headline_stream_states(stream_states)
assert json.loads(data)["text"] == "ordinary comparison ends in "
assert [json.loads(item)["text"] for item in flushed] == [suffix]
assert not stream_states
def test_sse_serializer_discards_confirmed_headline_at_end(self, channel):
stream_states = {}
event = _FakeDumpEvent(
{
"object": "content",
"delta": True,
"msg_id": "message-1",
"index": 0,
"text": "answer\n<!-- ⟦ unfinished headline",
},
)
data = channel._serialize_event_for_sse(event, stream_states)
flushed = channel._flush_headline_stream_states(stream_states)
assert json.loads(data)["text"] == "answer\n"
assert flushed == []
assert not stream_states
@pytest.mark.asyncio
async def test_send_prints_to_stdout(self, channel, capsys):
"""send() should print message to stdout when enabled."""
await channel.send("user123", "Hello World", meta={})
captured = capsys.readouterr()
assert "Hello World" in captured.out
assert "[BOT]" in captured.out or "user123" in captured.out
@pytest.mark.asyncio
async def test_send_disabled_does_nothing(self, mock_process, capsys):
"""send() should do nothing when disabled."""
from pineagents.app.channels.console.channel import ConsoleChannel
ch = ConsoleChannel(
process=mock_process,
enabled=False,
bot_prefix="[BOT] ",
)
await ch.send("user123", "Hello World", meta={})
captured = capsys.readouterr()
assert captured.out == ""
@pytest.mark.asyncio
async def test_send_includes_prefix(self, mock_process, capsys):
"""send() should include bot_prefix before message."""
from pineagents.app.channels.console.channel import ConsoleChannel
ch = ConsoleChannel(
process=mock_process,
enabled=True,
bot_prefix=">> ",
)
await ch.send("user123", "Test message", meta={})
captured = capsys.readouterr()
# Prefix should appear before or with message
assert ">> " in captured.out
assert "Test message" in captured.out
@pytest.mark.asyncio
async def test_start_when_enabled(self, channel):
"""start() should complete without error when enabled."""
# Should not raise
await channel.start()
@pytest.mark.asyncio
async def test_start_when_disabled(self, mock_process):
"""start() should handle disabled channel gracefully."""
from pineagents.app.channels.console.channel import ConsoleChannel
ch = ConsoleChannel(
process=mock_process,
enabled=False,
bot_prefix="",
)
# Should not raise
await ch.start()
@pytest.mark.asyncio
async def test_stop_when_enabled(self, channel):
"""stop() should complete without error when enabled."""
await channel.start()
await channel.stop()
# Should not raise
@pytest.mark.asyncio
async def test_stop_when_disabled(self, mock_process):
"""stop() should handle disabled channel gracefully."""
from pineagents.app.channels.console.channel import ConsoleChannel
ch = ConsoleChannel(
process=mock_process,
enabled=False,
bot_prefix="",
)
# Should not raise
await ch.stop()
@pytest.mark.asyncio
async def test_send_content_parts_combines_text(
self,
mock_process,
capsys,
):
"""send_content_parts() should combine multiple text parts."""
from pineagents.app.channels.base import TextContent, ContentType
ch = ConsoleChannel(
process=mock_process,
enabled=True,
bot_prefix="",
)
parts = [
TextContent(type=ContentType.TEXT, text="Line 1"),
TextContent(type=ContentType.TEXT, text="Line 2"),
]
await ch.send_content_parts("user123", parts, meta={})
captured = capsys.readouterr()
assert "Line 1" in captured.out
assert "Line 2" in captured.out
class TestConsoleChannelFromEnv:
"""Tests for from_env factory method."""
@pytest.fixture
def mock_process(self):
return AsyncMock()
def test_from_env_reads_enabled(self, mock_process, monkeypatch):
"""from_env should read CONSOLE_CHANNEL_ENABLED from environment."""
from pineagents.app.channels.console.channel import ConsoleChannel
monkeypatch.setenv("CONSOLE_CHANNEL_ENABLED", "0")
channel = ConsoleChannel.from_env(mock_process)
assert channel.enabled is False
def test_from_env_reads_bot_prefix(self, mock_process, monkeypatch):
"""from_env should read CONSOLE_BOT_PREFIX from environment."""
from pineagents.app.channels.console.channel import ConsoleChannel
monkeypatch.setenv("CONSOLE_BOT_PREFIX", "[TEST] ")
channel = ConsoleChannel.from_env(mock_process)
assert channel.bot_prefix == "[TEST] "
def test_from_env_defaults(self, mock_process, monkeypatch):
"""from_env should use sensible defaults."""
from pineagents.app.channels.console.channel import ConsoleChannel
# Clear environment
monkeypatch.delenv("CONSOLE_CHANNEL_ENABLED", raising=False)
monkeypatch.delenv("CONSOLE_BOT_PREFIX", raising=False)
monkeypatch.delenv("CONSOLE_MEDIA_DIR", raising=False)
channel = ConsoleChannel.from_env(mock_process)
assert channel.enabled is True # Default enabled
assert channel.bot_prefix == "" # Default is empty string
class TestConsoleChannelFromConfig:
"""Tests for from_config factory method."""
@pytest.fixture
def mock_process(self):
return AsyncMock()
def test_from_config_uses_config_values(self, mock_process):
"""from_config should use values from config object."""
from pineagents.app.channels.console.channel import ConsoleChannel
from pineagents.config.config import ConsoleConfig
config = ConsoleConfig(
enabled=False,
bot_prefix="[CFG] ",
)
channel = ConsoleChannel.from_config(
process=mock_process,
config=config,
)
assert channel.enabled is False
assert channel.bot_prefix == "[CFG] "
# =============================================================================
# P2: Console Output Formatting (_safe_print, _print_parts, _parts_to_text)
# =============================================================================
class TestConsolePrinting:
"""
Console output formatting and printing tests.
Covers _safe_print, _print_parts, _parts_to_text methods.
"""
@pytest.fixture
def channel_for_print(self):
"""Create channel for testing print methods."""
from pineagents.app.channels.console.channel import ConsoleChannel
return ConsoleChannel(
process=AsyncMock(),
enabled=True,
bot_prefix=">> ",
)
def test_safe_print_outputs_text(self, channel_for_print, capsys):
"""_safe_print should output text to stdout."""
channel_for_print._safe_print("Hello World")
captured = capsys.readouterr()
assert "Hello World" in captured.out
def test_print_parts_formats_text_content(
self,
channel_for_print,
capsys,
):
"""_print_parts should format and print text content."""
from pineagents.app.channels.base import TextContent, ContentType
parts = [TextContent(type=ContentType.TEXT, text="Test message")]
channel_for_print._print_parts(parts, ev_type="message.completed")
captured = capsys.readouterr()
assert ">> Test message" in captured.out
assert "Bot" in captured.out
def test_print_parts_formats_refusal_content(
self,
channel_for_print,
capsys,
):
"""_print_parts should format refusal content."""
from pineagents.app.channels.base import RefusalContent, ContentType
parts = [
RefusalContent(
type=ContentType.REFUSAL,
refusal="I cannot do that",
),
]
channel_for_print._print_parts(parts)
captured = capsys.readouterr()
assert "Refusal" in captured.out
assert "I cannot do that" in captured.out
def test_print_parts_formats_image_content(
self,
channel_for_print,
capsys,
):
"""_print_parts should format image content."""
from pineagents.app.channels.base import ImageContent, ContentType
parts = [
ImageContent(
type=ContentType.IMAGE,
image_url="http://example.com/image.jpg",
),
]
channel_for_print._print_parts(parts)
captured = capsys.readouterr()
assert "Image" in captured.out
assert "http://example.com/image.jpg" in captured.out
def test_print_parts_formats_video_content(
self,
channel_for_print,
capsys,
):
"""_print_parts should format video content."""
from pineagents.app.channels.base import VideoContent, ContentType
parts = [
VideoContent(
type=ContentType.VIDEO,
video_url="http://example.com/video.mp4",
),
]
channel_for_print._print_parts(parts)
captured = capsys.readouterr()
assert "Video" in captured.out
def test_print_error_formats_error(self, channel_for_print, capsys):
"""_print_error should format error message."""
channel_for_print._print_error("Something went wrong")
captured = capsys.readouterr()
assert "Error" in captured.out
assert "Something went wrong" in captured.out
def test_parts_to_text_combines_text_parts(self, channel_for_print):
"""_parts_to_text should combine multiple text parts."""
from pineagents.app.channels.base import TextContent, ContentType
parts = [
TextContent(type=ContentType.TEXT, text="Line 1"),
TextContent(type=ContentType.TEXT, text="Line 2"),
]
result = channel_for_print._parts_to_text(parts, meta={})
assert "Line 1" in result
assert "Line 2" in result
def test_parts_to_text_includes_prefix(self, channel_for_print):
"""_parts_to_text should include bot_prefix."""
from pineagents.app.channels.base import TextContent, ContentType
parts = [TextContent(type=ContentType.TEXT, text="Hello")]
result = channel_for_print._parts_to_text(parts, meta={})
assert ">> " in result
def test_parts_to_text_skips_empty_parts(self, channel_for_print):
"""_parts_to_text should skip empty text parts."""
from pineagents.app.channels.base import TextContent, ContentType
parts = [
TextContent(type=ContentType.TEXT, text=""),
TextContent(type=ContentType.TEXT, text="Valid"),
]
result = channel_for_print._parts_to_text(parts)
assert "Valid" in result
# =============================================================================
# P2: Console Streaming (stream_one)
# =============================================================================
@pytest.mark.asyncio
class TestConsoleStreaming:
"""
stream_one streaming process tests.
Core streaming logic for queue/terminal consumption.
"""
@pytest.fixture
def stream_channel(self):
"""Create channel for stream testing."""
from pineagents.app.channels.console.channel import ConsoleChannel
return ConsoleChannel(
process=AsyncMock(),
enabled=True,
bot_prefix=">> ",
)
async def test_stream_one_yields_events(self, stream_channel):
"""stream_one should yield SSE-formatted events."""
from pineagents.schemas import (
RunStatus,
Event,
Message,
MessageType,
Role,
TextContent,
ContentType,
)
mock_event = Event(
object="message",
status=RunStatus.Completed,
type="message.completed",
id="ev-1",
created_at=1234567890,
message=Message(
type=MessageType.MESSAGE,
role=Role.ASSISTANT,
content=[
TextContent(type=ContentType.TEXT, text="Hello"),
],
),
)
async def mock_process(request):
yield mock_event
stream_channel._process = mock_process
payload = {
"sender_id": "user123",
"content_parts": [
TextContent(
type=ContentType.TEXT,
text="Hello",
),
],
"meta": {},
}
events = []
async for event in stream_channel.stream_one(payload):
events.append(event)
break
assert len(events) == 1
assert "data:" in events[0]
@pytest.mark.parametrize("suffix", ("<", "<!", "<!--"))
async def test_stream_one_flushes_pending_prefix_before_completion(
self,
stream_channel,
suffix,
):
from pineagents.schemas import (
ContentType,
Event,
Message,
MessageType,
Role,
RunStatus,
TextContent,
)
delta = _FakeDumpEvent(
{
"object": "content",
"delta": True,
"msg_id": "message-1",
"index": 0,
"text": "ordinary comparison ends in " + suffix,
},
)
completed = Event(
object="message",
status=RunStatus.Completed,
type="message.completed",
id="message-1",
created_at=1234567890,
message=Message(
type=MessageType.MESSAGE,
role=Role.ASSISTANT,
content=[
TextContent(
type=ContentType.TEXT,
text="ordinary comparison ends in " + suffix,
),
],
),
)
async def mock_process(request):
del request
yield delta
yield completed
stream_channel._process = mock_process
payload = {
"sender_id": "user123",
"content_parts": [
TextContent(type=ContentType.TEXT, text="Hello"),
],
"meta": {},
}
events = [event async for event in stream_channel.stream_one(payload)]
payloads = [
json.loads(event.removeprefix("data: ").strip())
for event in events
]
assert payloads[0]["text"] == "ordinary comparison ends in "
assert payloads[1]["text"] == suffix
assert payloads[2]["object"] == "message"
async def test_stream_one_touches_chat_in_one_manager_call(
self,
stream_channel,
):
"""Console activity uses the single-transaction touch API."""
from pineagents.schemas import (
ContentType,
Event,
Message,
MessageType,
Role,
RunStatus,
TextContent,
)
mock_event = Event(
object="message",
status=RunStatus.Completed,
type="message.completed",
id="ev-touch",
created_at=1234567890,
message=Message(
type=MessageType.MESSAGE,
role=Role.ASSISTANT,
content=[
TextContent(type=ContentType.TEXT, text="Hello"),
],
),
)
async def mock_process(_request):
yield mock_event
stream_channel._process = mock_process
chat_manager = MagicMock()
chat_manager.touch_chat_by_session = AsyncMock()
stream_channel._workspace = MagicMock(chat_manager=chat_manager)
payload = {
"sender_id": "user123",
"content_parts": [
TextContent(type=ContentType.TEXT, text="Hello"),
],
"meta": {},
}
async for _event in stream_channel.stream_one(payload):
break
chat_manager.touch_chat_by_session.assert_awaited_once_with(
session_id="console:user123",
channel="console",
user_id="user123",
)
async def test_stream_one_handles_dict_payload(self, stream_channel):
"""stream_one should handle dict payload with debounce."""
from pineagents.schemas import (
RunStatus,
Event,
Message,
MessageType,
Role,
TextContent,
ContentType,
)
from unittest.mock import patch
mock_event = Event(
object="message",
status=RunStatus.Completed,
type="message.completed",
id="ev-1",
created_at=1234567890,
message=Message(
type=MessageType.MESSAGE,
role=Role.ASSISTANT,
content=[TextContent(type=ContentType.TEXT, text="Done")],
),
)
async def mock_process(request):
yield mock_event
stream_channel._process = mock_process
# Payload with content_parts dict-style
with patch.object(
stream_channel,
"_apply_no_text_debounce",
return_value=(True, []),
):
payload = {
"sender_id": "user123",
"content_parts": [],
"meta": {},
}
events = []
async for event in stream_channel.stream_one(payload):
events.append(event)
break
assert len(events) == 1
async def test_stream_one_falls_back_on_surrogate_json_error(
self,
stream_channel,
):
"""stream_one should fallback instead of crashing on bad surrogate."""
from pineagents.schemas import (
RunStatus,
TextContent,
ContentType,
)
class BrokenJsonEvent:
object = "response"
status = RunStatus.Completed
type = "response.completed"
output = []
def model_dump_json(self):
raise UnicodeEncodeError(
"utf-8",
"\ud83d",
0,
1,
"surrogates not allowed",
)
def model_dump(self, mode="python"):
del mode
return {
"object": "response",
"status": "completed",
"text": "\ud83d broken",
}
async def mock_process(_request):
yield BrokenJsonEvent()
stream_channel._process = mock_process
payload = {
"sender_id": "user123",
"content_parts": [
TextContent(
type=ContentType.TEXT,
text="Hello",
),
],
"meta": {},
}
events = []
async for event in stream_channel.stream_one(payload):
events.append(event)
break
assert len(events) == 1
assert events[0].startswith("data: ")
assert "\\ud83d" not in events[0]
assert "? broken" in events[0]
async def test_consume_one_drain_stream(self, stream_channel):
"""consume_one should drain stream_one."""
from unittest.mock import patch, AsyncMock
mock_stream = AsyncMock()
mock_stream.__aiter__.return_value = ["event1", "event2"]
with patch.object(
stream_channel,
"stream_one",
return_value=mock_stream,
):
await stream_channel.consume_one({"test": "payload"})
# =============================================================================
# P2: Console Media Handling
# =============================================================================
class TestConsoleMediaHandling:
"""
Console media directory and handling tests.
"""
@pytest.fixture
def media_channel(self):
"""Create channel for media testing."""
from pineagents.app.channels.console.channel import ConsoleChannel
return ConsoleChannel(
process=AsyncMock(),
enabled=True,
bot_prefix=">> ",
)
def test_media_dir_returns_path(self, media_channel):
"""media_dir should return a valid Path."""
from pathlib import Path
result = media_channel.media_dir
assert isinstance(result, Path)