diff --git a/ovos_workshop/skills/ovos.py b/ovos_workshop/skills/ovos.py index 5de55375..9f786cb0 100644 --- a/ovos_workshop/skills/ovos.py +++ b/ovos_workshop/skills/ovos.py @@ -1551,7 +1551,19 @@ def speak(self, utterance: str, expect_response: bool = False, meta["translation_data"]) m.context["translation_data"] = tx_data - self.bus.emit(m) + # bus-namespace migration: "speak" -> "ovos.utterance.speak" + # (architecture PIPELINE-1 ยง9.6). During the migration we dual-emit on + # both topics so consumers on either namespace are reached; they dedupe + # on content. Always emit the new topic; also emit the legacy "speak" + # while `legacy_namespace` is True (default). The full payload (incl. + # expect_response/meta) is kept on both emissions; the wait/ + # expect_response handshake is keyed on the session, not the topic, so + # it is unaffected by the topic change. + # TODO: remove the legacy "speak" branch in the next major release, + # once every node emits the ovos.* topic only. + if Configuration().get("legacy_namespace", True): + self.bus.emit(m.forward("speak", data)) + self.bus.emit(m.forward("ovos.utterance.speak", data)) if wait: timeout = 15 if isinstance(wait, bool) else wait diff --git a/pyproject.toml b/pyproject.toml index 3ba78f73..cbf7aec6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ authors = [{name = "jarbasAi", email = "jarbasai@mailfence.com"}] requires-python = ">=3.9" dependencies = [ "ovos-utils>= 0.7.0,<1.0.0", - "ovos_bus_client>=1.3.8a1,<3.0.0", + "ovos_bus_client>=2.2.0a1,<3.0.0", "ovos-config>=0.0.12,<3.0.0", "ovos-yes-no-plugin>=0.3.0,<1.0.0", "ovos-option-matcher-fuzzy-plugin>=0.0.1,<1.0.0", diff --git a/test/unittests/skills/test_ovos.py b/test/unittests/skills/test_ovos.py index fb9541f3..92606d7e 100644 --- a/test/unittests/skills/test_ovos.py +++ b/test/unittests/skills/test_ovos.py @@ -134,3 +134,60 @@ def test_class_inheritance(self): self.assertIsInstance(skill, OVOSSkill) self.assertNotIsInstance(skill, OVOSAbstractApplication) + +class TestSpeakNamespaceMigration(unittest.TestCase): + """speak -> ovos.utterance.speak bus-namespace migration (dual-emit).""" + + def setUp(self): + self.bus = FakeBus() + self.skill = OVOSSkill(bus=self.bus, skill_id="test_speak_ns") + self.emitted = [] + # FakeBus dispatches by exact msg_type, so subscribe to both topics + self.bus.on("speak", lambda m: self.emitted.append(m)) + self.bus.on("ovos.utterance.speak", + lambda m: self.emitted.append(m)) + + def _types(self): + return [m.msg_type for m in self.emitted] + + def test_dual_emit_when_legacy_namespace(self): + from unittest.mock import patch + cfg = {"legacy_namespace": True} + with patch("ovos_workshop.skills.ovos.Configuration", + return_value=cfg): + self.skill.speak("hello world") + # both the legacy and the new topic are emitted + self.assertIn("speak", self._types()) + self.assertIn("ovos.utterance.speak", self._types()) + self.assertEqual(len(self.emitted), 2) + # identical payload on both, all fields preserved + for m in self.emitted: + self.assertEqual(m.data["utterance"], "hello world") + self.assertIn("expect_response", m.data) + self.assertIn("meta", m.data) + self.assertIn("lang", m.data) + self.assertEqual(m.data["meta"]["skill"], "test_speak_ns") + # context preserved on both emissions + self.assertEqual(m.context.get("skill_id"), "test_speak_ns") + + def test_only_new_topic_when_legacy_disabled(self): + from unittest.mock import patch + cfg = {"legacy_namespace": False} + with patch("ovos_workshop.skills.ovos.Configuration", + return_value=cfg): + self.skill.speak("goodbye", expect_response=True) + self.assertEqual(self._types(), ["ovos.utterance.speak"]) + m = self.emitted[0] + self.assertEqual(m.data["utterance"], "goodbye") + # expect_response carried on the new topic too + self.assertTrue(m.data["expect_response"]) + + def test_default_is_dual_emit(self): + # no legacy_namespace key -> defaults to True (dual-emit during migration) + from unittest.mock import patch + with patch("ovos_workshop.skills.ovos.Configuration", + return_value={}): + self.skill.speak("default behaviour") + self.assertIn("speak", self._types()) + self.assertIn("ovos.utterance.speak", self._types()) +