chatui.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. """The advisor chat UI bundle (goal 27, task 2): the /chats list, the
  2. /chats/{id} session page, the dashboard hero composer and the statum
  3. actions under them, driven end-to-end against the scripted LLM
  4. endpoint (the same ScriptedLlm the backend bundle uses). The scenes:
  5. - the pages render: /chats lists the user's chats (title, status
  6. chip, delete action), the dashboard carries the hero composer
  7. - the hero: StartAdvisorChatAction creates the chat titled from the
  8. first line (clamped), records the message, kicks the turn and
  9. navigates to /chats/{id}
  10. - a chat page: the transcript slot carries the user message, the
  11. scripted tool call round (name/arguments/result parsed from the
  12. payload), the parked question with its options, and the send
  13. action is sealed against the chat
  14. - answering: the question card's answer rides SendAdvisorMessage's
  15. answer path -> "Human selected: ..." lands, the turn resumes and
  16. the final assistant reply appears in the slot
  17. - the refresh action: RefreshAdvisorChatAction rebuilds the slot
  18. - suggestions: the card state (title/mandate/mode/priority + the
  19. sealed decide action) renders from the inserted row
  20. - delete: the chat leaves the list and its page state reads as
  21. not-found (id 0)
  22. - privacy: a second user's /chats slot sees none of the first
  23. user's chats and their /chats/{id} slot renders not-found
  24. - the unconfigured dashboard: the hero renders with
  25. advisor_configured false
  26. """
  27. from harness import * # noqa: F401,F403 — the shared wire helpers
  28. import harness # the module itself (the unconfigured scene re-points its globals)
  29. from bundles.advisor import ScriptedLlm
  30. def restart_with_llm(ctx, llm):
  31. """Points the app at `llm` and restarts it (the model_config row
  32. is the configuration source — set_model_config writes it)."""
  33. set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm.port,
  34. advisor_model="scripted")
  35. ctx.app.terminate()
  36. try:
  37. ctx.app.wait(timeout=10)
  38. except subprocess.TimeoutExpired:
  39. ctx.app.kill()
  40. ctx.app = start_app()
  41. ctx.auth = login("admin", "adminpass123")
  42. return ctx.auth
  43. def db_rows(sql, params=()):
  44. conn = db()
  45. try:
  46. return conn.execute(sql, params).fetchall()
  47. finally:
  48. conn.close()
  49. def wait_status(chat_id, status, timeout=30):
  50. def read():
  51. rows = db_rows("select status from advisor_chats where id = ?",
  52. (chat_id,))
  53. return len(rows) == 1 and rows[0][0] == status and status or False
  54. return wait_for(read, timeout=timeout,
  55. what="chat %d -> %s" % (chat_id, status))
  56. def run(ctx):
  57. auth = ctx.auth
  58. print("== chatui: the pages render ==")
  59. chats = slot_public(auth, "/chats", "chats")
  60. check("chats" in chats, "the /chats slot carries the chat rows")
  61. check(chats.get("advisor_configured") is True,
  62. "the /chats slot reports the advisor configured")
  63. home = slot_public(auth, "/", "dashboard")
  64. check("start_advisor" in home, "the dashboard carries the hero action")
  65. home_html = http_get_page(auth, "/")
  66. check("advisor-composer" in home_html and "Ask the advisor" in home_html,
  67. "the dashboard renders the hero composer")
  68. check('stm-attribute.disabled="!dashboard.advisor_configured"'
  69. in home_html,
  70. "the hero composer disables itself on the configured flag")
  71. chats_html = http_get_page(auth, "/chats")
  72. check("Your advisor conversations" in chats_html
  73. and "dashboard" in chats_html,
  74. "the /chats page renders its copy and the empty-state pointer")
  75. print("== chatui: the hero starts a chat ==")
  76. llm = ScriptedLlm([
  77. {"tools": [{"id": "ui-1", "name": "list_projects",
  78. "arguments": "{}"}]},
  79. {"tools": [{"id": "ui-2", "name": "ask_question",
  80. "arguments": json.dumps({
  81. "question": "Which project is the priority?",
  82. "options": ["demo", "later"]})}]},
  83. {"content": "Targeting demo, then."}, # after the answer
  84. ])
  85. llm.serve()
  86. auth = restart_with_llm(ctx, llm)
  87. # A first line well past the clamp: the title is its head, on a
  88. # word boundary, with the ellipsis.
  89. long_opening = ("Please review the whole fleet and tell me what to "
  90. "work on next, with reasoning and references")
  91. home = slot_public(auth, "/", "dashboard")
  92. result = act(auth, home["start_advisor"], {"message": long_opening})
  93. navigations = [d for d in result if d.get("type") == "navigate"]
  94. check(len(navigations) == 1
  95. and navigations[0].get("uri", "").startswith("/chats/"),
  96. "the hero action navigates to the new chat (%s)" % navigations)
  97. chat_id = int(navigations[0]["uri"].rsplit("/", 1)[1])
  98. title = db_rows("select title from advisor_chats where id = ?",
  99. (chat_id,))[0][0]
  100. check(len(title) <= 81 and title.endswith("…"),
  101. "the title is clamped from the first line (%d chars)" % len(title))
  102. check(title.startswith("Please review"),
  103. "the title leads with the message's first words (%s)" % title)
  104. kinds = [r[0] for r in db_rows(
  105. "select kind from advisor_messages where chat_id = ? order by id",
  106. (chat_id,))]
  107. check(kinds == ["user"], "the opening message is recorded as a user row")
  108. print("== chatui: the chat page state (tool round + question) ==")
  109. check(wait_status(chat_id, "awaiting_answer"),
  110. "the scripted turn parks on ask_question")
  111. page = slot_public(auth, "/chats/%d" % chat_id, "chat")
  112. check(page["id"] == chat_id and page["status"] == "awaiting_answer"
  113. and page["status_display"] == "awaiting answer",
  114. "the chat slot carries the chat row and its status label")
  115. check("send" in page and "delete" in page and "refresh" in page,
  116. "the chat slot carries the send/delete/refresh actions")
  117. messages = page["messages"]
  118. check(any(m["kind"] == "user" and long_opening in m["content"]
  119. for m in messages), "the user message rides the slot")
  120. check(any(m["kind"] == "tool" and m.get("tool_name") == "list_projects"
  121. and "projects" in (m.get("result") or "")
  122. for m in messages),
  123. "the tool call renders with name, arguments and result")
  124. questions = [m for m in messages if m.get("is_question")]
  125. check(len(questions) == 1
  126. and questions[0]["content"] == "Which project is the priority?"
  127. and questions[0]["options"] == ["demo", "later"]
  128. and not questions[0].get("answered"),
  129. "the parked question renders with its options, unanswered")
  130. detail_html = http_get_page(auth, "/chats/%d" % chat_id)
  131. check("chat-thread" in detail_html and "chat-composer" in detail_html
  132. and "chat-options" in detail_html,
  133. "the chat page renders the thread, composer and options")
  134. print("== chatui: answering the question via the action ==")
  135. # The question card's option button rides the send action's
  136. # answer path (the same statum.act the page script issues).
  137. result = act(auth, page["send"], {"answer": "demo"})
  138. sets = [d for d in result if d.get("type") == "set"]
  139. check(len(sets) == 1, "the answer action answers with the rebuilt slot")
  140. check(wait_status(chat_id, "idle"), "the answered turn ends idle")
  141. check(any(m["kind"] == "user" and m["content"] == "Human selected: demo"
  142. for m in slot_public(auth, "/chats/%d" % chat_id,
  143. "chat")["messages"]),
  144. 'the option lands as "Human selected: demo" in the slot')
  145. check(wait_for(lambda: [m for m in slot_public(
  146. auth, "/chats/%d" % chat_id, "chat")["messages"]
  147. if m["kind"] == "assistant"
  148. and m["content"] == "Targeting demo, then."] or False,
  149. timeout=15, what="final reply in slot") is not None,
  150. "the resumed turn's reply reaches the slot")
  151. print("== chatui: the composer sends a follow-up ==")
  152. result = act(auth, page["send"], {"message": "thanks, that is all"})
  153. check(wait_status(chat_id, "idle"), "the follow-up turn ends idle")
  154. check(any(m["kind"] == "user" and m["content"] == "thanks, that is all"
  155. for m in slot_public(auth, "/chats/%d" % chat_id,
  156. "chat")["messages"]),
  157. "the composer message lands in the transcript")
  158. check(len(llm.requests) >= 3, "the follow-up reached the LLM")
  159. print("== chatui: the list page and the refresh action ==")
  160. chats = slot_public(auth, "/chats", "chats")
  161. row = [c for c in chats["chats"] if str(c["id"]) == str(chat_id)]
  162. check(len(row) == 1 and row[0]["status"] == "idle"
  163. and "delete" in row[0],
  164. "the list slot carries the chat with its status and delete")
  165. result = act(auth, page["refresh"], {"chat_id": chat_id})
  166. sets = [d for d in result if d.get("type") == "set"]
  167. check(len(sets) == 1, "the refresh action rebuilds the slot")
  168. print("== chatui: a suggestion card renders ==")
  169. llm2 = ScriptedLlm([
  170. {"tools": [{"id": "sug-1", "name": "suggest_task",
  171. "arguments": json.dumps({
  172. "title": "UI-driven task",
  173. "mandate": "Verify the chat UI suggestion card.",
  174. "mode": "test", "project_id": 1, "priority": 3})}]},
  175. {"content": "Suggested."},
  176. ])
  177. llm2.serve()
  178. auth = restart_with_llm(ctx, llm2)
  179. page = slot_public(auth, "/chats/%d" % chat_id, "chat")
  180. act(auth, page["send"], {"message": "suggest one task"})
  181. check(wait_status(chat_id, "idle"), "the suggestion turn ends idle")
  182. check(wait_for(lambda: slot_public(auth, "/chats/%d" % chat_id,
  183. "chat")["suggestions"] or False,
  184. timeout=15, what="suggestion in slot") is not None,
  185. "the suggestion reaches the slot")
  186. card = slot_public(auth, "/chats/%d" % chat_id, "chat")["suggestions"][0]
  187. check(card["kind"] == "task" and card["status"] == "pending"
  188. and card["title"] == "UI-driven task"
  189. and card["body"] == "Verify the chat UI suggestion card."
  190. and card["mode"] == "test" and str(card["priority"]) == "3",
  191. "the card carries the payload fields")
  192. check("decide" in card,
  193. "the pending card carries the sealed decide action")
  194. print("== chatui: per-user privacy ==")
  195. second = "chatui-" + str(int(time.time()))
  196. users_state = slot_public(auth, "/users", "users")
  197. act(auth, users_state["create"], {
  198. "username": second, "email": second + "@example.com",
  199. "forename": "Chat", "surname": "Ui", "date_of_birth": "1990-01-01",
  200. "password": "chatuipass1", "confirm_password": "chatuipass1"})
  201. other = login(second, "chatuipass1")
  202. other_chats = slot_public(other, "/chats", "chats")
  203. check(other_chats["chats"] == [],
  204. "the second user's list is empty (chats are private)")
  205. other_page = slot_public(other, "/chats/%d" % chat_id, "chat")
  206. check(other_page["id"] == 0,
  207. "the second user's chat slot reads not-found (id 0)")
  208. # their delete of someone else's chat: the slot carries no delete
  209. # action (id 0 not-found state), so there is nothing to invoke —
  210. # the row must survive untouched.
  211. check("delete" not in other_page,
  212. "the second user's chat slot carries no delete action")
  213. check(db_rows("select count(*) from advisor_chats where id = ?",
  214. (chat_id,))[0][0] == 1,
  215. "the second user cannot delete the first user's chat")
  216. print("== chatui: delete the chat ==")
  217. own_page = slot_public(auth, "/chats/%d" % chat_id, "chat")
  218. result = act(auth, own_page["delete"], {})
  219. check(any(d.get("type") == "navigate" and d.get("uri") == "/chats"
  220. for d in result), "delete navigates to /chats")
  221. check(db_rows("select count(*) from advisor_chats where id = ?",
  222. (chat_id,))[0][0] == 0, "the chat row is gone")
  223. check(db_rows("select count(*) from advisor_messages where chat_id = ?",
  224. (chat_id,))[0][0] == 0, "the messages cascade")
  225. check(db_rows("select count(*) from advisor_suggestions"
  226. " where chat_id = ?", (chat_id,))[0][0] == 0,
  227. "the suggestions cascade")
  228. gone = slot_public(auth, "/chats/%d" % chat_id, "chat")
  229. check(gone["id"] == 0, "the deleted chat's slot reads not-found")
  230. chats = slot_public(auth, "/chats", "chats")
  231. check(all(c["id"] != chat_id for c in chats["chats"]),
  232. "the deleted chat left the list")
  233. print("== chatui: the unconfigured hero ==")
  234. # The configuration lives in the model_config row, so the honest
  235. # unconfigured scene empties it (both the main and the advisor
  236. # override) — the hand-spawned app below then reads nothing.
  237. set_model_config(base_url="", model="", advisor_base_url="",
  238. advisor_model="")
  239. llm_env = {}
  240. ctx.app.terminate()
  241. try:
  242. ctx.app.wait(timeout=10)
  243. except subprocess.TimeoutExpired:
  244. ctx.app.kill()
  245. saved_web, saved_port = harness.WEB, harness.APP_PORT
  246. harness.APP_PORT = free_port()
  247. harness.WEB = "http://127.0.0.1:%d" % harness.APP_PORT
  248. env = dict(os.environ)
  249. env.update({
  250. "SPRY_DB_PATH": DB_PATH,
  251. "SPRY_SEED_ADMIN_USERNAME": "admin",
  252. "SPRY_SEED_ADMIN_PASSWORD": "adminpass123",
  253. "SADDLE_PROJECTS_ROOT": os.path.join(WORK, "workspace"),
  254. "SADDLE_LLM_BASE_URL": "",
  255. "ADVISOR_LLM_BASE_URL": "",
  256. "LD_LIBRARY_PATH": "",
  257. })
  258. with open(os.path.join(WORK, "app.log"), "a") as log:
  259. ctx.app = subprocess.Popen([APP, str(harness.APP_PORT)], cwd=WORK,
  260. env=env, stdout=log,
  261. stderr=subprocess.STDOUT)
  262. if wait_for(app_ready, timeout=30, what="unconfigured app ready") is None:
  263. raise SystemExit("unconfigured app did not come up")
  264. ctx.auth = login("admin", "adminpass123")
  265. home = slot_public(ctx.auth, "/", "dashboard")
  266. check(home.get("advisor_configured") is False,
  267. "the dashboard reports the advisor unconfigured")
  268. home_html = http_get_page(ctx.auth, "/")
  269. check("advisor is not" in home_html.lower()
  270. or "/models" in home_html,
  271. "the unconfigured hero renders its notice")
  272. unconfigured_chats = slot_public(ctx.auth, "/chats", "chats")
  273. check(unconfigured_chats.get("advisor_configured") is False,
  274. "the /chats slot reports the advisor unconfigured too")
  275. ctx.app.terminate()
  276. try:
  277. ctx.app.wait(timeout=10)
  278. except subprocess.TimeoutExpired:
  279. ctx.app.kill()
  280. harness.WEB, harness.APP_PORT = saved_web, saved_port
  281. # Restore whatever the caller had (other bundles share the env).
  282. for name, value in llm_env.items():
  283. if value is not None:
  284. os.environ[name] = value
  285. llm2.kill()
  286. llm.kill()