advisor_arc.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. """The advisor approvals + completion callback bundle (goal 27, task 3):
  2. the full arc from the dashboard composer to a task's completion landing
  3. back in the chat, against the scripted LLM endpoint. The scenes:
  4. - the arc: dashboard hero -> chat -> the advisor asks a question -> the
  5. option answer lands -> the advisor suggests a task -> approve &
  6. enqueue creates the task QUEUED with creator human, stamps the
  7. suggestion approved with its task_id, and appends the system notice
  8. (the advisor turn kicked by the decision acknowledges it)
  9. - the completion callback: the task flipped terminal (via the app's own
  10. surface - the fake saddle runs it to done) delivers ONE system
  11. message into the chat, stamps delivered_at and wakes the advisor
  12. - never twice: a second pass (and the advisor's own reaction turn)
  13. adds no second delivery
  14. - plain approve: the task lands DRAFT, creator human, nothing queued
  15. - decline: the row is dismissed, nothing is created, the chat is told
  16. - goal approve: a DRAFT goal exists, unqueued, owner of its branch;
  17. the goal suggestion never enqueues anything
  18. - decided rows cannot be decided again (the card's slot loses the
  19. decide action; a replayed decision is refused)
  20. - privacy: another user's decision on someone else's suggestion is
  21. refused and changes nothing
  22. """
  23. from harness import * # noqa: F401,F403 — the shared wire helpers
  24. import harness # the module itself (restart_with_llm re-points its globals)
  25. from bundles.advisor import ScriptedLlm
  26. def db_rows(sql, params=()):
  27. conn = db()
  28. try:
  29. return conn.execute(sql, params).fetchall()
  30. finally:
  31. conn.close()
  32. def wait_status(chat_id, status, timeout=30):
  33. def read():
  34. rows = db_rows("select status from advisor_chats where id = ?",
  35. (chat_id,))
  36. return len(rows) == 1 and rows[0][0] == status and status or False
  37. return wait_for(read, timeout=timeout,
  38. what="chat %d -> %s" % (chat_id, status))
  39. def restart_with_llm(ctx, llm):
  40. """Points the app at `llm` and restarts it (env re-read)."""
  41. set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm.port,
  42. advisor_model="scripted")
  43. ctx.app.terminate()
  44. try:
  45. ctx.app.wait(timeout=10)
  46. except subprocess.TimeoutExpired:
  47. ctx.app.kill()
  48. ctx.app = start_app()
  49. ctx.auth = login("admin", "adminpass123")
  50. return ctx.auth
  51. def run(ctx):
  52. auth = ctx.auth
  53. # The scripted loop: one ask_question, one suggest_task, then plain
  54. # replies for every later turn (the decision and callback kicks).
  55. llm = ScriptedLlm([
  56. {"tools": [{"id": "arc-1", "name": "ask_question",
  57. "arguments": json.dumps({
  58. "question": "Which project should the task target?",
  59. "options": ["demo", "skip it"]})}]},
  60. {"tools": [{"id": "arc-2", "name": "suggest_task",
  61. "arguments": json.dumps({
  62. "title": "Advisor arc task",
  63. "mandate": "Prove the advisor approval arc.",
  64. "mode": "test", "project_id": 1, "priority": 4})}]},
  65. {"content": "Task suggested - approve it in the card when ready."},
  66. {"content": "Noted - the task is queued."},
  67. {"content": "Good - the task finished."},
  68. ])
  69. llm.serve()
  70. auth = restart_with_llm(ctx, llm)
  71. print("== advisor_arc: the dashboard composer opens the chat ==")
  72. home = slot_public(auth, "/", "dashboard")
  73. result = act(auth, home["start_advisor"], {
  74. "message": "I want a small verification task on the demo project"})
  75. navigation = [d for d in result if d.get("type") == "navigate"][0]
  76. chat_id = int(navigation["uri"].rsplit("/", 1)[1])
  77. check(wait_status(chat_id, "awaiting_answer"),
  78. "the advisor asks its question and parks the chat")
  79. page = slot_public(auth, "/chats/%d" % chat_id, "chat")
  80. questions = [m for m in page["messages"] if m.get("is_question")]
  81. check(len(questions) == 1 and questions[0]["options"] == ["demo", "skip it"],
  82. "the question card renders with its options")
  83. print("== advisor_arc: the answer resumes into a suggestion ==")
  84. result = act(auth, page["send"], {"answer": "demo"})
  85. check(wait_status(chat_id, "idle"), "the answered turn completes")
  86. check(wait_for(lambda: slot_public(auth, "/chats/%d" % chat_id,
  87. "chat")["suggestions"] or False,
  88. timeout=15, what="suggestion card") is not None,
  89. "the suggested task reaches the card")
  90. page = slot_public(auth, "/chats/%d" % chat_id, "chat")
  91. card = page["suggestions"][0]
  92. check(card["kind"] == "task" and card["status"] == "pending"
  93. and card["title"] == "Advisor arc task"
  94. and "decide" in card,
  95. "the pending task card carries its payload and decide action")
  96. suggestion_id = int(card["id"])
  97. check(db_rows("select count(*) from tasks"
  98. " where title = 'Advisor arc task'")[0][0] == 0,
  99. "nothing exists before the approval")
  100. print("== advisor_arc: approve & enqueue ==")
  101. result = act(auth, card["decide"], {"decision": "approve_enqueue"})
  102. sets = [d for d in result if d.get("type") == "set"]
  103. check(len(sets) == 1, "the decision answers with the rebuilt slot")
  104. task_rows = db_rows("select id, status, creator, mode, priority,"
  105. " project_id, description from tasks"
  106. " where title = 'Advisor arc task'")
  107. check(len(task_rows) == 1, "the approval creates exactly one task")
  108. task_id, task_status, creator, mode, priority, project_id, description \
  109. = task_rows[0]
  110. check(task_status == "queued",
  111. "approve_enqueue leaves the task QUEUED (%s)" % task_status)
  112. check(creator == "human" and mode == "test"
  113. and str(priority) == "4" and str(project_id) == "1"
  114. and description == "Prove the advisor approval arc.",
  115. "the task keeps the suggestion's fields (creator human)")
  116. suggestion = db_rows("select status, task_id, decided_at from"
  117. " advisor_suggestions where id = ?",
  118. (suggestion_id,))[0]
  119. check(suggestion[0] == "approved" and str(suggestion[1]) == str(task_id)
  120. and suggestion[2] is not None,
  121. "the suggestion row is approved with its task_id and stamp")
  122. check(wait_for(lambda: [r for r in db_rows(
  123. "select kind, content from advisor_messages where chat_id = ?"
  124. " order by id", (chat_id,))
  125. if r[0] == "system"
  126. and r[1] == "Approved & enqueued — task #%d queued" % task_id]
  127. or False, timeout=15, what="approval notice") is not None,
  128. "the approval lands its system notice in the chat")
  129. check(wait_for(lambda: llm.requests
  130. and any(m.get("role") == "user" and "Approved & enqueued"
  131. in (m.get("content") or "")
  132. for m in llm.requests[-1]["messages"]) or False,
  133. timeout=15, what="decision kick reaches the LLM")
  134. is not None,
  135. "the decision kick runs an advisor turn that sees the notice")
  136. check(wait_status(chat_id, "idle"), "the kicked turn settles idle")
  137. print("== advisor_arc: the completion callback ==")
  138. # The fake saddle runs the queued task to done (the app's own
  139. # dispatch surface - no direct status surgery).
  140. check(wait_for(lambda: task_state(auth, task_id).get("status")
  141. == "done" or False, timeout=90,
  142. what="task %d -> done" % task_id) is not None,
  143. "the queued task runs to done on the fake saddle")
  144. check(wait_for(lambda: [r for r in db_rows(
  145. "select kind, content from advisor_messages where chat_id = ?"
  146. " order by id", (chat_id,))
  147. if r[0] == "system" and r[1].startswith(
  148. "Task #%d completed" % task_id)] or False,
  149. timeout=45, what="completion notice") is not None,
  150. "the completion callback lands its system message in the chat")
  151. check(db_rows("select delivered_at from advisor_suggestions"
  152. " where id = ?", (suggestion_id,))[0][0] is not None,
  153. "the suggestion row is stamped delivered_at")
  154. # The callback kicks an advisor turn: the model reads the bracketed
  155. # system notice and answers (the "Good - the task finished." step).
  156. check(wait_for(lambda: any(
  157. r[0] == "assistant" and r[1] == "Good - the task finished."
  158. for r in db_rows("select kind, content from advisor_messages"
  159. " where chat_id = ?", (chat_id,))) or False,
  160. timeout=30, what="advisor reaction") is not None,
  161. "the advisor reacts to the completion in the chat")
  162. check(wait_status(chat_id, "idle"), "the reaction turn settles idle")
  163. print("== advisor_arc: the callback never delivers twice ==")
  164. delivered = db_rows(
  165. "select count(*) from advisor_messages where chat_id = ?"
  166. " and kind = 'system' and content like 'Task #%d completed%%'"
  167. % task_id, (chat_id,))[0][0]
  168. check(delivered == 1,
  169. "exactly one completion message (%d seen)" % delivered)
  170. # A quiet window across more than one callback tick: no second
  171. # delivery, no extra turn.
  172. check(quiet_window(20,
  173. lambda: db_rows(
  174. "select count(*) from advisor_messages"
  175. " where chat_id = ? and kind = 'system'"
  176. " and content like 'Task #%d completed%%'"
  177. % task_id, (chat_id,))[0][0] == 1,
  178. "no second completion delivery"),
  179. "the pass stays quiet about an already-delivered outcome")
  180. print("== advisor_arc: plain approve leaves a draft ==")
  181. llm2 = ScriptedLlm([
  182. {"tools": [{"id": "dr-1", "name": "suggest_task",
  183. "arguments": json.dumps({
  184. "title": "Advisor draft task",
  185. "mandate": "Stay in draft.",
  186. "mode": "investigate",
  187. "project_id": 0, "priority": 6})}]},
  188. {"content": "Suggested."},
  189. {"content": "Thanks."},
  190. ])
  191. llm2.serve()
  192. auth = restart_with_llm(ctx, llm2)
  193. home = slot_public(auth, "/", "dashboard")
  194. result = act(auth, home["start_advisor"],
  195. {"message": "suggest one more task"})
  196. chat2 = int([d for d in result if d.get("type") == "navigate"][0]
  197. ["uri"].rsplit("/", 1)[1])
  198. check(wait_for(lambda: slot_public(auth, "/chats/%d" % chat2,
  199. "chat")["suggestions"] or False,
  200. timeout=15, what="draft suggestion") is not None,
  201. "the second suggestion lands")
  202. card = slot_public(auth, "/chats/%d" % chat2, "chat")["suggestions"][0]
  203. result = act(auth, card["decide"], {"decision": "approve"})
  204. draft = db_rows("select status, creator, project_id from tasks"
  205. " where title = 'Advisor draft task'")[0]
  206. check(draft[0] == "draft" and draft[1] == "human"
  207. and str(draft[2]) == "0",
  208. "plain approve creates a standalone DRAFT task (creator human)")
  209. check(wait_for(lambda: [r for r in db_rows(
  210. "select content from advisor_messages where chat_id = ?",
  211. (chat2,)) if r[0].startswith("Approved — task #")] or False,
  212. timeout=15, what="draft approval notice") is not None,
  213. "the draft approval lands its notice")
  214. # The draft task never dispatches (nothing queues it): a quiet
  215. # window across several dispatch ticks proves it stays draft.
  216. draft_id = db_rows("select id from tasks"
  217. " where title = 'Advisor draft task'")[0][0]
  218. check(quiet_window(6,
  219. lambda: db_rows(
  220. "select status from tasks where id = ?",
  221. (draft_id,))[0][0] == "draft",
  222. "draft stays draft"),
  223. "the approved-but-not-enqueued task stays draft")
  224. print("== advisor_arc: decline creates nothing ==")
  225. llm3 = ScriptedLlm([
  226. {"tools": [{"id": "dc-1", "name": "suggest_task",
  227. "arguments": json.dumps({
  228. "title": "Declined task", "mandate": "No.",
  229. "mode": "implement"})}]},
  230. {"content": "Suggested."},
  231. {"content": "Understood."},
  232. ])
  233. llm3.serve()
  234. auth = restart_with_llm(ctx, llm3)
  235. home = slot_public(auth, "/", "dashboard")
  236. result = act(auth, home["start_advisor"], {"message": "suggest to decline"})
  237. chat3 = int([d for d in result if d.get("type") == "navigate"][0]
  238. ["uri"].rsplit("/", 1)[1])
  239. check(wait_for(lambda: slot_public(auth, "/chats/%d" % chat3,
  240. "chat")["suggestions"] or False,
  241. timeout=15, what="decline suggestion") is not None,
  242. "the third suggestion lands")
  243. card = slot_public(auth, "/chats/%d" % chat3, "chat")["suggestions"][0]
  244. act(auth, card["decide"], {"decision": "decline"})
  245. check(db_rows("select status from advisor_suggestions"
  246. " where chat_id = ?", (chat3,))[0][0] == "declined",
  247. "the declined row is marked declined")
  248. check(db_rows("select count(*) from tasks"
  249. " where title = 'Declined task'")[0][0] == 0,
  250. "the declined suggestion creates no task")
  251. check(wait_for(lambda: [r for r in db_rows(
  252. "select kind, content from advisor_messages where chat_id = ?",
  253. (chat3,)) if r[0] == "system" and r[1] == "Declined."] or False,
  254. timeout=15, what="decline notice") is not None,
  255. "the decline lands its notice")
  256. print("== advisor_arc: goal approve is draft-only ==")
  257. llm4 = ScriptedLlm([
  258. {"tools": [{"id": "ga-1", "name": "suggest_goal",
  259. "arguments": json.dumps({
  260. "title": "Advisor arc goal",
  261. "brief": "Prove goal suggestions.",
  262. "project_id": 1,
  263. "branch_name": "goal/advisor-arc"})}]},
  264. {"content": "Suggested."},
  265. {"content": "Thanks."},
  266. ])
  267. llm4.serve()
  268. auth = restart_with_llm(ctx, llm4)
  269. home = slot_public(auth, "/", "dashboard")
  270. result = act(auth, home["start_advisor"],
  271. {"message": "suggest a goal now"})
  272. chat4 = int([d for d in result if d.get("type") == "navigate"][0]
  273. ["uri"].rsplit("/", 1)[1])
  274. check(wait_for(lambda: [s for s in slot_public(
  275. auth, "/chats/%d" % chat4, "chat")["suggestions"]
  276. if s["kind"] == "goal"] or False, timeout=15,
  277. what="goal suggestion") is not None,
  278. "the goal suggestion lands")
  279. card = [s for s in slot_public(auth, "/chats/%d" % chat4,
  280. "chat")["suggestions"]
  281. if s["kind"] == "goal"][0]
  282. result = act(auth, card["decide"], {"decision": "approve"})
  283. goal_rows = db_rows("select status, branch_name, creator from goals"
  284. " where title = 'Advisor arc goal'")
  285. check(len(goal_rows) == 1 and goal_rows[0][0] == "draft"
  286. and goal_rows[0][1] == "goal/advisor-arc"
  287. and goal_rows[0][2] == "human",
  288. "the approved goal is a DRAFT on its branch (creator human)")
  289. goal_id = db_rows("select id from goals"
  290. " where title = 'Advisor arc goal'")[0][0]
  291. suggestion4 = db_rows("select status, goal_id, task_id from"
  292. " advisor_suggestions where chat_id = ?"
  293. " and kind = 'goal'", (chat4,))[0]
  294. check(suggestion4[0] == "approved"
  295. and str(suggestion4[1]) == str(goal_id)
  296. and str(suggestion4[2]) == "0",
  297. "the goal suggestion keeps its goal_id and no task")
  298. check(db_rows("select count(*) from tasks where goal_id = ?",
  299. (goal_id,))[0][0] == 0,
  300. "the approved goal stays unqueued (no run task created)")
  301. check(wait_for(lambda: [r for r in db_rows(
  302. "select content from advisor_messages where chat_id = ?",
  303. (chat4,)) if r[0].startswith(
  304. "Approved — goal #%d created in draft" % goal_id)] or False,
  305. timeout=15, what="goal approval notice") is not None,
  306. "the goal approval lands its notice")
  307. # approve_enqueue on a goal behaves as plain approve: never queued.
  308. llm5 = ScriptedLlm([
  309. {"tools": [{"id": "ga-2", "name": "suggest_goal",
  310. "arguments": json.dumps({
  311. "title": "Advisor arc goal two",
  312. "brief": "Never start me.",
  313. "project_id": 1,
  314. "branch_name": "goal/advisor-arc-two"})}]},
  315. {"content": "Suggested."},
  316. {"content": "Thanks."},
  317. ])
  318. llm5.serve()
  319. auth = restart_with_llm(ctx, llm5)
  320. home = slot_public(auth, "/", "dashboard")
  321. result = act(auth, home["start_advisor"],
  322. {"message": "suggest another goal"})
  323. chat5 = int([d for d in result if d.get("type") == "navigate"][0]
  324. ["uri"].rsplit("/", 1)[1])
  325. check(wait_for(lambda: [s for s in slot_public(
  326. auth, "/chats/%d" % chat5, "chat")["suggestions"]
  327. if s["kind"] == "goal"] or False, timeout=15,
  328. what="second goal suggestion") is not None,
  329. "the second goal suggestion lands")
  330. card = [s for s in slot_public(auth, "/chats/%d" % chat5,
  331. "chat")["suggestions"]
  332. if s["kind"] == "goal"][0]
  333. act(auth, card["decide"], {"decision": "approve_enqueue"})
  334. goal5 = db_rows("select id, status from goals"
  335. " where title = 'Advisor arc goal two'")[0]
  336. check(goal5[1] == "draft"
  337. and db_rows("select count(*) from tasks where goal_id = ?",
  338. (goal5[0],))[0][0] == 0,
  339. "approve_enqueue on a goal still creates only a draft")
  340. print("== advisor_arc: decided rows refuse a second decision ==")
  341. page = slot_public(auth, "/chats/%d" % chat5, "chat")
  342. decided = [s for s in page["suggestions"] if s["kind"] == "goal"][0]
  343. check("decide" not in decided,
  344. "the decided card carries no decide action anymore")
  345. print("== advisor_arc: privacy - another user decides nothing ==")
  346. second = "arc-" + str(int(time.time()))
  347. users_state = slot_public(auth, "/users", "users")
  348. act(auth, users_state["create"], {
  349. "username": second, "email": second + "@example.com",
  350. "forename": "Arc", "surname": "Privacy", "date_of_birth": "1990-01-01",
  351. "password": "arcpass123", "confirm_password": "arcpass123"})
  352. other = login(second, "arcpass123")
  353. other_page = slot_public(other, "/chats/%d" % chat_id, "chat")
  354. check(other_page["id"] == 0,
  355. "another user's chat slot reads not-found")
  356. # The foreign decision is refused by the owner check: the row keeps
  357. # its approved/task_id state untouched.
  358. own = db_rows("select status, task_id from advisor_suggestions"
  359. " where id = ?", (suggestion_id,))[0]
  360. check(own[0] == "approved" and int(own[1]) == task_id,
  361. "the owner's row is intact before the foreign attempt")
  362. foreign_card = [s for s in slot_public(
  363. auth, "/chats/%d" % chat_id, "chat")["suggestions"]
  364. if str(s["id"]) == str(suggestion_id)][0]
  365. check("decide" not in foreign_card,
  366. "the decided card exposes nothing to replay")
  367. # Restore the daemon-facing LLM env the other checks may rely on
  368. # (the advisor env is test-local).
  369. for killer in (llm, llm2, llm3, llm4, llm5):
  370. killer.kill()
  371. set_model_config(advisor_base_url="", advisor_model="")