Skip to content

feat: skill_id lifecycle filter + eof_count for End2EndTest#110

Merged
JarbasAl merged 1 commit into
devfrom
feat/skill-id-filter-eof-count
Jun 29, 2026
Merged

feat: skill_id lifecycle filter + eof_count for End2EndTest#110
JarbasAl merged 1 commit into
devfrom
feat/skill-id-filter-eof-count

Conversation

@JarbasAl

@JarbasAl JarbasAl commented Jun 29, 2026

Copy link
Copy Markdown
Member

What

Two small, composable End2EndTest features for asserting scenarios that produce concurrent dispatch lifecycles whose messages interleave non-deterministically.

The motivating case (OVOS-PIPELINE-1 §8): stopping a running skill emits two lifecycles — the stop dispatch and the interrupted skill's own §8 trio + §9.5 terminal, which completes asynchronously when the skill's daemon unwinds. Their interleaving order (and even which ovos.utterance.handled arrives first) varies under load, so a strict single-sequence assertion is inherently flaky.

skill_id (filter)

Filter captured messages to a single context["skill_id"] before asserting, isolating one lifecycle. Run the same scenario once per skill_id to assert each lifecycle deterministically. Routing checks are skipped while filtering (the filtered stream is not a source/destination flip-chain).

eof_count (capture span)

End capture only after an eof topic has been seen N times, so capture spans all N lifecycles that terminate on the same topic (e.g. two ovos.utterance.handled) before the filter runs. The counter resets per capture() call.

Tests

  • test_capture_session.py: eof_count waits for N occurrences; resets between captures.
  • test_end2end_extended.py::TestSkillIdFilter: a skill emitting two skill_id-tagged lifecycles is asserted once per skill_id; the unfiltered run (with eof_count=2) sees both.

Both default to today's behaviour (skill_id=None, eof_count=1) — fully backward compatible.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • End-to-end capture now supports waiting for multiple termination signals before completing.
    • Added the ability to focus assertions on a single skill lifecycle when multiple lifecycles are present.
  • Bug Fixes

    • Capture sessions no longer stop too early when more than one end-of-flow signal is expected.
    • Repeated captures now correctly reset their completion tracking between runs.

Two composable features for asserting scenarios that produce CONCURRENT dispatch
lifecycles whose messages interleave non-deterministically (e.g. stopping a skill
that is mid-dispatch — the stop dispatch and the interrupted skill's own §8 trio +
§9.5 terminal race).

- skill_id: filter captured messages to a single context skill_id before
  asserting, isolating one lifecycle. Routing checks are skipped while filtering
  (the filtered stream is not a source/destination flip-chain).
- eof_count: end capture only after an eof topic has been seen N times — so capture
  spans all N lifecycles that each terminate on the same topic (e.g. two
  ovos.utterance.handled) before the filter runs.

Run the same scenario once per skill_id to assert each lifecycle deterministically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

CaptureSession gains thread-safe EOF counting (eof_count, _eof_lock, _eof_seen) so capture ends only after N EOF messages instead of the first. End2EndTest adds eof_count (passed to CaptureSession) and skill_id (filters captured messages by context.skill_id, disabling routing assertions). New unit tests cover both behaviors.

Multi-EOF capture and skill_id filtering

Layer / File(s) Summary
CaptureSession EOF counting state and handler
ovoscope/__init__.py
Adds eof_count, _eof_lock, _eof_seen fields; handle_end_of_test increments under lock and sets done only when _eof_seen >= eof_count; capture() resets _eof_seen at the start of each run.
End2EndTest fields and execute() wiring
ovoscope/__init__.py
Adds eof_count: int = 1 and skill_id: Optional[str] = None to End2EndTest; execute() passes eof_count to CaptureSession, filters messages by context.skill_id, and skips routing assertions when skill_id is set.
Unit tests for EOF counting and skill_id filtering
test/unittests/test_capture_session.py, test/unittests/test_end2end_extended.py
Verifies eof_count=2 waits for two EOFs and resets per capture; TwoLifecycleSkill emits two tagged lifecycles; TestSkillIdFilter confirms skill_id isolates a single lifecycle and omitting it returns both.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • OpenVoiceOS/ovoscope#3: Introduced CaptureSession and refactored End2EndTest to use it — the direct predecessor to this EOF counting extension.
  • OpenVoiceOS/ovoscope#7: Added ignore_messages filtering to the same CaptureSession/End2EndTest capture lifecycle code path.

Poem

🐇 Hopscotch through the message stream,
One EOF was just a dream—
Count to two before you rest,
Filter by skill, pass the test!
The rabbit waits for all the signs,
Then hops away through lifecycle lines. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding skill_id filtering and eof_count support to End2EndTest.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/skill-id-filter-eof-count

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown

Reporting for duty! The automated checks have completed. 🎖️

I've aggregated the results of the automated checks for this PR below.

📋 Repo Health

How's the repo's pulse? Let's take a look. 💓

✅ All required files present.

Latest Version: 1.3.0a1

ovoscope/version.py — Version file
README.md — README
LICENSE — License file
pyproject.toml — pyproject.toml
⚠️ setup.py — setup.py
CHANGELOG.md — Changelog
ovoscope/version.py has valid version block markers

⚖️ License Check

Reading the fine print so you don't have to! 🔎

✅ No license violations found.

Policy: Apache 2.0 (universal donor). StrongCopyleft / NetworkCopyleft / WeakCopyleft / Other / Error categories fail. MPL allowed.

🔒 Security (pip-audit)

I've checked for any hardcoded credentials. 🔑

✅ No known vulnerabilities found (78 packages scanned).

🔍 Lint

The automated sentinel is back with news. 💂‍♂️

ruff: issues found — see job log

🏷️ Release Preview

Checking if we've included all the important changes. 📋

Current: 1.3.0a1Next: 1.4.0a1

Signal Value
Label (none)
PR title feat: skill_id lifecycle filter + eof_count for End2EndTest
Bump minor

✅ PR title follows conventional commit format.


🚀 Release Channel Compatibility

Predicted next version: 1.4.0a1

Channel Status Note Current Constraint
Stable Not in channel -
Testing Too new (must be <1.0.0) ovoscope>=0.7.2,<1.0.0
Alpha Compatible ovoscope>=1.3.0a1

🔨 Build Tests

Ensuring the gears are properly lubricated. 💧

✅ All versions pass

Python Build Install Tests
3.10
3.11
3.12
3.13
3.14

📊 Coverage

Ensuring the logic is battle-tested. ⚔️

53.3% total coverage

Files below 80% coverage (17 files)
File Coverage Missing lines
ovoscope/simple_listener.py 0.0% 55
ovoscope/tts_intelligibility.py 0.0% 190
ovoscope/version.py 0.0% 5
ovoscope/intent_cases.py 14.0% 141
ovoscope/classic_listener.py 18.2% 117
ovoscope/pipeline.py 31.0% 78
ovoscope/ocp.py 31.8% 58
ovoscope/cli.py 32.8% 162
ovoscope/e2e.py 34.9% 99
ovoscope/pytest_plugin.py 41.4% 222
ovoscope/listener.py 56.2% 123
ovoscope/media.py 57.2% 101
ovoscope/voice_loop.py 58.3% 115
ovoscope/__init__.py 59.0% 315
ovoscope/coverage.py 63.3% 69
ovoscope/audio.py 64.2% 112
ovoscope/media_provider.py 64.9% 20

Full report: download the coverage-report artifact.


Just doing my bit for the OpenVoiceOS ecosystem. 🌍

@github-actions github-actions Bot added feature and removed feature labels Jun 29, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
ovoscope/__init__.py (1)

859-866: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Optional: guard against an empty post-filter result.

If skill_id matches nothing (e.g. a typo or a lifecycle that never tagged its context), messages becomes empty. Downstream consumers that index the list — notably messages[-1] in the test_final_session block (Line 980) — would then raise an IndexError rather than producing a clear assertion failure. A short explicit check after filtering would surface the misconfiguration directly.

🛡️ Optional guard
         if self.skill_id is not None:
             messages = [m for m in messages
                         if (m.context or {}).get("skill_id") == self.skill_id]
+            assert messages, f"❌ no messages matched skill_id='{self.skill_id}'"
             if self.verbose:
                 print(f"💡 filtered to skill_id='{self.skill_id}': {len(messages)} messages")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ovoscope/__init__.py` around lines 859 - 866, Add an explicit empty-result
check immediately after the skill_id filtering in the message collection logic
so a typo or missing lifecycle tag fails with a clear assertion instead of later
causing an IndexError. Update the filtering path in the relevant
message-processing block that uses self.skill_id, and make the failure message
explain that no messages matched the requested skill_id before any downstream
code like the test_final_session logic accesses messages[-1].
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/unittests/test_capture_session.py`:
- Around line 188-207: The test currently creates a new CaptureSession for the
second capture, so it does not verify that CaptureSession.capture() resets EOF
state on reuse. Update the test to use the same CaptureSession instance for both
capture() calls, then confirm the second run still waits for the full eof_count;
reference CaptureSession.capture() and the _eof_seen state it should reset
between captures.

In `@test/unittests/test_end2end_extended.py`:
- Around line 1014-1025: The filtered end-to-end cases are disabling routing too
early in _common_kwargs, so they never cover the new execute() guard when
skill_id is set. Update _common_kwargs in test_end2end_extended.py to keep
routing enabled by default and only let execute() suppress routing assertions
when skill_id is present, so the filtered tests still exercise that branch.

---

Nitpick comments:
In `@ovoscope/__init__.py`:
- Around line 859-866: Add an explicit empty-result check immediately after the
skill_id filtering in the message collection logic so a typo or missing
lifecycle tag fails with a clear assertion instead of later causing an
IndexError. Update the filtering path in the relevant message-processing block
that uses self.skill_id, and make the failure message explain that no messages
matched the requested skill_id before any downstream code like the
test_final_session logic accesses messages[-1].
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6bfa79cb-feb5-4084-b297-14fae777203d

📥 Commits

Reviewing files that changed from the base of the PR and between 561fa8c and 29b00c9.

📒 Files selected for processing (3)
  • ovoscope/__init__.py
  • test/unittests/test_capture_session.py
  • test/unittests/test_end2end_extended.py

Comment on lines +188 to +207
def test_eof_count_resets_between_captures(self):
"""The eof counter resets per capture() call so eof_count applies fresh."""
cs = CaptureSession(self.mc,
eof_msgs=["test.eof"],
eof_count=2,
ignore_messages=[])
self._emit_after(0.05, Message("test.eof"))
self._emit_after(0.10, Message("test.eof"))
cs.capture(Message("test.trigger1"), timeout=3)
cs.finish()
# a second capture must again require 2 eofs, not be already-done
cs2 = CaptureSession(self.mc, eof_msgs=["test.eof"], eof_count=2,
ignore_messages=[])
self._emit_after(0.05, Message("test.eof"))
self._emit_after(0.10, Message("test.mid"))
self._emit_after(0.15, Message("test.eof"))
cs2.capture(Message("test.trigger2"), timeout=3)
types = [m.msg_type for m in cs2.finish()]
self.assertIn("test.mid", types)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reuse the same CaptureSession for the second capture.

This only proves that a new instance starts clean. If CaptureSession.capture() stopped resetting _eof_seen, this test would still pass because cs2 gets a fresh counter and fresh handlers.

Suggested change
     def test_eof_count_resets_between_captures(self):
         """The eof counter resets per capture() call so eof_count applies fresh."""
         cs = CaptureSession(self.mc,
                             eof_msgs=["test.eof"],
                             eof_count=2,
                             ignore_messages=[])
         self._emit_after(0.05, Message("test.eof"))
         self._emit_after(0.10, Message("test.eof"))
         cs.capture(Message("test.trigger1"), timeout=3)
-        cs.finish()
-        # a second capture must again require 2 eofs, not be already-done
-        cs2 = CaptureSession(self.mc, eof_msgs=["test.eof"], eof_count=2,
-                             ignore_messages=[])
+        first_len = len(cs.responses)
+        # a second capture on the same session must again require 2 eofs
         self._emit_after(0.05, Message("test.eof"))
         self._emit_after(0.10, Message("test.mid"))
         self._emit_after(0.15, Message("test.eof"))
-        cs2.capture(Message("test.trigger2"), timeout=3)
-        types = [m.msg_type for m in cs2.finish()]
+        cs.capture(Message("test.trigger2"), timeout=3)
+        types = [m.msg_type for m in cs.finish()[first_len:]]
         self.assertIn("test.mid", types)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_eof_count_resets_between_captures(self):
"""The eof counter resets per capture() call so eof_count applies fresh."""
cs = CaptureSession(self.mc,
eof_msgs=["test.eof"],
eof_count=2,
ignore_messages=[])
self._emit_after(0.05, Message("test.eof"))
self._emit_after(0.10, Message("test.eof"))
cs.capture(Message("test.trigger1"), timeout=3)
cs.finish()
# a second capture must again require 2 eofs, not be already-done
cs2 = CaptureSession(self.mc, eof_msgs=["test.eof"], eof_count=2,
ignore_messages=[])
self._emit_after(0.05, Message("test.eof"))
self._emit_after(0.10, Message("test.mid"))
self._emit_after(0.15, Message("test.eof"))
cs2.capture(Message("test.trigger2"), timeout=3)
types = [m.msg_type for m in cs2.finish()]
self.assertIn("test.mid", types)
def test_eof_count_resets_between_captures(self):
"""The eof counter resets per capture() call so eof_count applies fresh."""
cs = CaptureSession(self.mc,
eof_msgs=["test.eof"],
eof_count=2,
ignore_messages=[])
self._emit_after(0.05, Message("test.eof"))
self._emit_after(0.10, Message("test.eof"))
cs.capture(Message("test.trigger1"), timeout=3)
first_len = len(cs.responses)
# a second capture on the same session must again require 2 eofs
self._emit_after(0.05, Message("test.eof"))
self._emit_after(0.10, Message("test.mid"))
self._emit_after(0.15, Message("test.eof"))
cs.capture(Message("test.trigger2"), timeout=3)
types = [m.msg_type for m in cs.finish()[first_len:]]
self.assertIn("test.mid", types)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unittests/test_capture_session.py` around lines 188 - 207, The test
currently creates a new CaptureSession for the second capture, so it does not
verify that CaptureSession.capture() resets EOF state on reuse. Update the test
to use the same CaptureSession instance for both capture() calls, then confirm
the second run still waits for the full eof_count; reference
CaptureSession.capture() and the _eof_seen state it should reset between
captures.

Comment on lines +1014 to +1025
def _common_kwargs(self):
return dict(
minicroft=self.mc,
skill_ids=[SKILL_ID],
eof_msgs=["ovos.utterance.handled"],
eof_count=2, # both lifecycles terminate on ovos.utterance.handled
test_routing=False,
test_active_skills=False,
test_final_session=False,
ignore_messages=DEFAULT_IGNORED + HANDLER_LIFECYCLE,
verbose=False,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep routing enabled in the filtered cases.

The new contract is that execute() suppresses routing assertions when skill_id is set, but _common_kwargs() disables routing for every case up front. That means the filtered tests never exercise the new guard, so a regression there would still pass.

Suggested change
     def _common_kwargs(self):
         return dict(
             minicroft=self.mc,
             skill_ids=[SKILL_ID],
             eof_msgs=["ovos.utterance.handled"],
             eof_count=2,  # both lifecycles terminate on ovos.utterance.handled
-            test_routing=False,
             test_active_skills=False,
             test_final_session=False,
             ignore_messages=DEFAULT_IGNORED + HANDLER_LIFECYCLE,
             verbose=False,
         )
@@
         test = End2EndTest(
             source_message=src,
             expected_messages=[src],
             test_message_number=False,
             test_msg_type=False,
             test_msg_data=False,
             test_msg_context=False,
+            test_routing=False,
             **self._common_kwargs(),
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unittests/test_end2end_extended.py` around lines 1014 - 1025, The
filtered end-to-end cases are disabling routing too early in _common_kwargs, so
they never cover the new execute() guard when skill_id is set. Update
_common_kwargs in test_end2end_extended.py to keep routing enabled by default
and only let execute() suppress routing assertions when skill_id is present, so
the filtered tests still exercise that branch.

@JarbasAl JarbasAl merged commit ca19870 into dev Jun 29, 2026
15 checks passed
@JarbasAl JarbasAl deleted the feat/skill-id-filter-eof-count branch June 29, 2026 22:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant