advisor_fix.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. """The advisor review-fix bundle (goal 27, rejected-review follow-up):
  2. one regression guard per defect the completion review found, each
  3. driven end-to-end through the app and the scripted LLM endpoint.
  4. - the tool schema (bug 1): the REQUEST's tools array carries no prose
  5. in a `type` field ("array of string" 400s strict providers),
  6. ask_question's options property is the items-object array shape,
  7. suggest_task's mode is an enum, and every schema has its `required`
  8. array
  9. - the 400 body (bug 2): a provider 400 with a JSON body lands in the
  10. chat's error row verbatim enough to read the provider's reason
  11. - the redirect loop (bug 3): the chat page's refresh action is SEALED
  12. (no form/chat_id round-trip) — a bare GET with no parameters answers
  13. a slot SET for the owner and navigates ONLY for a foreign or deleted
  14. chat
  15. - the reply variants (bug 4): provider bodies shaped like real ones
  16. (tool_calls absent, an empty message object, an error object over
  17. HTTP 200, content null) parse without a json-glib CRITICAL and
  18. without wedging the turn
  19. - the hero markup (bug 5): the dashboard's composer is its own block
  20. form (not form.inline/.form-row) with a full-width textarea
  21. """
  22. from harness import * # noqa: F401,F403 — the shared wire helpers
  23. import harness # the module itself (global re-pointing on restarts)
  24. from bundles import advisor as advisor_module
  25. from bundles.advisor import ScriptedLlm, advisor_api, chat_rows
  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 (the model_config row
  41. is the configuration source — set_model_config writes it)."""
  42. set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm.port,
  43. advisor_model="scripted")
  44. ctx.app.terminate()
  45. try:
  46. ctx.app.wait(timeout=10)
  47. except subprocess.TimeoutExpired:
  48. ctx.app.kill()
  49. ctx.app = start_app()
  50. ctx.auth = login("admin", "adminpass123")
  51. return ctx.auth
  52. def schema_types(value, seen):
  53. """Collects every JSON-Schema `type` leaf in a parsed structure."""
  54. if isinstance(value, dict):
  55. for key, item in value.items():
  56. if key == "type" and isinstance(item, str):
  57. seen.add(item)
  58. schema_types(item, seen)
  59. elif isinstance(value, list):
  60. for item in value:
  61. schema_types(item, seen)
  62. def run(ctx):
  63. auth = ctx.auth
  64. # The shared helpers imported from bundles.advisor (advisor_api
  65. # reads WEB) captured THIS module's star-import at load; in the
  66. # sequential full run those globals are the harness's re-pointed
  67. # ones while bundles.advisor's own copy stayed at ITS shard's
  68. # values — re-point them exactly as run_bundle does for us.
  69. for runtime_name in ("WORK", "DB_PATH", "APP_PORT", "DAEMON_PORT",
  70. "WEB"):
  71. setattr(advisor_module, runtime_name,
  72. getattr(harness, runtime_name))
  73. print("== advisor_fix: the tool schema on the wire ==")
  74. llm = ScriptedLlm([
  75. {"tools": [{"id": "fx-1", "name": "ask_question",
  76. "arguments": json.dumps({
  77. "question": "Schema ok?",
  78. "options": ["yes", "no"]})}]},
  79. {"content": "Schema verified."},
  80. ])
  81. llm.serve()
  82. auth = restart_with_llm(ctx, llm)
  83. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  84. {"title": "schema"})
  85. schema_chat = int(body["chat_id"])
  86. advisor_api(auth, "POST", "/api/advisor/turn",
  87. {"chat_id": schema_chat, "message": "check the schema"})
  88. check(wait_for(lambda: llm.requests or False, timeout=15,
  89. what="first LLM call") is not None,
  90. "the turn reached the LLM")
  91. tools = llm.requests[0].get("tools") or []
  92. names = {t["function"]["name"]: t["function"] for t in tools}
  93. check(set(names) >= {"list_projects", "ask_question", "suggest_task",
  94. "suggest_goal"},
  95. "the request declares the advisor tool set (%s)" % sorted(names))
  96. # Bug 1's regression: NO prose may ride a `type` field anywhere in
  97. # the tools array — strict providers validate it and 400 the whole
  98. # request before answering anything.
  99. types_seen = set()
  100. schema_types(tools, types_seen)
  101. check("array of string" not in types_seen
  102. and all(t in ("string", "integer", "array", "object", "boolean",
  103. "number", "function")
  104. for t in types_seen),
  105. "every schema type leaf is a real JSON-Schema type (%s)"
  106. % sorted(types_seen))
  107. question_params = names["ask_question"]["parameters"]
  108. options_property = question_params["properties"]["options"]
  109. check(options_property.get("type") == "array"
  110. and options_property.get("items", {}).get("type") == "string",
  111. "ask_question's options property is the items-object array shape"
  112. " (%s)" % options_property)
  113. check(question_params.get("required") == ["question"],
  114. "ask_question declares its required array (%s)"
  115. % question_params.get("required"))
  116. task_params = names["suggest_task"]["parameters"]
  117. mode_property = task_params["properties"]["mode"]
  118. check(mode_property.get("enum") == ["implement", "investigate",
  119. "review", "test"],
  120. "suggest_task's mode is the work-mode enum (%s)"
  121. % mode_property.get("enum"))
  122. check(task_params.get("required") == ["title", "mandate"],
  123. "suggest_task declares title+mandate required (%s)"
  124. % task_params.get("required"))
  125. goal_params = names["suggest_goal"]["parameters"]
  126. check(goal_params.get("required") == ["title", "brief", "project_id"],
  127. "suggest_goal declares title+brief+project_id required (%s)"
  128. % goal_params.get("required"))
  129. check("required" in names["list_projects"]["parameters"],
  130. "even the parameter-less read tools carry a required array")
  131. check(names["get_project"]["parameters"]["properties"]["project_id"]
  132. .get("type") == "integer",
  133. "integer params keep their integer type")
  134. # The question parks; answer it so the chat settles.
  135. check(wait_status(schema_chat, "awaiting_answer"),
  136. "the scripted question parks the chat")
  137. advisor_api(auth, "POST", "/api/advisor/answer",
  138. {"chat_id": schema_chat, "answer": "yes"})
  139. check(wait_status(schema_chat, "idle"), "the schema chat settles idle")
  140. print("== advisor_fix: the provider 400 body reaches the error row ==")
  141. # fail_next answers 500; the 400 scene needs a body the harness can
  142. # read back, so serve a raw 400 with a JSON error envelope.
  143. llm400 = ScriptedLlm([{"content": "never"}])
  144. llm400.serve()
  145. class Handler400(BaseHTTPRequestHandler):
  146. protocol_version = "HTTP/1.1"
  147. def log_message(self, fmt, *args):
  148. pass
  149. def do_POST(self):
  150. length = int(self.headers.get("Content-Length") or 0)
  151. self.rfile.read(length)
  152. body = json.dumps({
  153. "error": {"message": "Invalid schema for tool"
  154. " 'ask_question': options.type 'array of string'"
  155. " is not a valid type",
  156. "type": "invalid_request_error"}}).encode()
  157. self.send_response(400)
  158. self.send_header("Content-Type", "application/json")
  159. self.send_header("Content-Length", str(len(body)))
  160. self.end_headers()
  161. self.wfile.write(body)
  162. server400 = ThreadingHTTPServer(("127.0.0.1", 0), Handler400)
  163. threading.Thread(target=server400.serve_forever, daemon=True).start()
  164. auth = restart_with_llm(ctx, llm400)
  165. # Point the configuration at the raw 400 server AFTER the restart
  166. # read the (valid) llm400 for the configured check; the restart
  167. # below binds the app to the 400 endpoint.
  168. set_model_config(advisor_base_url=
  169. "http://127.0.0.1:%d" % server400.server_address[1])
  170. ctx.app.terminate()
  171. try:
  172. ctx.app.wait(timeout=10)
  173. except subprocess.TimeoutExpired:
  174. ctx.app.kill()
  175. ctx.app = start_app()
  176. ctx.auth = auth = login("admin", "adminpass123")
  177. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  178. {"title": "errors"})
  179. err_chat = int(body["chat_id"])
  180. advisor_api(auth, "POST", "/api/advisor/turn",
  181. {"chat_id": err_chat, "message": "provoke the 400"})
  182. check(wait_status(err_chat, "idle"),
  183. "the 400 turn returns the chat to idle")
  184. error_rows = wait_for(lambda: [r for r in chat_rows(err_chat,
  185. "kind, content")
  186. if r[0] == "error"] or False,
  187. timeout=15, what="400 error row")
  188. check(error_rows is not None and len(error_rows) == 1,
  189. "the 400 lands as exactly one error row")
  190. content = error_rows[0][1] if error_rows else ""
  191. check("HTTP 400" in content,
  192. "the error row names the status (%s)" % content[:120])
  193. check("is not a valid type" in content and "Invalid schema" in content,
  194. "the error row CONTAINS the provider's own message text (%s)"
  195. % content[:200])
  196. check(len(content) <= 600,
  197. "the error row stays clamped (%d chars)" % len(content))
  198. server400.shutdown()
  199. server400.server_close()
  200. llm400.kill()
  201. print("== advisor_fix: the refresh action is sealed (no redirect) ==")
  202. # A quiet scripted LLM: the scene only needs a chat that exists.
  203. llmr = ScriptedLlm([{"content": "quiet"}])
  204. llmr.serve()
  205. auth = restart_with_llm(ctx, llmr)
  206. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  207. {"title": "refresh"})
  208. refresh_chat = int(body["chat_id"])
  209. advisor_api(auth, "POST", "/api/advisor/turn",
  210. {"chat_id": refresh_chat, "message": "say something"})
  211. check(wait_status(refresh_chat, "idle"), "the refresh chat settles")
  212. page = slot_public(auth, "/chats/%d" % refresh_chat, "chat")
  213. check("refresh" in page, "the chat slot carries the refresh action")
  214. refresh = page["refresh"]
  215. check(refresh.get("method") == "GET" and refresh.get("private"),
  216. "the refresh action is GET and SEALED (private blob present)")
  217. # The pulse listener calls statum.act(chat.refresh, {}) — a bare
  218. # GET with NO parameters. Before the fix, Forms.integer read 0 and
  219. # every bump navigated to /chats (the redirect loop).
  220. result = act(auth, refresh, {})
  221. sets = [d for d in result if d.get("type") == "set"]
  222. navigations = [d for d in result if d.get("type") == "navigate"]
  223. check(len(sets) == 1 and not navigations,
  224. "the bare GET refresh answers a slot SET, no navigate (%s)"
  225. % [d.get("type") for d in result])
  226. set_chat = json.loads(sets[0]["snapshot_frame"]["content"])
  227. check(set_chat["slot"]["type"] == "chat"
  228. and set_chat["public"]["id"] == refresh_chat,
  229. "the refreshed slot is the owner's own chat")
  230. # The chat page's script must no longer pass a chat_id (the param
  231. # that was silently lost in the query string).
  232. detail_html = http_get_page(auth, "/chats/%d" % refresh_chat)
  233. check("statum.act(chat.refresh, {})" in detail_html,
  234. "the pulse listener invokes the refresh with no parameters")
  235. # A foreign viewer's sealed refresh: they cannot author one (the
  236. # not-found slot carries no actions), and a deleted chat navigates.
  237. second = "fix-" + str(int(time.time()))
  238. users_state = slot_public(auth, "/users", "users")
  239. act(auth, users_state["create"], {
  240. "username": second, "email": second + "@example.com",
  241. "forename": "Fix", "surname": "Bundle",
  242. "date_of_birth": "1990-01-01",
  243. "password": "fixpass123", "confirm_password": "fixpass123"})
  244. other = login(second, "fixpass123")
  245. other_page = slot_public(other, "/chats/%d" % refresh_chat, "chat")
  246. check(other_page["id"] == 0 and "refresh" not in other_page,
  247. "a foreign viewer gets the not-found slot with no refresh")
  248. status, _ = advisor_api(auth, "POST", "/api/advisor/chat/delete",
  249. {"chat_id": refresh_chat})
  250. doomed = slot_public(auth, "/chats", "chats")
  251. # Author a refresh from a LIVE chat, delete the chat, then fire the
  252. # action: only the genuine not-found path may navigate.
  253. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  254. {"title": "doomed"})
  255. doomed_chat = int(body["chat_id"])
  256. advisor_api(auth, "POST", "/api/advisor/turn",
  257. {"chat_id": doomed_chat, "message": "populate"})
  258. check(wait_status(doomed_chat, "idle"), "the doomed chat settles")
  259. doomed_page = slot_public(auth, "/chats/%d" % doomed_chat, "chat")
  260. advisor_api(auth, "POST", "/api/advisor/chat/delete",
  261. {"chat_id": doomed_chat})
  262. result = act(auth, doomed_page["refresh"], {})
  263. check(any(d.get("type") == "navigate" and d.get("uri") == "/chats"
  264. for d in result),
  265. "a refresh for a genuinely deleted chat navigates to /chats")
  266. print("== advisor_fix: reply variants parse without a CRITICAL ==")
  267. # Variants real providers send: no tool_calls member at all, an
  268. # empty message object, an error object over HTTP 200, and content
  269. # null. Each must parse (or throw a catchable Error) — never a
  270. # json-glib CRITICAL and never a wedged turn.
  271. variants = [
  272. ("no tool_calls member",
  273. {"choices": [{"index": 0, "finish_reason": "stop",
  274. "message": {"role": "assistant",
  275. "content": "plain reply"}}],
  276. "model": "scripted"}),
  277. ("empty message object",
  278. {"choices": [{"index": 0, "finish_reason": "stop",
  279. "message": {}}], "model": "scripted"}),
  280. ("null content",
  281. {"choices": [{"index": 0, "finish_reason": "stop",
  282. "message": {"role": "assistant", "content": None}}],
  283. "model": "scripted"}),
  284. ("null message object",
  285. {"choices": [{"index": 0, "finish_reason": "stop",
  286. "message": None}], "model": "scripted"}),
  287. ("first choice not an object",
  288. {"choices": [None], "model": "scripted"}),
  289. ("error object over HTTP 200",
  290. {"error": {"message": "quota exceeded", "type": "insufficient_quota"},
  291. "model": "scripted"}),
  292. ]
  293. variant_logs = []
  294. for label, envelope in variants:
  295. llmv = ScriptedLlm([{"content": "unused"}])
  296. llmv.serve()
  297. # Swap the served envelope by pointing the script at a raw
  298. # handler: simplest is a dedicated one-shot server.
  299. class HandlerVariant(BaseHTTPRequestHandler):
  300. protocol_version = "HTTP/1.1"
  301. def log_message(self, fmt, *args):
  302. pass
  303. def do_POST(self):
  304. length = int(self.headers.get("Content-Length") or 0)
  305. self.rfile.read(length)
  306. served = json.dumps(envelope).encode()
  307. self.send_response(200)
  308. self.send_header("Content-Type", "application/json")
  309. self.send_header("Content-Length", str(len(served)))
  310. self.end_headers()
  311. self.wfile.write(served)
  312. serverv = ThreadingHTTPServer(("127.0.0.1", 0), HandlerVariant)
  313. threading.Thread(target=serverv.serve_forever, daemon=True).start()
  314. set_model_config(advisor_base_url=
  315. "http://127.0.0.1:%d" % serverv.server_address[1])
  316. ctx.app.terminate()
  317. try:
  318. ctx.app.wait(timeout=10)
  319. except subprocess.TimeoutExpired:
  320. ctx.app.kill()
  321. ctx.app = start_app()
  322. ctx.auth = auth = login("admin", "adminpass123")
  323. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  324. {"title": label})
  325. variant_chat = int(body["chat_id"])
  326. advisor_api(auth, "POST", "/api/advisor/turn",
  327. {"chat_id": variant_chat, "message": "parse this"})
  328. # The turn's TERMINAL ROW is the deterministic signal (a fresh
  329. # chat already reads idle) — an assistant reply, an error row,
  330. # or the no-choices throw landing as one.
  331. terminal = wait_for(lambda: [r for r in chat_rows(variant_chat,
  332. "kind")
  333. if r[0] in ("assistant", "error")]
  334. or False, timeout=20,
  335. what="%s terminal row" % label)
  336. check(terminal is not None,
  337. "the %s variant lands a terminal row (%s)"
  338. % (label, [r[0] for r in chat_rows(variant_chat, "kind")]))
  339. check(wait_status(variant_chat, "idle", timeout=20),
  340. "the %s variant leaves the chat idle (no wedge)" % label)
  341. try:
  342. with open(os.path.join(WORK, "app.log"), "r",
  343. errors="replace") as handle:
  344. variant_logs.append(handle.read())
  345. except OSError:
  346. pass
  347. serverv.shutdown()
  348. serverv.server_close()
  349. llmv.kill()
  350. # A CRITICAL in ANY variant's app log would name the json-glib
  351. # accessor (json_object_get_array_member, json_object_has_member,
  352. # …) — none may appear for any of the driven shapes.
  353. log_text = "\n".join(variant_logs)
  354. critical_lines = [line for line in log_text.splitlines()
  355. if "Json-CRITICAL" in line]
  356. check(not critical_lines,
  357. "no json-glib CRITICAL lines across the variants (%d seen)%s"
  358. % (len(critical_lines),
  359. " e.g. %s" % critical_lines[0][:120] if critical_lines else ""))
  360. print("== advisor_fix: the hero composer markup ==")
  361. home_html = http_get_page(auth, "/")
  362. check('class="advisor-hero card"' in home_html,
  363. "the hero keeps its card")
  364. check('class="advisor-hero-form"' in home_html,
  365. "the hero form carries its own layout class")
  366. check('class="inline advisor-hero-form"' not in home_html,
  367. "the hero form is NOT form.inline")
  368. # The FORM tag onward (the explanatory comment above it names the
  369. # old classes in prose — it is not markup).
  370. hero_block = '<form class="advisor-hero-form"' + home_html.split(
  371. '<form class="advisor-hero-form"')[1].split("</form>")[0]
  372. check("form-row" not in hero_block,
  373. "the hero form no longer uses the 34rem .form-row column")
  374. check('id="advisor-composer"' in hero_block
  375. and "<textarea" in hero_block,
  376. "the hero textarea rides the form")
  377. css = ""
  378. for candidate in (os.path.join(os.path.dirname(os.path.abspath(
  379. harness.__file__)), "..", "src", "pages", "app.css"),):
  380. try:
  381. with open(candidate, "r", errors="replace") as handle:
  382. css += handle.read()
  383. except OSError:
  384. pass
  385. check(".advisor-hero-form textarea" in css
  386. and "min-height: 4.5rem" in css and "width: 100%" in css,
  387. "the CSS gives the hero textarea its full-width roomy layout")
  388. check(".advisor-hero .form-row" not in css,
  389. "the old squeezed .form-row hero rule is gone")
  390. llm.kill()
  391. llmr.kill()
  392. # Restore the daemon-facing model configuration the other checks
  393. # may rely on (the advisor override is test-local).
  394. set_model_config(advisor_base_url="", advisor_model="")