Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/specify_cli/workflows/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,14 @@ def _execute_steps(
}
self._record_result(context, state, step_id, step_data)

# Propagate model routing: if a step output specifies a model,
# use it as the default for subsequent steps. This enables
# evaluator-driven model routing (e.g., evaluator recommends
# "premium" tier → next phase uses premium model).
routed_model = result.output.get("model")
if routed_model and isinstance(routed_model, str):
context.default_model = routed_model

state.append_log(
{
"event": "step_completed",
Expand Down
100 changes: 100 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -17204,3 +17204,103 @@ def test_enable_disable_idempotent_warnings(self, project_dir, monkeypatch):
result = runner.invoke(app, ["workflow", "disable", "align-wf"])
assert result.exit_code == 0
assert "already disabled" in result.output


class TestModelRoutingPropagation:
"""Test that step output model propagates to context.default_model.

When a step's output includes a ``model`` field (e.g., from an evaluator
recommending a tier), the engine propagates it to ``context.default_model``
so subsequent steps use the routed model automatically.
"""

def test_step_output_model_propagates_to_context(self):
"""A step that outputs a model updates context.default_model."""
from specify_cli.workflows.base import StepContext, StepResult, StepStatus
from specify_cli.workflows.engine import WorkflowEngine

# Simulate what the engine does after step execution
ctx = StepContext(default_model="sonnet-4")
result = StepResult(
status=StepStatus.COMPLETED,
output={"model": "opus-4", "integration": "claude"},
)

# This is the propagation logic from engine.py
routed_model = result.output.get("model")
if routed_model and isinstance(routed_model, str):
ctx.default_model = routed_model

assert ctx.default_model == "opus-4"

def test_step_without_model_output_does_not_change_context(self):
"""A step without a model output leaves default_model unchanged."""
from specify_cli.workflows.base import StepContext, StepResult, StepStatus

ctx = StepContext(default_model="sonnet-4")
result = StepResult(
status=StepStatus.COMPLETED,
output={"integration": "claude"},
)

routed_model = result.output.get("model")
if routed_model and isinstance(routed_model, str):
ctx.default_model = routed_model

assert ctx.default_model == "sonnet-4"

def test_non_string_model_output_does_not_change_context(self):
"""A non-string model output (list, dict, None) is ignored."""
from specify_cli.workflows.base import StepContext, StepResult, StepStatus

for bad_model in (None, ["claude"], {"name": "gpt-5"}, 42):
ctx = StepContext(default_model="sonnet-4")
result = StepResult(
status=StepStatus.COMPLETED,
output={"model": bad_model},
)

routed_model = result.output.get("model")
if routed_model and isinstance(routed_model, str):
ctx.default_model = routed_model

assert ctx.default_model == "sonnet-4", f"Should not propagate {bad_model!r}"

def test_subsequent_step_uses_routed_model(self):
"""A subsequent step picks up the routed model from context."""
from unittest.mock import patch
from specify_cli.workflows.steps.command import CommandStep
from specify_cli.workflows.base import StepContext

# Simulate: first step routed to opus-4
ctx = StepContext(default_model="opus-4")
step = CommandStep()
config = {
"id": "next-step",
"command": "speckit.plan",
"input": {},
}
with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None):
result = step.execute(config, ctx)
# Step inherits the routed model from context
assert result.output["model"] == "opus-4"

def test_step_override_still_takes_priority_over_routed_model(self):
"""An explicit per-step model override still wins over routed context."""
from unittest.mock import patch
from specify_cli.workflows.steps.command import CommandStep
from specify_cli.workflows.base import StepContext

# Context was routed to opus-4, but step explicitly wants sonnet-4
ctx = StepContext(default_model="opus-4")
step = CommandStep()
config = {
"id": "override-step",
"command": "speckit.implement",
"model": "sonnet-4",
"input": {},
}
with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None):
result = step.execute(config, ctx)
# Explicit override wins
assert result.output["model"] == "sonnet-4"