Files
2026-07-13 12:45:58 +08:00

307 lines
12 KiB
Python

"""UI-Refresh cross-cutting acceptance — the merge gate (UI-REFRESH-VERIFICATION
"Cross-cutting acceptance, end-to-end, via FakeSlack").
One scripted scenario that exercises Phases 1-4 together against the **real** SlackAdapter /
slack_bolt stack driven by FakeSlack (no network, no tokens, no Slack app console):
1. Connect Slack against FakeSlack through the manager's gateway; allow a user; subscribe an
Ops "incident" session to a channel.
2. Post a channel message -> it reaches the session as a structured connector message: the
persisted user message carries a `source` with **resolved** channel/sender names (Phase 2),
the live turn_start carries that same source, and the provider gets framed text WITHOUT
`source`.
3. The agent proposes a tool needing approval; the (Unattended) session routes approvals to the
channel -> FakeSlack receives a Block Kit card; injecting the Approve button click resumes the
turn and a reply posts back to the origin channel.
4. Mute Slack for the session -> a further channel post does NOT wake it (but is still buffered).
5. `GET /v1/sessions/{id}/connections` `attention` == the persona's unconnected recommends count.
State is fully isolated: `COWORKER_STATE_DIR` redirects the SecretStore and the manager's data
dir lives under `tmp_path`, so the machine-global secrets/config are never touched.
"""
from __future__ import annotations
import asyncio
import time
from fastapi.testclient import TestClient
from coworker.interactions import decode
from coworker.providers import (
AssistantTurn,
ModelCapabilities,
ProviderClient,
ToolCall,
)
from coworker.server import create_app
from coworker.server.manager import SessionManager
from coworker.sessions import SessionRecord
SID = "incident"
CHANNEL = "C_OPS"
CHANNEL_NAME = "ops-incidents"
USER = "U1"
SENDER_DISPLAY = "Alice Display"
ALERT = "\U0001f6a8 deploy to prod failed at 14:03"
REPLY = "Acknowledged - investigating the deploy now."
class E2EProvider(ProviderClient):
"""Returns queued turns and records the messages handed to it on each call, so the test can
assert exactly what the provider saw (framed text, never a `source` sidecar)."""
def __init__(self, turns):
self._turns = list(turns)
self.calls: list[list[dict]] = []
def complete(self, *, model, messages, tools=None, **settings):
self.calls.append([dict(m) for m in messages])
return self._turns.pop(0)
def capabilities(self, model):
return ModelCapabilities()
def _tool(name, args, call_id):
return AssistantTurn(tool_calls=[ToolCall(id=call_id, name=name, arguments=args)])
def _text(text):
return AssistantTurn(text=text, finish_reason="stop")
async def _wait_until(predicate, *, timeout: float = 8.0, interval: float = 0.02):
"""Poll `predicate` until it returns truthy (or the timeout elapses); return the last value."""
deadline = time.monotonic() + timeout
val = predicate()
while not val and time.monotonic() < deadline:
await asyncio.sleep(interval)
val = predicate()
return val
def _find_card(outbound):
"""The mirrored approval card = a chat.postMessage carrying Block Kit blocks."""
for o in outbound:
if o["method"] == "chat.postMessage" and o.get("blocks"):
return o
return None
def _approve_button(card):
"""The first action button (`ocw_0` = Approve) of a mirrored approval card."""
for block in card.get("blocks") or []:
if block.get("type") == "actions":
for el in block.get("elements") or []:
if el.get("action_id") == "ocw_0":
return el
return None
def _find_reply(outbound, channel, text):
"""A plain (no-blocks) chat.postMessage = the agent's send_message reply."""
return [
o
for o in outbound
if o["method"] == "chat.postMessage"
and not o.get("blocks")
and o["channel"] == channel
and o["text"] == text
]
async def test_ui_refresh_cross_cutting_e2e(fake_slack, tmp_path, monkeypatch):
# Isolate the SecretStore (machine-global otherwise) so "is slack connected?" is decided only
# by what this test writes; the manager's own data dir lives under tmp_path/.coworker.
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
ws = tmp_path / "ops_ws"
ws.mkdir()
provider = E2EProvider(
[
# turn 1: a write needing approval -> mirrored to the channel as a Block Kit card.
_tool(
"write_file",
{"path": str(ws / "incident-note.txt"), "content": "ok"},
"call_w",
),
# turn 2: reply to the origin channel (pre-allowed so it doesn't ask a second time).
_tool(
"send_message", {"target": f"slack:{CHANNEL}", "text": REPLY}, "call_s"
),
# turn 3: wrap up.
_text("Posted the acknowledgement to the channel."),
]
)
mgr = SessionManager(workspace=tmp_path, provider=provider)
try:
# -- Step 1: connect Slack (real adapter -> FakeSlack via the manager's gateway) ----------
mgr.secrets.put(
"slack:default",
{"bot_token": "xoxb-test", "app_token": "xapp-test", "enabled": True},
)
live = (
await mgr.start_gateway()
) # builds the gateway + connects the SlackAdapter
assert "slack" in live
await fake_slack.wait_socket()
# register the user + channel in FakeSlack so users.info / conversations.info resolve names
fake_slack.add_user(USER, "alice", display_name=SENDER_DISPLAY)
fake_slack.add_channel(CHANNEL, CHANNEL_NAME)
# allow-list: the inbound gate drops unknown senders unless allowed
assert mgr.allow_user("slack", USER)["ok"] is True
# an Ops "incident" session subscribed to the channel, Unattended, approvals -> the channel
mgr.session_store.save(
SessionRecord(
session_id=SID,
workspace=str(ws),
model="gpt-5.5",
mode="interactive",
agent="ops",
)
)
mgr.subscriptions.subscribe(SID, f"slack:{CHANNEL}")
mgr.inbox_routing.set_binding("ops-incidents", channel="slack", target=CHANNEL)
mgr.inbox_routing.set_session_override(SID, "ops-incidents")
mgr.unattended.set(SID, True)
# pre-build the engine + pre-allow the reply tool so the reply (step 3) doesn't itself ask.
engine = mgr.get_engine(SID)
assert engine is not None
engine.permissions.allow_tool_for_session("send_message")
# observe the live turn stream for this session (the "card shows live" assertion).
ws_events: list[dict] = []
async def _client_cb(msg):
ws_events.append(msg)
mgr.register_session_client(SID, _client_cb)
client = TestClient(
create_app(mgr)
) # not entered as a CM -> no second gateway start
# -- Step 2 + 3a: post a channel message; it becomes a structured connector message and the
# agent's approval is mirrored as a Block Kit card --------------------------------------
await fake_slack.inbound(channel=CHANNEL, user=USER, text=ALERT)
card = await _wait_until(lambda: _find_card(fake_slack.outbound()))
assert (
card is not None
), f"approval card never mirrored: {fake_slack.outbound()}"
# (Step 2) persisted user message carries the structured, RESOLVED source sidecar.
msgs = client.get(f"/v1/sessions/{SID}/messages").json()["messages"]
user_msgs = [m for m in msgs if m.get("role") == "user"]
assert user_msgs, msgs
last = user_msgs[-1]
src = last["source"]
assert src["connector"] == "slack"
assert src["kind"] == "channel"
assert src["channel_id"] == CHANNEL
assert (
src["channel_name"] == CHANNEL_NAME
) # resolved via conversations.info (Phase 2)
assert src["sender_id"] == USER
assert src["sender_name"] == SENDER_DISPLAY # resolved via users.info (Phase 2)
assert src["text"] == ALERT # the RAW message (what the card renders)
# the model-facing content is the FRAMED text, not the raw message
assert "subscribed" in last["content"] and last["content"] != ALERT
# (Step 2) the provider saw the framed text and NO source / unknown keys, on every call.
assert provider.calls, "provider was never invoked"
first_users = [m for m in provider.calls[0] if m.get("role") == "user"]
assert first_users
sent = first_users[-1]
assert "source" not in sent
assert "subscribed" in sent["content"]
assert all("source" not in m for call in provider.calls for m in call)
# (Step 2) the live turn_start carried the same resolved source (card shows live).
starts = [e for e in ws_events if e.get("type") == "turn_start"]
assert starts, ws_events
live_src = starts[0]["data"]["source"]
assert live_src["channel_name"] == CHANNEL_NAME
assert live_src["sender_name"] == SENDER_DISPLAY
assert live_src["text"] == ALERT
# (Step 3a) the card is a real Block Kit approval, posted to the origin channel.
assert card["channel"] == CHANNEL
assert "write_file" in (card["text"] or "")
button = _approve_button(card)
assert button is not None and button["value"]
item_id, resolution = decode(button["value"])
assert resolution == "allow"
item = mgr.inbox.get(item_id)
assert item is not None and item.kind == "approval" and item.state == "pending"
# -- Step 3b: inject the Approve click -> turn resumes, reply posts to the origin channel --
await fake_slack.interaction(
channel=CHANNEL,
user=USER,
username="alice",
message_ts=card["ts"],
action_id=button["action_id"],
value=button["value"],
)
replies = await _wait_until(
lambda: _find_reply(fake_slack.outbound(), CHANNEL, REPLY)
)
assert replies, f"reply never posted back: {fake_slack.outbound()}"
assert await _wait_until(lambda: not mgr.is_running(SID))
assert mgr.inbox.get(item_id).state == "resolved"
# -- Step 4: mute Slack for the session -> a further post does NOT wake it, still buffered -
msgcount_before = len(mgr.session_messages(SID))
calls_before = len(provider.calls)
resp = client.post(
f"/v1/sessions/{SID}/connections",
json={"connector": "slack", "enabled": False},
).json()
assert resp["ok"] is True
assert "slack" not in mgr.effective_connectors(SID, "ops")
muted_text = "second alert while muted"
await fake_slack.inbound(channel=CHANNEL, user=USER, text=muted_text)
# the message is buffered for catch-up even though it's not delivered.
assert await _wait_until(
lambda: any(
m["text"] == muted_text
for m in mgr.channel_buffer.recent(f"slack:{CHANNEL}")
)
), "muted message was not buffered"
# the skip-delivery decision is synchronous with the buffering above (no await between);
# a short settle guards against any stray scheduled delivery, then assert nothing woke.
await asyncio.sleep(0.1)
assert len(mgr.session_messages(SID)) == msgcount_before # no new turn/message
assert len(provider.calls) == calls_before # provider not re-invoked
assert not mgr.is_running(SID)
# -- Step 5: attention == the persona's account-unconnected connector recommends ----------
detail = client.get("/v1/personas/ops").json()
unconnected = [
r
for r in detail["recommends"]
if r["kind"] == "connector" and not r["connected"]
]
view = client.get(f"/v1/sessions/{SID}/connections").json()
assert view["attention"] == len(unconnected)
# only slack is account-connected here -> github/datadog/pagerduty remain
assert view["attention"] == 3
assert {r["connector"] for r in view["recommended"]} == {
"github",
"datadog",
"pagerduty",
}
finally:
await mgr.aclose()
# State-dir isolation held: the SecretStore resolved to the tmp_path-scoped path, never the
# machine-global ~/.config/coworker (so this run cannot mutate the real secrets hash).
assert str(tmp_path) in str(mgr.secrets.path)