integration_test.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767
  1. #!/usr/bin/env python3
  2. """End-to-end integration test: ClankerSaddle web app + saddle daemon (Part 2).
  3. Drives the web app the way statum.js does (entrypoint state loads plus
  4. action POSTs carrying the auth slot and sealed private blobs) while a
  5. real saddle daemon works on a real git checkout under WORKSPACE/demo,
  6. powered by saddle/test/mock_llm.py with a deterministic script.
  7. Sections:
  8. 1. explore task (bash + comment + done) and dependency gating (v1 loop)
  9. 2. the Part 2 feature tour, driven by `saddle` commands the mock emits:
  10. plan via heredoc, `saddle query` with piped stdin, progress updates,
  11. a multiple-choice block (answered through the web UI), an RFC block
  12. with a markdown body, a `saddle review` banner approved via the UI,
  13. and a quoted user comment delivered to the daemon over /notify
  14. 3. the ingest API surface: bearer-token auth (401/200), typed updates,
  15. the poll endpoint's exactly-once handover
  16. 4. SSE: terminal stream replay + live push (with piped query input)
  17. 5. the stm-markdown renderer shipped in statum.js (via node)
  18. 6. the audit trail in SQLite
  19. Prerequisites (see README.md "Integration testing"):
  20. - the web app running with SPRY_SEED_ADMIN_USERNAME=admin,
  21. SPRY_SEED_ADMIN_PASSWORD=adminpass123, SADDLE_LLM_BASE_URL pointing at
  22. the mock (below), SADDLE_PROJECTS_ROOT=$WORKSPACE
  23. - a saddle daemon reachable at SADDLE
  24. - a git repo with at least two commits at $WORKSPACE/demo
  25. Environment:
  26. WEB web app base URL (default http://localhost:8080)
  27. SADDLE daemon control URL (default http://localhost:7100)
  28. SADDLE_HOST host:port to register (default localhost:7100)
  29. SADDLE_NAME registration name (default saddle-e2e-1)
  30. WORKSPACE demo repo parent dir (default /tmp/clanker-e2e/workspace)
  31. DB_PATH web app SQLite file (for audit assertions)
  32. MOCK mock_llm.py path (default <repo>/../saddle/test/mock_llm.py)
  33. MOCK_PORT local mock port (default 9999)
  34. MOCK_CMD run instead of the local mock (e.g. a podman exec line
  35. that starts the mock inside the saddle container); the
  36. script skips spawning the mock when set
  37. Prints PASS/FAIL lines Statum-style; exits non-zero on any failure.
  38. """
  39. import json
  40. import os
  41. import socket
  42. import sqlite3
  43. import subprocess
  44. import sys
  45. import time
  46. import urllib.error
  47. import urllib.parse
  48. import urllib.request
  49. REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  50. WEB = os.environ.get("WEB", "http://localhost:8080")
  51. SADDLE = os.environ.get("SADDLE", "http://localhost:7100")
  52. SADDLE_HOST = os.environ.get("SADDLE_HOST", "localhost:7100")
  53. SADDLE_NAME = os.environ.get("SADDLE_NAME", "saddle-e2e-1")
  54. WORKSPACE = os.environ.get("WORKSPACE", "/tmp/clanker-e2e/workspace")
  55. DB_PATH = os.environ.get("DB_PATH", os.path.join(WORKSPACE, "..", "web", "clanker-saddle.sqlite"))
  56. MOCK = os.environ.get("MOCK", os.path.join(os.path.dirname(REPO), "saddle", "test", "mock_llm.py"))
  57. MOCK_PORT = os.environ.get("MOCK_PORT", "9999")
  58. MOCK_CMD = os.environ.get("MOCK_CMD", "")
  59. DEMO = os.path.join(WORKSPACE, "demo")
  60. HOSTNAME = socket.gethostname().split(".")[0]
  61. passes = 0
  62. failures = 0
  63. def check(condition, label):
  64. global passes, failures
  65. if condition:
  66. passes += 1
  67. print("PASS %s" % label)
  68. else:
  69. failures += 1
  70. print("FAIL %s" % label)
  71. def http(method, url, data=None, headers=None, timeout=30):
  72. body = None
  73. if data is not None:
  74. body = "&".join("%s=%s" % (k, urllib.parse.quote(str(v))) for k, v in data.items()).encode()
  75. request = urllib.request.Request(WEB + url if url.startswith("/") else url,
  76. data=body, method=method)
  77. if data is not None:
  78. request.add_header("Content-Type", "application/x-www-form-urlencoded")
  79. for key, value in (headers or {}).items():
  80. request.add_header(key, value)
  81. try:
  82. with urllib.request.urlopen(request, timeout=timeout) as response:
  83. return response.status, response.read().decode()
  84. except urllib.error.HTTPError as error:
  85. return error.code, error.read().decode()
  86. def directives(uri, auth=None):
  87. headers = {}
  88. if auth:
  89. headers["X-Statum-Slot"] = "key=%s; as_at=%s" % (auth["key"], auth["as_at"])
  90. status, body = http("GET", "/_statum/entrypoint?uri=" + urllib.parse.quote(uri.split("?")[0]),
  91. headers=headers)
  92. if status != 200:
  93. raise SystemExit("entrypoint %s -> HTTP %d: %s" % (uri, status, body[:300]))
  94. return json.loads(body)
  95. def slot_public(auth, uri, type_name):
  96. for directive in directives(uri, auth):
  97. if directive["type"] == "set":
  98. content = json.loads(directive["snapshot_frame"]["content"])
  99. if content["slot"]["type"] == type_name:
  100. return content["public"]
  101. raise SystemExit("no %s slot in entrypoint state for %s" % (type_name, uri))
  102. def act(auth, action, fields):
  103. headers = {"X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])}
  104. if action.get("private"):
  105. headers["X-Statum-Private"] = action["private"]
  106. status, body = http(action.get("method", "POST"), action["uri"], fields, headers)
  107. if status != 200:
  108. raise SystemExit("action %s -> HTTP %d: %s" % (action["uri"], status, body[:300]))
  109. return json.loads(body)
  110. def login(username, password):
  111. frames = directives("/login")
  112. content = json.loads(frames[0]["snapshot_frame"]["content"])
  113. action = content["public"]["login"]
  114. status, body = http(action.get("method", "POST"), action["uri"],
  115. {"username": username, "password": password})
  116. if status != 200:
  117. raise SystemExit("login -> HTTP %d: %s" % (status, body[:300]))
  118. for directive in json.loads(body):
  119. if directive["type"] == "set":
  120. frame = json.loads(directive["snapshot_frame"]["content"])
  121. if frame["slot"]["type"] == "auth":
  122. return {"key": frame["slot"]["key"], "as_at": frame["as_at"]}
  123. raise SystemExit("login response carried no auth slot: " + body[:300])
  124. def saddle_status():
  125. try:
  126. status, body = http("GET", SADDLE + "/status", timeout=8)
  127. return json.loads(body) if status == 200 else None
  128. except (urllib.error.URLError, OSError):
  129. return None
  130. def wait_for(predicate, timeout=60, what="condition", interval=1.0):
  131. deadline = time.monotonic() + timeout
  132. last = None
  133. while time.monotonic() < deadline:
  134. last = predicate()
  135. if last is not False and last is not None:
  136. return last
  137. time.sleep(interval)
  138. print(" (timeout waiting for %s; last: %s)" % (what, last))
  139. return None
  140. def task_state(auth, task_id):
  141. return slot_public(auth, "/tasks/%d" % task_id, "task")
  142. def wait_task_status(auth, task_id, status, timeout=90):
  143. return wait_for(lambda: task_state(auth, task_id).get("status") == status or False,
  144. timeout=timeout, what="task %d -> %s" % (task_id, status))
  145. def saddle_api(method, path, token, payload=None):
  146. """Raw call against the saddle ingest API; returns (status, json)."""
  147. request = urllib.request.Request(WEB + path,
  148. data=json.dumps(payload or {}).encode(), method=method)
  149. request.add_header("Content-Type", "application/json")
  150. if token is not None:
  151. request.add_header("Authorization", "Bearer " + token)
  152. try:
  153. with urllib.request.urlopen(request, timeout=15) as response:
  154. return response.status, json.loads(response.read().decode() or "{}")
  155. except urllib.error.HTTPError as error:
  156. body = error.read().decode()
  157. try:
  158. return error.code, json.loads(body)
  159. except ValueError:
  160. return error.code, {"raw": body}
  161. def mock_argv():
  162. heredoc = "cat << 'SADDLE_EOF' | %s\n%%s\nSADDLE_EOF"
  163. responses = [
  164. "# Inspecting the checkout\nls -la %s" % DEMO,
  165. "# Checking history\ngit -C %s log --oneline" % DEMO,
  166. 'saddle comment "Inspected the checkout"\nsaddle done "Explored the demo repository"',
  167. "# Starting the follow-up\necho task-two-running",
  168. 'saddle done "Follow-up audit complete"',
  169. # Part 2 feature tour (task 3)
  170. "# First, write the plan\n" + (heredoc % "saddle plan") % (
  171. "# Feature tour plan\n\n1. Query the host\n2. Ask a question\n"
  172. "3. Propose an RFC\n4. Request review"),
  173. "# Confirm the working branch\ngit branch --show-current",
  174. "# Side question about this host\nhostname | saddle query \"What host is this?\"",
  175. "# Report progress\nsaddle progress-update queried the host",
  176. "# Need a decision\n" + (heredoc % 'saddle block question "Which database should we use?" "SQLite" "Postgres" "Flat files"') % (
  177. "# Storage choice\n\nWe need somewhere to keep the audit trail.\n\n"
  178. "- **SQLite** — zero admin\n- **Postgres** — serious scale\n- **Flat files** — yolo"),
  179. "# Proposing the module layout\n" + (heredoc % "saddle block rfc") % (
  180. "# Proposed layout\n\n```text\nsrc/\n api/\n services/\n```\n\n"
  181. "The `api` layer stays thin; services own the domain."),
  182. "# Ready for review\n" + (heredoc % "saddle review") % (
  183. "## Review: feature tour\n\nEverything on the tour is wired up.\n\n"
  184. "- plans render\n- blocks answer\n- reviews approve"),
  185. "# Waiting for any human input before finishing\nsleep 12",
  186. ]
  187. argv = []
  188. for response in responses:
  189. argv += ["-r", response]
  190. argv += [
  191. "--on-answer", "# Human decision received\nsaddle progress-update human input integrated",
  192. "--on-comment", 'saddle comment "Got the human comment"\nsaddle done "Part 2 feature tour complete"',
  193. "--on-query", "The hostname is %s." % HOSTNAME,
  194. ]
  195. return argv
  196. def start_mock():
  197. if MOCK_CMD:
  198. # e.g. MOCK_CMD='podman exec -d clanker-saddle python3
  199. # /usr/libexec/saddle/mock_llm.py' — the same scripted argv is
  200. # appended, so the in-container mock behaves identically.
  201. argv = MOCK_CMD.split() + mock_argv()
  202. else:
  203. argv = [sys.executable, MOCK, "--port", MOCK_PORT] + mock_argv()
  204. return subprocess.Popen(argv, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
  205. def markdown_renderer_checks():
  206. """Exercise the stm-markdown renderer shipped inside statum.js via node."""
  207. status, body = http("GET", "/_statum/resource/statum.js", timeout=15)
  208. if status != 200:
  209. check(False, "statum.js served to the browser")
  210. return
  211. check("stm-markdown" in body, "statum.js ships the stm-markdown directive")
  212. def extract(name, source):
  213. start = source.index("function %s(" % name)
  214. depth = 0
  215. index = source.index("{", start)
  216. end = index
  217. while True:
  218. if source[end] == "{":
  219. depth += 1
  220. elif source[end] == "}":
  221. depth -= 1
  222. if depth == 0:
  223. break
  224. end += 1
  225. return source[start:end + 1]
  226. try:
  227. parts = [extract(name, body)
  228. for name in ("safeMarkdownUrl", "mdLinks", "mdInline", "renderMarkdown")]
  229. except ValueError:
  230. check(False, "statum.js carries the markdown renderer functions")
  231. return
  232. harness = (
  233. "%s\n%s\n%s\n%s\n"
  234. "console.log(renderMarkdown('# Title\\n\\n- **bold** item\\n- `code` item\\n"
  235. "\\n> quoted text\\n\\n```text\\nfenced\\n```'));\n"
  236. "console.log('@@' + renderMarkdown('<script>alert(1)</script> [bad](javascript:alert(2))"
  237. " [ok](https://example.com) <img src=x onerror=alert(3)>'));\n" % tuple(parts)
  238. )
  239. try:
  240. result = subprocess.run(["node", "-e", harness], capture_output=True, text=True, timeout=20)
  241. except (OSError, subprocess.SubprocessError):
  242. check(True, "stm-markdown renderer present (node unavailable, deep check skipped)")
  243. return
  244. rendered, _, xss = result.stdout.partition("@@")
  245. check("<h1>" in rendered and "<li>" in rendered and "<code>" in rendered
  246. and "<blockquote>" in rendered and "<strong>" in rendered,
  247. "stm-markdown renders headings, lists, code, quotes, bold")
  248. check("<script" not in xss and "<img" not in xss
  249. and 'href="javascript:' not in xss and 'href="#"' in xss
  250. and '<a href="https://example.com">ok</a>' in xss,
  251. "stm-markdown escapes raw tags, defangs javascript: links, keeps safe ones")
  252. def main():
  253. mock = start_mock()
  254. time.sleep(2.0)
  255. try:
  256. # A previous suite run (or a crashed orchestrator) can leave the
  257. # daemon parked on a block; /stop releases it so dispatch works.
  258. try:
  259. http("POST", SADDLE + "/stop", timeout=5)
  260. except (urllib.error.URLError, OSError):
  261. pass
  262. auth = login("admin", "adminpass123")
  263. check(True, "login yields an auth slot")
  264. state = slot_public(auth, "/projects", "projects")
  265. act(auth, state["create"], {
  266. "name": "demo",
  267. "git_url": "https://example.com/demo.git",
  268. "default_branch": "main",
  269. "summary": "Integration test repository",
  270. })
  271. check(any(p["name"] == "demo" for p in slot_public(auth, "/projects", "projects")["projects"]),
  272. "project demo created")
  273. state = slot_public(auth, "/tasks", "tasks")
  274. act(auth, state["create"], {
  275. "project_id": "1",
  276. "title": "Explore the demo repository",
  277. "description": "List the checkout and inspect its git history.",
  278. "priority": "3",
  279. })
  280. act(auth, state["create"], {
  281. "project_id": "1",
  282. "title": "Follow-up audit",
  283. "description": "Runs after the explore task completes.",
  284. "priority": "4",
  285. })
  286. act(auth, state["create"], {
  287. "project_id": "1",
  288. "title": "Part 2 feature tour",
  289. "description": "Plan, query, block, RFC, review, comment — the works.",
  290. "priority": "5",
  291. "task_branch": "task/3-feature-tour",
  292. })
  293. titles = [t["title"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]]
  294. check(titles == ["Explore the demo repository", "Follow-up audit", "Part 2 feature tour"],
  295. "three tasks queued in priority order")
  296. tour = task_state(auth, 3)
  297. check(tour["branch_from"] == "main", "branch_from prefilled from the project default branch")
  298. check(tour["task_branch"] == "task/3-feature-tour", "task_branch stored at creation")
  299. detail = task_state(auth, 2)
  300. act(auth, detail["dependency"], {"depends_on_task_id": "1"})
  301. check(wait_for(lambda: any(int(d.get("id", 0)) == 1
  302. for d in task_state(auth, 2).get("dependencies", [])) or False,
  303. timeout=5, what="dependency edge of task 2"),
  304. "task 2 depends on task 1")
  305. state = slot_public(auth, "/saddles", "saddles")
  306. act(auth, state["register"], {"name": SADDLE_NAME, "host": SADDLE_HOST})
  307. registered = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  308. if s["name"] == SADDLE_NAME]
  309. check(len(registered) == 1 and registered[0]["token"].startswith("saddle-"),
  310. "saddle registered at %s with an API token" % SADDLE_HOST)
  311. print("== explore task ==")
  312. left_queue = wait_for(
  313. lambda: (lambda s: s if s != "queued" else False)(task_state(auth, 1).get("status")) or False,
  314. timeout=15, what="task 1 to leave the queue")
  315. check(left_queue is not None, "dispatcher picks task 1 up within 15s (status %s)" % left_queue)
  316. check(wait_for(lambda: (saddle_status() or {}).get("state") in ("working", "blocked", "done") or False,
  317. timeout=15, what="saddle active") is not None,
  318. "saddle daemon receives the task")
  319. check(wait_task_status(auth, 1, "working", timeout=30) is not None,
  320. "task 1 flips to working from daemon status")
  321. check(wait_task_status(auth, 1, "done", timeout=90) is not None, "task 1 completes")
  322. detail = task_state(auth, 1)
  323. check("Explored the demo repository" in (detail.get("summary") or ""),
  324. "task 1 carries the agent's summary")
  325. check(any(c["author"] == "bot" and "Inspected the checkout" in c["body"]
  326. for c in detail.get("comments", [])),
  327. "agent comment projected into task_comments")
  328. check(detail.get("sessions") and detail["sessions"][0].get("outcome") == "done",
  329. "session closed with outcome done")
  330. print("== dependency gating ==")
  331. check(wait_task_status(auth, 2, "done", timeout=90) is not None,
  332. "task 2 (dependent) dispatches and completes after task 1")
  333. print("== part 2 feature tour ==")
  334. check(wait_task_status(auth, 3, "working", timeout=30) is not None,
  335. "task 3 dispatches to the saddle")
  336. # plan via `saddle plan` heredoc
  337. check(wait_for(lambda: "# Feature tour plan" in (task_state(auth, 3).get("plan") or "") or False,
  338. timeout=60, what="task 3 plan") is not None,
  339. "saddle plan markdown lands in the task plan slot")
  340. db = sqlite3.connect(DB_PATH)
  341. stored_plan = db.execute("select plan from tasks where id = 3").fetchone()[0] or ""
  342. check("1. Query the host" in stored_plan and "4. Request review" in stored_plan,
  343. "plan stored in the tasks table verbatim")
  344. db.close()
  345. # `saddle query` with piped stdin
  346. check(wait_for(lambda: any("query" == row[0]
  347. for row in query_commands(3)) or False,
  348. timeout=60, what="query entry") is not None,
  349. "saddle query logged as a typed terminal entry")
  350. # the workspace clone sits on the task's branch
  351. db = sqlite3.connect(DB_PATH)
  352. branch_rows = db.execute(
  353. "select sc.output_text from session_commands sc join sessions s on s.id = sc.session_id"
  354. " where s.task_id = 3 and sc.command_text = 'git branch --show-current'"
  355. ).fetchall()
  356. db.close()
  357. check(branch_rows and (branch_rows[0][0] or "").strip() == "task/3-feature-tour",
  358. "workspace clone checks out the task branch")
  359. db = sqlite3.connect(DB_PATH)
  360. query_rows = db.execute(
  361. "select sc.command_text, sc.output_text, sc.piped_input from session_commands sc"
  362. " join sessions s on s.id = sc.session_id where s.task_id = 3 and sc.type = 'query'"
  363. ).fetchall()
  364. db.close()
  365. check(len(query_rows) == 1 and "What host is this?" in query_rows[0][0],
  366. "query entry carries the question as its command")
  367. check(query_rows and HOSTNAME in (query_rows[0][1] or ""),
  368. "query entry carries the side-LLM answer as output")
  369. check(query_rows and (query_rows[0][2] or "").strip(),
  370. "query entry stores the piped stdin in session_commands.piped_input")
  371. # progress updates
  372. check(wait_for(lambda: any("queried the host" == p["summary"]
  373. for p in task_state(auth, 3).get("progress", [])) or False,
  374. timeout=60, what="first progress update") is not None,
  375. "saddle progress-update appears on the task page")
  376. detail = task_state(auth, 3)
  377. check(detail["progress"][0]["summary"] == "queried the host",
  378. "latest progress update shown first")
  379. # block question with options + markdown body
  380. check(wait_task_status(auth, 3, "blocked", timeout=90) is not None,
  381. "saddle block question flips the task to blocked")
  382. detail = task_state(auth, 3)
  383. open_q = [b for b in detail.get("blocks", [])
  384. if b["block_type"] == "question" and not b.get("answered")]
  385. check(len(open_q) == 1 and "Which database" in open_q[0]["question"],
  386. "block question surfaced in the web UI")
  387. check(open_q and open_q[0]["options"] == ["SQLite", "Postgres", "Flat files"],
  388. "block options stored on the block")
  389. check(open_q and "# Storage choice" in open_q[0]["body"]
  390. and "**SQLite**" in open_q[0]["body"],
  391. "block markdown body stored on the block")
  392. act(auth, open_q[0]["answer_action"], {"option": "Postgres"})
  393. answered = [b for b in task_state(auth, 3)["blocks"]
  394. if b["block_type"] == "question"]
  395. check(any(b.get("answer") == "Human selected: Postgres" for b in answered),
  396. "option answer formatted for the agent")
  397. # the resume window can be brief; the agent continuing past the
  398. # answer is the durable signal (it raises the RFC next)
  399. check(wait_for(lambda: any(b.get("block_type") == "rfc"
  400. for b in task_state(auth, 3).get("blocks", [])) or False,
  401. timeout=90, what="agent continues after the answer") is not None,
  402. "answered question unblocks the agent")
  403. # RFC block with markdown body
  404. check(wait_task_status(auth, 3, "blocked", timeout=90) is not None,
  405. "saddle block rfc flips the task to blocked")
  406. detail = task_state(auth, 3)
  407. open_rfc = [b for b in detail.get("blocks", [])
  408. if b["block_type"] == "rfc" and not b.get("answered")]
  409. check(len(open_rfc) == 1 and "# Proposed layout" in open_rfc[0]["body"],
  410. "RFC markdown body stored on the block")
  411. check(open_rfc and "```text" in open_rfc[0]["body"],
  412. "RFC body keeps fenced code blocks intact")
  413. act(auth, open_rfc[0]["answer_action"], {"answer": "Layout approved as proposed"})
  414. check(any(b.get("answer") == "Layout approved as proposed"
  415. for b in task_state(auth, 3)["blocks"] if b["block_type"] == "rfc"),
  416. "RFC reply recorded verbatim")
  417. # the review banner following proves the agent resumed past the RFC
  418. check(wait_for(lambda: task_state(auth, 3).get("review_pending") or False,
  419. timeout=90, what="agent resumes after the RFC") is not None,
  420. "answered RFC unblocks the agent")
  421. # review banner + approve
  422. check(wait_task_status(auth, 3, "review_requested", timeout=90) is not None,
  423. "saddle review flips the task to review_requested")
  424. detail = task_state(auth, 3)
  425. check(detail["review_pending"] and "Everything on the tour is wired up" in detail["review_summary"],
  426. "review banner data on the task page")
  427. check(detail["review_diff_url"].endswith(
  428. "demo.git/compare/main...task/3-feature-tour"), "review diff link constructed")
  429. dash = slot_public(auth, "/", "dashboard")
  430. check(any(int(r["task_id"]) == 3 and r["block_type"] == "review"
  431. for r in dash["pinned"]),
  432. "dashboard pins the pending review")
  433. review_block = [b for b in detail["blocks"]
  434. if b["block_type"] == "review" and not b.get("answered")]
  435. check(len(review_block) == 1, "review raised as an answerable block")
  436. act(auth, review_block[0]["answer_action"], {"answer": "Human approved the review"})
  437. check(any(b.get("answer") == "Human approved the review"
  438. for b in task_state(auth, 3)["blocks"] if b["block_type"] == "review"),
  439. "review approval recorded")
  440. # single formatting end to end: the daemon's ANSWER entry repeats the
  441. # answer the way the agent read it
  442. check(wait_task_status(auth, 3, "working", timeout=90) is not None,
  443. "task 3 resumes working after the review approval")
  444. db = sqlite3.connect(DB_PATH)
  445. answer_rows = db.execute(
  446. "select sc.command_text from session_commands sc join sessions s on s.id = sc.session_id"
  447. " where s.task_id = 3 and sc.type = 'answer' order by sc.id").fetchall()
  448. db.close()
  449. check(any("Human selected: Postgres" == row[0] for row in answer_rows),
  450. "daemon formats the option answer exactly once")
  451. # quoted user comment while working (terminal highlight flow)
  452. detail = task_state(auth, 3)
  453. act(auth, detail["comment"], {
  454. "body": "The sleep is fine, but flush sooner next time",
  455. "quote": "sleep 12",
  456. "return_to": "/tasks/3/terminal",
  457. })
  458. check(wait_task_status(auth, 3, "done", timeout=90) is not None,
  459. "user comment delivered via /notify; task 3 completes")
  460. detail = task_state(auth, 3)
  461. check("Part 2 feature tour complete" in (detail.get("summary") or ""),
  462. "task 3 summary reflects the post-comment completion")
  463. check(any(c["author"] == "user:admin" and c.get("quote") == "sleep 12"
  464. for c in detail.get("comments", [])),
  465. "quoted user comment stored on the task")
  466. check(any(c["author"] == "bot" and "Got the human comment" in c["body"]
  467. for c in detail.get("comments", [])),
  468. "agent acknowledged the comment in-conversation")
  469. db = sqlite3.connect(DB_PATH)
  470. tour_types = {row[0] for row in db.execute(
  471. "select sc.type from session_commands sc join sessions s on s.id = sc.session_id"
  472. " where s.task_id = 3")}
  473. notified = db.execute(
  474. "select sc.output_text from session_commands sc join sessions s on s.id = sc.session_id"
  475. " where s.task_id = 3 and sc.type = 'user-comment'").fetchall()
  476. db.close()
  477. check({"bash", "comment", "block", "answer", "plan", "progress", "review",
  478. "query", "user-comment"} <= tour_types,
  479. "terminal log carries every part 2 entry type (%s)" % sorted(tour_types))
  480. check(any("sleep 12" in (row[0] or "") for row in notified),
  481. "/notify delivered the comment with its quoted terminal text")
  482. print("== audit trail ==")
  483. db = sqlite3.connect(DB_PATH)
  484. rows = db.execute(
  485. "select command_text, output_text, exit_code from session_commands order by id").fetchall()
  486. commands = {row[0]: row for row in rows}
  487. check(any("ls -la" in c and "hello.py" in (r[1] or "") for c, r in commands.items()),
  488. "ls output captured in session_commands")
  489. check(any("git" in c and "Initial commit" in (r[1] or "") and r[2] == 0
  490. for c, r in commands.items()),
  491. "git log output captured with exit code 0")
  492. per_session = db.execute(
  493. "select session_id, count(*) from session_commands group by session_id order by session_id"
  494. ).fetchall()
  495. check(all(count > 0 for _, count in per_session) and len(per_session) == 3,
  496. "each session carries its own commands (%s)" % per_session)
  497. progress_rows = db.execute(
  498. "select summary from task_progress where task_id = 3 order by id").fetchall()
  499. check([row[0] for row in progress_rows] == ["queried the host", "human input integrated",
  500. "human input integrated"],
  501. "task_progress keeps the full history in order")
  502. db.close()
  503. print("== SSE ==")
  504. db = sqlite3.connect(DB_PATH)
  505. tour_session = db.execute(
  506. "select s.id from sessions s where s.task_id = 3 order by s.id desc limit 1"
  507. ).fetchone()[0]
  508. db.close()
  509. request = urllib.request.Request(WEB + "/terminal-stream/%d" % tour_session)
  510. with urllib.request.urlopen(request, timeout=15) as stream:
  511. check(stream.headers.get("Content-Type", "").startswith("text/event-stream"),
  512. "terminal stream is text/event-stream")
  513. entries = []
  514. saw_piped_input = False
  515. deadline = time.monotonic() + 10
  516. while time.monotonic() < deadline and len(entries) < 120:
  517. if any(e.get("type") == "done" for e in entries):
  518. break
  519. line = stream.readline().decode().strip()
  520. if not line.startswith("data: ") or line == "data: ping":
  521. continue
  522. entry = json.loads(line[6:])
  523. entries.append(entry)
  524. if entry.get("type") == "query" and (entry.get("input") or "").strip():
  525. saw_piped_input = True
  526. check(len(entries) >= 12, "terminal stream replays the backlog (%d entries)" % len(entries))
  527. check(saw_piped_input, "terminal stream carries the query's piped input")
  528. check(any(e.get("type") == "review" for e in entries)
  529. and any(e.get("type") == "user-comment" for e in entries),
  530. "terminal stream carries typed part 2 events")
  531. db = sqlite3.connect(DB_PATH)
  532. db.execute("insert into session_commands (session_id, command_text, output_text,"
  533. " type, started_at) values (?, 'sse-live-check', 'ok', 'command',"
  534. " strftime('%s','now'))", (tour_session,))
  535. db.commit()
  536. db.close()
  537. got_live = False
  538. deadline = time.monotonic() + 6
  539. while time.monotonic() < deadline and not got_live:
  540. line = stream.readline().decode().strip()
  541. if line.startswith("data: ") and "sse-live-check" in line:
  542. got_live = True
  543. check(got_live, "terminal stream pushes new entries within seconds")
  544. pulse = directives("/tasks/3", auth)
  545. check(any(d.get("type") == "subscribe" for d in pulse),
  546. "task page subscribes to the pulse channel for live refresh")
  547. print("== part 2 API surface ==")
  548. db = sqlite3.connect(DB_PATH)
  549. saddles_state = slot_public(auth, "/saddles", "saddles")
  550. act(auth, saddles_state["register"], {"name": "saddle-e2e-api", "host": "localhost:7199"})
  551. api_rows = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  552. if s["name"] == "saddle-e2e-api"]
  553. check(len(api_rows) == 1 and api_rows[0]["token"].startswith("saddle-"),
  554. "registration returns an API token")
  555. api_saddle = int(api_rows[0]["id"])
  556. api_token = api_rows[0]["token"]
  557. tasks_state = slot_public(auth, "/tasks", "tasks")
  558. act(auth, tasks_state["create"], {
  559. "project_id": "1",
  560. "title": "Part 2 API surface",
  561. "description": "Typed blocks, plan, progress, review, quoted comments, SSE",
  562. "plan": "# Initial plan",
  563. "priority": "6",
  564. "task_branch": "task/9-api-surface",
  565. })
  566. api_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  567. if t["title"] == "Part 2 API surface"][0])
  568. detail = task_state(auth, api_task)
  569. check(detail["branch_from"] == "main",
  570. "branch_from prefilled from the project default branch")
  571. check(detail["task_branch"] == "task/9-api-surface", "task_branch stored")
  572. # Dispatch bookkeeping without a daemon: the ingest API is under
  573. # test, so the session and saddle/task state are staged directly.
  574. db.execute("insert into sessions (task_id, saddle_id, started_at)"
  575. " values (?, ?, strftime('%s','now'))", (api_task, api_saddle))
  576. db.execute("update saddles set current_task_id = ?, status = 'idle' where id = ?",
  577. (api_task, api_saddle))
  578. db.execute("update tasks set status = 'working' where id = ?", (api_task,))
  579. db.commit()
  580. api_session = db.execute("select max(id) from sessions").fetchone()[0]
  581. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, None,
  582. {"type": "comment", "session_id": api_session, "data": {"body": "x"}})
  583. check(status == 401, "update without a token is rejected")
  584. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, "saddle-not-a-token",
  585. {"type": "comment", "session_id": api_session, "data": {"body": "x"}})
  586. check(status == 401, "update with an invalid token is rejected")
  587. status, _ = saddle_api("GET", "/api/saddle/%d/poll" % api_saddle, None)
  588. check(status == 401, "poll without a token is rejected")
  589. status, body = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  590. {"type": "comment", "session_id": api_session,
  591. "data": {"body": "Surveyed the module"}})
  592. check(status == 200 and body.get("ok"), "comment update accepted with token")
  593. status, body = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  594. {"type": "block", "session_id": api_session, "data": {
  595. "question": "Which database?",
  596. "type": "question",
  597. "options": ["SQLite", "Postgres"],
  598. "body": "# Pick one"}})
  599. check(status == 200 and body.get("block_id"), "typed block returns block_id")
  600. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  601. {"type": "output", "session_id": api_session, "data": {
  602. "command": "cat notes.md | saddle query 'meaning?'",
  603. "output": "42",
  604. "type": "query",
  605. "input": "# notes\nmeaning of life"}})
  606. check(status == 200, "query output update accepted")
  607. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  608. {"type": "plan", "session_id": api_session,
  609. "data": {"plan": "# Plan\n- migrate schemas"}})
  610. check(status == 200, "plan update accepted")
  611. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  612. {"type": "progress", "session_id": api_session,
  613. "data": {"summary": "Migrated the schemas"}})
  614. check(status == 200, "progress update accepted")
  615. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  616. {"type": "review", "session_id": api_session,
  617. "data": {"summary": "Migration **ready** for review"}})
  618. check(status == 200, "review update accepted")
  619. detail = task_state(auth, api_task)
  620. check(detail["status"] == "review_requested", "review flips the task status")
  621. check([b["options"] for b in detail["blocks"]
  622. if b["block_type"] == "question"] == [["SQLite", "Postgres"]],
  623. "question block carries its options")
  624. check("- migrate schemas" in detail["plan"], "plan visible on the task page")
  625. check(detail["progress"][0]["summary"] == "Migrated the schemas",
  626. "progress history newest-first")
  627. check(detail["review_pending"] and "ready" in detail["review_summary"],
  628. "review banner data on the task page")
  629. check(detail["review_diff_url"].endswith(
  630. "demo.git/compare/main...task/9-api-surface"), "review diff link constructed")
  631. dash = slot_public(auth, "/", "dashboard")
  632. check(any(int(r["task_id"]) == api_task and r["block_type"] == "review"
  633. for r in dash["pinned"]),
  634. "dashboard pins the pending review")
  635. check(any(b["question"] == "Which database?" for b in dash["pinned"]),
  636. "dashboard pins blocks needing answers")
  637. piped = db.execute("select piped_input from session_commands where session_id = ?"
  638. " and type = 'query'", (api_session,)).fetchall()
  639. check(piped and "meaning of life" in (piped[0][0] or ""),
  640. "push API stores the query's piped input too")
  641. question = [b for b in detail["blocks"] if b["block_type"] == "question"][0]
  642. act(auth, question["answer_action"], {"option": "SQLite"})
  643. check([b["answer"] for b in task_state(auth, api_task)["blocks"]
  644. if b["block_type"] == "question"] == ["Human selected: SQLite"],
  645. "option answer formatted for the agent")
  646. act(auth, detail["comment"],
  647. {"body": "Check the migration output", "quote": "ERROR: table demos exists"})
  648. status, body = saddle_api("GET", "/api/saddle/%d/poll" % api_saddle, api_token)
  649. check(any(a["answer"] == "Human selected: SQLite" for a in body.get("answers", [])),
  650. "poll hands over the block answer")
  651. check(any(c["body"] == "Check the migration output" and "ERROR" in c["quote"]
  652. for c in body.get("comments", [])),
  653. "poll hands over the quoted user comment")
  654. status, body = saddle_api("GET", "/api/saddle/%d/poll" % api_saddle, api_token)
  655. check(body.get("answers") == [] and body.get("comments") == [],
  656. "poll delivers answers and comments exactly once")
  657. entry_types = {row[0] for row in db.execute(
  658. "select type from session_commands where session_id = ?", (api_session,))}
  659. check({"comment", "block", "plan", "progress", "review", "user-comment"} <= entry_types,
  660. "conversation events logged as typed terminal entries")
  661. act(auth, detail["cancel"], {"task_id": str(api_task)})
  662. db.close()
  663. print("== stm-markdown ==")
  664. markdown_renderer_checks()
  665. print("== quiesce ==")
  666. check(wait_for(
  667. lambda: all(int(s["current_task_id"] or 0) == 0 and s["status"] == "idle"
  668. for s in slot_public(auth, "/saddles", "saddles")["saddles"]) or False,
  669. timeout=30, what="saddle idle with no current task") is not None,
  670. "saddle freed and idle once every task finished")
  671. finally:
  672. if mock is not None:
  673. mock.terminate()
  674. print("%d passed, %d failed" % (passes, failures))
  675. return 1 if failures else 0
  676. def query_commands(task_id):
  677. db = sqlite3.connect(DB_PATH)
  678. try:
  679. return db.execute(
  680. "select sc.type from session_commands sc join sessions s on s.id = sc.session_id"
  681. " where s.task_id = ? and sc.type = 'query'", (task_id,)).fetchall()
  682. finally:
  683. db.close()
  684. if __name__ == "__main__":
  685. sys.exit(main())