2273 lines
70 KiB
Python
2273 lines
70 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from opensquilla.artifacts import ArtifactStore
|
|
from opensquilla.channels.contract import ChannelCapabilityProfile
|
|
from opensquilla.channels.stream_policy import resolve_channel_stream_policy
|
|
from opensquilla.channels.types import Attachment, IncomingMessage, OutgoingMessage
|
|
from opensquilla.engine.types import (
|
|
ArtifactEvent,
|
|
DoneEvent,
|
|
TextDeltaEvent,
|
|
ToolResultEvent,
|
|
ToolUseStartEvent,
|
|
)
|
|
from opensquilla.gateway.attachment_ingest import (
|
|
MAX_STAGED_PDF_BYTES,
|
|
MAX_TOTAL_ATTACHMENT_BYTES,
|
|
AttachmentTotalTooLargeError,
|
|
)
|
|
from opensquilla.gateway.channel_dispatch import (
|
|
_artifact_fallback_lines,
|
|
_build_reply_message,
|
|
_deliver_artifacts_as_channel_files,
|
|
_deliver_runtime_channel_reply,
|
|
_dispatch_channel_slash_command,
|
|
_dispatch_combined_message_after_debounce,
|
|
_ingest_channel_message_attachments,
|
|
_preserve_route_channel_metadata,
|
|
_route_envelope_reply_message,
|
|
_run_turn_batch_path,
|
|
_run_turn_with_streaming,
|
|
_RuntimeChannelStreamRelay,
|
|
)
|
|
from opensquilla.gateway.config import AgentEntryConfig, GatewayConfig
|
|
from opensquilla.gateway.protocol import make_ok_res
|
|
from opensquilla.gateway.routing import build_channel_route_envelope
|
|
from opensquilla.safety.permission_matrix import Principal, is_tool_allowed
|
|
from opensquilla.sandbox.run_mode import RunMode
|
|
from opensquilla.tools.types import CallerKind
|
|
|
|
|
|
class _FakeChannel:
|
|
def __init__(self) -> None:
|
|
self.sent: list[OutgoingMessage] = []
|
|
|
|
async def send(self, message: OutgoingMessage) -> None:
|
|
self.sent.append(message)
|
|
|
|
|
|
class _FakeEventBridge:
|
|
def __init__(self) -> None:
|
|
self.events: list[tuple[str, str, dict]] = []
|
|
|
|
async def emit(self, session_key: str, event_name: str, payload: dict) -> None:
|
|
self.events.append((session_key, event_name, payload))
|
|
|
|
|
|
class _RunContextSessionManager:
|
|
def __init__(self, origin: dict | None) -> None:
|
|
self.node = SimpleNamespace(origin=origin)
|
|
|
|
async def get_session(self, session_key: str):
|
|
return self.node
|
|
|
|
|
|
def _message() -> IncomingMessage:
|
|
return IncomingMessage(sender_id="u1", channel_id="c1", content="hello")
|
|
|
|
|
|
def _tool_ctx(agent_id: str = "main") -> SimpleNamespace:
|
|
return SimpleNamespace(agent_id=agent_id)
|
|
|
|
|
|
def _exact_pdf(size: int) -> bytes:
|
|
header = b"%PDF-1.4\n"
|
|
return header + b"a" * (size - len(header))
|
|
|
|
|
|
def test_channel_reply_sanitizes_provider_compaction_markers() -> None:
|
|
reply = _build_reply_message(
|
|
_FakeChannel(),
|
|
"Reply to user:\n[opensquilla_compacted:assistant_content:165:82bb251511c20cec]\n?",
|
|
_message(),
|
|
)
|
|
|
|
assert "opensquilla_compacted" not in reply.content
|
|
assert "assistant_content" not in reply.content
|
|
assert reply.content == "Reply to user:\n?"
|
|
|
|
|
|
def test_route_envelope_reply_preserves_channel_for_thread_target() -> None:
|
|
route_envelope = SimpleNamespace(channel_id="C42", thread_id="1700000000.000100")
|
|
|
|
reply = _route_envelope_reply_message("busy", route_envelope)
|
|
|
|
assert reply.reply_to == "1700000000.000100"
|
|
assert reply.metadata == {"channel": "C42"}
|
|
|
|
|
|
def test_preserve_route_channel_metadata_for_registry_thread_reply() -> None:
|
|
route_envelope = SimpleNamespace(channel_id="C42", thread_id="1700000000.000100")
|
|
reply = OutgoingMessage(
|
|
content="done",
|
|
reply_to="1700000000.000100",
|
|
metadata={"command": "compact"},
|
|
)
|
|
|
|
fixed = _preserve_route_channel_metadata(reply, route_envelope)
|
|
|
|
assert fixed.reply_to == "1700000000.000100"
|
|
assert fixed.metadata == {"command": "compact", "channel": "C42"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_registered_slash_command_preserves_channel_for_thread_target() -> None:
|
|
msg = IncomingMessage(
|
|
sender_id="U1",
|
|
channel_id="C42",
|
|
content="/compact",
|
|
metadata={"thread_ts": "1700000000.000100"},
|
|
)
|
|
route_envelope = build_channel_route_envelope(
|
|
msg,
|
|
session_key="agent:main:slack:group:C42:thread:1700000000.000100",
|
|
session_prefix="slack",
|
|
agent_id="main",
|
|
)
|
|
|
|
class FakeDispatcher:
|
|
async def dispatch(self, req_id, method, params, ctx):
|
|
return make_ok_res(
|
|
req_id,
|
|
{
|
|
"status": "skipped",
|
|
"compacted": False,
|
|
},
|
|
)
|
|
|
|
reply = await _dispatch_channel_slash_command(
|
|
route_envelope=route_envelope,
|
|
msg=msg,
|
|
session_manager=object(),
|
|
session_key=route_envelope.session_key,
|
|
session_prefix="slack",
|
|
rpc_dispatcher=FakeDispatcher(),
|
|
context_factory=lambda _envelope: object(),
|
|
)
|
|
|
|
assert reply is not None
|
|
assert reply.reply_to == "1700000000.000100"
|
|
assert reply.metadata["channel"] == "C42"
|
|
assert reply.metadata["command"] == "compact"
|
|
|
|
|
|
def test_channel_stream_policy_prefers_adapter_stream_updates() -> None:
|
|
class StreamingChannel:
|
|
async def send_streaming(self, chunks):
|
|
async for _ in chunks:
|
|
pass
|
|
|
|
policy = resolve_channel_stream_policy(StreamingChannel())
|
|
|
|
assert policy.mode == "adapter_stream"
|
|
assert policy.relay_stream is True
|
|
assert policy.typing_keepalive is False
|
|
|
|
|
|
def test_channel_stream_policy_uses_typing_placeholder_without_stream_editing() -> None:
|
|
class TypingOnlyChannel:
|
|
async def send_typing(self) -> None:
|
|
pass
|
|
|
|
async def send(self, message: OutgoingMessage) -> None:
|
|
pass
|
|
|
|
policy = resolve_channel_stream_policy(TypingOnlyChannel())
|
|
|
|
assert policy.mode == "typing_final"
|
|
assert policy.relay_stream is False
|
|
assert policy.typing_keepalive is True
|
|
|
|
|
|
def test_channel_stream_policy_allows_adapter_final_only_override() -> None:
|
|
class FinalOnlyChannel:
|
|
stream_update_strategy = "final_only"
|
|
|
|
async def send_streaming(self, chunks):
|
|
async for _ in chunks:
|
|
pass
|
|
|
|
policy = resolve_channel_stream_policy(FinalOnlyChannel())
|
|
|
|
assert policy.mode == "final_only"
|
|
assert policy.relay_stream is False
|
|
assert policy.typing_keepalive is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_channel_turn_emits_run_heartbeat_while_stream_is_quiet() -> None:
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
await asyncio.sleep(0.03)
|
|
yield TextDeltaEvent(text="ok")
|
|
yield DoneEvent()
|
|
|
|
channel = _FakeChannel()
|
|
bridge = _FakeEventBridge()
|
|
config = SimpleNamespace(
|
|
agent_stream_heartbeat_interval_seconds=0.01,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
await _run_turn_batch_path(
|
|
channel,
|
|
FakeTurnRunner(),
|
|
_message(),
|
|
"agent:main:channel-test",
|
|
_tool_ctx(),
|
|
bridge,
|
|
None,
|
|
config,
|
|
)
|
|
|
|
assert any(event_name == "session.event.run_heartbeat" for _, event_name, _ in bridge.events)
|
|
assert channel.sent[-1].content == "ok"
|
|
|
|
|
|
def test_direct_channel_batch_turn_emits_tool_events_to_webui() -> None:
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield ToolUseStartEvent(
|
|
tool_use_id="meta_step_outline",
|
|
tool_name="meta-step:outline",
|
|
)
|
|
yield ToolResultEvent(
|
|
tool_use_id="meta_step_outline",
|
|
tool_name="meta-step:outline",
|
|
result="outline done",
|
|
arguments={"kind": "llm_chat", "output_chars": 12},
|
|
)
|
|
yield TextDeltaEvent(text="ok")
|
|
yield DoneEvent()
|
|
|
|
channel = _FakeChannel()
|
|
bridge = _FakeEventBridge()
|
|
config = SimpleNamespace(
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
asyncio.run(
|
|
_run_turn_batch_path(
|
|
channel,
|
|
FakeTurnRunner(),
|
|
_message(),
|
|
"agent:main:channel-test",
|
|
_tool_ctx(),
|
|
bridge,
|
|
None,
|
|
config,
|
|
)
|
|
)
|
|
|
|
assert (
|
|
"agent:main:channel-test",
|
|
"session.event.tool_use_start",
|
|
{
|
|
"tool_use_id": "meta_step_outline",
|
|
"tool_name": "meta-step:outline",
|
|
"name": "meta-step:outline",
|
|
"synthetic_from_text": False,
|
|
},
|
|
) in bridge.events
|
|
assert any(
|
|
event_name == "session.event.tool_result"
|
|
and payload["tool_name"] == "meta-step:outline"
|
|
and payload["result"] == "outline done"
|
|
and payload["arguments"]["kind"] == "llm_chat"
|
|
for _, event_name, payload in bridge.events
|
|
)
|
|
assert channel.sent[-1].content == "ok"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_channel_batch_turn_sends_clarify_card_to_feishu_channel() -> None:
|
|
class FeishuLikeChannel(_FakeChannel):
|
|
@property
|
|
def capability_profile(self) -> ChannelCapabilityProfile:
|
|
return ChannelCapabilityProfile(
|
|
channel_type="feishu",
|
|
cards=True,
|
|
interactive_cards=True,
|
|
card_actions=True,
|
|
)
|
|
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield ToolResultEvent(
|
|
tool_use_id="meta_step_clarify",
|
|
tool_name="meta-step:clarify",
|
|
result="paused: awaiting user input",
|
|
arguments={
|
|
"kind": "user_input",
|
|
"paused": True,
|
|
"step": "clarify",
|
|
"run_id": "run-1",
|
|
"clarify_schema": {
|
|
"mode": "form",
|
|
"intro": "需要确认几件事。",
|
|
"fields": [
|
|
{
|
|
"name": "destination",
|
|
"type": "string",
|
|
"required": True,
|
|
"prompt": "目的地",
|
|
},
|
|
{
|
|
"name": "budget",
|
|
"type": "enum",
|
|
"required": False,
|
|
"prompt": "预算",
|
|
"choices": ["低", "中", "高"],
|
|
"default": "中",
|
|
},
|
|
],
|
|
"cancel_keywords": ["取消", "cancel"],
|
|
},
|
|
},
|
|
)
|
|
yield TextDeltaEvent(text="请回复以下字段:\n 1) destination — 目的地 [必填]")
|
|
yield DoneEvent()
|
|
|
|
channel = FeishuLikeChannel()
|
|
bridge = _FakeEventBridge()
|
|
config = SimpleNamespace(
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
await _run_turn_batch_path(
|
|
channel,
|
|
FakeTurnRunner(),
|
|
IncomingMessage(
|
|
sender_id="u1",
|
|
channel_id="c1",
|
|
content="hello",
|
|
metadata={"is_group": False, "chat_type": "p2p"},
|
|
),
|
|
"agent:main:channel-clarify",
|
|
_tool_ctx(),
|
|
bridge,
|
|
None,
|
|
config,
|
|
)
|
|
|
|
assert len(channel.sent) == 1
|
|
sent = channel.sent[0]
|
|
assert sent.reply_to == "c1"
|
|
card = sent.metadata["card"]
|
|
assert card["header"]["title"]["content"] == "需要补充信息"
|
|
assert "destination" in json.dumps(card, ensure_ascii=False)
|
|
assert "预算" in json.dumps(card, ensure_ascii=False)
|
|
assert '"opensquilla_action": "clarify_submit"' in json.dumps(card)
|
|
assert '"is_group": false' in json.dumps(card)
|
|
assert '"chat_type": "p2p"' in json.dumps(card)
|
|
assert "请回复以下字段" not in sent.content
|
|
assert any(event_name == "session.event.tool_result" for _, event_name, _ in bridge.events)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_channel_batch_turn_keeps_text_fallback_without_card_support() -> None:
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield ToolResultEvent(
|
|
tool_use_id="meta_step_clarify",
|
|
tool_name="meta-step:clarify",
|
|
result="paused: awaiting user input",
|
|
arguments={
|
|
"kind": "user_input",
|
|
"paused": True,
|
|
"step": "clarify",
|
|
"run_id": "run-1",
|
|
"clarify_schema": {
|
|
"mode": "form",
|
|
"intro": "需要确认几件事。",
|
|
"fields": [
|
|
{
|
|
"name": "destination",
|
|
"type": "string",
|
|
"required": True,
|
|
"prompt": "目的地",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
)
|
|
yield TextDeltaEvent(text="请回复以下字段:\n 1) destination — 目的地 [必填]")
|
|
yield DoneEvent()
|
|
|
|
channel = _FakeChannel()
|
|
config = SimpleNamespace(
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
await _run_turn_batch_path(
|
|
channel,
|
|
FakeTurnRunner(),
|
|
_message(),
|
|
"agent:main:channel-clarify",
|
|
_tool_ctx(),
|
|
_FakeEventBridge(),
|
|
None,
|
|
config,
|
|
)
|
|
|
|
assert len(channel.sent) == 1
|
|
assert "请回复以下字段" in channel.sent[0].content
|
|
assert "card" not in channel.sent[0].metadata
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_channel_batch_turn_sends_artifact_fallback() -> None:
|
|
artifact = {
|
|
"id": "art-channel",
|
|
"kind": "artifact_ref",
|
|
"name": "report.txt",
|
|
"mime": "text/plain",
|
|
"size": 4,
|
|
"sha256": "f" * 64,
|
|
"session_id": "session-1",
|
|
"session_key": "agent:main:channel-test",
|
|
"source": "publish_artifact",
|
|
"created_at": "2026-05-06T12:00:00Z",
|
|
"download_url": "/api/v1/artifacts/art-channel?sessionKey=agent%3Amain%3Achannel-test",
|
|
"store": "artifacts",
|
|
}
|
|
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield ArtifactEvent(**artifact)
|
|
yield DoneEvent()
|
|
|
|
channel = _FakeChannel()
|
|
bridge = _FakeEventBridge()
|
|
config = SimpleNamespace(
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
await _run_turn_batch_path(
|
|
channel,
|
|
FakeTurnRunner(),
|
|
_message(),
|
|
"agent:main:channel-test",
|
|
_tool_ctx(),
|
|
bridge,
|
|
None,
|
|
config,
|
|
)
|
|
|
|
assert channel.sent[-1].content == "Generated file: report.txt -> available in WebUI"
|
|
assert "/api/v1/artifacts" not in channel.sent[-1].content
|
|
assert "sessionKey" not in channel.sent[-1].content
|
|
event_artifact = bridge.events[-1][2]
|
|
assert bridge.events[-1] == (
|
|
"agent:main:channel-test",
|
|
"session.event.artifact",
|
|
event_artifact,
|
|
)
|
|
assert event_artifact["download_url"] == "/api/v1/artifacts/art-channel"
|
|
assert "session_key" not in event_artifact
|
|
assert "sessionKey" not in json.dumps(event_artifact)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_channel_batch_turn_sends_artifact_with_adapter_file_upload(tmp_path) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
ref = store.publish_bytes(
|
|
b"deck bytes",
|
|
session_id="session-1",
|
|
session_key="agent:main:channel-test",
|
|
name="report.pptx",
|
|
mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
source="publish_artifact",
|
|
)
|
|
artifact = ref.to_dict()
|
|
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield TextDeltaEvent(text="done")
|
|
yield ArtifactEvent(**artifact)
|
|
yield DoneEvent()
|
|
|
|
class FileUploadingChannel(_FakeChannel):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.files: list[tuple[str, str]] = []
|
|
|
|
async def send_file(self, chat_id: str, file_path: str) -> None:
|
|
assert Path(file_path).is_file()
|
|
self.files.append((chat_id, Path(file_path).name))
|
|
|
|
channel = FileUploadingChannel()
|
|
bridge = _FakeEventBridge()
|
|
config = SimpleNamespace(
|
|
attachments=SimpleNamespace(media_root=str(tmp_path)),
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
await _run_turn_batch_path(
|
|
channel,
|
|
FakeTurnRunner(),
|
|
_message(),
|
|
"agent:main:channel-test",
|
|
_tool_ctx(),
|
|
bridge,
|
|
None,
|
|
config,
|
|
)
|
|
|
|
assert channel.sent[-1].content == "done"
|
|
assert channel.files == [("c1", "report.pptx")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_channel_batch_turn_sends_artifact_with_original_filename(
|
|
tmp_path,
|
|
) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
ref = store.publish_bytes(
|
|
b"\x89PNG\r\n\x1a\nimage bytes",
|
|
session_id="session-1",
|
|
session_key="agent:main:channel-test",
|
|
name="思考快与慢_信息图.png",
|
|
mime="image/png",
|
|
source="publish_artifact",
|
|
)
|
|
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield TextDeltaEvent(text="done")
|
|
yield ArtifactEvent(**ref.to_dict())
|
|
yield DoneEvent()
|
|
|
|
class FileUploadingChannel(_FakeChannel):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.files: list[tuple[str, str]] = []
|
|
|
|
async def send_file(self, chat_id: str, file_path: str) -> None:
|
|
assert Path(file_path).is_file()
|
|
self.files.append((chat_id, Path(file_path).name))
|
|
|
|
channel = FileUploadingChannel()
|
|
config = SimpleNamespace(
|
|
attachments=SimpleNamespace(media_root=str(tmp_path)),
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
await _run_turn_batch_path(
|
|
channel,
|
|
FakeTurnRunner(),
|
|
_message(),
|
|
"agent:main:channel-test",
|
|
_tool_ctx(),
|
|
_FakeEventBridge(),
|
|
None,
|
|
config,
|
|
)
|
|
|
|
assert channel.sent[-1].content == "done"
|
|
assert channel.files == [("c1", "思考快与慢_信息图.png")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_channel_batch_turn_removes_delivered_markdown_image_reference(
|
|
tmp_path,
|
|
) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
ref = store.publish_bytes(
|
|
b"\x89PNG\r\n\x1a\nimage bytes",
|
|
session_id="session-1",
|
|
session_key="agent:main:channel-test",
|
|
name="thinking_fast_slow_v3.png",
|
|
mime="image/png",
|
|
source="publish_artifact",
|
|
)
|
|
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield TextDeltaEvent(
|
|
text=(
|
|
"新版改进:\n\n"
|
|
"\n\n"
|
|
"点击附件保存原图。"
|
|
)
|
|
)
|
|
yield ArtifactEvent(**ref.to_dict())
|
|
yield DoneEvent()
|
|
|
|
class FileUploadingChannel(_FakeChannel):
|
|
async def send_file(self, chat_id: str, file_path: str) -> None:
|
|
return None
|
|
|
|
channel = FileUploadingChannel()
|
|
config = SimpleNamespace(
|
|
attachments=SimpleNamespace(media_root=str(tmp_path)),
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
await _run_turn_batch_path(
|
|
channel,
|
|
FakeTurnRunner(),
|
|
_message(),
|
|
"agent:main:channel-test",
|
|
_tool_ctx(),
|
|
_FakeEventBridge(),
|
|
None,
|
|
config,
|
|
)
|
|
|
|
assert channel.sent[-1].content == "新版改进:\n\n点击附件保存原图。"
|
|
assert "![Thinking" not in channel.sent[-1].content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_channel_batch_turn_removes_artifact_markers_from_channel_text(
|
|
tmp_path,
|
|
) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
ref = store.publish_bytes(
|
|
b"image bytes",
|
|
session_id="session-1",
|
|
session_key="agent:main:channel-test",
|
|
name="chart.png",
|
|
mime="image/png",
|
|
source="publish_artifact",
|
|
)
|
|
marker = "[generated artifact omitted: chart.png (image/png)]"
|
|
artifact = ref.to_dict()
|
|
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield TextDeltaEvent(text=f"ready {marker}")
|
|
yield ArtifactEvent(**artifact)
|
|
yield DoneEvent()
|
|
|
|
class FileUploadingChannel(_FakeChannel):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.files: list[tuple[str, str]] = []
|
|
|
|
async def send_file(self, chat_id: str, file_path: str) -> None:
|
|
assert Path(file_path).is_file()
|
|
self.files.append((chat_id, Path(file_path).name))
|
|
|
|
channel = FileUploadingChannel()
|
|
config = SimpleNamespace(
|
|
attachments=SimpleNamespace(media_root=str(tmp_path)),
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
await _run_turn_batch_path(
|
|
channel,
|
|
FakeTurnRunner(),
|
|
_message(),
|
|
"agent:main:channel-test",
|
|
_tool_ctx(),
|
|
_FakeEventBridge(),
|
|
None,
|
|
config,
|
|
)
|
|
|
|
assert channel.sent[-1].content == "ready"
|
|
assert marker not in channel.sent[-1].content
|
|
assert channel.files == [("c1", "chart.png")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_admin_sender_gets_owner_tool_context_for_agent_turn(tmp_path) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
captured["tool_context"] = kwargs["tool_context"]
|
|
yield TextDeltaEvent(text="ok")
|
|
yield DoneEvent()
|
|
|
|
msg = _message()
|
|
envelope = build_channel_route_envelope(
|
|
msg,
|
|
session_key="agent:main:feishu:u1",
|
|
session_prefix="feishu",
|
|
agent_id="main",
|
|
)
|
|
config = SimpleNamespace(
|
|
channel_admin_senders={"feishu": ["u1"]},
|
|
workspace_dir=str(tmp_path),
|
|
workspace_strict=True,
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
await _run_turn_with_streaming(
|
|
_FakeChannel(),
|
|
FakeTurnRunner(),
|
|
msg,
|
|
"agent:main:feishu:u1",
|
|
config=config,
|
|
route_envelope=envelope,
|
|
)
|
|
|
|
tool_context = captured["tool_context"]
|
|
assert tool_context.is_owner is True
|
|
assert tool_context.caller_kind is CallerKind.CHANNEL
|
|
assert tool_context.channel_kind == "feishu"
|
|
assert tool_context.sender_id == "u1"
|
|
decision = is_tool_allowed(
|
|
"write_file",
|
|
"dm",
|
|
Principal(role="operator", channel_id=tool_context.session_key),
|
|
)
|
|
assert decision.allowed is True
|
|
assert decision.reason == "operator_override"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_saved_channel_run_context_is_applied_to_route_envelope(tmp_path) -> None:
|
|
from opensquilla.gateway.channel_dispatch import _apply_saved_channel_run_context
|
|
|
|
msg = _message()
|
|
envelope = build_channel_route_envelope(
|
|
msg,
|
|
session_key="agent:main:feishu:u1",
|
|
session_prefix="feishu",
|
|
agent_id="main",
|
|
)
|
|
manager = _RunContextSessionManager(
|
|
{
|
|
"sandbox_run_context": {
|
|
"run_mode": "full",
|
|
"workspace": str(tmp_path),
|
|
"mounts": [],
|
|
"domains": [],
|
|
"bundles": [],
|
|
"public_network": [],
|
|
"temporary_grants": [],
|
|
}
|
|
}
|
|
)
|
|
config = SimpleNamespace(
|
|
sandbox=SimpleNamespace(run_mode="standard", sandbox=True, security_grading=True),
|
|
permissions=SimpleNamespace(default_mode="off"),
|
|
)
|
|
|
|
await _apply_saved_channel_run_context(
|
|
envelope,
|
|
session_manager=manager,
|
|
config=config,
|
|
workspace_dir=str(tmp_path),
|
|
principal_is_owner=True,
|
|
)
|
|
|
|
assert envelope.metadata["run_mode"] == RunMode.FULL.value
|
|
assert envelope.metadata["elevated"] == "full"
|
|
assert envelope.metadata["sandbox_run_context"]["run_mode"] == "full"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unlisted_channel_sender_keeps_restricted_tool_context_for_agent_turn(
|
|
tmp_path,
|
|
) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
captured["tool_context"] = kwargs["tool_context"]
|
|
yield TextDeltaEvent(text="ok")
|
|
yield DoneEvent()
|
|
|
|
msg = _message()
|
|
envelope = build_channel_route_envelope(
|
|
msg,
|
|
session_key="agent:main:feishu:u1",
|
|
session_prefix="feishu",
|
|
agent_id="main",
|
|
)
|
|
config = SimpleNamespace(
|
|
channel_admin_senders={"feishu": ["other-user"]},
|
|
workspace_dir=str(tmp_path),
|
|
workspace_strict=True,
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
await _run_turn_with_streaming(
|
|
_FakeChannel(),
|
|
FakeTurnRunner(),
|
|
msg,
|
|
"agent:main:feishu:u1",
|
|
config=config,
|
|
route_envelope=envelope,
|
|
)
|
|
|
|
tool_context = captured["tool_context"]
|
|
assert tool_context.is_owner is False
|
|
assert tool_context.caller_kind is CallerKind.CHANNEL
|
|
assert tool_context.channel_kind == "feishu"
|
|
assert tool_context.sender_id == "u1"
|
|
|
|
|
|
def test_channel_artifact_fallback_uses_only_channel_safe_absolute_links() -> None:
|
|
assert _artifact_fallback_lines(
|
|
[
|
|
{
|
|
"id": "art-1",
|
|
"name": "report.txt",
|
|
"download_url": "/api/v1/artifacts/art-1?sessionKey=secret",
|
|
}
|
|
]
|
|
) == ["Generated file: report.txt -> available in WebUI"]
|
|
|
|
assert _artifact_fallback_lines(
|
|
[
|
|
{
|
|
"id": "art-2",
|
|
"name": "signed.txt",
|
|
"signed_download_url": "https://gateway.example/artifacts/art-2?sig=short",
|
|
}
|
|
]
|
|
) == [
|
|
"Generated file: signed.txt -> "
|
|
"https://gateway.example/artifacts/art-2?sig=short"
|
|
]
|
|
|
|
assert _artifact_fallback_lines(
|
|
[
|
|
{
|
|
"id": "art-3",
|
|
"name": "bad.txt",
|
|
"channel_download_url": "/api/v1/artifacts/art-3?token=long",
|
|
}
|
|
]
|
|
) == ["Generated file: bad.txt -> available in WebUI"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_channel_stream_relay_emits_artifact_fallback() -> None:
|
|
class StreamingChannel:
|
|
def __init__(self) -> None:
|
|
self.chunks: list[str] = []
|
|
|
|
async def send_streaming(self, chunks, **kwargs):
|
|
async for chunk in chunks:
|
|
self.chunks.append(chunk)
|
|
|
|
class FakeTaskRuntime:
|
|
async def enqueue(self, envelope, message: str, *, stream_event_sink=None):
|
|
return None
|
|
|
|
channel = StreamingChannel()
|
|
relay = _RuntimeChannelStreamRelay.maybe_start(channel, _message(), FakeTaskRuntime())
|
|
|
|
assert relay is not None
|
|
|
|
await relay.emit(
|
|
{
|
|
"kind": "artifact",
|
|
"id": "art-stream",
|
|
"name": "stream.txt",
|
|
"download_url": "/api/v1/artifacts/art-stream?sessionKey=secret",
|
|
}
|
|
)
|
|
await relay.close()
|
|
|
|
assert channel.chunks == ["Generated file: stream.txt -> available in WebUI"]
|
|
assert relay.text_emitted is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_channel_stream_relay_appends_artifact_fallback_to_text() -> None:
|
|
class StreamingChannel:
|
|
def __init__(self) -> None:
|
|
self.chunks: list[str] = []
|
|
|
|
async def send_streaming(self, chunks, **kwargs):
|
|
async for chunk in chunks:
|
|
self.chunks.append(chunk)
|
|
|
|
class FakeTaskRuntime:
|
|
async def enqueue(self, envelope, message: str, *, stream_event_sink=None):
|
|
return None
|
|
|
|
channel = StreamingChannel()
|
|
relay = _RuntimeChannelStreamRelay.maybe_start(channel, _message(), FakeTaskRuntime())
|
|
|
|
assert relay is not None
|
|
|
|
await relay.emit(TextDeltaEvent(text="done"))
|
|
await relay.emit(
|
|
{
|
|
"kind": "artifact",
|
|
"id": "art-stream",
|
|
"name": "stream.txt",
|
|
"download_url": "/api/v1/artifacts/art-stream?sessionKey=secret",
|
|
}
|
|
)
|
|
await relay.close()
|
|
|
|
assert channel.chunks == [
|
|
"done",
|
|
"\n\nGenerated file: stream.txt -> available in WebUI",
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_channel_stream_relay_sends_artifact_with_adapter_upload(
|
|
tmp_path,
|
|
) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
ref = store.publish_bytes(
|
|
b"deck bytes",
|
|
session_id="session-1",
|
|
session_key="agent:main:channel-test",
|
|
name="report.pptx",
|
|
mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
source="publish_artifact",
|
|
)
|
|
|
|
class StreamingFileChannel(_FakeChannel):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.chunks: list[str] = []
|
|
self.files: list[tuple[str, str]] = []
|
|
|
|
async def send_streaming(self, chunks, **kwargs):
|
|
async for chunk in chunks:
|
|
self.chunks.append(chunk)
|
|
|
|
async def send_file(self, chat_id: str, file_path: str) -> None:
|
|
assert Path(file_path).is_file()
|
|
self.files.append((chat_id, Path(file_path).name))
|
|
|
|
class FakeTaskRuntime:
|
|
async def enqueue(self, envelope, message: str, *, stream_event_sink=None):
|
|
return None
|
|
|
|
config = SimpleNamespace(attachments=SimpleNamespace(media_root=str(tmp_path)))
|
|
channel = StreamingFileChannel()
|
|
relay = _RuntimeChannelStreamRelay.maybe_start(
|
|
channel,
|
|
_message(),
|
|
FakeTaskRuntime(),
|
|
config,
|
|
)
|
|
|
|
assert relay is not None
|
|
|
|
await relay.emit(TextDeltaEvent(text="done"))
|
|
await relay.emit(ArtifactEvent(**ref.to_dict()))
|
|
await relay.close()
|
|
|
|
assert channel.chunks == ["done"]
|
|
assert channel.files == [("c1", "report.pptx")]
|
|
assert channel.sent == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_file_delivery_dedupes_same_artifact_material(tmp_path) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
payload = b"\x89PNG\r\n\x1a\nimage bytes"
|
|
first = store.publish_bytes(
|
|
payload,
|
|
session_id="session-1",
|
|
session_key="agent:main:feishu:direct:u1",
|
|
name="image.png",
|
|
mime="image/png",
|
|
source="image_generate",
|
|
)
|
|
second = store.publish_bytes(
|
|
payload,
|
|
session_id="session-1",
|
|
session_key="agent:main:feishu:direct:u1",
|
|
name="image.png",
|
|
mime="image/png",
|
|
source="publish_artifact",
|
|
)
|
|
|
|
class FileChannel(_FakeChannel):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.files: list[tuple[str, str]] = []
|
|
|
|
async def send_file(self, chat_id: str, file_path: str) -> None:
|
|
assert Path(file_path).is_file()
|
|
self.files.append((chat_id, Path(file_path).name))
|
|
|
|
channel = FileChannel()
|
|
config = SimpleNamespace(attachments=SimpleNamespace(media_root=str(tmp_path)))
|
|
|
|
undelivered = await _deliver_artifacts_as_channel_files(
|
|
channel,
|
|
_message(),
|
|
[first.to_dict(), second.to_dict()],
|
|
config,
|
|
)
|
|
|
|
assert undelivered == []
|
|
assert channel.files == [("c1", "image.png")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_channel_stream_relay_does_not_redeliver_transcript_artifact(
|
|
tmp_path,
|
|
) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
ref = store.publish_bytes(
|
|
b"\x89PNG\r\n\x1a\nimage bytes",
|
|
session_id="session-1",
|
|
session_key="agent:main:discord:direct:u1",
|
|
name="chart.png",
|
|
mime="image/png",
|
|
source="publish_artifact",
|
|
)
|
|
|
|
class StreamingFileChannel(_FakeChannel):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.chunks: list[str] = []
|
|
self.files: list[tuple[str, str]] = []
|
|
|
|
async def send_streaming(self, chunks, **kwargs):
|
|
async for chunk in chunks:
|
|
self.chunks.append(chunk)
|
|
|
|
async def send_file(self, chat_id: str, file_path: str) -> None:
|
|
assert Path(file_path).is_file()
|
|
self.files.append((chat_id, Path(file_path).name))
|
|
|
|
class FakeTaskRuntime:
|
|
async def enqueue(self, envelope, message: str, *, stream_event_sink=None):
|
|
return None
|
|
|
|
async def wait(self, task_id: str):
|
|
return SimpleNamespace(status="succeeded")
|
|
|
|
class FakeSessionManager:
|
|
async def read_transcript(self, key: str):
|
|
return [
|
|
{"role": "user", "content": "draw chart"},
|
|
{
|
|
"role": "assistant",
|
|
"content": json.dumps({"text": "", "artifacts": [ref.to_dict()]}),
|
|
},
|
|
]
|
|
|
|
config = SimpleNamespace(attachments=SimpleNamespace(media_root=str(tmp_path)))
|
|
channel = StreamingFileChannel()
|
|
runtime = FakeTaskRuntime()
|
|
relay = _RuntimeChannelStreamRelay.maybe_start(
|
|
channel,
|
|
_message(),
|
|
runtime,
|
|
config,
|
|
)
|
|
|
|
assert relay is not None
|
|
|
|
await relay.emit(ArtifactEvent(**ref.to_dict()))
|
|
await _deliver_runtime_channel_reply(
|
|
channel=channel,
|
|
task_runtime=runtime,
|
|
session_manager=FakeSessionManager(),
|
|
session_key="agent:main:discord:direct:u1",
|
|
task_id="task-1",
|
|
route_envelope=SimpleNamespace(reply_target=None),
|
|
inbound=_message(),
|
|
transcript_watermark=1,
|
|
config=config,
|
|
stream_relay=relay,
|
|
)
|
|
|
|
assert channel.files == [("c1", "chart.png")]
|
|
assert channel.sent == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_channel_turn_idle_timeout_sends_error_reply() -> None:
|
|
class SlowTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
await asyncio.sleep(1.0)
|
|
yield TextDeltaEvent(text="late")
|
|
|
|
channel = _FakeChannel()
|
|
config = SimpleNamespace(
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=0.01,
|
|
)
|
|
|
|
await _run_turn_batch_path(
|
|
channel,
|
|
SlowTurnRunner(),
|
|
_message(),
|
|
"agent:main:channel-timeout",
|
|
_tool_ctx(),
|
|
_FakeEventBridge(),
|
|
None,
|
|
config,
|
|
)
|
|
|
|
assert channel.sent
|
|
assert channel.sent[-1].content == "The task timed out before it could finish."
|
|
assert "Stream idle" not in channel.sent[-1].content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_channel_turn_honors_final_only_stream_policy() -> None:
|
|
class FinalOnlyStreamingChannel(_FakeChannel):
|
|
stream_update_strategy = "final_only"
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.streamed = False
|
|
|
|
async def send_streaming(self, chunks):
|
|
self.streamed = True
|
|
text = ""
|
|
async for chunk in chunks:
|
|
text += chunk
|
|
self.sent.append(OutgoingMessage(content=text))
|
|
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield TextDeltaEvent(text="final only")
|
|
yield DoneEvent()
|
|
|
|
channel = FinalOnlyStreamingChannel()
|
|
config = SimpleNamespace(
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
await _run_turn_with_streaming(
|
|
channel,
|
|
FakeTurnRunner(),
|
|
_message(),
|
|
"agent:main:final-only",
|
|
_FakeEventBridge(),
|
|
None,
|
|
config,
|
|
)
|
|
|
|
assert channel.streamed is False
|
|
assert channel.sent[-1].content == "final only"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_streaming_path_falls_back_when_adapter_stream_fails() -> None:
|
|
class FailingStreamingChannel(_FakeChannel):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.delivered_chunks: list[str] = []
|
|
|
|
async def send_streaming(self, chunks, **kwargs):
|
|
async for chunk in chunks:
|
|
self.delivered_chunks.append(chunk)
|
|
raise RuntimeError("stream edit failed")
|
|
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield TextDeltaEvent(text="part-one")
|
|
yield TextDeltaEvent(text="part-two")
|
|
yield DoneEvent()
|
|
|
|
channel = FailingStreamingChannel()
|
|
config = SimpleNamespace(
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
await _run_turn_with_streaming(
|
|
channel,
|
|
FakeTurnRunner(),
|
|
_message(),
|
|
"agent:main:stream-fallback",
|
|
_FakeEventBridge(),
|
|
None,
|
|
config,
|
|
)
|
|
|
|
assert channel.delivered_chunks == ["part-one"]
|
|
assert channel.sent
|
|
assert "part-one" in channel.sent[-1].content
|
|
assert "part-two" in channel.sent[-1].content
|
|
|
|
|
|
def test_direct_streaming_path_emits_tool_events_to_webui() -> None:
|
|
class StreamingChannel(_FakeChannel):
|
|
async def send_streaming(self, chunks, **kwargs):
|
|
text = ""
|
|
async for chunk in chunks:
|
|
text += chunk
|
|
self.sent.append(OutgoingMessage(content=text))
|
|
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield ToolUseStartEvent(
|
|
tool_use_id="meta_step_section",
|
|
tool_name="meta-step:section_introduction",
|
|
)
|
|
yield ToolResultEvent(
|
|
tool_use_id="meta_step_section",
|
|
tool_name="meta-step:section_introduction",
|
|
result="section done",
|
|
)
|
|
yield TextDeltaEvent(text="finished")
|
|
yield DoneEvent()
|
|
|
|
channel = StreamingChannel()
|
|
bridge = _FakeEventBridge()
|
|
config = SimpleNamespace(
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
asyncio.run(
|
|
_run_turn_with_streaming(
|
|
channel,
|
|
FakeTurnRunner(),
|
|
_message(),
|
|
"agent:main:stream-tool-events",
|
|
bridge,
|
|
None,
|
|
config,
|
|
)
|
|
)
|
|
|
|
event_names = [event_name for _, event_name, _ in bridge.events]
|
|
assert "session.event.tool_use_start" in event_names
|
|
assert "session.event.tool_result" in event_names
|
|
assert any(
|
|
event_name == "session.event.tool_use_start"
|
|
and payload["tool_name"] == "meta-step:section_introduction"
|
|
for _, event_name, payload in bridge.events
|
|
)
|
|
assert any(
|
|
event_name == "session.event.tool_result"
|
|
and payload["tool_name"] == "meta-step:section_introduction"
|
|
and payload["result"] == "section done"
|
|
for _, event_name, payload in bridge.events
|
|
)
|
|
assert channel.sent[-1].content == "finished"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_streaming_path_fallback_skips_delivered_chunks() -> None:
|
|
class FailingLateStreamingChannel(_FakeChannel):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.delivered_chunks: list[str] = []
|
|
|
|
async def send_streaming(self, chunks, **kwargs):
|
|
count = 0
|
|
async for chunk in chunks:
|
|
count += 1
|
|
if count == 3:
|
|
raise RuntimeError("late stream edit failed")
|
|
self.delivered_chunks.append(chunk)
|
|
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
for chunk in ("alpha", "beta", "gamma", "delta"):
|
|
yield TextDeltaEvent(text=chunk)
|
|
yield DoneEvent()
|
|
|
|
channel = FailingLateStreamingChannel()
|
|
config = SimpleNamespace(
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
await _run_turn_with_streaming(
|
|
channel,
|
|
FakeTurnRunner(),
|
|
_message(),
|
|
"agent:main:stream-fallback-late",
|
|
_FakeEventBridge(),
|
|
None,
|
|
config,
|
|
)
|
|
|
|
assert channel.delivered_chunks == ["alpha", "beta"]
|
|
assert channel.sent
|
|
fallback = channel.sent[-1].content
|
|
assert "gamma" in fallback
|
|
assert "delta" in fallback
|
|
assert "alpha" not in fallback
|
|
assert "beta" not in fallback
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_streaming_fallback_sanitizes_queued_directive_tags() -> None:
|
|
class FailingStreamingChannel(_FakeChannel):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.delivered_chunks: list[str] = []
|
|
|
|
async def send_streaming(self, chunks, **kwargs):
|
|
async for chunk in chunks:
|
|
self.delivered_chunks.append(chunk)
|
|
raise RuntimeError("stream edit failed")
|
|
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield TextDeltaEvent(text="visible ")
|
|
yield TextDeltaEvent(text="[[reply_to_current]]hidden")
|
|
yield DoneEvent()
|
|
|
|
channel = FailingStreamingChannel()
|
|
config = SimpleNamespace(
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
await _run_turn_with_streaming(
|
|
channel,
|
|
FakeTurnRunner(),
|
|
_message(),
|
|
"agent:main:stream-fallback-directive",
|
|
_FakeEventBridge(),
|
|
None,
|
|
config,
|
|
)
|
|
|
|
assert channel.delivered_chunks == ["visible "]
|
|
assert channel.sent
|
|
fallback = channel.sent[-1].content
|
|
assert "[[reply_to_current]]" not in fallback
|
|
assert "hidden" in fallback
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_streaming_sanitizes_split_provider_compaction_marker() -> None:
|
|
class StreamingChannel(_FakeChannel):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.delivered_chunks: list[str] = []
|
|
|
|
async def send_streaming(self, chunks, **kwargs):
|
|
async for chunk in chunks:
|
|
self.delivered_chunks.append(chunk)
|
|
|
|
class FakeTurnRunner:
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
yield TextDeltaEvent(text="Visible\n[opensquilla_")
|
|
yield TextDeltaEvent(text="compacted:assistant_content:165:abc]\nDone")
|
|
yield DoneEvent()
|
|
|
|
channel = StreamingChannel()
|
|
config = SimpleNamespace(
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
await _run_turn_with_streaming(
|
|
channel,
|
|
FakeTurnRunner(),
|
|
_message(),
|
|
"agent:main:stream-marker",
|
|
_FakeEventBridge(),
|
|
None,
|
|
config,
|
|
)
|
|
|
|
delivered = "".join(channel.delivered_chunks)
|
|
assert "opensquilla_compacted" not in delivered
|
|
assert "assistant_content" not in delivered
|
|
assert delivered == "Visible\nDone"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_batch_turn_uses_agent_registry_model() -> None:
|
|
class RecordingTurnRunner:
|
|
def __init__(self) -> None:
|
|
self.calls: list[dict] = []
|
|
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
self.calls.append(kwargs)
|
|
yield DoneEvent()
|
|
|
|
runner = RecordingTurnRunner()
|
|
config = GatewayConfig(
|
|
agents=[AgentEntryConfig(id="ops", model="agent/default")],
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
await _run_turn_batch_path(
|
|
_FakeChannel(),
|
|
runner,
|
|
_message(),
|
|
"agent:ops:channel-test",
|
|
_tool_ctx("ops"),
|
|
_FakeEventBridge(),
|
|
None,
|
|
config,
|
|
)
|
|
|
|
assert runner.calls[0]["model"] == "agent/default"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_ingest_resolves_adapter_bytes_to_engine_attachment() -> None:
|
|
class ResolvingChannel(_FakeChannel):
|
|
channel_id = "test"
|
|
|
|
async def resolve_inbound_attachment(self, attachment: Attachment) -> Attachment:
|
|
return Attachment(
|
|
name=attachment.name,
|
|
mime_type=attachment.mime_type,
|
|
data=b"hello",
|
|
size=5,
|
|
)
|
|
|
|
msg = IncomingMessage(
|
|
sender_id="u1",
|
|
channel_id="c1",
|
|
content="read",
|
|
attachments=[
|
|
Attachment(
|
|
name="note.txt",
|
|
mime_type="text/plain",
|
|
url="https://example.test/note.txt",
|
|
)
|
|
],
|
|
)
|
|
|
|
result = await _ingest_channel_message_attachments(channel=ResolvingChannel(), msg=msg)
|
|
|
|
assert result.text == "read"
|
|
assert result.failures == []
|
|
assert result.attachments == [
|
|
{
|
|
"name": "note.txt",
|
|
"type": "text/plain",
|
|
"data": base64.b64encode(b"hello").decode("ascii"),
|
|
"_was_staged": True,
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_ingest_honors_strict_admission_config() -> None:
|
|
from types import SimpleNamespace
|
|
|
|
class ResolvingChannel(_FakeChannel):
|
|
channel_id = "test"
|
|
|
|
async def resolve_inbound_attachment(self, attachment: Attachment) -> Attachment:
|
|
return Attachment(
|
|
name=attachment.name,
|
|
mime_type=attachment.mime_type,
|
|
data=b"\x00\x01binary",
|
|
size=8,
|
|
)
|
|
|
|
msg = IncomingMessage(
|
|
sender_id="u1",
|
|
channel_id="c1",
|
|
content="read",
|
|
attachments=[
|
|
Attachment(
|
|
name="x.bin",
|
|
mime_type="application/x-unknown",
|
|
url="https://example.test/x.bin",
|
|
)
|
|
],
|
|
)
|
|
strict = SimpleNamespace(attachments=SimpleNamespace(accept_opaque=False))
|
|
|
|
result = await _ingest_channel_message_attachments(
|
|
channel=ResolvingChannel(), msg=msg, config=strict
|
|
)
|
|
|
|
assert result.attachments == []
|
|
assert result.failures[0].reason == "unsupported_mime"
|
|
assert "[attachment unavailable: x.bin: unsupported_mime]" in result.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_ingest_honors_opaque_byte_cap_config() -> None:
|
|
from types import SimpleNamespace
|
|
|
|
class ResolvingChannel(_FakeChannel):
|
|
channel_id = "test"
|
|
|
|
async def resolve_inbound_attachment(self, attachment: Attachment) -> Attachment:
|
|
return Attachment(
|
|
name=attachment.name,
|
|
mime_type=attachment.mime_type,
|
|
data=b"\x00" + b"a" * 4096,
|
|
size=4097,
|
|
)
|
|
|
|
msg = IncomingMessage(
|
|
sender_id="u1",
|
|
channel_id="c1",
|
|
content="read",
|
|
attachments=[
|
|
Attachment(
|
|
name="x.bin",
|
|
mime_type="application/x-unknown",
|
|
url="https://example.test/x.bin",
|
|
)
|
|
],
|
|
)
|
|
capped = SimpleNamespace(
|
|
attachments=SimpleNamespace(accept_opaque=True, opaque_max_bytes=1024)
|
|
)
|
|
|
|
result = await _ingest_channel_message_attachments(
|
|
channel=ResolvingChannel(), msg=msg, config=capped
|
|
)
|
|
|
|
assert result.attachments == []
|
|
assert result.failures[0].reason == "oversize"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_ingest_hard_rejects_aggregate_attachment_cap() -> None:
|
|
one_pdf = _exact_pdf(MAX_TOTAL_ATTACHMENT_BYTES // 3 + 1)
|
|
assert len(one_pdf) < MAX_STAGED_PDF_BYTES
|
|
|
|
class ResolvingChannel(_FakeChannel):
|
|
channel_id = "test"
|
|
|
|
async def resolve_inbound_attachment(self, attachment: Attachment) -> Attachment:
|
|
return Attachment(
|
|
name=attachment.name,
|
|
mime_type="application/pdf",
|
|
data=one_pdf,
|
|
size=len(one_pdf),
|
|
)
|
|
|
|
msg = IncomingMessage(
|
|
sender_id="u1",
|
|
channel_id="c1",
|
|
content="read",
|
|
attachments=[
|
|
Attachment(
|
|
name=f"{index}.pdf",
|
|
mime_type="application/pdf",
|
|
url=f"https://example.test/{index}.pdf",
|
|
)
|
|
for index in range(3)
|
|
],
|
|
)
|
|
|
|
with pytest.raises(AttachmentTotalTooLargeError, match="total raw bytes"):
|
|
await _ingest_channel_message_attachments(channel=ResolvingChannel(), msg=msg)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_batch_turn_passes_normalized_attachments() -> None:
|
|
class RecordingTurnRunner:
|
|
def __init__(self) -> None:
|
|
self.calls: list[dict] = []
|
|
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
self.calls.append(kwargs)
|
|
yield DoneEvent()
|
|
|
|
runner = RecordingTurnRunner()
|
|
attachment = {
|
|
"type": "text/plain",
|
|
"name": "note.txt",
|
|
"data": base64.b64encode(b"hello").decode("ascii"),
|
|
}
|
|
|
|
await _run_turn_batch_path(
|
|
_FakeChannel(),
|
|
runner,
|
|
_message(),
|
|
"agent:main:channel-attachment",
|
|
_tool_ctx(),
|
|
_FakeEventBridge(),
|
|
None,
|
|
SimpleNamespace(
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
),
|
|
[attachment],
|
|
)
|
|
|
|
assert runner.calls[0]["attachments"] == [attachment]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_debounce_channel_turn_rejects_aggregate_cap_before_runtime_start() -> None:
|
|
one_pdf = _exact_pdf(MAX_TOTAL_ATTACHMENT_BYTES // 3 + 1)
|
|
assert len(one_pdf) < MAX_STAGED_PDF_BYTES
|
|
|
|
class ResolvingChannel(_FakeChannel):
|
|
channel_id = "test"
|
|
|
|
async def resolve_inbound_attachment(self, attachment: Attachment) -> Attachment:
|
|
return Attachment(
|
|
name=attachment.name,
|
|
mime_type="application/pdf",
|
|
data=one_pdf,
|
|
size=len(one_pdf),
|
|
)
|
|
|
|
class FakeSessionManager:
|
|
def __init__(self) -> None:
|
|
self.delivery_contexts: list[tuple[str, str]] = []
|
|
self.entries: list[dict[str, str]] = []
|
|
|
|
async def get_or_create(self, key: str, **kwargs):
|
|
return SimpleNamespace(session_key=key, **kwargs), True
|
|
|
|
async def update(self, key: str, **kwargs) -> None:
|
|
self.delivery_contexts.append((key, kwargs.get("last_channel") or ""))
|
|
|
|
async def append_message(self, key: str, role: str, content: str):
|
|
self.entries.append({"role": role, "content": content})
|
|
return SimpleNamespace(content=content)
|
|
|
|
async def read_transcript(self, key: str):
|
|
return list(self.entries)
|
|
|
|
class FakeTaskRuntime:
|
|
def __init__(self) -> None:
|
|
self.enqueue_calls: list[dict] = []
|
|
|
|
async def enqueue(self, envelope, message: str, **kwargs):
|
|
self.enqueue_calls.append({"message": message, **kwargs})
|
|
return SimpleNamespace(task_id="t1")
|
|
|
|
msg = IncomingMessage(
|
|
sender_id="u1",
|
|
channel_id="c1",
|
|
content="read",
|
|
attachments=[
|
|
Attachment(
|
|
name=f"{index}.pdf",
|
|
mime_type="application/pdf",
|
|
url=f"https://example.test/{index}.pdf",
|
|
)
|
|
for index in range(3)
|
|
],
|
|
)
|
|
runtime = FakeTaskRuntime()
|
|
manager = FakeSessionManager()
|
|
|
|
with pytest.raises(AttachmentTotalTooLargeError):
|
|
await _dispatch_combined_message_after_debounce(
|
|
ResolvingChannel(),
|
|
SimpleNamespace(message=msg, raw_content="read", coalesced_count=1),
|
|
SimpleNamespace(),
|
|
manager,
|
|
"agent:main:matrix:direct:u1",
|
|
"matrix",
|
|
runtime,
|
|
SimpleNamespace(),
|
|
)
|
|
|
|
assert runtime.enqueue_calls == []
|
|
assert manager.entries == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_streaming_turn_uses_agent_registry_model() -> None:
|
|
class StreamingChannel(_FakeChannel):
|
|
async def send_streaming(self, chunks, **kwargs):
|
|
async for _ in chunks:
|
|
pass
|
|
|
|
class RecordingTurnRunner:
|
|
def __init__(self) -> None:
|
|
self.calls: list[dict] = []
|
|
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
self.calls.append(kwargs)
|
|
yield DoneEvent()
|
|
|
|
runner = RecordingTurnRunner()
|
|
config = GatewayConfig(
|
|
agents=[AgentEntryConfig(id="ops", model="agent/default")],
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
)
|
|
|
|
await _run_turn_with_streaming(
|
|
StreamingChannel(),
|
|
runner,
|
|
_message(),
|
|
"agent:ops:channel-test",
|
|
_FakeEventBridge(),
|
|
None,
|
|
config,
|
|
)
|
|
|
|
assert runner.calls[0]["model"] == "agent/default"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_streaming_turn_passes_normalized_attachments() -> None:
|
|
class StreamingChannel(_FakeChannel):
|
|
async def send_streaming(self, chunks, **kwargs):
|
|
async for _ in chunks:
|
|
pass
|
|
|
|
class RecordingTurnRunner:
|
|
def __init__(self) -> None:
|
|
self.calls: list[dict] = []
|
|
|
|
async def run(self, message: str, session_key: str, **kwargs):
|
|
self.calls.append(kwargs)
|
|
yield DoneEvent()
|
|
|
|
runner = RecordingTurnRunner()
|
|
attachment = {
|
|
"type": "text/plain",
|
|
"name": "note.txt",
|
|
"data": base64.b64encode(b"hello").decode("ascii"),
|
|
}
|
|
|
|
await _run_turn_with_streaming(
|
|
StreamingChannel(),
|
|
runner,
|
|
_message(),
|
|
"agent:main:channel-stream-attachment",
|
|
_FakeEventBridge(),
|
|
None,
|
|
SimpleNamespace(
|
|
agent_stream_heartbeat_interval_seconds=0.0,
|
|
agent_stream_idle_timeout_seconds=1.0,
|
|
),
|
|
attachments=[attachment],
|
|
)
|
|
|
|
assert runner.calls[0]["attachments"] == [attachment]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_debounce_channel_turn_honors_attachment_persistence_config(tmp_path) -> None:
|
|
class RecordingLock:
|
|
def __init__(self) -> None:
|
|
self.in_lock = False
|
|
|
|
def locked(self) -> bool:
|
|
return self.in_lock
|
|
|
|
async def __aenter__(self):
|
|
self.in_lock = True
|
|
|
|
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
self.in_lock = False
|
|
|
|
lock = RecordingLock()
|
|
|
|
class ResolvingChannel(_FakeChannel):
|
|
channel_id = "test"
|
|
|
|
async def resolve_inbound_attachment(self, attachment: Attachment) -> Attachment:
|
|
assert lock.in_lock is False
|
|
return Attachment(
|
|
name=attachment.name,
|
|
mime_type=attachment.mime_type,
|
|
data=b"%PDF-1.4\nbody\n",
|
|
)
|
|
|
|
class FakeSessionManager:
|
|
def __init__(self) -> None:
|
|
self.entries: list[dict[str, str]] = []
|
|
|
|
async def get_or_create(self, key: str, **kwargs):
|
|
return SimpleNamespace(session_key=key, **kwargs), True
|
|
|
|
async def update(self, key: str, **kwargs) -> None:
|
|
pass
|
|
|
|
async def append_message(self, key: str, role: str, content: str):
|
|
entry = {"role": role, "content": content}
|
|
self.entries.append(entry)
|
|
return SimpleNamespace(content=content)
|
|
|
|
async def read_transcript(self, key: str):
|
|
return list(self.entries)
|
|
|
|
class FakeTaskRuntime:
|
|
def __init__(self) -> None:
|
|
self.enqueue_calls: list[dict] = []
|
|
|
|
async def enqueue(self, envelope, message: str, **kwargs):
|
|
self.enqueue_calls.append({"message": message, **kwargs})
|
|
return SimpleNamespace(task_id="t1")
|
|
|
|
async def wait(self, task_id: str):
|
|
return SimpleNamespace(status="succeeded")
|
|
|
|
class FakeTurnRunner:
|
|
def _get_session_lock(self, key: str):
|
|
return lock
|
|
|
|
msg = IncomingMessage(
|
|
sender_id="u1",
|
|
channel_id="c1",
|
|
content="read this",
|
|
attachments=[Attachment(name="doc.pdf", mime_type="application/pdf", url="mxc://doc")],
|
|
)
|
|
runtime = FakeTaskRuntime()
|
|
session_manager = FakeSessionManager()
|
|
config = SimpleNamespace(
|
|
attachments=SimpleNamespace(
|
|
persist_transcripts=False,
|
|
media_root=str(tmp_path),
|
|
transcript_disk_budget_bytes=1024,
|
|
)
|
|
)
|
|
|
|
await _dispatch_combined_message_after_debounce(
|
|
ResolvingChannel(),
|
|
SimpleNamespace(message=msg, raw_content="read this", coalesced_count=1),
|
|
FakeTurnRunner(),
|
|
session_manager,
|
|
"agent:main:matrix:direct:u1",
|
|
"matrix",
|
|
runtime,
|
|
config,
|
|
)
|
|
|
|
persisted = json.loads(session_manager.entries[-1]["content"])
|
|
assert persisted["attachments"][0] == {
|
|
"name": "doc.pdf",
|
|
"mime": "application/pdf",
|
|
"size": len(b"%PDF-1.4\nbody\n"),
|
|
"missing_reason": "attachment persistence disabled",
|
|
}
|
|
assert "sha256_ref" not in persisted["attachments"][0]
|
|
assert not (tmp_path / "transcripts").exists()
|
|
assert runtime.enqueue_calls[0]["attachments"][0]["_was_staged"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_reply_delivers_transcript_artifact_with_adapter_upload(tmp_path) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
ref = store.publish_bytes(
|
|
b"\x89PNG\r\n\x1a\nimage bytes",
|
|
session_id="session-1",
|
|
session_key="agent:main:feishu:direct:u1",
|
|
name="思考快与慢_信息图.png",
|
|
mime="image/png",
|
|
source="publish_artifact",
|
|
)
|
|
|
|
class FileUploadingChannel(_FakeChannel):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.files: list[tuple[str, str]] = []
|
|
|
|
async def send_file(self, chat_id: str, file_path: str) -> None:
|
|
assert Path(file_path).is_file()
|
|
self.files.append((chat_id, Path(file_path).name))
|
|
|
|
class FakeTaskRuntime:
|
|
async def wait(self, task_id: str):
|
|
return SimpleNamespace(status="succeeded")
|
|
|
|
class FakeSessionManager:
|
|
async def read_transcript(self, key: str):
|
|
return [
|
|
{"role": "user", "content": "create image"},
|
|
{
|
|
"role": "assistant",
|
|
"content": json.dumps(
|
|
{
|
|
"text": "做好了,点击上方按钮下载。",
|
|
"artifacts": [ref.to_dict()],
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
},
|
|
]
|
|
|
|
channel = FileUploadingChannel()
|
|
config = SimpleNamespace(attachments=SimpleNamespace(media_root=str(tmp_path)))
|
|
|
|
await _deliver_runtime_channel_reply(
|
|
channel=channel,
|
|
task_runtime=FakeTaskRuntime(),
|
|
session_manager=FakeSessionManager(),
|
|
session_key="agent:main:feishu:direct:u1",
|
|
task_id="task-1",
|
|
route_envelope=SimpleNamespace(reply_target=None),
|
|
inbound=_message(),
|
|
transcript_watermark=1,
|
|
config=config,
|
|
)
|
|
|
|
assert channel.sent[-1].content == "做好了,点击上方按钮下载。"
|
|
assert channel.files == [("c1", "思考快与慢_信息图.png")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_reply_delivers_file_artifact_with_adapter_upload(tmp_path) -> None:
|
|
store = ArtifactStore(tmp_path)
|
|
ref = store.publish_bytes(
|
|
b"%PDF-1.4\nreport",
|
|
session_id="session-1",
|
|
session_key="agent:main:feishu:direct:u1",
|
|
name="report.pdf",
|
|
mime="application/pdf",
|
|
source="publish_artifact",
|
|
)
|
|
|
|
class FileUploadingChannel(_FakeChannel):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.files: list[tuple[str, str]] = []
|
|
|
|
async def send_file(self, chat_id: str, file_path: str) -> None:
|
|
assert Path(file_path).is_file()
|
|
self.files.append((chat_id, Path(file_path).name))
|
|
|
|
class FakeTaskRuntime:
|
|
async def wait(self, task_id: str):
|
|
return SimpleNamespace(status="succeeded")
|
|
|
|
class FakeSessionManager:
|
|
async def read_transcript(self, key: str):
|
|
return [
|
|
{"role": "user", "content": "make report"},
|
|
{
|
|
"role": "assistant",
|
|
"content": json.dumps(
|
|
{
|
|
"text": "报告已生成。",
|
|
"artifacts": [ref.to_dict()],
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
},
|
|
]
|
|
|
|
channel = FileUploadingChannel()
|
|
config = SimpleNamespace(attachments=SimpleNamespace(media_root=str(tmp_path)))
|
|
|
|
await _deliver_runtime_channel_reply(
|
|
channel=channel,
|
|
task_runtime=FakeTaskRuntime(),
|
|
session_manager=FakeSessionManager(),
|
|
session_key="agent:main:feishu:direct:u1",
|
|
task_id="task-1",
|
|
route_envelope=SimpleNamespace(reply_target=None),
|
|
inbound=_message(),
|
|
transcript_watermark=1,
|
|
config=config,
|
|
)
|
|
|
|
assert channel.sent[-1].content == "报告已生成。"
|
|
assert channel.files == [("c1", "report.pdf")]
|
|
|
|
|
|
# ── Stream relay coalescing + per-event fallback ────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_channel_stream_relay_coalesces_consecutive_deltas() -> None:
|
|
"""Consecutive text deltas are batched into a single chunk under the
|
|
char threshold once the window expires.
|
|
"""
|
|
|
|
class StreamingChannel:
|
|
def __init__(self) -> None:
|
|
self.chunks: list[str] = []
|
|
|
|
async def send_streaming(self, chunks, **kwargs):
|
|
async for chunk in chunks:
|
|
self.chunks.append(chunk)
|
|
|
|
class FakeTaskRuntime:
|
|
async def enqueue(self, envelope, message: str, *, stream_event_sink=None):
|
|
return None
|
|
|
|
config = SimpleNamespace(
|
|
task_runtime=SimpleNamespace(
|
|
stream_relay_coalesce_ms=50.0,
|
|
stream_relay_coalesce_chars=256,
|
|
),
|
|
)
|
|
channel = StreamingChannel()
|
|
relay = _RuntimeChannelStreamRelay.maybe_start(
|
|
channel,
|
|
_message(),
|
|
FakeTaskRuntime(),
|
|
config,
|
|
)
|
|
|
|
assert relay is not None
|
|
|
|
# Push four small deltas in quick succession then close. The relay
|
|
# must coalesce them rather than yield four separate chunks.
|
|
await relay.emit(TextDeltaEvent(text="hel"))
|
|
await relay.emit(TextDeltaEvent(text="lo "))
|
|
await relay.emit(TextDeltaEvent(text="wor"))
|
|
await relay.emit(TextDeltaEvent(text="ld"))
|
|
await relay.close()
|
|
|
|
full_text = "".join(channel.chunks)
|
|
assert full_text == "hello world"
|
|
# Coalescing should land them in a single chunk; allow up to two chunks
|
|
# in case scheduler latency split the batch in half.
|
|
assert len(channel.chunks) <= 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_channel_stream_relay_coalesces_at_char_threshold() -> None:
|
|
"""A single delta exceeding the char threshold yields immediately."""
|
|
|
|
class StreamingChannel:
|
|
def __init__(self) -> None:
|
|
self.chunks: list[str] = []
|
|
|
|
async def send_streaming(self, chunks, **kwargs):
|
|
async for chunk in chunks:
|
|
self.chunks.append(chunk)
|
|
|
|
class FakeTaskRuntime:
|
|
async def enqueue(self, envelope, message: str, *, stream_event_sink=None):
|
|
return None
|
|
|
|
config = SimpleNamespace(
|
|
task_runtime=SimpleNamespace(
|
|
stream_relay_coalesce_ms=10_000.0, # very long window
|
|
stream_relay_coalesce_chars=8,
|
|
),
|
|
)
|
|
channel = StreamingChannel()
|
|
relay = _RuntimeChannelStreamRelay.maybe_start(
|
|
channel,
|
|
_message(),
|
|
FakeTaskRuntime(),
|
|
config,
|
|
)
|
|
|
|
assert relay is not None
|
|
|
|
# Push enough characters to cross the char threshold without waiting
|
|
# for the time window. The relay must yield without delay.
|
|
for _ in range(4):
|
|
await relay.emit(TextDeltaEvent(text="abcd"))
|
|
await relay.close()
|
|
|
|
assert "".join(channel.chunks) == "abcdabcdabcdabcd"
|
|
# First chunk must have crossed the 8-char threshold.
|
|
assert len(channel.chunks[0]) >= 8
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_channel_stream_relay_falls_back_on_mid_stream_failure() -> None:
|
|
"""When send_streaming raises mid-stream, the relay flushes the
|
|
not-yet-delivered chunks via channel.send so the user still sees the
|
|
rest of the reply.
|
|
"""
|
|
|
|
class FailingStreamingChannel(_FakeChannel):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.delivered_chunks: list[str] = []
|
|
|
|
async def send_streaming(self, chunks, **kwargs):
|
|
count = 0
|
|
async for chunk in chunks:
|
|
self.delivered_chunks.append(chunk)
|
|
count += 1
|
|
if count == 1:
|
|
raise RuntimeError("network blip")
|
|
|
|
class FakeTaskRuntime:
|
|
async def enqueue(self, envelope, message: str, *, stream_event_sink=None):
|
|
return None
|
|
|
|
config = SimpleNamespace(
|
|
task_runtime=SimpleNamespace(
|
|
stream_relay_coalesce_ms=0.0,
|
|
stream_relay_coalesce_chars=0,
|
|
),
|
|
)
|
|
channel = FailingStreamingChannel()
|
|
relay = _RuntimeChannelStreamRelay.maybe_start(
|
|
channel,
|
|
_message(),
|
|
FakeTaskRuntime(),
|
|
config,
|
|
)
|
|
|
|
assert relay is not None
|
|
|
|
await relay.emit(TextDeltaEvent(text="part-one"))
|
|
await relay.emit(TextDeltaEvent(text="part-two"))
|
|
await relay.emit(TextDeltaEvent(text="part-three"))
|
|
await relay.close()
|
|
|
|
# First chunk was consumed before the consumer raised — it appears in
|
|
# the consumer-side delivered list but the relay treats it as
|
|
# not-delivered because the consumer failed to fully process it.
|
|
assert channel.delivered_chunks == ["part-one"]
|
|
# Streaming error recorded.
|
|
assert isinstance(relay.stream_error, Exception)
|
|
# Fallback batch carries every chunk the consumer did not finish
|
|
# processing successfully — including the chunk that crashed it so the
|
|
# user does not lose content.
|
|
assert channel.sent, "fallback channel.send must fire when streaming fails"
|
|
fallback = channel.sent[-1].content
|
|
assert "part-one" in fallback
|
|
assert "part-two" in fallback
|
|
assert "part-three" in fallback
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_channel_stream_relay_no_fallback_on_success() -> None:
|
|
"""Successful streams must not trigger the fallback channel.send."""
|
|
|
|
class StreamingChannel(_FakeChannel):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.chunks: list[str] = []
|
|
|
|
async def send_streaming(self, chunks, **kwargs):
|
|
async for chunk in chunks:
|
|
self.chunks.append(chunk)
|
|
|
|
class FakeTaskRuntime:
|
|
async def enqueue(self, envelope, message: str, *, stream_event_sink=None):
|
|
return None
|
|
|
|
config = SimpleNamespace(
|
|
task_runtime=SimpleNamespace(
|
|
stream_relay_coalesce_ms=0.0,
|
|
stream_relay_coalesce_chars=0,
|
|
),
|
|
)
|
|
channel = StreamingChannel()
|
|
relay = _RuntimeChannelStreamRelay.maybe_start(
|
|
channel,
|
|
_message(),
|
|
FakeTaskRuntime(),
|
|
config,
|
|
)
|
|
|
|
assert relay is not None
|
|
|
|
await relay.emit(TextDeltaEvent(text="hello"))
|
|
await relay.close()
|
|
|
|
assert channel.chunks == ["hello"]
|
|
assert channel.sent == [], "no fallback send on a successful stream"
|
|
assert relay.stream_error is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_channel_stream_relay_disabled_coalescing_yields_each_delta() -> None:
|
|
"""Both window=0 and chars=0 disables coalescing — each delta yields."""
|
|
|
|
class StreamingChannel(_FakeChannel):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.chunks: list[str] = []
|
|
|
|
async def send_streaming(self, chunks, **kwargs):
|
|
async for chunk in chunks:
|
|
self.chunks.append(chunk)
|
|
|
|
class FakeTaskRuntime:
|
|
async def enqueue(self, envelope, message: str, *, stream_event_sink=None):
|
|
return None
|
|
|
|
config = SimpleNamespace(
|
|
task_runtime=SimpleNamespace(
|
|
stream_relay_coalesce_ms=0.0,
|
|
stream_relay_coalesce_chars=0,
|
|
),
|
|
)
|
|
channel = StreamingChannel()
|
|
relay = _RuntimeChannelStreamRelay.maybe_start(
|
|
channel,
|
|
_message(),
|
|
FakeTaskRuntime(),
|
|
config,
|
|
)
|
|
|
|
assert relay is not None
|
|
|
|
for chunk in ("a", "b", "c"):
|
|
await relay.emit(TextDeltaEvent(text=chunk))
|
|
await relay.close()
|
|
|
|
assert channel.chunks == ["a", "b", "c"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_channel_stream_relay_handles_late_failure_gracefully() -> None:
|
|
"""When the failure happens after most chunks delivered, only the
|
|
remaining slice is sent via fallback — already-delivered chunks are
|
|
not duplicated.
|
|
"""
|
|
|
|
class FailingLateChannel(_FakeChannel):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.delivered: list[str] = []
|
|
|
|
async def send_streaming(self, chunks, **kwargs):
|
|
count = 0
|
|
async for chunk in chunks:
|
|
count += 1
|
|
if count == 3:
|
|
raise RuntimeError("very late blip")
|
|
self.delivered.append(chunk)
|
|
|
|
class FakeTaskRuntime:
|
|
async def enqueue(self, envelope, message: str, *, stream_event_sink=None):
|
|
return None
|
|
|
|
config = SimpleNamespace(
|
|
task_runtime=SimpleNamespace(
|
|
stream_relay_coalesce_ms=0.0,
|
|
stream_relay_coalesce_chars=0,
|
|
),
|
|
)
|
|
channel = FailingLateChannel()
|
|
relay = _RuntimeChannelStreamRelay.maybe_start(
|
|
channel,
|
|
_message(),
|
|
FakeTaskRuntime(),
|
|
config,
|
|
)
|
|
|
|
assert relay is not None
|
|
|
|
for chunk in ("alpha", "beta", "gamma", "delta"):
|
|
await relay.emit(TextDeltaEvent(text=chunk))
|
|
await relay.close()
|
|
|
|
# First two chunks reached the consumer (and were appended to delivered);
|
|
# gamma was pulled from the iterator but the consumer raised before
|
|
# appending it; delta never left the relay queue.
|
|
assert channel.delivered == ["alpha", "beta"]
|
|
# Fallback delivers the un-acknowledged slice. The chunk that crashed
|
|
# the consumer (gamma) and the queued tail (delta) must appear so the
|
|
# user does not lose content.
|
|
assert channel.sent, "fallback must fire on late mid-stream failure"
|
|
fallback = channel.sent[-1].content
|
|
assert "gamma" in fallback
|
|
assert "delta" in fallback
|
|
# Successfully-yielded chunks must NOT be duplicated.
|
|
assert "alpha" not in fallback
|
|
assert "beta" not in fallback
|