test(e2e): journey — tutor conversation persists to messages (#392) - #434

Merged
AndresL230 merged 2 commits into
mainfrom
test/392-journey-tutor-persist
Jul 28, 2026
Merged

test(e2e): journey — tutor conversation persists to messages (#392)#434
AndresL230 merged 2 commits into
mainfrom
test/392-journey-tutor-persist

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Browser journey for the tutor chat (#392): resume the seeded rich-sess-cs-recursion session on /learn, send a message through the real composer (tutor-inputtutor-send), assert the deterministic reply renders in tutor-messages, then raw-SQL readback of messages proving the encryption boundary:

  • exactly 2 new rows for the session (seeded 4 → 6; a double-write from an accidental legacy fallback would fail the count),
  • content at rest is ciphertext (≠ the sent plaintext / rendered reply), and
  • both rows decrypt — via the backend's own decrypt_if_present, shelled through the backend venv — to exactly the sent text and the deterministic reply. Both directions are asserted because decrypt_if_present echoes plaintext input back (legacy tolerance), so decrypt-equality alone can't prove encryption.

No SSE handling and zero waitForTimeout: sendChat is a plain fetch returning the full { reply, … } object; assertions wait on the rendered reply locator.

Boot contract

SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up

scripts/e2e-up.sh does not scrub the environment — exported vars flow through make into the setsid venv/bin/python -m uvicorn launch, and backend/.env doesn't define SAPLING_*, so no sanctioned-passing workaround was needed. The second var is new (see below).

Seam findings (#391 coverage of the tutor path)

  1. POST /api/learn/chat IS covered by the seam — it runs chat_tutor (pydantic-ai) via _chat_via_agent, whose model comes from model_for("chat_tutor").
  2. …but the browser could never reach the seam before this PR.routes/learn.py::_resolve_model_pref built a live GoogleModel via google_model(name) for the per-request fast/smart pref, passed as agent.run(model=…) — overriding the FunctionModel. The frontend always sends a pref (ModelToggle's useModelPref defaults to "fast"), so in function mode every browser tutor turn would still have dialed real Gemini. Fixed here: in any non-real mode the pref resolves to None (the agent default, already mode-correct). real mode is byte-for-byte unchanged.
  3. POST /api/learn/start-session is NOT covered — it still runs the legacy services/gemini_service.py::call_gemini_multiturn path (explicit TODO(refactor-3 follow-up) in the route). The journey therefore enters the chat by resuming a seeded session, never via "Start learning". Same applies to /action and /mode-switch (both legacy).
  4. Handler registration for out-of-process runs did not exist.feat(backend): pydantic-ai test seam via FunctionModel (SAPLING_MODEL_MODE) #391's register_function_handler is in-process only; ADR 0019 anticipated E2E lanes would "set the env at process start, then register per-task handlers" but shipped no mechanism for a separate uvicorn process. Added additively: SAPLING_FUNCTION_HANDLERS=<module> is imported lazily, once, only on a dispatch miss — the pytest lane (env unset, explicit registration) is unchanged, a missing handler still raises the pointed LookupError, explicit registrations always win, and a typo'd module path fails loudly (ImportError) instead of running handler-less. agents/function_handlers_e2e.py holds the fixed-reply chat_tutor handler; other journeys should append theirs there.
  5. Residual live-Gemini attempt (not a dependency): RAG query embedding._chat_via_agent calls rag_service.retrieve_chunks whenever the session's course has a course_code (seeded CS101 does), and _embed_query hits gemini-embedding-001 outside any seam. retrieve_chunks swallows every exception and returns [], so the journey's outcome does not depend on it — but each E2E tutor turn still attempts one billed embedding call when backend/.env carries a real key. Worth a follow-up (seam or env kill-switch for embeddings).

Verification

  • 10 consecutive local runs green (single lock hold, fresh function-mode stack boot from this branch's worktree): runs 1–10 all 1 passed, 4.5–5.9s each, zero failed/flaky/retried, clean teardown. Boot line used: the contract above.
  • Backend unit suite in the worktree: 1075 passed, 1 skipped (includes 6 new tests in tests/test_e2e_function_handlers.py: env-module autoload end-to-end through the real socratic_agent, unset-var LookupError posture, bad-module loud failure, explicit-registration precedence, and _resolve_model_pref mode behavior both ways). ruff check clean on changed files.
  • tsc --noEmit and eslint clean on changed frontend files (one pre-existing baselined warning in Learn.tsx).
  • Encrypt→decrypt round-trip of the exact helper snippet verified against the local stack's ENCRYPTION_KEY before the runs.

Testid note (deviation, with precedent)

The journey needs one new testid, tutor-session-resume-{sessionId}, on the Learn screen's session rows — but screens/Learn.tsx has ~20 intrinsic interactive elements outside any browser journey, so adding it to the eslint files array would force testids on all of them. Followed the documented signin-trigger exception instead: testid added + inventoried in docs/frontend-testids.md with the exception noted, file stays out of the lint block.

The AI-disclosure DisclaimerModal (fixed overlay, no testids) is pre-acked via addInitScript (models a returning user); dismissing it by copy would violate the no-copy-anchoring rule and tagging it was out of scope.

Cross-PR convergence notes

  • frontend/e2e/global-setup.ts gained the sapling_user localStorage entry copied verbatim from test/386-journey-dashboard (per the harness-owner heads-up): the cookie-only storageState never populates UserContext (it bootstraps identity solely from that localStorage key), so the Learn screen's session list — which this journey resumes from — stays empty without it. Identical content on both branches, so the merge is clean whichever lands first.
  • At least one sibling journey PR boots with the same SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e contract and will carry its own version of the handlers module / _providers.py autoload. These were authored in isolated worktrees — whoever merges second should converge to one module (append handlers per task) rather than duplicate the mechanism.

Files

  • frontend/e2e/tutor.spec.ts — the journey (new)
  • frontend/e2e/support/decrypt.ts — backend-venv decrypt seam (new)
  • frontend/e2e/support/db.ts — additive queryRaw (parameterized SELECTs behind the existing loopback guard)
  • frontend/src/components/screens/Learn.tsxtutor-session-resume-{sessionId}
  • docs/frontend-testids.md — inventory + lint-exception note (append-only)
  • backend/agents/_providers.pySAPLING_FUNCTION_HANDLERS lazy autoload on dispatch miss
  • backend/agents/function_handlers_e2e.py — deterministic chat_tutor handler (new)
  • backend/routes/learn.py_resolve_model_pref respects SAPLING_MODEL_MODE
  • backend/tests/test_e2e_function_handlers.py — 6 unit tests (new)

Part of #402, closes#392

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for loading function-mode tutor handlers during startup and dispatch.
    • Added deterministic tutor behavior for end-to-end testing.
    • Added a test identifier for resuming recent tutor sessions.
  • Bug Fixes

    • Prevented live model overrides from being created in non-real modes.
    • Improved missing or invalid handler errors.
  • Tests

    • Added coverage for handler loading, routing, session persistence, and encrypted message storage.
  • Documentation

    • Updated tutor end-to-end testing and test-identifier guidance.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging70441efCommit Preview URL

Branch Preview URL
Jul 28 2026, 03:25 AM

@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: faa906ad-a112-466d-9982-90b1ee54e01d

📥 Commits

Reviewing files that changed from the base of the PR and between 30161b2 and 70441ef.

📒 Files selected for processing (11)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/support/db.ts
  • frontend/e2e/support/decrypt.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx
📝 Walkthrough

Walkthrough

The PR adds environment-based function-handler loading, deterministic tutor responses, non-real model routing, and browser E2E coverage for tutor rendering and encrypted message persistence.

Changes

Deterministic tutor E2E flow

Layer / File(s)Summary
Function-handler loading and dispatch
backend/agents/_providers.py, backend/agents/function_handlers_e2e.py, backend/tests/test_e2e_function_handlers.py
Function-mode dispatch lazily imports the configured handler module, registers the deterministic chat_tutor handler, preserves explicit registrations, and tests missing or failed imports.
Model-mode routing
backend/routes/learn.py, backend/tests/test_e2e_function_handlers.py
Model preference overrides are skipped outside real mode and continue producing Gemini overrides in real mode.
Tutor journey and encrypted persistence assertions
frontend/e2e/support/*, frontend/e2e/tutor.spec.ts, frontend/src/components/screens/Learn.tsx, frontend/eslint.*, docs/frontend-testids.md
The browser journey resumes a seeded session, submits a message, verifies the deterministic reply, queries encrypted database rows, decrypts them through backend logic, and uses documented session-resume test IDs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant TutorE2E
participant Learn
participant Backend
participant Database
participant DecryptHelper
TutorE2E->>Learn: Resume seeded session
Learn->>Backend: Submit tutor message
Backend->>Database: Store encrypted messages
Backend->>Learn: Return deterministic tutor reply
TutorE2E->>Database: Read session messages
TutorE2E->>DecryptHelper: Decrypt stored content
DecryptHelper->>TutorE2E: Return plaintext values
Loading

Possibly related PRs

  • SaplingLearn/Sapling#437: Modifies the same backend function-handler loading and tutor handler registration paths, including model-mode routing.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change: an e2e tutor journey persisting conversation data to messages.
Description check✅ PassedThe description is detailed and covers the summary, related issues, testing, and reviewer notes, even if the headings differ from the template.
Docstring Coverage✅ PassedDocstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/392-journey-tutor-persist

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

import pytest
from pydantic_ai.models.google import GoogleModel

import agents._providers as providers
Comment threadbackend/agents/_providers.py Fixed
AndresL230 added a commit that referenced this pull request Jul 28, 2026
…port (#434 review)
Review fixes on PR #434:
- _providers.py latched _ENV_HANDLERS_LOADED BEFORE importing the env
module, permanently caching a failed import as loaded: the first
dispatch raised ModuleNotFoundError correctly, every later one silently
downgraded to the generic LookupError. Latch now sets only after a
successful import (or when the var is unset), so a broken boot fails
loudly on EVERY dispatch. Regression test dispatches twice against a
bad module path within one latch lifetime (verified red on the old
order, green on the fix).
- Learn.tsx joins the eslint testid enforcement files array; its 21
pre-existing untagged elements are baselined via npm run lint:baseline
(the documented legacy-debt mechanism, matching #429's Dashboard.tsx
treatment) instead of the prose lint exception, and the doc's Tutor
table row now names the ChatPanel/Learn.tsx split.
- Qualified the "pytest lane never sets the var / never imports it"
comments to the normal (hermetic) lane — the seam's own tests do both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the test/392-journey-tutor-persist branch from 83e7951 to 30161b2CompareJuly 28, 2026 03:06
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review fixes pushed (30161b2, rebased onto 385a534/#431 — the verbatim global-setup.ts commit dropped as already-upstream):

  1. Latch bug (required): fixed._ENV_HANDLERS_LOADED now sets only after a successful import (or when the var is unset), so a bad SAPLING_FUNCTION_HANDLERS path raises ModuleNotFoundError on every dispatch instead of downgrading to the generic LookupError after the first. Regression test test_bad_env_module_path_fails_loudly_on_every_dispatch dispatches twice within one latch lifetime — verified red against the old latch order, green on the fix.
  2. Testid mechanism: converged on the suppressions baseline.screens/Learn.tsx joins the eslint files array; npm run lint:baseline produced exactly one new entry (no-restricted-syntax: 21 for Learn.tsx — 22 intrinsic elements minus the newly tagged resume button, no other counts moved). The prose lint exception is gone and the Tutor table row now names the ChatPanel/Learn.tsx split, Sign-in-row style.
  3. Comment accuracy: the "pytest lane never sets the var / never imports it" claims are qualified to the normal (hermetic) lane, with the seam's own tests called out as the deliberate exception.

Re-verification: backend 1076 passed, 1 skipped (+1 regression test), ruff/tsc/eslint (0 errors)/vitest (204 passed) clean, and a fresh lock-held function-mode confirmation cycle on the rebased branch: 3/3 consecutive e2e/tutor.spec.ts runs green (7.4s/5.9s/5.5s), clean teardown. The original 10/10 tally stands as acceptance evidence.

module = (os.getenv("SAPLING_FUNCTION_HANDLERS") or "").strip()
if module:
importlib.import_module(module) # raises before the latch on failure
_ENV_HANDLERS_LOADED = True

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/e2e/support/db.ts`:
- Around line 155-159: Update queryRaw to execute client.query(sql, params)
inside a read-only transaction, ensuring the transaction is explicitly
configured as read-only before returning rows. Preserve the existing withDb
usage, parameters, and result shape while preventing mutation statements through
this helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 179f9b80-dec5-417c-8b08-827b9a21f412

📥 Commits

Reviewing files that changed from the base of the PR and between 385a534 and 30161b2.

📒 Files selected for processing (11)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/support/db.ts
  • frontend/e2e/support/decrypt.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx

Comment on lines +155 to +159
export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
return withDb(async (client) => (await client.query(sql, params)).rows);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Enforce the read-only query contract.

This accepts arbitrary SQL despite being documented as SELECT-only, so a future E2E assertion can mutate or truncate the local database through this helper. Execute it in a read-only transaction.

Proposed fix
 export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
- return withDb(async (client) => (await client.query(sql, params)).rows);+ return withDb(async (client) => {+ await client.query("BEGIN READ ONLY");+ try {+ return (await client.query(sql, params)).rows;+ } finally {+ await client.query("ROLLBACK");+ }+ });
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportasyncfunctionqueryRaw(
sql: string,
params: unknown[]=[],
): Promise<Record<string,unknown>[]>{
returnwithDb(async(client)=>(awaitclient.query(sql,params)).rows);
exportasyncfunctionqueryRaw(
sql: string,
params: unknown[]=[],
): Promise<Record<string,unknown>[]>{
returnwithDb(async(client)=>{
awaitclient.query("BEGIN READ ONLY");
try{
return(awaitclient.query(sql,params)).rows;
}finally{
awaitclient.query("ROLLBACK");
}
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/e2e/support/db.ts` around lines 155 - 159, Update queryRaw to
execute client.query(sql, params) inside a read-only transaction, ensuring the
transaction is explicitly configured as read-only before returning rows.
Preserve the existing withDb usage, parameters, and result shape while
preventing mutation statements through this helper.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance. (Three review findings were fixed in-PR: the env-handlers failure latch now sets only after a successful import — regression-tested red→green with a dispatch-twice test; Learn.tsx joined eslint testid enforcement via the suppressions baseline; comment claims qualified.)

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 27, 2026 20:20
Browser journey: resume the seeded recursion session, send a message
through the real composer, assert the deterministic reply renders, then
raw-SQL readback of the two new `messages` rows proving the encryption
boundary (content is ciphertext at rest and decrypts to the sent text).
Deterministic model responses via the #391 seam, extended for
out-of-process runs:
- agents/_providers.py: SAPLING_FUNCTION_HANDLERS names a module imported
lazily (once) on a dispatch miss, so the E2E uvicorn process can
register handlers at boot; unset var = #391 behavior unchanged.
- agents/function_handlers_e2e.py: fixed-reply chat_tutor handler the E2E
stack points the var at.
- routes/learn.py::_resolve_model_pref: in any non-real mode, the
browser's fast/smart pref no longer builds a live GoogleModel override
(ModelToggle defaults to "fast", so every browser turn sends a pref —
this silently bypassed the seam and dialed real Gemini).
- tutor-session-resume-{sessionId} testid on the Learn screen's session
rows (documented under the signin-trigger lint exception): the journey
enters the chat by resuming, because start-session still runs on the
legacy call_gemini_multiturn path the seam does not cover.
- e2e/support/db.ts queryRaw (parameterized SELECT behind the loopback
guard) + e2e/support/decrypt.ts (shells the backend venv's
decrypt_if_present, mirroring the integration-suite readback pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…port (#434 review)
Review fixes on PR #434:
- _providers.py latched _ENV_HANDLERS_LOADED BEFORE importing the env
module, permanently caching a failed import as loaded: the first
dispatch raised ModuleNotFoundError correctly, every later one silently
downgraded to the generic LookupError. Latch now sets only after a
successful import (or when the var is unset), so a broken boot fails
loudly on EVERY dispatch. Regression test dispatches twice against a
bad module path within one latch lifetime (verified red on the old
order, green on the fix).
- Learn.tsx joins the eslint testid enforcement files array; its 21
pre-existing untagged elements are baselined via npm run lint:baseline
(the documented legacy-debt mechanism, matching #429's Dashboard.tsx
treatment) instead of the prose lint exception, and the doc's Tutor
table row now names the ChatPanel/Learn.tsx split.
- Qualified the "pytest lane never sets the var / never imports it"
comments to the normal (hermetic) lane — the seam's own tests do both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(e2e): journey — tutor conversation persists to messages

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

test(e2e): journey — tutor conversation persists to messages (#392) - #434

Merged
AndresL230 merged 2 commits into
mainfrom
test/392-journey-tutor-persist
Jul 28, 2026
Merged

test(e2e): journey — tutor conversation persists to messages (#392)#434
AndresL230 merged 2 commits into
mainfrom
test/392-journey-tutor-persist

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Browser journey for the tutor chat (#392): resume the seeded rich-sess-cs-recursion session on /learn, send a message through the real composer (tutor-inputtutor-send), assert the deterministic reply renders in tutor-messages, then raw-SQL readback of messages proving the encryption boundary:

  • exactly 2 new rows for the session (seeded 4 → 6; a double-write from an accidental legacy fallback would fail the count),
  • content at rest is ciphertext (≠ the sent plaintext / rendered reply), and
  • both rows decrypt — via the backend's own decrypt_if_present, shelled through the backend venv — to exactly the sent text and the deterministic reply. Both directions are asserted because decrypt_if_present echoes plaintext input back (legacy tolerance), so decrypt-equality alone can't prove encryption.

No SSE handling and zero waitForTimeout: sendChat is a plain fetch returning the full { reply, … } object; assertions wait on the rendered reply locator.

Boot contract

SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up

scripts/e2e-up.sh does not scrub the environment — exported vars flow through make into the setsid venv/bin/python -m uvicorn launch, and backend/.env doesn't define SAPLING_*, so no sanctioned-passing workaround was needed. The second var is new (see below).

Seam findings (#391 coverage of the tutor path)

  1. POST /api/learn/chat IS covered by the seam — it runs chat_tutor (pydantic-ai) via _chat_via_agent, whose model comes from model_for("chat_tutor").
  2. …but the browser could never reach the seam before this PR.routes/learn.py::_resolve_model_pref built a live GoogleModel via google_model(name) for the per-request fast/smart pref, passed as agent.run(model=…) — overriding the FunctionModel. The frontend always sends a pref (ModelToggle's useModelPref defaults to "fast"), so in function mode every browser tutor turn would still have dialed real Gemini. Fixed here: in any non-real mode the pref resolves to None (the agent default, already mode-correct). real mode is byte-for-byte unchanged.
  3. POST /api/learn/start-session is NOT covered — it still runs the legacy services/gemini_service.py::call_gemini_multiturn path (explicit TODO(refactor-3 follow-up) in the route). The journey therefore enters the chat by resuming a seeded session, never via "Start learning". Same applies to /action and /mode-switch (both legacy).
  4. Handler registration for out-of-process runs did not exist.feat(backend): pydantic-ai test seam via FunctionModel (SAPLING_MODEL_MODE) #391's register_function_handler is in-process only; ADR 0019 anticipated E2E lanes would "set the env at process start, then register per-task handlers" but shipped no mechanism for a separate uvicorn process. Added additively: SAPLING_FUNCTION_HANDLERS=<module> is imported lazily, once, only on a dispatch miss — the pytest lane (env unset, explicit registration) is unchanged, a missing handler still raises the pointed LookupError, explicit registrations always win, and a typo'd module path fails loudly (ImportError) instead of running handler-less. agents/function_handlers_e2e.py holds the fixed-reply chat_tutor handler; other journeys should append theirs there.
  5. Residual live-Gemini attempt (not a dependency): RAG query embedding._chat_via_agent calls rag_service.retrieve_chunks whenever the session's course has a course_code (seeded CS101 does), and _embed_query hits gemini-embedding-001 outside any seam. retrieve_chunks swallows every exception and returns [], so the journey's outcome does not depend on it — but each E2E tutor turn still attempts one billed embedding call when backend/.env carries a real key. Worth a follow-up (seam or env kill-switch for embeddings).

Verification

  • 10 consecutive local runs green (single lock hold, fresh function-mode stack boot from this branch's worktree): runs 1–10 all 1 passed, 4.5–5.9s each, zero failed/flaky/retried, clean teardown. Boot line used: the contract above.
  • Backend unit suite in the worktree: 1075 passed, 1 skipped (includes 6 new tests in tests/test_e2e_function_handlers.py: env-module autoload end-to-end through the real socratic_agent, unset-var LookupError posture, bad-module loud failure, explicit-registration precedence, and _resolve_model_pref mode behavior both ways). ruff check clean on changed files.
  • tsc --noEmit and eslint clean on changed frontend files (one pre-existing baselined warning in Learn.tsx).
  • Encrypt→decrypt round-trip of the exact helper snippet verified against the local stack's ENCRYPTION_KEY before the runs.

Testid note (deviation, with precedent)

The journey needs one new testid, tutor-session-resume-{sessionId}, on the Learn screen's session rows — but screens/Learn.tsx has ~20 intrinsic interactive elements outside any browser journey, so adding it to the eslint files array would force testids on all of them. Followed the documented signin-trigger exception instead: testid added + inventoried in docs/frontend-testids.md with the exception noted, file stays out of the lint block.

The AI-disclosure DisclaimerModal (fixed overlay, no testids) is pre-acked via addInitScript (models a returning user); dismissing it by copy would violate the no-copy-anchoring rule and tagging it was out of scope.

Cross-PR convergence notes

  • frontend/e2e/global-setup.ts gained the sapling_user localStorage entry copied verbatim from test/386-journey-dashboard (per the harness-owner heads-up): the cookie-only storageState never populates UserContext (it bootstraps identity solely from that localStorage key), so the Learn screen's session list — which this journey resumes from — stays empty without it. Identical content on both branches, so the merge is clean whichever lands first.
  • At least one sibling journey PR boots with the same SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e contract and will carry its own version of the handlers module / _providers.py autoload. These were authored in isolated worktrees — whoever merges second should converge to one module (append handlers per task) rather than duplicate the mechanism.

Files

  • frontend/e2e/tutor.spec.ts — the journey (new)
  • frontend/e2e/support/decrypt.ts — backend-venv decrypt seam (new)
  • frontend/e2e/support/db.ts — additive queryRaw (parameterized SELECTs behind the existing loopback guard)
  • frontend/src/components/screens/Learn.tsxtutor-session-resume-{sessionId}
  • docs/frontend-testids.md — inventory + lint-exception note (append-only)
  • backend/agents/_providers.pySAPLING_FUNCTION_HANDLERS lazy autoload on dispatch miss
  • backend/agents/function_handlers_e2e.py — deterministic chat_tutor handler (new)
  • backend/routes/learn.py_resolve_model_pref respects SAPLING_MODEL_MODE
  • backend/tests/test_e2e_function_handlers.py — 6 unit tests (new)

Part of #402, closes#392

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for loading function-mode tutor handlers during startup and dispatch.
    • Added deterministic tutor behavior for end-to-end testing.
    • Added a test identifier for resuming recent tutor sessions.
  • Bug Fixes

    • Prevented live model overrides from being created in non-real modes.
    • Improved missing or invalid handler errors.
  • Tests

    • Added coverage for handler loading, routing, session persistence, and encrypted message storage.
  • Documentation

    • Updated tutor end-to-end testing and test-identifier guidance.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging70441efCommit Preview URL

Branch Preview URL
Jul 28 2026, 03:25 AM

@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: faa906ad-a112-466d-9982-90b1ee54e01d

📥 Commits

Reviewing files that changed from the base of the PR and between 30161b2 and 70441ef.

📒 Files selected for processing (11)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/support/db.ts
  • frontend/e2e/support/decrypt.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx
📝 Walkthrough

Walkthrough

The PR adds environment-based function-handler loading, deterministic tutor responses, non-real model routing, and browser E2E coverage for tutor rendering and encrypted message persistence.

Changes

Deterministic tutor E2E flow

Layer / File(s)Summary
Function-handler loading and dispatch
backend/agents/_providers.py, backend/agents/function_handlers_e2e.py, backend/tests/test_e2e_function_handlers.py
Function-mode dispatch lazily imports the configured handler module, registers the deterministic chat_tutor handler, preserves explicit registrations, and tests missing or failed imports.
Model-mode routing
backend/routes/learn.py, backend/tests/test_e2e_function_handlers.py
Model preference overrides are skipped outside real mode and continue producing Gemini overrides in real mode.
Tutor journey and encrypted persistence assertions
frontend/e2e/support/*, frontend/e2e/tutor.spec.ts, frontend/src/components/screens/Learn.tsx, frontend/eslint.*, docs/frontend-testids.md
The browser journey resumes a seeded session, submits a message, verifies the deterministic reply, queries encrypted database rows, decrypts them through backend logic, and uses documented session-resume test IDs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant TutorE2E
participant Learn
participant Backend
participant Database
participant DecryptHelper
TutorE2E->>Learn: Resume seeded session
Learn->>Backend: Submit tutor message
Backend->>Database: Store encrypted messages
Backend->>Learn: Return deterministic tutor reply
TutorE2E->>Database: Read session messages
TutorE2E->>DecryptHelper: Decrypt stored content
DecryptHelper->>TutorE2E: Return plaintext values
Loading

Possibly related PRs

  • SaplingLearn/Sapling#437: Modifies the same backend function-handler loading and tutor handler registration paths, including model-mode routing.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change: an e2e tutor journey persisting conversation data to messages.
Description check✅ PassedThe description is detailed and covers the summary, related issues, testing, and reviewer notes, even if the headings differ from the template.
Docstring Coverage✅ PassedDocstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/392-journey-tutor-persist

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

import pytest
from pydantic_ai.models.google import GoogleModel

import agents._providers as providers
Comment threadbackend/agents/_providers.py Fixed
AndresL230 added a commit that referenced this pull request Jul 28, 2026
…port (#434 review)
Review fixes on PR #434:
- _providers.py latched _ENV_HANDLERS_LOADED BEFORE importing the env
module, permanently caching a failed import as loaded: the first
dispatch raised ModuleNotFoundError correctly, every later one silently
downgraded to the generic LookupError. Latch now sets only after a
successful import (or when the var is unset), so a broken boot fails
loudly on EVERY dispatch. Regression test dispatches twice against a
bad module path within one latch lifetime (verified red on the old
order, green on the fix).
- Learn.tsx joins the eslint testid enforcement files array; its 21
pre-existing untagged elements are baselined via npm run lint:baseline
(the documented legacy-debt mechanism, matching #429's Dashboard.tsx
treatment) instead of the prose lint exception, and the doc's Tutor
table row now names the ChatPanel/Learn.tsx split.
- Qualified the "pytest lane never sets the var / never imports it"
comments to the normal (hermetic) lane — the seam's own tests do both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the test/392-journey-tutor-persist branch from 83e7951 to 30161b2CompareJuly 28, 2026 03:06
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review fixes pushed (30161b2, rebased onto 385a534/#431 — the verbatim global-setup.ts commit dropped as already-upstream):

  1. Latch bug (required): fixed._ENV_HANDLERS_LOADED now sets only after a successful import (or when the var is unset), so a bad SAPLING_FUNCTION_HANDLERS path raises ModuleNotFoundError on every dispatch instead of downgrading to the generic LookupError after the first. Regression test test_bad_env_module_path_fails_loudly_on_every_dispatch dispatches twice within one latch lifetime — verified red against the old latch order, green on the fix.
  2. Testid mechanism: converged on the suppressions baseline.screens/Learn.tsx joins the eslint files array; npm run lint:baseline produced exactly one new entry (no-restricted-syntax: 21 for Learn.tsx — 22 intrinsic elements minus the newly tagged resume button, no other counts moved). The prose lint exception is gone and the Tutor table row now names the ChatPanel/Learn.tsx split, Sign-in-row style.
  3. Comment accuracy: the "pytest lane never sets the var / never imports it" claims are qualified to the normal (hermetic) lane, with the seam's own tests called out as the deliberate exception.

Re-verification: backend 1076 passed, 1 skipped (+1 regression test), ruff/tsc/eslint (0 errors)/vitest (204 passed) clean, and a fresh lock-held function-mode confirmation cycle on the rebased branch: 3/3 consecutive e2e/tutor.spec.ts runs green (7.4s/5.9s/5.5s), clean teardown. The original 10/10 tally stands as acceptance evidence.

module = (os.getenv("SAPLING_FUNCTION_HANDLERS") or "").strip()
if module:
importlib.import_module(module) # raises before the latch on failure
_ENV_HANDLERS_LOADED = True

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/e2e/support/db.ts`:
- Around line 155-159: Update queryRaw to execute client.query(sql, params)
inside a read-only transaction, ensuring the transaction is explicitly
configured as read-only before returning rows. Preserve the existing withDb
usage, parameters, and result shape while preventing mutation statements through
this helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 179f9b80-dec5-417c-8b08-827b9a21f412

📥 Commits

Reviewing files that changed from the base of the PR and between 385a534 and 30161b2.

📒 Files selected for processing (11)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/support/db.ts
  • frontend/e2e/support/decrypt.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx

Comment on lines +155 to +159
export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
return withDb(async (client) => (await client.query(sql, params)).rows);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Enforce the read-only query contract.

This accepts arbitrary SQL despite being documented as SELECT-only, so a future E2E assertion can mutate or truncate the local database through this helper. Execute it in a read-only transaction.

Proposed fix
 export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
- return withDb(async (client) => (await client.query(sql, params)).rows);+ return withDb(async (client) => {+ await client.query("BEGIN READ ONLY");+ try {+ return (await client.query(sql, params)).rows;+ } finally {+ await client.query("ROLLBACK");+ }+ });
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportasyncfunctionqueryRaw(
sql: string,
params: unknown[]=[],
): Promise<Record<string,unknown>[]>{
returnwithDb(async(client)=>(awaitclient.query(sql,params)).rows);
exportasyncfunctionqueryRaw(
sql: string,
params: unknown[]=[],
): Promise<Record<string,unknown>[]>{
returnwithDb(async(client)=>{
awaitclient.query("BEGIN READ ONLY");
try{
return(awaitclient.query(sql,params)).rows;
}finally{
awaitclient.query("ROLLBACK");
}
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/e2e/support/db.ts` around lines 155 - 159, Update queryRaw to
execute client.query(sql, params) inside a read-only transaction, ensuring the
transaction is explicitly configured as read-only before returning rows.
Preserve the existing withDb usage, parameters, and result shape while
preventing mutation statements through this helper.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance. (Three review findings were fixed in-PR: the env-handlers failure latch now sets only after a successful import — regression-tested red→green with a dispatch-twice test; Learn.tsx joined eslint testid enforcement via the suppressions baseline; comment claims qualified.)

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 27, 2026 20:20
Browser journey: resume the seeded recursion session, send a message
through the real composer, assert the deterministic reply renders, then
raw-SQL readback of the two new `messages` rows proving the encryption
boundary (content is ciphertext at rest and decrypts to the sent text).
Deterministic model responses via the #391 seam, extended for
out-of-process runs:
- agents/_providers.py: SAPLING_FUNCTION_HANDLERS names a module imported
lazily (once) on a dispatch miss, so the E2E uvicorn process can
register handlers at boot; unset var = #391 behavior unchanged.
- agents/function_handlers_e2e.py: fixed-reply chat_tutor handler the E2E
stack points the var at.
- routes/learn.py::_resolve_model_pref: in any non-real mode, the
browser's fast/smart pref no longer builds a live GoogleModel override
(ModelToggle defaults to "fast", so every browser turn sends a pref —
this silently bypassed the seam and dialed real Gemini).
- tutor-session-resume-{sessionId} testid on the Learn screen's session
rows (documented under the signin-trigger lint exception): the journey
enters the chat by resuming, because start-session still runs on the
legacy call_gemini_multiturn path the seam does not cover.
- e2e/support/db.ts queryRaw (parameterized SELECT behind the loopback
guard) + e2e/support/decrypt.ts (shells the backend venv's
decrypt_if_present, mirroring the integration-suite readback pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…port (#434 review)
Review fixes on PR #434:
- _providers.py latched _ENV_HANDLERS_LOADED BEFORE importing the env
module, permanently caching a failed import as loaded: the first
dispatch raised ModuleNotFoundError correctly, every later one silently
downgraded to the generic LookupError. Latch now sets only after a
successful import (or when the var is unset), so a broken boot fails
loudly on EVERY dispatch. Regression test dispatches twice against a
bad module path within one latch lifetime (verified red on the old
order, green on the fix).
- Learn.tsx joins the eslint testid enforcement files array; its 21
pre-existing untagged elements are baselined via npm run lint:baseline
(the documented legacy-debt mechanism, matching #429's Dashboard.tsx
treatment) instead of the prose lint exception, and the doc's Tutor
table row now names the ChatPanel/Learn.tsx split.
- Qualified the "pytest lane never sets the var / never imports it"
comments to the normal (hermetic) lane — the seam's own tests do both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(e2e): journey — tutor conversation persists to messages

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

test(e2e): journey — tutor conversation persists to messages (#392) - #434

Merged
AndresL230 merged 2 commits into
mainfrom
test/392-journey-tutor-persist
Jul 28, 2026
Merged

test(e2e): journey — tutor conversation persists to messages (#392)#434
AndresL230 merged 2 commits into
mainfrom
test/392-journey-tutor-persist

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Browser journey for the tutor chat (#392): resume the seeded rich-sess-cs-recursion session on /learn, send a message through the real composer (tutor-inputtutor-send), assert the deterministic reply renders in tutor-messages, then raw-SQL readback of messages proving the encryption boundary:

  • exactly 2 new rows for the session (seeded 4 → 6; a double-write from an accidental legacy fallback would fail the count),
  • content at rest is ciphertext (≠ the sent plaintext / rendered reply), and
  • both rows decrypt — via the backend's own decrypt_if_present, shelled through the backend venv — to exactly the sent text and the deterministic reply. Both directions are asserted because decrypt_if_present echoes plaintext input back (legacy tolerance), so decrypt-equality alone can't prove encryption.

No SSE handling and zero waitForTimeout: sendChat is a plain fetch returning the full { reply, … } object; assertions wait on the rendered reply locator.

Boot contract

SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up

scripts/e2e-up.sh does not scrub the environment — exported vars flow through make into the setsid venv/bin/python -m uvicorn launch, and backend/.env doesn't define SAPLING_*, so no sanctioned-passing workaround was needed. The second var is new (see below).

Seam findings (#391 coverage of the tutor path)

  1. POST /api/learn/chat IS covered by the seam — it runs chat_tutor (pydantic-ai) via _chat_via_agent, whose model comes from model_for("chat_tutor").
  2. …but the browser could never reach the seam before this PR.routes/learn.py::_resolve_model_pref built a live GoogleModel via google_model(name) for the per-request fast/smart pref, passed as agent.run(model=…) — overriding the FunctionModel. The frontend always sends a pref (ModelToggle's useModelPref defaults to "fast"), so in function mode every browser tutor turn would still have dialed real Gemini. Fixed here: in any non-real mode the pref resolves to None (the agent default, already mode-correct). real mode is byte-for-byte unchanged.
  3. POST /api/learn/start-session is NOT covered — it still runs the legacy services/gemini_service.py::call_gemini_multiturn path (explicit TODO(refactor-3 follow-up) in the route). The journey therefore enters the chat by resuming a seeded session, never via "Start learning". Same applies to /action and /mode-switch (both legacy).
  4. Handler registration for out-of-process runs did not exist.feat(backend): pydantic-ai test seam via FunctionModel (SAPLING_MODEL_MODE) #391's register_function_handler is in-process only; ADR 0019 anticipated E2E lanes would "set the env at process start, then register per-task handlers" but shipped no mechanism for a separate uvicorn process. Added additively: SAPLING_FUNCTION_HANDLERS=<module> is imported lazily, once, only on a dispatch miss — the pytest lane (env unset, explicit registration) is unchanged, a missing handler still raises the pointed LookupError, explicit registrations always win, and a typo'd module path fails loudly (ImportError) instead of running handler-less. agents/function_handlers_e2e.py holds the fixed-reply chat_tutor handler; other journeys should append theirs there.
  5. Residual live-Gemini attempt (not a dependency): RAG query embedding._chat_via_agent calls rag_service.retrieve_chunks whenever the session's course has a course_code (seeded CS101 does), and _embed_query hits gemini-embedding-001 outside any seam. retrieve_chunks swallows every exception and returns [], so the journey's outcome does not depend on it — but each E2E tutor turn still attempts one billed embedding call when backend/.env carries a real key. Worth a follow-up (seam or env kill-switch for embeddings).

Verification

  • 10 consecutive local runs green (single lock hold, fresh function-mode stack boot from this branch's worktree): runs 1–10 all 1 passed, 4.5–5.9s each, zero failed/flaky/retried, clean teardown. Boot line used: the contract above.
  • Backend unit suite in the worktree: 1075 passed, 1 skipped (includes 6 new tests in tests/test_e2e_function_handlers.py: env-module autoload end-to-end through the real socratic_agent, unset-var LookupError posture, bad-module loud failure, explicit-registration precedence, and _resolve_model_pref mode behavior both ways). ruff check clean on changed files.
  • tsc --noEmit and eslint clean on changed frontend files (one pre-existing baselined warning in Learn.tsx).
  • Encrypt→decrypt round-trip of the exact helper snippet verified against the local stack's ENCRYPTION_KEY before the runs.

Testid note (deviation, with precedent)

The journey needs one new testid, tutor-session-resume-{sessionId}, on the Learn screen's session rows — but screens/Learn.tsx has ~20 intrinsic interactive elements outside any browser journey, so adding it to the eslint files array would force testids on all of them. Followed the documented signin-trigger exception instead: testid added + inventoried in docs/frontend-testids.md with the exception noted, file stays out of the lint block.

The AI-disclosure DisclaimerModal (fixed overlay, no testids) is pre-acked via addInitScript (models a returning user); dismissing it by copy would violate the no-copy-anchoring rule and tagging it was out of scope.

Cross-PR convergence notes

  • frontend/e2e/global-setup.ts gained the sapling_user localStorage entry copied verbatim from test/386-journey-dashboard (per the harness-owner heads-up): the cookie-only storageState never populates UserContext (it bootstraps identity solely from that localStorage key), so the Learn screen's session list — which this journey resumes from — stays empty without it. Identical content on both branches, so the merge is clean whichever lands first.
  • At least one sibling journey PR boots with the same SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e contract and will carry its own version of the handlers module / _providers.py autoload. These were authored in isolated worktrees — whoever merges second should converge to one module (append handlers per task) rather than duplicate the mechanism.

Files

  • frontend/e2e/tutor.spec.ts — the journey (new)
  • frontend/e2e/support/decrypt.ts — backend-venv decrypt seam (new)
  • frontend/e2e/support/db.ts — additive queryRaw (parameterized SELECTs behind the existing loopback guard)
  • frontend/src/components/screens/Learn.tsxtutor-session-resume-{sessionId}
  • docs/frontend-testids.md — inventory + lint-exception note (append-only)
  • backend/agents/_providers.pySAPLING_FUNCTION_HANDLERS lazy autoload on dispatch miss
  • backend/agents/function_handlers_e2e.py — deterministic chat_tutor handler (new)
  • backend/routes/learn.py_resolve_model_pref respects SAPLING_MODEL_MODE
  • backend/tests/test_e2e_function_handlers.py — 6 unit tests (new)

Part of #402, closes#392

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for loading function-mode tutor handlers during startup and dispatch.
    • Added deterministic tutor behavior for end-to-end testing.
    • Added a test identifier for resuming recent tutor sessions.
  • Bug Fixes

    • Prevented live model overrides from being created in non-real modes.
    • Improved missing or invalid handler errors.
  • Tests

    • Added coverage for handler loading, routing, session persistence, and encrypted message storage.
  • Documentation

    • Updated tutor end-to-end testing and test-identifier guidance.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging70441efCommit Preview URL

Branch Preview URL
Jul 28 2026, 03:25 AM

@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: faa906ad-a112-466d-9982-90b1ee54e01d

📥 Commits

Reviewing files that changed from the base of the PR and between 30161b2 and 70441ef.

📒 Files selected for processing (11)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/support/db.ts
  • frontend/e2e/support/decrypt.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx
📝 Walkthrough

Walkthrough

The PR adds environment-based function-handler loading, deterministic tutor responses, non-real model routing, and browser E2E coverage for tutor rendering and encrypted message persistence.

Changes

Deterministic tutor E2E flow

Layer / File(s)Summary
Function-handler loading and dispatch
backend/agents/_providers.py, backend/agents/function_handlers_e2e.py, backend/tests/test_e2e_function_handlers.py
Function-mode dispatch lazily imports the configured handler module, registers the deterministic chat_tutor handler, preserves explicit registrations, and tests missing or failed imports.
Model-mode routing
backend/routes/learn.py, backend/tests/test_e2e_function_handlers.py
Model preference overrides are skipped outside real mode and continue producing Gemini overrides in real mode.
Tutor journey and encrypted persistence assertions
frontend/e2e/support/*, frontend/e2e/tutor.spec.ts, frontend/src/components/screens/Learn.tsx, frontend/eslint.*, docs/frontend-testids.md
The browser journey resumes a seeded session, submits a message, verifies the deterministic reply, queries encrypted database rows, decrypts them through backend logic, and uses documented session-resume test IDs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant TutorE2E
participant Learn
participant Backend
participant Database
participant DecryptHelper
TutorE2E->>Learn: Resume seeded session
Learn->>Backend: Submit tutor message
Backend->>Database: Store encrypted messages
Backend->>Learn: Return deterministic tutor reply
TutorE2E->>Database: Read session messages
TutorE2E->>DecryptHelper: Decrypt stored content
DecryptHelper->>TutorE2E: Return plaintext values
Loading

Possibly related PRs

  • SaplingLearn/Sapling#437: Modifies the same backend function-handler loading and tutor handler registration paths, including model-mode routing.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change: an e2e tutor journey persisting conversation data to messages.
Description check✅ PassedThe description is detailed and covers the summary, related issues, testing, and reviewer notes, even if the headings differ from the template.
Docstring Coverage✅ PassedDocstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/392-journey-tutor-persist

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

import pytest
from pydantic_ai.models.google import GoogleModel

import agents._providers as providers
Comment threadbackend/agents/_providers.py Fixed
AndresL230 added a commit that referenced this pull request Jul 28, 2026
…port (#434 review)
Review fixes on PR #434:
- _providers.py latched _ENV_HANDLERS_LOADED BEFORE importing the env
module, permanently caching a failed import as loaded: the first
dispatch raised ModuleNotFoundError correctly, every later one silently
downgraded to the generic LookupError. Latch now sets only after a
successful import (or when the var is unset), so a broken boot fails
loudly on EVERY dispatch. Regression test dispatches twice against a
bad module path within one latch lifetime (verified red on the old
order, green on the fix).
- Learn.tsx joins the eslint testid enforcement files array; its 21
pre-existing untagged elements are baselined via npm run lint:baseline
(the documented legacy-debt mechanism, matching #429's Dashboard.tsx
treatment) instead of the prose lint exception, and the doc's Tutor
table row now names the ChatPanel/Learn.tsx split.
- Qualified the "pytest lane never sets the var / never imports it"
comments to the normal (hermetic) lane — the seam's own tests do both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the test/392-journey-tutor-persist branch from 83e7951 to 30161b2CompareJuly 28, 2026 03:06
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review fixes pushed (30161b2, rebased onto 385a534/#431 — the verbatim global-setup.ts commit dropped as already-upstream):

  1. Latch bug (required): fixed._ENV_HANDLERS_LOADED now sets only after a successful import (or when the var is unset), so a bad SAPLING_FUNCTION_HANDLERS path raises ModuleNotFoundError on every dispatch instead of downgrading to the generic LookupError after the first. Regression test test_bad_env_module_path_fails_loudly_on_every_dispatch dispatches twice within one latch lifetime — verified red against the old latch order, green on the fix.
  2. Testid mechanism: converged on the suppressions baseline.screens/Learn.tsx joins the eslint files array; npm run lint:baseline produced exactly one new entry (no-restricted-syntax: 21 for Learn.tsx — 22 intrinsic elements minus the newly tagged resume button, no other counts moved). The prose lint exception is gone and the Tutor table row now names the ChatPanel/Learn.tsx split, Sign-in-row style.
  3. Comment accuracy: the "pytest lane never sets the var / never imports it" claims are qualified to the normal (hermetic) lane, with the seam's own tests called out as the deliberate exception.

Re-verification: backend 1076 passed, 1 skipped (+1 regression test), ruff/tsc/eslint (0 errors)/vitest (204 passed) clean, and a fresh lock-held function-mode confirmation cycle on the rebased branch: 3/3 consecutive e2e/tutor.spec.ts runs green (7.4s/5.9s/5.5s), clean teardown. The original 10/10 tally stands as acceptance evidence.

module = (os.getenv("SAPLING_FUNCTION_HANDLERS") or "").strip()
if module:
importlib.import_module(module) # raises before the latch on failure
_ENV_HANDLERS_LOADED = True

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/e2e/support/db.ts`:
- Around line 155-159: Update queryRaw to execute client.query(sql, params)
inside a read-only transaction, ensuring the transaction is explicitly
configured as read-only before returning rows. Preserve the existing withDb
usage, parameters, and result shape while preventing mutation statements through
this helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 179f9b80-dec5-417c-8b08-827b9a21f412

📥 Commits

Reviewing files that changed from the base of the PR and between 385a534 and 30161b2.

📒 Files selected for processing (11)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/support/db.ts
  • frontend/e2e/support/decrypt.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx

Comment on lines +155 to +159
export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
return withDb(async (client) => (await client.query(sql, params)).rows);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Enforce the read-only query contract.

This accepts arbitrary SQL despite being documented as SELECT-only, so a future E2E assertion can mutate or truncate the local database through this helper. Execute it in a read-only transaction.

Proposed fix
 export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
- return withDb(async (client) => (await client.query(sql, params)).rows);+ return withDb(async (client) => {+ await client.query("BEGIN READ ONLY");+ try {+ return (await client.query(sql, params)).rows;+ } finally {+ await client.query("ROLLBACK");+ }+ });
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportasyncfunctionqueryRaw(
sql: string,
params: unknown[]=[],
): Promise<Record<string,unknown>[]>{
returnwithDb(async(client)=>(awaitclient.query(sql,params)).rows);
exportasyncfunctionqueryRaw(
sql: string,
params: unknown[]=[],
): Promise<Record<string,unknown>[]>{
returnwithDb(async(client)=>{
awaitclient.query("BEGIN READ ONLY");
try{
return(awaitclient.query(sql,params)).rows;
}finally{
awaitclient.query("ROLLBACK");
}
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/e2e/support/db.ts` around lines 155 - 159, Update queryRaw to
execute client.query(sql, params) inside a read-only transaction, ensuring the
transaction is explicitly configured as read-only before returning rows.
Preserve the existing withDb usage, parameters, and result shape while
preventing mutation statements through this helper.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance. (Three review findings were fixed in-PR: the env-handlers failure latch now sets only after a successful import — regression-tested red→green with a dispatch-twice test; Learn.tsx joined eslint testid enforcement via the suppressions baseline; comment claims qualified.)

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 27, 2026 20:20
Browser journey: resume the seeded recursion session, send a message
through the real composer, assert the deterministic reply renders, then
raw-SQL readback of the two new `messages` rows proving the encryption
boundary (content is ciphertext at rest and decrypts to the sent text).
Deterministic model responses via the #391 seam, extended for
out-of-process runs:
- agents/_providers.py: SAPLING_FUNCTION_HANDLERS names a module imported
lazily (once) on a dispatch miss, so the E2E uvicorn process can
register handlers at boot; unset var = #391 behavior unchanged.
- agents/function_handlers_e2e.py: fixed-reply chat_tutor handler the E2E
stack points the var at.
- routes/learn.py::_resolve_model_pref: in any non-real mode, the
browser's fast/smart pref no longer builds a live GoogleModel override
(ModelToggle defaults to "fast", so every browser turn sends a pref —
this silently bypassed the seam and dialed real Gemini).
- tutor-session-resume-{sessionId} testid on the Learn screen's session
rows (documented under the signin-trigger lint exception): the journey
enters the chat by resuming, because start-session still runs on the
legacy call_gemini_multiturn path the seam does not cover.
- e2e/support/db.ts queryRaw (parameterized SELECT behind the loopback
guard) + e2e/support/decrypt.ts (shells the backend venv's
decrypt_if_present, mirroring the integration-suite readback pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…port (#434 review)
Review fixes on PR #434:
- _providers.py latched _ENV_HANDLERS_LOADED BEFORE importing the env
module, permanently caching a failed import as loaded: the first
dispatch raised ModuleNotFoundError correctly, every later one silently
downgraded to the generic LookupError. Latch now sets only after a
successful import (or when the var is unset), so a broken boot fails
loudly on EVERY dispatch. Regression test dispatches twice against a
bad module path within one latch lifetime (verified red on the old
order, green on the fix).
- Learn.tsx joins the eslint testid enforcement files array; its 21
pre-existing untagged elements are baselined via npm run lint:baseline
(the documented legacy-debt mechanism, matching #429's Dashboard.tsx
treatment) instead of the prose lint exception, and the doc's Tutor
table row now names the ChatPanel/Learn.tsx split.
- Qualified the "pytest lane never sets the var / never imports it"
comments to the normal (hermetic) lane — the seam's own tests do both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(e2e): journey — tutor conversation persists to messages

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

test(e2e): journey — tutor conversation persists to messages (#392) - #434

Merged
AndresL230 merged 2 commits into
mainfrom
test/392-journey-tutor-persist
Jul 28, 2026
Merged

test(e2e): journey — tutor conversation persists to messages (#392)#434
AndresL230 merged 2 commits into
mainfrom
test/392-journey-tutor-persist

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Browser journey for the tutor chat (#392): resume the seeded rich-sess-cs-recursion session on /learn, send a message through the real composer (tutor-inputtutor-send), assert the deterministic reply renders in tutor-messages, then raw-SQL readback of messages proving the encryption boundary:

  • exactly 2 new rows for the session (seeded 4 → 6; a double-write from an accidental legacy fallback would fail the count),
  • content at rest is ciphertext (≠ the sent plaintext / rendered reply), and
  • both rows decrypt — via the backend's own decrypt_if_present, shelled through the backend venv — to exactly the sent text and the deterministic reply. Both directions are asserted because decrypt_if_present echoes plaintext input back (legacy tolerance), so decrypt-equality alone can't prove encryption.

No SSE handling and zero waitForTimeout: sendChat is a plain fetch returning the full { reply, … } object; assertions wait on the rendered reply locator.

Boot contract

SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up

scripts/e2e-up.sh does not scrub the environment — exported vars flow through make into the setsid venv/bin/python -m uvicorn launch, and backend/.env doesn't define SAPLING_*, so no sanctioned-passing workaround was needed. The second var is new (see below).

Seam findings (#391 coverage of the tutor path)

  1. POST /api/learn/chat IS covered by the seam — it runs chat_tutor (pydantic-ai) via _chat_via_agent, whose model comes from model_for("chat_tutor").
  2. …but the browser could never reach the seam before this PR.routes/learn.py::_resolve_model_pref built a live GoogleModel via google_model(name) for the per-request fast/smart pref, passed as agent.run(model=…) — overriding the FunctionModel. The frontend always sends a pref (ModelToggle's useModelPref defaults to "fast"), so in function mode every browser tutor turn would still have dialed real Gemini. Fixed here: in any non-real mode the pref resolves to None (the agent default, already mode-correct). real mode is byte-for-byte unchanged.
  3. POST /api/learn/start-session is NOT covered — it still runs the legacy services/gemini_service.py::call_gemini_multiturn path (explicit TODO(refactor-3 follow-up) in the route). The journey therefore enters the chat by resuming a seeded session, never via "Start learning". Same applies to /action and /mode-switch (both legacy).
  4. Handler registration for out-of-process runs did not exist.feat(backend): pydantic-ai test seam via FunctionModel (SAPLING_MODEL_MODE) #391's register_function_handler is in-process only; ADR 0019 anticipated E2E lanes would "set the env at process start, then register per-task handlers" but shipped no mechanism for a separate uvicorn process. Added additively: SAPLING_FUNCTION_HANDLERS=<module> is imported lazily, once, only on a dispatch miss — the pytest lane (env unset, explicit registration) is unchanged, a missing handler still raises the pointed LookupError, explicit registrations always win, and a typo'd module path fails loudly (ImportError) instead of running handler-less. agents/function_handlers_e2e.py holds the fixed-reply chat_tutor handler; other journeys should append theirs there.
  5. Residual live-Gemini attempt (not a dependency): RAG query embedding._chat_via_agent calls rag_service.retrieve_chunks whenever the session's course has a course_code (seeded CS101 does), and _embed_query hits gemini-embedding-001 outside any seam. retrieve_chunks swallows every exception and returns [], so the journey's outcome does not depend on it — but each E2E tutor turn still attempts one billed embedding call when backend/.env carries a real key. Worth a follow-up (seam or env kill-switch for embeddings).

Verification

  • 10 consecutive local runs green (single lock hold, fresh function-mode stack boot from this branch's worktree): runs 1–10 all 1 passed, 4.5–5.9s each, zero failed/flaky/retried, clean teardown. Boot line used: the contract above.
  • Backend unit suite in the worktree: 1075 passed, 1 skipped (includes 6 new tests in tests/test_e2e_function_handlers.py: env-module autoload end-to-end through the real socratic_agent, unset-var LookupError posture, bad-module loud failure, explicit-registration precedence, and _resolve_model_pref mode behavior both ways). ruff check clean on changed files.
  • tsc --noEmit and eslint clean on changed frontend files (one pre-existing baselined warning in Learn.tsx).
  • Encrypt→decrypt round-trip of the exact helper snippet verified against the local stack's ENCRYPTION_KEY before the runs.

Testid note (deviation, with precedent)

The journey needs one new testid, tutor-session-resume-{sessionId}, on the Learn screen's session rows — but screens/Learn.tsx has ~20 intrinsic interactive elements outside any browser journey, so adding it to the eslint files array would force testids on all of them. Followed the documented signin-trigger exception instead: testid added + inventoried in docs/frontend-testids.md with the exception noted, file stays out of the lint block.

The AI-disclosure DisclaimerModal (fixed overlay, no testids) is pre-acked via addInitScript (models a returning user); dismissing it by copy would violate the no-copy-anchoring rule and tagging it was out of scope.

Cross-PR convergence notes

  • frontend/e2e/global-setup.ts gained the sapling_user localStorage entry copied verbatim from test/386-journey-dashboard (per the harness-owner heads-up): the cookie-only storageState never populates UserContext (it bootstraps identity solely from that localStorage key), so the Learn screen's session list — which this journey resumes from — stays empty without it. Identical content on both branches, so the merge is clean whichever lands first.
  • At least one sibling journey PR boots with the same SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e contract and will carry its own version of the handlers module / _providers.py autoload. These were authored in isolated worktrees — whoever merges second should converge to one module (append handlers per task) rather than duplicate the mechanism.

Files

  • frontend/e2e/tutor.spec.ts — the journey (new)
  • frontend/e2e/support/decrypt.ts — backend-venv decrypt seam (new)
  • frontend/e2e/support/db.ts — additive queryRaw (parameterized SELECTs behind the existing loopback guard)
  • frontend/src/components/screens/Learn.tsxtutor-session-resume-{sessionId}
  • docs/frontend-testids.md — inventory + lint-exception note (append-only)
  • backend/agents/_providers.pySAPLING_FUNCTION_HANDLERS lazy autoload on dispatch miss
  • backend/agents/function_handlers_e2e.py — deterministic chat_tutor handler (new)
  • backend/routes/learn.py_resolve_model_pref respects SAPLING_MODEL_MODE
  • backend/tests/test_e2e_function_handlers.py — 6 unit tests (new)

Part of #402, closes#392

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for loading function-mode tutor handlers during startup and dispatch.
    • Added deterministic tutor behavior for end-to-end testing.
    • Added a test identifier for resuming recent tutor sessions.
  • Bug Fixes

    • Prevented live model overrides from being created in non-real modes.
    • Improved missing or invalid handler errors.
  • Tests

    • Added coverage for handler loading, routing, session persistence, and encrypted message storage.
  • Documentation

    • Updated tutor end-to-end testing and test-identifier guidance.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging70441efCommit Preview URL

Branch Preview URL
Jul 28 2026, 03:25 AM

@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: faa906ad-a112-466d-9982-90b1ee54e01d

📥 Commits

Reviewing files that changed from the base of the PR and between 30161b2 and 70441ef.

📒 Files selected for processing (11)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/support/db.ts
  • frontend/e2e/support/decrypt.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx
📝 Walkthrough

Walkthrough

The PR adds environment-based function-handler loading, deterministic tutor responses, non-real model routing, and browser E2E coverage for tutor rendering and encrypted message persistence.

Changes

Deterministic tutor E2E flow

Layer / File(s)Summary
Function-handler loading and dispatch
backend/agents/_providers.py, backend/agents/function_handlers_e2e.py, backend/tests/test_e2e_function_handlers.py
Function-mode dispatch lazily imports the configured handler module, registers the deterministic chat_tutor handler, preserves explicit registrations, and tests missing or failed imports.
Model-mode routing
backend/routes/learn.py, backend/tests/test_e2e_function_handlers.py
Model preference overrides are skipped outside real mode and continue producing Gemini overrides in real mode.
Tutor journey and encrypted persistence assertions
frontend/e2e/support/*, frontend/e2e/tutor.spec.ts, frontend/src/components/screens/Learn.tsx, frontend/eslint.*, docs/frontend-testids.md
The browser journey resumes a seeded session, submits a message, verifies the deterministic reply, queries encrypted database rows, decrypts them through backend logic, and uses documented session-resume test IDs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant TutorE2E
participant Learn
participant Backend
participant Database
participant DecryptHelper
TutorE2E->>Learn: Resume seeded session
Learn->>Backend: Submit tutor message
Backend->>Database: Store encrypted messages
Backend->>Learn: Return deterministic tutor reply
TutorE2E->>Database: Read session messages
TutorE2E->>DecryptHelper: Decrypt stored content
DecryptHelper->>TutorE2E: Return plaintext values
Loading

Possibly related PRs

  • SaplingLearn/Sapling#437: Modifies the same backend function-handler loading and tutor handler registration paths, including model-mode routing.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change: an e2e tutor journey persisting conversation data to messages.
Description check✅ PassedThe description is detailed and covers the summary, related issues, testing, and reviewer notes, even if the headings differ from the template.
Docstring Coverage✅ PassedDocstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/392-journey-tutor-persist

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

import pytest
from pydantic_ai.models.google import GoogleModel

import agents._providers as providers
Comment threadbackend/agents/_providers.py Fixed
AndresL230 added a commit that referenced this pull request Jul 28, 2026
…port (#434 review)
Review fixes on PR #434:
- _providers.py latched _ENV_HANDLERS_LOADED BEFORE importing the env
module, permanently caching a failed import as loaded: the first
dispatch raised ModuleNotFoundError correctly, every later one silently
downgraded to the generic LookupError. Latch now sets only after a
successful import (or when the var is unset), so a broken boot fails
loudly on EVERY dispatch. Regression test dispatches twice against a
bad module path within one latch lifetime (verified red on the old
order, green on the fix).
- Learn.tsx joins the eslint testid enforcement files array; its 21
pre-existing untagged elements are baselined via npm run lint:baseline
(the documented legacy-debt mechanism, matching #429's Dashboard.tsx
treatment) instead of the prose lint exception, and the doc's Tutor
table row now names the ChatPanel/Learn.tsx split.
- Qualified the "pytest lane never sets the var / never imports it"
comments to the normal (hermetic) lane — the seam's own tests do both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the test/392-journey-tutor-persist branch from 83e7951 to 30161b2CompareJuly 28, 2026 03:06
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review fixes pushed (30161b2, rebased onto 385a534/#431 — the verbatim global-setup.ts commit dropped as already-upstream):

  1. Latch bug (required): fixed._ENV_HANDLERS_LOADED now sets only after a successful import (or when the var is unset), so a bad SAPLING_FUNCTION_HANDLERS path raises ModuleNotFoundError on every dispatch instead of downgrading to the generic LookupError after the first. Regression test test_bad_env_module_path_fails_loudly_on_every_dispatch dispatches twice within one latch lifetime — verified red against the old latch order, green on the fix.
  2. Testid mechanism: converged on the suppressions baseline.screens/Learn.tsx joins the eslint files array; npm run lint:baseline produced exactly one new entry (no-restricted-syntax: 21 for Learn.tsx — 22 intrinsic elements minus the newly tagged resume button, no other counts moved). The prose lint exception is gone and the Tutor table row now names the ChatPanel/Learn.tsx split, Sign-in-row style.
  3. Comment accuracy: the "pytest lane never sets the var / never imports it" claims are qualified to the normal (hermetic) lane, with the seam's own tests called out as the deliberate exception.

Re-verification: backend 1076 passed, 1 skipped (+1 regression test), ruff/tsc/eslint (0 errors)/vitest (204 passed) clean, and a fresh lock-held function-mode confirmation cycle on the rebased branch: 3/3 consecutive e2e/tutor.spec.ts runs green (7.4s/5.9s/5.5s), clean teardown. The original 10/10 tally stands as acceptance evidence.

module = (os.getenv("SAPLING_FUNCTION_HANDLERS") or "").strip()
if module:
importlib.import_module(module) # raises before the latch on failure
_ENV_HANDLERS_LOADED = True

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/e2e/support/db.ts`:
- Around line 155-159: Update queryRaw to execute client.query(sql, params)
inside a read-only transaction, ensuring the transaction is explicitly
configured as read-only before returning rows. Preserve the existing withDb
usage, parameters, and result shape while preventing mutation statements through
this helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 179f9b80-dec5-417c-8b08-827b9a21f412

📥 Commits

Reviewing files that changed from the base of the PR and between 385a534 and 30161b2.

📒 Files selected for processing (11)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/support/db.ts
  • frontend/e2e/support/decrypt.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx

Comment on lines +155 to +159
export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
return withDb(async (client) => (await client.query(sql, params)).rows);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Enforce the read-only query contract.

This accepts arbitrary SQL despite being documented as SELECT-only, so a future E2E assertion can mutate or truncate the local database through this helper. Execute it in a read-only transaction.

Proposed fix
 export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
- return withDb(async (client) => (await client.query(sql, params)).rows);+ return withDb(async (client) => {+ await client.query("BEGIN READ ONLY");+ try {+ return (await client.query(sql, params)).rows;+ } finally {+ await client.query("ROLLBACK");+ }+ });
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportasyncfunctionqueryRaw(
sql: string,
params: unknown[]=[],
): Promise<Record<string,unknown>[]>{
returnwithDb(async(client)=>(awaitclient.query(sql,params)).rows);
exportasyncfunctionqueryRaw(
sql: string,
params: unknown[]=[],
): Promise<Record<string,unknown>[]>{
returnwithDb(async(client)=>{
awaitclient.query("BEGIN READ ONLY");
try{
return(awaitclient.query(sql,params)).rows;
}finally{
awaitclient.query("ROLLBACK");
}
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/e2e/support/db.ts` around lines 155 - 159, Update queryRaw to
execute client.query(sql, params) inside a read-only transaction, ensuring the
transaction is explicitly configured as read-only before returning rows.
Preserve the existing withDb usage, parameters, and result shape while
preventing mutation statements through this helper.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance. (Three review findings were fixed in-PR: the env-handlers failure latch now sets only after a successful import — regression-tested red→green with a dispatch-twice test; Learn.tsx joined eslint testid enforcement via the suppressions baseline; comment claims qualified.)

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 27, 2026 20:20
Browser journey: resume the seeded recursion session, send a message
through the real composer, assert the deterministic reply renders, then
raw-SQL readback of the two new `messages` rows proving the encryption
boundary (content is ciphertext at rest and decrypts to the sent text).
Deterministic model responses via the #391 seam, extended for
out-of-process runs:
- agents/_providers.py: SAPLING_FUNCTION_HANDLERS names a module imported
lazily (once) on a dispatch miss, so the E2E uvicorn process can
register handlers at boot; unset var = #391 behavior unchanged.
- agents/function_handlers_e2e.py: fixed-reply chat_tutor handler the E2E
stack points the var at.
- routes/learn.py::_resolve_model_pref: in any non-real mode, the
browser's fast/smart pref no longer builds a live GoogleModel override
(ModelToggle defaults to "fast", so every browser turn sends a pref —
this silently bypassed the seam and dialed real Gemini).
- tutor-session-resume-{sessionId} testid on the Learn screen's session
rows (documented under the signin-trigger lint exception): the journey
enters the chat by resuming, because start-session still runs on the
legacy call_gemini_multiturn path the seam does not cover.
- e2e/support/db.ts queryRaw (parameterized SELECT behind the loopback
guard) + e2e/support/decrypt.ts (shells the backend venv's
decrypt_if_present, mirroring the integration-suite readback pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…port (#434 review)
Review fixes on PR #434:
- _providers.py latched _ENV_HANDLERS_LOADED BEFORE importing the env
module, permanently caching a failed import as loaded: the first
dispatch raised ModuleNotFoundError correctly, every later one silently
downgraded to the generic LookupError. Latch now sets only after a
successful import (or when the var is unset), so a broken boot fails
loudly on EVERY dispatch. Regression test dispatches twice against a
bad module path within one latch lifetime (verified red on the old
order, green on the fix).
- Learn.tsx joins the eslint testid enforcement files array; its 21
pre-existing untagged elements are baselined via npm run lint:baseline
(the documented legacy-debt mechanism, matching #429's Dashboard.tsx
treatment) instead of the prose lint exception, and the doc's Tutor
table row now names the ChatPanel/Learn.tsx split.
- Qualified the "pytest lane never sets the var / never imports it"
comments to the normal (hermetic) lane — the seam's own tests do both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(e2e): journey — tutor conversation persists to messages

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

test(e2e): journey — tutor conversation persists to messages (#392) - #434

Merged
AndresL230 merged 2 commits into
mainfrom
test/392-journey-tutor-persist
Jul 28, 2026
Merged

test(e2e): journey — tutor conversation persists to messages (#392)#434
AndresL230 merged 2 commits into
mainfrom
test/392-journey-tutor-persist

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Browser journey for the tutor chat (#392): resume the seeded rich-sess-cs-recursion session on /learn, send a message through the real composer (tutor-inputtutor-send), assert the deterministic reply renders in tutor-messages, then raw-SQL readback of messages proving the encryption boundary:

  • exactly 2 new rows for the session (seeded 4 → 6; a double-write from an accidental legacy fallback would fail the count),
  • content at rest is ciphertext (≠ the sent plaintext / rendered reply), and
  • both rows decrypt — via the backend's own decrypt_if_present, shelled through the backend venv — to exactly the sent text and the deterministic reply. Both directions are asserted because decrypt_if_present echoes plaintext input back (legacy tolerance), so decrypt-equality alone can't prove encryption.

No SSE handling and zero waitForTimeout: sendChat is a plain fetch returning the full { reply, … } object; assertions wait on the rendered reply locator.

Boot contract

SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up

scripts/e2e-up.sh does not scrub the environment — exported vars flow through make into the setsid venv/bin/python -m uvicorn launch, and backend/.env doesn't define SAPLING_*, so no sanctioned-passing workaround was needed. The second var is new (see below).

Seam findings (#391 coverage of the tutor path)

  1. POST /api/learn/chat IS covered by the seam — it runs chat_tutor (pydantic-ai) via _chat_via_agent, whose model comes from model_for("chat_tutor").
  2. …but the browser could never reach the seam before this PR.routes/learn.py::_resolve_model_pref built a live GoogleModel via google_model(name) for the per-request fast/smart pref, passed as agent.run(model=…) — overriding the FunctionModel. The frontend always sends a pref (ModelToggle's useModelPref defaults to "fast"), so in function mode every browser tutor turn would still have dialed real Gemini. Fixed here: in any non-real mode the pref resolves to None (the agent default, already mode-correct). real mode is byte-for-byte unchanged.
  3. POST /api/learn/start-session is NOT covered — it still runs the legacy services/gemini_service.py::call_gemini_multiturn path (explicit TODO(refactor-3 follow-up) in the route). The journey therefore enters the chat by resuming a seeded session, never via "Start learning". Same applies to /action and /mode-switch (both legacy).
  4. Handler registration for out-of-process runs did not exist.feat(backend): pydantic-ai test seam via FunctionModel (SAPLING_MODEL_MODE) #391's register_function_handler is in-process only; ADR 0019 anticipated E2E lanes would "set the env at process start, then register per-task handlers" but shipped no mechanism for a separate uvicorn process. Added additively: SAPLING_FUNCTION_HANDLERS=<module> is imported lazily, once, only on a dispatch miss — the pytest lane (env unset, explicit registration) is unchanged, a missing handler still raises the pointed LookupError, explicit registrations always win, and a typo'd module path fails loudly (ImportError) instead of running handler-less. agents/function_handlers_e2e.py holds the fixed-reply chat_tutor handler; other journeys should append theirs there.
  5. Residual live-Gemini attempt (not a dependency): RAG query embedding._chat_via_agent calls rag_service.retrieve_chunks whenever the session's course has a course_code (seeded CS101 does), and _embed_query hits gemini-embedding-001 outside any seam. retrieve_chunks swallows every exception and returns [], so the journey's outcome does not depend on it — but each E2E tutor turn still attempts one billed embedding call when backend/.env carries a real key. Worth a follow-up (seam or env kill-switch for embeddings).

Verification

  • 10 consecutive local runs green (single lock hold, fresh function-mode stack boot from this branch's worktree): runs 1–10 all 1 passed, 4.5–5.9s each, zero failed/flaky/retried, clean teardown. Boot line used: the contract above.
  • Backend unit suite in the worktree: 1075 passed, 1 skipped (includes 6 new tests in tests/test_e2e_function_handlers.py: env-module autoload end-to-end through the real socratic_agent, unset-var LookupError posture, bad-module loud failure, explicit-registration precedence, and _resolve_model_pref mode behavior both ways). ruff check clean on changed files.
  • tsc --noEmit and eslint clean on changed frontend files (one pre-existing baselined warning in Learn.tsx).
  • Encrypt→decrypt round-trip of the exact helper snippet verified against the local stack's ENCRYPTION_KEY before the runs.

Testid note (deviation, with precedent)

The journey needs one new testid, tutor-session-resume-{sessionId}, on the Learn screen's session rows — but screens/Learn.tsx has ~20 intrinsic interactive elements outside any browser journey, so adding it to the eslint files array would force testids on all of them. Followed the documented signin-trigger exception instead: testid added + inventoried in docs/frontend-testids.md with the exception noted, file stays out of the lint block.

The AI-disclosure DisclaimerModal (fixed overlay, no testids) is pre-acked via addInitScript (models a returning user); dismissing it by copy would violate the no-copy-anchoring rule and tagging it was out of scope.

Cross-PR convergence notes

  • frontend/e2e/global-setup.ts gained the sapling_user localStorage entry copied verbatim from test/386-journey-dashboard (per the harness-owner heads-up): the cookie-only storageState never populates UserContext (it bootstraps identity solely from that localStorage key), so the Learn screen's session list — which this journey resumes from — stays empty without it. Identical content on both branches, so the merge is clean whichever lands first.
  • At least one sibling journey PR boots with the same SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e contract and will carry its own version of the handlers module / _providers.py autoload. These were authored in isolated worktrees — whoever merges second should converge to one module (append handlers per task) rather than duplicate the mechanism.

Files

  • frontend/e2e/tutor.spec.ts — the journey (new)
  • frontend/e2e/support/decrypt.ts — backend-venv decrypt seam (new)
  • frontend/e2e/support/db.ts — additive queryRaw (parameterized SELECTs behind the existing loopback guard)
  • frontend/src/components/screens/Learn.tsxtutor-session-resume-{sessionId}
  • docs/frontend-testids.md — inventory + lint-exception note (append-only)
  • backend/agents/_providers.pySAPLING_FUNCTION_HANDLERS lazy autoload on dispatch miss
  • backend/agents/function_handlers_e2e.py — deterministic chat_tutor handler (new)
  • backend/routes/learn.py_resolve_model_pref respects SAPLING_MODEL_MODE
  • backend/tests/test_e2e_function_handlers.py — 6 unit tests (new)

Part of #402, closes#392

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for loading function-mode tutor handlers during startup and dispatch.
    • Added deterministic tutor behavior for end-to-end testing.
    • Added a test identifier for resuming recent tutor sessions.
  • Bug Fixes

    • Prevented live model overrides from being created in non-real modes.
    • Improved missing or invalid handler errors.
  • Tests

    • Added coverage for handler loading, routing, session persistence, and encrypted message storage.
  • Documentation

    • Updated tutor end-to-end testing and test-identifier guidance.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging70441efCommit Preview URL

Branch Preview URL
Jul 28 2026, 03:25 AM

@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: faa906ad-a112-466d-9982-90b1ee54e01d

📥 Commits

Reviewing files that changed from the base of the PR and between 30161b2 and 70441ef.

📒 Files selected for processing (11)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/support/db.ts
  • frontend/e2e/support/decrypt.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx
📝 Walkthrough

Walkthrough

The PR adds environment-based function-handler loading, deterministic tutor responses, non-real model routing, and browser E2E coverage for tutor rendering and encrypted message persistence.

Changes

Deterministic tutor E2E flow

Layer / File(s)Summary
Function-handler loading and dispatch
backend/agents/_providers.py, backend/agents/function_handlers_e2e.py, backend/tests/test_e2e_function_handlers.py
Function-mode dispatch lazily imports the configured handler module, registers the deterministic chat_tutor handler, preserves explicit registrations, and tests missing or failed imports.
Model-mode routing
backend/routes/learn.py, backend/tests/test_e2e_function_handlers.py
Model preference overrides are skipped outside real mode and continue producing Gemini overrides in real mode.
Tutor journey and encrypted persistence assertions
frontend/e2e/support/*, frontend/e2e/tutor.spec.ts, frontend/src/components/screens/Learn.tsx, frontend/eslint.*, docs/frontend-testids.md
The browser journey resumes a seeded session, submits a message, verifies the deterministic reply, queries encrypted database rows, decrypts them through backend logic, and uses documented session-resume test IDs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant TutorE2E
participant Learn
participant Backend
participant Database
participant DecryptHelper
TutorE2E->>Learn: Resume seeded session
Learn->>Backend: Submit tutor message
Backend->>Database: Store encrypted messages
Backend->>Learn: Return deterministic tutor reply
TutorE2E->>Database: Read session messages
TutorE2E->>DecryptHelper: Decrypt stored content
DecryptHelper->>TutorE2E: Return plaintext values
Loading

Possibly related PRs

  • SaplingLearn/Sapling#437: Modifies the same backend function-handler loading and tutor handler registration paths, including model-mode routing.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change: an e2e tutor journey persisting conversation data to messages.
Description check✅ PassedThe description is detailed and covers the summary, related issues, testing, and reviewer notes, even if the headings differ from the template.
Docstring Coverage✅ PassedDocstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/392-journey-tutor-persist

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

import pytest
from pydantic_ai.models.google import GoogleModel

import agents._providers as providers
Comment threadbackend/agents/_providers.py Fixed
AndresL230 added a commit that referenced this pull request Jul 28, 2026
…port (#434 review)
Review fixes on PR #434:
- _providers.py latched _ENV_HANDLERS_LOADED BEFORE importing the env
module, permanently caching a failed import as loaded: the first
dispatch raised ModuleNotFoundError correctly, every later one silently
downgraded to the generic LookupError. Latch now sets only after a
successful import (or when the var is unset), so a broken boot fails
loudly on EVERY dispatch. Regression test dispatches twice against a
bad module path within one latch lifetime (verified red on the old
order, green on the fix).
- Learn.tsx joins the eslint testid enforcement files array; its 21
pre-existing untagged elements are baselined via npm run lint:baseline
(the documented legacy-debt mechanism, matching #429's Dashboard.tsx
treatment) instead of the prose lint exception, and the doc's Tutor
table row now names the ChatPanel/Learn.tsx split.
- Qualified the "pytest lane never sets the var / never imports it"
comments to the normal (hermetic) lane — the seam's own tests do both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the test/392-journey-tutor-persist branch from 83e7951 to 30161b2CompareJuly 28, 2026 03:06
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review fixes pushed (30161b2, rebased onto 385a534/#431 — the verbatim global-setup.ts commit dropped as already-upstream):

  1. Latch bug (required): fixed._ENV_HANDLERS_LOADED now sets only after a successful import (or when the var is unset), so a bad SAPLING_FUNCTION_HANDLERS path raises ModuleNotFoundError on every dispatch instead of downgrading to the generic LookupError after the first. Regression test test_bad_env_module_path_fails_loudly_on_every_dispatch dispatches twice within one latch lifetime — verified red against the old latch order, green on the fix.
  2. Testid mechanism: converged on the suppressions baseline.screens/Learn.tsx joins the eslint files array; npm run lint:baseline produced exactly one new entry (no-restricted-syntax: 21 for Learn.tsx — 22 intrinsic elements minus the newly tagged resume button, no other counts moved). The prose lint exception is gone and the Tutor table row now names the ChatPanel/Learn.tsx split, Sign-in-row style.
  3. Comment accuracy: the "pytest lane never sets the var / never imports it" claims are qualified to the normal (hermetic) lane, with the seam's own tests called out as the deliberate exception.

Re-verification: backend 1076 passed, 1 skipped (+1 regression test), ruff/tsc/eslint (0 errors)/vitest (204 passed) clean, and a fresh lock-held function-mode confirmation cycle on the rebased branch: 3/3 consecutive e2e/tutor.spec.ts runs green (7.4s/5.9s/5.5s), clean teardown. The original 10/10 tally stands as acceptance evidence.

module = (os.getenv("SAPLING_FUNCTION_HANDLERS") or "").strip()
if module:
importlib.import_module(module) # raises before the latch on failure
_ENV_HANDLERS_LOADED = True

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/e2e/support/db.ts`:
- Around line 155-159: Update queryRaw to execute client.query(sql, params)
inside a read-only transaction, ensuring the transaction is explicitly
configured as read-only before returning rows. Preserve the existing withDb
usage, parameters, and result shape while preventing mutation statements through
this helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 179f9b80-dec5-417c-8b08-827b9a21f412

📥 Commits

Reviewing files that changed from the base of the PR and between 385a534 and 30161b2.

📒 Files selected for processing (11)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/support/db.ts
  • frontend/e2e/support/decrypt.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx

Comment on lines +155 to +159
export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
return withDb(async (client) => (await client.query(sql, params)).rows);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Enforce the read-only query contract.

This accepts arbitrary SQL despite being documented as SELECT-only, so a future E2E assertion can mutate or truncate the local database through this helper. Execute it in a read-only transaction.

Proposed fix
 export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
- return withDb(async (client) => (await client.query(sql, params)).rows);+ return withDb(async (client) => {+ await client.query("BEGIN READ ONLY");+ try {+ return (await client.query(sql, params)).rows;+ } finally {+ await client.query("ROLLBACK");+ }+ });
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportasyncfunctionqueryRaw(
sql: string,
params: unknown[]=[],
): Promise<Record<string,unknown>[]>{
returnwithDb(async(client)=>(awaitclient.query(sql,params)).rows);
exportasyncfunctionqueryRaw(
sql: string,
params: unknown[]=[],
): Promise<Record<string,unknown>[]>{
returnwithDb(async(client)=>{
awaitclient.query("BEGIN READ ONLY");
try{
return(awaitclient.query(sql,params)).rows;
}finally{
awaitclient.query("ROLLBACK");
}
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/e2e/support/db.ts` around lines 155 - 159, Update queryRaw to
execute client.query(sql, params) inside a read-only transaction, ensuring the
transaction is explicitly configured as read-only before returning rows.
Preserve the existing withDb usage, parameters, and result shape while
preventing mutation statements through this helper.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance. (Three review findings were fixed in-PR: the env-handlers failure latch now sets only after a successful import — regression-tested red→green with a dispatch-twice test; Learn.tsx joined eslint testid enforcement via the suppressions baseline; comment claims qualified.)

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 27, 2026 20:20
Browser journey: resume the seeded recursion session, send a message
through the real composer, assert the deterministic reply renders, then
raw-SQL readback of the two new `messages` rows proving the encryption
boundary (content is ciphertext at rest and decrypts to the sent text).
Deterministic model responses via the #391 seam, extended for
out-of-process runs:
- agents/_providers.py: SAPLING_FUNCTION_HANDLERS names a module imported
lazily (once) on a dispatch miss, so the E2E uvicorn process can
register handlers at boot; unset var = #391 behavior unchanged.
- agents/function_handlers_e2e.py: fixed-reply chat_tutor handler the E2E
stack points the var at.
- routes/learn.py::_resolve_model_pref: in any non-real mode, the
browser's fast/smart pref no longer builds a live GoogleModel override
(ModelToggle defaults to "fast", so every browser turn sends a pref —
this silently bypassed the seam and dialed real Gemini).
- tutor-session-resume-{sessionId} testid on the Learn screen's session
rows (documented under the signin-trigger lint exception): the journey
enters the chat by resuming, because start-session still runs on the
legacy call_gemini_multiturn path the seam does not cover.
- e2e/support/db.ts queryRaw (parameterized SELECT behind the loopback
guard) + e2e/support/decrypt.ts (shells the backend venv's
decrypt_if_present, mirroring the integration-suite readback pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…port (#434 review)
Review fixes on PR #434:
- _providers.py latched _ENV_HANDLERS_LOADED BEFORE importing the env
module, permanently caching a failed import as loaded: the first
dispatch raised ModuleNotFoundError correctly, every later one silently
downgraded to the generic LookupError. Latch now sets only after a
successful import (or when the var is unset), so a broken boot fails
loudly on EVERY dispatch. Regression test dispatches twice against a
bad module path within one latch lifetime (verified red on the old
order, green on the fix).
- Learn.tsx joins the eslint testid enforcement files array; its 21
pre-existing untagged elements are baselined via npm run lint:baseline
(the documented legacy-debt mechanism, matching #429's Dashboard.tsx
treatment) instead of the prose lint exception, and the doc's Tutor
table row now names the ChatPanel/Learn.tsx split.
- Qualified the "pytest lane never sets the var / never imports it"
comments to the normal (hermetic) lane — the seam's own tests do both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(e2e): journey — tutor conversation persists to messages

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

test(e2e): journey — tutor conversation persists to messages (#392) - #434

Merged
AndresL230 merged 2 commits into
mainfrom
test/392-journey-tutor-persist
Jul 28, 2026
Merged

test(e2e): journey — tutor conversation persists to messages (#392)#434
AndresL230 merged 2 commits into
mainfrom
test/392-journey-tutor-persist

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Browser journey for the tutor chat (#392): resume the seeded rich-sess-cs-recursion session on /learn, send a message through the real composer (tutor-inputtutor-send), assert the deterministic reply renders in tutor-messages, then raw-SQL readback of messages proving the encryption boundary:

  • exactly 2 new rows for the session (seeded 4 → 6; a double-write from an accidental legacy fallback would fail the count),
  • content at rest is ciphertext (≠ the sent plaintext / rendered reply), and
  • both rows decrypt — via the backend's own decrypt_if_present, shelled through the backend venv — to exactly the sent text and the deterministic reply. Both directions are asserted because decrypt_if_present echoes plaintext input back (legacy tolerance), so decrypt-equality alone can't prove encryption.

No SSE handling and zero waitForTimeout: sendChat is a plain fetch returning the full { reply, … } object; assertions wait on the rendered reply locator.

Boot contract

SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up

scripts/e2e-up.sh does not scrub the environment — exported vars flow through make into the setsid venv/bin/python -m uvicorn launch, and backend/.env doesn't define SAPLING_*, so no sanctioned-passing workaround was needed. The second var is new (see below).

Seam findings (#391 coverage of the tutor path)

  1. POST /api/learn/chat IS covered by the seam — it runs chat_tutor (pydantic-ai) via _chat_via_agent, whose model comes from model_for("chat_tutor").
  2. …but the browser could never reach the seam before this PR.routes/learn.py::_resolve_model_pref built a live GoogleModel via google_model(name) for the per-request fast/smart pref, passed as agent.run(model=…) — overriding the FunctionModel. The frontend always sends a pref (ModelToggle's useModelPref defaults to "fast"), so in function mode every browser tutor turn would still have dialed real Gemini. Fixed here: in any non-real mode the pref resolves to None (the agent default, already mode-correct). real mode is byte-for-byte unchanged.
  3. POST /api/learn/start-session is NOT covered — it still runs the legacy services/gemini_service.py::call_gemini_multiturn path (explicit TODO(refactor-3 follow-up) in the route). The journey therefore enters the chat by resuming a seeded session, never via "Start learning". Same applies to /action and /mode-switch (both legacy).
  4. Handler registration for out-of-process runs did not exist.feat(backend): pydantic-ai test seam via FunctionModel (SAPLING_MODEL_MODE) #391's register_function_handler is in-process only; ADR 0019 anticipated E2E lanes would "set the env at process start, then register per-task handlers" but shipped no mechanism for a separate uvicorn process. Added additively: SAPLING_FUNCTION_HANDLERS=<module> is imported lazily, once, only on a dispatch miss — the pytest lane (env unset, explicit registration) is unchanged, a missing handler still raises the pointed LookupError, explicit registrations always win, and a typo'd module path fails loudly (ImportError) instead of running handler-less. agents/function_handlers_e2e.py holds the fixed-reply chat_tutor handler; other journeys should append theirs there.
  5. Residual live-Gemini attempt (not a dependency): RAG query embedding._chat_via_agent calls rag_service.retrieve_chunks whenever the session's course has a course_code (seeded CS101 does), and _embed_query hits gemini-embedding-001 outside any seam. retrieve_chunks swallows every exception and returns [], so the journey's outcome does not depend on it — but each E2E tutor turn still attempts one billed embedding call when backend/.env carries a real key. Worth a follow-up (seam or env kill-switch for embeddings).

Verification

  • 10 consecutive local runs green (single lock hold, fresh function-mode stack boot from this branch's worktree): runs 1–10 all 1 passed, 4.5–5.9s each, zero failed/flaky/retried, clean teardown. Boot line used: the contract above.
  • Backend unit suite in the worktree: 1075 passed, 1 skipped (includes 6 new tests in tests/test_e2e_function_handlers.py: env-module autoload end-to-end through the real socratic_agent, unset-var LookupError posture, bad-module loud failure, explicit-registration precedence, and _resolve_model_pref mode behavior both ways). ruff check clean on changed files.
  • tsc --noEmit and eslint clean on changed frontend files (one pre-existing baselined warning in Learn.tsx).
  • Encrypt→decrypt round-trip of the exact helper snippet verified against the local stack's ENCRYPTION_KEY before the runs.

Testid note (deviation, with precedent)

The journey needs one new testid, tutor-session-resume-{sessionId}, on the Learn screen's session rows — but screens/Learn.tsx has ~20 intrinsic interactive elements outside any browser journey, so adding it to the eslint files array would force testids on all of them. Followed the documented signin-trigger exception instead: testid added + inventoried in docs/frontend-testids.md with the exception noted, file stays out of the lint block.

The AI-disclosure DisclaimerModal (fixed overlay, no testids) is pre-acked via addInitScript (models a returning user); dismissing it by copy would violate the no-copy-anchoring rule and tagging it was out of scope.

Cross-PR convergence notes

  • frontend/e2e/global-setup.ts gained the sapling_user localStorage entry copied verbatim from test/386-journey-dashboard (per the harness-owner heads-up): the cookie-only storageState never populates UserContext (it bootstraps identity solely from that localStorage key), so the Learn screen's session list — which this journey resumes from — stays empty without it. Identical content on both branches, so the merge is clean whichever lands first.
  • At least one sibling journey PR boots with the same SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e contract and will carry its own version of the handlers module / _providers.py autoload. These were authored in isolated worktrees — whoever merges second should converge to one module (append handlers per task) rather than duplicate the mechanism.

Files

  • frontend/e2e/tutor.spec.ts — the journey (new)
  • frontend/e2e/support/decrypt.ts — backend-venv decrypt seam (new)
  • frontend/e2e/support/db.ts — additive queryRaw (parameterized SELECTs behind the existing loopback guard)
  • frontend/src/components/screens/Learn.tsxtutor-session-resume-{sessionId}
  • docs/frontend-testids.md — inventory + lint-exception note (append-only)
  • backend/agents/_providers.pySAPLING_FUNCTION_HANDLERS lazy autoload on dispatch miss
  • backend/agents/function_handlers_e2e.py — deterministic chat_tutor handler (new)
  • backend/routes/learn.py_resolve_model_pref respects SAPLING_MODEL_MODE
  • backend/tests/test_e2e_function_handlers.py — 6 unit tests (new)

Part of #402, closes#392

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for loading function-mode tutor handlers during startup and dispatch.
    • Added deterministic tutor behavior for end-to-end testing.
    • Added a test identifier for resuming recent tutor sessions.
  • Bug Fixes

    • Prevented live model overrides from being created in non-real modes.
    • Improved missing or invalid handler errors.
  • Tests

    • Added coverage for handler loading, routing, session persistence, and encrypted message storage.
  • Documentation

    • Updated tutor end-to-end testing and test-identifier guidance.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging70441efCommit Preview URL

Branch Preview URL
Jul 28 2026, 03:25 AM

@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: faa906ad-a112-466d-9982-90b1ee54e01d

📥 Commits

Reviewing files that changed from the base of the PR and between 30161b2 and 70441ef.

📒 Files selected for processing (11)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/support/db.ts
  • frontend/e2e/support/decrypt.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx
📝 Walkthrough

Walkthrough

The PR adds environment-based function-handler loading, deterministic tutor responses, non-real model routing, and browser E2E coverage for tutor rendering and encrypted message persistence.

Changes

Deterministic tutor E2E flow

Layer / File(s)Summary
Function-handler loading and dispatch
backend/agents/_providers.py, backend/agents/function_handlers_e2e.py, backend/tests/test_e2e_function_handlers.py
Function-mode dispatch lazily imports the configured handler module, registers the deterministic chat_tutor handler, preserves explicit registrations, and tests missing or failed imports.
Model-mode routing
backend/routes/learn.py, backend/tests/test_e2e_function_handlers.py
Model preference overrides are skipped outside real mode and continue producing Gemini overrides in real mode.
Tutor journey and encrypted persistence assertions
frontend/e2e/support/*, frontend/e2e/tutor.spec.ts, frontend/src/components/screens/Learn.tsx, frontend/eslint.*, docs/frontend-testids.md
The browser journey resumes a seeded session, submits a message, verifies the deterministic reply, queries encrypted database rows, decrypts them through backend logic, and uses documented session-resume test IDs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant TutorE2E
participant Learn
participant Backend
participant Database
participant DecryptHelper
TutorE2E->>Learn: Resume seeded session
Learn->>Backend: Submit tutor message
Backend->>Database: Store encrypted messages
Backend->>Learn: Return deterministic tutor reply
TutorE2E->>Database: Read session messages
TutorE2E->>DecryptHelper: Decrypt stored content
DecryptHelper->>TutorE2E: Return plaintext values
Loading

Possibly related PRs

  • SaplingLearn/Sapling#437: Modifies the same backend function-handler loading and tutor handler registration paths, including model-mode routing.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change: an e2e tutor journey persisting conversation data to messages.
Description check✅ PassedThe description is detailed and covers the summary, related issues, testing, and reviewer notes, even if the headings differ from the template.
Docstring Coverage✅ PassedDocstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/392-journey-tutor-persist

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

import pytest
from pydantic_ai.models.google import GoogleModel

import agents._providers as providers
Comment threadbackend/agents/_providers.py Fixed
AndresL230 added a commit that referenced this pull request Jul 28, 2026
…port (#434 review)
Review fixes on PR #434:
- _providers.py latched _ENV_HANDLERS_LOADED BEFORE importing the env
module, permanently caching a failed import as loaded: the first
dispatch raised ModuleNotFoundError correctly, every later one silently
downgraded to the generic LookupError. Latch now sets only after a
successful import (or when the var is unset), so a broken boot fails
loudly on EVERY dispatch. Regression test dispatches twice against a
bad module path within one latch lifetime (verified red on the old
order, green on the fix).
- Learn.tsx joins the eslint testid enforcement files array; its 21
pre-existing untagged elements are baselined via npm run lint:baseline
(the documented legacy-debt mechanism, matching #429's Dashboard.tsx
treatment) instead of the prose lint exception, and the doc's Tutor
table row now names the ChatPanel/Learn.tsx split.
- Qualified the "pytest lane never sets the var / never imports it"
comments to the normal (hermetic) lane — the seam's own tests do both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the test/392-journey-tutor-persist branch from 83e7951 to 30161b2CompareJuly 28, 2026 03:06
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review fixes pushed (30161b2, rebased onto 385a534/#431 — the verbatim global-setup.ts commit dropped as already-upstream):

  1. Latch bug (required): fixed._ENV_HANDLERS_LOADED now sets only after a successful import (or when the var is unset), so a bad SAPLING_FUNCTION_HANDLERS path raises ModuleNotFoundError on every dispatch instead of downgrading to the generic LookupError after the first. Regression test test_bad_env_module_path_fails_loudly_on_every_dispatch dispatches twice within one latch lifetime — verified red against the old latch order, green on the fix.
  2. Testid mechanism: converged on the suppressions baseline.screens/Learn.tsx joins the eslint files array; npm run lint:baseline produced exactly one new entry (no-restricted-syntax: 21 for Learn.tsx — 22 intrinsic elements minus the newly tagged resume button, no other counts moved). The prose lint exception is gone and the Tutor table row now names the ChatPanel/Learn.tsx split, Sign-in-row style.
  3. Comment accuracy: the "pytest lane never sets the var / never imports it" claims are qualified to the normal (hermetic) lane, with the seam's own tests called out as the deliberate exception.

Re-verification: backend 1076 passed, 1 skipped (+1 regression test), ruff/tsc/eslint (0 errors)/vitest (204 passed) clean, and a fresh lock-held function-mode confirmation cycle on the rebased branch: 3/3 consecutive e2e/tutor.spec.ts runs green (7.4s/5.9s/5.5s), clean teardown. The original 10/10 tally stands as acceptance evidence.

module = (os.getenv("SAPLING_FUNCTION_HANDLERS") or "").strip()
if module:
importlib.import_module(module) # raises before the latch on failure
_ENV_HANDLERS_LOADED = True

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/e2e/support/db.ts`:
- Around line 155-159: Update queryRaw to execute client.query(sql, params)
inside a read-only transaction, ensuring the transaction is explicitly
configured as read-only before returning rows. Preserve the existing withDb
usage, parameters, and result shape while preventing mutation statements through
this helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 179f9b80-dec5-417c-8b08-827b9a21f412

📥 Commits

Reviewing files that changed from the base of the PR and between 385a534 and 30161b2.

📒 Files selected for processing (11)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/support/db.ts
  • frontend/e2e/support/decrypt.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx

Comment on lines +155 to +159
export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
return withDb(async (client) => (await client.query(sql, params)).rows);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Enforce the read-only query contract.

This accepts arbitrary SQL despite being documented as SELECT-only, so a future E2E assertion can mutate or truncate the local database through this helper. Execute it in a read-only transaction.

Proposed fix
 export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
- return withDb(async (client) => (await client.query(sql, params)).rows);+ return withDb(async (client) => {+ await client.query("BEGIN READ ONLY");+ try {+ return (await client.query(sql, params)).rows;+ } finally {+ await client.query("ROLLBACK");+ }+ });
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportasyncfunctionqueryRaw(
sql: string,
params: unknown[]=[],
): Promise<Record<string,unknown>[]>{
returnwithDb(async(client)=>(awaitclient.query(sql,params)).rows);
exportasyncfunctionqueryRaw(
sql: string,
params: unknown[]=[],
): Promise<Record<string,unknown>[]>{
returnwithDb(async(client)=>{
awaitclient.query("BEGIN READ ONLY");
try{
return(awaitclient.query(sql,params)).rows;
}finally{
awaitclient.query("ROLLBACK");
}
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/e2e/support/db.ts` around lines 155 - 159, Update queryRaw to
execute client.query(sql, params) inside a read-only transaction, ensuring the
transaction is explicitly configured as read-only before returning rows.
Preserve the existing withDb usage, parameters, and result shape while
preventing mutation statements through this helper.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance. (Three review findings were fixed in-PR: the env-handlers failure latch now sets only after a successful import — regression-tested red→green with a dispatch-twice test; Learn.tsx joined eslint testid enforcement via the suppressions baseline; comment claims qualified.)

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 27, 2026 20:20
Browser journey: resume the seeded recursion session, send a message
through the real composer, assert the deterministic reply renders, then
raw-SQL readback of the two new `messages` rows proving the encryption
boundary (content is ciphertext at rest and decrypts to the sent text).
Deterministic model responses via the #391 seam, extended for
out-of-process runs:
- agents/_providers.py: SAPLING_FUNCTION_HANDLERS names a module imported
lazily (once) on a dispatch miss, so the E2E uvicorn process can
register handlers at boot; unset var = #391 behavior unchanged.
- agents/function_handlers_e2e.py: fixed-reply chat_tutor handler the E2E
stack points the var at.
- routes/learn.py::_resolve_model_pref: in any non-real mode, the
browser's fast/smart pref no longer builds a live GoogleModel override
(ModelToggle defaults to "fast", so every browser turn sends a pref —
this silently bypassed the seam and dialed real Gemini).
- tutor-session-resume-{sessionId} testid on the Learn screen's session
rows (documented under the signin-trigger lint exception): the journey
enters the chat by resuming, because start-session still runs on the
legacy call_gemini_multiturn path the seam does not cover.
- e2e/support/db.ts queryRaw (parameterized SELECT behind the loopback
guard) + e2e/support/decrypt.ts (shells the backend venv's
decrypt_if_present, mirroring the integration-suite readback pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…port (#434 review)
Review fixes on PR #434:
- _providers.py latched _ENV_HANDLERS_LOADED BEFORE importing the env
module, permanently caching a failed import as loaded: the first
dispatch raised ModuleNotFoundError correctly, every later one silently
downgraded to the generic LookupError. Latch now sets only after a
successful import (or when the var is unset), so a broken boot fails
loudly on EVERY dispatch. Regression test dispatches twice against a
bad module path within one latch lifetime (verified red on the old
order, green on the fix).
- Learn.tsx joins the eslint testid enforcement files array; its 21
pre-existing untagged elements are baselined via npm run lint:baseline
(the documented legacy-debt mechanism, matching #429's Dashboard.tsx
treatment) instead of the prose lint exception, and the doc's Tutor
table row now names the ChatPanel/Learn.tsx split.
- Qualified the "pytest lane never sets the var / never imports it"
comments to the normal (hermetic) lane — the seam's own tests do both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(e2e): journey — tutor conversation persists to messages

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

test(e2e): journey — tutor conversation persists to messages (#392) - #434

Merged
AndresL230 merged 2 commits into
mainfrom
test/392-journey-tutor-persist
Jul 28, 2026
Merged

test(e2e): journey — tutor conversation persists to messages (#392)#434
AndresL230 merged 2 commits into
mainfrom
test/392-journey-tutor-persist

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Browser journey for the tutor chat (#392): resume the seeded rich-sess-cs-recursion session on /learn, send a message through the real composer (tutor-inputtutor-send), assert the deterministic reply renders in tutor-messages, then raw-SQL readback of messages proving the encryption boundary:

  • exactly 2 new rows for the session (seeded 4 → 6; a double-write from an accidental legacy fallback would fail the count),
  • content at rest is ciphertext (≠ the sent plaintext / rendered reply), and
  • both rows decrypt — via the backend's own decrypt_if_present, shelled through the backend venv — to exactly the sent text and the deterministic reply. Both directions are asserted because decrypt_if_present echoes plaintext input back (legacy tolerance), so decrypt-equality alone can't prove encryption.

No SSE handling and zero waitForTimeout: sendChat is a plain fetch returning the full { reply, … } object; assertions wait on the rendered reply locator.

Boot contract

SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up

scripts/e2e-up.sh does not scrub the environment — exported vars flow through make into the setsid venv/bin/python -m uvicorn launch, and backend/.env doesn't define SAPLING_*, so no sanctioned-passing workaround was needed. The second var is new (see below).

Seam findings (#391 coverage of the tutor path)

  1. POST /api/learn/chat IS covered by the seam — it runs chat_tutor (pydantic-ai) via _chat_via_agent, whose model comes from model_for("chat_tutor").
  2. …but the browser could never reach the seam before this PR.routes/learn.py::_resolve_model_pref built a live GoogleModel via google_model(name) for the per-request fast/smart pref, passed as agent.run(model=…) — overriding the FunctionModel. The frontend always sends a pref (ModelToggle's useModelPref defaults to "fast"), so in function mode every browser tutor turn would still have dialed real Gemini. Fixed here: in any non-real mode the pref resolves to None (the agent default, already mode-correct). real mode is byte-for-byte unchanged.
  3. POST /api/learn/start-session is NOT covered — it still runs the legacy services/gemini_service.py::call_gemini_multiturn path (explicit TODO(refactor-3 follow-up) in the route). The journey therefore enters the chat by resuming a seeded session, never via "Start learning". Same applies to /action and /mode-switch (both legacy).
  4. Handler registration for out-of-process runs did not exist.feat(backend): pydantic-ai test seam via FunctionModel (SAPLING_MODEL_MODE) #391's register_function_handler is in-process only; ADR 0019 anticipated E2E lanes would "set the env at process start, then register per-task handlers" but shipped no mechanism for a separate uvicorn process. Added additively: SAPLING_FUNCTION_HANDLERS=<module> is imported lazily, once, only on a dispatch miss — the pytest lane (env unset, explicit registration) is unchanged, a missing handler still raises the pointed LookupError, explicit registrations always win, and a typo'd module path fails loudly (ImportError) instead of running handler-less. agents/function_handlers_e2e.py holds the fixed-reply chat_tutor handler; other journeys should append theirs there.
  5. Residual live-Gemini attempt (not a dependency): RAG query embedding._chat_via_agent calls rag_service.retrieve_chunks whenever the session's course has a course_code (seeded CS101 does), and _embed_query hits gemini-embedding-001 outside any seam. retrieve_chunks swallows every exception and returns [], so the journey's outcome does not depend on it — but each E2E tutor turn still attempts one billed embedding call when backend/.env carries a real key. Worth a follow-up (seam or env kill-switch for embeddings).

Verification

  • 10 consecutive local runs green (single lock hold, fresh function-mode stack boot from this branch's worktree): runs 1–10 all 1 passed, 4.5–5.9s each, zero failed/flaky/retried, clean teardown. Boot line used: the contract above.
  • Backend unit suite in the worktree: 1075 passed, 1 skipped (includes 6 new tests in tests/test_e2e_function_handlers.py: env-module autoload end-to-end through the real socratic_agent, unset-var LookupError posture, bad-module loud failure, explicit-registration precedence, and _resolve_model_pref mode behavior both ways). ruff check clean on changed files.
  • tsc --noEmit and eslint clean on changed frontend files (one pre-existing baselined warning in Learn.tsx).
  • Encrypt→decrypt round-trip of the exact helper snippet verified against the local stack's ENCRYPTION_KEY before the runs.

Testid note (deviation, with precedent)

The journey needs one new testid, tutor-session-resume-{sessionId}, on the Learn screen's session rows — but screens/Learn.tsx has ~20 intrinsic interactive elements outside any browser journey, so adding it to the eslint files array would force testids on all of them. Followed the documented signin-trigger exception instead: testid added + inventoried in docs/frontend-testids.md with the exception noted, file stays out of the lint block.

The AI-disclosure DisclaimerModal (fixed overlay, no testids) is pre-acked via addInitScript (models a returning user); dismissing it by copy would violate the no-copy-anchoring rule and tagging it was out of scope.

Cross-PR convergence notes

  • frontend/e2e/global-setup.ts gained the sapling_user localStorage entry copied verbatim from test/386-journey-dashboard (per the harness-owner heads-up): the cookie-only storageState never populates UserContext (it bootstraps identity solely from that localStorage key), so the Learn screen's session list — which this journey resumes from — stays empty without it. Identical content on both branches, so the merge is clean whichever lands first.
  • At least one sibling journey PR boots with the same SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e contract and will carry its own version of the handlers module / _providers.py autoload. These were authored in isolated worktrees — whoever merges second should converge to one module (append handlers per task) rather than duplicate the mechanism.

Files

  • frontend/e2e/tutor.spec.ts — the journey (new)
  • frontend/e2e/support/decrypt.ts — backend-venv decrypt seam (new)
  • frontend/e2e/support/db.ts — additive queryRaw (parameterized SELECTs behind the existing loopback guard)
  • frontend/src/components/screens/Learn.tsxtutor-session-resume-{sessionId}
  • docs/frontend-testids.md — inventory + lint-exception note (append-only)
  • backend/agents/_providers.pySAPLING_FUNCTION_HANDLERS lazy autoload on dispatch miss
  • backend/agents/function_handlers_e2e.py — deterministic chat_tutor handler (new)
  • backend/routes/learn.py_resolve_model_pref respects SAPLING_MODEL_MODE
  • backend/tests/test_e2e_function_handlers.py — 6 unit tests (new)

Part of #402, closes#392

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for loading function-mode tutor handlers during startup and dispatch.
    • Added deterministic tutor behavior for end-to-end testing.
    • Added a test identifier for resuming recent tutor sessions.
  • Bug Fixes

    • Prevented live model overrides from being created in non-real modes.
    • Improved missing or invalid handler errors.
  • Tests

    • Added coverage for handler loading, routing, session persistence, and encrypted message storage.
  • Documentation

    • Updated tutor end-to-end testing and test-identifier guidance.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging70441efCommit Preview URL

Branch Preview URL
Jul 28 2026, 03:25 AM

@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: faa906ad-a112-466d-9982-90b1ee54e01d

📥 Commits

Reviewing files that changed from the base of the PR and between 30161b2 and 70441ef.

📒 Files selected for processing (11)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/support/db.ts
  • frontend/e2e/support/decrypt.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx
📝 Walkthrough

Walkthrough

The PR adds environment-based function-handler loading, deterministic tutor responses, non-real model routing, and browser E2E coverage for tutor rendering and encrypted message persistence.

Changes

Deterministic tutor E2E flow

Layer / File(s)Summary
Function-handler loading and dispatch
backend/agents/_providers.py, backend/agents/function_handlers_e2e.py, backend/tests/test_e2e_function_handlers.py
Function-mode dispatch lazily imports the configured handler module, registers the deterministic chat_tutor handler, preserves explicit registrations, and tests missing or failed imports.
Model-mode routing
backend/routes/learn.py, backend/tests/test_e2e_function_handlers.py
Model preference overrides are skipped outside real mode and continue producing Gemini overrides in real mode.
Tutor journey and encrypted persistence assertions
frontend/e2e/support/*, frontend/e2e/tutor.spec.ts, frontend/src/components/screens/Learn.tsx, frontend/eslint.*, docs/frontend-testids.md
The browser journey resumes a seeded session, submits a message, verifies the deterministic reply, queries encrypted database rows, decrypts them through backend logic, and uses documented session-resume test IDs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant TutorE2E
participant Learn
participant Backend
participant Database
participant DecryptHelper
TutorE2E->>Learn: Resume seeded session
Learn->>Backend: Submit tutor message
Backend->>Database: Store encrypted messages
Backend->>Learn: Return deterministic tutor reply
TutorE2E->>Database: Read session messages
TutorE2E->>DecryptHelper: Decrypt stored content
DecryptHelper->>TutorE2E: Return plaintext values
Loading

Possibly related PRs

  • SaplingLearn/Sapling#437: Modifies the same backend function-handler loading and tutor handler registration paths, including model-mode routing.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change: an e2e tutor journey persisting conversation data to messages.
Description check✅ PassedThe description is detailed and covers the summary, related issues, testing, and reviewer notes, even if the headings differ from the template.
Docstring Coverage✅ PassedDocstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/392-journey-tutor-persist

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

import pytest
from pydantic_ai.models.google import GoogleModel

import agents._providers as providers
Comment threadbackend/agents/_providers.py Fixed
AndresL230 added a commit that referenced this pull request Jul 28, 2026
…port (#434 review)
Review fixes on PR #434:
- _providers.py latched _ENV_HANDLERS_LOADED BEFORE importing the env
module, permanently caching a failed import as loaded: the first
dispatch raised ModuleNotFoundError correctly, every later one silently
downgraded to the generic LookupError. Latch now sets only after a
successful import (or when the var is unset), so a broken boot fails
loudly on EVERY dispatch. Regression test dispatches twice against a
bad module path within one latch lifetime (verified red on the old
order, green on the fix).
- Learn.tsx joins the eslint testid enforcement files array; its 21
pre-existing untagged elements are baselined via npm run lint:baseline
(the documented legacy-debt mechanism, matching #429's Dashboard.tsx
treatment) instead of the prose lint exception, and the doc's Tutor
table row now names the ChatPanel/Learn.tsx split.
- Qualified the "pytest lane never sets the var / never imports it"
comments to the normal (hermetic) lane — the seam's own tests do both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the test/392-journey-tutor-persist branch from 83e7951 to 30161b2CompareJuly 28, 2026 03:06
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review fixes pushed (30161b2, rebased onto 385a534/#431 — the verbatim global-setup.ts commit dropped as already-upstream):

  1. Latch bug (required): fixed._ENV_HANDLERS_LOADED now sets only after a successful import (or when the var is unset), so a bad SAPLING_FUNCTION_HANDLERS path raises ModuleNotFoundError on every dispatch instead of downgrading to the generic LookupError after the first. Regression test test_bad_env_module_path_fails_loudly_on_every_dispatch dispatches twice within one latch lifetime — verified red against the old latch order, green on the fix.
  2. Testid mechanism: converged on the suppressions baseline.screens/Learn.tsx joins the eslint files array; npm run lint:baseline produced exactly one new entry (no-restricted-syntax: 21 for Learn.tsx — 22 intrinsic elements minus the newly tagged resume button, no other counts moved). The prose lint exception is gone and the Tutor table row now names the ChatPanel/Learn.tsx split, Sign-in-row style.
  3. Comment accuracy: the "pytest lane never sets the var / never imports it" claims are qualified to the normal (hermetic) lane, with the seam's own tests called out as the deliberate exception.

Re-verification: backend 1076 passed, 1 skipped (+1 regression test), ruff/tsc/eslint (0 errors)/vitest (204 passed) clean, and a fresh lock-held function-mode confirmation cycle on the rebased branch: 3/3 consecutive e2e/tutor.spec.ts runs green (7.4s/5.9s/5.5s), clean teardown. The original 10/10 tally stands as acceptance evidence.

module = (os.getenv("SAPLING_FUNCTION_HANDLERS") or "").strip()
if module:
importlib.import_module(module) # raises before the latch on failure
_ENV_HANDLERS_LOADED = True

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/e2e/support/db.ts`:
- Around line 155-159: Update queryRaw to execute client.query(sql, params)
inside a read-only transaction, ensuring the transaction is explicitly
configured as read-only before returning rows. Preserve the existing withDb
usage, parameters, and result shape while preventing mutation statements through
this helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 179f9b80-dec5-417c-8b08-827b9a21f412

📥 Commits

Reviewing files that changed from the base of the PR and between 385a534 and 30161b2.

📒 Files selected for processing (11)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/support/db.ts
  • frontend/e2e/support/decrypt.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx

Comment on lines +155 to +159
export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
return withDb(async (client) => (await client.query(sql, params)).rows);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Enforce the read-only query contract.

This accepts arbitrary SQL despite being documented as SELECT-only, so a future E2E assertion can mutate or truncate the local database through this helper. Execute it in a read-only transaction.

Proposed fix
 export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
- return withDb(async (client) => (await client.query(sql, params)).rows);+ return withDb(async (client) => {+ await client.query("BEGIN READ ONLY");+ try {+ return (await client.query(sql, params)).rows;+ } finally {+ await client.query("ROLLBACK");+ }+ });
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportasyncfunctionqueryRaw(
sql: string,
params: unknown[]=[],
): Promise<Record<string,unknown>[]>{
returnwithDb(async(client)=>(awaitclient.query(sql,params)).rows);
exportasyncfunctionqueryRaw(
sql: string,
params: unknown[]=[],
): Promise<Record<string,unknown>[]>{
returnwithDb(async(client)=>{
awaitclient.query("BEGIN READ ONLY");
try{
return(awaitclient.query(sql,params)).rows;
}finally{
awaitclient.query("ROLLBACK");
}
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/e2e/support/db.ts` around lines 155 - 159, Update queryRaw to
execute client.query(sql, params) inside a read-only transaction, ensuring the
transaction is explicitly configured as read-only before returning rows.
Preserve the existing withDb usage, parameters, and result shape while
preventing mutation statements through this helper.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance. (Three review findings were fixed in-PR: the env-handlers failure latch now sets only after a successful import — regression-tested red→green with a dispatch-twice test; Learn.tsx joined eslint testid enforcement via the suppressions baseline; comment claims qualified.)

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 27, 2026 20:20
Browser journey: resume the seeded recursion session, send a message
through the real composer, assert the deterministic reply renders, then
raw-SQL readback of the two new `messages` rows proving the encryption
boundary (content is ciphertext at rest and decrypts to the sent text).
Deterministic model responses via the #391 seam, extended for
out-of-process runs:
- agents/_providers.py: SAPLING_FUNCTION_HANDLERS names a module imported
lazily (once) on a dispatch miss, so the E2E uvicorn process can
register handlers at boot; unset var = #391 behavior unchanged.
- agents/function_handlers_e2e.py: fixed-reply chat_tutor handler the E2E
stack points the var at.
- routes/learn.py::_resolve_model_pref: in any non-real mode, the
browser's fast/smart pref no longer builds a live GoogleModel override
(ModelToggle defaults to "fast", so every browser turn sends a pref —
this silently bypassed the seam and dialed real Gemini).
- tutor-session-resume-{sessionId} testid on the Learn screen's session
rows (documented under the signin-trigger lint exception): the journey
enters the chat by resuming, because start-session still runs on the
legacy call_gemini_multiturn path the seam does not cover.
- e2e/support/db.ts queryRaw (parameterized SELECT behind the loopback
guard) + e2e/support/decrypt.ts (shells the backend venv's
decrypt_if_present, mirroring the integration-suite readback pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…port (#434 review)
Review fixes on PR #434:
- _providers.py latched _ENV_HANDLERS_LOADED BEFORE importing the env
module, permanently caching a failed import as loaded: the first
dispatch raised ModuleNotFoundError correctly, every later one silently
downgraded to the generic LookupError. Latch now sets only after a
successful import (or when the var is unset), so a broken boot fails
loudly on EVERY dispatch. Regression test dispatches twice against a
bad module path within one latch lifetime (verified red on the old
order, green on the fix).
- Learn.tsx joins the eslint testid enforcement files array; its 21
pre-existing untagged elements are baselined via npm run lint:baseline
(the documented legacy-debt mechanism, matching #429's Dashboard.tsx
treatment) instead of the prose lint exception, and the doc's Tutor
table row now names the ChatPanel/Learn.tsx split.
- Qualified the "pytest lane never sets the var / never imports it"
comments to the normal (hermetic) lane — the seam's own tests do both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(e2e): journey — tutor conversation persists to messages

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

test(e2e): journey — tutor conversation persists to messages (#392) - #434

Merged
AndresL230 merged 2 commits into
mainfrom
test/392-journey-tutor-persist
Jul 28, 2026
Merged

test(e2e): journey — tutor conversation persists to messages (#392)#434
AndresL230 merged 2 commits into
mainfrom
test/392-journey-tutor-persist

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Browser journey for the tutor chat (#392): resume the seeded rich-sess-cs-recursion session on /learn, send a message through the real composer (tutor-inputtutor-send), assert the deterministic reply renders in tutor-messages, then raw-SQL readback of messages proving the encryption boundary:

  • exactly 2 new rows for the session (seeded 4 → 6; a double-write from an accidental legacy fallback would fail the count),
  • content at rest is ciphertext (≠ the sent plaintext / rendered reply), and
  • both rows decrypt — via the backend's own decrypt_if_present, shelled through the backend venv — to exactly the sent text and the deterministic reply. Both directions are asserted because decrypt_if_present echoes plaintext input back (legacy tolerance), so decrypt-equality alone can't prove encryption.

No SSE handling and zero waitForTimeout: sendChat is a plain fetch returning the full { reply, … } object; assertions wait on the rendered reply locator.

Boot contract

SAPLING_MODEL_MODE=function SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e make e2e-up

scripts/e2e-up.sh does not scrub the environment — exported vars flow through make into the setsid venv/bin/python -m uvicorn launch, and backend/.env doesn't define SAPLING_*, so no sanctioned-passing workaround was needed. The second var is new (see below).

Seam findings (#391 coverage of the tutor path)

  1. POST /api/learn/chat IS covered by the seam — it runs chat_tutor (pydantic-ai) via _chat_via_agent, whose model comes from model_for("chat_tutor").
  2. …but the browser could never reach the seam before this PR.routes/learn.py::_resolve_model_pref built a live GoogleModel via google_model(name) for the per-request fast/smart pref, passed as agent.run(model=…) — overriding the FunctionModel. The frontend always sends a pref (ModelToggle's useModelPref defaults to "fast"), so in function mode every browser tutor turn would still have dialed real Gemini. Fixed here: in any non-real mode the pref resolves to None (the agent default, already mode-correct). real mode is byte-for-byte unchanged.
  3. POST /api/learn/start-session is NOT covered — it still runs the legacy services/gemini_service.py::call_gemini_multiturn path (explicit TODO(refactor-3 follow-up) in the route). The journey therefore enters the chat by resuming a seeded session, never via "Start learning". Same applies to /action and /mode-switch (both legacy).
  4. Handler registration for out-of-process runs did not exist.feat(backend): pydantic-ai test seam via FunctionModel (SAPLING_MODEL_MODE) #391's register_function_handler is in-process only; ADR 0019 anticipated E2E lanes would "set the env at process start, then register per-task handlers" but shipped no mechanism for a separate uvicorn process. Added additively: SAPLING_FUNCTION_HANDLERS=<module> is imported lazily, once, only on a dispatch miss — the pytest lane (env unset, explicit registration) is unchanged, a missing handler still raises the pointed LookupError, explicit registrations always win, and a typo'd module path fails loudly (ImportError) instead of running handler-less. agents/function_handlers_e2e.py holds the fixed-reply chat_tutor handler; other journeys should append theirs there.
  5. Residual live-Gemini attempt (not a dependency): RAG query embedding._chat_via_agent calls rag_service.retrieve_chunks whenever the session's course has a course_code (seeded CS101 does), and _embed_query hits gemini-embedding-001 outside any seam. retrieve_chunks swallows every exception and returns [], so the journey's outcome does not depend on it — but each E2E tutor turn still attempts one billed embedding call when backend/.env carries a real key. Worth a follow-up (seam or env kill-switch for embeddings).

Verification

  • 10 consecutive local runs green (single lock hold, fresh function-mode stack boot from this branch's worktree): runs 1–10 all 1 passed, 4.5–5.9s each, zero failed/flaky/retried, clean teardown. Boot line used: the contract above.
  • Backend unit suite in the worktree: 1075 passed, 1 skipped (includes 6 new tests in tests/test_e2e_function_handlers.py: env-module autoload end-to-end through the real socratic_agent, unset-var LookupError posture, bad-module loud failure, explicit-registration precedence, and _resolve_model_pref mode behavior both ways). ruff check clean on changed files.
  • tsc --noEmit and eslint clean on changed frontend files (one pre-existing baselined warning in Learn.tsx).
  • Encrypt→decrypt round-trip of the exact helper snippet verified against the local stack's ENCRYPTION_KEY before the runs.

Testid note (deviation, with precedent)

The journey needs one new testid, tutor-session-resume-{sessionId}, on the Learn screen's session rows — but screens/Learn.tsx has ~20 intrinsic interactive elements outside any browser journey, so adding it to the eslint files array would force testids on all of them. Followed the documented signin-trigger exception instead: testid added + inventoried in docs/frontend-testids.md with the exception noted, file stays out of the lint block.

The AI-disclosure DisclaimerModal (fixed overlay, no testids) is pre-acked via addInitScript (models a returning user); dismissing it by copy would violate the no-copy-anchoring rule and tagging it was out of scope.

Cross-PR convergence notes

  • frontend/e2e/global-setup.ts gained the sapling_user localStorage entry copied verbatim from test/386-journey-dashboard (per the harness-owner heads-up): the cookie-only storageState never populates UserContext (it bootstraps identity solely from that localStorage key), so the Learn screen's session list — which this journey resumes from — stays empty without it. Identical content on both branches, so the merge is clean whichever lands first.
  • At least one sibling journey PR boots with the same SAPLING_FUNCTION_HANDLERS=agents.function_handlers_e2e contract and will carry its own version of the handlers module / _providers.py autoload. These were authored in isolated worktrees — whoever merges second should converge to one module (append handlers per task) rather than duplicate the mechanism.

Files

  • frontend/e2e/tutor.spec.ts — the journey (new)
  • frontend/e2e/support/decrypt.ts — backend-venv decrypt seam (new)
  • frontend/e2e/support/db.ts — additive queryRaw (parameterized SELECTs behind the existing loopback guard)
  • frontend/src/components/screens/Learn.tsxtutor-session-resume-{sessionId}
  • docs/frontend-testids.md — inventory + lint-exception note (append-only)
  • backend/agents/_providers.pySAPLING_FUNCTION_HANDLERS lazy autoload on dispatch miss
  • backend/agents/function_handlers_e2e.py — deterministic chat_tutor handler (new)
  • backend/routes/learn.py_resolve_model_pref respects SAPLING_MODEL_MODE
  • backend/tests/test_e2e_function_handlers.py — 6 unit tests (new)

Part of #402, closes#392

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for loading function-mode tutor handlers during startup and dispatch.
    • Added deterministic tutor behavior for end-to-end testing.
    • Added a test identifier for resuming recent tutor sessions.
  • Bug Fixes

    • Prevented live model overrides from being created in non-real modes.
    • Improved missing or invalid handler errors.
  • Tests

    • Added coverage for handler loading, routing, session persistence, and encrypted message storage.
  • Documentation

    • Updated tutor end-to-end testing and test-identifier guidance.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging70441efCommit Preview URL

Branch Preview URL
Jul 28 2026, 03:25 AM

@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: faa906ad-a112-466d-9982-90b1ee54e01d

📥 Commits

Reviewing files that changed from the base of the PR and between 30161b2 and 70441ef.

📒 Files selected for processing (11)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/support/db.ts
  • frontend/e2e/support/decrypt.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx
📝 Walkthrough

Walkthrough

The PR adds environment-based function-handler loading, deterministic tutor responses, non-real model routing, and browser E2E coverage for tutor rendering and encrypted message persistence.

Changes

Deterministic tutor E2E flow

Layer / File(s)Summary
Function-handler loading and dispatch
backend/agents/_providers.py, backend/agents/function_handlers_e2e.py, backend/tests/test_e2e_function_handlers.py
Function-mode dispatch lazily imports the configured handler module, registers the deterministic chat_tutor handler, preserves explicit registrations, and tests missing or failed imports.
Model-mode routing
backend/routes/learn.py, backend/tests/test_e2e_function_handlers.py
Model preference overrides are skipped outside real mode and continue producing Gemini overrides in real mode.
Tutor journey and encrypted persistence assertions
frontend/e2e/support/*, frontend/e2e/tutor.spec.ts, frontend/src/components/screens/Learn.tsx, frontend/eslint.*, docs/frontend-testids.md
The browser journey resumes a seeded session, submits a message, verifies the deterministic reply, queries encrypted database rows, decrypts them through backend logic, and uses documented session-resume test IDs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant TutorE2E
participant Learn
participant Backend
participant Database
participant DecryptHelper
TutorE2E->>Learn: Resume seeded session
Learn->>Backend: Submit tutor message
Backend->>Database: Store encrypted messages
Backend->>Learn: Return deterministic tutor reply
TutorE2E->>Database: Read session messages
TutorE2E->>DecryptHelper: Decrypt stored content
DecryptHelper->>TutorE2E: Return plaintext values
Loading

Possibly related PRs

  • SaplingLearn/Sapling#437: Modifies the same backend function-handler loading and tutor handler registration paths, including model-mode routing.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change: an e2e tutor journey persisting conversation data to messages.
Description check✅ PassedThe description is detailed and covers the summary, related issues, testing, and reviewer notes, even if the headings differ from the template.
Docstring Coverage✅ PassedDocstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/392-journey-tutor-persist

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

import pytest
from pydantic_ai.models.google import GoogleModel

import agents._providers as providers
Comment threadbackend/agents/_providers.py Fixed
AndresL230 added a commit that referenced this pull request Jul 28, 2026
…port (#434 review)
Review fixes on PR #434:
- _providers.py latched _ENV_HANDLERS_LOADED BEFORE importing the env
module, permanently caching a failed import as loaded: the first
dispatch raised ModuleNotFoundError correctly, every later one silently
downgraded to the generic LookupError. Latch now sets only after a
successful import (or when the var is unset), so a broken boot fails
loudly on EVERY dispatch. Regression test dispatches twice against a
bad module path within one latch lifetime (verified red on the old
order, green on the fix).
- Learn.tsx joins the eslint testid enforcement files array; its 21
pre-existing untagged elements are baselined via npm run lint:baseline
(the documented legacy-debt mechanism, matching #429's Dashboard.tsx
treatment) instead of the prose lint exception, and the doc's Tutor
table row now names the ChatPanel/Learn.tsx split.
- Qualified the "pytest lane never sets the var / never imports it"
comments to the normal (hermetic) lane — the seam's own tests do both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the test/392-journey-tutor-persist branch from 83e7951 to 30161b2CompareJuly 28, 2026 03:06
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review fixes pushed (30161b2, rebased onto 385a534/#431 — the verbatim global-setup.ts commit dropped as already-upstream):

  1. Latch bug (required): fixed._ENV_HANDLERS_LOADED now sets only after a successful import (or when the var is unset), so a bad SAPLING_FUNCTION_HANDLERS path raises ModuleNotFoundError on every dispatch instead of downgrading to the generic LookupError after the first. Regression test test_bad_env_module_path_fails_loudly_on_every_dispatch dispatches twice within one latch lifetime — verified red against the old latch order, green on the fix.
  2. Testid mechanism: converged on the suppressions baseline.screens/Learn.tsx joins the eslint files array; npm run lint:baseline produced exactly one new entry (no-restricted-syntax: 21 for Learn.tsx — 22 intrinsic elements minus the newly tagged resume button, no other counts moved). The prose lint exception is gone and the Tutor table row now names the ChatPanel/Learn.tsx split, Sign-in-row style.
  3. Comment accuracy: the "pytest lane never sets the var / never imports it" claims are qualified to the normal (hermetic) lane, with the seam's own tests called out as the deliberate exception.

Re-verification: backend 1076 passed, 1 skipped (+1 regression test), ruff/tsc/eslint (0 errors)/vitest (204 passed) clean, and a fresh lock-held function-mode confirmation cycle on the rebased branch: 3/3 consecutive e2e/tutor.spec.ts runs green (7.4s/5.9s/5.5s), clean teardown. The original 10/10 tally stands as acceptance evidence.

module = (os.getenv("SAPLING_FUNCTION_HANDLERS") or "").strip()
if module:
importlib.import_module(module) # raises before the latch on failure
_ENV_HANDLERS_LOADED = True

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/e2e/support/db.ts`:
- Around line 155-159: Update queryRaw to execute client.query(sql, params)
inside a read-only transaction, ensuring the transaction is explicitly
configured as read-only before returning rows. Preserve the existing withDb
usage, parameters, and result shape while preventing mutation statements through
this helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 179f9b80-dec5-417c-8b08-827b9a21f412

📥 Commits

Reviewing files that changed from the base of the PR and between 385a534 and 30161b2.

📒 Files selected for processing (11)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/learn.py
  • backend/tests/test_e2e_function_handlers.py
  • docs/frontend-testids.md
  • frontend/e2e/support/db.ts
  • frontend/e2e/support/decrypt.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx

Comment on lines +155 to +159
export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
return withDb(async (client) => (await client.query(sql, params)).rows);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Enforce the read-only query contract.

This accepts arbitrary SQL despite being documented as SELECT-only, so a future E2E assertion can mutate or truncate the local database through this helper. Execute it in a read-only transaction.

Proposed fix
 export async function queryRaw(
sql: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
- return withDb(async (client) => (await client.query(sql, params)).rows);+ return withDb(async (client) => {+ await client.query("BEGIN READ ONLY");+ try {+ return (await client.query(sql, params)).rows;+ } finally {+ await client.query("ROLLBACK");+ }+ });
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportasyncfunctionqueryRaw(
sql: string,
params: unknown[]=[],
): Promise<Record<string,unknown>[]>{
returnwithDb(async(client)=>(awaitclient.query(sql,params)).rows);
exportasyncfunctionqueryRaw(
sql: string,
params: unknown[]=[],
): Promise<Record<string,unknown>[]>{
returnwithDb(async(client)=>{
awaitclient.query("BEGIN READ ONLY");
try{
return(awaitclient.query(sql,params)).rows;
}finally{
awaitclient.query("ROLLBACK");
}
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/e2e/support/db.ts` around lines 155 - 159, Update queryRaw to
execute client.query(sql, params) inside a read-only transaction, ensuring the
transaction is explicitly configured as read-only before returning rows.
Preserve the existing withDb usage, parameters, and result shape while
preventing mutation statements through this helper.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance. (Three review findings were fixed in-PR: the env-handlers failure latch now sets only after a successful import — regression-tested red→green with a dispatch-twice test; Learn.tsx joined eslint testid enforcement via the suppressions baseline; comment claims qualified.)

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 27, 2026 20:20
Browser journey: resume the seeded recursion session, send a message
through the real composer, assert the deterministic reply renders, then
raw-SQL readback of the two new `messages` rows proving the encryption
boundary (content is ciphertext at rest and decrypts to the sent text).
Deterministic model responses via the #391 seam, extended for
out-of-process runs:
- agents/_providers.py: SAPLING_FUNCTION_HANDLERS names a module imported
lazily (once) on a dispatch miss, so the E2E uvicorn process can
register handlers at boot; unset var = #391 behavior unchanged.
- agents/function_handlers_e2e.py: fixed-reply chat_tutor handler the E2E
stack points the var at.
- routes/learn.py::_resolve_model_pref: in any non-real mode, the
browser's fast/smart pref no longer builds a live GoogleModel override
(ModelToggle defaults to "fast", so every browser turn sends a pref —
this silently bypassed the seam and dialed real Gemini).
- tutor-session-resume-{sessionId} testid on the Learn screen's session
rows (documented under the signin-trigger lint exception): the journey
enters the chat by resuming, because start-session still runs on the
legacy call_gemini_multiturn path the seam does not cover.
- e2e/support/db.ts queryRaw (parameterized SELECT behind the loopback
guard) + e2e/support/decrypt.ts (shells the backend venv's
decrypt_if_present, mirroring the integration-suite readback pattern).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…port (#434 review)
Review fixes on PR #434:
- _providers.py latched _ENV_HANDLERS_LOADED BEFORE importing the env
module, permanently caching a failed import as loaded: the first
dispatch raised ModuleNotFoundError correctly, every later one silently
downgraded to the generic LookupError. Latch now sets only after a
successful import (or when the var is unset), so a broken boot fails
loudly on EVERY dispatch. Regression test dispatches twice against a
bad module path within one latch lifetime (verified red on the old
order, green on the fix).
- Learn.tsx joins the eslint testid enforcement files array; its 21
pre-existing untagged elements are baselined via npm run lint:baseline
(the documented legacy-debt mechanism, matching #429's Dashboard.tsx
treatment) instead of the prose lint exception, and the doc's Tutor
table row now names the ChatPanel/Learn.tsx split.
- Qualified the "pytest lane never sets the var / never imports it"
comments to the normal (hermetic) lane — the seam's own tests do both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(e2e): journey — tutor conversation persists to messages

1 participant

@AndresL230