Skip to content

Commit ec9f37e

Browse files
Fix Phase 1 tests and JSON output integrity
1 parent edba8b9 commit ec9f37e

9 files changed

Lines changed: 95 additions & 14 deletions

File tree

mythic_vibe_cli/ai/providers/model_catalog.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -668,7 +668,7 @@ def list_models(
668668
# (for tests) and returns a CatalogFreshness.
669669
# ---------------------------------------------------------------------------
670670

671-
from datetime import date as _date_type, datetime as _datetime # noqa: E402
671+
from datetime import UTC as _UTC, date as _date_type, datetime as _datetime # noqa: E402
672672

673673
STATIC_LAST_UPDATED = _STATIC_LAST_UPDATED
674674

@@ -721,7 +721,7 @@ def evaluate_catalog_freshness(
721721
passing.
722722
"""
723723
if today is None:
724-
today = _datetime.utcnow().date()
724+
today = _datetime.now(_UTC).date()
725725

726726
try:
727727
parsed = _datetime.strptime(last_updated, "%Y-%m-%d").date()

mythic_vibe_cli/patch/manager.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,29 @@ def __init__(self, project_root: str | Path | None = None) -> None:
5959
self.project_root = Path(project_root).resolve()
6060

6161
self.state_file = self.project_root / ".mythic" / "active_patch.json"
62+
self._active_loaded = False
63+
self._active: PatchProposal | None = None
6264

6365
def _read_active(self) -> PatchProposal | None:
66+
if self._active_loaded:
67+
return self._active
6468
if not self.state_file.exists():
69+
self._active_loaded = True
70+
self._active = None
6571
return None
6672
try:
6773
data = json.loads(self.state_file.read_text(encoding="utf-8"))
68-
return PatchProposal.from_dict(data)
74+
self._active = PatchProposal.from_dict(data)
75+
self._active_loaded = True
76+
return self._active
6977
except Exception:
78+
self._active_loaded = True
79+
self._active = None
7080
return None
7181

7282
def _write_active(self, proposal: PatchProposal | None) -> None:
83+
self._active = proposal
84+
self._active_loaded = True
7385
self.state_file.parent.mkdir(parents=True, exist_ok=True)
7486
if proposal is None:
7587
if self.state_file.exists():

mythic_vibe_cli/robustness/simulate.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
from pathlib import Path
3737
from typing import Any, Callable
3838

39+
from ..runtime.output_guard import suspend_stdout_guard
40+
3941

4042
@dataclass(frozen=True)
4143
class SimulationOutcome:
@@ -101,7 +103,7 @@ def _run_handler(
101103
"""Invoke a CLI handler with stdout/stderr captured."""
102104
out = io.StringIO()
103105
err = io.StringIO()
104-
with redirect_stdout(out), redirect_stderr(err):
106+
with suspend_stdout_guard(), redirect_stdout(out), redirect_stderr(err):
105107
exit_code = handler(namespace)
106108
return exit_code, out.getvalue(), err.getvalue()
107109

mythic_vibe_cli/runtime/output_guard.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,24 @@ def flush_raw_stdout() -> None:
120120
target.flush()
121121

122122

123+
@contextmanager
124+
def suspend_stdout_guard() -> Iterator[None]:
125+
"""Temporarily restore normal stdout while preserving guard state.
126+
127+
This is for in-process command nesting where the outer command owns a
128+
protocol stream, but must capture a child handler's stdout instead of
129+
letting :func:`write_raw_stdout` bypass ``redirect_stdout``.
130+
"""
131+
if _state is None:
132+
yield
133+
return
134+
restore_stdout()
135+
try:
136+
yield
137+
finally:
138+
take_over_stdout()
139+
140+
123141
@contextmanager
124142
def json_output_guard(active: bool) -> Iterator[None]:
125143
"""Optionally activate the stdout guard for the duration of a ``with`` block.

mythic_vibe_cli/runtime/slash_commands.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@ def to_dict(self) -> dict[str, object]:
9292
BuiltinSlashCommand(name="help", description="List available slash commands and their sources"),
9393
BuiltinSlashCommand(name="model", description="Show, list, or set the active shell model/provider"),
9494
BuiltinSlashCommand(name="reload", description="Reload plugins, skills, prompts, and method cache"),
95-
BuiltinSlashCommand(name="tui", description="Launch the interactive Terminal User Interface (TUI)"),
9695
BuiltinSlashCommand(name="quit", description="Exit the interactive session"),
9796

9897
# --- Project lifecycle ---
@@ -210,6 +209,9 @@ def to_dict(self) -> dict[str, object]:
210209
# --- Docker scaffolding (PH-12 slice 12.2) ---
211210
BuiltinSlashCommand(name="docker", description="Docker scaffolding (Dockerfile + .dockerignore + docker-compose.yml)"),
212211

212+
# --- Patch Proposal System (PH-08) ---
213+
BuiltinSlashCommand(name="patch", description="Manage patch proposals: propose a safe file edit for explicit review"),
214+
213215
# --- Release helper (PH-12 slice 12.3) ---
214216
BuiltinSlashCommand(name="release", description="Semver-aware release helper — version bump + CHANGELOG stub + optional local tag (never pushes)"),
215217

@@ -228,11 +230,4 @@ def to_dict(self) -> dict[str, object]:
228230
# --- Multi-surface access (PH-17) ---
229231
BuiltinSlashCommand(name="surface", description="Web terminal / SSH-readiness check / chat bridge — multi-surface access"),
230232

231-
# --- Patch Proposal System (PH-08) ---
232-
BuiltinSlashCommand(name="diff", description="View the currently proposed patch/diff"),
233-
BuiltinSlashCommand(name="apply", description="Approve and apply the currently proposed patch"),
234-
BuiltinSlashCommand(name="reject", description="Reject the currently proposed patch"),
235-
236-
# --- Test Runner System (PH-09) ---
237-
BuiltinSlashCommand(name="test", description="Run the project test suite and feed failures into the model"),
238233
)

mythic_vibe_cli/tui/app.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -871,8 +871,7 @@ def on_mount(self) -> None:
871871
if self.narrow_mode:
872872
self.sub_title = f"{self.SUB_TITLE} · narrow"
873873

874-
from .cockpit import CockpitScreen
875-
self.push_screen(CockpitScreen(self.root))
874+
self.push_screen(StatusScreen(self.root))
876875

877876
def action_cycle_theme(self) -> None:
878877
"""Advance to the next entry in :data:`THEME_CYCLE`. Bound to ``t``

tests/test_cli_kernel.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ def test_command_registry_preserves_current_commands_and_aliases(self) -> None:
122122
"ci",
123123
# PH-12 slice 12.2 — Docker scaffold
124124
"docker",
125+
"patch",
125126
# PH-12 slice 12.3 — Release helper
126127
"release",
127128
# PH-12 slice 12.4 — Rollback summariser
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Regression tests for machine-readable CLI stdout contracts."""
2+
3+
from __future__ import annotations
4+
5+
import io
6+
import json
7+
import unittest
8+
from contextlib import redirect_stderr, redirect_stdout
9+
from typing import Any
10+
11+
from mythic_vibe_cli import app
12+
from mythic_vibe_cli.exit_codes import SUCCESS
13+
14+
15+
def _parse_single_json_document(text: str) -> dict[str, Any]:
16+
decoder = json.JSONDecoder()
17+
payload, end = decoder.raw_decode(text)
18+
trailing = text[end:].strip()
19+
if trailing:
20+
raise AssertionError(f"stdout contains extra data after JSON document: {trailing!r}")
21+
if not isinstance(payload, dict):
22+
raise AssertionError(f"stdout JSON must be an object, got {type(payload).__name__}")
23+
return payload
24+
25+
26+
class JsonOutputIntegrityTests(unittest.TestCase):
27+
def test_simulate_json_emits_one_document(self) -> None:
28+
stdout = io.StringIO()
29+
stderr = io.StringIO()
30+
31+
with redirect_stdout(stdout), redirect_stderr(stderr):
32+
code = app.main(["simulate", "--json"])
33+
34+
self.assertEqual(code, SUCCESS)
35+
self.assertEqual(stderr.getvalue(), "")
36+
payload = _parse_single_json_document(stdout.getvalue())
37+
self.assertEqual(payload["command"], "simulate")
38+
self.assertTrue(payload["report"]["ok"])
39+
self.assertEqual(payload["report"]["total"], 4)

tests/test_output_guard.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
is_stdout_taken_over,
2020
json_output_guard,
2121
restore_stdout,
22+
suspend_stdout_guard,
2223
take_over_stdout,
2324
write_raw_stdout,
2425
)
@@ -151,6 +152,20 @@ def test_json_output_guard_restores_on_exception(self) -> None:
151152
self.assertFalse(is_stdout_taken_over())
152153
self.assertIs(sys.stdout, self.fake_stdout)
153154

155+
def test_suspend_stdout_guard_captures_raw_writes_then_restores(self) -> None:
156+
take_over_stdout()
157+
nested = io.StringIO()
158+
159+
with suspend_stdout_guard():
160+
self.assertFalse(is_stdout_taken_over())
161+
sys.stdout = nested
162+
write_raw_stdout("nested payload\n")
163+
sys.stdout = self.fake_stdout
164+
165+
self.assertTrue(is_stdout_taken_over())
166+
self.assertEqual(nested.getvalue(), "nested payload\n")
167+
self.assertEqual(self.fake_stdout.getvalue(), "")
168+
154169

155170
# PH-23.8 — coverage push for output_guard's _ProxyStream
156171
# property getters + isatty error branches. The existing tests

0 commit comments

Comments
 (0)