feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理 - 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类) - 技能: md-to-office (pandoc + wkhtmltopdf) - 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/ - 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
313
tests/test_ui_refresh_e2e.py
Normal file
313
tests/test_ui_refresh_e2e.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""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
|
||||
assert mgr.set_slack_approval_owner(USER, add=True)["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}")
|
||||
assert mgr.set_inbox_binding(
|
||||
"ops-incidents", channel="slack", target=CHANNEL
|
||||
)["ok"]
|
||||
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 -
|
||||
# The turn's mark_idle fired an auto-title completion (fire-and-forget, on a worker
|
||||
# thread); let it settle so its provider call can't land between the snapshot below
|
||||
# and the "provider not re-invoked" assertion.
|
||||
assert await _wait_until(lambda: SID not in mgr._autotitle_inflight)
|
||||
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)
|
||||
Reference in New Issue
Block a user