The destination preflight / refusal path suppressed the whole progress lane for a flat DM regardless of mode, so an operator who WROTE `tool_progress: all` got nothing there (before #108668 they got text bubbles via the fallback). Silence is right only for Slack's tier default, where no text lane was asked for; explicit new/all now routes through the editable text fallback instead. Also hoists resolve_tool_progress into the existing display_config import in _run_agent_display_settings. Test proven red on the salvaged head (adapter.sent == [] with `all`).
143 lines
6.2 KiB
Python
143 lines
6.2 KiB
Python
"""Destination eligibility must precede transport failure and its text fallback."""
|
|
|
|
import queue
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import pytest
|
|
|
|
from gateway.config import PlatformConfig
|
|
from gateway.relay.adapter import RelayAdapter
|
|
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
|
|
from gateway.run_turn_runner import TurnRunner
|
|
from plugins.platforms.slack.adapter import SlackAdapter
|
|
|
|
|
|
class CardTransport:
|
|
def __init__(self, error="relay outbound timed out"):
|
|
self.error = error
|
|
self.frames = []
|
|
|
|
async def send_outbound(self, payload, platform=None):
|
|
self.frames.append(payload)
|
|
if payload["op"] == "task_card":
|
|
return {"success": False, "error": self.error, "ambiguous": True}
|
|
return {"success": True, "message_id": "900.1"}
|
|
|
|
|
|
def make_adapter(kind, monkeypatch, *, error="relay outbound timed out"):
|
|
if kind == "native":
|
|
adapter = SlackAdapter(PlatformConfig(extra={"reply_in_thread": False}))
|
|
client = SimpleNamespace(
|
|
api_call=AsyncMock(side_effect=lambda *a, **kw: {"ts": "900.1"}),
|
|
chat_postMessage=AsyncMock(return_value={"ts": "900.2"}),
|
|
chat_update=AsyncMock(return_value={"ts": "900.2"}),
|
|
)
|
|
monkeypatch.setattr(adapter, "_get_client", Mock(return_value=client))
|
|
adapter._app = None
|
|
return adapter, client
|
|
descriptor = CapabilityDescriptor(
|
|
contract_version=CONTRACT_VERSION, platform="slack", label="Slack",
|
|
max_message_length=39000, supports_draft_streaming=True, supports_edit=True,
|
|
supports_threads=True, markdown_dialect="slack", len_unit="chars",
|
|
emoji="", platform_hint="", pii_safe=False,
|
|
supported_ops=("send", "edit", "task_card", "task_card_stop"),
|
|
)
|
|
transport = CardTransport(error)
|
|
adapter = RelayAdapter(PlatformConfig(), descriptor, transport=transport)
|
|
adapter._platform_by_chat["D1"] = "slack"
|
|
adapter._chat_type_by_chat["D1"] = "dm"
|
|
return adapter, transport
|
|
|
|
|
|
async def run_progress(adapter, metadata, reply_to, after_first=None):
|
|
class Events(queue.Queue):
|
|
def get_nowait(self):
|
|
event = super().get_nowait()
|
|
if event["type"] == "tool.completed" and after_first:
|
|
after_first()
|
|
return event
|
|
|
|
events = Events()
|
|
for event_type in ("tool.started", "tool.completed", "tool.completed"):
|
|
events.put({"type": event_type, "tool_call_id": "call-1", "tool_name": "terminal"})
|
|
ctx = SimpleNamespace(
|
|
source=SimpleNamespace(chat_id="D1"), _progress_reply_to=reply_to,
|
|
_progress_metadata=metadata, _cleanup_progress=False, progress_queue=events,
|
|
tool_progress_enabled=False, # Slack tier default: no text lane was asked for
|
|
_run_still_current=lambda: not events.empty(), agent_holder=[None],
|
|
)
|
|
await TurnRunner(None, ctx)._send_native_task_card_progress(adapter)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("kind,metadata,reply_to,eligible", [
|
|
("native", {}, None, False),
|
|
("native", {"thread_id": "100.1"}, "100.1", False),
|
|
("native", {"thread_id": "100.1"}, "100.2", True),
|
|
("native", {"thread_ts": "100.1"}, None, True),
|
|
("relay", {}, None, False),
|
|
("relay", {"message_id": "100.2", "reply_to_message_id": "100.2"}, None, False),
|
|
("relay", {"thread_id": "100.1"}, "100.2", True),
|
|
("relay", {"thread_ts": "100.1"}, None, True),
|
|
("relay", {}, "100.1", True),
|
|
])
|
|
async def test_destination_preflight_survives_transport_recovery(
|
|
monkeypatch, kind, metadata, reply_to, eligible,
|
|
):
|
|
adapter, io = make_adapter(kind, monkeypatch)
|
|
original_metadata = dict(metadata)
|
|
stop = AsyncMock(wraps=adapter.stop_native_task_card_progress)
|
|
monkeypatch.setattr(adapter, "stop_native_task_card_progress", stop)
|
|
|
|
await run_progress(adapter, metadata, reply_to, lambda: setattr(adapter, "_app", object()))
|
|
|
|
stop.assert_awaited_once_with("D1", reply_to=reply_to, metadata=metadata)
|
|
assert metadata == original_metadata
|
|
if kind == "native":
|
|
assert bool(io.chat_postMessage.await_count) is eligible
|
|
assert bool(io.chat_update.await_count) is eligible
|
|
io.api_call.assert_not_awaited() # Disconnected first attempt stays on fallback.
|
|
else:
|
|
ops = [frame["op"] for frame in io.frames]
|
|
assert ops == (["task_card", "send", "edit", "edit", "task_card_stop"]
|
|
if eligible else ["task_card_stop"])
|
|
if eligible:
|
|
card = io.frames[0]
|
|
anchor = card["metadata"].get("thread_id") or card["metadata"].get("thread_ts")
|
|
assert anchor == (metadata.get("thread_id") or metadata.get("thread_ts") or reply_to)
|
|
|
|
# Suppression belongs to this turn, not the adapter: the same chat can host
|
|
# a later real thread, which still gets native publication and finalization.
|
|
if not eligible:
|
|
if kind == "relay":
|
|
io.frames.clear()
|
|
await run_progress(adapter, {"thread_id": "200.1"}, "200.2")
|
|
if kind == "native":
|
|
methods = [call.args[0] for call in io.api_call.await_args_list]
|
|
assert methods == ["chat.startStream", "chat.appendStream", "chat.appendStream",
|
|
"chat.appendStream", "chat.stopStream"]
|
|
assert not adapter._native_task_card_streams
|
|
else:
|
|
assert [frame["op"] for frame in io.frames] == [
|
|
"task_card", "send", "edit", "edit", "task_card_stop",
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("error,suppressed", [
|
|
("slack task_card requires a thread anchor", True),
|
|
("slack task_card requires a thread anchor (Slack streams are thread replies)", True),
|
|
("thread anchor lookup timed out", False),
|
|
("thread target temporarily unavailable", False),
|
|
])
|
|
async def test_only_explicit_destination_refusals_suppress_threaded_fallback(
|
|
monkeypatch, error, suppressed,
|
|
):
|
|
adapter, io = make_adapter("relay", monkeypatch, error=error)
|
|
await run_progress(adapter, {"thread_id": "100.1"}, "100.2")
|
|
assert [frame["op"] for frame in io.frames] == (
|
|
["task_card", "task_card_stop"] if suppressed
|
|
else ["task_card", "send", "edit", "edit", "task_card_stop"]
|
|
)
|