From ffd4b7062cc6f87c7eb24eb350cb2697a54e34ce Mon Sep 17 00:00:00 2001 From: yxshee <84263676+yxshee@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:48:44 +0530 Subject: [PATCH] fix(workflows): stop dispatching "None" as command step arguments A command step's `args` that is a single `{{ }}` block keeps the expression's typed value, so a reference with nothing behind it resolves to None: an optional input that was not supplied, or an output key the earlier step does not produce. The documented example `args: "{{ steps.specify.output.file }}"` is one, since command steps have no `file` output. `execute()` passed `str(None)` to the integration, so the agent received `/speckit-plan None`. The same value inside a longer template renders as empty text, and a missing `args` key already means no arguments. Treat a null `args` as no arguments. Values that are only falsy (0, false, the empty string) are dispatched as before. Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous) Co-Authored-By: Claude Opus 5.5 --- .../workflows/step/command/__init__.py | 8 +++- tests/test_workflows.py | 43 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/workflows/step/command/__init__.py b/src/specify_cli/workflows/step/command/__init__.py index 770757e981..10a464850f 100644 --- a/src/specify_cli/workflows/step/command/__init__.py +++ b/src/specify_cli/workflows/step/command/__init__.py @@ -165,7 +165,13 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: return StepResult(status=StepStatus.FAILED, error=invalid_timeout) timeout = config.get("timeout", _DEFAULT_CAPTURE_TIMEOUT) - args_str = str(resolved_input.get("args", "")) + # A lone ``{{ }}`` block keeps its typed value, so an ``args`` reference + # with nothing behind it (an optional input that was not supplied, an + # output key the earlier step does not produce) is None. ``str()`` sent + # that to the agent as the text "None"; send no arguments instead, as + # for a missing 'args' key and as interpolation renders None in text. + raw_args = resolved_input.get("args", "") + args_str = "" if raw_args is None else str(raw_args) output: dict[str, Any] = { "command": command, "integration": integration, diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 04298dc640..6cd7100c5a 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -2155,6 +2155,49 @@ def test_dispatch_with_mock_cli(self, tmp_path, monkeypatch): # Claude is a SkillsIntegration so uses /speckit-specify assert "/speckit-specify login" in call_args[0][0][2] + @pytest.mark.parametrize( + ("args", "expected_prompt"), + [ + # A lone expression that resolves to None (an optional input that + # was not supplied, an output key the earlier step does not + # produce) means no arguments, as a missing 'args' key does. + ("{{ inputs.feature }}", "/speckit-plan"), + ("{{ steps.specify.output.file }}", "/speckit-plan"), + # Values that are only falsy are still dispatched. + ("{{ inputs.count }}", "/speckit-plan 0"), + ("{{ inputs.flag }}", "/speckit-plan False"), + ("{{ inputs.empty }}", "/speckit-plan"), + ("Feature: {{ inputs.name }}", "/speckit-plan Feature: login"), + ], + ) + def test_dispatch_args_resolving_to_null_send_no_arguments( + self, tmp_path, args, expected_prompt + ): + from unittest.mock import patch, MagicMock + from specify_cli.workflows.step.command import CommandStep + from specify_cli.workflows.base import StepContext, StepStatus + + ctx = StepContext( + inputs={"count": 0, "flag": False, "empty": "", "name": "login"}, + steps={"specify": {"output": {"exit_code": 0}}}, + default_integration="claude", + project_root=str(tmp_path), + ) + config = {"id": "plan", "command": "speckit.plan", "input": {"args": args}} + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "" + mock_result.stderr = "" + + with patch("specify_cli.workflows.step.command.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("subprocess.run", return_value=mock_result) as mock_run: + result = CommandStep().execute(config, ctx) + + assert result.status == StepStatus.COMPLETED + assert mock_run.call_args[0][0][2] == expected_prompt + def test_dispatch_uses_executable_override_for_fallback_preflight(self, tmp_path, monkeypatch): """Command preflight falls back to build_exec_args() argv[0].""" from unittest.mock import MagicMock, patch