diff --git a/backend/app/gateway/routers/runs.py b/backend/app/gateway/routers/runs.py index 2f09ac8ac..9cf4e701b 100644 --- a/backend/app/gateway/routers/runs.py +++ b/backend/app/gateway/routers/runs.py @@ -26,7 +26,7 @@ router = APIRouter(prefix="/api/runs", tags=["runs"]) def _resolve_thread_id(body: RunCreateRequest) -> str: """Return the thread_id from the request body, or generate a new one.""" - thread_id = (body.config or {}).get("configurable", {}).get("thread_id") + thread_id = ((body.config or {}).get("configurable") or {}).get("thread_id") if thread_id: return str(thread_id) return str(uuid.uuid4()) diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py index 48d7eb45c..ef49a424c 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -466,7 +466,7 @@ def build_run_config( logger.warning( "build_run_config: client sent both 'context' and 'configurable'; preferring 'context' (LangGraph >= 0.6.0). thread_id=%s, caller_configurable keys=%s", thread_id, - list(request_config.get("configurable", {}).keys()), + list((request_config.get("configurable") or {}).keys()), ) context_value = request_config["context"] if context_value is None: @@ -493,7 +493,7 @@ def build_run_config( config["configurable"] = {"thread_id": thread_id} else: configurable = {"thread_id": thread_id} - configurable.update(request_config.get("configurable", {})) + configurable.update(request_config.get("configurable") or {}) config["configurable"] = configurable for k, v in request_config.items(): if k not in ("configurable", "context"): diff --git a/backend/tests/test_runs_api_endpoints.py b/backend/tests/test_runs_api_endpoints.py index fbb7bdce3..12ab7f1dd 100644 --- a/backend/tests/test_runs_api_endpoints.py +++ b/backend/tests/test_runs_api_endpoints.py @@ -304,3 +304,41 @@ class TestRunFeedback: with TestClient(app) as client: response = client.get("/api/runs/run-fb-3/feedback") assert response.status_code == 503 + + +def test_resolve_thread_id_handles_null_configurable(): + """A client may send ``config.configurable`` as JSON ``null``. + + The key is then present with value ``None``, so the old + ``.get("configurable", {}).get("thread_id")`` raised ``AttributeError`` + (an unhandled HTTP 500). Per the docstring it should generate a new id. + """ + import uuid + + from app.gateway.routers.thread_runs import RunCreateRequest + + tid = runs._resolve_thread_id(RunCreateRequest(config={"configurable": None})) + uuid.UUID(tid) # a freshly generated id, not a crash + + # working inputs are unaffected + assert runs._resolve_thread_id(RunCreateRequest(config={"configurable": {"thread_id": "t1"}})) == "t1" + + +def test_build_run_config_handles_null_configurable(): + """A null ``configurable`` must also survive ``build_run_config``. + + ``_resolve_thread_id`` is not the only place that reads it: ``build_run_config`` + does ``configurable.update(request_config.get("configurable", {}))`` and, in the + ``context`` branch, ``request_config.get("configurable", {}).keys()``. With the + key present and ``None``, ``.get(..., {})`` returns ``None``, so both raised + (``dict.update(None)`` / ``None.keys()``) -- an unhandled HTTP 500 that the + isolated ``_resolve_thread_id`` test could not catch. + """ + from app.gateway.services import build_run_config + + config = build_run_config("t1", {"configurable": None}, None) + assert config["configurable"]["thread_id"] == "t1" + + # the context branch logs the caller's configurable keys; a null value must not crash + config = build_run_config("t1", {"context": {}, "configurable": None}, None) + assert config["configurable"]["thread_id"] == "t1"