From b92471ff1c85448251f719d9a662e89718c0be81 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Thu, 30 Oct 2025 11:40:14 +0100 Subject: [PATCH 01/11] refactor: ZMQ context and proxy Use ZMQ's global context instance and setup an XPUB/XSUB proxy intermediary to allow for easier multi-pubs. close: N25B-217 --- .../agents/ri_command_agent.py | 9 +++-- .../agents/ri_communication_agent.py | 15 ++++---- .../transcription/transcription_agent.py | 6 +-- src/control_backend/agents/vad_agent.py | 5 +-- .../api/v1/endpoints/command.py | 14 ++++--- .../api/v1/endpoints/message.py | 10 +++-- src/control_backend/core/config.py | 5 ++- src/control_backend/core/zmq_context.py | 3 -- src/control_backend/main.py | 36 +++++++++++------- .../api/endpoints/test_command_endpoint.py | 38 +++++++++++++++++-- 10 files changed, 92 insertions(+), 49 deletions(-) delete mode 100644 src/control_backend/core/zmq_context.py diff --git a/src/control_backend/agents/ri_command_agent.py b/src/control_backend/agents/ri_command_agent.py index 01fc824..0dcc981 100644 --- a/src/control_backend/agents/ri_command_agent.py +++ b/src/control_backend/agents/ri_command_agent.py @@ -1,11 +1,12 @@ import json import logging + +import zmq from spade.agent import Agent from spade.behaviour import CyclicBehaviour -import zmq +from zmq.asyncio import Context from control_backend.core.config import settings -from control_backend.core.zmq_context import context from control_backend.schemas.ri_message import SpeechCommand logger = logging.getLogger(__name__) @@ -55,6 +56,8 @@ class RICommandAgent(Agent): """ logger.info("Setting up %s", self.jid) + context = Context.instance() + # To the robot self.pubsocket = context.socket(zmq.PUB) if self.bind: @@ -64,7 +67,7 @@ class RICommandAgent(Agent): # Receive internal topics regarding commands self.subsocket = context.socket(zmq.SUB) - self.subsocket.connect(settings.zmq_settings.internal_comm_address) + self.subsocket.connect(settings.zmq_settings.internal_sub_address) self.subsocket.setsockopt(zmq.SUBSCRIBE, b"command") # Add behaviour to our agent diff --git a/src/control_backend/agents/ri_communication_agent.py b/src/control_backend/agents/ri_communication_agent.py index 504c707..638b967 100644 --- a/src/control_backend/agents/ri_communication_agent.py +++ b/src/control_backend/agents/ri_communication_agent.py @@ -1,14 +1,13 @@ import asyncio -import json import logging + +import zmq from spade.agent import Agent from spade.behaviour import CyclicBehaviour -import zmq +from zmq.asyncio import Context -from control_backend.core.config import settings -from control_backend.core.zmq_context import context -from control_backend.schemas.message import Message from control_backend.agents.ri_command_agent import RICommandAgent +from control_backend.core.config import settings logger = logging.getLogger(__name__) @@ -47,7 +46,7 @@ class RICommunicationAgent(Agent): message = await asyncio.wait_for(self.agent.req_socket.recv_json(), timeout=3.0) # We didnt get a reply :( - except asyncio.TimeoutError as e: + except TimeoutError: logger.info("No ping retrieved in 3 seconds, killing myself.") self.kill() @@ -75,7 +74,7 @@ class RICommunicationAgent(Agent): # Let's try a certain amount of times before failing connection while retries < max_retries: # Bind request socket - self.req_socket = context.socket(zmq.REQ) + self.req_socket = Context.instance().socket(zmq.REQ) if self._bind: self.req_socket.bind(self._address) else: @@ -88,7 +87,7 @@ class RICommunicationAgent(Agent): try: received_message = await asyncio.wait_for(self.req_socket.recv_json(), timeout=20.0) - except asyncio.TimeoutError: + except TimeoutError: logger.warning( "No connection established in 20 seconds (attempt %d/%d)", retries + 1, diff --git a/src/control_backend/agents/transcription/transcription_agent.py b/src/control_backend/agents/transcription/transcription_agent.py index a2c8e2b..530bd68 100644 --- a/src/control_backend/agents/transcription/transcription_agent.py +++ b/src/control_backend/agents/transcription/transcription_agent.py @@ -10,7 +10,6 @@ from spade.message import Message from control_backend.agents.transcription.speech_recognizer import SpeechRecognizer from control_backend.core.config import settings -from control_backend.core.zmq_context import context as zmq_context logger = logging.getLogger(__name__) @@ -47,7 +46,8 @@ class TranscriptionAgent(Agent): """Share a transcription to the other agents that depend on it.""" receiver_jids = [ settings.agent_settings.text_belief_extractor_agent_name - + '@' + settings.agent_settings.host, + + "@" + + settings.agent_settings.host, ] # Set message receivers here for receiver_jid in receiver_jids: @@ -68,7 +68,7 @@ class TranscriptionAgent(Agent): return await super().stop() def _connect_audio_in_socket(self): - self.audio_in_socket = zmq_context.socket(zmq.SUB) + self.audio_in_socket = azmq.Context.instance().socket(zmq.SUB) self.audio_in_socket.setsockopt_string(zmq.SUBSCRIBE, "") self.audio_in_socket.connect(self.audio_in_address) diff --git a/src/control_backend/agents/vad_agent.py b/src/control_backend/agents/vad_agent.py index a228135..f16abf4 100644 --- a/src/control_backend/agents/vad_agent.py +++ b/src/control_backend/agents/vad_agent.py @@ -9,7 +9,6 @@ from spade.behaviour import CyclicBehaviour from control_backend.agents.transcription import TranscriptionAgent from control_backend.core.config import settings -from control_backend.core.zmq_context import context as zmq_context logger = logging.getLogger(__name__) @@ -121,7 +120,7 @@ class VADAgent(Agent): return await super().stop() def _connect_audio_in_socket(self): - self.audio_in_socket = zmq_context.socket(zmq.SUB) + self.audio_in_socket = azmq.Context.instance().socket(zmq.SUB) self.audio_in_socket.setsockopt_string(zmq.SUBSCRIBE, "") if self.audio_in_bind: self.audio_in_socket.bind(self.audio_in_address) @@ -132,7 +131,7 @@ class VADAgent(Agent): def _connect_audio_out_socket(self) -> int | None: """Returns the port bound, or None if binding failed.""" try: - self.audio_out_socket = zmq_context.socket(zmq.PUB) + self.audio_out_socket = azmq.Context.instance().socket(zmq.PUB) return self.audio_out_socket.bind_to_random_port("tcp://*", max_tries=100) except zmq.ZMQBindError: logger.error("Failed to bind an audio output socket after 100 tries.") diff --git a/src/control_backend/api/v1/endpoints/command.py b/src/control_backend/api/v1/endpoints/command.py index badaf90..88c859b 100644 --- a/src/control_backend/api/v1/endpoints/command.py +++ b/src/control_backend/api/v1/endpoints/command.py @@ -1,9 +1,11 @@ -from fastapi import APIRouter, Request import logging -from zmq import Socket +import zmq +from fastapi import APIRouter, Request +from zmq.asyncio import Context -from control_backend.schemas.ri_message import SpeechCommand, RIEndpoint +from control_backend.core.config import settings +from control_backend.schemas.ri_message import SpeechCommand logger = logging.getLogger(__name__) @@ -15,8 +17,8 @@ async def receive_command(command: SpeechCommand, request: Request): # Validate and retrieve data. SpeechCommand.model_validate(command) topic = b"command" - pub_socket: Socket = request.app.state.internal_comm_socket - pub_socket.send_multipart([topic, command.model_dump_json().encode()]) - + pub_socket = Context.instance().socket(zmq.PUB) + pub_socket.connect(settings.zmq_settings.internal_pub_address) + await pub_socket.send_multipart([topic, command.model_dump_json().encode()]) return {"status": "Command received"} diff --git a/src/control_backend/api/v1/endpoints/message.py b/src/control_backend/api/v1/endpoints/message.py index 1053c3c..1a58377 100644 --- a/src/control_backend/api/v1/endpoints/message.py +++ b/src/control_backend/api/v1/endpoints/message.py @@ -1,8 +1,10 @@ import logging +import zmq from fastapi import APIRouter, Request -from zmq import Socket +from zmq.asyncio import Context +from control_backend.core.config import settings from control_backend.schemas.message import Message logger = logging.getLogger(__name__) @@ -17,8 +19,8 @@ async def receive_message(message: Message, request: Request): topic = b"message" body = message.model_dump_json().encode("utf-8") - pub_socket: Socket = request.app.state.internal_comm_socket - - pub_socket.send_multipart([topic, body]) + pub_socket = Context.instance().socket(zmq.PUB) + pub_socket.bind(settings.zmq_settings.internal_pub_address) + await pub_socket.send_multipart([topic, body]) return {"status": "Message received"} diff --git a/src/control_backend/core/config.py b/src/control_backend/core/config.py index 5e4b764..8de2403 100644 --- a/src/control_backend/core/config.py +++ b/src/control_backend/core/config.py @@ -3,7 +3,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class ZMQSettings(BaseModel): - internal_comm_address: str = "tcp://localhost:5560" + internal_pub_address: str = "tcp://localhost:5560" + internal_sub_address: str = "tcp://localhost:5561" class AgentSettings(BaseModel): @@ -24,6 +25,7 @@ class LLMSettings(BaseModel): local_llm_url: str = "http://localhost:1234/v1/chat/completions" local_llm_model: str = "openai/gpt-oss-20b" + class Settings(BaseSettings): app_title: str = "PepperPlus" @@ -37,4 +39,5 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env") + settings = Settings() diff --git a/src/control_backend/core/zmq_context.py b/src/control_backend/core/zmq_context.py deleted file mode 100644 index a74544f..0000000 --- a/src/control_backend/core/zmq_context.py +++ /dev/null @@ -1,3 +0,0 @@ -from zmq.asyncio import Context - -context = Context() diff --git a/src/control_backend/main.py b/src/control_backend/main.py index d3588ea..1543882 100644 --- a/src/control_backend/main.py +++ b/src/control_backend/main.py @@ -7,17 +7,18 @@ import logging import zmq from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from zmq.asyncio import Context + +from control_backend.agents.bdi.bdi_core import BDICoreAgent +from control_backend.agents.bdi.text_extractor import TBeliefExtractor +from control_backend.agents.belief_collector.belief_collector import BeliefCollectorAgent +from control_backend.agents.llm.llm import LLMAgent # Internal imports from control_backend.agents.ri_communication_agent import RICommunicationAgent -from control_backend.agents.bdi.bdi_core import BDICoreAgent from control_backend.agents.vad_agent import VADAgent -from control_backend.agents.llm.llm import LLMAgent -from control_backend.agents.bdi.text_extractor import TBeliefExtractor -from control_backend.agents.belief_collector.belief_collector import BeliefCollectorAgent from control_backend.api.v1.router import api_router from control_backend.core.config import settings -from control_backend.core.zmq_context import context logger = logging.getLogger(__name__) logging.basicConfig(level=logging.DEBUG) @@ -28,12 +29,17 @@ async def lifespan(app: FastAPI): logger.info("%s starting up.", app.title) # Initiate sockets - internal_comm_socket = context.socket(zmq.PUB) - internal_comm_address = settings.zmq_settings.internal_comm_address - internal_comm_socket.bind(internal_comm_address) - app.state.internal_comm_socket = internal_comm_socket - logger.info("Internal publishing socket bound to %s", internal_comm_socket) + context = Context.instance() + internal_pub_socket = context.socket(zmq.XPUB) + internal_pub_socket.bind(settings.zmq_settings.internal_pub_address) + logger.debug("Internal publishing socket bound to %s", internal_pub_socket) + + internal_sub_socket = context.socket(zmq.XSUB) + internal_sub_socket.bind(settings.zmq_settings.internal_sub_address) + logger.debug("Internal subscribing socket bound to %s", internal_sub_socket) + + zmq.proxy(internal_pub_socket, internal_sub_socket) # Initiate agents ri_communication_agent = RICommunicationAgent( @@ -45,26 +51,28 @@ async def lifespan(app: FastAPI): await ri_communication_agent.start() llm_agent = LLMAgent( - settings.agent_settings.llm_agent_name + '@' + settings.agent_settings.host, + settings.agent_settings.llm_agent_name + "@" + settings.agent_settings.host, settings.agent_settings.llm_agent_name, ) await llm_agent.start() bdi_core = BDICoreAgent( - settings.agent_settings.bdi_core_agent_name + '@' + settings.agent_settings.host, + settings.agent_settings.bdi_core_agent_name + "@" + settings.agent_settings.host, settings.agent_settings.bdi_core_agent_name, "src/control_backend/agents/bdi/rules.asl", ) await bdi_core.start() belief_collector = BeliefCollectorAgent( - settings.agent_settings.belief_collector_agent_name + '@' + settings.agent_settings.host, + settings.agent_settings.belief_collector_agent_name + "@" + settings.agent_settings.host, settings.agent_settings.belief_collector_agent_name, ) await belief_collector.start() text_belief_extractor = TBeliefExtractor( - settings.agent_settings.text_belief_extractor_agent_name + '@' + settings.agent_settings.host, + settings.agent_settings.text_belief_extractor_agent_name + + "@" + + settings.agent_settings.host, settings.agent_settings.text_belief_extractor_agent_name, ) await text_belief_extractor.start() diff --git a/test/integration/api/endpoints/test_command_endpoint.py b/test/integration/api/endpoints/test_command_endpoint.py index 07bd866..7e38924 100644 --- a/test/integration/api/endpoints/test_command_endpoint.py +++ b/test/integration/api/endpoints/test_command_endpoint.py @@ -1,7 +1,8 @@ +from unittest.mock import AsyncMock, patch + import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from unittest.mock import MagicMock from control_backend.api.v1.endpoints import command from control_backend.schemas.ri_message import SpeechCommand @@ -15,7 +16,6 @@ def app(): """ app = FastAPI() app.include_router(command.router) - app.state.internal_comm_socket = MagicMock() # mock ZMQ socket return app @@ -25,12 +25,42 @@ def client(app): return TestClient(app) -def test_receive_command_endpoint(client, app): +@pytest.mark.asyncio +@patch("control_backend.api.endpoints.command.Context.instance") +async def test_receive_command_success(mock_context_instance, async_client): + """ + Test for successful reception of a command. + Ensures the status code is 202 and the response body is correct. + It also verifies that the ZeroMQ socket's send_multipart method is called with the expected data. + """ + # Arrange + mock_pub_socket = AsyncMock() + mock_context_instance.return_value.socket.return_value = mock_pub_socket + + command_data = {"command": "test_command", "text": "This is a test"} + speech_command = SpeechCommand(**command_data) + + # Act + response = await async_client.post("/command", json=command_data) + + # Assert + assert response.status_code == 202 + assert response.json() == {"status": "Command received"} + + # Verify that the ZMQ socket was used correctly + mock_context_instance.return_value.socket.assert_called_once_with(1) # zmq.PUB is 1 + mock_pub_socket.connect.assert_called_once() + mock_pub_socket.send_multipart.assert_awaited_once_with( + [b"command", speech_command.model_dump_json().encode()] + ) + + +def test_receive_command_endpoint(client, app, mocker): """ Test that a POST to /command sends the right multipart message and returns a 202 with the expected JSON body. """ - mock_socket = app.state.internal_comm_socket + mock_socket = mocker.patch.object() # Prepare test payload that matches SpeechCommand payload = {"endpoint": "actuate/speech", "data": "yooo"} From 10deb4bece5d0a915c319cdae141ca6f95db5f32 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Thu, 30 Oct 2025 12:52:18 +0100 Subject: [PATCH 02/11] fix: separate thread for proxy ref: N25B-217 --- src/control_backend/main.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/control_backend/main.py b/src/control_backend/main.py index 1543882..ff63e1f 100644 --- a/src/control_backend/main.py +++ b/src/control_backend/main.py @@ -3,6 +3,7 @@ # External imports import contextlib import logging +import threading import zmq from fastapi import FastAPI @@ -23,12 +24,7 @@ from control_backend.core.config import settings logger = logging.getLogger(__name__) logging.basicConfig(level=logging.DEBUG) - -@contextlib.asynccontextmanager -async def lifespan(app: FastAPI): - logger.info("%s starting up.", app.title) - - # Initiate sockets +def setup_sockets(): context = Context.instance() internal_pub_socket = context.socket(zmq.XPUB) @@ -38,8 +34,22 @@ async def lifespan(app: FastAPI): internal_sub_socket = context.socket(zmq.XSUB) internal_sub_socket.bind(settings.zmq_settings.internal_sub_address) logger.debug("Internal subscribing socket bound to %s", internal_sub_socket) + try: + zmq.proxy(internal_pub_socket, internal_sub_socket) + except zmq.ZMQError: + logger.warning("Error while handling PUB/SUB proxy. Closing sockets.") + finally: + internal_pub_socket.close() + internal_sub_socket.close() - zmq.proxy(internal_pub_socket, internal_sub_socket) +@contextlib.asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("%s starting up.", app.title) + + # Initiate sockets + proxy_thread = threading.Thread(target=setup_sockets) + proxy_thread.daemon = True + proxy_thread.start() # Initiate agents ri_communication_agent = RICommunicationAgent( From 20a49eb553266ce31177090e2c8c025a4bd130fb Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Fri, 31 Oct 2025 10:36:21 +0100 Subject: [PATCH 03/11] fix: endpoints don't create sockets ref: N25B-217 --- src/control_backend/api/v1/endpoints/command.py | 3 +-- src/control_backend/api/v1/endpoints/message.py | 6 +----- src/control_backend/main.py | 12 +++++++++--- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/control_backend/api/v1/endpoints/command.py b/src/control_backend/api/v1/endpoints/command.py index 88c859b..1ec76d5 100644 --- a/src/control_backend/api/v1/endpoints/command.py +++ b/src/control_backend/api/v1/endpoints/command.py @@ -17,8 +17,7 @@ async def receive_command(command: SpeechCommand, request: Request): # Validate and retrieve data. SpeechCommand.model_validate(command) topic = b"command" - pub_socket = Context.instance().socket(zmq.PUB) - pub_socket.connect(settings.zmq_settings.internal_pub_address) + pub_socket = request.app.state.endpoints_pub_socket await pub_socket.send_multipart([topic, command.model_dump_json().encode()]) return {"status": "Command received"} diff --git a/src/control_backend/api/v1/endpoints/message.py b/src/control_backend/api/v1/endpoints/message.py index 1a58377..bd88a0b 100644 --- a/src/control_backend/api/v1/endpoints/message.py +++ b/src/control_backend/api/v1/endpoints/message.py @@ -1,10 +1,7 @@ import logging -import zmq from fastapi import APIRouter, Request -from zmq.asyncio import Context -from control_backend.core.config import settings from control_backend.schemas.message import Message logger = logging.getLogger(__name__) @@ -19,8 +16,7 @@ async def receive_message(message: Message, request: Request): topic = b"message" body = message.model_dump_json().encode("utf-8") - pub_socket = Context.instance().socket(zmq.PUB) - pub_socket.bind(settings.zmq_settings.internal_pub_address) + pub_socket = request.app.state.endpoints_pub_socket await pub_socket.send_multipart([topic, body]) return {"status": "Message received"} diff --git a/src/control_backend/main.py b/src/control_backend/main.py index ff63e1f..9d0f664 100644 --- a/src/control_backend/main.py +++ b/src/control_backend/main.py @@ -28,14 +28,14 @@ def setup_sockets(): context = Context.instance() internal_pub_socket = context.socket(zmq.XPUB) - internal_pub_socket.bind(settings.zmq_settings.internal_pub_address) + internal_pub_socket.bind(settings.zmq_settings.internal_sub_address) logger.debug("Internal publishing socket bound to %s", internal_pub_socket) internal_sub_socket = context.socket(zmq.XSUB) - internal_sub_socket.bind(settings.zmq_settings.internal_sub_address) + internal_sub_socket.bind(settings.zmq_settings.internal_pub_address) logger.debug("Internal subscribing socket bound to %s", internal_sub_socket) try: - zmq.proxy(internal_pub_socket, internal_sub_socket) + zmq.proxy(internal_sub_socket, internal_pub_socket) except zmq.ZMQError: logger.warning("Error while handling PUB/SUB proxy. Closing sockets.") finally: @@ -51,6 +51,12 @@ async def lifespan(app: FastAPI): proxy_thread.daemon = True proxy_thread.start() + context = Context.instance() + + endpoints_pub_socket = context.socket(zmq.PUB) + endpoints_pub_socket.connect(settings.zmq_settings.internal_pub_address) + app.state.endpoints_pub_socket = endpoints_pub_socket + # Initiate agents ri_communication_agent = RICommunicationAgent( settings.agent_settings.ri_communication_agent_name + "@" + settings.agent_settings.host, From 020bf55772f8b8a482c62151b39de537d908c841 Mon Sep 17 00:00:00 2001 From: Kasper Date: Sun, 2 Nov 2025 22:02:32 +0100 Subject: [PATCH 04/11] fix: automated commit detection ref: N25B-241 --- .githooks/check-commit-msg.sh | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/.githooks/check-commit-msg.sh b/.githooks/check-commit-msg.sh index 82bd441..f87749a 100755 --- a/.githooks/check-commit-msg.sh +++ b/.githooks/check-commit-msg.sh @@ -23,6 +23,37 @@ NC='\033[0m' # No Color # The first argument to the hook is the path to the file containing the commit message COMMIT_MSG_FILE=$1 +# --- Automated Commit Detection --- + +# Git directory (.git/) +GIT_DIR=$(git rev-parse --git-dir) +# Check for a merge commit +if [ -f "$GIT_DIR/MERGE_HEAD" ]; then + echo "Hook: Detected a merge commit." + # Ensure the message follows a 'Merge branch...' pattern. + first_line=$(head -n1 "$COMMIT_MSG_FILE") + if [[ ! "$first_line" =~ ^Merge.* ]]; then + echo "Error: Merge commit message should start with 'Merge'." >&2 + exit 1 + fi + exit 0 + +# Check for a squash commit (from git merge --squash) +elif [ -f "$GIT_DIR/SQUASH_MSG" ]; then + echo "Hook: Detected a squash commit. Skipping validation." + exit 0 + +# Check for a revert commit +elif [ -f "$GIT_DIR/REVERT_HEAD" ]; then + echo "Hook: Detected a revert commit. Skipping validation." + exit 0 + +# Check for a cherry-pick commit +elif [ -f "$GIT_DIR/CHERRY_PICK_HEAD" ]; then + echo "Hook: Detected a cherry-pick commit. Skipping validation." + exit 0 +fi + # --- Validation Functions --- # Function to print an error message and exit From 0d5e198cad506d495093785d267599b223269a16 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Mon, 3 Nov 2025 14:51:18 +0100 Subject: [PATCH 05/11] fix: pattern matching instead of file existence The previous method of detecting automated commits was error-prone, specifically when using VSCode to commit changes. This new method uses a simple Regex pattern match to see if the commit message matches any known auto-generated commits. ref: N25B-241 --- .githooks/check-commit-msg.sh | 43 +++++++++++++++++------------------ 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/.githooks/check-commit-msg.sh b/.githooks/check-commit-msg.sh index f87749a..6fbc251 100755 --- a/.githooks/check-commit-msg.sh +++ b/.githooks/check-commit-msg.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # This script checks if a commit message follows the specified format. # It's designed to be used as a 'commit-msg' git hook. @@ -25,32 +25,31 @@ COMMIT_MSG_FILE=$1 # --- Automated Commit Detection --- -# Git directory (.git/) -GIT_DIR=$(git rev-parse --git-dir) -# Check for a merge commit -if [ -f "$GIT_DIR/MERGE_HEAD" ]; then - echo "Hook: Detected a merge commit." - # Ensure the message follows a 'Merge branch...' pattern. - first_line=$(head -n1 "$COMMIT_MSG_FILE") - if [[ ! "$first_line" =~ ^Merge.* ]]; then - echo "Error: Merge commit message should start with 'Merge'." >&2 - exit 1 - fi - exit 0 +# Read the first line (header) for initial checks +HEADER=$(head -n 1 "$COMMIT_MSG_FILE") -# Check for a squash commit (from git merge --squash) -elif [ -f "$GIT_DIR/SQUASH_MSG" ]; then - echo "Hook: Detected a squash commit. Skipping validation." +# Check for Merge commits (covers 'git merge' and PR merges from GitHub/GitLab) +# Examples: "Merge branch 'main' into ...", "Merge pull request #123 from ..." +MERGE_PATTERN="^Merge (branch|pull request|tag) .*" +if [[ "$HEADER" =~ $MERGE_PATTERN ]]; then + echo -e "${GREEN}Merge commit detected by message content. Skipping validation.${NC}" exit 0 +fi -# Check for a revert commit -elif [ -f "$GIT_DIR/REVERT_HEAD" ]; then - echo "Hook: Detected a revert commit. Skipping validation." +# Check for Revert commits +# Example: "Revert "feat: add new feature"" +REVERT_PATTERN="^Revert \".*\"" +if [[ "$HEADER" =~ $REVERT_PATTERN ]]; then + echo -e "${GREEN}Revert commit detected by message content. Skipping validation.${NC}" exit 0 +fi -# Check for a cherry-pick commit -elif [ -f "$GIT_DIR/CHERRY_PICK_HEAD" ]; then - echo "Hook: Detected a cherry-pick commit. Skipping validation." +# Check for Cherry-pick commits (this pattern appears at the end of the message) +# Example: "(cherry picked from commit deadbeef...)" +# We use grep -q to search the whole file quietly. +CHERRY_PICK_PATTERN="\(cherry picked from commit [a-f0-9]{7,40}\)" +if grep -qE "$CHERRY_PICK_PATTERN" "$COMMIT_MSG_FILE"; then + echo -e "${GREEN}Cherry-pick detected by message content. Skipping validation.${NC}" exit 0 fi From 3c8cee54eb15a9ddd6a795ab3d0cf7beee078820 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Mon, 3 Nov 2025 14:51:18 +0100 Subject: [PATCH 06/11] fix: pattern matching instead of file existence The previous method of detecting automated commits was error-prone, specifically when using VSCode to commit changes. This new method uses a simple Regex pattern match to see if the commit message matches any known auto-generated commits. ref: N25B-241 --- .githooks/check-branch-name.sh | 2 +- .githooks/check-commit-msg.sh | 43 +++++++++++++++++----------------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/.githooks/check-branch-name.sh b/.githooks/check-branch-name.sh index 752e199..0e71c9b 100755 --- a/.githooks/check-branch-name.sh +++ b/.githooks/check-branch-name.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # This script checks if the current branch name follows the specified format. # It's designed to be used as a 'pre-commit' git hook. diff --git a/.githooks/check-commit-msg.sh b/.githooks/check-commit-msg.sh index f87749a..6fbc251 100755 --- a/.githooks/check-commit-msg.sh +++ b/.githooks/check-commit-msg.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # This script checks if a commit message follows the specified format. # It's designed to be used as a 'commit-msg' git hook. @@ -25,32 +25,31 @@ COMMIT_MSG_FILE=$1 # --- Automated Commit Detection --- -# Git directory (.git/) -GIT_DIR=$(git rev-parse --git-dir) -# Check for a merge commit -if [ -f "$GIT_DIR/MERGE_HEAD" ]; then - echo "Hook: Detected a merge commit." - # Ensure the message follows a 'Merge branch...' pattern. - first_line=$(head -n1 "$COMMIT_MSG_FILE") - if [[ ! "$first_line" =~ ^Merge.* ]]; then - echo "Error: Merge commit message should start with 'Merge'." >&2 - exit 1 - fi - exit 0 +# Read the first line (header) for initial checks +HEADER=$(head -n 1 "$COMMIT_MSG_FILE") -# Check for a squash commit (from git merge --squash) -elif [ -f "$GIT_DIR/SQUASH_MSG" ]; then - echo "Hook: Detected a squash commit. Skipping validation." +# Check for Merge commits (covers 'git merge' and PR merges from GitHub/GitLab) +# Examples: "Merge branch 'main' into ...", "Merge pull request #123 from ..." +MERGE_PATTERN="^Merge (branch|pull request|tag) .*" +if [[ "$HEADER" =~ $MERGE_PATTERN ]]; then + echo -e "${GREEN}Merge commit detected by message content. Skipping validation.${NC}" exit 0 +fi -# Check for a revert commit -elif [ -f "$GIT_DIR/REVERT_HEAD" ]; then - echo "Hook: Detected a revert commit. Skipping validation." +# Check for Revert commits +# Example: "Revert "feat: add new feature"" +REVERT_PATTERN="^Revert \".*\"" +if [[ "$HEADER" =~ $REVERT_PATTERN ]]; then + echo -e "${GREEN}Revert commit detected by message content. Skipping validation.${NC}" exit 0 +fi -# Check for a cherry-pick commit -elif [ -f "$GIT_DIR/CHERRY_PICK_HEAD" ]; then - echo "Hook: Detected a cherry-pick commit. Skipping validation." +# Check for Cherry-pick commits (this pattern appears at the end of the message) +# Example: "(cherry picked from commit deadbeef...)" +# We use grep -q to search the whole file quietly. +CHERRY_PICK_PATTERN="\(cherry picked from commit [a-f0-9]{7,40}\)" +if grep -qE "$CHERRY_PICK_PATTERN" "$COMMIT_MSG_FILE"; then + echo -e "${GREEN}Cherry-pick detected by message content. Skipping validation.${NC}" exit 0 fi From 360f601d007041324aa36a727da598d52b6ce68a Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Mon, 3 Nov 2025 15:23:11 +0100 Subject: [PATCH 07/11] feat: chore doesn't need ref If we detect a chore commit, we don't check for the correct ref/close footer. ref: N25B-241 --- .githooks/check-commit-msg.sh | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/.githooks/check-commit-msg.sh b/.githooks/check-commit-msg.sh index 6fbc251..cdf56fb 100755 --- a/.githooks/check-commit-msg.sh +++ b/.githooks/check-commit-msg.sh @@ -86,20 +86,24 @@ if ! [[ "$HEADER" =~ $HEADER_REGEX ]]; then error_exit "Invalid header format.\n\nHeader must be in the format: : \nAllowed types: ${ALLOWED_TYPES[*]}\nExample: feat: add new user authentication feature" fi -# 3. Validate the footer (last line) of the commit message -FOOTER=$(tail -n 1 "$COMMIT_MSG_FILE") +# Only validate footer if commit type is not chore +TYPE=$(echo "$HEADER" | cut -d':' -f1) +if [ "$TYPE" != "chore" ]; then + # 3. Validate the footer (last line) of the commit message + FOOTER=$(tail -n 1 "$COMMIT_MSG_FILE") -# Regex breakdown: -# ^(ref|close) - Starts with 'ref' or 'close' -# : - Followed by a literal colon -# \s - Followed by a single space -# N25B- - Followed by the literal string 'N25B-' -# [0-9]+ - Followed by one or more digits -# $ - End of the line -FOOTER_REGEX="^(ref|close): N25B-[0-9]+$" + # Regex breakdown: + # ^(ref|close) - Starts with 'ref' or 'close' + # : - Followed by a literal colon + # \s - Followed by a single space + # N25B- - Followed by the literal string 'N25B-' + # [0-9]+ - Followed by one or more digits + # $ - End of the line + FOOTER_REGEX="^(ref|close): N25B-[0-9]+$" -if ! [[ "$FOOTER" =~ $FOOTER_REGEX ]]; then - error_exit "Invalid footer format.\n\nFooter must be in the format: [ref/close]: \nExample: ref: N25B-123" + if ! [[ "$FOOTER" =~ $FOOTER_REGEX ]]; then + error_exit "Invalid footer format.\n\nFooter must be in the format: [ref/close]: \nExample: ref: N25B-123" + fi fi # 4. If the message has more than 2 lines, validate the separator From cb5457b6be1457a7e6fb118d0c7f8164e327cd6b Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Mon, 3 Nov 2025 15:29:06 +0100 Subject: [PATCH 08/11] feat: check for squash commits ref: N25B-241 --- .githooks/check-commit-msg.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.githooks/check-commit-msg.sh b/.githooks/check-commit-msg.sh index cdf56fb..eacf2a8 100755 --- a/.githooks/check-commit-msg.sh +++ b/.githooks/check-commit-msg.sh @@ -53,6 +53,14 @@ if grep -qE "$CHERRY_PICK_PATTERN" "$COMMIT_MSG_FILE"; then exit 0 fi +# Check for Squash +# Example: "Squash commits ..." +SQUASH_PATTERN="^Squash .+" +if [[ "$HEADER" =~ $SQUASH_PATTERN ]]; then + echo -e "${GREEN}Squash commit detected by message content. Skipping validation.${NC}" + exit 0 +fi + # --- Validation Functions --- # Function to print an error message and exit From b0085625541f059866cc8a01c2d7d6a31c932229 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 5 Nov 2025 12:08:07 +0100 Subject: [PATCH 09/11] fix: tests To work with the new zmq instance context. ref: N25B-217 --- .../agents/test_ri_commands_agent.py | 33 +++--- .../agents/test_ri_communication_agent.py | 100 +++++------------- .../agents/vad_agent/test_vad_agent.py | 22 ++-- .../api/endpoints/test_command_endpoint.py | 40 +------ 4 files changed, 61 insertions(+), 134 deletions(-) diff --git a/test/integration/agents/test_ri_commands_agent.py b/test/integration/agents/test_ri_commands_agent.py index 219d682..15498e3 100644 --- a/test/integration/agents/test_ri_commands_agent.py +++ b/test/integration/agents/test_ri_commands_agent.py @@ -7,19 +7,21 @@ from control_backend.agents.ri_command_agent import RICommandAgent from control_backend.schemas.ri_message import SpeechCommand +@pytest.fixture +def zmq_context(mocker): + mock_context = mocker.patch("control_backend.agents.vad_agent.azmq.Context.instance") + mock_context.return_value = MagicMock() + return mock_context + + @pytest.mark.asyncio -async def test_setup_bind(monkeypatch): +async def test_setup_bind(zmq_context, mocker): """Test setup with bind=True""" - fake_socket = MagicMock() - monkeypatch.setattr( - "control_backend.agents.ri_command_agent.context.socket", lambda _: fake_socket - ) + fake_socket = zmq_context.return_value.socket.return_value agent = RICommandAgent("test@server", "password", address="tcp://localhost:5555", bind=True) - monkeypatch.setattr( - "control_backend.agents.ri_command_agent.settings", - MagicMock(zmq_settings=MagicMock(internal_comm_address="tcp://internal:1234")), - ) + settings = mocker.patch("control_backend.agents.ri_command_agent.settings") + settings.zmq_settings.internal_sub_address = "tcp://internal:1234" await agent.setup() @@ -34,18 +36,13 @@ async def test_setup_bind(monkeypatch): @pytest.mark.asyncio -async def test_setup_connect(monkeypatch): +async def test_setup_connect(zmq_context, mocker): """Test setup with bind=False""" - fake_socket = MagicMock() - monkeypatch.setattr( - "control_backend.agents.ri_command_agent.context.socket", lambda _: fake_socket - ) + fake_socket = zmq_context.return_value.socket.return_value agent = RICommandAgent("test@server", "password", address="tcp://localhost:5555", bind=False) - monkeypatch.setattr( - "control_backend.agents.ri_command_agent.settings", - MagicMock(zmq_settings=MagicMock(internal_comm_address="tcp://internal:1234")), - ) + settings = mocker.patch("control_backend.agents.ri_command_agent.settings") + settings.zmq_settings.internal_sub_address = "tcp://internal:1234" await agent.setup() diff --git a/test/integration/agents/test_ri_communication_agent.py b/test/integration/agents/test_ri_communication_agent.py index 3e4a056..a641c61 100644 --- a/test/integration/agents/test_ri_communication_agent.py +++ b/test/integration/agents/test_ri_communication_agent.py @@ -82,21 +82,23 @@ def fake_json_invalid_id_negototiate(): ) +@pytest.fixture +def zmq_context(mocker): + mock_context = mocker.patch("control_backend.agents.vad_agent.azmq.Context.instance") + mock_context.return_value = MagicMock() + return mock_context + + @pytest.mark.asyncio -async def test_setup_creates_socket_and_negotiate_1(monkeypatch): +async def test_setup_creates_socket_and_negotiate_1(zmq_context): """ Test the setup of the communication agent """ # --- Arrange --- - fake_socket = MagicMock() + fake_socket = zmq_context.return_value.socket.return_value fake_socket.send_json = AsyncMock() fake_socket.recv_json = fake_json_correct_negototiate_1() - # Mock context.socket to return our fake socket - monkeypatch.setattr( - "control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket - ) - # Mock RICommandAgent agent startup with patch( "control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True @@ -126,20 +128,15 @@ async def test_setup_creates_socket_and_negotiate_1(monkeypatch): @pytest.mark.asyncio -async def test_setup_creates_socket_and_negotiate_2(monkeypatch): +async def test_setup_creates_socket_and_negotiate_2(zmq_context): """ Test the setup of the communication agent """ # --- Arrange --- - fake_socket = MagicMock() + fake_socket = zmq_context.return_value.socket.return_value fake_socket.send_json = AsyncMock() fake_socket.recv_json = fake_json_correct_negototiate_2() - # Mock context.socket to return our fake socket - monkeypatch.setattr( - "control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket - ) - # Mock RICommandAgent agent startup with patch( "control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True @@ -169,20 +166,15 @@ async def test_setup_creates_socket_and_negotiate_2(monkeypatch): @pytest.mark.asyncio -async def test_setup_creates_socket_and_negotiate_3(monkeypatch, caplog): +async def test_setup_creates_socket_and_negotiate_3(zmq_context, caplog): """ Test the functionality of setup with incorrect negotiation message """ # --- Arrange --- - fake_socket = MagicMock() + fake_socket = zmq_context.return_value.socket.return_value fake_socket.send_json = AsyncMock() fake_socket.recv_json = fake_json_wrong_negototiate_1() - # Mock context.socket to return our fake socket - monkeypatch.setattr( - "control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket - ) - # Mock RICommandAgent agent startup # We are sending wrong negotiation info to the communication agent, so we should retry and expect a @@ -213,20 +205,15 @@ async def test_setup_creates_socket_and_negotiate_3(monkeypatch, caplog): @pytest.mark.asyncio -async def test_setup_creates_socket_and_negotiate_4(monkeypatch): +async def test_setup_creates_socket_and_negotiate_4(zmq_context): """ Test the setup of the communication agent with different bind value """ # --- Arrange --- - fake_socket = MagicMock() + fake_socket = zmq_context.return_value.socket.return_value fake_socket.send_json = AsyncMock() fake_socket.recv_json = fake_json_correct_negototiate_3() - # Mock context.socket to return our fake socket - monkeypatch.setattr( - "control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket - ) - # Mock RICommandAgent agent startup with patch( "control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True @@ -256,20 +243,15 @@ async def test_setup_creates_socket_and_negotiate_4(monkeypatch): @pytest.mark.asyncio -async def test_setup_creates_socket_and_negotiate_5(monkeypatch): +async def test_setup_creates_socket_and_negotiate_5(zmq_context): """ Test the setup of the communication agent """ # --- Arrange --- - fake_socket = MagicMock() + fake_socket = zmq_context.return_value.socket.return_value fake_socket.send_json = AsyncMock() fake_socket.recv_json = fake_json_correct_negototiate_4() - # Mock context.socket to return our fake socket - monkeypatch.setattr( - "control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket - ) - # Mock RICommandAgent agent startup with patch( "control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True @@ -299,20 +281,15 @@ async def test_setup_creates_socket_and_negotiate_5(monkeypatch): @pytest.mark.asyncio -async def test_setup_creates_socket_and_negotiate_6(monkeypatch): +async def test_setup_creates_socket_and_negotiate_6(zmq_context): """ Test the setup of the communication agent """ # --- Arrange --- - fake_socket = MagicMock() + fake_socket = zmq_context.return_value.socket.return_value fake_socket.send_json = AsyncMock() fake_socket.recv_json = fake_json_correct_negototiate_5() - # Mock context.socket to return our fake socket - monkeypatch.setattr( - "control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket - ) - # Mock RICommandAgent agent startup with patch( "control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True @@ -342,20 +319,15 @@ async def test_setup_creates_socket_and_negotiate_6(monkeypatch): @pytest.mark.asyncio -async def test_setup_creates_socket_and_negotiate_7(monkeypatch, caplog): +async def test_setup_creates_socket_and_negotiate_7(zmq_context, caplog): """ Test the functionality of setup with incorrect id """ # --- Arrange --- - fake_socket = MagicMock() + fake_socket = zmq_context.return_value.socket.return_value fake_socket.send_json = AsyncMock() fake_socket.recv_json = fake_json_invalid_id_negototiate() - # Mock context.socket to return our fake socket - monkeypatch.setattr( - "control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket - ) - # Mock RICommandAgent agent startup # We are sending wrong negotiation info to the communication agent, so we should retry and expect a @@ -383,20 +355,15 @@ async def test_setup_creates_socket_and_negotiate_7(monkeypatch, caplog): @pytest.mark.asyncio -async def test_setup_creates_socket_and_negotiate_timeout(monkeypatch, caplog): +async def test_setup_creates_socket_and_negotiate_timeout(zmq_context, caplog): """ Test the functionality of setup with incorrect negotiation message """ # --- Arrange --- - fake_socket = MagicMock() + fake_socket = zmq_context.return_value.socket.return_value fake_socket.send_json = AsyncMock() fake_socket.recv_json = AsyncMock(side_effect=asyncio.TimeoutError) - # Mock context.socket to return our fake socket - monkeypatch.setattr( - "control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket - ) - with patch( "control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True ) as MockCommandAgent: @@ -478,8 +445,8 @@ async def test_listen_behaviour_ping_wrong_endpoint(caplog): @pytest.mark.asyncio -async def test_listen_behaviour_timeout(caplog): - fake_socket = AsyncMock() +async def test_listen_behaviour_timeout(zmq_context, caplog): + fake_socket = zmq_context.return_value.socket.return_value fake_socket.send_json = AsyncMock() # recv_json will never resolve, simulate timeout fake_socket.recv_json = AsyncMock(side_effect=asyncio.TimeoutError) @@ -527,16 +494,12 @@ async def test_listen_behaviour_ping_no_endpoint(caplog): @pytest.mark.asyncio -async def test_setup_unexpected_exception(monkeypatch, caplog): - fake_socket = MagicMock() +async def test_setup_unexpected_exception(zmq_context, caplog): + fake_socket = zmq_context.return_value.socket.return_value fake_socket.send_json = AsyncMock() # Simulate unexpected exception during recv_json() fake_socket.recv_json = AsyncMock(side_effect=Exception("boom!")) - monkeypatch.setattr( - "control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket - ) - agent = RICommunicationAgent( "test@server", "password", address="tcp://localhost:5555", bind=False ) @@ -549,9 +512,9 @@ async def test_setup_unexpected_exception(monkeypatch, caplog): @pytest.mark.asyncio -async def test_setup_unpacking_exception(monkeypatch, caplog): +async def test_setup_unpacking_exception(zmq_context, caplog): # --- Arrange --- - fake_socket = MagicMock() + fake_socket = zmq_context.return_value.socket.return_value fake_socket.send_json = AsyncMock() # Make recv_json return malformed negotiation data to trigger unpacking exception @@ -561,11 +524,6 @@ async def test_setup_unpacking_exception(monkeypatch, caplog): } # missing 'port' and 'bind' fake_socket.recv_json = AsyncMock(return_value=malformed_data) - # Patch context.socket - monkeypatch.setattr( - "control_backend.agents.ri_communication_agent.context.socket", lambda _: fake_socket - ) - # Patch RICommandAgent so it won't actually start with patch( "control_backend.agents.ri_communication_agent.RICommandAgent", autospec=True diff --git a/test/integration/agents/vad_agent/test_vad_agent.py b/test/integration/agents/vad_agent/test_vad_agent.py index 54c9d82..7d8e173 100644 --- a/test/integration/agents/vad_agent/test_vad_agent.py +++ b/test/integration/agents/vad_agent/test_vad_agent.py @@ -10,7 +10,9 @@ from control_backend.agents.vad_agent import VADAgent @pytest.fixture def zmq_context(mocker): - return mocker.patch("control_backend.agents.vad_agent.zmq_context") + mock_context = mocker.patch("control_backend.agents.vad_agent.azmq.Context.instance") + mock_context.return_value = MagicMock() + return mock_context @pytest.fixture @@ -54,13 +56,13 @@ def test_in_socket_creation(zmq_context, do_bind: bool): assert vad_agent.audio_in_socket is not None - zmq_context.socket.assert_called_once_with(zmq.SUB) - zmq_context.socket.return_value.setsockopt_string.assert_called_once_with(zmq.SUBSCRIBE, "") + zmq_context.return_value.socket.assert_called_once_with(zmq.SUB) + zmq_context.return_value.socket.return_value.setsockopt_string.assert_called_once_with(zmq.SUBSCRIBE, "") if do_bind: - zmq_context.socket.return_value.bind.assert_called_once_with("tcp://*:12345") + zmq_context.return_value.socket.return_value.bind.assert_called_once_with("tcp://*:12345") else: - zmq_context.socket.return_value.connect.assert_called_once_with("tcp://localhost:12345") + zmq_context.return_value.socket.return_value.connect.assert_called_once_with("tcp://localhost:12345") def test_out_socket_creation(zmq_context): @@ -73,8 +75,8 @@ def test_out_socket_creation(zmq_context): assert vad_agent.audio_out_socket is not None - zmq_context.socket.assert_called_once_with(zmq.PUB) - zmq_context.socket.return_value.bind_to_random_port.assert_called_once() + zmq_context.return_value.socket.assert_called_once_with(zmq.PUB) + zmq_context.return_value.socket.return_value.bind_to_random_port.assert_called_once() @pytest.mark.asyncio @@ -83,7 +85,7 @@ async def test_out_socket_creation_failure(zmq_context): Test setup failure when the audio output socket cannot be created. """ with patch.object(Agent, "stop", new_callable=AsyncMock) as mock_super_stop: - zmq_context.socket.return_value.bind_to_random_port.side_effect = zmq.ZMQBindError + zmq_context.return_value.socket.return_value.bind_to_random_port.side_effect = zmq.ZMQBindError vad_agent = VADAgent("tcp://localhost:12345", False) await vad_agent.setup() @@ -98,11 +100,11 @@ async def test_stop(zmq_context, transcription_agent): Test that when the VAD agent is stopped, the sockets are closed correctly. """ vad_agent = VADAgent("tcp://localhost:12345", False) - zmq_context.socket.return_value.bind_to_random_port.return_value = random.randint(1000, 10000) + zmq_context.return_value.socket.return_value.bind_to_random_port.return_value = random.randint(1000, 10000) await vad_agent.setup() await vad_agent.stop() - assert zmq_context.socket.return_value.close.call_count == 2 + assert zmq_context.return_value.socket.return_value.close.call_count == 2 assert vad_agent.audio_in_socket is None assert vad_agent.audio_out_socket is None diff --git a/test/integration/api/endpoints/test_command_endpoint.py b/test/integration/api/endpoints/test_command_endpoint.py index 7e38924..8ecf816 100644 --- a/test/integration/api/endpoints/test_command_endpoint.py +++ b/test/integration/api/endpoints/test_command_endpoint.py @@ -26,8 +26,8 @@ def client(app): @pytest.mark.asyncio -@patch("control_backend.api.endpoints.command.Context.instance") -async def test_receive_command_success(mock_context_instance, async_client): +@patch("control_backend.api.v1.endpoints.command.Context.instance") +async def test_receive_command_success(mock_context_instance, client): """ Test for successful reception of a command. Ensures the status code is 202 and the response body is correct. @@ -35,54 +35,24 @@ async def test_receive_command_success(mock_context_instance, async_client): """ # Arrange mock_pub_socket = AsyncMock() - mock_context_instance.return_value.socket.return_value = mock_pub_socket + client.app.state.endpoints_pub_socket = mock_pub_socket - command_data = {"command": "test_command", "text": "This is a test"} + command_data = {"endpoint": "actuate/speech", "data": "This is a test"} speech_command = SpeechCommand(**command_data) # Act - response = await async_client.post("/command", json=command_data) + response = client.post("/command", json=command_data) # Assert assert response.status_code == 202 assert response.json() == {"status": "Command received"} # Verify that the ZMQ socket was used correctly - mock_context_instance.return_value.socket.assert_called_once_with(1) # zmq.PUB is 1 - mock_pub_socket.connect.assert_called_once() mock_pub_socket.send_multipart.assert_awaited_once_with( [b"command", speech_command.model_dump_json().encode()] ) -def test_receive_command_endpoint(client, app, mocker): - """ - Test that a POST to /command sends the right multipart message - and returns a 202 with the expected JSON body. - """ - mock_socket = mocker.patch.object() - - # Prepare test payload that matches SpeechCommand - payload = {"endpoint": "actuate/speech", "data": "yooo"} - - # Send POST request - response = client.post("/command", json=payload) - - # Check response - assert response.status_code == 202 - assert response.json() == {"status": "Command received"} - - # Verify that the socket was called with the correct data - assert mock_socket.send_multipart.called, "Socket should be used to send data" - - args, kwargs = mock_socket.send_multipart.call_args - sent_data = args[0] - - assert sent_data[0] == b"command" - # Check JSON encoding roughly matches - assert isinstance(SpeechCommand.model_validate_json(sent_data[1].decode()), SpeechCommand) - - def test_receive_command_invalid_payload(client): """ Test invalid data handling (schema validation). From 2c867adce2641007b4e200d540dd652ffd79b5f2 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 5 Nov 2025 12:22:42 +0100 Subject: [PATCH 10/11] fix: go back to the working ri command endpoint test Merged the wrong version because it seemed to solve the same problem. It did not. Now using the one I commited two commits ago. ref: N25B-217 --- .../api/endpoints/test_command_endpoint.py | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/test/integration/api/endpoints/test_command_endpoint.py b/test/integration/api/endpoints/test_command_endpoint.py index 04890c1..c343f0c 100644 --- a/test/integration/api/endpoints/test_command_endpoint.py +++ b/test/integration/api/endpoints/test_command_endpoint.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, patch import pytest from fastapi import FastAPI @@ -16,7 +16,6 @@ def app(): """ app = FastAPI() app.include_router(command.router) - app.state.internal_comm_socket = MagicMock() # mock ZMQ socket return app @@ -26,32 +25,30 @@ def client(app): return TestClient(app) -def test_receive_command_endpoint(client, app): +def test_receive_command_success(client): """ - Test that a POST to /command sends the right multipart message - and returns a 202 with the expected JSON body. + Test for successful reception of a command. + Ensures the status code is 202 and the response body is correct. + It also verifies that the ZeroMQ socket's send_multipart method is called with the expected data. """ - mock_socket = app.state.internal_comm_socket + # Arrange + mock_pub_socket = AsyncMock() + client.app.state.endpoints_pub_socket = mock_pub_socket - # Prepare test payload that matches SpeechCommand - payload = {"endpoint": "actuate/speech", "data": "yooo"} + command_data = {"endpoint": "actuate/speech", "data": "This is a test"} + speech_command = SpeechCommand(**command_data) - # Send POST request - response = client.post("/command", json=payload) + # Act + response = client.post("/command", json=command_data) - # Check response + # Assert assert response.status_code == 202 assert response.json() == {"status": "Command received"} - # Verify that the socket was called with the correct data - assert mock_socket.send_multipart.called, "Socket should be used to send data" - - args, kwargs = mock_socket.send_multipart.call_args - sent_data = args[0] - - assert sent_data[0] == b"command" - # Check JSON encoding roughly matches - assert isinstance(SpeechCommand.model_validate_json(sent_data[1].decode()), SpeechCommand) + # Verify that the ZMQ socket was used correctly + mock_pub_socket.send_multipart.assert_awaited_once_with( + [b"command", speech_command.model_dump_json().encode()] + ) def test_receive_command_invalid_payload(client): From f854a60e46d2805baea1efaf5821f4ff03504f8e Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 5 Nov 2025 12:34:30 +0100 Subject: [PATCH 11/11] style: import order and lines too long ref: N25B-217 --- src/control_backend/agents/ri_command_agent.py | 2 +- .../agents/ri_communication_agent.py | 2 +- src/control_backend/main.py | 1 - .../agents/vad_agent/test_vad_agent.py | 18 ++++++++++++++---- .../api/endpoints/test_command_endpoint.py | 8 ++++---- 5 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/control_backend/agents/ri_command_agent.py b/src/control_backend/agents/ri_command_agent.py index 09c4299..0dcc981 100644 --- a/src/control_backend/agents/ri_command_agent.py +++ b/src/control_backend/agents/ri_command_agent.py @@ -1,9 +1,9 @@ import json import logging +import zmq from spade.agent import Agent from spade.behaviour import CyclicBehaviour -import zmq from zmq.asyncio import Context from control_backend.core.config import settings diff --git a/src/control_backend/agents/ri_communication_agent.py b/src/control_backend/agents/ri_communication_agent.py index 20b2a4b..638b967 100644 --- a/src/control_backend/agents/ri_communication_agent.py +++ b/src/control_backend/agents/ri_communication_agent.py @@ -1,9 +1,9 @@ import asyncio import logging +import zmq from spade.agent import Agent from spade.behaviour import CyclicBehaviour -import zmq from zmq.asyncio import Context from control_backend.agents.ri_command_agent import RICommandAgent diff --git a/src/control_backend/main.py b/src/control_backend/main.py index f1cdfa6..29f1396 100644 --- a/src/control_backend/main.py +++ b/src/control_backend/main.py @@ -19,7 +19,6 @@ from control_backend.agents.vad_agent import VADAgent from control_backend.api.v1.router import api_router from control_backend.core.config import settings - logger = logging.getLogger(__name__) logging.basicConfig(level=logging.DEBUG) diff --git a/test/integration/agents/vad_agent/test_vad_agent.py b/test/integration/agents/vad_agent/test_vad_agent.py index 7d8e173..0e1fae2 100644 --- a/test/integration/agents/vad_agent/test_vad_agent.py +++ b/test/integration/agents/vad_agent/test_vad_agent.py @@ -57,12 +57,17 @@ def test_in_socket_creation(zmq_context, do_bind: bool): assert vad_agent.audio_in_socket is not None zmq_context.return_value.socket.assert_called_once_with(zmq.SUB) - zmq_context.return_value.socket.return_value.setsockopt_string.assert_called_once_with(zmq.SUBSCRIBE, "") + zmq_context.return_value.socket.return_value.setsockopt_string.assert_called_once_with( + zmq.SUBSCRIBE, + "", + ) if do_bind: zmq_context.return_value.socket.return_value.bind.assert_called_once_with("tcp://*:12345") else: - zmq_context.return_value.socket.return_value.connect.assert_called_once_with("tcp://localhost:12345") + zmq_context.return_value.socket.return_value.connect.assert_called_once_with( + "tcp://localhost:12345" + ) def test_out_socket_creation(zmq_context): @@ -85,7 +90,9 @@ async def test_out_socket_creation_failure(zmq_context): Test setup failure when the audio output socket cannot be created. """ with patch.object(Agent, "stop", new_callable=AsyncMock) as mock_super_stop: - zmq_context.return_value.socket.return_value.bind_to_random_port.side_effect = zmq.ZMQBindError + zmq_context.return_value.socket.return_value.bind_to_random_port.side_effect = ( + zmq.ZMQBindError + ) vad_agent = VADAgent("tcp://localhost:12345", False) await vad_agent.setup() @@ -100,7 +107,10 @@ async def test_stop(zmq_context, transcription_agent): Test that when the VAD agent is stopped, the sockets are closed correctly. """ vad_agent = VADAgent("tcp://localhost:12345", False) - zmq_context.return_value.socket.return_value.bind_to_random_port.return_value = random.randint(1000, 10000) + zmq_context.return_value.socket.return_value.bind_to_random_port.return_value = random.randint( + 1000, + 10000, + ) await vad_agent.setup() await vad_agent.stop() diff --git a/test/integration/api/endpoints/test_command_endpoint.py b/test/integration/api/endpoints/test_command_endpoint.py index c343f0c..1c9213a 100644 --- a/test/integration/api/endpoints/test_command_endpoint.py +++ b/test/integration/api/endpoints/test_command_endpoint.py @@ -1,4 +1,4 @@ -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest from fastapi import FastAPI @@ -27,9 +27,9 @@ def client(app): def test_receive_command_success(client): """ - Test for successful reception of a command. - Ensures the status code is 202 and the response body is correct. - It also verifies that the ZeroMQ socket's send_multipart method is called with the expected data. + Test for successful reception of a command. Ensures the status code is 202 and the response body + is correct. It also verifies that the ZeroMQ socket's send_multipart method is called with the + expected data. """ # Arrange mock_pub_socket = AsyncMock()