Skip to content
Open
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
9 changes: 9 additions & 0 deletions backend/app/api/v1/generation_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,13 @@ async def get_generation_session_status(
kb_init_in_progress=kb_init_in_progress,
)

# Deploy & E2E loop progress.
workspace_phases_deployment = session_doc.get("workspace_phases_deployment", {})
workspace_phases_deployment_view: Dict[str, Any] = {
ws_id: _ws_phase_view_entry(ws_data or {}, usage=None, models=[])
for ws_id, ws_data in workspace_phases_deployment.items()
}

usage = ModelTokenUsage.from_generation_session_doc(session_doc)
tok_used = usage.total_tokens
response = {
Expand All @@ -671,6 +678,8 @@ async def get_generation_session_status(
"last_spec_summary": session_doc.get("last_spec_summary"),
"workspace_phases": workspace_phases_view,
}
if workspace_phases_deployment_view:
response["workspace_phases_deployment"] = workspace_phases_deployment_view

# 2.5a: include result fields when COMPLETED (Gap 1 fix)
if session_doc.get("status") == GenerationStatus.COMPLETED.value:
Expand Down
90 changes: 90 additions & 0 deletions backend/test/api/test_generation_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,96 @@ def test_status_includes_lean_workspace_phases_with_derived_phase_name(
# Heavy planning_data must not leak into the polled response.
assert "planning_data" not in wp["ws-01-1"]

def test_status_surfaces_deploy_phases_during_deploy_loop(
self, client, mock_generation_session_service
):
"""Once the deploy & E2E loop starts, its per-workspace progress is
surfaced under ``workspace_phases_deployment`` (same lean shape as the
code-gen view) so the TUI can show deploy progress additively (issue #38)."""
generation_id = "est-deploy-1"
doc = {
"generation_id": generation_id,
"status": "running",
"user_email": "u@example.com",
"workspace_ids": ["ws-01-1"],
"parameters": {"workspace_count": 1},
"progress": {},
"error": None,
"workspace_phases": {
"ws-01-1": {"last_completed_phase": 12, "total_phases": 12, "planning_data": {}},
},
"workspace_phases_deployment": {
"ws-01-1": {
"last_completed_phase": 1,
"total_phases": 4,
"planning_data": {
"phases": [
{"number": 1, "name": "Smoke E2E"},
{"number": 2, "name": "Auth E2E"},
{"number": 3, "name": "Checkout E2E"},
{"number": 4, "name": "Regression"},
]
},
},
},
}
mock_generation_session_service.get_generation_session_status = AsyncMock(return_value=doc)
mock_generation_session_service.get_checkpoint = Mock(
return_value=GenerationCheckpoint.GENERATION_DONE
)
mock_generation_session_service.get_current_phase_name = Mock(return_value="Deploy & E2E")

response = client.get(
f"/api/v1/generation-sessions/{generation_id}/status",
headers={"X-API-Key": "test-key"},
)

assert response.status_code == 200
body = response.json()
wpd = body["workspace_phases_deployment"]
# 1 deploy phase done → currently on phase index 1 = "Auth E2E".
assert wpd["ws-01-1"] == {
"last_completed_phase": 1,
"total_phases": 4,
"phase_name": "Auth E2E",
}
# Deploy view carries no usage/models (no per-variant deploy drill-in).
assert "usage" not in wpd["ws-01-1"]
assert "models" not in wpd["ws-01-1"]
assert "planning_data" not in wpd["ws-01-1"]

def test_status_omits_deploy_phases_before_deploy_loop(
self, client, mock_generation_session_service
):
"""No ``workspace_phases_deployment`` key until the deploy loop starts —
keeps the polled payload lean and LOCAL_ONLY runs unaffected."""
generation_id = "est-no-deploy"
doc = {
"generation_id": generation_id,
"status": "running",
"user_email": "u@example.com",
"workspace_ids": ["ws-01-1"],
"parameters": {"workspace_count": 1},
"progress": {},
"error": None,
"workspace_phases": {
"ws-01-1": {"last_completed_phase": 3, "total_phases": 6, "planning_data": {}},
},
}
mock_generation_session_service.get_generation_session_status = AsyncMock(return_value=doc)
mock_generation_session_service.get_checkpoint = Mock(
return_value=GenerationCheckpoint.GENERATION_STARTED
)
mock_generation_session_service.get_current_phase_name = Mock(return_value="Generating")

response = client.get(
f"/api/v1/generation-sessions/{generation_id}/status",
headers={"X-API-Key": "test-key"},
)

assert response.status_code == 200
assert "workspace_phases_deployment" not in response.json()

def test_status_phase_name_reports_kb_init_before_kb_init_done(
self, client, mock_generation_session_service
):
Expand Down
84 changes: 84 additions & 0 deletions mcp_server/tests/test_tui_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,67 @@ def test_unknown_total_is_safe(self):
assert bar.percent == 0
assert bar.phase_label == "Phase 2/?"

def test_no_deploy_map_leaves_deploy_inactive(self):
bar = render.workspace_bars(_running_payload())[0]
assert bar.deploy_active is False
assert bar.active_phase_name == bar.phase_name

def test_deploy_progress_shown_additively(self):
# Code-gen complete (12/12), deploy loop on its first round (1/4).
payload = {
"workspace_phases": {
"ws-01-1": {"last_completed_phase": 12, "total_phases": 12, "phase_name": "Wrap-up"},
},
"workspace_phases_deployment": {
"ws-01-1": {"last_completed_phase": 1, "total_phases": 4, "phase_name": "Smoke E2E"},
},
}
bar = render.workspace_bars(payload)[0]
assert bar.deploy_active is True
assert bar.phase_label == "Phase 12/12 · Deploy 1/4"
# The active stage is deploy, so fraction/name reflect the deploy round.
assert abs(bar.fraction - 1 / 4) < 1e-9
assert bar.percent == 25
assert bar.active_phase_name == "Smoke E2E"

def test_deploy_not_started_for_workspace_stays_codegen(self):
# Deployment map present for another workspace but not this one.
payload = {
"workspace_phases": {
"ws-01-1": {"last_completed_phase": 12, "total_phases": 12, "phase_name": "Wrap-up"},
"ws-01-2": {"last_completed_phase": 12, "total_phases": 12, "phase_name": "Wrap-up"},
},
"workspace_phases_deployment": {
"ws-01-1": {"last_completed_phase": 2, "total_phases": 4, "phase_name": "Auth E2E"},
},
}
bars = {b.workspace_id: b for b in render.workspace_bars(payload)}
assert bars["ws-01-1"].deploy_active is True
assert bars["ws-01-2"].deploy_active is False
assert bars["ws-01-2"].phase_label == "Phase 12/12"

def test_deploy_unknown_total_is_safe(self):
payload = {
"workspace_phases": {"ws-1": {"last_completed_phase": 5, "total_phases": 5}},
"workspace_phases_deployment": {"ws-1": {"last_completed_phase": 1}},
}
bar = render.workspace_bars(payload)[0]
# total_phases absent → deploy not treated as active (nothing to count against).
assert bar.deploy_active is False
assert bar.phase_label == "Phase 5/5"

def test_deploy_zero_total_is_safe(self):
# A zero total is as meaningless as a missing one — both mean "no live
# deploy stage to count against", so they must read identically.
payload = {
"workspace_phases": {"ws-1": {"last_completed_phase": 5, "total_phases": 5}},
"workspace_phases_deployment": {"ws-1": {"last_completed_phase": 0, "total_phases": 0}},
}
bar = render.workspace_bars(payload)[0]
assert bar.deploy_active is False
assert bar.phase_label == "Phase 5/5"
assert bar.active_phase_name == bar.phase_name


class TestProgressBar:
def test_full_and_empty(self):
Expand Down Expand Up @@ -334,6 +395,29 @@ def test_reads_legacy_progress_nesting(self):
stats = render.workspace_stats(payload, "ws-1")
assert stats.percent == 50

def test_drill_in_tracks_active_deploy_stage(self):
# Once deploy starts, the drill-in must reflect the same active stage as
# the dashboard row (issue #38) — not the finished code-gen stage.
payload = {
"workspace_phases": {
"ws-01-1": {"last_completed_phase": 12, "total_phases": 12, "phase_name": "Wrap-up"},
},
"workspace_phases_deployment": {
"ws-01-1": {"last_completed_phase": 1, "total_phases": 4, "phase_name": "Auth E2E"},
},
}
stats = render.workspace_stats(payload, "ws-01-1")
assert stats.deploy_active is True
assert stats.active_phase_name == "Auth E2E"
assert stats.phase_label == "Phase 12/12 · Deploy 1/4"
assert stats.percent == 25

def test_drill_in_without_deploy_stays_codegen(self):
stats = render.workspace_stats(_usage_payload(), "ws-01-1")
assert stats.deploy_active is False
assert stats.active_phase_name == "Auth API"
assert stats.phase_label == "Phase 3/9"


class TestFormatTokens:
def test_none_is_dash(self):
Expand Down
8 changes: 4 additions & 4 deletions mcp_server/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ def _workspaces_panel(payload: dict[str, Any], selected_ws_id: str | None = None
Text(marker, style="yellow"),
Text(bar.workspace_id, style=id_style),
Text(bar.phase_label, style="cyan"),
Text(bar.phase_name[:32], style="dim"),
Text(bar.active_phase_name[:32], style="dim"),
Text(render.progress_bar(bar.fraction), style="green"),
Text(f"{bar.percent}%"),
)
Expand Down Expand Up @@ -282,8 +282,8 @@ def build_workspace_stats(payload: dict[str, Any] | None, workspace_id: str) ->
if stats.models:
grid.add_row("Model(s)", ", ".join(stats.models))
phase = f"{stats.phase_label}".strip()
if stats.phase_name:
phase += f" {stats.phase_name}"
if stats.active_phase_name:
phase += f" {stats.active_phase_name}"
grid.add_row("Phase", phase)
grid.add_row("Progress", f"{render.progress_bar(stats.fraction)} {stats.percent}%")
if stats.has_usage:
Expand Down Expand Up @@ -1860,7 +1860,7 @@ def _plain_status(payload: dict[str, Any] | None, generation_id: str) -> str:
if tokens:
lines.append(f" Usage: {tokens}")
for bar in render.workspace_bars(payload):
lines.append(f" {bar.workspace_id} {bar.phase_label} {bar.percent}% {bar.phase_name}")
lines.append(f" {bar.workspace_id} {bar.phase_label} {bar.percent}% {bar.active_phase_name}")
return "\n".join(lines)


Expand Down
Loading