From 05b3d0266e663b7b4a4fc998d0e8d0d1a223894c Mon Sep 17 00:00:00 2001 From: NetDevAutomate Date: Sun, 27 Sep 2026 14:43:49 +0100 Subject: [PATCH 01/16] test: RED for the header and six doctor rows reported on 2026-09-27 Andy sent a screenshot of the web header and six doctor rows with a question each. Thirteen tests fail for the reason the report gives; one is a guard. - header: the voice-engine badge has no CSS rule for any state the script assigns, and the voice and theme pickers have no visible label while Font and Size do; - project_aliases is reported as an inert key although agent-session-tools' build_project_filter reads it (checked as a class: every direct load_config() read must name a known key); - grok is reported as "No manifest entry" although it reads the repo-root AGENTS.md that the codex/AGENTS.md entry tracks; - the xTiles row calls the wind-down skill "opt-in" and says nothing about where an xtiles MCP server is registered; - export freshness reports only the newest message across every harness, which hid a 22-day Kiro gap, and cannot see kiro-cli's new session store (the no-false-positive case is a guard and passes now); - the Kokoro model row appears for backends that never read those files; - "Obsidian export disabled" gives no reason and no next step. Two signature stubs keep the RED type-checked so it fails on behaviour: installers.xtiles_mcp_harnesses() returns [] and check_export_freshness() accepts and ignores kiro_sessions_dir. --- .../src/studyloop/doctor/exporter.py | 2 + .../studyloop/src/studyloop/installers.py | 5 + .../studyloop/tests/test_doctor_agents.py | 41 ++++++++ .../studyloop/tests/test_doctor_config.py | 24 +++++ .../studyloop/tests/test_doctor_exporter.py | 68 +++++++++++++ .../tests/test_doctor_second_brain.py | 26 +++++ .../studyloop/tests/test_mcp_registration.py | 19 ++++ .../studyloop/tests/test_settings_custom.py | 34 +++++++ .../studyloop/tests/test_voice_backends.py | 23 +++++ .../tests/test_web_header_controls.py | 98 +++++++++++++++++++ 10 files changed, 340 insertions(+) create mode 100644 packages/studyloop/tests/test_web_header_controls.py diff --git a/packages/studyloop/src/studyloop/doctor/exporter.py b/packages/studyloop/src/studyloop/doctor/exporter.py index 7adfc0a03..e239436b5 100644 --- a/packages/studyloop/src/studyloop/doctor/exporter.py +++ b/packages/studyloop/src/studyloop/doctor/exporter.py @@ -205,7 +205,9 @@ def check_export_freshness( *, now: datetime | None = None, max_age_hours: float = EXPORT_FRESHNESS_HOURS, + kiro_sessions_dir: Path | None = None, # stub; RED ) -> CheckResult: + del kiro_sessions_dir db_path = db_path or _db_path() newest = newest_message_at(db_path) if db_path.exists() else None if newest is None: diff --git a/packages/studyloop/src/studyloop/installers.py b/packages/studyloop/src/studyloop/installers.py index 09566bad9..863119775 100644 --- a/packages/studyloop/src/studyloop/installers.py +++ b/packages/studyloop/src/studyloop/installers.py @@ -991,6 +991,11 @@ def unregister_mcp_servers(tools: list[str]) -> dict[str, int]: return changed +def xtiles_mcp_harnesses() -> list[str]: + """Harnesses whose MCP config registers a server named ``xtiles`` (stub; RED).""" + return [] + + def mcp_registration_status(tools: list[str] | None = None) -> dict[str, bool]: """Report registration state without modifying any harness configuration.""" import json diff --git a/packages/studyloop/tests/test_doctor_agents.py b/packages/studyloop/tests/test_doctor_agents.py index 4c964af5f..ecc120496 100644 --- a/packages/studyloop/tests/test_doctor_agents.py +++ b/packages/studyloop/tests/test_doctor_agents.py @@ -309,3 +309,44 @@ def test_checks_every_manifest_key_for_a_tool_not_just_the_first(self, tmp_path: assert "study-plan-architect" in secondary[0].message or "study-plan-architect" in ( secondary[0].name ) + + +class TestSharedDefinitionIsCheckedAgainstItsSource: + """Grok Build has no definition of its own: it reads the repo-root AGENTS.md + that the ``codex/AGENTS.md`` manifest entry tracks (installers._TOOL_LINKS). + The doctor looked for a ``grok/`` key, found none and said "No manifest + entry for grok" -- a row about a file that cannot exist, while the file + Grok actually reads went unchecked (reported 2026-09-27).""" + + @staticmethod + def _manifest(body: bytes) -> dict: + import hashlib + + digest = hashlib.sha256(body).hexdigest()[:16] + return {"version": 1, "agents": {"codex/AGENTS.md": {"hash": digest, "updated": "x"}}} + + @staticmethod + def _rows(tmp_path: Path, installed: bytes, manifest: dict) -> dict: + from studyloop.doctor.agents import check_agent_definitions + + agents_md = tmp_path / "AGENTS.md" + agents_md.write_bytes(installed) + with ( + patch("studyloop.doctor.agents._detect_ai_tools", return_value=["grok"]), + patch("studyloop.doctor.agents._get_agent_install_path", return_value=agents_md), + patch( + "studyloop.doctor.agents._fetch_manifest_with_reason", + return_value=(manifest, ""), + ), + ): + return {row.name: row for row in check_agent_definitions()} + + def test_a_current_shared_file_passes_and_names_its_source(self, tmp_path: Path): + rows = self._rows(tmp_path, b"persona", self._manifest(b"persona")) + assert rows["agent_grok"].status == "pass" + assert "No manifest entry" not in rows["agent_grok"].message + assert "codex/AGENTS.md" in rows["agent_grok"].message + + def test_a_stale_shared_file_warns(self, tmp_path: Path): + rows = self._rows(tmp_path, b"older persona", self._manifest(b"persona")) + assert rows["agent_grok"].status == "warn" diff --git a/packages/studyloop/tests/test_doctor_config.py b/packages/studyloop/tests/test_doctor_config.py index e88e870b8..c33a80e04 100644 --- a/packages/studyloop/tests/test_doctor_config.py +++ b/packages/studyloop/tests/test_doctor_config.py @@ -332,3 +332,27 @@ def test_returns_warn_when_memory_dir_is_not_writable(self, tmp_path: Path): # owned by a different remediation lane (m5); the ttyd-retirement lane (m1) # that needs this check for the orphaned ttyd_port key put it in a module it # owns instead. + + +class TestObsidianExportSaysWhyItIsOff: + """ "Obsidian export disabled" named no reason and no next step. The export in + question is the session-memory export (``obsidian.export_enabled``), which is + independent of ``second_brain``: a learner on ``provider: xtiles`` read the row + as "off because Obsidian is not my second brain" (reported 2026-09-27).""" + + def test_the_row_names_the_switch_and_its_independence(self, tmp_path: Path): + from studyloop.doctor.config import check_obsidian_export + + vault = str(tmp_path / "vault") + obs_cfg = _make_obsidian_config(export_enabled=False, vault_path=vault) + with patch( + "studyloop.doctor.config._load_settings", + return_value=_make_settings(obsidian_base=vault, obsidian=obs_cfg), + ): + (row,) = check_obsidian_export() + + assert row.status == "info" + assert "disabled" in row.message.lower() + assert "second_brain" in row.message + assert "export_enabled: true" in row.fix_hint + assert "AgentMemory" in row.fix_hint diff --git a/packages/studyloop/tests/test_doctor_exporter.py b/packages/studyloop/tests/test_doctor_exporter.py index 420f161ff..f5d570db5 100644 --- a/packages/studyloop/tests/test_doctor_exporter.py +++ b/packages/studyloop/tests/test_doctor_exporter.py @@ -296,3 +296,71 @@ def test_zulu_timestamps_parse(self, tmp_path: Path) -> None: def test_an_empty_database_warns(self, tmp_path: Path) -> None: db = _db(tmp_path / "empty.db", 48, None) assert exporter.check_export_freshness(db).status == "warn" + + +def _db_by_source(path: Path, newest: dict[str, datetime]) -> Path: + conn = sqlite3.connect(path) + conn.execute("CREATE TABLE sessions(id TEXT, source TEXT)") + conn.execute("CREATE TABLE messages(id TEXT, session_id TEXT, timestamp TEXT)") + for n, (source, stamp) in enumerate(newest.items()): + conn.execute("INSERT INTO sessions VALUES (?, ?)", (f"s{n}", source)) + conn.execute("INSERT INTO messages VALUES (?, ?, ?)", (f"m{n}", f"s{n}", stamp.isoformat())) + conn.execute("PRAGMA user_version = 48") + conn.commit() + conn.close() + return path + + +class TestFreshnessNamesTheStaleHarness: + """The row said "newest exported message is 48 h old": the newest across ALL + harnesses. That hid that Kiro, the harness in daily use, had exported nothing + for 22 days, because kiro-cli moved its sessions to ~/.kiro/sessions/cli/, + which the exporter does not read (reported 2026-09-27).""" + + NOW = datetime(2026, 9, 27, 12, 0, tzinfo=UTC) + + def test_a_stale_row_lists_every_harness_newest_first(self, tmp_path: Path) -> None: + db = _db_by_source( + tmp_path / "s.db", + { + "kiro_cli": self.NOW - timedelta(days=22), + "codex": self.NOW - timedelta(hours=48), + "pi": self.NOW - timedelta(days=11), + }, + ) + result = exporter.check_export_freshness( + db, now=self.NOW, kiro_sessions_dir=tmp_path / "absent" + ) + assert result.status == "warn" + assert "codex 2 d, pi 11 d, kiro_cli 22 d" in result.message + + def test_kiro_sessions_the_exporter_cannot_read_warn_even_when_another_is_fresh( + self, tmp_path: Path + ) -> None: + db = _db_by_source( + tmp_path / "s.db", + {"codex": self.NOW - timedelta(hours=1), "kiro_cli": self.NOW - timedelta(days=22)}, + ) + store = tmp_path / "cli" + store.mkdir() + (store / "a.jsonl").write_text("{}\n") + stamp = (self.NOW - timedelta(hours=1)).timestamp() + os.utime(store, (stamp, stamp)) + + result = exporter.check_export_freshness(db, now=self.NOW, kiro_sessions_dir=store) + assert result.status == "warn" + assert str(store) in result.message + assert "does not read" in result.message + assert "kiro_cli 22 d" in result.message + + def test_a_kiro_store_older_than_the_last_kiro_export_says_nothing( + self, tmp_path: Path + ) -> None: + db = _db_by_source(tmp_path / "s.db", {"kiro_cli": self.NOW - timedelta(hours=2)}) + store = tmp_path / "cli" + store.mkdir() + stamp = (self.NOW - timedelta(days=3)).timestamp() + os.utime(store, (stamp, stamp)) + + result = exporter.check_export_freshness(db, now=self.NOW, kiro_sessions_dir=store) + assert result.status == "pass" diff --git a/packages/studyloop/tests/test_doctor_second_brain.py b/packages/studyloop/tests/test_doctor_second_brain.py index 1551a10df..2aa61cbc0 100644 --- a/packages/studyloop/tests/test_doctor_second_brain.py +++ b/packages/studyloop/tests/test_doctor_second_brain.py @@ -124,3 +124,29 @@ def test_registered_only_when_the_section_exists(config, tmp_path, monkeypatch) config({"second_brain": {"provider": "obsidian", "vault_path": str(vault)}}) registered = {fn.__name__ for _category, fn in _get_registry()._checkers} assert "check_second_brain" in registered + + +def test_rows_xtiles_say_what_is_installed_and_where_a_server_is_registered( + config, tmp_path, monkeypatch +) -> None: + """ "no programmatic backend; prompts and an opt-in assistant skill" read as + "the skill still needs installing". ``studyloop install agents`` installs it + for every harness; what decides whether it ever offers is an MCP server named + ``xtiles`` in the session, so the row now says where one is registered + (reported 2026-09-27).""" + from studyloop import installers + + config({"second_brain": {"provider": "xtiles"}}) + hub = tmp_path / "hub" + hub.mkdir() + monkeypatch.setattr(installers, "XTILES_SKILL_HUB", hub) + monkeypatch.setattr(installers, "xtiles_mcp_harnesses", lambda: ["codex"]) + + (row,) = check_second_brain() + assert "opt-in assistant skill" not in row.message + assert "skill is installed" in row.message + assert "codex" in row.message + + monkeypatch.setattr(installers, "xtiles_mcp_harnesses", lambda: []) + (row,) = check_second_brain() + assert "no harness" in row.message.lower() diff --git a/packages/studyloop/tests/test_mcp_registration.py b/packages/studyloop/tests/test_mcp_registration.py index 55b20f370..a117f9c19 100644 --- a/packages/studyloop/tests/test_mcp_registration.py +++ b/packages/studyloop/tests/test_mcp_registration.py @@ -1018,3 +1018,22 @@ def test_topic_exercises_generic_mcp_example_uses_studyloop_as_server_name() -> text = _repo_text("docs/topic-exercises.md") assert '"studyloop": {' in text assert '"studyloop-mcp": {' not in text + + +def test_xtiles_mcp_harnesses_reads_each_config_for_a_server_named_xtiles( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Read-only report the doctor's xTiles row uses. A missing or unreadable + config is simply "not registered there", never an error.""" + files = { + "claude": tmp_path / "claude.json", + "kiro": tmp_path / "mcp.json", + "codex": tmp_path / "config.toml", + "opencode": tmp_path / "absent.json", + } + files["claude"].write_text(json.dumps({"mcpServers": {"studyloop": {}}})) + files["kiro"].write_text(json.dumps({"mcpServers": {"xtiles": {"url": "https://x.invalid"}}})) + files["codex"].write_text('[mcp_servers.xtiles]\nurl = "https://x.invalid"\n') + monkeypatch.setattr(installers, "_mcp_config_path", lambda tool: files[tool]) + + assert installers.xtiles_mcp_harnesses() == ["codex", "kiro"] diff --git a/packages/studyloop/tests/test_settings_custom.py b/packages/studyloop/tests/test_settings_custom.py index f0bfebb5f..1f3c226d0 100644 --- a/packages/studyloop/tests/test_settings_custom.py +++ b/packages/studyloop/tests/test_settings_custom.py @@ -933,3 +933,37 @@ def test_known_top_level_keys_covers_the_full_docs_drift_derivation(): known = known_top_level_keys() full_derivation = _known_top_level_keys() assert full_derivation <= known, sorted(full_derivation - known) + + +def test_project_aliases_is_a_known_key_because_session_search_reads_it(): + """agent-session-tools reads ``project_aliases`` from the same config.yaml + (``query_utils.build_project_filter``; PROJECT_ALIASES.md). The doctor called + it inert and said to delete it -- which would have cut session search off + from every aliased path: 732 sessions for one project on the machine that + reported it (2026-09-27).""" + from studyloop.settings import known_top_level_keys, unknown_top_level_keys + + assert "project_aliases" in known_top_level_keys() + assert unknown_top_level_keys({"project_aliases": {"/a": ["/b"]}}) == [] + + +def test_every_key_agent_session_tools_reads_directly_is_known(): + """The class, not the instance: every ``load_config().get("")`` or + ``load_config()[""]`` in agent-session-tools names a top-level key that + the doctor must not report as unknown. DEFAULT_CONFIG's keys are covered by + test_docs_drift; this covers the reads that bypass it.""" + import re + + import agent_session_tools + from studyloop.settings import known_top_level_keys + + pattern = re.compile(r"""load_config\(\)(?:\.get\(|\[)\s*["']([A-Za-z_]+)["']""") + package = Path(agent_session_tools.__file__).parent + read = { + (key, path.name) + for path in package.rglob("*.py") + for key in pattern.findall(path.read_text(encoding="utf-8")) + } + assert read, "the scan found no reads; the pattern no longer matches the code" + known = known_top_level_keys() + assert sorted(pair for pair in read if pair[0] not in known) == [] diff --git a/packages/studyloop/tests/test_voice_backends.py b/packages/studyloop/tests/test_voice_backends.py index 089e2bd60..54866081b 100644 --- a/packages/studyloop/tests/test_voice_backends.py +++ b/packages/studyloop/tests/test_voice_backends.py @@ -416,3 +416,26 @@ def test_doctor_reports_the_valid_backend_it_resolved( assert by_name["backend"].status == "pass" assert "openvox" in by_name["backend"].message + + +def test_kokoro_model_files_are_only_reported_for_the_kokoro_backend( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + """Only study-speak's local ``kokoro`` backend reads ~/.cache/kokoro-onnx, and + it downloads the files itself on first use. With ``backend: openvox`` the row + asked a learner to pre-download about 354 MB that nothing on their machine + would read (reported 2026-09-27).""" + from studyloop.doctor import voice as doctor_voice + + monkeypatch.setattr(doctor_voice, "_KOKORO_MODEL", tmp_path / "kokoro-v1.0.onnx") + monkeypatch.setattr(doctor_voice, "_KOKORO_VOICES", tmp_path / "voices-v1.0.bin") + monkeypatch.setattr(doctor_voice, "_openvox_reachable", lambda base_url: True) + + monkeypatch.setattr(doctor_voice, "load_raw_config", lambda: {"tts": {"backend": "openvox"}}) + assert "kokoro_models" not in {r.name for r in doctor_voice.check_voice_readiness()} + + monkeypatch.setattr(doctor_voice, "load_raw_config", lambda: {"tts": {"backend": "kokoro"}}) + row = {r.name: r for r in doctor_voice.check_voice_readiness()}["kokoro_models"] + assert row.status == "info" + assert "first use" in row.message + assert "MB" in row.message diff --git a/packages/studyloop/tests/test_web_header_controls.py b/packages/studyloop/tests/test_web_header_controls.py new file mode 100644 index 000000000..6f3b16e2c --- /dev/null +++ b/packages/studyloop/tests/test_web_header_controls.py @@ -0,0 +1,98 @@ +"""The header's controls read as one row of labelled fields. + +Reported 2026-09-27 with a screenshot of the header: the voice-engine badge +("System voices") rendered as large bold text between the voice and theme +pickers, and those two pickers had no visible label while Font and Size did. + +Cause: the badge's classes (``tts-engine-badge`` plus ``pending``/``ok``/ +``degraded``, assigned by ``ttsEngineClass`` in components.js) had no CSS rule +at all from the commit that added the badge (de7e870b), so the span inherited +the header's text styles. It is the same unbacked-class slip the +``.header-field`` comment in style.css already records, which is why the first +test reads every state the script can assign instead of naming one selector. +""" + +from __future__ import annotations + +import re +from html.parser import HTMLParser +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[1] / "src" / "studyloop" / "web" / "static" + + +class _HeaderFields(HTMLParser): + """Collect the header's ``.header-field`` groups: label text, pickers, badge.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.depth = 0 + self.field_depth: int | None = None + self.label_depth: int | None = None + self.fields: list[dict] = [] + self.loose_selects: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + self.depth += 1 + attr = {key: value or "" for key, value in attrs} + classes = attr.get("class", "").split() + if "header-field" in classes and self.field_depth is None: + self.field_depth = self.depth + self.fields.append({"label": "", "selects": [], "badge": False}) + elif self.field_depth is not None and "header-field-label" in classes: + self.label_depth = self.depth + if tag == "select": + name = attr.get("id") or attr.get("title", "") + if self.field_depth is None: + self.loose_selects.append(name) + else: + self.fields[-1]["selects"].append(name) + if attr.get("id") == "tts-engine-badge" and self.field_depth is not None: + self.fields[-1]["badge"] = True + + def handle_endtag(self, tag: str) -> None: + if self.label_depth == self.depth: + self.label_depth = None + if self.field_depth == self.depth: + self.field_depth = None + self.depth -= 1 + + def handle_data(self, data: str) -> None: + if self.label_depth is not None: + self.fields[-1]["label"] += data.strip() + + +def _header() -> _HeaderFields: + html = (STATIC / "index.html").read_text(encoding="utf-8") + start = html.index('
') + end = html.index("", start) + parser = _HeaderFields() + parser.feed(html[start:end]) + parser.close() + assert parser.depth == 0, "the header controls did not parse as balanced elements" + return parser + + +def test_every_state_the_script_assigns_the_voice_badge_has_a_style() -> None: + script = (STATIC / "components.js").read_text(encoding="utf-8") + states = set(re.findall(r"'tts-engine-badge (\w+)'", script)) + assert states == {"pending", "ok", "degraded"}, f"the class seam moved: {states}" + + css = (STATIC / "style.css").read_text(encoding="utf-8") + assert re.search(r"(^|\n)\.tts-engine-badge\s*\{", css), "no rule for the badge itself" + for state in sorted(states): + assert re.search(rf"\.tts-engine-badge\.{state}\s*\{{", css), f"no rule for .{state}" + + +def test_every_header_picker_has_a_visible_label_in_reading_order() -> None: + header = _header() + assert header.loose_selects == [], f"pickers with no visible label: {header.loose_selects}" + assert [field["label"] for field in header.fields] == ["Voice", "Theme", "Font", "Size"] + assert all(len(field["selects"]) == 1 for field in header.fields) + + +def test_the_voice_badge_sits_in_the_voice_field() -> None: + header = _header() + voice = next(field for field in header.fields if field["selects"] == ["voice-select"]) + assert voice["badge"], "the engine badge floats outside the voice field" + assert sum(field["badge"] for field in header.fields) == 1 From bdc6a44a4c7ef24ac1375a690c2b3ed0a667f827 Mon Sep 17 00:00:00 2001 From: NetDevAutomate Date: Sun, 27 Sep 2026 14:49:56 +0100 Subject: [PATCH 02/16] fix(web): label every header picker and style the voice-engine badge Reported with a screenshot: "System voices" rendered as large bold text between the voice and theme pickers, and those two pickers had no visible label while Font and Size did. The badge's classes (tts-engine-badge + pending/ok/degraded) never had a CSS rule, so the span inherited the header's text styles. It is now a small pill in the voice field's label row, using the .bd-chip colours: green when the host's Kokoro is speaking, yellow when something else is (the "degraded" state the badge exists to make noticeable), italic while detecting. The voice and theme pickers get the same visible label as Font and Size, so the header reads VOICE / THEME / FONT / SIZE. The voice field hides as a whole when voice is off (x-show plus x-cloak), so no label is left alone. The controls now share one bottom edge (align-items: flex-end); stretch had pulled the unlabelled buttons and pickers up to the labelled fields' height. Checked in a browser against this tree: labels VOICE, THEME, FONT, SIZE; badge 10.2 px, weight 600, yellow; all seven visible controls end at the same y. Static and web suites 258 passed, JS 167/167. --- .../src/studyloop/web/static/index.html | 65 +++++++++++-------- .../src/studyloop/web/static/style.css | 46 +++++++++++++ 2 files changed, 84 insertions(+), 27 deletions(-) diff --git a/packages/studyloop/src/studyloop/web/static/index.html b/packages/studyloop/src/studyloop/web/static/index.html index 102a7381b..38fdf36dc 100644 --- a/packages/studyloop/src/studyloop/web/static/index.html +++ b/packages/studyloop/src/studyloop/web/static/index.html @@ -134,17 +134,25 @@
- - - + +
+ + + + + +
-
-
+
+ +

Body Double

A focused workspace with timer and terminal. Just be here.

@@ -1681,7 +1687,7 @@

Body Double

reason this surface exists, and test_remaining_surface asserts #bd-timer-display matches the SERVED defaults, so it binds to the store rather than hardcoding 00:00 as the old markup did. --> -
+
00:00
@@ -1698,39 +1704,12 @@

Body Double

- -
-
- - - - - stale -
-
-
- Nothing in focus yet. Park a tangent or set a focus to fill these slots. -
- - at capacity - -
-
+ -
+
+ + Break + +
@@ -1878,7 +1867,10 @@

Body Double

- + +
-
+
+