From 6ea275b82c639b705ce13baa2f851c16b02c8082 Mon Sep 17 00:00:00 2001 From: lukeocodes Date: Fri, 8 May 2026 19:47:16 +0100 Subject: [PATCH] fix(listen): restrict feature prompt to bare-invocation guided flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The optional-features prompt (Speaker diarization / Generate summary / Detect topics / Sentiment analysis) was firing whenever the user passed a source as a positional arg without also passing a feature flag. So: dg listen URL # prompted dg listen URL | cat # also prompted (only +1 of 3 # soft signals — score below # threshold so _agentic stayed # False) dg listen URL --diarize # finally skipped Per the new mental model: the moment the user provides any arg, value, or flag, they have signalled scripting intent. The interactive flow exists only for bare 'dg listen' invocations. This commit lifts the _interactive_features() call into the bare- invocation branch where _interactive_select_source() lives, and removes the standalone gate that was triggering on its own. A new local guided_flow boolean (initialised False, flipped True only in the bare- invocation else-branch) anchors the check at the call site. After this: dg listen URL # no prompts dg listen URL --diarize # no prompts (already worked) dg listen URL | cat # no prompts dg listen --mic # no prompts dg listen # full guided flow (source + # features, as before) Five new pytest cases in TestGuidedFlow lock the invariants: URL arg skips both prompts, file arg skips both, URL+--diarize skips both, bare invocation runs both, and cancelled source-select returns the cancelled BaseResult without ever asking about features. --- .../src/deepctl_cmd_listen/command.py | 11 +-- .../tests/unit/test_listen_command.py | 92 +++++++++++++++++++ 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/command.py b/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/command.py index fb0a457..b198013 100644 --- a/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/command.py +++ b/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/command.py @@ -258,6 +258,7 @@ def handle( ) -> BaseResult: source: str | None = kwargs.get("source") use_mic: bool = kwargs.get("mic", False) + guided_flow = False # ── Resolve mode ─────────────────────────────────────────────── if source == "-": @@ -280,12 +281,12 @@ def handle( ), ) else: - # Interactive: ask the user selected_mode, selected_source = self._interactive_select_source() if not selected_mode: return BaseResult(status="cancelled", message="Cancelled.") mode = selected_mode source = selected_source + guided_flow = True # ── Gather options ───────────────────────────────────────────── model = kwargs.get("model") or "nova-3" @@ -322,13 +323,7 @@ def handle( if caption_format and not diarize: diarize = False # keep explicit — user can add --diarize themselves - # Interactive feature selection when user chose source interactively - # (signals they want a guided experience) - if ( - not _agentic - and mode in ("prerecorded_file", "prerecorded_url") - and not any([diarize, summarize, topics, sentiment]) - ): + if guided_flow and mode in ("prerecorded_file", "prerecorded_url"): diarize, summarize, topics, sentiment = self._interactive_features() # ── Dispatch ─────────────────────────────────────────────────── diff --git a/packages/deepctl-cmd-listen/tests/unit/test_listen_command.py b/packages/deepctl-cmd-listen/tests/unit/test_listen_command.py index 832cffb..9ef74f6 100644 --- a/packages/deepctl-cmd-listen/tests/unit/test_listen_command.py +++ b/packages/deepctl-cmd-listen/tests/unit/test_listen_command.py @@ -241,6 +241,98 @@ def test_handle_explicit_stdin_dash( assert result.source == "stdin" +class TestGuidedFlow: + """Verify the guided-flow gate: prompts only fire on bare `dg listen`.""" + + @pytest.fixture + def command(self): + return ListenCommand() + + @pytest.fixture + def common_kwargs(self): + return { + "client": Mock(spec=DeepgramClient), + "config": Mock(spec=Config), + "auth_manager": Mock(spec=AuthManager), + } + + def test_url_arg_skips_both_prompts(self, command, common_kwargs): + with patch.object(command, "_interactive_features") as feat, patch.object( + command, "_interactive_select_source" + ) as src, patch.object( + command, "_prerecorded", return_value=BaseResult(status="ok") + ): + command.handle( + **common_kwargs, source="https://example.com/audio.wav" + ) + assert feat.call_count == 0 + assert src.call_count == 0 + + def test_file_arg_skips_both_prompts(self, command, common_kwargs): + with patch.object(command, "_interactive_features") as feat, patch.object( + command, "_interactive_select_source" + ) as src, patch.object( + command, "_prerecorded", return_value=BaseResult(status="ok") + ): + command.handle(**common_kwargs, source="/tmp/audio.wav") + assert feat.call_count == 0 + assert src.call_count == 0 + + def test_url_arg_with_diarize_skips_both_prompts( + self, command, common_kwargs + ): + with patch.object(command, "_interactive_features") as feat, patch.object( + command, "_interactive_select_source" + ) as src, patch.object( + command, "_prerecorded", return_value=BaseResult(status="ok") + ): + command.handle( + **common_kwargs, + source="https://example.com/audio.wav", + diarize=True, + ) + assert feat.call_count == 0 + assert src.call_count == 0 + + def test_bare_invocation_runs_full_guided_flow( + self, command, common_kwargs + ): + with patch.object( + command, + "_interactive_features", + return_value=(False, False, False, False), + ) as feat, patch.object( + command, + "_interactive_select_source", + return_value=("prerecorded_url", "https://x.com/a.wav"), + ) as src, patch.object( + command, "_prerecorded", return_value=BaseResult(status="ok") + ), patch( + "sys.stdin" + ) as mock_stdin, patch( + "deepctl_cmd_listen.command._agentic", False + ): + mock_stdin.isatty.return_value = True + command.handle(**common_kwargs) + assert src.call_count == 1 + assert feat.call_count == 1 + + def test_cancelled_source_select_returns_cancelled( + self, command, common_kwargs + ): + with patch.object( + command, "_interactive_select_source", return_value=(None, None) + ), patch.object(command, "_interactive_features") as feat, patch( + "sys.stdin" + ) as mock_stdin, patch( + "deepctl_cmd_listen.command._agentic", False + ): + mock_stdin.isatty.return_value = True + result = command.handle(**common_kwargs) + assert result.status == "cancelled" + assert feat.call_count == 0 + + class TestListenResult: """Test ListenResult model fields and defaults."""