Files
hermes-agent/evals/slack_stream_wire_contract.py
Teknium 4c287fa7bf test(evals): wire-contract A/B harness for Slack chat.*Stream payloads
Replays the native task-card rail and the text-stream rail through the real
slack_sdk AsyncWebClient against a local aiohttp receiver that enforces the
documented markdown_text/chunks mutual exclusion. Local contract proof, not
Slack live: records outgoing bodies before/after #87743.
2026-09-06 05:34:36 -07:00

132 lines
5.9 KiB
Python

"""Wire-contract A/B for Slack native task-card streams (#87743).
Runs the REAL SlackAdapter.send_native_task_card_progress / stop_native_task_card_progress and
send_draft/_seal_stream paths against a REAL slack_sdk AsyncWebClient whose ``base_url`` points at a
local aiohttp receiver that records every request body. The receiver enforces Slack's documented
mutual-exclusion rule for chat.startStream/appendStream/stopStream (``markdown_text`` and
``chunks`` cannot both be present → ``cannot_provide_both_markdown_text_and_chunks``).
THIS IS WIRE-CONTRACT PROOF, NOT SLACK LIVE: no Slack workspace or token is involved. It proves what
bytes the adapter puts on the wire and that they satisfy the documented contract.
Usage:
<python-with-slack_sdk> evals/slack_stream_wire_contract.py --repo <worktree> --out <json>
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
import time
EXCLUSIVE_METHODS = {"chat.startStream", "chat.appendStream", "chat.stopStream"}
def _make_app(log):
from aiohttp import web
async def handler(request):
method = request.match_info["method"]
ctype = request.headers.get("Content-Type", "")
if "json" in ctype:
body = await request.json()
else:
body = dict(await request.post())
entry = {"method": method, "content_type": ctype, "body": body}
log.append(entry)
if method in EXCLUSIVE_METHODS and "markdown_text" in body and "chunks" in body:
entry["response"] = {"ok": False, "error": "cannot_provide_both_markdown_text_and_chunks"}
else:
entry["response"] = {"ok": True, "channel": body.get("channel"), "ts": body.get("ts") or "1700000000.000100"}
return web.json_response(entry["response"])
app = web.Application()
app.router.add_post("/api/{method}", handler)
return app
async def run(repo: str, out: str):
from aiohttp import web
sys.path.insert(0, repo)
for m in list(sys.modules):
if m.startswith(("gateway", "plugins", "tools", "hermes", "agent")):
del sys.modules[m]
from slack_sdk.web.async_client import AsyncWebClient
from gateway.config import PlatformConfig
from plugins.platforms.slack.adapter import SlackAdapter, SLACK_AVAILABLE
assert SLACK_AVAILABLE, "slack_sdk must be importable for a real-client wire probe"
log: list = []
runner = web.AppRunner(_make_app(log))
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
base_url = f"http://127.0.0.1:{port}/api/"
adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-wire-probe"))
class _App: # minimal stand-in for slack_bolt AsyncApp: only .client is read by these paths
client = AsyncWebClient(token="xoxb-wire-probe", base_url=base_url)
adapter._app = _App()
metadata = {"thread_id": "1700000000.000001", "user_id": "U1", "recipient_team_id": "T1", "recipient_user_id": "U1"}
results = {}
# --- Task-card rail (the bug): start + append(chunks) + stop
r1 = await adapter.send_native_task_card_progress(
"C1", [{"id": "call-1", "title": "terminal - ls", "status": "in_progress"}],
metadata=metadata, fallback_text="Hermes is working\n- terminal - ls - running")
r2 = await adapter.send_native_task_card_progress(
"C1", [{"id": "call-1", "title": "terminal - ls", "status": "complete"}],
metadata=metadata, fallback_text="Hermes is working\n- terminal - ls - complete")
await adapter.stop_native_task_card_progress("C1", metadata=metadata)
results["task_card"] = {"first": {"success": r1.success, "error": r1.error},
"second": {"success": r2.success, "error": r2.error}}
# --- Text stream rail (regression): start(markdown_text) + append(markdown_text) + stop(markdown_text)
d1 = await adapter.send_draft("C2", 1, "Hello", metadata=metadata)
d2 = await adapter.send_draft("C2", 1, "Hello world", metadata=metadata)
stream = adapter._active_streams.get("C2")
sealed = await adapter._seal_stream("C2", stream, final_text="Hello world!") if stream else None
results["text_stream"] = {"first": {"success": d1.success, "error": d1.error},
"second": {"success": d2.success, "error": d2.error}, "sealed": sealed}
await runner.cleanup()
violations = [e for e in log if e["method"] in EXCLUSIVE_METHODS and "markdown_text" in e["body"] and "chunks" in e["body"]]
summary = {
"repo": repo, "utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"label": "WIRE-CONTRACT PROOF (local receiver, slack_sdk real client) — NOT Slack live",
"slack_sdk_version": __import__("slack_sdk.version", fromlist=["__version__"]).__version__,
"requests": [{"method": e["method"], "content_type": e["content_type"],
"body_keys": sorted(e["body"].keys()), "body": e["body"], "response": e["response"]} for e in log],
"violations": [{"method": v["method"], "body_keys": sorted(v["body"].keys())} for v in violations],
"results": results,
}
with open(out, "w", encoding="utf-8") as f:
json.dump(summary, f, indent=2)
seq = [(e["method"], sorted(e["body"].keys()), e["response"].get("error")) for e in log]
for s in seq:
print(*s)
print("violations:", len(violations), "| task_card:", results["task_card"], "| text_stream:", results["text_stream"])
return 1 if violations else 0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--repo", required=True)
ap.add_argument("--out", required=True)
a = ap.parse_args()
os.environ.setdefault("HERMES_HOME", os.path.join(os.path.dirname(a.out), "hermes-home-probe"))
os.makedirs(os.environ["HERMES_HOME"], exist_ok=True)
sys.exit(asyncio.run(run(os.path.abspath(a.repo), a.out)))
if __name__ == "__main__":
main()