From 02d80925cfcc768ea888999ea25964ebd7b9ca94 Mon Sep 17 00:00:00 2001 From: JarbasAi Date: Thu, 25 Jun 2026 16:38:26 +0100 Subject: [PATCH 1/3] feat: dual-emit speak on ovos.utterance.speak (namespace migration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OVOSSkill.speak() now emits the utterance on both the legacy "speak" topic and the new "ovos.utterance.speak" topic (architecture PIPELINE-1 §9.6) during the bus-namespace migration, so consumers on either namespace are reached. Gated on Configuration "legacy_namespace" (default True); when False only the new ovos.* topic is emitted. Uses emit_migration_pair from ovos-bus-client; full payload (utterance/expect_response/meta/lang) and message context are preserved on both emissions. The wait/expect_response handshake is keyed on the session, not the topic, so it is unaffected. Co-Authored-By: Claude Opus 4.8 --- ovos_workshop/skills/ovos.py | 16 ++++++++- pyproject.toml | 2 +- test/unittests/skills/test_ovos.py | 57 ++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/ovos_workshop/skills/ovos.py b/ovos_workshop/skills/ovos.py index 5de55375..b018a154 100644 --- a/ovos_workshop/skills/ovos.py +++ b/ovos_workshop/skills/ovos.py @@ -36,6 +36,7 @@ from ovos_bus_client.message import Message, dig_for_message from ovos_bus_client.session import SessionManager, Session from ovos_bus_client.util import get_message_lang +from ovos_bus_client.util.migration import emit_migration_pair from ovos_config.config import Configuration from ovos_config.locations import get_xdg_cache_save_path from ovos_config.locations import get_xdg_config_save_path @@ -1551,7 +1552,20 @@ 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. Gated on `legacy_namespace` (default True). 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): + emit_migration_pair(self.bus, m, "speak", + "ovos.utterance.speak", data) + else: + 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..c6ed5914 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.1.2a1,<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()) + From 9e94ee462584943bb08fbd98d27d8a3e8240b78b Mon Sep 17 00:00:00 2001 From: JarbasAi Date: Thu, 25 Jun 2026 16:52:51 +0100 Subject: [PATCH 2/3] fix: pin ovos_bus_client>=2.2.0a1 (the dedup helper's release version) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c6ed5914..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>=2.1.2a1,<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", From b5ef4d0e81526c03df8e124f2ffd411f96be509e Mon Sep 17 00:00:00 2001 From: JarbasAi Date: Thu, 25 Jun 2026 17:22:35 +0100 Subject: [PATCH 3/3] refactor: inline dual-emit for speak namespace migration The bus-client migration helper was reduced to a consumer-side Deduplicator only (no emit helper), so emit the pair inline: always emit "ovos.utterance.speak", and also emit the legacy "speak" while Configuration "legacy_namespace" is True (default). Full payload preserved on both; drops the removed emit_migration_pair import. Co-Authored-By: Claude Opus 4.8 --- ovos_workshop/skills/ovos.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/ovos_workshop/skills/ovos.py b/ovos_workshop/skills/ovos.py index b018a154..9f786cb0 100644 --- a/ovos_workshop/skills/ovos.py +++ b/ovos_workshop/skills/ovos.py @@ -36,7 +36,6 @@ from ovos_bus_client.message import Message, dig_for_message from ovos_bus_client.session import SessionManager, Session from ovos_bus_client.util import get_message_lang -from ovos_bus_client.util.migration import emit_migration_pair from ovos_config.config import Configuration from ovos_config.locations import get_xdg_cache_save_path from ovos_config.locations import get_xdg_config_save_path @@ -1555,17 +1554,16 @@ def speak(self, utterance: str, expect_response: bool = False, # 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. Gated on `legacy_namespace` (default True). 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. + # 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): - emit_migration_pair(self.bus, m, "speak", - "ovos.utterance.speak", data) - else: - self.bus.emit(m.forward("ovos.utterance.speak", data)) + 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