"""The advisor UTF-8 wire bundle (invalid-UTF-8 payload fix): a regression guard per entry point the defect travelled, each driven end-to-end through the app and the scripted LLM endpoint. The defect: the advisor's request body (and the stored advisor_messages payload, re-read on the next turn) carried structurally invalid UTF-8 whenever a multi-byte character straddled a byte-count clamp — every clamp in AdvisorService was a raw byte cut: - get_task/get_goal/get_task_log clamp long text to 2000 BYTES; a completion report whose 2000th byte is mid-character (an em dash — routine in these reports) emitted an orphaned lead byte. json-glib embeds it verbatim, the provider 400s the body, and our own parser rejects the stored row on read-back ("JSON data must be UTF-8 encoded"), silently dropping the turn's content. - query_canonical_text's 90000-byte head/tail cut had the same shape. - a provider error body (latin-1 HTML from proxies) rode the chat's error row unsanitised, with a raw 500-byte cut. The fix mirrors the daemon's Wire/Canon idiom: cuts land on UTF-8 character boundaries, and every string that reaches a serialisation boundary is sanitised (invalid bytes -> U+FFFD). - the completion-report cut: a goal whose completion_report places a 3-byte em dash at bytes 1999..2001 reaches the LLM as a VALID UTF-8 JSON string, ends on a whole character, and the stored advisor message payload re-parses - the task summary cut: the same straddle through get_task - the canon query middle cut: a canon text over the 90000-byte budget cut on boundaries on BOTH sides (checked through a fresh canon dir) - legacy rescue: a stored advisor payload that already holds invalid bytes (written before the fix) is recovered, not silently dropped """ from harness import * # noqa: F401,F403 — the shared wire helpers import harness # the module itself (global re-pointing on restarts) from bundles import advisor as advisor_module from bundles.advisor import ScriptedLlm, advisor_api, chat_rows from bundles.advisor_fix import restart_with_llm, wait_status def db_rows(sql, params=()): conn = db() try: return conn.execute(sql, params).fetchall() finally: conn.close() def run(ctx): auth = ctx.auth # The shared helpers imported from bundles.advisor (advisor_api # reads WEB) captured THIS module's star-import at load; in the # sequential full run those globals are the harness's re-pointed # ones while bundles.advisor's own copy stayed at ITS shard's # values — re-point them exactly as run_bundle does for us. for runtime_name in ("WORK", "DB_PATH", "APP_PORT", "DAEMON_PORT", "WEB"): setattr(advisor_module, runtime_name, getattr(harness, runtime_name)) # The clamp constant under test (TEXT_CLAMP_CHARS in # AdvisorService): the straddle is arranged so the em dash's lead # byte sits exactly AT byte 1999, i.e. byte 2000 lands mid-sequence. CLAMP = 2000 EM = "\u2014" # — (3 bytes: e2 80 94) DOT = "\u2026" # … (3 bytes: e2 80 a6) def straddling(pad_to, tail=" and the closing thought of the report"): """Text whose multi-byte character straddles byte `pad_to`.""" return "a" * (pad_to - 1) + EM + tail report = straddling(CLAMP) # em dash at 1999..2001 summary = straddling(CLAMP - 400) # well inside the clamp long_summary = straddling(CLAMP) # the cut case print("== advisor_utf8: the completion-report cut ==") conn = db() conn.execute( "insert into goals (project_id, title, brief, branch_name, status," " plan, completion_report, completed_at, created_at)" " values (1, 'UTF8 goal', 'b', 'goal/utf8', 'complete'," " 'plan text', ?, strftime('%s','now'), strftime('%s','now'))", (report,)) goal_id = conn.execute("select max(id) from goals").fetchone()[0] conn.execute( "insert into tasks (project_id, goal_id, kind, title, status," " priority, mode, creator, summary, completed_at, created_at)" " values (1, ?, 'task', 'UTF8 task', 'done', 5, 'implement'," " 'human', ?, strftime('%s','now'), strftime('%s','now'))", (goal_id, long_summary)) task_id = conn.execute("select max(id) from tasks").fetchone()[0] conn.execute( "insert into tasks (project_id, kind, title, status, priority," " mode, creator, summary, created_at)" " values (1, 'task', 'Short task', 'done', 5, 'implement'," " 'human', ?, strftime('%s','now'))", (summary,)) short_id = conn.execute("select max(id) from tasks").fetchone()[0] conn.commit() conn.close() def tool_result(call_id, requests): for request in requests: for message in request.get("messages") or []: if message.get("role") == "tool" and \ message.get("tool_call_id") == call_id: return message.get("content") return None llm = ScriptedLlm([ {"tools": [{"id": "u1", "name": "get_goal", "arguments": json.dumps({"goal_id": goal_id})}]}, {"tools": [{"id": "u2", "name": "get_task", "arguments": json.dumps({"task_id": task_id})}]}, {"tools": [{"id": "u3", "name": "get_task", "arguments": json.dumps({"task_id": short_id})}]}, {"content": "Read them all."}, ]) llm.serve() auth = restart_with_llm(ctx, llm) status, body = advisor_api(auth, "POST", "/api/advisor/chat", {"title": "utf8"}) chat_id = int(body["chat_id"]) status, _ = advisor_api(auth, "POST", "/api/advisor/turn", {"chat_id": chat_id, "message": "Pull the completion story."}) check(status == 200, "the report-reading turn is accepted") check(wait_for(lambda: len(llm.requests) >= 4 or False, timeout=25, what="the report-reading rounds") is not None, "the report-reading turn completes") # EVERY request body the LLM received must be valid UTF-8 JSON. The # ScriptedLlm already json.loads each body (a raise would 500 the # turn before any of the checks below), so this inspects the wire # strings themselves. def wire_ok(): for index, request in enumerate(llm.requests): for message in request.get("messages") or []: content = message.get("content") if not isinstance(content, str): continue try: content.encode("utf-8", "strict") except UnicodeEncodeError: return index, message.get("role") return None check(wire_ok() is None, "every message content on the wire is encodable UTF-8") # The completion report: cut on a character boundary, never halved. goal_view = tool_result("u1", llm.requests) check(goal_view is not None, "get_goal answered") parsed = json.loads(goal_view) if goal_view else {} seen = parsed.get("completion_report", "") check(seen.endswith(DOT) and not seen.endswith(EM[0]), "the cut completion report ends on the ellipsis marker") check(EM not in seen, "the halved em dash is gone (the straddling char was moved out)") check(len(seen.encode("utf-8")) <= CLAMP + len(DOT.encode()), "the clamped report respects the byte budget (got %d bytes)" % len(seen.encode("utf-8"))) # The task summary: same shape through get_task. task_view = tool_result("u2", llm.requests) parsed = json.loads(task_view) if task_view else {} seen = parsed.get("summary", "") check(seen.endswith(DOT) and EM not in seen, "the cut task summary ends on the ellipsis marker, char whole") # A short summary passes through untouched (boundary cut is a no-op). short_view = tool_result("u3", llm.requests) parsed = json.loads(short_view) if short_view else {} check(parsed.get("summary") == summary, "an under-budget summary rides verbatim") # The stored advisor_messages payload must re-parse — the row the # next turn's wire rebuild reads (the pre-fix bug poisoned it). tool_payloads = [r[0] for r in chat_rows(chat_id, "payload") if r[0]] reparsed = 0 for payload in tool_payloads: doc = json.loads(payload) # raises on invalid UTF-8 JSON reparsed += 1 inner = doc.get("result") if isinstance(inner, str): json.loads(inner) # the double-encoded result check(reparsed >= 3, "every stored tool payload re-parses cleanly (%d rows)" % reparsed) print("== advisor_utf8: the canon query middle cut ==") # A canon dir with one text over the 90000-byte budget, its cut # point arranged mid-character, pointed at the app through # CLANKER_CANON_DIR... which the app reads at startup: restart. canon_dir = os.path.join(WORK, "canon-utf8") os.makedirs(canon_dir, exist_ok=True) BUDGET = 90000 body_text = ("word — dash … ellipsis ✓ check " * 3000)[:BUDGET + 700] # place a 4-byte emoji so the head cut at BUDGET/2 lands mid-char body_bytes = body_text.encode("utf-8") half = BUDGET // 2 body_bytes = (body_bytes[:half] + "\U0001F600".encode("utf-8") + body_bytes[half:]) with open(os.path.join(canon_dir, "utf8probe.md"), "wb") as handle: handle.write( b"---\nslug: utf8probe\ntitle: UTF8 Probe\nauthor: anon\n" b"source: test\nlicence: pd\nsummary: probe\n---\n") handle.write(body_bytes) llm2 = ScriptedLlm([ {"tools": [{"id": "c1", "name": "query_canonical_text", "arguments": json.dumps({"slug": "utf8probe", "question": "anything"})}]}, {"content": "Queried."}, ]) llm2.serve() set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm2.port, advisor_model="scripted") ctx.app.terminate() try: ctx.app.wait(timeout=10) except subprocess.TimeoutExpired: ctx.app.kill() ctx.app = start_app(extra_env={"CLANKER_CANON_DIR": canon_dir}) ctx.auth = auth = login("admin", "adminpass123") status, body = advisor_api(auth, "POST", "/api/advisor/chat", {"title": "canon utf8"}) canon_chat = int(body["chat_id"]) status, _ = advisor_api(auth, "POST", "/api/advisor/turn", {"chat_id": canon_chat, "message": "query the canon"}) check(status == 200, "the canon-query turn is accepted") # TWO requests: the main loop's tool round, then the side call the # tool itself makes (the text never enters the conversation). check(wait_for(lambda: len(llm2.requests) >= 2 or False, timeout=25, what="the canon query side call") is not None, "the canon query reaches the LLM") # The side call's user message carries the clamped body: it must be # encodable UTF-8 (the last ScriptedLlm json.loads proves decodable; # this pins the encoding side of the contract). side_bodies = [m.get("content", "") for r in llm2.requests for m in (r.get("messages") or []) if m.get("role") == "user" and "Text:" in (m.get("content") or "")] check(len(side_bodies) >= 1, "the canon side call carried the text") for text in side_bodies: try: text.encode("utf-8", "strict") ok = True except UnicodeEncodeError: ok = False check(ok, "the canon side-call text is encodable UTF-8") check("[output truncated:" in text, "the over-budget canon body states its truncation") print("== advisor_utf8: legacy stored payload rescue ==") # A stored advisor_messages row holding invalid UTF-8 (written # before the fix): the next turn must recover its members through # the sanitising loader instead of silently dropping them. legacy_chat_title = "legacy" status, body = advisor_api(auth, "POST", "/api/advisor/chat", {"title": legacy_chat_title}) legacy_chat = int(body["chat_id"]) conn = db() dirty_payload = ('{"call_id": "legacy-1", "name": "get_task",' ' "result": "{\\"note\\": \\"broken \xff bytes\\"}"}') dirty = dirty_payload.encode("utf-8", "surrogateescape") # CAST keeps the row TEXT-typed (the shape the pre-fix Vala writer # stored: sqlite3_bind_text keeps raw bytes); a bare bytes param # would type it BLOB and the ORM's string mapping would reject the # ROW — a different failure this bundle does not claim to guard. conn.execute( "insert into advisor_messages (chat_id, kind, content, payload," " created_at) values (?, 'tool', '@tool result', CAST(? AS TEXT)," " strftime('%s','now'))", (legacy_chat, dirty)) conn.commit() conn.close() llm3 = ScriptedLlm([{"content": "Recovered."}]) llm3.serve() auth = restart_with_llm(ctx, llm3) status, _ = advisor_api(auth, "POST", "/api/advisor/turn", {"chat_id": legacy_chat, "message": "carry on"}) check(status == 200, "the legacy-row turn is accepted") check(wait_for(lambda: len(llm3.requests) >= 1 or False, timeout=25, what="the legacy-row LLM call") is not None, "the legacy-row turn reaches the LLM") legacy_tool = [m for r in llm3.requests for m in (r.get("messages") or []) if m.get("role") == "tool" and m.get("tool_call_id") == "legacy-1"] check(len(legacy_tool) == 1 and "broken" in legacy_tool[0].get("content", ""), "the legacy dirty row's result survives to the wire (was silently" " dropped before the sanitising loader)") print("== advisor_utf8: a provider 400 body with non-UTF-8 bytes ==") # The provider's error body rides the chat's error row: raw bytes # from a latin-1 proxy page must not poison the row or the next # turn's wire rebuild. class DirtyErrorLlm(ScriptedLlm): def serve(self): class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, fmt, *args): pass def do_POST(self): length = int(self.headers.get("Content-Length") or 0) self.rfile.read(length) # cp1252: the single-byte codepage a real proxy # error page carries (latin-1 proper cannot hold # the em dash — cp1252's 0x97 is exactly the # byte soup this scenario wants). body = ("Erreur — café naïve" ).encode("cp1252") self.send_response(400) self.send_header("Content-Type", "text/html") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) self.port = self.server.server_address[1] threading.Thread(target=self.server.serve_forever, daemon=True).start() return self.server llm4 = DirtyErrorLlm([]) llm4.serve() auth = restart_with_llm(ctx, llm4) status, body = advisor_api(auth, "POST", "/api/advisor/chat", {"title": "dirty 400"}) dirty_chat = int(body["chat_id"]) status, _ = advisor_api(auth, "POST", "/api/advisor/turn", {"chat_id": dirty_chat, "message": "go"}) check(status == 200, "the dirty-400 turn is accepted") # A fresh chat is already 'idle' — wait on the error row itself. def error_row(): rows = [r for r in chat_rows(dirty_chat, "kind, content") if r[0] == "error"] return rows or False rows = wait_for(error_row, timeout=25, what="the 400 error row") check(rows is not None and "HTTP 400" in rows[0][1], "the provider 400 lands in the chat's error row") try: rows[0][1].encode("utf-8", "strict") ok = True except UnicodeEncodeError: ok = False check(ok, "the error row content is encodable UTF-8") # And the poisoned-error-row chat must still hold a next turn: its # wire rebuild sanitises the notice instead of failing the turn. llm5 = ScriptedLlm([{"content": "Still alive."}]) llm5.serve() auth = restart_with_llm(ctx, llm5) status, _ = advisor_api(auth, "POST", "/api/advisor/turn", {"chat_id": dirty_chat, "message": "continue after the error"}) check(status == 200, "the post-error turn is accepted") check(wait_for(lambda: len(llm5.requests) >= 1 or False, timeout=25, what="the post-error LLM call") is not None, "the post-error turn reaches the LLM") notices = [m.get("content", "") for r in llm5.requests for m in (r.get("messages") or []) if m.get("role") == "user" and "notice]" in (m.get("content") or "")] check(len(notices) >= 1, "the error notice rides the rebuilt wire") for notice in notices: try: notice.encode("utf-8", "strict") ok = True except UnicodeEncodeError: ok = False check(ok, "the rebuilt error notice is encodable UTF-8")