Repository navigation
refactor: consolidate test infrastructure and document conventions - #1
Conversation
Dedup migrated_db fixture (7 copies → 1 in conftest), replace unused pytest.mark.live with integration marker, add studyctl pytest config, create _helpers.py for shared fixture factories, and write TESTING.md covering all conventions. 650 tests pass unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Refactors and consolidates pytest infrastructure across the workspace to reduce fixture duplication, standardize markers/config, and document testing conventions for contributors.
Changes:
- Consolidates duplicated
migrated_dbfixtures intopackages/agent-session-tools/tests/conftest.py. - Standardizes on an
integrationpytest marker and declares it in root + package configs. - Adds studyctl-specific test helpers and introduces
docs/TESTING.mddocumenting conventions and invocation patterns.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
pyproject.toml |
Declares the integration marker at workspace root for root-level pytest runs. |
packages/studyctl/tests/_helpers.py |
Adds shared factory helpers intended to replace the need for a studyctl conftest.py. |
packages/studyctl/pyproject.toml |
Adds pytest config for studyctl when running tests from within the package directory. |
packages/agent-session-tools/tests/test_exporter_*.py |
Removes duplicated migrated_db fixtures/imports now provided via conftest. |
packages/agent-session-tools/tests/conftest.py |
Introduces the centralized migrated_db fixture (runs migrations on temp_db). |
packages/agent-session-tools/pyproject.toml |
Replaces the unused live marker with the standardized integration marker. |
docs/TESTING.md |
Adds comprehensive testing conventions and how-to-run guidance. |
docs/brainstorms/2026-04-01-test-consolidation-tdd-foundation-brainstorm.md |
Adds a decision record/brainstorm capturing rationale and testing strategy. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Usage:: | ||
|
|
||
| from _helpers import make_review_db, make_isolated_config | ||
|
|
||
| @pytest.fixture() | ||
| def review_db(tmp_path): | ||
| return make_review_db(tmp_path) | ||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def isolated_config(tmp_path, monkeypatch): | ||
| return make_isolated_config(tmp_path, monkeypatch) |
There was a problem hiding this comment.
The usage example imports helpers via from _helpers import ..., but _helpers.py lives under packages/studyctl/tests/ and neither the workspace-root nor packages/studyctl pytest config sets pythonpath to include that directory. In typical pytest runs this makes _helpers not importable (you'd need to either add an explicit pythonpath entry or move/rename the helpers into an importable module under studyctl so imports are stable from both root and package invocations).
| db_path = tmp_path / "reviews.db" | ||
| # Create the file so ensure_tables finds it | ||
| conn = sqlite3.connect(str(db_path)) | ||
| conn.execute("PRAGMA journal_mode=WAL") | ||
| conn.execute("PRAGMA busy_timeout=5000") | ||
| conn.close() | ||
|
|
||
| # Import lazily so the module can be loaded even if studyctl | ||
| # isn't fully installed (e.g. during collection). | ||
| from studyctl.review_db import ensure_tables | ||
|
|
||
| ensure_tables(db_path) |
There was a problem hiding this comment.
make_review_db() manually opens/closes a sqlite connection just to create the file and set WAL/busy_timeout, but studyctl.review_db.ensure_tables() already uses _connect() which sets those pragmas. Consider simplifying this helper to just ensure the file exists and then rely on ensure_tables() for connection setup, to avoid duplicated DB setup logic that can drift from production behavior.
| """Redirect studyctl's central config paths to a temp directory. | ||
|
|
||
| Patches ``studyctl.settings.CONFIG_DIR`` and | ||
| ``studyctl.settings._CONFIG_PATH`` so all config-reading code | ||
| hits *tmp_path* instead of ``~/.config/studyctl``. | ||
|
|
There was a problem hiding this comment.
The docstring for make_isolated_config() says it redirects config paths so “all config-reading code” hits tmp_path, but some modules import CONFIG_DIR at module import time (e.g. studyctl.cli._setup.CONFIG_DIR) and won’t be affected by patching studyctl.settings.CONFIG_DIR. Either narrow the claim in the docstring or extend the helper to also patch those known import sites to prevent tests from accidentally writing to the user’s real config directory.
| The workspace has two packages with independent test suites: | ||
|
|
||
| | Package | Tests | Path | | ||
| |---------|-------|------| | ||
| | studyctl | 293 | `packages/studyctl/tests/` | | ||
| | agent-session-tools | 357 | `packages/agent-session-tools/tests/` | | ||
|
|
There was a problem hiding this comment.
This guide hard-codes exact test counts (293/357/650). Those numbers will drift as tests are added/removed, which can quickly make the doc misleading. Consider removing the counts or phrasing them as approximate (or pointing readers to pytest --collect-only/pytest -q to see current totals).
| Import factory functions from `_helpers.py` and wrap them in `@pytest.fixture`: | ||
|
|
||
| ```python | ||
| import pytest | ||
| from _helpers import make_review_db, make_isolated_config | ||
|
|
||
|
|
||
| @pytest.fixture() | ||
| def review_db(tmp_path): | ||
| return make_review_db(tmp_path) | ||
|
|
There was a problem hiding this comment.
The _helpers.py import example uses from _helpers import ..., but _helpers.py is nested under packages/studyctl/tests/ and the documented root invocation uses --import-mode=importlib without adding that directory to pythonpath. As written, this import is likely to fail; consider updating the recommended import path and/or pytest config so helper imports work consistently from both workspace-root and per-package test runs.
Brings the Playwright suite from 10 passing / 3 broken to 126 passing / 4 intentionally skipped. All surfaces of the Phase 1 web UI are now covered end-to-end. New test files -------------- - test_web_review_flow.py (13 tests) — reviewApp() across flashcards and quiz modes: courses view, card rendering, config navigation, start-session flow, flip card, correct/wrong counters, retry-wrong cycle, quiz lock-after-answer, summary view + percentage calc. Uses Playwright route interception to stub /api/courses, /api/cards, /api/sources, /api/stats, /api/review — no real content needed on disk. - test_web_session_lifecycle.py (12 tests) — sessionTimer() picker + full start flow: agent hydration from /session/options, target-kind switcher parametrised over all 4 kinds, start-button enable/disable, study-session-start event dispatch on 201, 503 install_hint surfacing (the §1.5b structured error), 409 already-active UX, network-failure UX, end-session confirm dialog. - test_web_stores.py (13 tests) — Alpine ``settings`` + ``pomodoro`` stores: theme toggle + localStorage persistence, dyslexic font toggle, voice toggle, stopSpeaking idempotency, pomodoro start / pause / stop lifecycle, saveDurations persistence + recomputation, header-button wiring. - test_web_agent_matrix.py (15 tests) — per-agent parametrised (claude, codex, gemini, kiro, opencode × 3 tests each). Spawns the server with STUDYLOOP_TEST_AGENT_CMD='echo agent-stub-ready; exec cat' so every agent's launch path is driven uniformly without needing the real binary. Asserts: POST /session/start returns 201 + ws_url with the correct agent, WebSocket emits Started(agent=X), binary output frames stream the stub banner through the pump. This is the Amendment #1 "each coding agent with a test prompt that returns the expected response" requirement, literally. Supporting: _playwright_helpers.py (already landed in the nav commit) ------------------------------------------------------------------ - web_server_fixture_factory(port), auth_context_fixture_factory(), web_page_fixture_factory(server, auth) — shared factories so test files don't copy-paste server boot + HTTP Basic Auth wiring. - clean_ipc() + effective_credentials() — utilities reused by the per-agent matrix fixture which needs its own Popen to inject env. Deleted: test_e2e_session_demo.py ---------------------------------- Pre-existing recorded-demo integration test whose assertions targeted the pre-§1.7 #study-session UI (.meta-topic / .meta-energy / etc., all gone after the xterm.js rewrite). Replaced by the focused tests above which cover the same surface with much less coupling and run ten times faster. User-approved deletion. Port allocation (documented here so future tests don't collide) ---------------------------------------------------------------- 18570 — test_web_navigation 18571 — test_web_review_flow 18572 — test_web_session_lifecycle 18573 — test_web_stores 18574 — test_web_agent_matrix 18567 — test_web_terminal (shared with 18568, 18569 — pre-existing) Final state ----------- Main suite: 2001 passed, 210 deselected (the e2e set). E2e suite: 126 passed, 4 skipped (intentional — all documented), 0 failed. Skip reasons: xterm.js canvas input unreliable in headless Chromium (3 tests), and the collapse-toggle test that needs a real ttyd (1 test, correctly flagged since Phase 0). Ruff + format clean. Plan: Amendment #1 "comprehensive Playwright test suite as a first-class deliverable" — status: landed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the original feature: a user who selects a provider with no stored
key can now enter it in the UI. Previously the backend secrets routes existed
and were tested, but the frontend never called them — there was no way to add
a key through the web app.
- index.html: inline key-entry row, shown only when the selected provider is
keyed (adapter != bedrock) AND not yet available. Password input + 'Test &
save' -> POST /api/content/secrets; on success clears the raw key from
memory, re-fetches /providers so the option flips to enabled, shows a
'verified & stored encrypted' confirmation; on 400/422 shows the provider's
rejection message. needsKey / selectedProvider getters; keyEntry state reset
on provider change.
- style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing
palette vars; password/text inputs styled to match selects.
- test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for
unavailable keyed provider, hidden for available one, save POSTs correct
{provider,key} body + shows success, rejection shows error.
Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c)
the feature now works end-to-end: select provider -> enter missing key ->
tested + stored encrypted -> consumed at generation.
Generated decks never appeared in the Quiz/Flashcard panels. Root cause was
TWO independent bugs, not the single "3-level layout" hypothesis in the handoff:
A. Read-root unconfigured (dominant). The panels read decks from
`review.directories`; the generator WRITES them under `content.base_path`.
These are two separate config keys that must agree, but the live config sets
only `content`, so `_web.py` handed `discover_directories([])` and the panels
showed nothing regardless of disk contents.
Fix: new `settings.resolve_study_dirs()` falls back to `content.base_path`
when `review.directories` is unset. Wired into `_web.py` and `mcp/tools.py`.
B. Single-level descent. The real vault is 3 levels deep
(`base/<publisher>/<course>/{flashcards,quizzes}/`) but `discover_directories`
only walked one child level (the publisher), never reaching course dirs.
Fix: recursive descent to depth 4, stopping at the first content-bearing
dir on each branch (a course is a leaf — never recurse into its deck subdirs,
and skip empty deck dirs that `get_course_dir` eagerly mkdirs).
Proven end-to-end (scripts/prove_gen_readpath.py + live server + Playwright):
generated a real Ollama/gemma4 deck into CodeWithMosh/Complete_SQL_Mastery,
confirmed files land where panels read, `/api/courses` lists it (9 fc / 6 quiz),
`/api/cards` returns quality content, and the Flashcard panel renders card 1/9.
Tests: +9 (4 loader, 4 resolver, 1 empty-dir guard). 87 passed, no regressions.
…tion - scripts/prove_gen_readpath.py: end-to-end proof that a generated deck lands where the panels read (drives job.run_job + settings.resolve_study_dirs + review_loader.discover_directories + services.review.get_cards). Used to prove Issue #1's fix. - scripts/gen_for_workflow.py: parameterised single-provider generation harness for the autonomous gen+judge workflow. Controls target card count and injects judge-feedback guidance into the prompts (generators have no count param); decouples SOURCE course (scope) from OUTPUT course (--output-course) so concurrent providers write to isolated dirs; emits WF_RESULT_JSON for the orchestrating workflow to parse. Both proven live against Ollama gemma4, Bedrock Sonnet 4.6, and MiniMax M2.7.
…omplete Final overnight handoff + the autonomous workflow's per-provider report. Outcome: - Issue #1 (decks invisible to panels) FIXED & PROVEN — was 2 bugs (read-root unconfigured + 1-level-only discovery), not the hypothesised single 3-level one. - Autonomous gen+judge+validate+report workflow built, launched, completed: Bedrock APPROVED 9/9, Ollama APPROVED 8/7, MiniMax generates cleanly (2 adapter bugs fixed) but quiz quality is below the Opus judge bar (model limitation). - Playwright validated all 3 provider courses render in the panels. - Issue #2 (6 e2e selector failures) fixed. Issue #3 (model dropdown) deferred with rationale. Open product decision for the user: MiniMax rotation (flashcards-only / both / drop-for-quizzes). No further code needed for MiniMax to function.
…es, scalable course list Brings 20 commits to main: - Settings → LLM Providers admin panel (auth-kind taxonomy, Bedrock bearer, Ollama URL, per-provider test) + e2e selector fixes. - Content-gen fixes: Issue #1 deck write-root/read-root reconciliation (resolve_study_dirs + recursive discovery); MiniMax tool_result correction, inline-XML tool-call fallback, transient-retry; --max-retries harness knob. - Autonomous generate+judge+validate+report workflow (3 providers). - Scalable course list: publisher field, mode-split Flashcards/Quizzes panels, collapsible publisher groups, search, compact rows (+37 e2e tests).
…-side ACP guard (P0) Audit P0 #1 + #2. The user-controlled topic became a session-dir path segment via a naive .replace(' ','-') slug in FOUR places, none of which stripped '/', '\\' or '..' — and the dir is later rmtree'd on failure, so a topic like '../../x' was a real escape+delete vector. - New slug_session_dir() collapses everything outside [a-z0-9] to '-' and falls back to 'session' when empty; session_dir_name() routes through it. - Replace the three inline duplicate slugs (web PTY/ttyd _start.py, CLI session/start.py) with the shared helper — root-cause fix, one segment. - Add server-side ACP capability guard in _start_acp_session: a PTY-only agent (Claude Code, Codex) requesting transport=acp now gets a 400 with cause + repair BEFORE any spawn, instead of an opaque failure. - ACP_CAPABLE_AGENTS is the single source of truth; _options.py's picker flags now reference it instead of a duplicated {kiro,gemini,grok} literal. Tests: parametrized traversal cases + empty-slug fallback + ACP-guard rejection for claude/codex + capable-set lock. Held-out gate: the traversal tests fail on the pre-fix slug (verified by revert), pass on the fix.
Council item C6 (SIGNOFF-M2/ARBITRATION.md), matrix gap #1 of 3 (the other two -- C1's interleaving, C4's restart-with-live-child -- are closed by those items' own tests). No code change: claim_blocks_cli_start never consults _grace state, only pid liveness, so this cell was already correct by construction. The test exists to pin that the two mechanisms compose correctly, not to fix a defect. Test: TestWebThenCli:: test_cli_start_is_refused_while_a_web_session_is_detached_within_grace -- schedules a real grace-release timer for a web claim with a live pid, confirms has_pending_release() is true, then asserts a CLI start still refuses with the existing message and logs no reclaim. Evidence: reviews/2026-09-02-full-repo-review/evidence/M2/step-9/C6/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Brings the Playwright suite from 10 passing / 3 broken to 126 passing / 4 intentionally skipped. All surfaces of the Phase 1 web UI are now covered end-to-end. New test files -------------- - test_web_review_flow.py (13 tests) — reviewApp() across flashcards and quiz modes: courses view, card rendering, config navigation, start-session flow, flip card, correct/wrong counters, retry-wrong cycle, quiz lock-after-answer, summary view + percentage calc. Uses Playwright route interception to stub /api/courses, /api/cards, /api/sources, /api/stats, /api/review — no real content needed on disk. - test_web_session_lifecycle.py (12 tests) — sessionTimer() picker + full start flow: agent hydration from /session/options, target-kind switcher parametrised over all 4 kinds, start-button enable/disable, study-session-start event dispatch on 201, 503 install_hint surfacing (the §1.5b structured error), 409 already-active UX, network-failure UX, end-session confirm dialog. - test_web_stores.py (13 tests) — Alpine ``settings`` + ``pomodoro`` stores: theme toggle + localStorage persistence, dyslexic font toggle, voice toggle, stopSpeaking idempotency, pomodoro start / pause / stop lifecycle, saveDurations persistence + recomputation, header-button wiring. - test_web_agent_matrix.py (15 tests) — per-agent parametrised (claude, codex, gemini, kiro, opencode × 3 tests each). Spawns the server with STUDYLOOP_TEST_AGENT_CMD='echo agent-stub-ready; exec cat' so every agent's launch path is driven uniformly without needing the real binary. Asserts: POST /session/start returns 201 + ws_url with the correct agent, WebSocket emits Started(agent=X), binary output frames stream the stub banner through the pump. This is the Amendment #1 "each coding agent with a test prompt that returns the expected response" requirement, literally. Supporting: _playwright_helpers.py (already landed in the nav commit) ------------------------------------------------------------------ - web_server_fixture_factory(port), auth_context_fixture_factory(), web_page_fixture_factory(server, auth) — shared factories so test files don't copy-paste server boot + HTTP Basic Auth wiring. - clean_ipc() + effective_credentials() — utilities reused by the per-agent matrix fixture which needs its own Popen to inject env. Deleted: test_e2e_session_demo.py ---------------------------------- Pre-existing recorded-demo integration test whose assertions targeted the pre-§1.7 #study-session UI (.meta-topic / .meta-energy / etc., all gone after the xterm.js rewrite). Replaced by the focused tests above which cover the same surface with much less coupling and run ten times faster. User-approved deletion. Port allocation (documented here so future tests don't collide) ---------------------------------------------------------------- 18570 — test_web_navigation 18571 — test_web_review_flow 18572 — test_web_session_lifecycle 18573 — test_web_stores 18574 — test_web_agent_matrix 18567 — test_web_terminal (shared with 18568, 18569 — pre-existing) Final state ----------- Main suite: 2001 passed, 210 deselected (the e2e set). E2e suite: 126 passed, 4 skipped (intentional — all documented), 0 failed. Skip reasons: xterm.js canvas input unreliable in headless Chromium (3 tests), and the collapse-toggle test that needs a real ttyd (1 test, correctly flagged since Phase 0). Ruff + format clean. Plan: Amendment #1 "comprehensive Playwright test suite as a first-class deliverable" — status: landed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the original feature: a user who selects a provider with no stored
key can now enter it in the UI. Previously the backend secrets routes existed
and were tested, but the frontend never called them — there was no way to add
a key through the web app.
- index.html: inline key-entry row, shown only when the selected provider is
keyed (adapter != bedrock) AND not yet available. Password input + 'Test &
save' -> POST /api/content/secrets; on success clears the raw key from
memory, re-fetches /providers so the option flips to enabled, shows a
'verified & stored encrypted' confirmation; on 400/422 shows the provider's
rejection message. needsKey / selectedProvider getters; keyEntry state reset
on provider change.
- style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing
palette vars; password/text inputs styled to match selects.
- test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for
unavailable keyed provider, hidden for available one, save POSTs correct
{provider,key} body + shows success, rejection shows error.
Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c)
the feature now works end-to-end: select provider -> enter missing key ->
tested + stored encrypted -> consumed at generation.
Generated decks never appeared in the Quiz/Flashcard panels. Root cause was
TWO independent bugs, not the single "3-level layout" hypothesis in the handoff:
A. Read-root unconfigured (dominant). The panels read decks from
`review.directories`; the generator WRITES them under `content.base_path`.
These are two separate config keys that must agree, but the live config sets
only `content`, so `_web.py` handed `discover_directories([])` and the panels
showed nothing regardless of disk contents.
Fix: new `settings.resolve_study_dirs()` falls back to `content.base_path`
when `review.directories` is unset. Wired into `_web.py` and `mcp/tools.py`.
B. Single-level descent. The real vault is 3 levels deep
(`base/<publisher>/<course>/{flashcards,quizzes}/`) but `discover_directories`
only walked one child level (the publisher), never reaching course dirs.
Fix: recursive descent to depth 4, stopping at the first content-bearing
dir on each branch (a course is a leaf — never recurse into its deck subdirs,
and skip empty deck dirs that `get_course_dir` eagerly mkdirs).
Proven end-to-end (scripts/prove_gen_readpath.py + live server + Playwright):
generated a real Ollama/gemma4 deck into CodeWithMosh/Complete_SQL_Mastery,
confirmed files land where panels read, `/api/courses` lists it (9 fc / 6 quiz),
`/api/cards` returns quality content, and the Flashcard panel renders card 1/9.
Tests: +9 (4 loader, 4 resolver, 1 empty-dir guard). 87 passed, no regressions.
…tion - scripts/prove_gen_readpath.py: end-to-end proof that a generated deck lands where the panels read (drives job.run_job + settings.resolve_study_dirs + review_loader.discover_directories + services.review.get_cards). Used to prove Issue #1's fix. - scripts/gen_for_workflow.py: parameterised single-provider generation harness for the autonomous gen+judge workflow. Controls target card count and injects judge-feedback guidance into the prompts (generators have no count param); decouples SOURCE course (scope) from OUTPUT course (--output-course) so concurrent providers write to isolated dirs; emits WF_RESULT_JSON for the orchestrating workflow to parse. Both proven live against Ollama gemma4, Bedrock Sonnet 4.6, and MiniMax M2.7.
…es, scalable course list Brings 20 commits to main: - Settings → LLM Providers admin panel (auth-kind taxonomy, Bedrock bearer, Ollama URL, per-provider test) + e2e selector fixes. - Content-gen fixes: Issue #1 deck write-root/read-root reconciliation (resolve_study_dirs + recursive discovery); MiniMax tool_result correction, inline-XML tool-call fallback, transient-retry; --max-retries harness knob. - Autonomous generate+judge+validate+report workflow (3 providers). - Scalable course list: publisher field, mode-split Flashcards/Quizzes panels, collapsible publisher groups, search, compact rows (+37 e2e tests).
…-side ACP guard (P0) Audit P0 #1 + #2. The user-controlled topic became a session-dir path segment via a naive .replace(' ','-') slug in FOUR places, none of which stripped '/', '\\' or '..' — and the dir is later rmtree'd on failure, so a topic like '../../x' was a real escape+delete vector. - New slug_session_dir() collapses everything outside [a-z0-9] to '-' and falls back to 'session' when empty; session_dir_name() routes through it. - Replace the three inline duplicate slugs (web PTY/ttyd _start.py, CLI session/start.py) with the shared helper — root-cause fix, one segment. - Add server-side ACP capability guard in _start_acp_session: a PTY-only agent (Claude Code, Codex) requesting transport=acp now gets a 400 with cause + repair BEFORE any spawn, instead of an opaque failure. - ACP_CAPABLE_AGENTS is the single source of truth; _options.py's picker flags now reference it instead of a duplicated {kiro,gemini,grok} literal. Tests: parametrized traversal cases + empty-slug fallback + ACP-guard rejection for claude/codex + capable-set lock. Held-out gate: the traversal tests fail on the pre-fix slug (verified by revert), pass on the fix.
Council item C6 (SIGNOFF-M2/ARBITRATION.md), matrix gap #1 of 3 (the other two -- C1's interleaving, C4's restart-with-live-child -- are closed by those items' own tests). No code change: claim_blocks_cli_start never consults _grace state, only pid liveness, so this cell was already correct by construction. The test exists to pin that the two mechanisms compose correctly, not to fix a defect. Test: TestWebThenCli:: test_cli_start_is_refused_while_a_web_session_is_detached_within_grace -- schedules a real grace-release timer for a web claim with a live pid, confirms has_pending_release() is true, then asserts a CLI start still refuses with the existing message and logs no reclaim. Evidence: reviews/2026-09-02-full-repo-review/evidence/M2/step-9/C6/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Brings the Playwright suite from 10 passing / 3 broken to 126 passing / 4 intentionally skipped. All surfaces of the Phase 1 web UI are now covered end-to-end. New test files -------------- - test_web_review_flow.py (13 tests) — reviewApp() across flashcards and quiz modes: courses view, card rendering, config navigation, start-session flow, flip card, correct/wrong counters, retry-wrong cycle, quiz lock-after-answer, summary view + percentage calc. Uses Playwright route interception to stub /api/courses, /api/cards, /api/sources, /api/stats, /api/review — no real content needed on disk. - test_web_session_lifecycle.py (12 tests) — sessionTimer() picker + full start flow: agent hydration from /session/options, target-kind switcher parametrised over all 4 kinds, start-button enable/disable, study-session-start event dispatch on 201, 503 install_hint surfacing (the §1.5b structured error), 409 already-active UX, network-failure UX, end-session confirm dialog. - test_web_stores.py (13 tests) — Alpine ``settings`` + ``pomodoro`` stores: theme toggle + localStorage persistence, dyslexic font toggle, voice toggle, stopSpeaking idempotency, pomodoro start / pause / stop lifecycle, saveDurations persistence + recomputation, header-button wiring. - test_web_agent_matrix.py (15 tests) — per-agent parametrised (claude, codex, gemini, kiro, opencode × 3 tests each). Spawns the server with STUDYLOOP_TEST_AGENT_CMD='echo agent-stub-ready; exec cat' so every agent's launch path is driven uniformly without needing the real binary. Asserts: POST /session/start returns 201 + ws_url with the correct agent, WebSocket emits Started(agent=X), binary output frames stream the stub banner through the pump. This is the Amendment #1 "each coding agent with a test prompt that returns the expected response" requirement, literally. Supporting: _playwright_helpers.py (already landed in the nav commit) ------------------------------------------------------------------ - web_server_fixture_factory(port), auth_context_fixture_factory(), web_page_fixture_factory(server, auth) — shared factories so test files don't copy-paste server boot + HTTP Basic Auth wiring. - clean_ipc() + effective_credentials() — utilities reused by the per-agent matrix fixture which needs its own Popen to inject env. Deleted: test_e2e_session_demo.py ---------------------------------- Pre-existing recorded-demo integration test whose assertions targeted the pre-§1.7 #study-session UI (.meta-topic / .meta-energy / etc., all gone after the xterm.js rewrite). Replaced by the focused tests above which cover the same surface with much less coupling and run ten times faster. User-approved deletion. Port allocation (documented here so future tests don't collide) ---------------------------------------------------------------- 18570 — test_web_navigation 18571 — test_web_review_flow 18572 — test_web_session_lifecycle 18573 — test_web_stores 18574 — test_web_agent_matrix 18567 — test_web_terminal (shared with 18568, 18569 — pre-existing) Final state ----------- Main suite: 2001 passed, 210 deselected (the e2e set). E2e suite: 126 passed, 4 skipped (intentional — all documented), 0 failed. Skip reasons: xterm.js canvas input unreliable in headless Chromium (3 tests), and the collapse-toggle test that needs a real ttyd (1 test, correctly flagged since Phase 0). Ruff + format clean. Plan: Amendment #1 "comprehensive Playwright test suite as a first-class deliverable" — status: landed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the original feature: a user who selects a provider with no stored
key can now enter it in the UI. Previously the backend secrets routes existed
and were tested, but the frontend never called them — there was no way to add
a key through the web app.
- index.html: inline key-entry row, shown only when the selected provider is
keyed (adapter != bedrock) AND not yet available. Password input + 'Test &
save' -> POST /api/content/secrets; on success clears the raw key from
memory, re-fetches /providers so the option flips to enabled, shows a
'verified & stored encrypted' confirmation; on 400/422 shows the provider's
rejection message. needsKey / selectedProvider getters; keyEntry state reset
on provider change.
- style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing
palette vars; password/text inputs styled to match selects.
- test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for
unavailable keyed provider, hidden for available one, save POSTs correct
{provider,key} body + shows success, rejection shows error.
Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c)
the feature now works end-to-end: select provider -> enter missing key ->
tested + stored encrypted -> consumed at generation.
Generated decks never appeared in the Quiz/Flashcard panels. Root cause was
TWO independent bugs, not the single "3-level layout" hypothesis in the handoff:
A. Read-root unconfigured (dominant). The panels read decks from
`review.directories`; the generator WRITES them under `content.base_path`.
These are two separate config keys that must agree, but the live config sets
only `content`, so `_web.py` handed `discover_directories([])` and the panels
showed nothing regardless of disk contents.
Fix: new `settings.resolve_study_dirs()` falls back to `content.base_path`
when `review.directories` is unset. Wired into `_web.py` and `mcp/tools.py`.
B. Single-level descent. The real vault is 3 levels deep
(`base/<publisher>/<course>/{flashcards,quizzes}/`) but `discover_directories`
only walked one child level (the publisher), never reaching course dirs.
Fix: recursive descent to depth 4, stopping at the first content-bearing
dir on each branch (a course is a leaf — never recurse into its deck subdirs,
and skip empty deck dirs that `get_course_dir` eagerly mkdirs).
Proven end-to-end (scripts/prove_gen_readpath.py + live server + Playwright):
generated a real Ollama/gemma4 deck into CodeWithMosh/Complete_SQL_Mastery,
confirmed files land where panels read, `/api/courses` lists it (9 fc / 6 quiz),
`/api/cards` returns quality content, and the Flashcard panel renders card 1/9.
Tests: +9 (4 loader, 4 resolver, 1 empty-dir guard). 87 passed, no regressions.
…tion - scripts/prove_gen_readpath.py: end-to-end proof that a generated deck lands where the panels read (drives job.run_job + settings.resolve_study_dirs + review_loader.discover_directories + services.review.get_cards). Used to prove Issue #1's fix. - scripts/gen_for_workflow.py: parameterised single-provider generation harness for the autonomous gen+judge workflow. Controls target card count and injects judge-feedback guidance into the prompts (generators have no count param); decouples SOURCE course (scope) from OUTPUT course (--output-course) so concurrent providers write to isolated dirs; emits WF_RESULT_JSON for the orchestrating workflow to parse. Both proven live against Ollama gemma4, Bedrock Sonnet 4.6, and MiniMax M2.7.
…es, scalable course list Brings 20 commits to main: - Settings → LLM Providers admin panel (auth-kind taxonomy, Bedrock bearer, Ollama URL, per-provider test) + e2e selector fixes. - Content-gen fixes: Issue #1 deck write-root/read-root reconciliation (resolve_study_dirs + recursive discovery); MiniMax tool_result correction, inline-XML tool-call fallback, transient-retry; --max-retries harness knob. - Autonomous generate+judge+validate+report workflow (3 providers). - Scalable course list: publisher field, mode-split Flashcards/Quizzes panels, collapsible publisher groups, search, compact rows (+37 e2e tests).
…-side ACP guard (P0) Audit P0 #1 + #2. The user-controlled topic became a session-dir path segment via a naive .replace(' ','-') slug in FOUR places, none of which stripped '/', '\\' or '..' — and the dir is later rmtree'd on failure, so a topic like '../../x' was a real escape+delete vector. - New slug_session_dir() collapses everything outside [a-z0-9] to '-' and falls back to 'session' when empty; session_dir_name() routes through it. - Replace the three inline duplicate slugs (web PTY/ttyd _start.py, CLI session/start.py) with the shared helper — root-cause fix, one segment. - Add server-side ACP capability guard in _start_acp_session: a PTY-only agent (Claude Code, Codex) requesting transport=acp now gets a 400 with cause + repair BEFORE any spawn, instead of an opaque failure. - ACP_CAPABLE_AGENTS is the single source of truth; _options.py's picker flags now reference it instead of a duplicated {kiro,gemini,grok} literal. Tests: parametrized traversal cases + empty-slug fallback + ACP-guard rejection for claude/codex + capable-set lock. Held-out gate: the traversal tests fail on the pre-fix slug (verified by revert), pass on the fix.
Council item C6 (SIGNOFF-M2/ARBITRATION.md), matrix gap #1 of 3 (the other two -- C1's interleaving, C4's restart-with-live-child -- are closed by those items' own tests). No code change: claim_blocks_cli_start never consults _grace state, only pid liveness, so this cell was already correct by construction. The test exists to pin that the two mechanisms compose correctly, not to fix a defect. Test: TestWebThenCli:: test_cli_start_is_refused_while_a_web_session_is_detached_within_grace -- schedules a real grace-release timer for a web claim with a live pid, confirms has_pending_release() is true, then asserts a CLI start still refuses with the existing message and logs no reclaim. Evidence: reviews/2026-09-02-full-repo-review/evidence/M2/step-9/C6/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Generated decks never appeared in the Quiz/Flashcard panels. Root cause was
TWO independent bugs, not the single "3-level layout" hypothesis in the handoff:
A. Read-root unconfigured (dominant). The panels read decks from
`review.directories`; the generator WRITES them under `content.base_path`.
These are two separate config keys that must agree, but the live config sets
only `content`, so `_web.py` handed `discover_directories([])` and the panels
showed nothing regardless of disk contents.
Fix: new `settings.resolve_study_dirs()` falls back to `content.base_path`
when `review.directories` is unset. Wired into `_web.py` and `mcp/tools.py`.
B. Single-level descent. The real vault is 3 levels deep
(`base/<publisher>/<course>/{flashcards,quizzes}/`) but `discover_directories`
only walked one child level (the publisher), never reaching course dirs.
Fix: recursive descent to depth 4, stopping at the first content-bearing
dir on each branch (a course is a leaf — never recurse into its deck subdirs,
and skip empty deck dirs that `get_course_dir` eagerly mkdirs).
Proven end-to-end (scripts/prove_gen_readpath.py + live server + Playwright):
generated a real Ollama/gemma4 deck into CodeWithMosh/Complete_SQL_Mastery,
confirmed files land where panels read, `/api/courses` lists it (9 fc / 6 quiz),
`/api/cards` returns quality content, and the Flashcard panel renders card 1/9.
Tests: +9 (4 loader, 4 resolver, 1 empty-dir guard). 87 passed, no regressions.
…tion - scripts/prove_gen_readpath.py: end-to-end proof that a generated deck lands where the panels read (drives job.run_job + settings.resolve_study_dirs + review_loader.discover_directories + services.review.get_cards). Used to prove Issue #1's fix. - scripts/gen_for_workflow.py: parameterised single-provider generation harness for the autonomous gen+judge workflow. Controls target card count and injects judge-feedback guidance into the prompts (generators have no count param); decouples SOURCE course (scope) from OUTPUT course (--output-course) so concurrent providers write to isolated dirs; emits WF_RESULT_JSON for the orchestrating workflow to parse. Both proven live against Ollama gemma4, Bedrock Sonnet 4.6, and MiniMax M2.7.
Brings the Playwright suite from 10 passing / 3 broken to 126 passing / 4 intentionally skipped. All surfaces of the Phase 1 web UI are now covered end-to-end. New test files -------------- - test_web_review_flow.py (13 tests) — reviewApp() across flashcards and quiz modes: courses view, card rendering, config navigation, start-session flow, flip card, correct/wrong counters, retry-wrong cycle, quiz lock-after-answer, summary view + percentage calc. Uses Playwright route interception to stub /api/courses, /api/cards, /api/sources, /api/stats, /api/review — no real content needed on disk. - test_web_session_lifecycle.py (12 tests) — sessionTimer() picker + full start flow: agent hydration from /session/options, target-kind switcher parametrised over all 4 kinds, start-button enable/disable, study-session-start event dispatch on 201, 503 install_hint surfacing (the §1.5b structured error), 409 already-active UX, network-failure UX, end-session confirm dialog. - test_web_stores.py (13 tests) — Alpine ``settings`` + ``pomodoro`` stores: theme toggle + localStorage persistence, dyslexic font toggle, voice toggle, stopSpeaking idempotency, pomodoro start / pause / stop lifecycle, saveDurations persistence + recomputation, header-button wiring. - test_web_agent_matrix.py (15 tests) — per-agent parametrised (claude, codex, gemini, kiro, opencode × 3 tests each). Spawns the server with STUDYLOOP_TEST_AGENT_CMD='echo agent-stub-ready; exec cat' so every agent's launch path is driven uniformly without needing the real binary. Asserts: POST /session/start returns 201 + ws_url with the correct agent, WebSocket emits Started(agent=X), binary output frames stream the stub banner through the pump. This is the Amendment #1 "each coding agent with a test prompt that returns the expected response" requirement, literally. Supporting: _playwright_helpers.py (already landed in the nav commit) ------------------------------------------------------------------ - web_server_fixture_factory(port), auth_context_fixture_factory(), web_page_fixture_factory(server, auth) — shared factories so test files don't copy-paste server boot + HTTP Basic Auth wiring. - clean_ipc() + effective_credentials() — utilities reused by the per-agent matrix fixture which needs its own Popen to inject env. Deleted: test_e2e_session_demo.py ---------------------------------- Pre-existing recorded-demo integration test whose assertions targeted the pre-§1.7 #study-session UI (.meta-topic / .meta-energy / etc., all gone after the xterm.js rewrite). Replaced by the focused tests above which cover the same surface with much less coupling and run ten times faster. User-approved deletion. Port allocation (documented here so future tests don't collide) ---------------------------------------------------------------- 18570 — test_web_navigation 18571 — test_web_review_flow 18572 — test_web_session_lifecycle 18573 — test_web_stores 18574 — test_web_agent_matrix 18567 — test_web_terminal (shared with 18568, 18569 — pre-existing) Final state ----------- Main suite: 2001 passed, 210 deselected (the e2e set). E2e suite: 126 passed, 4 skipped (intentional — all documented), 0 failed. Skip reasons: xterm.js canvas input unreliable in headless Chromium (3 tests), and the collapse-toggle test that needs a real ttyd (1 test, correctly flagged since Phase 0). Ruff + format clean. Plan: Amendment #1 "comprehensive Playwright test suite as a first-class deliverable" — status: landed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the original feature: a user who selects a provider with no stored
key can now enter it in the UI. Previously the backend secrets routes existed
and were tested, but the frontend never called them — there was no way to add
a key through the web app.
- index.html: inline key-entry row, shown only when the selected provider is
keyed (adapter != bedrock) AND not yet available. Password input + 'Test &
save' -> POST /api/content/secrets; on success clears the raw key from
memory, re-fetches /providers so the option flips to enabled, shows a
'verified & stored encrypted' confirmation; on 400/422 shows the provider's
rejection message. needsKey / selectedProvider getters; keyEntry state reset
on provider change.
- style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing
palette vars; password/text inputs styled to match selects.
- test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for
unavailable keyed provider, hidden for available one, save POSTs correct
{provider,key} body + shows success, rejection shows error.
Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c)
the feature now works end-to-end: select provider -> enter missing key ->
tested + stored encrypted -> consumed at generation.
Generated decks never appeared in the Quiz/Flashcard panels. Root cause was
TWO independent bugs, not the single "3-level layout" hypothesis in the handoff:
A. Read-root unconfigured (dominant). The panels read decks from
`review.directories`; the generator WRITES them under `content.base_path`.
These are two separate config keys that must agree, but the live config sets
only `content`, so `_web.py` handed `discover_directories([])` and the panels
showed nothing regardless of disk contents.
Fix: new `settings.resolve_study_dirs()` falls back to `content.base_path`
when `review.directories` is unset. Wired into `_web.py` and `mcp/tools.py`.
B. Single-level descent. The real vault is 3 levels deep
(`base/<publisher>/<course>/{flashcards,quizzes}/`) but `discover_directories`
only walked one child level (the publisher), never reaching course dirs.
Fix: recursive descent to depth 4, stopping at the first content-bearing
dir on each branch (a course is a leaf — never recurse into its deck subdirs,
and skip empty deck dirs that `get_course_dir` eagerly mkdirs).
Proven end-to-end (scripts/prove_gen_readpath.py + live server + Playwright):
generated a real Ollama/gemma4 deck into CodeWithMosh/Complete_SQL_Mastery,
confirmed files land where panels read, `/api/courses` lists it (9 fc / 6 quiz),
`/api/cards` returns quality content, and the Flashcard panel renders card 1/9.
Tests: +9 (4 loader, 4 resolver, 1 empty-dir guard). 87 passed, no regressions.
…tion - scripts/prove_gen_readpath.py: end-to-end proof that a generated deck lands where the panels read (drives job.run_job + settings.resolve_study_dirs + review_loader.discover_directories + services.review.get_cards). Used to prove Issue #1's fix. - scripts/gen_for_workflow.py: parameterised single-provider generation harness for the autonomous gen+judge workflow. Controls target card count and injects judge-feedback guidance into the prompts (generators have no count param); decouples SOURCE course (scope) from OUTPUT course (--output-course) so concurrent providers write to isolated dirs; emits WF_RESULT_JSON for the orchestrating workflow to parse. Both proven live against Ollama gemma4, Bedrock Sonnet 4.6, and MiniMax M2.7.
…es, scalable course list Brings 20 commits to main: - Settings → LLM Providers admin panel (auth-kind taxonomy, Bedrock bearer, Ollama URL, per-provider test) + e2e selector fixes. - Content-gen fixes: Issue #1 deck write-root/read-root reconciliation (resolve_study_dirs + recursive discovery); MiniMax tool_result correction, inline-XML tool-call fallback, transient-retry; --max-retries harness knob. - Autonomous generate+judge+validate+report workflow (3 providers). - Scalable course list: publisher field, mode-split Flashcards/Quizzes panels, collapsible publisher groups, search, compact rows (+37 e2e tests).
…-side ACP guard (P0) Audit P0 #1 + #2. The user-controlled topic became a session-dir path segment via a naive .replace(' ','-') slug in FOUR places, none of which stripped '/', '\\' or '..' — and the dir is later rmtree'd on failure, so a topic like '../../x' was a real escape+delete vector. - New slug_session_dir() collapses everything outside [a-z0-9] to '-' and falls back to 'session' when empty; session_dir_name() routes through it. - Replace the three inline duplicate slugs (web PTY/ttyd _start.py, CLI session/start.py) with the shared helper — root-cause fix, one segment. - Add server-side ACP capability guard in _start_acp_session: a PTY-only agent (Claude Code, Codex) requesting transport=acp now gets a 400 with cause + repair BEFORE any spawn, instead of an opaque failure. - ACP_CAPABLE_AGENTS is the single source of truth; _options.py's picker flags now reference it instead of a duplicated {kiro,gemini,grok} literal. Tests: parametrized traversal cases + empty-slug fallback + ACP-guard rejection for claude/codex + capable-set lock. Held-out gate: the traversal tests fail on the pre-fix slug (verified by revert), pass on the fix.
Council item C6 (SIGNOFF-M2/ARBITRATION.md), matrix gap #1 of 3 (the other two -- C1's interleaving, C4's restart-with-live-child -- are closed by those items' own tests). No code change: claim_blocks_cli_start never consults _grace state, only pid liveness, so this cell was already correct by construction. The test exists to pin that the two mechanisms compose correctly, not to fix a defect. Test: TestWebThenCli:: test_cli_start_is_refused_while_a_web_session_is_detached_within_grace -- schedules a real grace-release timer for a web claim with a live pid, confirms has_pending_release() is true, then asserts a CLI start still refuses with the existing message and logs no reclaim. Evidence: reviews/2026-09-02-full-repo-review/evidence/M2/step-9/C6/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Brings the Playwright suite from 10 passing / 3 broken to 126 passing / 4 intentionally skipped. All surfaces of the Phase 1 web UI are now covered end-to-end. New test files -------------- - test_web_review_flow.py (13 tests) — reviewApp() across flashcards and quiz modes: courses view, card rendering, config navigation, start-session flow, flip card, correct/wrong counters, retry-wrong cycle, quiz lock-after-answer, summary view + percentage calc. Uses Playwright route interception to stub /api/courses, /api/cards, /api/sources, /api/stats, /api/review — no real content needed on disk. - test_web_session_lifecycle.py (12 tests) — sessionTimer() picker + full start flow: agent hydration from /session/options, target-kind switcher parametrised over all 4 kinds, start-button enable/disable, study-session-start event dispatch on 201, 503 install_hint surfacing (the §1.5b structured error), 409 already-active UX, network-failure UX, end-session confirm dialog. - test_web_stores.py (13 tests) — Alpine ``settings`` + ``pomodoro`` stores: theme toggle + localStorage persistence, dyslexic font toggle, voice toggle, stopSpeaking idempotency, pomodoro start / pause / stop lifecycle, saveDurations persistence + recomputation, header-button wiring. - test_web_agent_matrix.py (15 tests) — per-agent parametrised (claude, codex, gemini, kiro, opencode × 3 tests each). Spawns the server with STUDYLOOP_TEST_AGENT_CMD='echo agent-stub-ready; exec cat' so every agent's launch path is driven uniformly without needing the real binary. Asserts: POST /session/start returns 201 + ws_url with the correct agent, WebSocket emits Started(agent=X), binary output frames stream the stub banner through the pump. This is the Amendment #1 "each coding agent with a test prompt that returns the expected response" requirement, literally. Supporting: _playwright_helpers.py (already landed in the nav commit) ------------------------------------------------------------------ - web_server_fixture_factory(port), auth_context_fixture_factory(), web_page_fixture_factory(server, auth) — shared factories so test files don't copy-paste server boot + HTTP Basic Auth wiring. - clean_ipc() + effective_credentials() — utilities reused by the per-agent matrix fixture which needs its own Popen to inject env. Deleted: test_e2e_session_demo.py ---------------------------------- Pre-existing recorded-demo integration test whose assertions targeted the pre-§1.7 #study-session UI (.meta-topic / .meta-energy / etc., all gone after the xterm.js rewrite). Replaced by the focused tests above which cover the same surface with much less coupling and run ten times faster. User-approved deletion. Port allocation (documented here so future tests don't collide) ---------------------------------------------------------------- 18570 — test_web_navigation 18571 — test_web_review_flow 18572 — test_web_session_lifecycle 18573 — test_web_stores 18574 — test_web_agent_matrix 18567 — test_web_terminal (shared with 18568, 18569 — pre-existing) Final state ----------- Main suite: 2001 passed, 210 deselected (the e2e set). E2e suite: 126 passed, 4 skipped (intentional — all documented), 0 failed. Skip reasons: xterm.js canvas input unreliable in headless Chromium (3 tests), and the collapse-toggle test that needs a real ttyd (1 test, correctly flagged since Phase 0). Ruff + format clean. Plan: Amendment #1 "comprehensive Playwright test suite as a first-class deliverable" — status: landed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the original feature: a user who selects a provider with no stored
key can now enter it in the UI. Previously the backend secrets routes existed
and were tested, but the frontend never called them — there was no way to add
a key through the web app.
- index.html: inline key-entry row, shown only when the selected provider is
keyed (adapter != bedrock) AND not yet available. Password input + 'Test &
save' -> POST /api/content/secrets; on success clears the raw key from
memory, re-fetches /providers so the option flips to enabled, shows a
'verified & stored encrypted' confirmation; on 400/422 shows the provider's
rejection message. needsKey / selectedProvider getters; keyEntry state reset
on provider change.
- style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing
palette vars; password/text inputs styled to match selects.
- test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for
unavailable keyed provider, hidden for available one, save POSTs correct
{provider,key} body + shows success, rejection shows error.
Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c)
the feature now works end-to-end: select provider -> enter missing key ->
tested + stored encrypted -> consumed at generation.
Generated decks never appeared in the Quiz/Flashcard panels. Root cause was
TWO independent bugs, not the single "3-level layout" hypothesis in the handoff:
A. Read-root unconfigured (dominant). The panels read decks from
`review.directories`; the generator WRITES them under `content.base_path`.
These are two separate config keys that must agree, but the live config sets
only `content`, so `_web.py` handed `discover_directories([])` and the panels
showed nothing regardless of disk contents.
Fix: new `settings.resolve_study_dirs()` falls back to `content.base_path`
when `review.directories` is unset. Wired into `_web.py` and `mcp/tools.py`.
B. Single-level descent. The real vault is 3 levels deep
(`base/<publisher>/<course>/{flashcards,quizzes}/`) but `discover_directories`
only walked one child level (the publisher), never reaching course dirs.
Fix: recursive descent to depth 4, stopping at the first content-bearing
dir on each branch (a course is a leaf — never recurse into its deck subdirs,
and skip empty deck dirs that `get_course_dir` eagerly mkdirs).
Proven end-to-end (scripts/prove_gen_readpath.py + live server + Playwright):
generated a real Ollama/gemma4 deck into CodeWithMosh/Complete_SQL_Mastery,
confirmed files land where panels read, `/api/courses` lists it (9 fc / 6 quiz),
`/api/cards` returns quality content, and the Flashcard panel renders card 1/9.
Tests: +9 (4 loader, 4 resolver, 1 empty-dir guard). 87 passed, no regressions.
…tion - scripts/prove_gen_readpath.py: end-to-end proof that a generated deck lands where the panels read (drives job.run_job + settings.resolve_study_dirs + review_loader.discover_directories + services.review.get_cards). Used to prove Issue #1's fix. - scripts/gen_for_workflow.py: parameterised single-provider generation harness for the autonomous gen+judge workflow. Controls target card count and injects judge-feedback guidance into the prompts (generators have no count param); decouples SOURCE course (scope) from OUTPUT course (--output-course) so concurrent providers write to isolated dirs; emits WF_RESULT_JSON for the orchestrating workflow to parse. Both proven live against Ollama gemma4, Bedrock Sonnet 4.6, and MiniMax M2.7.
…es, scalable course list Brings 20 commits to main: - Settings → LLM Providers admin panel (auth-kind taxonomy, Bedrock bearer, Ollama URL, per-provider test) + e2e selector fixes. - Content-gen fixes: Issue #1 deck write-root/read-root reconciliation (resolve_study_dirs + recursive discovery); MiniMax tool_result correction, inline-XML tool-call fallback, transient-retry; --max-retries harness knob. - Autonomous generate+judge+validate+report workflow (3 providers). - Scalable course list: publisher field, mode-split Flashcards/Quizzes panels, collapsible publisher groups, search, compact rows (+37 e2e tests).
…-side ACP guard (P0) Audit P0 #1 + #2. The user-controlled topic became a session-dir path segment via a naive .replace(' ','-') slug in FOUR places, none of which stripped '/', '\\' or '..' — and the dir is later rmtree'd on failure, so a topic like '../../x' was a real escape+delete vector. - New slug_session_dir() collapses everything outside [a-z0-9] to '-' and falls back to 'session' when empty; session_dir_name() routes through it. - Replace the three inline duplicate slugs (web PTY/ttyd _start.py, CLI session/start.py) with the shared helper — root-cause fix, one segment. - Add server-side ACP capability guard in _start_acp_session: a PTY-only agent (Claude Code, Codex) requesting transport=acp now gets a 400 with cause + repair BEFORE any spawn, instead of an opaque failure. - ACP_CAPABLE_AGENTS is the single source of truth; _options.py's picker flags now reference it instead of a duplicated {kiro,gemini,grok} literal. Tests: parametrized traversal cases + empty-slug fallback + ACP-guard rejection for claude/codex + capable-set lock. Held-out gate: the traversal tests fail on the pre-fix slug (verified by revert), pass on the fix.
Council item C6 (SIGNOFF-M2/ARBITRATION.md), matrix gap #1 of 3 (the other two -- C1's interleaving, C4's restart-with-live-child -- are closed by those items' own tests). No code change: claim_blocks_cli_start never consults _grace state, only pid liveness, so this cell was already correct by construction. The test exists to pin that the two mechanisms compose correctly, not to fix a defect. Test: TestWebThenCli:: test_cli_start_is_refused_while_a_web_session_is_detached_within_grace -- schedules a real grace-release timer for a web claim with a live pid, confirms has_pending_release() is true, then asserts a CLI start still refuses with the existing message and logs no reclaim. Evidence: reviews/2026-09-02-full-repo-review/evidence/M2/step-9/C6/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Dedup migrated_db fixture (7 copies → 1 in conftest), replace unused pytest.mark.live with integration marker, add studyctl pytest config, create _helpers.py for shared fixture factories, and write TESTING.md covering all conventions. 650 tests pass unchanged. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Brings the Playwright suite from 10 passing / 3 broken to 126 passing / 4 intentionally skipped. All surfaces of the Phase 1 web UI are now covered end-to-end. New test files -------------- - test_web_review_flow.py (13 tests) — reviewApp() across flashcards and quiz modes: courses view, card rendering, config navigation, start-session flow, flip card, correct/wrong counters, retry-wrong cycle, quiz lock-after-answer, summary view + percentage calc. Uses Playwright route interception to stub /api/courses, /api/cards, /api/sources, /api/stats, /api/review — no real content needed on disk. - test_web_session_lifecycle.py (12 tests) — sessionTimer() picker + full start flow: agent hydration from /session/options, target-kind switcher parametrised over all 4 kinds, start-button enable/disable, study-session-start event dispatch on 201, 503 install_hint surfacing (the §1.5b structured error), 409 already-active UX, network-failure UX, end-session confirm dialog. - test_web_stores.py (13 tests) — Alpine ``settings`` + ``pomodoro`` stores: theme toggle + localStorage persistence, dyslexic font toggle, voice toggle, stopSpeaking idempotency, pomodoro start / pause / stop lifecycle, saveDurations persistence + recomputation, header-button wiring. - test_web_agent_matrix.py (15 tests) — per-agent parametrised (claude, codex, gemini, kiro, opencode × 3 tests each). Spawns the server with STUDYLOOP_TEST_AGENT_CMD='echo agent-stub-ready; exec cat' so every agent's launch path is driven uniformly without needing the real binary. Asserts: POST /session/start returns 201 + ws_url with the correct agent, WebSocket emits Started(agent=X), binary output frames stream the stub banner through the pump. This is the Amendment #1 "each coding agent with a test prompt that returns the expected response" requirement, literally. Supporting: _playwright_helpers.py (already landed in the nav commit) ------------------------------------------------------------------ - web_server_fixture_factory(port), auth_context_fixture_factory(), web_page_fixture_factory(server, auth) — shared factories so test files don't copy-paste server boot + HTTP Basic Auth wiring. - clean_ipc() + effective_credentials() — utilities reused by the per-agent matrix fixture which needs its own Popen to inject env. Deleted: test_e2e_session_demo.py ---------------------------------- Pre-existing recorded-demo integration test whose assertions targeted the pre-§1.7 #study-session UI (.meta-topic / .meta-energy / etc., all gone after the xterm.js rewrite). Replaced by the focused tests above which cover the same surface with much less coupling and run ten times faster. User-approved deletion. Port allocation (documented here so future tests don't collide) ---------------------------------------------------------------- 18570 — test_web_navigation 18571 — test_web_review_flow 18572 — test_web_session_lifecycle 18573 — test_web_stores 18574 — test_web_agent_matrix 18567 — test_web_terminal (shared with 18568, 18569 — pre-existing) Final state ----------- Main suite: 2001 passed, 210 deselected (the e2e set). E2e suite: 126 passed, 4 skipped (intentional — all documented), 0 failed. Skip reasons: xterm.js canvas input unreliable in headless Chromium (3 tests), and the collapse-toggle test that needs a real ttyd (1 test, correctly flagged since Phase 0). Ruff + format clean. Plan: Amendment #1 "comprehensive Playwright test suite as a first-class deliverable" — status: landed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the original feature: a user who selects a provider with no stored
key can now enter it in the UI. Previously the backend secrets routes existed
and were tested, but the frontend never called them — there was no way to add
a key through the web app.
- index.html: inline key-entry row, shown only when the selected provider is
keyed (adapter != bedrock) AND not yet available. Password input + 'Test &
save' -> POST /api/content/secrets; on success clears the raw key from
memory, re-fetches /providers so the option flips to enabled, shows a
'verified & stored encrypted' confirmation; on 400/422 shows the provider's
rejection message. needsKey / selectedProvider getters; keyEntry state reset
on provider change.
- style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing
palette vars; password/text inputs styled to match selects.
- test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for
unavailable keyed provider, hidden for available one, save POSTs correct
{provider,key} body + shows success, rejection shows error.
Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c)
the feature now works end-to-end: select provider -> enter missing key ->
tested + stored encrypted -> consumed at generation.
Generated decks never appeared in the Quiz/Flashcard panels. Root cause was
TWO independent bugs, not the single "3-level layout" hypothesis in the handoff:
A. Read-root unconfigured (dominant). The panels read decks from
`review.directories`; the generator WRITES them under `content.base_path`.
These are two separate config keys that must agree, but the live config sets
only `content`, so `_web.py` handed `discover_directories([])` and the panels
showed nothing regardless of disk contents.
Fix: new `settings.resolve_study_dirs()` falls back to `content.base_path`
when `review.directories` is unset. Wired into `_web.py` and `mcp/tools.py`.
B. Single-level descent. The real vault is 3 levels deep
(`base/<publisher>/<course>/{flashcards,quizzes}/`) but `discover_directories`
only walked one child level (the publisher), never reaching course dirs.
Fix: recursive descent to depth 4, stopping at the first content-bearing
dir on each branch (a course is a leaf — never recurse into its deck subdirs,
and skip empty deck dirs that `get_course_dir` eagerly mkdirs).
Proven end-to-end (scripts/prove_gen_readpath.py + live server + Playwright):
generated a real Ollama/gemma4 deck into CodeWithMosh/Complete_SQL_Mastery,
confirmed files land where panels read, `/api/courses` lists it (9 fc / 6 quiz),
`/api/cards` returns quality content, and the Flashcard panel renders card 1/9.
Tests: +9 (4 loader, 4 resolver, 1 empty-dir guard). 87 passed, no regressions.
…tion - scripts/prove_gen_readpath.py: end-to-end proof that a generated deck lands where the panels read (drives job.run_job + settings.resolve_study_dirs + review_loader.discover_directories + services.review.get_cards). Used to prove Issue #1's fix. - scripts/gen_for_workflow.py: parameterised single-provider generation harness for the autonomous gen+judge workflow. Controls target card count and injects judge-feedback guidance into the prompts (generators have no count param); decouples SOURCE course (scope) from OUTPUT course (--output-course) so concurrent providers write to isolated dirs; emits WF_RESULT_JSON for the orchestrating workflow to parse. Both proven live against Ollama gemma4, Bedrock Sonnet 4.6, and MiniMax M2.7.
…es, scalable course list Brings 20 commits to main: - Settings → LLM Providers admin panel (auth-kind taxonomy, Bedrock bearer, Ollama URL, per-provider test) + e2e selector fixes. - Content-gen fixes: Issue #1 deck write-root/read-root reconciliation (resolve_study_dirs + recursive discovery); MiniMax tool_result correction, inline-XML tool-call fallback, transient-retry; --max-retries harness knob. - Autonomous generate+judge+validate+report workflow (3 providers). - Scalable course list: publisher field, mode-split Flashcards/Quizzes panels, collapsible publisher groups, search, compact rows (+37 e2e tests).
…-side ACP guard (P0) Audit P0 #1 + #2. The user-controlled topic became a session-dir path segment via a naive .replace(' ','-') slug in FOUR places, none of which stripped '/', '\\' or '..' — and the dir is later rmtree'd on failure, so a topic like '../../x' was a real escape+delete vector. - New slug_session_dir() collapses everything outside [a-z0-9] to '-' and falls back to 'session' when empty; session_dir_name() routes through it. - Replace the three inline duplicate slugs (web PTY/ttyd _start.py, CLI session/start.py) with the shared helper — root-cause fix, one segment. - Add server-side ACP capability guard in _start_acp_session: a PTY-only agent (Claude Code, Codex) requesting transport=acp now gets a 400 with cause + repair BEFORE any spawn, instead of an opaque failure. - ACP_CAPABLE_AGENTS is the single source of truth; _options.py's picker flags now reference it instead of a duplicated {kiro,gemini,grok} literal. Tests: parametrized traversal cases + empty-slug fallback + ACP-guard rejection for claude/codex + capable-set lock. Held-out gate: the traversal tests fail on the pre-fix slug (verified by revert), pass on the fix.
Council item C6 (SIGNOFF-M2/ARBITRATION.md), matrix gap #1 of 3 (the other two -- C1's interleaving, C4's restart-with-live-child -- are closed by those items' own tests). No code change: claim_blocks_cli_start never consults _grace state, only pid liveness, so this cell was already correct by construction. The test exists to pin that the two mechanisms compose correctly, not to fix a defect. Test: TestWebThenCli:: test_cli_start_is_refused_while_a_web_session_is_detached_within_grace -- schedules a real grace-release timer for a web claim with a live pid, confirms has_pending_release() is true, then asserts a CLI start still refuses with the existing message and logs no reclaim. Evidence: reviews/2026-09-02-full-repo-review/evidence/M2/step-9/C6/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… Python
page.wait_for_function does not await a returned Promise: the Promise
object itself is truthy, so every 'async () => ...' predicate passed on
its first poll no matter what it would have resolved to. Verified
empirically — wait_for_function('async () => false') returns instantly.
That silent no-op is how phase 5 of the body-double journey went red on
main (run 33859082268): its 'wait until the server holds both notes' guard
never waited, and the active_total read raced the second POST on a loaded
runner. Six call sites carried the same defect — one had already failed,
five were latent.
Add _env.await_async_predicate, which drives the predicate through
page.evaluate (which DOES await) on a Python-side deadline, and convert
all six sites. Positive control per the campaign's trap #1: an
always-false predicate times out, an eventually-true one passes at the
flip. Sync predicates keep using wait_for_function, which is cheaper.
Verified: test_body_double_journey 13 passed; test_session_recovery_journey
+ test_ghostty_dev_terminal 40 passed, 2 skipped.
… the next cut (Q5) 0.2.0 shipped the whole second-brain layer while its change sat at 0/19 tasks, unarchived, with a proposal still selling the withdrawn Obsidian-CLI adapter — an agent following docs/contributing.md found no capability spec and instructions to build something three families cut. Ruling: archive- and-reconcile, never archive fiction (ARBITRATION Q5). Reconciled BEFORE archiving: proposal.md's What Changes and Risks now match design D4/D12 (adapter withdrawn; the four keys it used are refused with a naming error); tasks.md records what actually happened — shipped items ticked, the adapter and daily_note struck, the per-lane verifier items replaced by the P2 council that actually reviewed the merged diff, and the owner prompt run ticked against its filled checklist (run 2026-09-04, Kiro CLI 2.21.0). Then openspec archive second-brain, which wrote the second-brain capability spec into openspec/specs/ and applied the two deltas. ADR-0010 is now Accepted (it shipped), here and in the index. The guard, so this cannot recur: check-release-consistency.py gains a --release mode wired into release-check via the new release-consistency-shipped recipe (NOT preflight — open changes are legal during a cycle; only shipping one is not). It fails when any directory under openspec/changes/ (archive/ excluded) has commits since the last tag and is neither archived nor carrying an explicit 'deferred: <reason>' in its .openspec.yaml, and it validates archive entries added since the last tag with the openspec CLI (soft-skip when absent, same convention as spec-check; scoped to NEW archives because a July archive predating the guard has unticked tasks nobody has evidence to reconcile). The always-on ADR check fails any ADR present in the last tag that still says Proposed. Positive controls, per this campaign's trap #1: setting ADR-0010 back to Proposed turns the always-on check red (proving it would have fired on 0.2.0); restore turns it green. The shipped-changes guard's control run follows in the next verification step, since it needs a committed change.
Why: lanes C and R exposed their Click groups as importable objects and registered nothing (design.md §3: cli/__init__.py is W's hotspot, at assembly). `studyloop module` and `studyloop drill` now resolve through the LazyGroup map; `--help` for both and the /api/modules route table are verified in evidence/W/w3-registrations-prerequisites.green.txt. `_drill.py` (W2 brief, owned block #1): `module_prerequisites(snapshot)` reads `prerequisites[].id` through lane C's ModuleManifest and replaces the `prerequisites = ()` TODO, so the tools a module declares (and only those -- M1 PATH ruling) are what `runner.resolve_prerequisites` looks up on the learner's PATH before anyone is asked to consent. A snapshot whose manifest no longer validates refuses the phase rather than running with an empty tool list. Tests: test_module_cli's "root CLI has no module command" test is UPDATED to assert the registration (not deleted); test_module_lane_seams gains the C->R prerequisites seam (a copy of valid-scaffold declaring `kind`, a fake `kind` on a scratch PATH reaching resolve_prerequisites; the missing-tool refusal before consent) and a root-CLI `drill setup` invocation that declines consent. Red first: evidence/W/w3-registrations-prerequisites.red.txt (4 failed on their assertions). Gate: evidence/W/w3-gate-g.txt -- lint, pyright 0, test-js 184, docs strict, release-consistency, spec-check green; just test 7898 passed / 3 failed (the pre-existing tmux red and the two coverage-ratchet tests W4's journey satisfies).
…rowser in, a non-loopback --bind also listens on 127.0.0.1, `web --show-url`, --lan docs and stale comments, changelog Coordinator verification: 31 files; orchestrator.py was outside the written grant but is the bind/serve path the grant described (start_web_background now returns the token and records web_bind_host / web_sign_in_token; the password is no longer persisted to session state), accepted. 85 bind/lan/auth tests pass under my run. CHANGELOG.md conflicted with lane N in three hunks, all additive: resolved as the union, with N's reworded `archive` bullet (no version named) winning the one true overlap. Six Keep-a-Changelog sections, in order; docs contract tests and mkdocs --strict pass on the resolved tree. Lane K's found-not-fixed #1 is accepted as a coordinator unit next: Basic auth answers the practice_check hop with 401 before the route's own loopback+token guard can, so `drill` fails under any password mode; item 3 alone did not fix that.
…tears the adapter down Review 2026-10-21, remediation plan 5, larger #1 (lane 2's found-not-fixed #1, #3, #4). ``session/active.py`` ``release()`` ran ``clear_session_files()`` unconditionally, so three things went wrong on every web ending: (a) The reaper's ``state_file_replaced`` release of session A deleted the state, topics and parking files a later session B had just written. ``release()`` now reads the state file first and touches nothing when it names another session. (b) The ``mode=ended`` state the end paths write (``persist_session_end``) was deleted with the rest, so ``/api/session/state`` answered ``{}`` and the SSE summary view (``_render.py`` ``mode == "ended"``) was unreachable for browser sessions. ``release()`` keeps a saved (``mode=ended``) state file; an unsaved or id-less one still goes, so a live-looking claim owned by this process's pid never outlives the slot. The marker is never written by ``release()``: it means "persisted" and only the code that persisted may say so. (c) Stop, agent exit, grace expiry, the reaper and shutdown saved the session (F-WEB-02) but never ran ``teardown_session``, so Kiro's agent-JSON restore and the persona-file unlink happened only on End. ``_grace._end_before_release`` (was ``_persist_before_release``) now runs ``end_session_common(raise_if_not_persisted=False)``: the same persist-then-teardown, in the same order, the End route and the CLI's ``cleanup_on_exit`` already run. A later End on the kept ended file is now idempotent (200 after an idempotent teardown) rather than 404, which the End dialog reads as "could not end, try again" and keeps open; lane 2's repeated-end test pins the new answer and that nothing is persisted twice.
just e2e on db0eefe: 9 failed / 586 passed / 20 skipped of 615. All nine polled or asserted 'not state.study_session_id' after a web ending. Since 10a38eb (plan 5, larger #1) a web release keeps the saved mode=ended state file so the dashboard's summary view can render from it -- exactly the assertion lane R predicted would be 'wrong by design' in its log. Every consumer already reads an ended id as no session: _render.py:29, session_state.has_active_session / _claim_exists, components.js:1090,3568, session-timer.js:279. The nine sites now use the same predicate (a shared _live() helper in the two Python files; the JS predicates inline), and the abandon-mid-flight test pins the kept record truthfully (mode=ended, purpose=planning) while the page-level assertion keeps guarding that no LIVE planning label survives. The nine pass against the real server (9 passed in 47 s); the predicate still rejects an id whose mode is not ended, so a session that genuinely failed to release still fails them.
…wo natural verbs, four reclassified (pass 10 Q5) Council pass 10 Q5 (lane A's RC3). Seats split three ways (leave / convert all nine / leave); the ruling takes the text seriously and the label honestly: - #3 "A struggle **repair** carries an energy demand of its own, which SHALL be derived once …" -> "A struggle **repair** SHALL carry an energy demand of its own, derived once …": the modal sits on the sentence's own finite verb instead of an inserted relative clause. - #6 "Rule 3 SHALL be: It names the plan's next milestone …" -> "It SHALL name the plan's next milestone …": the scaffold stated nothing; the obligation is the sentence's. - #1, #2, #8, #9 keep their text but move from `inherit-shall` to `modalise`: they are closing or tail sentences, not list items under "It SHALL apply these rules", so the kind now says what happened (one modal, one verb form; the plural "need" keeps its form). - #4, #5, #7 stay `inherit-shall`: genuine list items under that lead-in. Checker: 0 fail, 29 warn, 452 drift (inherit-shall 3, modalise 30). Strict valid. No test or doc cites either changed sentence (the headings they sit under are unchanged).
Summary
migrated_dbfixture — 7 identical copies across exporter test files consolidated intoconftest.pypytest.mark.livewithintegrationmarker declared at workspace root[tool.pytest.ini_options]with markers and--tb=short_helpers.py— shared fixture factories for studyctl tests (can't use conftest due to pluggy conflict)docs/TESTING.md— complete testing conventions guide covering fixtures, mocking, markers, and the conftest prohibitionTest plan
migrated_dbonly defined in conftest, removed from all 7 exporter test filespytest.mark.integrationvisible from workspace root (uv run pytest --markers)_helpers.pyimports cleanly and is not collected by pytestPost-Deploy Monitoring & Validation
No additional operational monitoring required: pure test infrastructure refactor with zero behavioural changes to application code.
🤖 Generated with Claude Code