[Tested patch] Hacker News tools ignore JSON POST bodies after payload changed to Any
The three Hacker News POST tools on current main declare payload: Any = None, so FastAPI binds payload as a query parameter instead of reading the JSON body documented in the plugin README. A JSON search returns Missing required field: query; a JSON discussion request returns Missing required field: item_id; the front-page request ignores its JSON limit. The existing 10 dependency-free tests pass despite this regression because they call the handlers directly.
The complete four-file repair below is implemented and tested: explicitly declare Annotated[Any, Body()], preserve the ordinary None default and existing non-object handling, add real HTTP-level regression coverage, update the framework stub, and document the new test command. The tests execute real FastAPI routing, Pydantic response serialization, and HTTPX request/response processing; only the outbound Algolia transport is mocked. No live services or credentials are used.
Verification performed
Unmodified existing suite: 10 tests passed
New HTTP suite against original code: 13 methods, 17 failures including subtests
Patched existing suite: 10 tests passed
Patched HTTP suite: 13 tests passed
Combined discovery: 23 tests passed, no skips
Python compileall: passed
git diff --check: passed
git apply --check on verified source: passed
Applied patch, combined discovery: 23 tests passedCommands:
python plugins/omi-hacker-news-app/test_main.py -v
python plugins/omi-hacker-news-app/test_http.py -v
python -m unittest discover -s plugins/omi-hacker-news-app -p 'test*.py' -v
python -m compileall -q plugins/omi-hacker-news-app
git apply --check repair.patch
git apply repair.patchSave the entire diff below as repair.patch to apply it. Source files were verified against their Git blob hashes, including main.py = f8d1470a1f8dd2ada327e94550c6c5b57b3f772e, test_main.py = e8414ac18da70ea9759e3c995c2c119067b4a5d0, and README = 44597e4cec3743af1f4c672b5cde12cafcbe68a1. Main and the existing test blob were rechecked against the default branch during this submission. Reference source commit: 27ef82c3a51ba144c061c1a90f21ac7c46c96b93.
Environment: Python 3.13.5, FastAPI 0.128.2, Pydantic 2.13.4, HTTPX 0.28.1, Starlette 0.50.0. The plugin-pinned FastAPI 0.115.6 / Pydantic 2.10.4 environment is not verified: the package installation attempt could not obtain those packages in this environment. Repository-wide preflight, integration of the new HTTP runner into the shared CI manifest, and deployed-service verification are also not claimed. This is a tested source patch, not an upstream PR or a claim of complete CI qualification.
The repair is committed locally as 777e6799b62b9479fc1578af1b4fc41891c6225a in a source-verified subset repository, not a full clone of upstream history. That local SHA is not presented as an existing GitHub commit. The connector available here cannot create a fork, so the full patch is supplied directly rather than claiming a PR was opened.
Related: #14283 is the recent input-hardening change. The broader pending #14195 overlaps this area and may incorporate the same correction; this submission makes no claim on its author's work or any existing reward. The HTTP tests can serve as independent regression coverage for that work.
Reference for explicit body binding: https://fastapi.tiangolo.com/tutorial/body-multiple-params/#singular-values-in-body
@josancamon19 @kodjima33 — submitting this delivered patch for consideration of a €125 bounty under the contribution guide's bounty-suggestion process. No bounty approval, acceptance, or payment is assumed.
Disclosure: investigation, implementation, testing, and this submission were performed with GPT-6 Astra Pro assistance on behalf of @Python840. No separate human review is claimed.
Complete four-file patchdiff --git a/plugins/omi-hacker-news-app/README.md b/plugins/omi-hacker-news-app/README.md
index 44597e4..ae9974a 100644
--- a/plugins/omi-hacker-news-app/README.md
+++ b/plugins/omi-hacker-news-app/README.md
@@ -32,6 +32,17 @@ real text cleaner and discussion handler without network access. They cover
escaped literal angle brackets, real provider markup, code formatting, and text
preservation in both post and comment output.
+With the plugin runtime dependencies installed, also run the HTTP contract suite:
+
+```bash
+python3 plugins/omi-hacker-news-app/test_http.py
+```
+
+This suite uses real FastAPI routing, Pydantic response models, and HTTPX. Only
+the outbound Algolia transport is mocked. It verifies the documented unembedded
+JSON POST bodies, optional-body defaults, limits, error envelopes, and OpenAPI.
+Direct handler calls alone cannot detect a body/query binding regression.
+
## Deployment
Deploy this folder as a standalone FastAPI service. No environment variables are required.
diff --git a/plugins/omi-hacker-news-app/main.py b/plugins/omi-hacker-news-app/main.py
index f8d1470..ea5778a 100644
--- a/plugins/omi-hacker-news-app/main.py
+++ b/plugins/omi-hacker-news-app/main.py
@@ -7,10 +7,10 @@ and fetching an item with top-level comments.
from html import unescape
import re
-from typing import Any, Optional
+from typing import Annotated, Any, Optional
import httpx
-from fastapi import FastAPI
+from fastapi import Body, FastAPI
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
@@ -192,7 +192,7 @@ async def get_omi_tools_manifest():
@app.post("/tools/get_front_page", tags=["chat_tools"], response_model=ChatToolResponse)
-async def get_front_page(payload: Any = None):
+async def get_front_page(payload: Annotated[Any, Body()] = None):
payload = _safe_payload(payload)
try:
limit = _safe_limit(payload.get("limit"))
@@ -209,7 +209,7 @@ async def get_front_page(payload: Any = None):
@app.post("/tools/search_stories", tags=["chat_tools"], response_model=ChatToolResponse)
-async def search_stories(payload: Any = None):
+async def search_stories(payload: Annotated[Any, Body()] = None):
payload = _safe_payload(payload)
query = (payload.get("query") or "").strip()
if not query:
@@ -232,7 +232,7 @@ async def search_stories(payload: Any = None):
@app.post("/tools/get_discussion", tags=["chat_tools"], response_model=ChatToolResponse)
-async def get_discussion(payload: Any = None):
+async def get_discussion(payload: Annotated[Any, Body()] = None):
payload = _safe_payload(payload)
item_id = payload.get("item_id")
if item_id is None or item_id == "" or isinstance(item_id, bool):
diff --git a/plugins/omi-hacker-news-app/test_http.py b/plugins/omi-hacker-news-app/test_http.py
new file mode 100644
index 0000000..5cc5e90
--- /dev/null
+++ b/plugins/omi-hacker-news-app/test_http.py
@@ -0,0 +1,234 @@
+"""HTTP contract regressions using real FastAPI routing and response models.
+
+Install this plugin's requirements.txt, then run this file. Only the outbound
+Algolia transport is replaced; requests to the app use HTTPX ASGITransport.
+No live network, credentials, or framework stubs are used.
+"""
+
+import importlib.util
+from pathlib import Path
+import unittest
+from unittest.mock import patch
+
+import httpx
+
+
+_spec = importlib.util.spec_from_file_location(
+ "hacker_news_http_app", Path(__file__).with_name("main.py")
+)
+main = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(main)
+
+_REAL_ASYNC_CLIENT = httpx.AsyncClient
+_ROUTES = (
+ ("get_front_page", {"limit": 2}, "Hacker News request failed:"),
+ ("search_stories", {"query": "python"}, "Hacker News search failed:"),
+ ("get_discussion", {"item_id": 8863}, "Hacker News discussion request failed:"),
+)
+
+
+class HTTPContractTests(unittest.IsolatedAsyncioTestCase):
+ async def asyncSetUp(self):
+ self.requests = []
+ self.provider_status = 200
+ self.provider_timeout = False
+ self.provider_empty = False
+ self.client = _REAL_ASYNC_CLIENT(
+ transport=httpx.ASGITransport(app=main.app),
+ base_url="http://test.local",
+ )
+ # Capture the real class before patching its constructor in main's module.
+ # Every provider client still has real HTTPX request/response processing.
+ self.client_patch = patch.object(
+ main.httpx,
+ "AsyncClient",
+ side_effect=lambda **kwargs: _REAL_ASYNC_CLIENT(
+ transport=httpx.MockTransport(self.provider), **kwargs
+ ),
+ )
+ self.client_patch.start()
+ self.addCleanup(self.client_patch.stop)
+ self.addAsyncCleanup(self.client.aclose)
+
+ def provider(self, request):
+ self.requests.append(request)
+ self.assertEqual(request.url.host, "hn.algolia.com")
+ self.assertEqual(request.url.scheme, "https")
+ if self.provider_timeout:
+ raise httpx.ReadTimeout("fixture timeout", request=request)
+ if self.provider_status != 200:
+ return httpx.Response(self.provider_status, json={"message": "fixture"})
+ if request.url.path.startswith("/api/v1/items/"):
+ return httpx.Response(
+ 200,
+ json={
+ "title": "Discussion fixture",
+ "author": "alice",
+ "points": 7,
+ "text": "<p>Use <vector> here.</p>",
+ "children": [
+ {"author": "bob", "text": "<p>First comment</p>"},
+ {"author": "carol", "text": "<p>Second comment</p>"},
+ ],
+ },
+ )
+ self.assertIn(request.url.path, ("/api/v1/search", "/api/v1/search_by_date"))
+ hits = [] if self.provider_empty else [
+ {"title": f"Story {n}", "author": "alice", "objectID": str(n)}
+ for n in range(1, 4)
+ ]
+ return httpx.Response(200, json={"hits": hits})
+
+ async def post(self, route, **kwargs):
+ response = await self.client.post(f"/tools/{route}", **kwargs)
+ self.assertEqual(response.status_code, 200, response.text)
+ body = response.json()
+ self.assertEqual(set(body), {"result", "error"})
+ return body
+
+ async def test_front_page_reads_limit_from_json_body(self):
+ body = await self.post("get_front_page", json={"limit": 2})
+ self.assertIsNone(body["error"])
+ self.assertIn("2. Story 2", body["result"])
+ self.assertNotIn("3. Story 3", body["result"])
+ self.assertEqual(len(self.requests), 1)
+ self.assertEqual(dict(self.requests[0].url.params), {
+ "tags": "front_page", "hitsPerPage": "2"
+ })
+
+ async def test_search_reads_query_sort_and_limit_from_json_body(self):
+ body = await self.post(
+ "search_stories", json={"query": " python ", "sort_by": "date", "limit": 2}
+ )
+ self.assertIsNone(body["error"])
+ self.assertIn("Hacker News stories for 'python'", body["result"])
+ self.assertNotIn("3. Story 3", body["result"])
+ self.assertEqual(len(self.requests), 1)
+ self.assertEqual(self.requests[0].url.path, "/api/v1/search_by_date")
+ self.assertEqual(dict(self.requests[0].url.params), {
+ "query": "python", "tags": "story", "hitsPerPage": "2"
+ })
+
+ async def test_discussion_reads_id_and_comment_limit_from_json_body(self):
+ body = await self.post("get_discussion", json={"item_id": 8863, "comment_limit": 1})
+ self.assertIsNone(body["error"])
+ self.assertIn("Post text:\nUse <vector> here.", body["result"])
+ self.assertIn("Top 1 comments:", body["result"])
+ self.assertIn("1. bob: First comment", body["result"])
+ self.assertNotIn("Second comment", body["result"])
+ self.assertEqual(len(self.requests), 1)
+ self.assertEqual(self.requests[0].url.path, "/api/v1/items/8863")
+
+ async def test_body_is_not_replaced_by_query_parameters(self):
+ body = await self.post(
+ "search_stories",
+ params={"payload": '{"query":"wrong"}', "query": "wrong"},
+ json={"query": "right", "limit": 1},
+ )
+ self.assertIsNone(body["error"])
+ self.assertEqual(len(self.requests), 1)
+ self.assertEqual(self.requests[0].url.params["query"], "right")
+ self.assertEqual(self.requests[0].url.params["hitsPerPage"], "1")
+
+ async def test_omitted_null_and_non_object_bodies_keep_existing_defaults(self):
+ cases = [{}, *[
+ {"content": value, "headers": {"Content-Type": "application/json"}}
+ for value in ("null", "[]", '"text"', "5", "true", "{}")
+ ]]
+ for kwargs in cases:
+ with self.subTest(body=kwargs):
+ self.requests.clear()
+ front = await self.post("get_front_page", **kwargs)
+ self.assertIsNone(front["error"])
+ self.assertEqual(len(self.requests), 1)
+ self.assertEqual(self.requests[0].url.params["hitsPerPage"], "10")
+ self.requests.clear()
+ search = await self.post("search_stories", **kwargs)
+ self.assertEqual(search["error"], "Missing required field: query")
+ discussion = await self.post("get_discussion", **kwargs)
+ self.assertEqual(discussion["error"], "Missing required field: item_id")
+ self.assertEqual(self.requests, [])
+
+ async def test_front_page_limit_defaults_and_clamps_are_preserved(self):
+ for limit, expected in ((None, 10), (True, 10), ("bad", 10), (0, 1), (99, 20)):
+ with self.subTest(limit=limit):
+ self.requests.clear()
+ body = await self.post("get_front_page", json={"limit": limit})
+ self.assertIsNone(body["error"])
+ self.assertEqual(len(self.requests), 1)
+ self.assertEqual(self.requests[0].url.params["hitsPerPage"], str(expected))
+
+ async def test_provider_http_errors_keep_tool_error_envelopes(self):
+ self.provider_status = 503
+ for route, payload, prefix in _ROUTES:
+ with self.subTest(route=route):
+ self.requests.clear()
+ body = await self.post(route, json=payload)
+ self.assertIsNone(body["result"])
+ self.assertTrue(body["error"].startswith(prefix), body)
+ self.assertEqual(len(self.requests), 1)
+
+ async def test_provider_timeouts_keep_tool_error_envelopes(self):
+ self.provider_timeout = True
+ for route, payload, prefix in _ROUTES:
+ with self.subTest(route=route):
+ self.requests.clear()
+ body = await self.post(route, json=payload)
+ self.assertIsNone(body["result"])
+ self.assertTrue(body["error"].startswith(prefix), body)
+ self.assertIn("fixture timeout", body["error"])
+ self.assertEqual(len(self.requests), 1)
+
+ async def test_empty_search_uses_json_query_and_default_sort(self):
+ self.provider_empty = True
+ body = await self.post("search_stories", json={"query": "absent"})
+ self.assertEqual(body, {
+ "result": "No Hacker News stories found for 'absent'.", "error": None
+ })
+ self.assertEqual(len(self.requests), 1)
+ self.assertEqual(self.requests[0].url.path, "/api/v1/search")
+ self.assertEqual(self.requests[0].url.params["hitsPerPage"], "10")
+
+ async def test_invalid_json_is_rejected_before_provider_request(self):
+ for route, _, _ in _ROUTES:
+ with self.subTest(route=route):
+ self.requests.clear()
+ response = await self.client.post(
+ f"/tools/{route}", content="{",
+ headers={"Content-Type": "application/json"},
+ )
+ self.assertEqual(response.status_code, 422)
+ self.assertEqual(self.requests, [])
+
+ async def test_openapi_declares_json_body_not_payload_query_parameter(self):
+ response = await self.client.get("/openapi.json")
+ self.assertEqual(response.status_code, 200)
+ for route, _, _ in _ROUTES:
+ with self.subTest(route=route):
+ operation = response.json()["paths"][f"/tools/{route}"]["post"]
+ self.assertIn("application/json", operation.get("requestBody", {}).get("content", {}))
+ self.assertFalse(operation["requestBody"].get("required", False))
+ self.assertFalse(any(p["name"] == "payload" for p in operation.get("parameters", [])))
+
+ async def test_direct_python_call_defaults_remain_none(self):
+ self.assertIsNone((await main.get_front_page()).error)
+ self.assertEqual(self.requests[0].url.params["hitsPerPage"], "10")
+ self.requests.clear()
+ self.assertEqual((await main.search_stories()).error, "Missing required field: query")
+ self.assertEqual((await main.get_discussion()).error, "Missing required field: item_id")
+ self.assertEqual(self.requests, [])
+
+ async def test_health_and_tool_manifest_remain_available(self):
+ health = await self.client.get("/health")
+ self.assertEqual(health.json(), {"status": "ok"})
+ manifest = await self.client.get("/.well-known/omi-tools.json")
+ self.assertEqual(manifest.status_code, 200)
+ self.assertEqual(
+ {t["endpoint"] for t in manifest.json()["tools"]},
+ {f"/tools/{route}" for route, _, _ in _ROUTES},
+ )
+ self.assertEqual(self.requests, [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/plugins/omi-hacker-news-app/test_main.py b/plugins/omi-hacker-news-app/test_main.py
index e8414ac..d51504f 100644
--- a/plugins/omi-hacker-news-app/test_main.py
+++ b/plugins/omi-hacker-news-app/test_main.py
@@ -35,6 +35,7 @@ def load_app():
httpx.HTTPError = HTTPError
fastapi = ModuleType("fastapi")
fastapi.FastAPI = FastAPI
+ fastapi.Body = lambda *args, **kwargs: None
responses = ModuleType("fastapi.responses")
responses.HTMLResponse = str
pydantic = ModuleType("pydantic")Source: BasedHardware/omi