steerdev 1.0.57__tar.gz → 1.0.59__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. {steerdev-1.0.57 → steerdev-1.0.59}/PKG-INFO +1 -1
  2. {steerdev-1.0.57 → steerdev-1.0.59}/pyproject.toml +1 -1
  3. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/agent_loop.py +55 -0
  4. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/commands.py +1 -1
  5. steerdev-1.0.59/src/steerdev_agent/api/merger.py +71 -0
  6. steerdev-1.0.59/tests/test_workflow_runs_api.py +358 -0
  7. {steerdev-1.0.57 → steerdev-1.0.59}/.github/workflows/pre-commit.yml +0 -0
  8. {steerdev-1.0.57 → steerdev-1.0.59}/.github/workflows/publish.yml +0 -0
  9. {steerdev-1.0.57 → steerdev-1.0.59}/.gitignore +0 -0
  10. {steerdev-1.0.57 → steerdev-1.0.59}/.pre-commit-config.yaml +0 -0
  11. {steerdev-1.0.57 → steerdev-1.0.59}/AGENTS.md +0 -0
  12. {steerdev-1.0.57 → steerdev-1.0.59}/CLAUDE.md +0 -0
  13. {steerdev-1.0.57 → steerdev-1.0.59}/README.md +0 -0
  14. {steerdev-1.0.57 → steerdev-1.0.59}/scripts/pre-commit-version-bump.sh +0 -0
  15. {steerdev-1.0.57 → steerdev-1.0.59}/snapshots/steerdev-agent-v1/Dockerfile +0 -0
  16. {steerdev-1.0.57 → steerdev-1.0.59}/snapshots/steerdev-agent-v1/start-agent.sh +0 -0
  17. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/__init__.py +0 -0
  18. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/__init__.py +0 -0
  19. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/activity.py +0 -0
  20. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/agents.py +0 -0
  21. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/canals.py +0 -0
  22. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/client.py +0 -0
  23. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/configs.py +0 -0
  24. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/context.py +0 -0
  25. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/events.py +0 -0
  26. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/hooks.py +0 -0
  27. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/implementation_plan.py +0 -0
  28. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/messages.py +0 -0
  29. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/prd.py +0 -0
  30. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/reports.py +0 -0
  31. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/runs.py +0 -0
  32. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/sessions.py +0 -0
  33. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/specs.py +0 -0
  34. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/tasks.py +0 -0
  35. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/workflow_runs.py +0 -0
  36. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/api/workflows.py +0 -0
  37. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/cli.py +0 -0
  38. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/config/__init__.py +0 -0
  39. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/config/models.py +0 -0
  40. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/config/platform.py +0 -0
  41. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/config/settings.py +0 -0
  42. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/evidence.py +0 -0
  43. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/evidence_assets.py +0 -0
  44. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/executor/__init__.py +0 -0
  45. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/executor/base.py +0 -0
  46. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/executor/claude.py +0 -0
  47. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/executor/stream.py +0 -0
  48. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/handlers/__init__.py +0 -0
  49. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/handlers/prd.py +0 -0
  50. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/integration.py +0 -0
  51. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/prompt/__init__.py +0 -0
  52. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/prompt/builder.py +0 -0
  53. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/prompt/templates.py +0 -0
  54. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/prompt/workflow_template.py +0 -0
  55. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/py.typed +0 -0
  56. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/retry.py +0 -0
  57. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/runner.py +0 -0
  58. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/__init__.py +0 -0
  59. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/claude_setup.py +0 -0
  60. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/repo_setup.py +0 -0
  61. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/templates/ci/canal-integration.yml +0 -0
  62. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/templates/claude_md_section.md +0 -0
  63. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/templates/settings.json +0 -0
  64. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/templates/skills/steerdev-activity-skill/SKILL.md +0 -0
  65. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/templates/skills/steerdev-canal-workflow-skill/SKILL.md +0 -0
  66. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/templates/skills/steerdev-context-skill/SKILL.md +0 -0
  67. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/templates/skills/steerdev-git-workflow-skill/SKILL.md +0 -0
  68. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/templates/skills/steerdev-merge-into-canal-skill/SKILL.md +0 -0
  69. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/templates/skills/steerdev-progress-logging-skill/SKILL.md +0 -0
  70. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/templates/skills/steerdev-single-task-merge-skill/SKILL.md +0 -0
  71. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/templates/skills/steerdev-specs-management-skill/SKILL.md +0 -0
  72. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/templates/skills/steerdev-task-management-skill/SKILL.md +0 -0
  73. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/templates/skills/steerdev-wave-tasks-merge-skill/SKILL.md +0 -0
  74. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/templates/steerdev.yaml +0 -0
  75. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/setup/templates/worktrunk.config.toml +0 -0
  76. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/update_check.py +0 -0
  77. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/version.py +0 -0
  78. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/workflow/__init__.py +0 -0
  79. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/workflow/context.py +0 -0
  80. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/workflow/executor.py +0 -0
  81. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/workflow/memory.py +0 -0
  82. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/workspace/__init__.py +0 -0
  83. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/workspace/project_manager.py +0 -0
  84. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/workspace/tool_detection.py +0 -0
  85. {steerdev-1.0.57 → steerdev-1.0.59}/src/steerdev_agent/worktree.py +0 -0
  86. {steerdev-1.0.57 → steerdev-1.0.59}/tests/__init__.py +0 -0
  87. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_agent_loop.py +0 -0
  88. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_agent_loop_extended.py +0 -0
  89. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_agents_api.py +0 -0
  90. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_api_client.py +0 -0
  91. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_claude_executor.py +0 -0
  92. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_claude_setup.py +0 -0
  93. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_client_methods.py +0 -0
  94. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_commands_api.py +0 -0
  95. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_config.py +0 -0
  96. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_config_extended.py +0 -0
  97. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_conflict_mitigation.py +0 -0
  98. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_context_search.py +0 -0
  99. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_executor.py +0 -0
  100. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_platform_config.py +0 -0
  101. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_prompt.py +0 -0
  102. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_reports_client.py +0 -0
  103. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_retry.py +0 -0
  104. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_runner_merge_modes.py +0 -0
  105. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_runner_worktrees.py +0 -0
  106. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_stream_parser.py +0 -0
  107. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_tasks.py +0 -0
  108. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_version.py +0 -0
  109. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_workflow_context.py +0 -0
  110. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_workflow_memory.py +0 -0
  111. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_workflow_prompt_template.py +0 -0
  112. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_workspace.py +0 -0
  113. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_workspace_extended.py +0 -0
  114. {steerdev-1.0.57 → steerdev-1.0.59}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerdev
3
- Version: 1.0.57
3
+ Version: 1.0.59
4
4
  Summary: Backend task runner for steerdev.com - orchestrates CLI coding agents with activity reporting
5
5
  Project-URL: Homepage, https://github.com/pentoai/steerdev-agent
6
6
  Project-URL: Repository, https://github.com/pentoai/steerdev-agent
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "steerdev"
3
- version = "1.0.57"
3
+ version = "1.0.59"
4
4
  description = "Backend task runner for steerdev.com - orchestrates CLI coding agents with activity reporting"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -126,6 +126,8 @@ class CommandExecutor:
126
126
  await self._execute_task_command(command, project_id, working_directory)
127
127
  elif command.command_type == "prompt":
128
128
  await self._execute_prompt_command(command, project_id, working_directory)
129
+ elif command.command_type == "merger":
130
+ await self._execute_merger_command(command, project_id, working_directory)
129
131
  else:
130
132
  await self._commands_client.mark_failed(
131
133
  command.id, error=f"Unknown command type: {command.command_type}"
@@ -403,6 +405,59 @@ class CommandExecutor:
403
405
  finally:
404
406
  await events_client.close()
405
407
 
408
+ async def _execute_merger_command(
409
+ self,
410
+ command: CommandResponse,
411
+ project_id: str,
412
+ working_directory: Path,
413
+ ) -> None:
414
+ """Execute a merger command - triggers merger run and polls for completion."""
415
+ from steerdev_agent.api.merger import MergerClient
416
+
417
+ assert self._commands_client is not None
418
+
419
+ merger_run_id = command.metadata.get("mergerRunId")
420
+ if not merger_run_id:
421
+ raise ValueError("merger command missing mergerRunId in metadata")
422
+
423
+ merger_client = MergerClient(api_key=self._api_key)
424
+
425
+ logger.info(f"Starting merger run: {merger_run_id}")
426
+
427
+ # Mark command as executing
428
+ await self._commands_client.mark_executing(command.id)
429
+
430
+ # Trigger execution
431
+ result = merger_client.execute_merger_run(merger_run_id)
432
+ if not result:
433
+ raise RuntimeError(f"Failed to trigger merger run {merger_run_id}")
434
+
435
+ # Poll for completion
436
+ max_polls = 60 # 30 minutes at 30s intervals
437
+ for _ in range(max_polls):
438
+ run = merger_client.get_merger_run(merger_run_id)
439
+ if not run:
440
+ raise RuntimeError("Failed to get merger run status")
441
+
442
+ if run.status in ("completed", "failed", "partial"):
443
+ logger.info(f"Merger run {merger_run_id} finished: {run.status}")
444
+ if run.status == "failed":
445
+ raise RuntimeError(f"Merger run failed: {run.error_message}")
446
+ await self._commands_client.mark_completed(
447
+ command.id,
448
+ result=f"Merger run {merger_run_id} {run.status}",
449
+ )
450
+ self._commands_succeeded += 1
451
+ return
452
+
453
+ if self._shutdown_event.is_set():
454
+ logger.info("Shutdown during merger polling, aborting")
455
+ raise RuntimeError("Shutdown requested during merger run")
456
+
457
+ await asyncio.sleep(30)
458
+
459
+ raise RuntimeError(f"Merger run {merger_run_id} timed out")
460
+
406
461
 
407
462
  # ---------------------------------------------------------------------------
408
463
  # Project-scoped agent (original)
@@ -13,7 +13,7 @@ from steerdev_agent.api.client import get_agent_id, get_api_endpoint, get_api_ke
13
13
 
14
14
  console = Console()
15
15
 
16
- CommandType = Literal["task", "prompt", "control"]
16
+ CommandType = Literal["task", "prompt", "control", "merger"]
17
17
  CommandStatus = Literal[
18
18
  "pending", "claimed", "executing", "completed", "failed", "cancelled", "expired"
19
19
  ]
@@ -0,0 +1,71 @@
1
+ """Merger API client for merger agent operations."""
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from steerdev_agent.api.client import SteerDevClient
8
+
9
+
10
+ class MergerRunResponse(BaseModel):
11
+ id: str
12
+ status: str
13
+ wave_ids: list[str] = []
14
+ task_ids: list[str] = []
15
+ pr_ids: list[str] = []
16
+ evidence_report_ids: list[str] = []
17
+ summary: str | None = None
18
+ merged_branches: list[str] | None = None
19
+ error_message: str | None = None
20
+ started_at: str | None = None
21
+ completed_at: str | None = None
22
+
23
+
24
+ class GateResponse(BaseModel):
25
+ id: str
26
+ merger_run_id: str
27
+ canal_id: str
28
+ gate_number: int
29
+ status: str
30
+ started_at: str | None = None
31
+ completed_at: str | None = None
32
+ details: dict[str, Any] | None = None
33
+ error_message: str | None = None
34
+
35
+
36
+ class MergerClient(SteerDevClient):
37
+ """Client for merger agent API operations."""
38
+
39
+ def execute_merger_run(self, merger_run_id: str) -> dict[str, Any] | None:
40
+ """Trigger execution of a full merger run."""
41
+ response = self.post(f"/merger-runs/{merger_run_id}/execute")
42
+ if response and response.status_code == 200:
43
+ return response.json()
44
+ return None
45
+
46
+ def get_merger_run(self, merger_run_id: str) -> MergerRunResponse | None:
47
+ """Get merger run status."""
48
+ response = self.get(f"/merger-runs/{merger_run_id}")
49
+ if response and response.status_code == 200:
50
+ return MergerRunResponse(**response.json())
51
+ return None
52
+
53
+ def get_merger_gates(self, merger_run_id: str) -> list[GateResponse]:
54
+ """Get gate statuses for a merger run."""
55
+ response = self.get(f"/merger-runs/{merger_run_id}/gates")
56
+ if response and response.status_code == 200:
57
+ data = response.json()
58
+ return [GateResponse(**g) for g in data.get("gates", [])]
59
+ return []
60
+
61
+ def execute_gate(
62
+ self, canal_id: str, gate_number: int, merger_run_id: str, merge_id: str | None = None
63
+ ) -> dict[str, Any] | None:
64
+ """Trigger execution of a specific gate."""
65
+ payload: dict[str, Any] = {"merger_run_id": merger_run_id}
66
+ if merge_id:
67
+ payload["merge_id"] = merge_id
68
+ response = self.post(f"/canals/{canal_id}/gates/{gate_number}", json=payload)
69
+ if response and response.status_code == 200:
70
+ return response.json()
71
+ return None
@@ -0,0 +1,358 @@
1
+ """Tests for workflow runs API client — models, start_workflow, and disabled-workflow handling."""
2
+
3
+ import os
4
+ from typing import Any
5
+ from unittest.mock import AsyncMock, patch
6
+
7
+ import httpx
8
+ import pytest
9
+
10
+ from steerdev_agent.api.workflow_runs import (
11
+ PhaseRunResponse,
12
+ WorkflowRunResponse,
13
+ WorkflowRunsClient,
14
+ )
15
+
16
+ # ── Shared fixtures ───────────────────────────────────────────────────────
17
+
18
+ NOW = "2026-01-01T00:00:00Z"
19
+
20
+
21
+ def _make_workflow_run_json(
22
+ *,
23
+ workflow_id: str = "wf-1",
24
+ workflow_name: str = "Feature Dev",
25
+ status: str = "running",
26
+ total_phases: int = 3,
27
+ **extra: Any,
28
+ ) -> dict[str, Any]:
29
+ return {
30
+ "id": "wr-1",
31
+ "workflow_id": workflow_id,
32
+ "workflow_name": workflow_name,
33
+ "status": status,
34
+ "total_phases": total_phases,
35
+ "created_at": NOW,
36
+ "updated_at": NOW,
37
+ **extra,
38
+ }
39
+
40
+
41
+ # ── Model tests ───────────────────────────────────────────────────────────
42
+
43
+
44
+ class TestWorkflowRunModels:
45
+ """Tests for Pydantic models in workflow_runs.py."""
46
+
47
+ def test_workflow_run_response_minimal(self):
48
+ resp = WorkflowRunResponse(
49
+ id="wr-1",
50
+ workflow_id="wf-1",
51
+ workflow_name="Test",
52
+ status="running",
53
+ created_at=NOW,
54
+ updated_at=NOW,
55
+ )
56
+ assert resp.id == "wr-1"
57
+ assert resp.project_id is None
58
+ assert resp.phase_runs is None
59
+ assert resp.phases_completed == 0
60
+ assert resp.total_phases == 0
61
+
62
+ def test_workflow_run_response_full(self):
63
+ resp = WorkflowRunResponse(
64
+ id="wr-1",
65
+ workflow_id="wf-1",
66
+ workflow_name="Feature Dev",
67
+ project_id="proj-1",
68
+ run_id="run-1",
69
+ linear_issue_id="LIN-123",
70
+ status="completed",
71
+ current_phase_id="p3",
72
+ current_phase_name="Verify",
73
+ context={"key": "value"},
74
+ phases_completed=3,
75
+ total_phases=3,
76
+ started_at=NOW,
77
+ completed_at=NOW,
78
+ created_at=NOW,
79
+ updated_at=NOW,
80
+ )
81
+ assert resp.phases_completed == 3
82
+ assert resp.context == {"key": "value"}
83
+ assert resp.linear_issue_id == "LIN-123"
84
+
85
+ def test_phase_run_response_minimal(self):
86
+ resp = PhaseRunResponse(
87
+ id="pr-1",
88
+ workflow_run_id="wr-1",
89
+ phase_id="p-1",
90
+ phase_name="Plan",
91
+ phase_type="plan",
92
+ status="pending",
93
+ created_at=NOW,
94
+ updated_at=NOW,
95
+ )
96
+ assert resp.attempt_number == 1
97
+ assert resp.input_context == {}
98
+ assert resp.result_summary is None
99
+
100
+ def test_phase_run_response_full(self):
101
+ resp = PhaseRunResponse(
102
+ id="pr-1",
103
+ workflow_run_id="wr-1",
104
+ phase_id="p-1",
105
+ phase_name="Implement",
106
+ phase_type="implement",
107
+ status="completed",
108
+ attempt_number=2,
109
+ input_context={"spec": "build API"},
110
+ output_context={"files": ["main.py"]},
111
+ result_summary="Implementation complete",
112
+ started_at=NOW,
113
+ completed_at=NOW,
114
+ created_at=NOW,
115
+ updated_at=NOW,
116
+ )
117
+ assert resp.attempt_number == 2
118
+ assert resp.output_context["files"] == ["main.py"]
119
+
120
+
121
+ # ── Client tests ──────────────────────────────────────────────────────────
122
+
123
+
124
+ class TestWorkflowRunsClient:
125
+ """Tests for WorkflowRunsClient."""
126
+
127
+ def test_init_with_api_key(self):
128
+ client = WorkflowRunsClient(api_key="test-key")
129
+ assert client.api_key == "test-key"
130
+
131
+ def test_init_from_env(self):
132
+ with patch.dict(os.environ, {"STEERDEV_API_KEY": "env-key"}):
133
+ client = WorkflowRunsClient()
134
+ assert client.api_key == "env-key"
135
+
136
+ def test_headers(self):
137
+ client = WorkflowRunsClient(api_key="test-key")
138
+ assert client.headers["Authorization"] == "Bearer test-key"
139
+
140
+ @pytest.mark.asyncio
141
+ async def test_context_manager(self):
142
+ async with WorkflowRunsClient(api_key="key") as client:
143
+ assert client.api_key == "key"
144
+ assert client._client is None
145
+
146
+ @pytest.mark.asyncio
147
+ async def test_close_without_client(self):
148
+ client = WorkflowRunsClient(api_key="key")
149
+ await client.close() # Should not raise
150
+
151
+
152
+ class TestStartWorkflow:
153
+ """Tests for start_workflow — including disabled-workflow (409) handling."""
154
+
155
+ @pytest.mark.asyncio
156
+ async def test_start_workflow_success(self):
157
+ """201 → returns WorkflowRunResponse."""
158
+ client = WorkflowRunsClient(api_key="key")
159
+ mock_response = httpx.Response(201, json=_make_workflow_run_json())
160
+ mock_http = AsyncMock()
161
+ mock_http.post = AsyncMock(return_value=mock_response)
162
+ client._client = mock_http
163
+
164
+ result = await client.start_workflow("wf-1", run_id="run-1")
165
+ assert result is not None
166
+ assert result.id == "wr-1"
167
+ assert result.workflow_id == "wf-1"
168
+
169
+ @pytest.mark.asyncio
170
+ async def test_start_workflow_with_initial_context(self):
171
+ """Initial context is passed in the payload."""
172
+ client = WorkflowRunsClient(api_key="key")
173
+ mock_response = httpx.Response(201, json=_make_workflow_run_json())
174
+ mock_http = AsyncMock()
175
+ mock_http.post = AsyncMock(return_value=mock_response)
176
+ client._client = mock_http
177
+
178
+ result = await client.start_workflow(
179
+ "wf-1",
180
+ initial_context={"task_title": "Fix login bug"},
181
+ )
182
+ assert result is not None
183
+ # Verify payload included initial_context
184
+ call_kwargs = mock_http.post.call_args
185
+ payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
186
+ assert payload["initial_context"] == {"task_title": "Fix login bug"}
187
+
188
+ @pytest.mark.asyncio
189
+ async def test_start_workflow_server_error(self):
190
+ """500 → returns None."""
191
+ client = WorkflowRunsClient(api_key="key")
192
+ mock_response = httpx.Response(500, text="Internal Server Error")
193
+ mock_http = AsyncMock()
194
+ mock_http.post = AsyncMock(return_value=mock_response)
195
+ client._client = mock_http
196
+
197
+ result = await client.start_workflow("wf-1")
198
+ assert result is None
199
+
200
+ @pytest.mark.asyncio
201
+ async def test_start_workflow_not_found(self):
202
+ """404 → returns None."""
203
+ client = WorkflowRunsClient(api_key="key")
204
+ mock_response = httpx.Response(
205
+ 404,
206
+ json={"error": "Not Found", "message": "Workflow not found"},
207
+ )
208
+ mock_http = AsyncMock()
209
+ mock_http.post = AsyncMock(return_value=mock_response)
210
+ client._client = mock_http
211
+
212
+ result = await client.start_workflow("wf-nonexistent")
213
+ assert result is None
214
+
215
+ @pytest.mark.asyncio
216
+ async def test_start_workflow_request_error(self):
217
+ """Network error → returns None."""
218
+ client = WorkflowRunsClient(api_key="key")
219
+ mock_http = AsyncMock()
220
+ mock_http.post = AsyncMock(side_effect=httpx.RequestError("connection refused"))
221
+ client._client = mock_http
222
+
223
+ result = await client.start_workflow("wf-1")
224
+ assert result is None
225
+
226
+ @pytest.mark.asyncio
227
+ async def test_start_workflow_disabled_409_returns_none(self):
228
+ """409 with NO_ACTIVE_WORKFLOW → returns None (agent should proceed without workflow)."""
229
+ client = WorkflowRunsClient(api_key="key")
230
+ mock_response = httpx.Response(
231
+ 409,
232
+ json={
233
+ "error": "Workflow disabled",
234
+ "message": "The requested workflow is disabled and no suitable active workflow was found.",
235
+ "code": "NO_ACTIVE_WORKFLOW",
236
+ },
237
+ )
238
+ mock_http = AsyncMock()
239
+ mock_http.post = AsyncMock(return_value=mock_response)
240
+ client._client = mock_http
241
+
242
+ result = await client.start_workflow("wf-disabled")
243
+ assert result is None
244
+
245
+ @pytest.mark.asyncio
246
+ async def test_start_workflow_disabled_409_reassigned(self):
247
+ """409 that re-triaged to a different workflow → 201 with new workflow_id.
248
+
249
+ This tests the server-side behavior: the API transparently re-assigns
250
+ to the best active workflow. The client receives a normal 201 with the
251
+ new workflow's run.
252
+ """
253
+ client = WorkflowRunsClient(api_key="key")
254
+ # Server re-triaged to a different workflow
255
+ mock_response = httpx.Response(
256
+ 201,
257
+ json=_make_workflow_run_json(
258
+ workflow_id="wf-fallback",
259
+ workflow_name="Bug Fix Workflow",
260
+ ),
261
+ )
262
+ mock_http = AsyncMock()
263
+ mock_http.post = AsyncMock(return_value=mock_response)
264
+ client._client = mock_http
265
+
266
+ result = await client.start_workflow("wf-disabled-original")
267
+ assert result is not None
268
+ # The returned workflow_id is the re-assigned one, not the original
269
+ assert result.workflow_id == "wf-fallback"
270
+ assert result.workflow_name == "Bug Fix Workflow"
271
+
272
+
273
+ class TestAdvancePhase:
274
+ """Tests for advance_phase."""
275
+
276
+ @pytest.mark.asyncio
277
+ async def test_advance_success(self):
278
+ client = WorkflowRunsClient(api_key="key")
279
+ mock_response = httpx.Response(
280
+ 200,
281
+ json=_make_workflow_run_json(status="running"),
282
+ )
283
+ mock_http = AsyncMock()
284
+ mock_http.post = AsyncMock(return_value=mock_response)
285
+ client._client = mock_http
286
+
287
+ result = await client.advance_phase("wr-1", output_context={"files": ["a.py"]})
288
+ assert result is not None
289
+ assert result.status == "running"
290
+
291
+ @pytest.mark.asyncio
292
+ async def test_advance_failure(self):
293
+ client = WorkflowRunsClient(api_key="key")
294
+ mock_response = httpx.Response(500, text="error")
295
+ mock_http = AsyncMock()
296
+ mock_http.post = AsyncMock(return_value=mock_response)
297
+ client._client = mock_http
298
+
299
+ result = await client.advance_phase("wr-1")
300
+ assert result is None
301
+
302
+
303
+ class TestFailPhase:
304
+ """Tests for fail_phase."""
305
+
306
+ @pytest.mark.asyncio
307
+ async def test_fail_phase_success(self):
308
+ client = WorkflowRunsClient(api_key="key")
309
+ mock_response = httpx.Response(
310
+ 200,
311
+ json=_make_workflow_run_json(status="running"),
312
+ )
313
+ mock_http = AsyncMock()
314
+ mock_http.post = AsyncMock(return_value=mock_response)
315
+ client._client = mock_http
316
+
317
+ result = await client.fail_phase("wr-1", error_message="compilation failed")
318
+ assert result is not None
319
+
320
+ @pytest.mark.asyncio
321
+ async def test_fail_phase_network_error(self):
322
+ client = WorkflowRunsClient(api_key="key")
323
+ mock_http = AsyncMock()
324
+ mock_http.post = AsyncMock(side_effect=httpx.RequestError("timeout"))
325
+ client._client = mock_http
326
+
327
+ result = await client.fail_phase("wr-1", error_message="oops")
328
+ assert result is None
329
+
330
+
331
+ class TestCancelWorkflowRun:
332
+ """Tests for cancel_workflow_run."""
333
+
334
+ @pytest.mark.asyncio
335
+ async def test_cancel_success(self):
336
+ client = WorkflowRunsClient(api_key="key")
337
+ mock_response = httpx.Response(
338
+ 200,
339
+ json=_make_workflow_run_json(status="cancelled"),
340
+ )
341
+ mock_http = AsyncMock()
342
+ mock_http.delete = AsyncMock(return_value=mock_response)
343
+ client._client = mock_http
344
+
345
+ result = await client.cancel_workflow_run("wr-1")
346
+ assert result is not None
347
+ assert result.status == "cancelled"
348
+
349
+ @pytest.mark.asyncio
350
+ async def test_cancel_failure(self):
351
+ client = WorkflowRunsClient(api_key="key")
352
+ mock_response = httpx.Response(404, text="Not found")
353
+ mock_http = AsyncMock()
354
+ mock_http.delete = AsyncMock(return_value=mock_response)
355
+ client._client = mock_http
356
+
357
+ result = await client.cancel_workflow_run("wr-nonexistent")
358
+ assert result is None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes