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