From 5bf93c78f135858bbe2bcb94617081122ea4c466 Mon Sep 17 00:00:00 2001 From: grandcamel Date: Wed, 12 Aug 2026 14:53:18 -0500 Subject: [PATCH 1/2] fix(ai_chat): forward conversation_timeout on the start path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prepare() puts conversation_timeout into the start params, but the HTTP dispatch rebuilt the create_conversation call with only id and config_url, silently dropping it. A gateway configured with conversation_timeout=900 told the browser 900 (via effective_timeout) while the service kept its own 3600 default — the page schedules its idle warning around a number the service never enforces. The chat path was unaffected (raw_post streams params verbatim), which kept the drift invisible for conversations opened by a first message. The regression test drives the real HTTP dispatch through the ASGI harness — prepare() was already correct, so a prepare()-level assertion would have passed with the bug in place. Verified red without the fix, green with it; it also pins the auto-create chat path so the two paths cannot drift apart again. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MgA3KeCEPMKMJVvroZY1wV --- signalwire/signalwire/ai_chat/gateway.py | 7 ++++- tests/unit/ai_chat/test_gateway.py | 37 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/signalwire/signalwire/ai_chat/gateway.py b/signalwire/signalwire/ai_chat/gateway.py index e2f4f5f..acc916d 100644 --- a/signalwire/signalwire/ai_chat/gateway.py +++ b/signalwire/signalwire/ai_chat/gateway.py @@ -481,8 +481,13 @@ async def proxy(request: Request) -> Response: return JSONResponse({"status": "ended"}, headers=cors) if method == "create_conversation": + # prepare() decides whether a timeout applies; dropping it here + # would report one number to the browser below while the + # service quietly keeps its own default. info = await self._client.create_conversation( - params["id"], config_url=params["config_url"] + params["id"], + config_url=params["config_url"], + timeout=params.get("conversation_timeout"), ) if minted: cors["X-Chat-Handle"] = minted diff --git a/tests/unit/ai_chat/test_gateway.py b/tests/unit/ai_chat/test_gateway.py index 9e78d67..6bc795f 100644 --- a/tests/unit/ai_chat/test_gateway.py +++ b/tests/unit/ai_chat/test_gateway.py @@ -541,3 +541,40 @@ async def test_start_then_reload_replays_the_same_conversation(gateway, service) assert "messages" in replay.json() # and it asked the service for the conversation the handle names assert service.seen[-1]["params"]["id"] == gateway.read_handle(handle) + + +async def test_start_forwards_the_configured_timeout_upstream(service): + """prepare() puts conversation_timeout in the start params, but the HTTP + dispatch used to rebuild the create_conversation call and drop it — the + browser was told 900 while the service kept its 3600 default. The number + the page schedules its idle warning around must be the number the service + actually enforces.""" + gw = make_gateway( + service, + allowed_origins=["https://shop.example.com"], + conversation_timeout=900, + ) + try: + async with asgi(gw) as http: + started = await http.post( + "/chat/", json={"method": "start"}, headers=HEADERS + ) + assert started.status_code == 200 + assert started.json()["timeout"] == 900 + sent = service.seen[-1] + assert sent["method"] == "create_conversation" + assert sent["params"]["conversation_timeout"] == 900 + + # The chat path auto-creates too, and takes the same timeout. + handle = started.headers["x-chat-handle"] + chatted = await http.post( + "/chat/", + json={"message": "hi", "handle": handle}, + headers=HEADERS, + ) + assert chatted.status_code == 200 + sent = service.seen[-1] + assert sent["method"] == "chat" + assert sent["params"]["conversation_timeout"] == 900 + finally: + await gw._client.close() From 8f00976e75ee295555c4805bdfff094ab4af043c Mon Sep 17 00:00:00 2001 From: Anthony Minessale II Date: Thu, 13 Aug 2026 10:23:25 -0500 Subject: [PATCH 2/2] test: annotate the new test so mypy --strict stays clean The config puts tests in scope on purpose ("a new untyped test fails the gate"), so an unannotated test function reds TYPECHECK on main. --- tests/unit/ai_chat/test_gateway.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/ai_chat/test_gateway.py b/tests/unit/ai_chat/test_gateway.py index 6bc795f..f554c64 100644 --- a/tests/unit/ai_chat/test_gateway.py +++ b/tests/unit/ai_chat/test_gateway.py @@ -543,7 +543,7 @@ async def test_start_then_reload_replays_the_same_conversation(gateway, service) assert service.seen[-1]["params"]["id"] == gateway.read_handle(handle) -async def test_start_forwards_the_configured_timeout_upstream(service): +async def test_start_forwards_the_configured_timeout_upstream(service: Any) -> None: """prepare() puts conversation_timeout in the start params, but the HTTP dispatch used to rebuild the create_conversation call and drop it — the browser was told 900 while the service kept its 3600 default. The number