draft-board 0.1.0-beta.0

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 (250) hide show
  1. package/app/backend/.env.example +9 -0
  2. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
  3. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
  4. package/app/backend/CURL_EXAMPLES.md +335 -0
  5. package/app/backend/ENV_SETUP.md +65 -0
  6. package/app/backend/alembic/env.py +71 -0
  7. package/app/backend/alembic/script.py.mako +28 -0
  8. package/app/backend/alembic/versions/001_initial_schema.py +104 -0
  9. package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
  10. package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
  11. package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
  12. package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
  13. package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
  14. package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
  15. package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
  16. package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
  17. package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
  18. package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
  19. package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
  20. package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
  21. package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
  22. package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
  23. package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
  24. package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
  25. package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
  26. package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
  27. package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
  28. package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
  29. package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
  30. package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
  31. package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
  32. package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
  33. package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
  34. package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
  35. package/app/backend/alembic/versions/add_job_variant.py +34 -0
  36. package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
  37. package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
  38. package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
  39. package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
  40. package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
  41. package/app/backend/alembic.ini +89 -0
  42. package/app/backend/app/__init__.py +3 -0
  43. package/app/backend/app/data_dir.py +85 -0
  44. package/app/backend/app/database.py +70 -0
  45. package/app/backend/app/database_sync.py +64 -0
  46. package/app/backend/app/dependencies/__init__.py +5 -0
  47. package/app/backend/app/dependencies/auth.py +80 -0
  48. package/app/backend/app/dependencies.py +43 -0
  49. package/app/backend/app/exceptions.py +178 -0
  50. package/app/backend/app/executors/__init__.py +1 -0
  51. package/app/backend/app/executors/adapters/__init__.py +1 -0
  52. package/app/backend/app/executors/adapters/aider.py +152 -0
  53. package/app/backend/app/executors/adapters/amazon_q.py +103 -0
  54. package/app/backend/app/executors/adapters/amp.py +123 -0
  55. package/app/backend/app/executors/adapters/claude.py +177 -0
  56. package/app/backend/app/executors/adapters/cline.py +127 -0
  57. package/app/backend/app/executors/adapters/codex.py +167 -0
  58. package/app/backend/app/executors/adapters/copilot.py +202 -0
  59. package/app/backend/app/executors/adapters/cursor.py +87 -0
  60. package/app/backend/app/executors/adapters/droid.py +123 -0
  61. package/app/backend/app/executors/adapters/gemini.py +132 -0
  62. package/app/backend/app/executors/adapters/goose.py +131 -0
  63. package/app/backend/app/executors/adapters/opencode.py +123 -0
  64. package/app/backend/app/executors/adapters/qwen.py +123 -0
  65. package/app/backend/app/executors/plugins/__init__.py +1 -0
  66. package/app/backend/app/executors/registry.py +202 -0
  67. package/app/backend/app/executors/spec.py +226 -0
  68. package/app/backend/app/main.py +486 -0
  69. package/app/backend/app/middleware/__init__.py +13 -0
  70. package/app/backend/app/middleware/idempotency.py +426 -0
  71. package/app/backend/app/middleware/rate_limit.py +312 -0
  72. package/app/backend/app/middleware/security_headers.py +43 -0
  73. package/app/backend/app/middleware/timeout.py +37 -0
  74. package/app/backend/app/models/__init__.py +56 -0
  75. package/app/backend/app/models/agent_conversation_history.py +56 -0
  76. package/app/backend/app/models/agent_session.py +127 -0
  77. package/app/backend/app/models/analysis_cache.py +49 -0
  78. package/app/backend/app/models/base.py +9 -0
  79. package/app/backend/app/models/board.py +79 -0
  80. package/app/backend/app/models/board_repo.py +68 -0
  81. package/app/backend/app/models/cost_budget.py +42 -0
  82. package/app/backend/app/models/enums.py +40 -0
  83. package/app/backend/app/models/evidence.py +132 -0
  84. package/app/backend/app/models/goal.py +102 -0
  85. package/app/backend/app/models/idempotency_entry.py +30 -0
  86. package/app/backend/app/models/job.py +163 -0
  87. package/app/backend/app/models/job_queue.py +39 -0
  88. package/app/backend/app/models/kv_store.py +28 -0
  89. package/app/backend/app/models/merge_checklist.py +87 -0
  90. package/app/backend/app/models/normalized_log.py +100 -0
  91. package/app/backend/app/models/planner_lock.py +43 -0
  92. package/app/backend/app/models/rate_limit_entry.py +25 -0
  93. package/app/backend/app/models/repo.py +66 -0
  94. package/app/backend/app/models/review_comment.py +91 -0
  95. package/app/backend/app/models/review_summary.py +69 -0
  96. package/app/backend/app/models/revision.py +130 -0
  97. package/app/backend/app/models/ticket.py +223 -0
  98. package/app/backend/app/models/ticket_event.py +83 -0
  99. package/app/backend/app/models/user.py +47 -0
  100. package/app/backend/app/models/workspace.py +71 -0
  101. package/app/backend/app/redis_client.py +119 -0
  102. package/app/backend/app/routers/__init__.py +29 -0
  103. package/app/backend/app/routers/agents.py +296 -0
  104. package/app/backend/app/routers/auth.py +94 -0
  105. package/app/backend/app/routers/board.py +885 -0
  106. package/app/backend/app/routers/dashboard.py +351 -0
  107. package/app/backend/app/routers/debug.py +528 -0
  108. package/app/backend/app/routers/evidence.py +96 -0
  109. package/app/backend/app/routers/executors.py +324 -0
  110. package/app/backend/app/routers/goals.py +574 -0
  111. package/app/backend/app/routers/jobs.py +448 -0
  112. package/app/backend/app/routers/maintenance.py +172 -0
  113. package/app/backend/app/routers/merge.py +360 -0
  114. package/app/backend/app/routers/planner.py +537 -0
  115. package/app/backend/app/routers/pull_requests.py +382 -0
  116. package/app/backend/app/routers/repos.py +263 -0
  117. package/app/backend/app/routers/revisions.py +939 -0
  118. package/app/backend/app/routers/settings.py +267 -0
  119. package/app/backend/app/routers/tickets.py +2003 -0
  120. package/app/backend/app/routers/webhooks.py +143 -0
  121. package/app/backend/app/routers/websocket.py +249 -0
  122. package/app/backend/app/schemas/__init__.py +109 -0
  123. package/app/backend/app/schemas/board.py +87 -0
  124. package/app/backend/app/schemas/common.py +33 -0
  125. package/app/backend/app/schemas/evidence.py +87 -0
  126. package/app/backend/app/schemas/goal.py +90 -0
  127. package/app/backend/app/schemas/job.py +97 -0
  128. package/app/backend/app/schemas/merge.py +139 -0
  129. package/app/backend/app/schemas/planner.py +500 -0
  130. package/app/backend/app/schemas/repo.py +187 -0
  131. package/app/backend/app/schemas/review.py +137 -0
  132. package/app/backend/app/schemas/revision.py +114 -0
  133. package/app/backend/app/schemas/ticket.py +238 -0
  134. package/app/backend/app/schemas/ticket_event.py +72 -0
  135. package/app/backend/app/schemas/workspace.py +19 -0
  136. package/app/backend/app/services/__init__.py +31 -0
  137. package/app/backend/app/services/agent_memory_service.py +223 -0
  138. package/app/backend/app/services/agent_registry.py +346 -0
  139. package/app/backend/app/services/agent_session_manager.py +318 -0
  140. package/app/backend/app/services/agent_session_service.py +219 -0
  141. package/app/backend/app/services/agent_tools.py +379 -0
  142. package/app/backend/app/services/auth_service.py +98 -0
  143. package/app/backend/app/services/autonomy_service.py +380 -0
  144. package/app/backend/app/services/board_repo_service.py +201 -0
  145. package/app/backend/app/services/board_service.py +326 -0
  146. package/app/backend/app/services/cleanup_service.py +1085 -0
  147. package/app/backend/app/services/config_service.py +908 -0
  148. package/app/backend/app/services/context_gatherer.py +557 -0
  149. package/app/backend/app/services/cost_tracking_service.py +293 -0
  150. package/app/backend/app/services/cursor_log_normalizer.py +536 -0
  151. package/app/backend/app/services/delivery_pipeline.py +440 -0
  152. package/app/backend/app/services/executor_service.py +634 -0
  153. package/app/backend/app/services/git_host/__init__.py +11 -0
  154. package/app/backend/app/services/git_host/factory.py +87 -0
  155. package/app/backend/app/services/git_host/github.py +270 -0
  156. package/app/backend/app/services/git_host/gitlab.py +194 -0
  157. package/app/backend/app/services/git_host/protocol.py +75 -0
  158. package/app/backend/app/services/git_merge_simple.py +346 -0
  159. package/app/backend/app/services/git_ops.py +384 -0
  160. package/app/backend/app/services/github_service.py +233 -0
  161. package/app/backend/app/services/goal_service.py +113 -0
  162. package/app/backend/app/services/job_service.py +423 -0
  163. package/app/backend/app/services/job_watchdog_service.py +424 -0
  164. package/app/backend/app/services/langchain_adapter.py +122 -0
  165. package/app/backend/app/services/llm_provider_clients.py +351 -0
  166. package/app/backend/app/services/llm_service.py +285 -0
  167. package/app/backend/app/services/log_normalizer.py +342 -0
  168. package/app/backend/app/services/log_stream_service.py +276 -0
  169. package/app/backend/app/services/merge_checklist_service.py +264 -0
  170. package/app/backend/app/services/merge_service.py +784 -0
  171. package/app/backend/app/services/orchestrator_log.py +84 -0
  172. package/app/backend/app/services/planner_service.py +1662 -0
  173. package/app/backend/app/services/planner_tick_sync.py +1040 -0
  174. package/app/backend/app/services/queued_message_service.py +156 -0
  175. package/app/backend/app/services/reliability_wrapper.py +389 -0
  176. package/app/backend/app/services/repo_discovery_service.py +318 -0
  177. package/app/backend/app/services/review_service.py +334 -0
  178. package/app/backend/app/services/revision_service.py +389 -0
  179. package/app/backend/app/services/safe_autopilot.py +510 -0
  180. package/app/backend/app/services/sqlite_worker.py +372 -0
  181. package/app/backend/app/services/task_dispatch.py +135 -0
  182. package/app/backend/app/services/ticket_generation_service.py +1781 -0
  183. package/app/backend/app/services/ticket_service.py +486 -0
  184. package/app/backend/app/services/udar_planner_service.py +1007 -0
  185. package/app/backend/app/services/webhook_service.py +126 -0
  186. package/app/backend/app/services/workspace_service.py +465 -0
  187. package/app/backend/app/services/worktree_file_service.py +92 -0
  188. package/app/backend/app/services/worktree_validator.py +213 -0
  189. package/app/backend/app/sqlite_kv.py +278 -0
  190. package/app/backend/app/state_machine.py +128 -0
  191. package/app/backend/app/templates/__init__.py +5 -0
  192. package/app/backend/app/templates/registry.py +243 -0
  193. package/app/backend/app/utils/__init__.py +5 -0
  194. package/app/backend/app/utils/artifact_reader.py +87 -0
  195. package/app/backend/app/utils/circuit_breaker.py +229 -0
  196. package/app/backend/app/utils/db_retry.py +136 -0
  197. package/app/backend/app/utils/ignored_fields.py +123 -0
  198. package/app/backend/app/utils/validators.py +54 -0
  199. package/app/backend/app/websocket/__init__.py +5 -0
  200. package/app/backend/app/websocket/manager.py +179 -0
  201. package/app/backend/app/websocket/state_tracker.py +113 -0
  202. package/app/backend/app/worker.py +3190 -0
  203. package/app/backend/calculator_tickets.json +40 -0
  204. package/app/backend/canary_tests.sh +591 -0
  205. package/app/backend/celerybeat-schedule +0 -0
  206. package/app/backend/celerybeat-schedule-shm +0 -0
  207. package/app/backend/celerybeat-schedule-wal +0 -0
  208. package/app/backend/logs/.gitkeep +3 -0
  209. package/app/backend/multiplication_division_implementation_tickets.json +55 -0
  210. package/app/backend/multiplication_division_tickets.json +42 -0
  211. package/app/backend/pyproject.toml +45 -0
  212. package/app/backend/requirements-dev.txt +8 -0
  213. package/app/backend/requirements.txt +20 -0
  214. package/app/backend/run.sh +30 -0
  215. package/app/backend/run_with_logs.sh +10 -0
  216. package/app/backend/scientific_calculator_tickets.json +40 -0
  217. package/app/backend/scripts/extract_openapi.py +21 -0
  218. package/app/backend/scripts/seed_demo.py +187 -0
  219. package/app/backend/setup_demo_review.py +302 -0
  220. package/app/backend/test_actual_parse.py +41 -0
  221. package/app/backend/test_agent_streaming.py +61 -0
  222. package/app/backend/test_parse.py +51 -0
  223. package/app/backend/test_streaming.py +51 -0
  224. package/app/backend/test_subprocess_streaming.py +50 -0
  225. package/app/backend/tests/__init__.py +1 -0
  226. package/app/backend/tests/conftest.py +46 -0
  227. package/app/backend/tests/test_auth.py +341 -0
  228. package/app/backend/tests/test_autonomy_service.py +391 -0
  229. package/app/backend/tests/test_cleanup_service_safety.py +417 -0
  230. package/app/backend/tests/test_middleware.py +279 -0
  231. package/app/backend/tests/test_planner_providers.py +290 -0
  232. package/app/backend/tests/test_planner_unblock.py +183 -0
  233. package/app/backend/tests/test_revision_invariants.py +618 -0
  234. package/app/backend/tests/test_sqlite_kv.py +290 -0
  235. package/app/backend/tests/test_sqlite_worker.py +353 -0
  236. package/app/backend/tests/test_task_dispatch.py +100 -0
  237. package/app/backend/tests/test_ticket_validation.py +304 -0
  238. package/app/backend/tests/test_udar_agent.py +693 -0
  239. package/app/backend/tests/test_webhook_service.py +184 -0
  240. package/app/backend/tickets_output.json +59 -0
  241. package/app/backend/user_management_tickets.json +50 -0
  242. package/app/backend/uvicorn.log +0 -0
  243. package/app/draft.yaml +313 -0
  244. package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
  245. package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
  246. package/app/frontend/dist/index.html +14 -0
  247. package/app/frontend/dist/vite.svg +1 -0
  248. package/app/frontend/package.json +101 -0
  249. package/bin/cli.js +527 -0
  250. package/package.json +37 -0
@@ -0,0 +1,693 @@
1
+ """Tests for UDAR agent (Phase 1: Foundation).
2
+
3
+ Tests basic functionality of tools and LangGraph workflow compilation.
4
+ """
5
+
6
+ from pathlib import Path
7
+ from unittest.mock import AsyncMock, patch
8
+
9
+ import pytest
10
+
11
+ from app.models.goal import Goal
12
+ from app.models.ticket import Ticket
13
+ from app.services.agent_tools import analyze_codebase, get_goal_context, search_tickets
14
+ from app.services.langchain_adapter import LangChainLLMAdapter
15
+ from app.services.udar_planner_service import UDARPlannerService
16
+
17
+
18
+ @pytest.mark.asyncio
19
+ async def test_analyze_codebase_tool():
20
+ """Test analyze_codebase tool returns valid JSON."""
21
+ # Use current repo for testing
22
+ repo_path = str(Path(__file__).parent.parent.parent)
23
+
24
+ result = await analyze_codebase.ainvoke({"repo_root": repo_path})
25
+
26
+ # Should return valid JSON
27
+ import json
28
+
29
+ parsed = json.loads(result)
30
+
31
+ assert "project_type" in parsed
32
+ assert "file_count" in parsed
33
+ assert parsed["file_count"] > 0
34
+
35
+
36
+ @pytest.mark.asyncio
37
+ async def test_search_tickets_tool(db):
38
+ """Test search_tickets tool queries database correctly."""
39
+ # Create test goal and tickets
40
+ goal = Goal(
41
+ id="test-goal-1",
42
+ title="Test Goal",
43
+ description="Test",
44
+ )
45
+ db.add(goal)
46
+
47
+ ticket1 = Ticket(
48
+ id="test-ticket-1",
49
+ goal_id=goal.id,
50
+ title="Implement authentication",
51
+ description="Add OAuth2",
52
+ state="planned",
53
+ priority=90,
54
+ )
55
+ ticket2 = Ticket(
56
+ id="test-ticket-2",
57
+ goal_id=goal.id,
58
+ title="Add tests",
59
+ description="Test coverage",
60
+ state="done",
61
+ priority=50,
62
+ )
63
+ db.add(ticket1)
64
+ db.add(ticket2)
65
+ await db.commit()
66
+
67
+ # Test search
68
+ result = await search_tickets.ainvoke(
69
+ {
70
+ "db": db,
71
+ "goal_id": goal.id,
72
+ "query": "auth",
73
+ }
74
+ )
75
+
76
+ # Should return valid JSON with matching tickets
77
+ import json
78
+
79
+ parsed = json.loads(result)
80
+
81
+ assert parsed["total"] == 1
82
+ assert parsed["tickets"][0]["title"] == "Implement authentication"
83
+
84
+
85
+ @pytest.mark.asyncio
86
+ async def test_get_goal_context_tool(db):
87
+ """Test get_goal_context tool retrieves goal details."""
88
+ # Create test goal
89
+ goal = Goal(
90
+ id="test-goal-2",
91
+ title="Add feature X",
92
+ description="Detailed description",
93
+ )
94
+ db.add(goal)
95
+ await db.commit()
96
+
97
+ # Test retrieval
98
+ result = await get_goal_context.ainvoke(
99
+ {
100
+ "db": db,
101
+ "goal_id": goal.id,
102
+ }
103
+ )
104
+
105
+ # Should return valid JSON with goal details
106
+ import json
107
+
108
+ parsed = json.loads(result)
109
+
110
+ assert parsed["id"] == goal.id
111
+ assert parsed["title"] == "Add feature X"
112
+ assert "ticket_counts" in parsed
113
+
114
+
115
+ def test_langchain_adapter_properties():
116
+ """Test LangChainLLMAdapter class exists and can be imported."""
117
+ # Just test that the adapter class exists
118
+ # Full integration testing requires actual LLMService instance
119
+ assert LangChainLLMAdapter is not None
120
+ assert hasattr(LangChainLLMAdapter, "_llm_type")
121
+
122
+
123
+ @pytest.mark.asyncio
124
+ async def test_udar_workflow_compiles():
125
+ """Test UDAR LangGraph workflow compiles without errors."""
126
+ from unittest.mock import MagicMock
127
+
128
+ # Mock database
129
+ mock_db = MagicMock()
130
+
131
+ # Create service (this will compile the workflow)
132
+ service = UDARPlannerService(db=mock_db)
133
+
134
+ # Verify workflow compiled
135
+ assert service.agent is not None
136
+
137
+ # Verify all nodes exist in graph
138
+ # Note: This is a basic test - full workflow testing requires mocking LLM
139
+
140
+
141
+ @pytest.mark.asyncio
142
+ async def test_udar_state_initialization():
143
+ """Test UDAR state can be initialized with correct types."""
144
+ from app.services.udar_planner_service import UDARState
145
+
146
+ # Create state
147
+ state: UDARState = {
148
+ "goal_id": "test-goal",
149
+ "goal_title": "Test Goal",
150
+ "goal_description": "Test description",
151
+ "repo_root": "/path/to/repo",
152
+ "trigger": "initial_generation",
153
+ "codebase_summary": None,
154
+ "existing_tickets": [],
155
+ "existing_ticket_count": 0,
156
+ "project_type": None,
157
+ "proposed_tickets": [],
158
+ "reasoning": "",
159
+ "should_generate_new": False,
160
+ "llm_calls_made": 0,
161
+ "validated_tickets": [],
162
+ "validation_results": [],
163
+ "final_tickets": [],
164
+ "review_summary": "",
165
+ "phase": "init",
166
+ "iteration": 0,
167
+ "errors": [],
168
+ "total_input_tokens": 0,
169
+ "total_output_tokens": 0,
170
+ }
171
+
172
+ # Verify required keys exist
173
+ assert "goal_id" in state
174
+ assert "llm_calls_made" in state
175
+ assert state["llm_calls_made"] == 0
176
+
177
+
178
+ @pytest.mark.asyncio
179
+ async def test_analyze_ticket_changes_tool(db):
180
+ """Test analyze_ticket_changes tool parses diffs correctly."""
181
+ import json
182
+
183
+ from app.models.revision import Revision
184
+ from app.services.agent_tools import analyze_ticket_changes
185
+
186
+ # Create test ticket with revision
187
+ goal = Goal(
188
+ id="test-goal-3",
189
+ title="Test Goal",
190
+ description="Test",
191
+ )
192
+ db.add(goal)
193
+
194
+ ticket = Ticket(
195
+ id="test-ticket-3",
196
+ goal_id=goal.id,
197
+ title="Add authentication",
198
+ description="Implement OAuth2",
199
+ state="done",
200
+ priority=90,
201
+ )
202
+ db.add(ticket)
203
+
204
+ # Create a job for the revision (required FK)
205
+ from app.models.job import Job
206
+
207
+ job = Job(
208
+ id="test-job-changes",
209
+ ticket_id=ticket.id,
210
+ kind="execute",
211
+ status="succeeded",
212
+ )
213
+ db.add(job)
214
+
215
+ # Create revision without diff content (no evidence in this test)
216
+ revision = Revision(
217
+ ticket_id=ticket.id,
218
+ job_id=job.id,
219
+ number=1,
220
+ status="approved",
221
+ )
222
+ db.add(revision)
223
+ await db.commit()
224
+
225
+ # Test tool
226
+ result = await analyze_ticket_changes.ainvoke(
227
+ {
228
+ "db": db,
229
+ "ticket_id": ticket.id,
230
+ }
231
+ )
232
+
233
+ # Should return valid JSON
234
+ parsed = json.loads(result)
235
+
236
+ assert parsed["ticket_id"] == ticket.id
237
+ assert parsed["has_revision"] is True
238
+ assert parsed["verification_passed"] is True
239
+
240
+
241
+ @pytest.mark.asyncio
242
+ async def test_udar_config_loads():
243
+ """Test UDAR config loads from YAML correctly."""
244
+ from app.services.config_service import ConfigService
245
+
246
+ config = ConfigService().load_config()
247
+
248
+ assert hasattr(config.planner_config, "udar")
249
+ assert hasattr(config.planner_config.udar, "enabled")
250
+ assert hasattr(config.planner_config.udar, "replan_batch_size")
251
+ assert config.planner_config.udar.replan_batch_size == 5 # Default value
252
+ assert config.planner_config.udar.max_self_correction_iterations == 1 # Default
253
+
254
+
255
+ @pytest.mark.asyncio
256
+ async def test_agent_memory_service(db):
257
+ """Test agent memory service saves and loads checkpoints."""
258
+ from app.services.agent_memory_service import AgentMemoryService
259
+
260
+ memory_service = AgentMemoryService(db)
261
+
262
+ # Create test goal
263
+ goal = Goal(
264
+ id="test-goal-memory",
265
+ title="Test Goal",
266
+ description="Test memory",
267
+ )
268
+ db.add(goal)
269
+ await db.commit()
270
+
271
+ # Save checkpoint
272
+ test_state = {
273
+ "goal_id": goal.id,
274
+ "phase": "review",
275
+ "iteration": 0,
276
+ "proposed_tickets": [{"title": "Test Ticket"}],
277
+ "validated_tickets": [{"title": "Test Ticket"}],
278
+ "reasoning": "Test reasoning for memory checkpoint",
279
+ "llm_calls_made": 1,
280
+ "trigger": "initial_generation",
281
+ }
282
+
283
+ await memory_service.save_checkpoint(
284
+ goal_id=goal.id,
285
+ checkpoint_id="test-checkpoint-1",
286
+ state=test_state,
287
+ )
288
+
289
+ # Load checkpoint
290
+ loaded = await memory_service.load_checkpoint(goal.id)
291
+
292
+ assert loaded is not None
293
+ assert loaded["phase"] == "review"
294
+ assert loaded["llm_calls_made"] == 1
295
+ assert loaded["tickets_proposed"] == 1
296
+
297
+
298
+ @pytest.mark.asyncio
299
+ async def test_agent_memory_cleanup(db):
300
+ """Test agent memory cleanup deletes old checkpoints."""
301
+
302
+ from app.services.agent_memory_service import AgentMemoryService
303
+
304
+ memory_service = AgentMemoryService(db)
305
+
306
+ # Create test goal
307
+ goal = Goal(
308
+ id="test-goal-cleanup",
309
+ title="Test Goal",
310
+ description="Test cleanup",
311
+ )
312
+ db.add(goal)
313
+ await db.commit()
314
+
315
+ # Save checkpoint
316
+ test_state = {
317
+ "goal_id": goal.id,
318
+ "phase": "review",
319
+ "iteration": 0,
320
+ "proposed_tickets": [],
321
+ "validated_tickets": [],
322
+ "reasoning": "Test",
323
+ "llm_calls_made": 0,
324
+ "trigger": "initial_generation",
325
+ }
326
+
327
+ await memory_service.save_checkpoint(
328
+ goal_id=goal.id,
329
+ checkpoint_id="test-checkpoint-old",
330
+ state=test_state,
331
+ )
332
+
333
+ # Cleanup old checkpoints (0 days = everything)
334
+ deleted_count = await memory_service.cleanup_old_checkpoints(days=0)
335
+
336
+ assert deleted_count >= 1
337
+
338
+
339
+ @pytest.mark.asyncio
340
+ async def test_self_correction_conditional_edge():
341
+ """Test self-correction conditional edge logic."""
342
+ from unittest.mock import MagicMock
343
+
344
+ from app.services.udar_planner_service import UDARPlannerService, UDARState
345
+
346
+ # Mock database
347
+ mock_db = MagicMock()
348
+ service = UDARPlannerService(db=mock_db)
349
+
350
+ # Test case 1: No failures, should proceed
351
+ state_no_failures: UDARState = {
352
+ "goal_id": "test",
353
+ "goal_title": "Test",
354
+ "goal_description": "Test",
355
+ "repo_root": ".",
356
+ "trigger": "initial_generation",
357
+ "codebase_summary": None,
358
+ "existing_tickets": [],
359
+ "existing_ticket_count": 0,
360
+ "project_type": None,
361
+ "proposed_tickets": [],
362
+ "reasoning": "",
363
+ "should_generate_new": False,
364
+ "llm_calls_made": 0,
365
+ "validated_tickets": [],
366
+ "validation_results": [
367
+ {"ticket_title": "Test", "is_valid": True, "reason": "Valid"}
368
+ ],
369
+ "final_tickets": [],
370
+ "review_summary": "",
371
+ "phase": "validate",
372
+ "iteration": 0,
373
+ "errors": [],
374
+ "total_input_tokens": 0,
375
+ "total_output_tokens": 0,
376
+ }
377
+
378
+ result = service._should_retry(state_no_failures)
379
+ assert result == "proceed" # No failures, should proceed
380
+
381
+ # Test case 2: Failures but max iterations reached
382
+ state_max_iterations: UDARState = {
383
+ **state_no_failures,
384
+ "validation_results": [
385
+ {"ticket_title": "Test", "is_valid": False, "reason": "Duplicate"}
386
+ ],
387
+ "iteration": 1, # Already at max (default is 1)
388
+ }
389
+
390
+ result = service._should_retry(state_max_iterations)
391
+ assert result == "proceed" # Max iterations reached, should proceed
392
+
393
+ # Test case 3: Failures and under iteration limit
394
+ state_can_retry: UDARState = {
395
+ **state_no_failures,
396
+ "validation_results": [
397
+ {"ticket_title": "Test", "is_valid": False, "reason": "Duplicate"}
398
+ ],
399
+ "iteration": 0, # Under max
400
+ }
401
+
402
+ result = service._should_retry(state_can_retry)
403
+ assert result == "retry" # Can retry
404
+ assert state_can_retry["iteration"] == 1 # Iteration incremented
405
+
406
+
407
+ # Phase 1 verification checklist:
408
+ # [x] Tools can be called independently
409
+ # [x] LangGraph state graph compiles
410
+ # [ ] No disruption to existing ticket generation (requires integration test)
411
+ # [x] Unit tests for tools pass
412
+
413
+ # Phase 3 verification checklist:
414
+ # [x] analyze_ticket_changes tool works
415
+ # [x] UDAR config loads with replanning settings
416
+ # [ ] Replanning batches tickets correctly (requires integration test)
417
+ # [ ] Only calls LLM for significant changes (requires integration test)
418
+
419
+
420
+ # Phase 5: Production Hardening Tests
421
+
422
+
423
+ @pytest.mark.asyncio
424
+ async def test_udar_timeout_fallback(db):
425
+ """Test UDAR falls back to legacy on timeout."""
426
+
427
+ from app.exceptions import LLMTimeoutError
428
+ from app.models.board import Board
429
+ from app.models.goal import Goal
430
+ from app.services.udar_planner_service import UDARPlannerService
431
+
432
+ # Create test board and goal
433
+ board = Board(
434
+ id="test-board",
435
+ name="Test Board",
436
+ repo_root=".",
437
+ )
438
+ db.add(board)
439
+
440
+ goal = Goal(
441
+ id="test-goal-timeout",
442
+ title="Test Goal",
443
+ description="Test timeout handling",
444
+ board_id=board.id,
445
+ )
446
+ db.add(goal)
447
+ await db.commit()
448
+
449
+ service = UDARPlannerService(db)
450
+
451
+ mock_fallback_result = {
452
+ "tickets": [],
453
+ "summary": "Fallback result",
454
+ "llm_calls_made": 1,
455
+ "phases_completed": ["legacy"],
456
+ "errors": ["UDAR fallback: timeout"],
457
+ "used_legacy_fallback": True,
458
+ "fallback_reason": "timeout",
459
+ "cost_tracking": {"input_tokens": 0, "output_tokens": 0},
460
+ }
461
+
462
+ with (
463
+ patch.object(service.agent, "ainvoke", side_effect=TimeoutError()),
464
+ patch.object(
465
+ service,
466
+ "_fallback_to_legacy",
467
+ new_callable=AsyncMock,
468
+ return_value=mock_fallback_result,
469
+ ),
470
+ ):
471
+ # With fallback enabled (default), should return legacy result
472
+ result = await service.generate_from_goal(
473
+ goal.id, fallback_to_legacy=True, timeout_seconds=1
474
+ )
475
+ assert result["used_legacy_fallback"] is True
476
+ assert result["fallback_reason"] == "timeout"
477
+
478
+ with patch.object(service.agent, "ainvoke", side_effect=TimeoutError()):
479
+ # With fallback disabled, should raise exception
480
+ with pytest.raises(LLMTimeoutError):
481
+ await service.generate_from_goal(
482
+ goal.id, fallback_to_legacy=False, timeout_seconds=1
483
+ )
484
+
485
+
486
+ @pytest.mark.asyncio
487
+ async def test_udar_tool_error_fallback(db):
488
+ """Test UDAR falls back to legacy on tool execution error."""
489
+ from app.exceptions import ToolExecutionError
490
+ from app.models.board import Board
491
+ from app.models.goal import Goal
492
+ from app.services.udar_planner_service import UDARPlannerService
493
+
494
+ # Create test board and goal
495
+ board = Board(
496
+ id="test-board-2",
497
+ name="Test Board",
498
+ repo_root=".",
499
+ )
500
+ db.add(board)
501
+
502
+ goal = Goal(
503
+ id="test-goal-tool-error",
504
+ title="Test Goal",
505
+ description="Test tool error handling",
506
+ board_id=board.id,
507
+ )
508
+ db.add(goal)
509
+ await db.commit()
510
+
511
+ service = UDARPlannerService(db)
512
+
513
+ mock_fallback_result = {
514
+ "tickets": [],
515
+ "summary": "Fallback result",
516
+ "llm_calls_made": 1,
517
+ "phases_completed": ["legacy"],
518
+ "errors": ["UDAR fallback: tool_error:analyze_codebase"],
519
+ "used_legacy_fallback": True,
520
+ "fallback_reason": "tool_error:analyze_codebase",
521
+ "cost_tracking": {"input_tokens": 0, "output_tokens": 0},
522
+ }
523
+
524
+ # Mock tool execution failure and fallback
525
+ with (
526
+ patch.object(
527
+ service.agent,
528
+ "ainvoke",
529
+ side_effect=ToolExecutionError(
530
+ "analyze_codebase", "File not found", "understand"
531
+ ),
532
+ ),
533
+ patch.object(
534
+ service,
535
+ "_fallback_to_legacy",
536
+ new_callable=AsyncMock,
537
+ return_value=mock_fallback_result,
538
+ ),
539
+ ):
540
+ result = await service.generate_from_goal(goal.id, fallback_to_legacy=True)
541
+ assert result["used_legacy_fallback"] is True
542
+ assert "tool_error" in result["fallback_reason"]
543
+
544
+
545
+ @pytest.mark.asyncio
546
+ async def test_udar_cost_tracking(db, caplog):
547
+ """Test UDAR cost tracking logs costs without crashing.
548
+
549
+ Note: AgentSession requires ticket_id (FK to tickets), so UDAR
550
+ goal-level sessions are logged but not persisted to the DB.
551
+ """
552
+ import logging
553
+
554
+ from app.models.board import Board
555
+ from app.models.goal import Goal
556
+ from app.services.udar_planner_service import UDARPlannerService
557
+
558
+ # Create test board and goal
559
+ board = Board(
560
+ id="test-board-3",
561
+ name="Test Board",
562
+ repo_root=".",
563
+ )
564
+ db.add(board)
565
+
566
+ goal = Goal(
567
+ id="test-goal-cost",
568
+ title="Test Goal",
569
+ description="Test cost tracking",
570
+ board_id=board.id,
571
+ )
572
+ db.add(goal)
573
+ await db.commit()
574
+
575
+ service = UDARPlannerService(db)
576
+
577
+ # Mock successful state with token counts
578
+ test_state = {
579
+ "goal_id": goal.id,
580
+ "phase": "review",
581
+ "final_tickets": [],
582
+ "review_summary": "Test",
583
+ "llm_calls_made": 1,
584
+ "errors": [],
585
+ "total_input_tokens": 1000,
586
+ "total_output_tokens": 500,
587
+ }
588
+
589
+ # Call cost tracking - should not raise, should log cost info
590
+ with caplog.at_level(logging.INFO):
591
+ await service._track_agent_session(goal.id, test_state)
592
+
593
+ # Verify cost was logged
594
+ assert "1000 input tokens" in caplog.text
595
+ assert "500 output tokens" in caplog.text
596
+ assert goal.id in caplog.text
597
+
598
+
599
+ @pytest.mark.asyncio
600
+ async def test_phase5_config_loads():
601
+ """Test Phase 5 configuration options load correctly."""
602
+ from app.services.config_service import ConfigService
603
+
604
+ config = ConfigService().load_config()
605
+
606
+ # Verify Phase 5 settings exist and have defaults
607
+ assert hasattr(config.planner_config.udar, "fallback_to_legacy")
608
+ assert config.planner_config.udar.fallback_to_legacy is True
609
+
610
+ assert hasattr(config.planner_config.udar, "timeout_seconds")
611
+ assert config.planner_config.udar.timeout_seconds == 120
612
+
613
+ assert hasattr(config.planner_config.udar, "enable_cost_tracking")
614
+ assert config.planner_config.udar.enable_cost_tracking is True
615
+
616
+ assert hasattr(config.planner_config.udar, "max_retries_on_error")
617
+ assert config.planner_config.udar.max_retries_on_error == 0
618
+
619
+
620
+ @pytest.mark.asyncio
621
+ async def test_udar_graceful_degradation(db):
622
+ """Test UDAR degrades gracefully on unexpected errors."""
623
+ from app.exceptions import UDARAgentError
624
+ from app.models.board import Board
625
+ from app.models.goal import Goal
626
+ from app.services.udar_planner_service import UDARPlannerService
627
+
628
+ # Create test board and goal
629
+ board = Board(
630
+ id="test-board-4",
631
+ name="Test Board",
632
+ repo_root=".",
633
+ )
634
+ db.add(board)
635
+
636
+ goal = Goal(
637
+ id="test-goal-degradation",
638
+ title="Test Goal",
639
+ description="Test graceful degradation",
640
+ board_id=board.id,
641
+ )
642
+ db.add(goal)
643
+ await db.commit()
644
+
645
+ service = UDARPlannerService(db)
646
+
647
+ mock_fallback_result = {
648
+ "tickets": [],
649
+ "summary": "Fallback result",
650
+ "llm_calls_made": 1,
651
+ "phases_completed": ["legacy"],
652
+ "errors": ["UDAR fallback: unexpected_error"],
653
+ "used_legacy_fallback": True,
654
+ "fallback_reason": "unexpected_error",
655
+ "cost_tracking": {"input_tokens": 0, "output_tokens": 0},
656
+ }
657
+
658
+ # With fallback enabled, should handle gracefully
659
+ with (
660
+ patch.object(
661
+ service.agent,
662
+ "ainvoke",
663
+ side_effect=RuntimeError("Unexpected error in LangGraph"),
664
+ ),
665
+ patch.object(
666
+ service,
667
+ "_fallback_to_legacy",
668
+ new_callable=AsyncMock,
669
+ return_value=mock_fallback_result,
670
+ ),
671
+ ):
672
+ result = await service.generate_from_goal(goal.id, fallback_to_legacy=True)
673
+ assert result["used_legacy_fallback"] is True
674
+ assert result["fallback_reason"] == "unexpected_error"
675
+
676
+ # With fallback disabled, should raise UDARAgentError
677
+ with patch.object(
678
+ service.agent,
679
+ "ainvoke",
680
+ side_effect=RuntimeError("Unexpected error in LangGraph"),
681
+ ):
682
+ with pytest.raises(UDARAgentError):
683
+ await service.generate_from_goal(goal.id, fallback_to_legacy=False)
684
+
685
+
686
+ # Phase 5 verification checklist:
687
+ # [x] Timeout handling with fallback to legacy
688
+ # [x] Tool execution error handling with fallback
689
+ # [x] Cost tracking in AgentSession
690
+ # [x] Phase 5 configuration loads correctly
691
+ # [x] Graceful degradation on unexpected errors
692
+ # [ ] Rate limiting enforced for UDAR endpoints (requires HTTP test)
693
+ # [ ] Telemetry/monitoring metrics (requires Prometheus integration)