diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 9a6527dcdb..b8e273c785 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -137,6 +137,8 @@ class APIServerAdapter(BasePlatformAdapter): self._runner: Optional["web.AppRunner"] = None self._site: Optional["web.TCPSite"] = None self._response_store = ResponseStore() + # Conversation name → latest response_id mapping + self._conversations: Dict[str, str] = {} # ------------------------------------------------------------------ # Auth helper @@ -370,8 +372,21 @@ class APIServerAdapter(BasePlatformAdapter): instructions = body.get("instructions") previous_response_id = body.get("previous_response_id") + conversation = body.get("conversation") store = body.get("store", True) + # conversation and previous_response_id are mutually exclusive + if conversation and previous_response_id: + return web.json_response( + {"error": {"message": "Cannot use both 'conversation' and 'previous_response_id'", "type": "invalid_request_error"}}, + status=400, + ) + + # Resolve conversation name to latest response_id + if conversation: + previous_response_id = self._conversations.get(conversation) + # No error if conversation doesn't exist yet — it's a new conversation + # Normalize input to message list input_messages: List[Dict[str, str]] = [] if isinstance(raw_input, str): @@ -489,6 +504,10 @@ class APIServerAdapter(BasePlatformAdapter): "conversation_history": full_history, "instructions": instructions, }) + # Update conversation mapping so the next request with the same + # conversation name automatically chains to this response + if conversation: + self._conversations[conversation] = response_id return web.json_response(response_data) diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 3e701dcadb..89cc8b82bc 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -1159,3 +1159,123 @@ class TestCORS: assert resp.status == 200 assert resp.headers.get("Access-Control-Allow-Origin") == "*" assert "Authorization" in resp.headers.get("Access-Control-Allow-Headers", "") + + +# --------------------------------------------------------------------------- +# Conversation parameter +# --------------------------------------------------------------------------- + + +class TestConversationParameter: + @pytest.mark.asyncio + async def test_conversation_creates_new(self, adapter): + """First request with a conversation name works (new conversation).""" + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = ( + {"final_response": "Hello!", "messages": [], "api_calls": 1}, + {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + ) + resp = await cli.post("/v1/responses", json={ + "input": "hi", + "conversation": "my-chat", + }) + assert resp.status == 200 + data = await resp.json() + assert data["status"] == "completed" + # Conversation mapping should be set + assert "my-chat" in adapter._conversations + + @pytest.mark.asyncio + async def test_conversation_chains_automatically(self, adapter): + """Second request with same conversation name chains to first.""" + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = ( + {"final_response": "First response", "messages": [], "api_calls": 1}, + {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + ) + # First request + resp1 = await cli.post("/v1/responses", json={ + "input": "hello", + "conversation": "test-conv", + }) + assert resp1.status == 200 + data1 = await resp1.json() + resp1_id = data1["id"] + + # Second request — should chain + mock_run.return_value = ( + {"final_response": "Second response", "messages": [], "api_calls": 1}, + {"input_tokens": 20, "output_tokens": 10, "total_tokens": 30}, + ) + resp2 = await cli.post("/v1/responses", json={ + "input": "follow up", + "conversation": "test-conv", + }) + assert resp2.status == 200 + + # The second call should have received conversation history from the first + assert mock_run.call_count == 2 + second_call_kwargs = mock_run.call_args_list[1] + history = second_call_kwargs.kwargs.get("conversation_history", + second_call_kwargs[1].get("conversation_history", []) if len(second_call_kwargs) > 1 else []) + # History should be non-empty (contains messages from first response) + assert len(history) > 0 + + @pytest.mark.asyncio + async def test_conversation_and_previous_response_id_conflict(self, adapter): + """Cannot use both conversation and previous_response_id.""" + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post("/v1/responses", json={ + "input": "hi", + "conversation": "my-chat", + "previous_response_id": "resp_abc123", + }) + assert resp.status == 400 + data = await resp.json() + assert "Cannot use both" in data["error"]["message"] + + @pytest.mark.asyncio + async def test_separate_conversations_are_isolated(self, adapter): + """Different conversation names have independent histories.""" + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = ( + {"final_response": "Response A", "messages": [], "api_calls": 1}, + {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + ) + # Conversation A + await cli.post("/v1/responses", json={"input": "conv-a msg", "conversation": "conv-a"}) + # Conversation B + mock_run.return_value = ( + {"final_response": "Response B", "messages": [], "api_calls": 1}, + {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + ) + await cli.post("/v1/responses", json={"input": "conv-b msg", "conversation": "conv-b"}) + + # They should have different response IDs in the mapping + assert adapter._conversations["conv-a"] != adapter._conversations["conv-b"] + + @pytest.mark.asyncio + async def test_conversation_store_false_no_mapping(self, adapter): + """If store=false, conversation mapping is not updated.""" + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = ( + {"final_response": "Ephemeral", "messages": [], "api_calls": 1}, + {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + ) + resp = await cli.post("/v1/responses", json={ + "input": "hi", + "conversation": "ephemeral-chat", + "store": False, + }) + assert resp.status == 200 + # Conversation mapping should NOT be set since store=false + assert "ephemeral-chat" not in adapter._conversations