⚡ Sangsakala Proxy 🏠 Home
Skip to content

Commit 5becbff

Browse files
yxsheeclaudeKSchlobohm
authored
fix(workflows): stop dispatching "None" as command step arguments (#5016)
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 <noreply@anthropic.com> Co-authored-by: Ken Schlobohm <keschlob@microsoft.com>
1 parent ba7894d commit 5becbff

2 files changed

Lines changed: 50 additions & 1 deletion

File tree

‎src/specify_cli/workflows/step/command/__init__.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,13 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
165165
return StepResult(status=StepStatus.FAILED, error=invalid_timeout)
166166
timeout = config.get("timeout", _DEFAULT_CAPTURE_TIMEOUT)
167167

168-
args_str = str(resolved_input.get("args", ""))
168+
# A lone ``{{ }}`` block keeps its typed value, so an ``args`` reference
169+
# with nothing behind it (an optional input that was not supplied, an
170+
# output key the earlier step does not produce) is None. ``str()`` sent
171+
# that to the agent as the text "None"; send no arguments instead, as
172+
# for a missing 'args' key and as interpolation renders None in text.
173+
raw_args = resolved_input.get("args", "")
174+
args_str = "" if raw_args is None else str(raw_args)
169175
output: dict[str, Any] = {
170176
"command": command,
171177
"integration": integration,

‎tests/test_workflows.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2352,6 +2352,49 @@ def test_dispatch_with_mock_cli(self, tmp_path, monkeypatch):
23522352
# Claude is a SkillsIntegration so uses /speckit-specify
23532353
assert "/speckit-specify login" in call_args[0][0][2]
23542354

2355+
@pytest.mark.parametrize(
2356+
("args", "expected_prompt"),
2357+
[
2358+
# A lone expression that resolves to None (an optional input that
2359+
# was not supplied, an output key the earlier step does not
2360+
# produce) means no arguments, as a missing 'args' key does.
2361+
("{{ inputs.feature }}", "/speckit-plan"),
2362+
("{{ steps.specify.output.file }}", "/speckit-plan"),
2363+
# Values that are only falsy are still dispatched.
2364+
("{{ inputs.count }}", "/speckit-plan 0"),
2365+
("{{ inputs.flag }}", "/speckit-plan False"),
2366+
("{{ inputs.empty }}", "/speckit-plan"),
2367+
("Feature: {{ inputs.name }}", "/speckit-plan Feature: login"),
2368+
],
2369+
)
2370+
def test_dispatch_args_resolving_to_null_send_no_arguments(
2371+
self, tmp_path, args, expected_prompt
2372+
):
2373+
from unittest.mock import patch, MagicMock
2374+
from specify_cli.workflows.step.command import CommandStep
2375+
from specify_cli.workflows.base import StepContext, StepStatus
2376+
2377+
ctx = StepContext(
2378+
inputs={"count": 0, "flag": False, "empty": "", "name": "login"},
2379+
steps={"specify": {"output": {"exit_code": 0}}},
2380+
default_integration="claude",
2381+
project_root=str(tmp_path),
2382+
)
2383+
config = {"id": "plan", "command": "speckit.plan", "input": {"args": args}}
2384+
2385+
mock_result = MagicMock()
2386+
mock_result.returncode = 0
2387+
mock_result.stdout = ""
2388+
mock_result.stderr = ""
2389+
2390+
with patch("specify_cli.workflows.step.command.shutil.which", return_value="/usr/local/bin/claude"), \
2391+
patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/claude"), \
2392+
patch("subprocess.run", return_value=mock_result) as mock_run:
2393+
result = CommandStep().execute(config, ctx)
2394+
2395+
assert result.status == StepStatus.COMPLETED
2396+
assert mock_run.call_args[0][0][2] == expected_prompt
2397+
23552398
def test_dispatch_uses_executable_override_for_fallback_preflight(self, tmp_path, monkeypatch):
23562399
"""Command preflight falls back to build_exec_args() argv[0]."""
23572400
from unittest.mock import MagicMock, patch

0 commit comments

Comments
 (0)