fix(learn): consume /learn?resume= deep links (#164) + ADR-0020 interrupted-turn Retry + promoted streaming journeys (#356) - #461

Merged
AndresL230 merged 3 commits into
mainfrom
fix/164-356-tutor-resume-streaming
Jul 29, 2026
Merged

fix(learn): consume /learn?resume= deep links (#164) + ADR-0020 interrupted-turn Retry + promoted streaming journeys (#356)#461
AndresL230 merged 3 commits into
mainfrom
fix/164-356-tutor-resume-streaming

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B1 of the backlog clear — the tutor-resume P1 plus the streaming smoke tail, in one PR because they share files and journeys.

#164 — dead resume deep links

Learn.tsx read only topic/mode/course/suggest off the URL; Dashboard's "Where you left off" cards push /learn?resume=<id> and Tree's session rows pushed /learn?session=<id>, so both stranded users on the session picker. Now:

  • readResumeParam (exported, unit-tested) accepts resume (canonical) and session (legacy alias); Tree unified on ?resume=.
  • A mount effect consumes the param once per id (guard ref — the mode-sync URL rewrite preserves params and must not re-trigger it) and calls resumeSessionById, which is server-authoritative: topic/mode/course come off the resume payload, so deep links work for sessions outside the 10-row recent list.
  • A quiet loading state replaces the picker flash while the deep link hydrates.

#356 item 5 / ADR 0020 — interrupted turns keep the partial + offer Retry

The merged #349 code still discarded partials on Stop and rendered Rung-2 failures as an Error: assistant bubble. Per ADR 0020:

  • ChatMsg gains interrupted/retryText; ChatPanel renders the partial (dimmed, never blanked) + an "Interrupted" marker + Retry.
  • send's ladder: Stop, Rung-2, and a failed Rung-3 JSON fallback all append the interrupted bubble (error detail goes to a toast, not the transcript). Retry drops the failed pair (removeInterruptedTurn, pure + unit-tested) and re-sends — safe because the routes persist only on completion.
  • A turn aborted by switching sessions appends nothing (sessionIdRef guard) — otherwise the fix itself would have introduced the item-7 stale bubble.
  • Scope note added to ADR 0020: the greeting turn (beginSession) returns to the entry screen with the topic draft intact — there is no transcript turn to mark.

#356 items 4/6/7 — promoted journeys (frontend/e2e/streaming.spec.ts)

  • item 6: /api/learn/chat/stream route-aborted → transparent JSON fallback → deterministic reply + exactly one encrypted user/assistant pair (decrypt-verified).
  • items 4+5: Stop mid-stream → partial kept + interrupted + Retry, zero rows persisted; Retry completes and persists exactly one pair.
  • item 7: switch sessions mid-stream → SSE request torn down (requestfailed observed), no stale bubble, both sessions' row counts untouched.
  • Dashboard 'Where you left off' and Tree session links are dead — /learn?resume= and ?session= are never read #164: journey enters via the REAL dashboard card (new dashboard-resume-{id} testid) in tutor.spec.ts.

Mid-stream windows are real: the function-mode seam paces streamed replay (set_function_stream_delay_ms — 24-char chunks, reset by clear_function_handlers, no new lane env var: the e2e handlers module opts in at import with 150ms) and serves a long deterministic reply on the E2E_SLOW_STREAM message trigger. Constants stay byte-identical between the JSON and streamed lanes; handler-constant ↔ spec ↔ test_e2e_function_handlers.py sync maintained.

#356 item 3 — live evidence instead of a lane journey

The server-side Rung-1 legacy fallback (call_gemini_multiturn) has no function-mode gate by design (fallback-only, deleted in #151), so it cannot run deterministically in the e2e lane. New tests/test_streaming_rung1_live.py (opt-in RUN_LIVE_STREAM_RUNGS=1, billable, live_llm lane) proves the real failure shape degrades to a live legacy reply arriving as a single token + done with on_complete never called — run green locally. Mechanics stay pinned hermetically in test_chat_stream.py / test_learn_stream_routes.py.

Verification

  • Backend: full hermetic suite 1316 passed on the local venv (pydantic-ai 1.89) and the streaming-adjacent files re-run green on a lock-pinned scratch venv (pydantic-ai 1.107.0, the CI universe).
  • Frontend: vitest 254 passed (12 new), tsc --noEmit clean, lint 0 errors (suppressions pruned — the two new testids removed suppressed occurrences).
  • Live Rung-1 test: 1 passed against real Gemini.
  • Full local e2e cycle (Playwright + oracles under the stack flock): to be run pre-merge; results will be posted below.

🤖 Generated with Claude Code

Closes#164. Closes#356.

…rupted-turn Retry, promoted streaming journeys (#356)
#164: Learn read only topic/mode/course/suggest off the URL, so Dashboard's
'Where you left off' cards (?resume=) and Tree's session rows (?session=)
landed on the session picker — dead buttons. LearnInner now consumes
?resume= (legacy ?session= accepted) once per id via resumeSessionById,
which is server-authoritative (topic/mode/course off the resume payload),
with a loading state instead of a picker flash. Tree unified on ?resume=.
ADR 0020 (#356 item 5): a chat turn that is Stopped or fails mid-stream
(Rung 2, or the Rung-3 JSON fallback itself failing) now keeps the partial
reply, marks the bubble interrupted, and offers Retry — nothing was
persisted, so Retry is a plain re-send (drops the failed pair first).
A turn aborted by SWITCHING sessions appends nothing (no stale bubble).
#356 items 4/6/7 land as promoted journeys (frontend/e2e/streaming.spec.ts):
stream-fails-to-open → transparent JSON fallback; Stop → interrupted +
Retry + zero rows persisted; switch-mid-stream → aborted stream, no stale
bubble, both sessions' rows untouched. The mid-stream window is real: the
function-mode seam now paces streamed replay (set_function_stream_delay_ms,
24-char chunks; the e2e handlers module opts in at 150ms and serves a long
deterministic reply on the E2E_SLOW_STREAM trigger). Item 3 (server Rung-1
legacy fallback) cannot run deterministically in the lane by design — its
live proof is tests/test_streaming_rung1_live.py (opt-in, billable), its
mechanics stay pinned in test_chat_stream.py. #164 gets a dashboard-entry
journey in tutor.spec.ts.
Verified under both dep universes: local venv (pydantic-ai 1.89) and a
lock-pinned scratch venv (1.107.0) — full backend suite 1316 passed;
frontend vitest 254 passed, tsc clean, lint 0 errors (suppressions pruned:
the new testids removed two suppressed occurrences).
Closes#164. Closes#356.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in:50 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: b002004b-ab00-40c8-b149-7c330912165e

📥 Commits

Reviewing files that changed from the base of the PR and between 90a5f99 and d6ab1f5.

📒 Files selected for processing (20)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/services/agent_events.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_learn_stream_routes.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_streaming_rung1_live.py
  • docs/decisions/0020-streaming-tutor-interrupt-retry.md
  • docs/frontend-testids.md
  • frontend/e2e/streaming.spec.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/components/ChatPanel.test.tsx
  • frontend/src/components/ChatPanel.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.resume.test.ts
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Tree.tsx

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 29, 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-stagingd6ab1f5Commit Preview URL

Branch Preview URL
Jul 29 2026, 07:28 PM

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 2 issues:

  1. The two new DB-readback tests in streaming.spec.ts decrypt-verify messages.content but skip the ciphertext-at-rest assertion (expect(row.content).not.toBe(plaintext)). support/decrypt.ts says "Specs must always make both" — decrypt_if_present echoes plaintext back unchanged, so decrypt-equality alone cannot prove the column was encrypted; if the encryption layer silently broke, both tests would still pass. The sibling tutor.spec.ts journey makes both checks.

expect(rows).toHaveLength(SEEDED_MESSAGE_COUNT+2);
const[userRow,assistantRow]=rows.slice(-2);
expect(userRow.role).toBe("user");
expect(assistantRow.role).toBe("assistant");
const[userPlain,assistantPlain]=awaitdecryptTexts([
userRow.content,
assistantRow.content,
]);
expect(userPlain).toBe(STUDENT_MESSAGE);
expect(assistantPlain).toBe(TUTOR_REPLY);
});

  1. The /learn?resume= deep link still paints the "Start a session" picker for the first frame(s): resuming starts false and only flips inside a post-paint useEffect, so the first committed render with a resume param takes the picker branch — the exact flash the new loading state exists to avoid. A lazy initializer (useState(() => !!readResumeParam(searchParams))) closes it deterministically.

// flashing the session picker the deep link is about to leave.
const[resuming,setResuming]=useState(false);
// Mirror of `sessionId` readable from async closures: `send`'s error paths

Lower-confidence items (below threshold, noted for the author): the success-path append after the un-abortable Rung-3 sendChat is not guarded by the new sessionIdRef check, so a session switch during a Rung-3 fallback can still append the old session's reply into the new transcript; the "three-rung fallback ladder" summary comment above send still describes the pre-ADR-0020 Stop/Rung-2 behavior; ADR 0020's "Scope / where it lands" section contradicts its updated Status line.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 29, 2026 12:17
- Guard the success-path append (and the streamingText clear) with the same
sessionIdRef/controller ownership checks as appendInterrupted — the
un-abortable Rung-3 sendChat could resolve after a session switch and
inject its reply into the other session's transcript. New journey pins it
(delayed JSON fallback + switch → reply dropped client-side, persisted
server-side to the original session only).
- streaming.spec.ts DB readbacks now assert ciphertext-at-rest AND
decrypt-equality (support/decrypt.ts: 'Specs must always make both').
- resuming state lazily initialized from the URL so a /learn?resume= deep
link's first paint is the loading branch, not a one-frame picker flash.
- send()'s three-rung ladder summary comment updated to the ADR-0020
behavior it now implements; beginSession's mirror comment documents the
greeting-scope exception.
- ADR 0020 'Scope / where it lands' rewritten to match the implemented
Status line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ansform
The e2e lane's two paced mid-stream journeys failed with ZERO tokens for
5s then everything at once: the stack serves the frontend with production
`next start`, whose default compress:true wraps the /api/* rewrite proxy
in the `compression` middleware, and gzip buffers small SSE frames until
end-of-response. Instant function-mode streams passed only because their
single burst IS the whole response — progressive rendering was silently
broken behind every self-hosted `next start` proxy (docker-compose too).
Verified server-side pacing was live (probe: deltas at 0.01..1.37s for a
10-chunk reply) before touching the HTTP layer. Fix: SSE_CACHE_CONTROL
("no-store, no-transform") on every EventSourceResponse — the middleware's
standard filter skips no-transform responses; sse_starlette only
setdefaults Cache-Control so the route value wins. Red-first route tests
pin the header on both learn streams.
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

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

fix(learn): consume /learn?resume= deep links (#164) + ADR-0020 interrupted-turn Retry + promoted streaming journeys (#356) - #461

Merged
AndresL230 merged 3 commits into
mainfrom
fix/164-356-tutor-resume-streaming
Jul 29, 2026
Merged

fix(learn): consume /learn?resume= deep links (#164) + ADR-0020 interrupted-turn Retry + promoted streaming journeys (#356)#461
AndresL230 merged 3 commits into
mainfrom
fix/164-356-tutor-resume-streaming

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B1 of the backlog clear — the tutor-resume P1 plus the streaming smoke tail, in one PR because they share files and journeys.

#164 — dead resume deep links

Learn.tsx read only topic/mode/course/suggest off the URL; Dashboard's "Where you left off" cards push /learn?resume=<id> and Tree's session rows pushed /learn?session=<id>, so both stranded users on the session picker. Now:

  • readResumeParam (exported, unit-tested) accepts resume (canonical) and session (legacy alias); Tree unified on ?resume=.
  • A mount effect consumes the param once per id (guard ref — the mode-sync URL rewrite preserves params and must not re-trigger it) and calls resumeSessionById, which is server-authoritative: topic/mode/course come off the resume payload, so deep links work for sessions outside the 10-row recent list.
  • A quiet loading state replaces the picker flash while the deep link hydrates.

#356 item 5 / ADR 0020 — interrupted turns keep the partial + offer Retry

The merged #349 code still discarded partials on Stop and rendered Rung-2 failures as an Error: assistant bubble. Per ADR 0020:

  • ChatMsg gains interrupted/retryText; ChatPanel renders the partial (dimmed, never blanked) + an "Interrupted" marker + Retry.
  • send's ladder: Stop, Rung-2, and a failed Rung-3 JSON fallback all append the interrupted bubble (error detail goes to a toast, not the transcript). Retry drops the failed pair (removeInterruptedTurn, pure + unit-tested) and re-sends — safe because the routes persist only on completion.
  • A turn aborted by switching sessions appends nothing (sessionIdRef guard) — otherwise the fix itself would have introduced the item-7 stale bubble.
  • Scope note added to ADR 0020: the greeting turn (beginSession) returns to the entry screen with the topic draft intact — there is no transcript turn to mark.

#356 items 4/6/7 — promoted journeys (frontend/e2e/streaming.spec.ts)

  • item 6: /api/learn/chat/stream route-aborted → transparent JSON fallback → deterministic reply + exactly one encrypted user/assistant pair (decrypt-verified).
  • items 4+5: Stop mid-stream → partial kept + interrupted + Retry, zero rows persisted; Retry completes and persists exactly one pair.
  • item 7: switch sessions mid-stream → SSE request torn down (requestfailed observed), no stale bubble, both sessions' row counts untouched.
  • Dashboard 'Where you left off' and Tree session links are dead — /learn?resume= and ?session= are never read #164: journey enters via the REAL dashboard card (new dashboard-resume-{id} testid) in tutor.spec.ts.

Mid-stream windows are real: the function-mode seam paces streamed replay (set_function_stream_delay_ms — 24-char chunks, reset by clear_function_handlers, no new lane env var: the e2e handlers module opts in at import with 150ms) and serves a long deterministic reply on the E2E_SLOW_STREAM message trigger. Constants stay byte-identical between the JSON and streamed lanes; handler-constant ↔ spec ↔ test_e2e_function_handlers.py sync maintained.

#356 item 3 — live evidence instead of a lane journey

The server-side Rung-1 legacy fallback (call_gemini_multiturn) has no function-mode gate by design (fallback-only, deleted in #151), so it cannot run deterministically in the e2e lane. New tests/test_streaming_rung1_live.py (opt-in RUN_LIVE_STREAM_RUNGS=1, billable, live_llm lane) proves the real failure shape degrades to a live legacy reply arriving as a single token + done with on_complete never called — run green locally. Mechanics stay pinned hermetically in test_chat_stream.py / test_learn_stream_routes.py.

Verification

  • Backend: full hermetic suite 1316 passed on the local venv (pydantic-ai 1.89) and the streaming-adjacent files re-run green on a lock-pinned scratch venv (pydantic-ai 1.107.0, the CI universe).
  • Frontend: vitest 254 passed (12 new), tsc --noEmit clean, lint 0 errors (suppressions pruned — the two new testids removed suppressed occurrences).
  • Live Rung-1 test: 1 passed against real Gemini.
  • Full local e2e cycle (Playwright + oracles under the stack flock): to be run pre-merge; results will be posted below.

🤖 Generated with Claude Code

Closes#164. Closes#356.

…rupted-turn Retry, promoted streaming journeys (#356)
#164: Learn read only topic/mode/course/suggest off the URL, so Dashboard's
'Where you left off' cards (?resume=) and Tree's session rows (?session=)
landed on the session picker — dead buttons. LearnInner now consumes
?resume= (legacy ?session= accepted) once per id via resumeSessionById,
which is server-authoritative (topic/mode/course off the resume payload),
with a loading state instead of a picker flash. Tree unified on ?resume=.
ADR 0020 (#356 item 5): a chat turn that is Stopped or fails mid-stream
(Rung 2, or the Rung-3 JSON fallback itself failing) now keeps the partial
reply, marks the bubble interrupted, and offers Retry — nothing was
persisted, so Retry is a plain re-send (drops the failed pair first).
A turn aborted by SWITCHING sessions appends nothing (no stale bubble).
#356 items 4/6/7 land as promoted journeys (frontend/e2e/streaming.spec.ts):
stream-fails-to-open → transparent JSON fallback; Stop → interrupted +
Retry + zero rows persisted; switch-mid-stream → aborted stream, no stale
bubble, both sessions' rows untouched. The mid-stream window is real: the
function-mode seam now paces streamed replay (set_function_stream_delay_ms,
24-char chunks; the e2e handlers module opts in at 150ms and serves a long
deterministic reply on the E2E_SLOW_STREAM trigger). Item 3 (server Rung-1
legacy fallback) cannot run deterministically in the lane by design — its
live proof is tests/test_streaming_rung1_live.py (opt-in, billable), its
mechanics stay pinned in test_chat_stream.py. #164 gets a dashboard-entry
journey in tutor.spec.ts.
Verified under both dep universes: local venv (pydantic-ai 1.89) and a
lock-pinned scratch venv (1.107.0) — full backend suite 1316 passed;
frontend vitest 254 passed, tsc clean, lint 0 errors (suppressions pruned:
the new testids removed two suppressed occurrences).
Closes#164. Closes#356.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in:50 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: b002004b-ab00-40c8-b149-7c330912165e

📥 Commits

Reviewing files that changed from the base of the PR and between 90a5f99 and d6ab1f5.

📒 Files selected for processing (20)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/services/agent_events.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_learn_stream_routes.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_streaming_rung1_live.py
  • docs/decisions/0020-streaming-tutor-interrupt-retry.md
  • docs/frontend-testids.md
  • frontend/e2e/streaming.spec.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/components/ChatPanel.test.tsx
  • frontend/src/components/ChatPanel.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.resume.test.ts
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Tree.tsx

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 29, 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-stagingd6ab1f5Commit Preview URL

Branch Preview URL
Jul 29 2026, 07:28 PM

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 2 issues:

  1. The two new DB-readback tests in streaming.spec.ts decrypt-verify messages.content but skip the ciphertext-at-rest assertion (expect(row.content).not.toBe(plaintext)). support/decrypt.ts says "Specs must always make both" — decrypt_if_present echoes plaintext back unchanged, so decrypt-equality alone cannot prove the column was encrypted; if the encryption layer silently broke, both tests would still pass. The sibling tutor.spec.ts journey makes both checks.

expect(rows).toHaveLength(SEEDED_MESSAGE_COUNT+2);
const[userRow,assistantRow]=rows.slice(-2);
expect(userRow.role).toBe("user");
expect(assistantRow.role).toBe("assistant");
const[userPlain,assistantPlain]=awaitdecryptTexts([
userRow.content,
assistantRow.content,
]);
expect(userPlain).toBe(STUDENT_MESSAGE);
expect(assistantPlain).toBe(TUTOR_REPLY);
});

  1. The /learn?resume= deep link still paints the "Start a session" picker for the first frame(s): resuming starts false and only flips inside a post-paint useEffect, so the first committed render with a resume param takes the picker branch — the exact flash the new loading state exists to avoid. A lazy initializer (useState(() => !!readResumeParam(searchParams))) closes it deterministically.

// flashing the session picker the deep link is about to leave.
const[resuming,setResuming]=useState(false);
// Mirror of `sessionId` readable from async closures: `send`'s error paths

Lower-confidence items (below threshold, noted for the author): the success-path append after the un-abortable Rung-3 sendChat is not guarded by the new sessionIdRef check, so a session switch during a Rung-3 fallback can still append the old session's reply into the new transcript; the "three-rung fallback ladder" summary comment above send still describes the pre-ADR-0020 Stop/Rung-2 behavior; ADR 0020's "Scope / where it lands" section contradicts its updated Status line.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 29, 2026 12:17
- Guard the success-path append (and the streamingText clear) with the same
sessionIdRef/controller ownership checks as appendInterrupted — the
un-abortable Rung-3 sendChat could resolve after a session switch and
inject its reply into the other session's transcript. New journey pins it
(delayed JSON fallback + switch → reply dropped client-side, persisted
server-side to the original session only).
- streaming.spec.ts DB readbacks now assert ciphertext-at-rest AND
decrypt-equality (support/decrypt.ts: 'Specs must always make both').
- resuming state lazily initialized from the URL so a /learn?resume= deep
link's first paint is the loading branch, not a one-frame picker flash.
- send()'s three-rung ladder summary comment updated to the ADR-0020
behavior it now implements; beginSession's mirror comment documents the
greeting-scope exception.
- ADR 0020 'Scope / where it lands' rewritten to match the implemented
Status line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ansform
The e2e lane's two paced mid-stream journeys failed with ZERO tokens for
5s then everything at once: the stack serves the frontend with production
`next start`, whose default compress:true wraps the /api/* rewrite proxy
in the `compression` middleware, and gzip buffers small SSE frames until
end-of-response. Instant function-mode streams passed only because their
single burst IS the whole response — progressive rendering was silently
broken behind every self-hosted `next start` proxy (docker-compose too).
Verified server-side pacing was live (probe: deltas at 0.01..1.37s for a
10-chunk reply) before touching the HTTP layer. Fix: SSE_CACHE_CONTROL
("no-store, no-transform") on every EventSourceResponse — the middleware's
standard filter skips no-transform responses; sse_starlette only
setdefaults Cache-Control so the route value wins. Red-first route tests
pin the header on both learn streams.
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

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

fix(learn): consume /learn?resume= deep links (#164) + ADR-0020 interrupted-turn Retry + promoted streaming journeys (#356) - #461

Merged
AndresL230 merged 3 commits into
mainfrom
fix/164-356-tutor-resume-streaming
Jul 29, 2026
Merged

fix(learn): consume /learn?resume= deep links (#164) + ADR-0020 interrupted-turn Retry + promoted streaming journeys (#356)#461
AndresL230 merged 3 commits into
mainfrom
fix/164-356-tutor-resume-streaming

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B1 of the backlog clear — the tutor-resume P1 plus the streaming smoke tail, in one PR because they share files and journeys.

#164 — dead resume deep links

Learn.tsx read only topic/mode/course/suggest off the URL; Dashboard's "Where you left off" cards push /learn?resume=<id> and Tree's session rows pushed /learn?session=<id>, so both stranded users on the session picker. Now:

  • readResumeParam (exported, unit-tested) accepts resume (canonical) and session (legacy alias); Tree unified on ?resume=.
  • A mount effect consumes the param once per id (guard ref — the mode-sync URL rewrite preserves params and must not re-trigger it) and calls resumeSessionById, which is server-authoritative: topic/mode/course come off the resume payload, so deep links work for sessions outside the 10-row recent list.
  • A quiet loading state replaces the picker flash while the deep link hydrates.

#356 item 5 / ADR 0020 — interrupted turns keep the partial + offer Retry

The merged #349 code still discarded partials on Stop and rendered Rung-2 failures as an Error: assistant bubble. Per ADR 0020:

  • ChatMsg gains interrupted/retryText; ChatPanel renders the partial (dimmed, never blanked) + an "Interrupted" marker + Retry.
  • send's ladder: Stop, Rung-2, and a failed Rung-3 JSON fallback all append the interrupted bubble (error detail goes to a toast, not the transcript). Retry drops the failed pair (removeInterruptedTurn, pure + unit-tested) and re-sends — safe because the routes persist only on completion.
  • A turn aborted by switching sessions appends nothing (sessionIdRef guard) — otherwise the fix itself would have introduced the item-7 stale bubble.
  • Scope note added to ADR 0020: the greeting turn (beginSession) returns to the entry screen with the topic draft intact — there is no transcript turn to mark.

#356 items 4/6/7 — promoted journeys (frontend/e2e/streaming.spec.ts)

  • item 6: /api/learn/chat/stream route-aborted → transparent JSON fallback → deterministic reply + exactly one encrypted user/assistant pair (decrypt-verified).
  • items 4+5: Stop mid-stream → partial kept + interrupted + Retry, zero rows persisted; Retry completes and persists exactly one pair.
  • item 7: switch sessions mid-stream → SSE request torn down (requestfailed observed), no stale bubble, both sessions' row counts untouched.
  • Dashboard 'Where you left off' and Tree session links are dead — /learn?resume= and ?session= are never read #164: journey enters via the REAL dashboard card (new dashboard-resume-{id} testid) in tutor.spec.ts.

Mid-stream windows are real: the function-mode seam paces streamed replay (set_function_stream_delay_ms — 24-char chunks, reset by clear_function_handlers, no new lane env var: the e2e handlers module opts in at import with 150ms) and serves a long deterministic reply on the E2E_SLOW_STREAM message trigger. Constants stay byte-identical between the JSON and streamed lanes; handler-constant ↔ spec ↔ test_e2e_function_handlers.py sync maintained.

#356 item 3 — live evidence instead of a lane journey

The server-side Rung-1 legacy fallback (call_gemini_multiturn) has no function-mode gate by design (fallback-only, deleted in #151), so it cannot run deterministically in the e2e lane. New tests/test_streaming_rung1_live.py (opt-in RUN_LIVE_STREAM_RUNGS=1, billable, live_llm lane) proves the real failure shape degrades to a live legacy reply arriving as a single token + done with on_complete never called — run green locally. Mechanics stay pinned hermetically in test_chat_stream.py / test_learn_stream_routes.py.

Verification

  • Backend: full hermetic suite 1316 passed on the local venv (pydantic-ai 1.89) and the streaming-adjacent files re-run green on a lock-pinned scratch venv (pydantic-ai 1.107.0, the CI universe).
  • Frontend: vitest 254 passed (12 new), tsc --noEmit clean, lint 0 errors (suppressions pruned — the two new testids removed suppressed occurrences).
  • Live Rung-1 test: 1 passed against real Gemini.
  • Full local e2e cycle (Playwright + oracles under the stack flock): to be run pre-merge; results will be posted below.

🤖 Generated with Claude Code

Closes#164. Closes#356.

…rupted-turn Retry, promoted streaming journeys (#356)
#164: Learn read only topic/mode/course/suggest off the URL, so Dashboard's
'Where you left off' cards (?resume=) and Tree's session rows (?session=)
landed on the session picker — dead buttons. LearnInner now consumes
?resume= (legacy ?session= accepted) once per id via resumeSessionById,
which is server-authoritative (topic/mode/course off the resume payload),
with a loading state instead of a picker flash. Tree unified on ?resume=.
ADR 0020 (#356 item 5): a chat turn that is Stopped or fails mid-stream
(Rung 2, or the Rung-3 JSON fallback itself failing) now keeps the partial
reply, marks the bubble interrupted, and offers Retry — nothing was
persisted, so Retry is a plain re-send (drops the failed pair first).
A turn aborted by SWITCHING sessions appends nothing (no stale bubble).
#356 items 4/6/7 land as promoted journeys (frontend/e2e/streaming.spec.ts):
stream-fails-to-open → transparent JSON fallback; Stop → interrupted +
Retry + zero rows persisted; switch-mid-stream → aborted stream, no stale
bubble, both sessions' rows untouched. The mid-stream window is real: the
function-mode seam now paces streamed replay (set_function_stream_delay_ms,
24-char chunks; the e2e handlers module opts in at 150ms and serves a long
deterministic reply on the E2E_SLOW_STREAM trigger). Item 3 (server Rung-1
legacy fallback) cannot run deterministically in the lane by design — its
live proof is tests/test_streaming_rung1_live.py (opt-in, billable), its
mechanics stay pinned in test_chat_stream.py. #164 gets a dashboard-entry
journey in tutor.spec.ts.
Verified under both dep universes: local venv (pydantic-ai 1.89) and a
lock-pinned scratch venv (1.107.0) — full backend suite 1316 passed;
frontend vitest 254 passed, tsc clean, lint 0 errors (suppressions pruned:
the new testids removed two suppressed occurrences).
Closes#164. Closes#356.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in:50 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: b002004b-ab00-40c8-b149-7c330912165e

📥 Commits

Reviewing files that changed from the base of the PR and between 90a5f99 and d6ab1f5.

📒 Files selected for processing (20)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/services/agent_events.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_learn_stream_routes.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_streaming_rung1_live.py
  • docs/decisions/0020-streaming-tutor-interrupt-retry.md
  • docs/frontend-testids.md
  • frontend/e2e/streaming.spec.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/components/ChatPanel.test.tsx
  • frontend/src/components/ChatPanel.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.resume.test.ts
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Tree.tsx

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 29, 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-stagingd6ab1f5Commit Preview URL

Branch Preview URL
Jul 29 2026, 07:28 PM

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 2 issues:

  1. The two new DB-readback tests in streaming.spec.ts decrypt-verify messages.content but skip the ciphertext-at-rest assertion (expect(row.content).not.toBe(plaintext)). support/decrypt.ts says "Specs must always make both" — decrypt_if_present echoes plaintext back unchanged, so decrypt-equality alone cannot prove the column was encrypted; if the encryption layer silently broke, both tests would still pass. The sibling tutor.spec.ts journey makes both checks.

expect(rows).toHaveLength(SEEDED_MESSAGE_COUNT+2);
const[userRow,assistantRow]=rows.slice(-2);
expect(userRow.role).toBe("user");
expect(assistantRow.role).toBe("assistant");
const[userPlain,assistantPlain]=awaitdecryptTexts([
userRow.content,
assistantRow.content,
]);
expect(userPlain).toBe(STUDENT_MESSAGE);
expect(assistantPlain).toBe(TUTOR_REPLY);
});

  1. The /learn?resume= deep link still paints the "Start a session" picker for the first frame(s): resuming starts false and only flips inside a post-paint useEffect, so the first committed render with a resume param takes the picker branch — the exact flash the new loading state exists to avoid. A lazy initializer (useState(() => !!readResumeParam(searchParams))) closes it deterministically.

// flashing the session picker the deep link is about to leave.
const[resuming,setResuming]=useState(false);
// Mirror of `sessionId` readable from async closures: `send`'s error paths

Lower-confidence items (below threshold, noted for the author): the success-path append after the un-abortable Rung-3 sendChat is not guarded by the new sessionIdRef check, so a session switch during a Rung-3 fallback can still append the old session's reply into the new transcript; the "three-rung fallback ladder" summary comment above send still describes the pre-ADR-0020 Stop/Rung-2 behavior; ADR 0020's "Scope / where it lands" section contradicts its updated Status line.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 29, 2026 12:17
- Guard the success-path append (and the streamingText clear) with the same
sessionIdRef/controller ownership checks as appendInterrupted — the
un-abortable Rung-3 sendChat could resolve after a session switch and
inject its reply into the other session's transcript. New journey pins it
(delayed JSON fallback + switch → reply dropped client-side, persisted
server-side to the original session only).
- streaming.spec.ts DB readbacks now assert ciphertext-at-rest AND
decrypt-equality (support/decrypt.ts: 'Specs must always make both').
- resuming state lazily initialized from the URL so a /learn?resume= deep
link's first paint is the loading branch, not a one-frame picker flash.
- send()'s three-rung ladder summary comment updated to the ADR-0020
behavior it now implements; beginSession's mirror comment documents the
greeting-scope exception.
- ADR 0020 'Scope / where it lands' rewritten to match the implemented
Status line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ansform
The e2e lane's two paced mid-stream journeys failed with ZERO tokens for
5s then everything at once: the stack serves the frontend with production
`next start`, whose default compress:true wraps the /api/* rewrite proxy
in the `compression` middleware, and gzip buffers small SSE frames until
end-of-response. Instant function-mode streams passed only because their
single burst IS the whole response — progressive rendering was silently
broken behind every self-hosted `next start` proxy (docker-compose too).
Verified server-side pacing was live (probe: deltas at 0.01..1.37s for a
10-chunk reply) before touching the HTTP layer. Fix: SSE_CACHE_CONTROL
("no-store, no-transform") on every EventSourceResponse — the middleware's
standard filter skips no-transform responses; sse_starlette only
setdefaults Cache-Control so the route value wins. Red-first route tests
pin the header on both learn streams.
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

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

fix(learn): consume /learn?resume= deep links (#164) + ADR-0020 interrupted-turn Retry + promoted streaming journeys (#356) - #461

Merged
AndresL230 merged 3 commits into
mainfrom
fix/164-356-tutor-resume-streaming
Jul 29, 2026
Merged

fix(learn): consume /learn?resume= deep links (#164) + ADR-0020 interrupted-turn Retry + promoted streaming journeys (#356)#461
AndresL230 merged 3 commits into
mainfrom
fix/164-356-tutor-resume-streaming

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B1 of the backlog clear — the tutor-resume P1 plus the streaming smoke tail, in one PR because they share files and journeys.

#164 — dead resume deep links

Learn.tsx read only topic/mode/course/suggest off the URL; Dashboard's "Where you left off" cards push /learn?resume=<id> and Tree's session rows pushed /learn?session=<id>, so both stranded users on the session picker. Now:

  • readResumeParam (exported, unit-tested) accepts resume (canonical) and session (legacy alias); Tree unified on ?resume=.
  • A mount effect consumes the param once per id (guard ref — the mode-sync URL rewrite preserves params and must not re-trigger it) and calls resumeSessionById, which is server-authoritative: topic/mode/course come off the resume payload, so deep links work for sessions outside the 10-row recent list.
  • A quiet loading state replaces the picker flash while the deep link hydrates.

#356 item 5 / ADR 0020 — interrupted turns keep the partial + offer Retry

The merged #349 code still discarded partials on Stop and rendered Rung-2 failures as an Error: assistant bubble. Per ADR 0020:

  • ChatMsg gains interrupted/retryText; ChatPanel renders the partial (dimmed, never blanked) + an "Interrupted" marker + Retry.
  • send's ladder: Stop, Rung-2, and a failed Rung-3 JSON fallback all append the interrupted bubble (error detail goes to a toast, not the transcript). Retry drops the failed pair (removeInterruptedTurn, pure + unit-tested) and re-sends — safe because the routes persist only on completion.
  • A turn aborted by switching sessions appends nothing (sessionIdRef guard) — otherwise the fix itself would have introduced the item-7 stale bubble.
  • Scope note added to ADR 0020: the greeting turn (beginSession) returns to the entry screen with the topic draft intact — there is no transcript turn to mark.

#356 items 4/6/7 — promoted journeys (frontend/e2e/streaming.spec.ts)

  • item 6: /api/learn/chat/stream route-aborted → transparent JSON fallback → deterministic reply + exactly one encrypted user/assistant pair (decrypt-verified).
  • items 4+5: Stop mid-stream → partial kept + interrupted + Retry, zero rows persisted; Retry completes and persists exactly one pair.
  • item 7: switch sessions mid-stream → SSE request torn down (requestfailed observed), no stale bubble, both sessions' row counts untouched.
  • Dashboard 'Where you left off' and Tree session links are dead — /learn?resume= and ?session= are never read #164: journey enters via the REAL dashboard card (new dashboard-resume-{id} testid) in tutor.spec.ts.

Mid-stream windows are real: the function-mode seam paces streamed replay (set_function_stream_delay_ms — 24-char chunks, reset by clear_function_handlers, no new lane env var: the e2e handlers module opts in at import with 150ms) and serves a long deterministic reply on the E2E_SLOW_STREAM message trigger. Constants stay byte-identical between the JSON and streamed lanes; handler-constant ↔ spec ↔ test_e2e_function_handlers.py sync maintained.

#356 item 3 — live evidence instead of a lane journey

The server-side Rung-1 legacy fallback (call_gemini_multiturn) has no function-mode gate by design (fallback-only, deleted in #151), so it cannot run deterministically in the e2e lane. New tests/test_streaming_rung1_live.py (opt-in RUN_LIVE_STREAM_RUNGS=1, billable, live_llm lane) proves the real failure shape degrades to a live legacy reply arriving as a single token + done with on_complete never called — run green locally. Mechanics stay pinned hermetically in test_chat_stream.py / test_learn_stream_routes.py.

Verification

  • Backend: full hermetic suite 1316 passed on the local venv (pydantic-ai 1.89) and the streaming-adjacent files re-run green on a lock-pinned scratch venv (pydantic-ai 1.107.0, the CI universe).
  • Frontend: vitest 254 passed (12 new), tsc --noEmit clean, lint 0 errors (suppressions pruned — the two new testids removed suppressed occurrences).
  • Live Rung-1 test: 1 passed against real Gemini.
  • Full local e2e cycle (Playwright + oracles under the stack flock): to be run pre-merge; results will be posted below.

🤖 Generated with Claude Code

Closes#164. Closes#356.

…rupted-turn Retry, promoted streaming journeys (#356)
#164: Learn read only topic/mode/course/suggest off the URL, so Dashboard's
'Where you left off' cards (?resume=) and Tree's session rows (?session=)
landed on the session picker — dead buttons. LearnInner now consumes
?resume= (legacy ?session= accepted) once per id via resumeSessionById,
which is server-authoritative (topic/mode/course off the resume payload),
with a loading state instead of a picker flash. Tree unified on ?resume=.
ADR 0020 (#356 item 5): a chat turn that is Stopped or fails mid-stream
(Rung 2, or the Rung-3 JSON fallback itself failing) now keeps the partial
reply, marks the bubble interrupted, and offers Retry — nothing was
persisted, so Retry is a plain re-send (drops the failed pair first).
A turn aborted by SWITCHING sessions appends nothing (no stale bubble).
#356 items 4/6/7 land as promoted journeys (frontend/e2e/streaming.spec.ts):
stream-fails-to-open → transparent JSON fallback; Stop → interrupted +
Retry + zero rows persisted; switch-mid-stream → aborted stream, no stale
bubble, both sessions' rows untouched. The mid-stream window is real: the
function-mode seam now paces streamed replay (set_function_stream_delay_ms,
24-char chunks; the e2e handlers module opts in at 150ms and serves a long
deterministic reply on the E2E_SLOW_STREAM trigger). Item 3 (server Rung-1
legacy fallback) cannot run deterministically in the lane by design — its
live proof is tests/test_streaming_rung1_live.py (opt-in, billable), its
mechanics stay pinned in test_chat_stream.py. #164 gets a dashboard-entry
journey in tutor.spec.ts.
Verified under both dep universes: local venv (pydantic-ai 1.89) and a
lock-pinned scratch venv (1.107.0) — full backend suite 1316 passed;
frontend vitest 254 passed, tsc clean, lint 0 errors (suppressions pruned:
the new testids removed two suppressed occurrences).
Closes#164. Closes#356.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in:50 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: b002004b-ab00-40c8-b149-7c330912165e

📥 Commits

Reviewing files that changed from the base of the PR and between 90a5f99 and d6ab1f5.

📒 Files selected for processing (20)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/services/agent_events.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_learn_stream_routes.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_streaming_rung1_live.py
  • docs/decisions/0020-streaming-tutor-interrupt-retry.md
  • docs/frontend-testids.md
  • frontend/e2e/streaming.spec.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/components/ChatPanel.test.tsx
  • frontend/src/components/ChatPanel.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.resume.test.ts
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Tree.tsx

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 29, 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-stagingd6ab1f5Commit Preview URL

Branch Preview URL
Jul 29 2026, 07:28 PM

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 2 issues:

  1. The two new DB-readback tests in streaming.spec.ts decrypt-verify messages.content but skip the ciphertext-at-rest assertion (expect(row.content).not.toBe(plaintext)). support/decrypt.ts says "Specs must always make both" — decrypt_if_present echoes plaintext back unchanged, so decrypt-equality alone cannot prove the column was encrypted; if the encryption layer silently broke, both tests would still pass. The sibling tutor.spec.ts journey makes both checks.

expect(rows).toHaveLength(SEEDED_MESSAGE_COUNT+2);
const[userRow,assistantRow]=rows.slice(-2);
expect(userRow.role).toBe("user");
expect(assistantRow.role).toBe("assistant");
const[userPlain,assistantPlain]=awaitdecryptTexts([
userRow.content,
assistantRow.content,
]);
expect(userPlain).toBe(STUDENT_MESSAGE);
expect(assistantPlain).toBe(TUTOR_REPLY);
});

  1. The /learn?resume= deep link still paints the "Start a session" picker for the first frame(s): resuming starts false and only flips inside a post-paint useEffect, so the first committed render with a resume param takes the picker branch — the exact flash the new loading state exists to avoid. A lazy initializer (useState(() => !!readResumeParam(searchParams))) closes it deterministically.

// flashing the session picker the deep link is about to leave.
const[resuming,setResuming]=useState(false);
// Mirror of `sessionId` readable from async closures: `send`'s error paths

Lower-confidence items (below threshold, noted for the author): the success-path append after the un-abortable Rung-3 sendChat is not guarded by the new sessionIdRef check, so a session switch during a Rung-3 fallback can still append the old session's reply into the new transcript; the "three-rung fallback ladder" summary comment above send still describes the pre-ADR-0020 Stop/Rung-2 behavior; ADR 0020's "Scope / where it lands" section contradicts its updated Status line.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 29, 2026 12:17
- Guard the success-path append (and the streamingText clear) with the same
sessionIdRef/controller ownership checks as appendInterrupted — the
un-abortable Rung-3 sendChat could resolve after a session switch and
inject its reply into the other session's transcript. New journey pins it
(delayed JSON fallback + switch → reply dropped client-side, persisted
server-side to the original session only).
- streaming.spec.ts DB readbacks now assert ciphertext-at-rest AND
decrypt-equality (support/decrypt.ts: 'Specs must always make both').
- resuming state lazily initialized from the URL so a /learn?resume= deep
link's first paint is the loading branch, not a one-frame picker flash.
- send()'s three-rung ladder summary comment updated to the ADR-0020
behavior it now implements; beginSession's mirror comment documents the
greeting-scope exception.
- ADR 0020 'Scope / where it lands' rewritten to match the implemented
Status line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ansform
The e2e lane's two paced mid-stream journeys failed with ZERO tokens for
5s then everything at once: the stack serves the frontend with production
`next start`, whose default compress:true wraps the /api/* rewrite proxy
in the `compression` middleware, and gzip buffers small SSE frames until
end-of-response. Instant function-mode streams passed only because their
single burst IS the whole response — progressive rendering was silently
broken behind every self-hosted `next start` proxy (docker-compose too).
Verified server-side pacing was live (probe: deltas at 0.01..1.37s for a
10-chunk reply) before touching the HTTP layer. Fix: SSE_CACHE_CONTROL
("no-store, no-transform") on every EventSourceResponse — the middleware's
standard filter skips no-transform responses; sse_starlette only
setdefaults Cache-Control so the route value wins. Red-first route tests
pin the header on both learn streams.
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

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

fix(learn): consume /learn?resume= deep links (#164) + ADR-0020 interrupted-turn Retry + promoted streaming journeys (#356) - #461

Merged
AndresL230 merged 3 commits into
mainfrom
fix/164-356-tutor-resume-streaming
Jul 29, 2026
Merged

fix(learn): consume /learn?resume= deep links (#164) + ADR-0020 interrupted-turn Retry + promoted streaming journeys (#356)#461
AndresL230 merged 3 commits into
mainfrom
fix/164-356-tutor-resume-streaming

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B1 of the backlog clear — the tutor-resume P1 plus the streaming smoke tail, in one PR because they share files and journeys.

#164 — dead resume deep links

Learn.tsx read only topic/mode/course/suggest off the URL; Dashboard's "Where you left off" cards push /learn?resume=<id> and Tree's session rows pushed /learn?session=<id>, so both stranded users on the session picker. Now:

  • readResumeParam (exported, unit-tested) accepts resume (canonical) and session (legacy alias); Tree unified on ?resume=.
  • A mount effect consumes the param once per id (guard ref — the mode-sync URL rewrite preserves params and must not re-trigger it) and calls resumeSessionById, which is server-authoritative: topic/mode/course come off the resume payload, so deep links work for sessions outside the 10-row recent list.
  • A quiet loading state replaces the picker flash while the deep link hydrates.

#356 item 5 / ADR 0020 — interrupted turns keep the partial + offer Retry

The merged #349 code still discarded partials on Stop and rendered Rung-2 failures as an Error: assistant bubble. Per ADR 0020:

  • ChatMsg gains interrupted/retryText; ChatPanel renders the partial (dimmed, never blanked) + an "Interrupted" marker + Retry.
  • send's ladder: Stop, Rung-2, and a failed Rung-3 JSON fallback all append the interrupted bubble (error detail goes to a toast, not the transcript). Retry drops the failed pair (removeInterruptedTurn, pure + unit-tested) and re-sends — safe because the routes persist only on completion.
  • A turn aborted by switching sessions appends nothing (sessionIdRef guard) — otherwise the fix itself would have introduced the item-7 stale bubble.
  • Scope note added to ADR 0020: the greeting turn (beginSession) returns to the entry screen with the topic draft intact — there is no transcript turn to mark.

#356 items 4/6/7 — promoted journeys (frontend/e2e/streaming.spec.ts)

  • item 6: /api/learn/chat/stream route-aborted → transparent JSON fallback → deterministic reply + exactly one encrypted user/assistant pair (decrypt-verified).
  • items 4+5: Stop mid-stream → partial kept + interrupted + Retry, zero rows persisted; Retry completes and persists exactly one pair.
  • item 7: switch sessions mid-stream → SSE request torn down (requestfailed observed), no stale bubble, both sessions' row counts untouched.
  • Dashboard 'Where you left off' and Tree session links are dead — /learn?resume= and ?session= are never read #164: journey enters via the REAL dashboard card (new dashboard-resume-{id} testid) in tutor.spec.ts.

Mid-stream windows are real: the function-mode seam paces streamed replay (set_function_stream_delay_ms — 24-char chunks, reset by clear_function_handlers, no new lane env var: the e2e handlers module opts in at import with 150ms) and serves a long deterministic reply on the E2E_SLOW_STREAM message trigger. Constants stay byte-identical between the JSON and streamed lanes; handler-constant ↔ spec ↔ test_e2e_function_handlers.py sync maintained.

#356 item 3 — live evidence instead of a lane journey

The server-side Rung-1 legacy fallback (call_gemini_multiturn) has no function-mode gate by design (fallback-only, deleted in #151), so it cannot run deterministically in the e2e lane. New tests/test_streaming_rung1_live.py (opt-in RUN_LIVE_STREAM_RUNGS=1, billable, live_llm lane) proves the real failure shape degrades to a live legacy reply arriving as a single token + done with on_complete never called — run green locally. Mechanics stay pinned hermetically in test_chat_stream.py / test_learn_stream_routes.py.

Verification

  • Backend: full hermetic suite 1316 passed on the local venv (pydantic-ai 1.89) and the streaming-adjacent files re-run green on a lock-pinned scratch venv (pydantic-ai 1.107.0, the CI universe).
  • Frontend: vitest 254 passed (12 new), tsc --noEmit clean, lint 0 errors (suppressions pruned — the two new testids removed suppressed occurrences).
  • Live Rung-1 test: 1 passed against real Gemini.
  • Full local e2e cycle (Playwright + oracles under the stack flock): to be run pre-merge; results will be posted below.

🤖 Generated with Claude Code

Closes#164. Closes#356.

…rupted-turn Retry, promoted streaming journeys (#356)
#164: Learn read only topic/mode/course/suggest off the URL, so Dashboard's
'Where you left off' cards (?resume=) and Tree's session rows (?session=)
landed on the session picker — dead buttons. LearnInner now consumes
?resume= (legacy ?session= accepted) once per id via resumeSessionById,
which is server-authoritative (topic/mode/course off the resume payload),
with a loading state instead of a picker flash. Tree unified on ?resume=.
ADR 0020 (#356 item 5): a chat turn that is Stopped or fails mid-stream
(Rung 2, or the Rung-3 JSON fallback itself failing) now keeps the partial
reply, marks the bubble interrupted, and offers Retry — nothing was
persisted, so Retry is a plain re-send (drops the failed pair first).
A turn aborted by SWITCHING sessions appends nothing (no stale bubble).
#356 items 4/6/7 land as promoted journeys (frontend/e2e/streaming.spec.ts):
stream-fails-to-open → transparent JSON fallback; Stop → interrupted +
Retry + zero rows persisted; switch-mid-stream → aborted stream, no stale
bubble, both sessions' rows untouched. The mid-stream window is real: the
function-mode seam now paces streamed replay (set_function_stream_delay_ms,
24-char chunks; the e2e handlers module opts in at 150ms and serves a long
deterministic reply on the E2E_SLOW_STREAM trigger). Item 3 (server Rung-1
legacy fallback) cannot run deterministically in the lane by design — its
live proof is tests/test_streaming_rung1_live.py (opt-in, billable), its
mechanics stay pinned in test_chat_stream.py. #164 gets a dashboard-entry
journey in tutor.spec.ts.
Verified under both dep universes: local venv (pydantic-ai 1.89) and a
lock-pinned scratch venv (1.107.0) — full backend suite 1316 passed;
frontend vitest 254 passed, tsc clean, lint 0 errors (suppressions pruned:
the new testids removed two suppressed occurrences).
Closes#164. Closes#356.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in:50 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: b002004b-ab00-40c8-b149-7c330912165e

📥 Commits

Reviewing files that changed from the base of the PR and between 90a5f99 and d6ab1f5.

📒 Files selected for processing (20)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/services/agent_events.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_learn_stream_routes.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_streaming_rung1_live.py
  • docs/decisions/0020-streaming-tutor-interrupt-retry.md
  • docs/frontend-testids.md
  • frontend/e2e/streaming.spec.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/components/ChatPanel.test.tsx
  • frontend/src/components/ChatPanel.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.resume.test.ts
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Tree.tsx

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 29, 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-stagingd6ab1f5Commit Preview URL

Branch Preview URL
Jul 29 2026, 07:28 PM

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 2 issues:

  1. The two new DB-readback tests in streaming.spec.ts decrypt-verify messages.content but skip the ciphertext-at-rest assertion (expect(row.content).not.toBe(plaintext)). support/decrypt.ts says "Specs must always make both" — decrypt_if_present echoes plaintext back unchanged, so decrypt-equality alone cannot prove the column was encrypted; if the encryption layer silently broke, both tests would still pass. The sibling tutor.spec.ts journey makes both checks.

expect(rows).toHaveLength(SEEDED_MESSAGE_COUNT+2);
const[userRow,assistantRow]=rows.slice(-2);
expect(userRow.role).toBe("user");
expect(assistantRow.role).toBe("assistant");
const[userPlain,assistantPlain]=awaitdecryptTexts([
userRow.content,
assistantRow.content,
]);
expect(userPlain).toBe(STUDENT_MESSAGE);
expect(assistantPlain).toBe(TUTOR_REPLY);
});

  1. The /learn?resume= deep link still paints the "Start a session" picker for the first frame(s): resuming starts false and only flips inside a post-paint useEffect, so the first committed render with a resume param takes the picker branch — the exact flash the new loading state exists to avoid. A lazy initializer (useState(() => !!readResumeParam(searchParams))) closes it deterministically.

// flashing the session picker the deep link is about to leave.
const[resuming,setResuming]=useState(false);
// Mirror of `sessionId` readable from async closures: `send`'s error paths

Lower-confidence items (below threshold, noted for the author): the success-path append after the un-abortable Rung-3 sendChat is not guarded by the new sessionIdRef check, so a session switch during a Rung-3 fallback can still append the old session's reply into the new transcript; the "three-rung fallback ladder" summary comment above send still describes the pre-ADR-0020 Stop/Rung-2 behavior; ADR 0020's "Scope / where it lands" section contradicts its updated Status line.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 29, 2026 12:17
- Guard the success-path append (and the streamingText clear) with the same
sessionIdRef/controller ownership checks as appendInterrupted — the
un-abortable Rung-3 sendChat could resolve after a session switch and
inject its reply into the other session's transcript. New journey pins it
(delayed JSON fallback + switch → reply dropped client-side, persisted
server-side to the original session only).
- streaming.spec.ts DB readbacks now assert ciphertext-at-rest AND
decrypt-equality (support/decrypt.ts: 'Specs must always make both').
- resuming state lazily initialized from the URL so a /learn?resume= deep
link's first paint is the loading branch, not a one-frame picker flash.
- send()'s three-rung ladder summary comment updated to the ADR-0020
behavior it now implements; beginSession's mirror comment documents the
greeting-scope exception.
- ADR 0020 'Scope / where it lands' rewritten to match the implemented
Status line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ansform
The e2e lane's two paced mid-stream journeys failed with ZERO tokens for
5s then everything at once: the stack serves the frontend with production
`next start`, whose default compress:true wraps the /api/* rewrite proxy
in the `compression` middleware, and gzip buffers small SSE frames until
end-of-response. Instant function-mode streams passed only because their
single burst IS the whole response — progressive rendering was silently
broken behind every self-hosted `next start` proxy (docker-compose too).
Verified server-side pacing was live (probe: deltas at 0.01..1.37s for a
10-chunk reply) before touching the HTTP layer. Fix: SSE_CACHE_CONTROL
("no-store, no-transform") on every EventSourceResponse — the middleware's
standard filter skips no-transform responses; sse_starlette only
setdefaults Cache-Control so the route value wins. Red-first route tests
pin the header on both learn streams.
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

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

fix(learn): consume /learn?resume= deep links (#164) + ADR-0020 interrupted-turn Retry + promoted streaming journeys (#356) - #461

Merged
AndresL230 merged 3 commits into
mainfrom
fix/164-356-tutor-resume-streaming
Jul 29, 2026
Merged

fix(learn): consume /learn?resume= deep links (#164) + ADR-0020 interrupted-turn Retry + promoted streaming journeys (#356)#461
AndresL230 merged 3 commits into
mainfrom
fix/164-356-tutor-resume-streaming

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B1 of the backlog clear — the tutor-resume P1 plus the streaming smoke tail, in one PR because they share files and journeys.

#164 — dead resume deep links

Learn.tsx read only topic/mode/course/suggest off the URL; Dashboard's "Where you left off" cards push /learn?resume=<id> and Tree's session rows pushed /learn?session=<id>, so both stranded users on the session picker. Now:

  • readResumeParam (exported, unit-tested) accepts resume (canonical) and session (legacy alias); Tree unified on ?resume=.
  • A mount effect consumes the param once per id (guard ref — the mode-sync URL rewrite preserves params and must not re-trigger it) and calls resumeSessionById, which is server-authoritative: topic/mode/course come off the resume payload, so deep links work for sessions outside the 10-row recent list.
  • A quiet loading state replaces the picker flash while the deep link hydrates.

#356 item 5 / ADR 0020 — interrupted turns keep the partial + offer Retry

The merged #349 code still discarded partials on Stop and rendered Rung-2 failures as an Error: assistant bubble. Per ADR 0020:

  • ChatMsg gains interrupted/retryText; ChatPanel renders the partial (dimmed, never blanked) + an "Interrupted" marker + Retry.
  • send's ladder: Stop, Rung-2, and a failed Rung-3 JSON fallback all append the interrupted bubble (error detail goes to a toast, not the transcript). Retry drops the failed pair (removeInterruptedTurn, pure + unit-tested) and re-sends — safe because the routes persist only on completion.
  • A turn aborted by switching sessions appends nothing (sessionIdRef guard) — otherwise the fix itself would have introduced the item-7 stale bubble.
  • Scope note added to ADR 0020: the greeting turn (beginSession) returns to the entry screen with the topic draft intact — there is no transcript turn to mark.

#356 items 4/6/7 — promoted journeys (frontend/e2e/streaming.spec.ts)

  • item 6: /api/learn/chat/stream route-aborted → transparent JSON fallback → deterministic reply + exactly one encrypted user/assistant pair (decrypt-verified).
  • items 4+5: Stop mid-stream → partial kept + interrupted + Retry, zero rows persisted; Retry completes and persists exactly one pair.
  • item 7: switch sessions mid-stream → SSE request torn down (requestfailed observed), no stale bubble, both sessions' row counts untouched.
  • Dashboard 'Where you left off' and Tree session links are dead — /learn?resume= and ?session= are never read #164: journey enters via the REAL dashboard card (new dashboard-resume-{id} testid) in tutor.spec.ts.

Mid-stream windows are real: the function-mode seam paces streamed replay (set_function_stream_delay_ms — 24-char chunks, reset by clear_function_handlers, no new lane env var: the e2e handlers module opts in at import with 150ms) and serves a long deterministic reply on the E2E_SLOW_STREAM message trigger. Constants stay byte-identical between the JSON and streamed lanes; handler-constant ↔ spec ↔ test_e2e_function_handlers.py sync maintained.

#356 item 3 — live evidence instead of a lane journey

The server-side Rung-1 legacy fallback (call_gemini_multiturn) has no function-mode gate by design (fallback-only, deleted in #151), so it cannot run deterministically in the e2e lane. New tests/test_streaming_rung1_live.py (opt-in RUN_LIVE_STREAM_RUNGS=1, billable, live_llm lane) proves the real failure shape degrades to a live legacy reply arriving as a single token + done with on_complete never called — run green locally. Mechanics stay pinned hermetically in test_chat_stream.py / test_learn_stream_routes.py.

Verification

  • Backend: full hermetic suite 1316 passed on the local venv (pydantic-ai 1.89) and the streaming-adjacent files re-run green on a lock-pinned scratch venv (pydantic-ai 1.107.0, the CI universe).
  • Frontend: vitest 254 passed (12 new), tsc --noEmit clean, lint 0 errors (suppressions pruned — the two new testids removed suppressed occurrences).
  • Live Rung-1 test: 1 passed against real Gemini.
  • Full local e2e cycle (Playwright + oracles under the stack flock): to be run pre-merge; results will be posted below.

🤖 Generated with Claude Code

Closes#164. Closes#356.

…rupted-turn Retry, promoted streaming journeys (#356)
#164: Learn read only topic/mode/course/suggest off the URL, so Dashboard's
'Where you left off' cards (?resume=) and Tree's session rows (?session=)
landed on the session picker — dead buttons. LearnInner now consumes
?resume= (legacy ?session= accepted) once per id via resumeSessionById,
which is server-authoritative (topic/mode/course off the resume payload),
with a loading state instead of a picker flash. Tree unified on ?resume=.
ADR 0020 (#356 item 5): a chat turn that is Stopped or fails mid-stream
(Rung 2, or the Rung-3 JSON fallback itself failing) now keeps the partial
reply, marks the bubble interrupted, and offers Retry — nothing was
persisted, so Retry is a plain re-send (drops the failed pair first).
A turn aborted by SWITCHING sessions appends nothing (no stale bubble).
#356 items 4/6/7 land as promoted journeys (frontend/e2e/streaming.spec.ts):
stream-fails-to-open → transparent JSON fallback; Stop → interrupted +
Retry + zero rows persisted; switch-mid-stream → aborted stream, no stale
bubble, both sessions' rows untouched. The mid-stream window is real: the
function-mode seam now paces streamed replay (set_function_stream_delay_ms,
24-char chunks; the e2e handlers module opts in at 150ms and serves a long
deterministic reply on the E2E_SLOW_STREAM trigger). Item 3 (server Rung-1
legacy fallback) cannot run deterministically in the lane by design — its
live proof is tests/test_streaming_rung1_live.py (opt-in, billable), its
mechanics stay pinned in test_chat_stream.py. #164 gets a dashboard-entry
journey in tutor.spec.ts.
Verified under both dep universes: local venv (pydantic-ai 1.89) and a
lock-pinned scratch venv (1.107.0) — full backend suite 1316 passed;
frontend vitest 254 passed, tsc clean, lint 0 errors (suppressions pruned:
the new testids removed two suppressed occurrences).
Closes#164. Closes#356.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in:50 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: b002004b-ab00-40c8-b149-7c330912165e

📥 Commits

Reviewing files that changed from the base of the PR and between 90a5f99 and d6ab1f5.

📒 Files selected for processing (20)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/services/agent_events.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_learn_stream_routes.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_streaming_rung1_live.py
  • docs/decisions/0020-streaming-tutor-interrupt-retry.md
  • docs/frontend-testids.md
  • frontend/e2e/streaming.spec.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/components/ChatPanel.test.tsx
  • frontend/src/components/ChatPanel.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.resume.test.ts
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Tree.tsx

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 29, 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-stagingd6ab1f5Commit Preview URL

Branch Preview URL
Jul 29 2026, 07:28 PM

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 2 issues:

  1. The two new DB-readback tests in streaming.spec.ts decrypt-verify messages.content but skip the ciphertext-at-rest assertion (expect(row.content).not.toBe(plaintext)). support/decrypt.ts says "Specs must always make both" — decrypt_if_present echoes plaintext back unchanged, so decrypt-equality alone cannot prove the column was encrypted; if the encryption layer silently broke, both tests would still pass. The sibling tutor.spec.ts journey makes both checks.

expect(rows).toHaveLength(SEEDED_MESSAGE_COUNT+2);
const[userRow,assistantRow]=rows.slice(-2);
expect(userRow.role).toBe("user");
expect(assistantRow.role).toBe("assistant");
const[userPlain,assistantPlain]=awaitdecryptTexts([
userRow.content,
assistantRow.content,
]);
expect(userPlain).toBe(STUDENT_MESSAGE);
expect(assistantPlain).toBe(TUTOR_REPLY);
});

  1. The /learn?resume= deep link still paints the "Start a session" picker for the first frame(s): resuming starts false and only flips inside a post-paint useEffect, so the first committed render with a resume param takes the picker branch — the exact flash the new loading state exists to avoid. A lazy initializer (useState(() => !!readResumeParam(searchParams))) closes it deterministically.

// flashing the session picker the deep link is about to leave.
const[resuming,setResuming]=useState(false);
// Mirror of `sessionId` readable from async closures: `send`'s error paths

Lower-confidence items (below threshold, noted for the author): the success-path append after the un-abortable Rung-3 sendChat is not guarded by the new sessionIdRef check, so a session switch during a Rung-3 fallback can still append the old session's reply into the new transcript; the "three-rung fallback ladder" summary comment above send still describes the pre-ADR-0020 Stop/Rung-2 behavior; ADR 0020's "Scope / where it lands" section contradicts its updated Status line.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 29, 2026 12:17
- Guard the success-path append (and the streamingText clear) with the same
sessionIdRef/controller ownership checks as appendInterrupted — the
un-abortable Rung-3 sendChat could resolve after a session switch and
inject its reply into the other session's transcript. New journey pins it
(delayed JSON fallback + switch → reply dropped client-side, persisted
server-side to the original session only).
- streaming.spec.ts DB readbacks now assert ciphertext-at-rest AND
decrypt-equality (support/decrypt.ts: 'Specs must always make both').
- resuming state lazily initialized from the URL so a /learn?resume= deep
link's first paint is the loading branch, not a one-frame picker flash.
- send()'s three-rung ladder summary comment updated to the ADR-0020
behavior it now implements; beginSession's mirror comment documents the
greeting-scope exception.
- ADR 0020 'Scope / where it lands' rewritten to match the implemented
Status line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ansform
The e2e lane's two paced mid-stream journeys failed with ZERO tokens for
5s then everything at once: the stack serves the frontend with production
`next start`, whose default compress:true wraps the /api/* rewrite proxy
in the `compression` middleware, and gzip buffers small SSE frames until
end-of-response. Instant function-mode streams passed only because their
single burst IS the whole response — progressive rendering was silently
broken behind every self-hosted `next start` proxy (docker-compose too).
Verified server-side pacing was live (probe: deltas at 0.01..1.37s for a
10-chunk reply) before touching the HTTP layer. Fix: SSE_CACHE_CONTROL
("no-store, no-transform") on every EventSourceResponse — the middleware's
standard filter skips no-transform responses; sse_starlette only
setdefaults Cache-Control so the route value wins. Red-first route tests
pin the header on both learn streams.
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

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

fix(learn): consume /learn?resume= deep links (#164) + ADR-0020 interrupted-turn Retry + promoted streaming journeys (#356) - #461

Merged
AndresL230 merged 3 commits into
mainfrom
fix/164-356-tutor-resume-streaming
Jul 29, 2026
Merged

fix(learn): consume /learn?resume= deep links (#164) + ADR-0020 interrupted-turn Retry + promoted streaming journeys (#356)#461
AndresL230 merged 3 commits into
mainfrom
fix/164-356-tutor-resume-streaming

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B1 of the backlog clear — the tutor-resume P1 plus the streaming smoke tail, in one PR because they share files and journeys.

#164 — dead resume deep links

Learn.tsx read only topic/mode/course/suggest off the URL; Dashboard's "Where you left off" cards push /learn?resume=<id> and Tree's session rows pushed /learn?session=<id>, so both stranded users on the session picker. Now:

  • readResumeParam (exported, unit-tested) accepts resume (canonical) and session (legacy alias); Tree unified on ?resume=.
  • A mount effect consumes the param once per id (guard ref — the mode-sync URL rewrite preserves params and must not re-trigger it) and calls resumeSessionById, which is server-authoritative: topic/mode/course come off the resume payload, so deep links work for sessions outside the 10-row recent list.
  • A quiet loading state replaces the picker flash while the deep link hydrates.

#356 item 5 / ADR 0020 — interrupted turns keep the partial + offer Retry

The merged #349 code still discarded partials on Stop and rendered Rung-2 failures as an Error: assistant bubble. Per ADR 0020:

  • ChatMsg gains interrupted/retryText; ChatPanel renders the partial (dimmed, never blanked) + an "Interrupted" marker + Retry.
  • send's ladder: Stop, Rung-2, and a failed Rung-3 JSON fallback all append the interrupted bubble (error detail goes to a toast, not the transcript). Retry drops the failed pair (removeInterruptedTurn, pure + unit-tested) and re-sends — safe because the routes persist only on completion.
  • A turn aborted by switching sessions appends nothing (sessionIdRef guard) — otherwise the fix itself would have introduced the item-7 stale bubble.
  • Scope note added to ADR 0020: the greeting turn (beginSession) returns to the entry screen with the topic draft intact — there is no transcript turn to mark.

#356 items 4/6/7 — promoted journeys (frontend/e2e/streaming.spec.ts)

  • item 6: /api/learn/chat/stream route-aborted → transparent JSON fallback → deterministic reply + exactly one encrypted user/assistant pair (decrypt-verified).
  • items 4+5: Stop mid-stream → partial kept + interrupted + Retry, zero rows persisted; Retry completes and persists exactly one pair.
  • item 7: switch sessions mid-stream → SSE request torn down (requestfailed observed), no stale bubble, both sessions' row counts untouched.
  • Dashboard 'Where you left off' and Tree session links are dead — /learn?resume= and ?session= are never read #164: journey enters via the REAL dashboard card (new dashboard-resume-{id} testid) in tutor.spec.ts.

Mid-stream windows are real: the function-mode seam paces streamed replay (set_function_stream_delay_ms — 24-char chunks, reset by clear_function_handlers, no new lane env var: the e2e handlers module opts in at import with 150ms) and serves a long deterministic reply on the E2E_SLOW_STREAM message trigger. Constants stay byte-identical between the JSON and streamed lanes; handler-constant ↔ spec ↔ test_e2e_function_handlers.py sync maintained.

#356 item 3 — live evidence instead of a lane journey

The server-side Rung-1 legacy fallback (call_gemini_multiturn) has no function-mode gate by design (fallback-only, deleted in #151), so it cannot run deterministically in the e2e lane. New tests/test_streaming_rung1_live.py (opt-in RUN_LIVE_STREAM_RUNGS=1, billable, live_llm lane) proves the real failure shape degrades to a live legacy reply arriving as a single token + done with on_complete never called — run green locally. Mechanics stay pinned hermetically in test_chat_stream.py / test_learn_stream_routes.py.

Verification

  • Backend: full hermetic suite 1316 passed on the local venv (pydantic-ai 1.89) and the streaming-adjacent files re-run green on a lock-pinned scratch venv (pydantic-ai 1.107.0, the CI universe).
  • Frontend: vitest 254 passed (12 new), tsc --noEmit clean, lint 0 errors (suppressions pruned — the two new testids removed suppressed occurrences).
  • Live Rung-1 test: 1 passed against real Gemini.
  • Full local e2e cycle (Playwright + oracles under the stack flock): to be run pre-merge; results will be posted below.

🤖 Generated with Claude Code

Closes#164. Closes#356.

…rupted-turn Retry, promoted streaming journeys (#356)
#164: Learn read only topic/mode/course/suggest off the URL, so Dashboard's
'Where you left off' cards (?resume=) and Tree's session rows (?session=)
landed on the session picker — dead buttons. LearnInner now consumes
?resume= (legacy ?session= accepted) once per id via resumeSessionById,
which is server-authoritative (topic/mode/course off the resume payload),
with a loading state instead of a picker flash. Tree unified on ?resume=.
ADR 0020 (#356 item 5): a chat turn that is Stopped or fails mid-stream
(Rung 2, or the Rung-3 JSON fallback itself failing) now keeps the partial
reply, marks the bubble interrupted, and offers Retry — nothing was
persisted, so Retry is a plain re-send (drops the failed pair first).
A turn aborted by SWITCHING sessions appends nothing (no stale bubble).
#356 items 4/6/7 land as promoted journeys (frontend/e2e/streaming.spec.ts):
stream-fails-to-open → transparent JSON fallback; Stop → interrupted +
Retry + zero rows persisted; switch-mid-stream → aborted stream, no stale
bubble, both sessions' rows untouched. The mid-stream window is real: the
function-mode seam now paces streamed replay (set_function_stream_delay_ms,
24-char chunks; the e2e handlers module opts in at 150ms and serves a long
deterministic reply on the E2E_SLOW_STREAM trigger). Item 3 (server Rung-1
legacy fallback) cannot run deterministically in the lane by design — its
live proof is tests/test_streaming_rung1_live.py (opt-in, billable), its
mechanics stay pinned in test_chat_stream.py. #164 gets a dashboard-entry
journey in tutor.spec.ts.
Verified under both dep universes: local venv (pydantic-ai 1.89) and a
lock-pinned scratch venv (1.107.0) — full backend suite 1316 passed;
frontend vitest 254 passed, tsc clean, lint 0 errors (suppressions pruned:
the new testids removed two suppressed occurrences).
Closes#164. Closes#356.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in:50 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: b002004b-ab00-40c8-b149-7c330912165e

📥 Commits

Reviewing files that changed from the base of the PR and between 90a5f99 and d6ab1f5.

📒 Files selected for processing (20)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/services/agent_events.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_learn_stream_routes.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_streaming_rung1_live.py
  • docs/decisions/0020-streaming-tutor-interrupt-retry.md
  • docs/frontend-testids.md
  • frontend/e2e/streaming.spec.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/components/ChatPanel.test.tsx
  • frontend/src/components/ChatPanel.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.resume.test.ts
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Tree.tsx

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 29, 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-stagingd6ab1f5Commit Preview URL

Branch Preview URL
Jul 29 2026, 07:28 PM

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 2 issues:

  1. The two new DB-readback tests in streaming.spec.ts decrypt-verify messages.content but skip the ciphertext-at-rest assertion (expect(row.content).not.toBe(plaintext)). support/decrypt.ts says "Specs must always make both" — decrypt_if_present echoes plaintext back unchanged, so decrypt-equality alone cannot prove the column was encrypted; if the encryption layer silently broke, both tests would still pass. The sibling tutor.spec.ts journey makes both checks.

expect(rows).toHaveLength(SEEDED_MESSAGE_COUNT+2);
const[userRow,assistantRow]=rows.slice(-2);
expect(userRow.role).toBe("user");
expect(assistantRow.role).toBe("assistant");
const[userPlain,assistantPlain]=awaitdecryptTexts([
userRow.content,
assistantRow.content,
]);
expect(userPlain).toBe(STUDENT_MESSAGE);
expect(assistantPlain).toBe(TUTOR_REPLY);
});

  1. The /learn?resume= deep link still paints the "Start a session" picker for the first frame(s): resuming starts false and only flips inside a post-paint useEffect, so the first committed render with a resume param takes the picker branch — the exact flash the new loading state exists to avoid. A lazy initializer (useState(() => !!readResumeParam(searchParams))) closes it deterministically.

// flashing the session picker the deep link is about to leave.
const[resuming,setResuming]=useState(false);
// Mirror of `sessionId` readable from async closures: `send`'s error paths

Lower-confidence items (below threshold, noted for the author): the success-path append after the un-abortable Rung-3 sendChat is not guarded by the new sessionIdRef check, so a session switch during a Rung-3 fallback can still append the old session's reply into the new transcript; the "three-rung fallback ladder" summary comment above send still describes the pre-ADR-0020 Stop/Rung-2 behavior; ADR 0020's "Scope / where it lands" section contradicts its updated Status line.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 29, 2026 12:17
- Guard the success-path append (and the streamingText clear) with the same
sessionIdRef/controller ownership checks as appendInterrupted — the
un-abortable Rung-3 sendChat could resolve after a session switch and
inject its reply into the other session's transcript. New journey pins it
(delayed JSON fallback + switch → reply dropped client-side, persisted
server-side to the original session only).
- streaming.spec.ts DB readbacks now assert ciphertext-at-rest AND
decrypt-equality (support/decrypt.ts: 'Specs must always make both').
- resuming state lazily initialized from the URL so a /learn?resume= deep
link's first paint is the loading branch, not a one-frame picker flash.
- send()'s three-rung ladder summary comment updated to the ADR-0020
behavior it now implements; beginSession's mirror comment documents the
greeting-scope exception.
- ADR 0020 'Scope / where it lands' rewritten to match the implemented
Status line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ansform
The e2e lane's two paced mid-stream journeys failed with ZERO tokens for
5s then everything at once: the stack serves the frontend with production
`next start`, whose default compress:true wraps the /api/* rewrite proxy
in the `compression` middleware, and gzip buffers small SSE frames until
end-of-response. Instant function-mode streams passed only because their
single burst IS the whole response — progressive rendering was silently
broken behind every self-hosted `next start` proxy (docker-compose too).
Verified server-side pacing was live (probe: deltas at 0.01..1.37s for a
10-chunk reply) before touching the HTTP layer. Fix: SSE_CACHE_CONTROL
("no-store, no-transform") on every EventSourceResponse — the middleware's
standard filter skips no-transform responses; sse_starlette only
setdefaults Cache-Control so the route value wins. Red-first route tests
pin the header on both learn streams.
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

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

fix(learn): consume /learn?resume= deep links (#164) + ADR-0020 interrupted-turn Retry + promoted streaming journeys (#356) - #461

Merged
AndresL230 merged 3 commits into
mainfrom
fix/164-356-tutor-resume-streaming
Jul 29, 2026
Merged

fix(learn): consume /learn?resume= deep links (#164) + ADR-0020 interrupted-turn Retry + promoted streaming journeys (#356)#461
AndresL230 merged 3 commits into
mainfrom
fix/164-356-tutor-resume-streaming

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B1 of the backlog clear — the tutor-resume P1 plus the streaming smoke tail, in one PR because they share files and journeys.

#164 — dead resume deep links

Learn.tsx read only topic/mode/course/suggest off the URL; Dashboard's "Where you left off" cards push /learn?resume=<id> and Tree's session rows pushed /learn?session=<id>, so both stranded users on the session picker. Now:

  • readResumeParam (exported, unit-tested) accepts resume (canonical) and session (legacy alias); Tree unified on ?resume=.
  • A mount effect consumes the param once per id (guard ref — the mode-sync URL rewrite preserves params and must not re-trigger it) and calls resumeSessionById, which is server-authoritative: topic/mode/course come off the resume payload, so deep links work for sessions outside the 10-row recent list.
  • A quiet loading state replaces the picker flash while the deep link hydrates.

#356 item 5 / ADR 0020 — interrupted turns keep the partial + offer Retry

The merged #349 code still discarded partials on Stop and rendered Rung-2 failures as an Error: assistant bubble. Per ADR 0020:

  • ChatMsg gains interrupted/retryText; ChatPanel renders the partial (dimmed, never blanked) + an "Interrupted" marker + Retry.
  • send's ladder: Stop, Rung-2, and a failed Rung-3 JSON fallback all append the interrupted bubble (error detail goes to a toast, not the transcript). Retry drops the failed pair (removeInterruptedTurn, pure + unit-tested) and re-sends — safe because the routes persist only on completion.
  • A turn aborted by switching sessions appends nothing (sessionIdRef guard) — otherwise the fix itself would have introduced the item-7 stale bubble.
  • Scope note added to ADR 0020: the greeting turn (beginSession) returns to the entry screen with the topic draft intact — there is no transcript turn to mark.

#356 items 4/6/7 — promoted journeys (frontend/e2e/streaming.spec.ts)

  • item 6: /api/learn/chat/stream route-aborted → transparent JSON fallback → deterministic reply + exactly one encrypted user/assistant pair (decrypt-verified).
  • items 4+5: Stop mid-stream → partial kept + interrupted + Retry, zero rows persisted; Retry completes and persists exactly one pair.
  • item 7: switch sessions mid-stream → SSE request torn down (requestfailed observed), no stale bubble, both sessions' row counts untouched.
  • Dashboard 'Where you left off' and Tree session links are dead — /learn?resume= and ?session= are never read #164: journey enters via the REAL dashboard card (new dashboard-resume-{id} testid) in tutor.spec.ts.

Mid-stream windows are real: the function-mode seam paces streamed replay (set_function_stream_delay_ms — 24-char chunks, reset by clear_function_handlers, no new lane env var: the e2e handlers module opts in at import with 150ms) and serves a long deterministic reply on the E2E_SLOW_STREAM message trigger. Constants stay byte-identical between the JSON and streamed lanes; handler-constant ↔ spec ↔ test_e2e_function_handlers.py sync maintained.

#356 item 3 — live evidence instead of a lane journey

The server-side Rung-1 legacy fallback (call_gemini_multiturn) has no function-mode gate by design (fallback-only, deleted in #151), so it cannot run deterministically in the e2e lane. New tests/test_streaming_rung1_live.py (opt-in RUN_LIVE_STREAM_RUNGS=1, billable, live_llm lane) proves the real failure shape degrades to a live legacy reply arriving as a single token + done with on_complete never called — run green locally. Mechanics stay pinned hermetically in test_chat_stream.py / test_learn_stream_routes.py.

Verification

  • Backend: full hermetic suite 1316 passed on the local venv (pydantic-ai 1.89) and the streaming-adjacent files re-run green on a lock-pinned scratch venv (pydantic-ai 1.107.0, the CI universe).
  • Frontend: vitest 254 passed (12 new), tsc --noEmit clean, lint 0 errors (suppressions pruned — the two new testids removed suppressed occurrences).
  • Live Rung-1 test: 1 passed against real Gemini.
  • Full local e2e cycle (Playwright + oracles under the stack flock): to be run pre-merge; results will be posted below.

🤖 Generated with Claude Code

Closes#164. Closes#356.

…rupted-turn Retry, promoted streaming journeys (#356)
#164: Learn read only topic/mode/course/suggest off the URL, so Dashboard's
'Where you left off' cards (?resume=) and Tree's session rows (?session=)
landed on the session picker — dead buttons. LearnInner now consumes
?resume= (legacy ?session= accepted) once per id via resumeSessionById,
which is server-authoritative (topic/mode/course off the resume payload),
with a loading state instead of a picker flash. Tree unified on ?resume=.
ADR 0020 (#356 item 5): a chat turn that is Stopped or fails mid-stream
(Rung 2, or the Rung-3 JSON fallback itself failing) now keeps the partial
reply, marks the bubble interrupted, and offers Retry — nothing was
persisted, so Retry is a plain re-send (drops the failed pair first).
A turn aborted by SWITCHING sessions appends nothing (no stale bubble).
#356 items 4/6/7 land as promoted journeys (frontend/e2e/streaming.spec.ts):
stream-fails-to-open → transparent JSON fallback; Stop → interrupted +
Retry + zero rows persisted; switch-mid-stream → aborted stream, no stale
bubble, both sessions' rows untouched. The mid-stream window is real: the
function-mode seam now paces streamed replay (set_function_stream_delay_ms,
24-char chunks; the e2e handlers module opts in at 150ms and serves a long
deterministic reply on the E2E_SLOW_STREAM trigger). Item 3 (server Rung-1
legacy fallback) cannot run deterministically in the lane by design — its
live proof is tests/test_streaming_rung1_live.py (opt-in, billable), its
mechanics stay pinned in test_chat_stream.py. #164 gets a dashboard-entry
journey in tutor.spec.ts.
Verified under both dep universes: local venv (pydantic-ai 1.89) and a
lock-pinned scratch venv (1.107.0) — full backend suite 1316 passed;
frontend vitest 254 passed, tsc clean, lint 0 errors (suppressions pruned:
the new testids removed two suppressed occurrences).
Closes#164. Closes#356.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in:50 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: b002004b-ab00-40c8-b149-7c330912165e

📥 Commits

Reviewing files that changed from the base of the PR and between 90a5f99 and d6ab1f5.

📒 Files selected for processing (20)
  • backend/agents/_providers.py
  • backend/agents/function_handlers_e2e.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/services/agent_events.py
  • backend/tests/test_e2e_function_handlers.py
  • backend/tests/test_learn_stream_routes.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_streaming_rung1_live.py
  • docs/decisions/0020-streaming-tutor-interrupt-retry.md
  • docs/frontend-testids.md
  • frontend/e2e/streaming.spec.ts
  • frontend/e2e/tutor.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/components/ChatPanel.test.tsx
  • frontend/src/components/ChatPanel.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.resume.test.ts
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Tree.tsx

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 29, 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-stagingd6ab1f5Commit Preview URL

Branch Preview URL
Jul 29 2026, 07:28 PM

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 2 issues:

  1. The two new DB-readback tests in streaming.spec.ts decrypt-verify messages.content but skip the ciphertext-at-rest assertion (expect(row.content).not.toBe(plaintext)). support/decrypt.ts says "Specs must always make both" — decrypt_if_present echoes plaintext back unchanged, so decrypt-equality alone cannot prove the column was encrypted; if the encryption layer silently broke, both tests would still pass. The sibling tutor.spec.ts journey makes both checks.

expect(rows).toHaveLength(SEEDED_MESSAGE_COUNT+2);
const[userRow,assistantRow]=rows.slice(-2);
expect(userRow.role).toBe("user");
expect(assistantRow.role).toBe("assistant");
const[userPlain,assistantPlain]=awaitdecryptTexts([
userRow.content,
assistantRow.content,
]);
expect(userPlain).toBe(STUDENT_MESSAGE);
expect(assistantPlain).toBe(TUTOR_REPLY);
});

  1. The /learn?resume= deep link still paints the "Start a session" picker for the first frame(s): resuming starts false and only flips inside a post-paint useEffect, so the first committed render with a resume param takes the picker branch — the exact flash the new loading state exists to avoid. A lazy initializer (useState(() => !!readResumeParam(searchParams))) closes it deterministically.

// flashing the session picker the deep link is about to leave.
const[resuming,setResuming]=useState(false);
// Mirror of `sessionId` readable from async closures: `send`'s error paths

Lower-confidence items (below threshold, noted for the author): the success-path append after the un-abortable Rung-3 sendChat is not guarded by the new sessionIdRef check, so a session switch during a Rung-3 fallback can still append the old session's reply into the new transcript; the "three-rung fallback ladder" summary comment above send still describes the pre-ADR-0020 Stop/Rung-2 behavior; ADR 0020's "Scope / where it lands" section contradicts its updated Status line.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 29, 2026 12:17
- Guard the success-path append (and the streamingText clear) with the same
sessionIdRef/controller ownership checks as appendInterrupted — the
un-abortable Rung-3 sendChat could resolve after a session switch and
inject its reply into the other session's transcript. New journey pins it
(delayed JSON fallback + switch → reply dropped client-side, persisted
server-side to the original session only).
- streaming.spec.ts DB readbacks now assert ciphertext-at-rest AND
decrypt-equality (support/decrypt.ts: 'Specs must always make both').
- resuming state lazily initialized from the URL so a /learn?resume= deep
link's first paint is the loading branch, not a one-frame picker flash.
- send()'s three-rung ladder summary comment updated to the ADR-0020
behavior it now implements; beginSession's mirror comment documents the
greeting-scope exception.
- ADR 0020 'Scope / where it lands' rewritten to match the implemented
Status line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ansform
The e2e lane's two paced mid-stream journeys failed with ZERO tokens for
5s then everything at once: the stack serves the frontend with production
`next start`, whose default compress:true wraps the /api/* rewrite proxy
in the `compression` middleware, and gzip buffers small SSE frames until
end-of-response. Instant function-mode streams passed only because their
single burst IS the whole response — progressive rendering was silently
broken behind every self-hosted `next start` proxy (docker-compose too).
Verified server-side pacing was live (probe: deltas at 0.01..1.37s for a
10-chunk reply) before touching the HTTP layer. Fix: SSE_CACHE_CONTROL
("no-store, no-transform") on every EventSourceResponse — the middleware's
standard filter skips no-transform responses; sse_starlette only
setdefaults Cache-Control so the route value wins. Red-first route tests
pin the header on both learn streams.
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

1 participant

@AndresL230