Skip to content

Commit 3075b53

Browse files
mariuspruvotclaude
andcommitted
fix(comprehension): finalize Story 3-3 — manual QA + cross-cutting fixes
Bundle of Story 3-3 follow-up patches and three regressions surfaced during the live manual QA session on 2026-04-11. ## Manual QA fixes (cross-cutting) * Epic 1 regression — get_installations_for_user called GET /user/installations, which 403s for OAuth-App tokens (only GitHub-App user-auth tokens work there). Refactored to verify access locally for User-type installs and via /user/orgs for Org-type installs. Added read:org to OAUTH_SCOPES. Updated test_router mock. This regression silently broke GET /api/v1/sessions/{id} (502 Bad Gateway) and would have shipped to production if Story 3-3 hadn't exercised the path end-to-end. * Story 3-3 SSE auth wiring (3 compounding bugs): - useSSE.ts doc claimed JWT lived in a cookie; it lives in the Zustand store. EventSource cannot send custom headers. - ChatPanel built a relative SSE URL; Vite dev does not proxy to the API on port 8000. - get_current_user only read the Authorization header. Fix: backend accepts ?access_token= query-param fallback (security trade-off documented inline). Frontend builds an absolute URL with the JWT in the query string. API_BASE exported from shared/api/client.ts. 2 new dependency tests for the new auth path. * Story 3-2 dark-mode contrast — react-diff-view's default CSS uses pale-green / pale-red backgrounds with `color: initial`, making the diff text unreadable on the dark surface. Override the --diff-* CSS custom properties in index.css. Cascade order fix: import the library CSS in main.tsx BEFORE index.css. ## Story 3-3 follow-up patches Bundled together: SSE generator polish, repository test additions, agents test, diff_refs test, store/useSSE test additions, alembic migration tweaks. See individual file diffs. ## QA results 20/26 checklist items checked, 6 explicitly skipped per Option 3 scoping (rationale documented in the story file's Manual QA Results section). 4 deferred follow-ups documented (no SSE replay on resume, StrictMode double-mount rollback, misleading Connection-lost banner for 4xx, react-diff-view markdown tokenize warning). Story 3-3 transitions awaiting-manual-qa → done in sprint-status.yaml. ## Verification 259 backend tests + 57 frontend tests + ruff + ruff format + eslint all green on the bundled state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7d20e78 commit 3075b53

26 files changed

Lines changed: 1478 additions & 149 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ apps/web/dist/
2222
.env.local
2323
.env.*.local
2424

25+
# Local docker compose overrides (may contain secrets like GitHub App private keys for local dev)
26+
docker-compose.override.yml
27+
docker-compose.override.yaml
28+
2529
# IDE
2630
.idea/
2731
.vscode/

_bmad-output/implementation-artifacts/sprint-status.yaml

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
# 'awaiting-manual-qa', unless invoked with `--skip-manual-qa-gate <reason>`.
4646

4747
generated: 2026-04-09
48-
last_updated: 2026-04-11 # 3-3 → review (dev-story complete: question generation + SSE streaming)
48+
last_updated: 2026-04-11 # 3-3 → done after live manual QA session (Project Lead Marius). 20/26 checklist items checked, 6/26 explicitly skipped with rationale (Option 3 scoping). Session surfaced + fixed: 1 Epic 1 regression (get_installations_for_user / OAuth scopes), 1 Story 3-3 SSE auth wiring (3 compounding bugs), 1 Story 3-2 dark-mode contrast. 4 deferred follow-ups filed. 259 backend + 57 frontend tests green after fixes.
4949
project: helprs
5050
project_key: NOKEY
5151
tracking_system: file-system
@@ -114,7 +114,21 @@ development_status:
114114
# replaced "Story 3.3 backlog" prose with grep-able TODO(story-3.3).
115115
# make lint && vitest = clean + 24 web tests green (24 = 25 −3 +2).
116116
3-2-split-view-session-ui-and-diff-viewer: done
117-
3-3-question-generation-and-sse-streaming: review
117+
# Manual QA completed 2026-04-11 by Project Lead. 20/26 items checked,
118+
# 6 explicitly skipped per Option 3 scoping (rationale documented in
119+
# the story file's "Manual QA Results" section). QA session surfaced
120+
# AND fixed 3 bugs: (1) Epic 1 regression — get_installations_for_user
121+
# called /user/installations which 403s for OAuth-App tokens; refactored
122+
# to use local DB + /user/orgs and added read:org scope; (2) Story 3-3
123+
# SSE auth wiring — useSSE doc claimed cookie auth but JWT was in
124+
# memory; ChatPanel built relative URL; get_current_user only read
125+
# header; fixed via query-param fallback + absolute URL. (3) Story 3-2
126+
# dark-mode CSS — react-diff-view default theme unreadable on dark
127+
# surface; overrode --diff-* CSS vars + reordered import cascade.
128+
# 4 deferred follow-ups filed (no SSE replay on resume, StrictMode
129+
# double-mount rollback, misleading "Connection lost" for 4xx,
130+
# markdown tokenize warning). NONE of these block 3-3 done.
131+
3-3-question-generation-and-sse-streaming: done
118132
3-4-answer-submission-and-feedback-with-code-links: backlog
119133
3-5-role-adaptation-beyond-diff-and-large-pr-handling: backlog
120134
epic-3-retrospective: optional

apps/api/alembic/versions/a1b2c3d4e5f6_add_questions_table_and_total_.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525

2626
def upgrade() -> None:
2727
# ---- sessions.total_questions (additive, nullable=False + default) ----
28+
# The server_default is used ONLY to backfill existing rows during the
29+
# ADD COLUMN. We drop it immediately afterward (P13 from story-3.3
30+
# review) so the application layer is the only source of truth for
31+
# this value — forgetting to set ``total_questions`` in an INSERT
32+
# should fail loud, not silently land at 0.
2833
op.add_column(
2934
"sessions",
3035
sa.Column(
@@ -34,8 +39,14 @@ def upgrade() -> None:
3439
server_default=sa.text("0"),
3540
),
3641
)
42+
op.alter_column("sessions", "total_questions", server_default=None)
3743

3844
# ---- questions table -------------------------------------------------
45+
# Note (P14): no separate ``ix_questions_session_id`` index is
46+
# created — the UniqueConstraint on ``(session_id, number)`` already
47+
# builds a B-tree whose leftmost column is ``session_id``, which
48+
# covers both ``WHERE session_id = ?`` lookups and the
49+
# ``ORDER BY number`` scan used by ``list_questions``.
3950
op.create_table(
4051
"questions",
4152
sa.Column("session_id", sa.Uuid(), nullable=False),
@@ -67,15 +78,8 @@ def upgrade() -> None:
6778
name="uq_questions_session_number",
6879
),
6980
)
70-
op.create_index(
71-
"ix_questions_session_id",
72-
"questions",
73-
["session_id"],
74-
unique=False,
75-
)
7681

7782

7883
def downgrade() -> None:
79-
op.drop_index("ix_questions_session_id", table_name="questions")
8084
op.drop_table("questions")
8185
op.drop_column("sessions", "total_questions")

apps/api/src/helprs/core/dependencies.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,29 @@ async def get_current_user(
3737
session: DbSession,
3838
settings: GetSettings,
3939
):
40-
"""Extract and validate Bearer token, return authenticated GitHubUser."""
40+
"""Extract and validate Bearer token, return authenticated GitHubUser.
41+
42+
Token sources, in priority order:
43+
44+
1. ``Authorization: Bearer <token>`` header — preferred for all
45+
normal API calls (apiFetch sets it).
46+
2. ``?access_token=<token>`` query parameter — fallback used by SSE
47+
endpoints. ``EventSource`` cannot set custom request headers, so
48+
the SSE caller must put the JWT in the URL. The query-param path
49+
was added 2026-04-11 alongside the Story 3-3 SSE manual-QA fix.
50+
Trade-off: query params land in access logs and browser history,
51+
which is acceptable for a 30-min JWT but not ideal — preferred
52+
long-term solution is fetch+ReadableStream (deferred).
53+
"""
4154
from helprs.modules.identity.models import GitHubUser
4255

4356
auth_header = request.headers.get("Authorization")
44-
if not auth_header or not auth_header.startswith("Bearer "):
45-
raise UnauthorizedError("Missing or invalid Authorization header")
46-
47-
token = auth_header.removeprefix("Bearer ")
57+
if auth_header and auth_header.startswith("Bearer "):
58+
token = auth_header.removeprefix("Bearer ")
59+
else:
60+
token = request.query_params.get("access_token") or ""
61+
if not token:
62+
raise UnauthorizedError("Missing or invalid Authorization header")
4863

4964
try:
5065
payload = decode_access_token(token, settings.SECRET_KEY)

apps/api/src/helprs/modules/comprehension/infrastructure/diff_refs.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,39 @@ def parse_diff_file_paths(diff: str) -> list[str]:
6262
def extract_file_refs(diff: str, text: str) -> list[str]:
6363
"""Return the list of file paths from ``diff`` mentioned in ``text``.
6464
65-
Matching is a plain substring check — cheap, unambiguous, and
66-
good enough for UX-DR6's "highlight if the question mentions a
67-
file" behaviour. A future Story 3.5 may replace this with an
68-
LLM-side tool-call that emits references structurally.
65+
Matching uses path-character-aware boundaries so that:
66+
67+
* ``foo.py`` does NOT match a question mentioning ``foo.py.bak``
68+
(the trailing ``.bak`` would extend the path — a separate file)
69+
* ``foo.py`` DOES match a sentence ending in ``foo.py.`` (the
70+
trailing period is sentence punctuation, not a path extension)
71+
* ``src/foo.ts`` does NOT accidentally match a bare ``foo.ts`` in
72+
some other directory
73+
* ``bar.py`` does NOT match ``bar.pyramid`` (``r`` extends the
74+
name into a different identifier)
6975
7076
Order follows diff order so the frontend's "first reference"
71-
lookup is deterministic.
77+
lookup is deterministic. A future Story 3.5 may replace this
78+
with an LLM-side tool-call that emits references structurally.
7279
"""
7380
if not text or not diff:
7481
return []
7582
paths = parse_diff_file_paths(diff)
76-
return [p for p in paths if p in text]
83+
refs: list[str] = []
84+
for path in paths:
85+
# Left boundary ``(?<![\w/.-])``: the position must not be
86+
# preceded by a path-component character (including ``.`` and
87+
# ``/``) so we don't match the tail of a longer path.
88+
#
89+
# Right boundary ``(?![\w/-])``: the position must not be
90+
# followed by a word/slash/dash character (extending the name
91+
# or adding another path segment).
92+
#
93+
# Right boundary ``(?!\.\w)``: the position must not be
94+
# followed by ``.<wordchar>`` — this is the key asymmetry that
95+
# lets ``foo.py`` match ``foo.py.`` (sentence period) but NOT
96+
# ``foo.py.bak`` (path extension).
97+
pattern = rf"(?<![\w/.-]){re.escape(path)}(?![\w/-])(?!\.\w)"
98+
if re.search(pattern, text):
99+
refs.append(path)
100+
return refs

apps/api/src/helprs/modules/comprehension/infrastructure/models.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
import uuid
1515

16-
from sqlalchemy import BigInteger, ForeignKey, Index, Integer, String, UniqueConstraint
16+
from sqlalchemy import BigInteger, ForeignKey, Integer, String, UniqueConstraint
1717
from sqlalchemy.orm import Mapped, mapped_column
1818

1919
from helprs.core.database import Base
@@ -60,12 +60,14 @@ class SessionModel(Base):
6060
status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False)
6161
# Story 3.3: count of questions the session plans to ask (set once at
6262
# session creation time by ``StartSessionHandler`` using
63-
# ``estimate_question_count``). ``server_default='0'`` is critical —
64-
# existing rows must get a value during the backfill migration.
63+
# ``estimate_question_count``). The migration uses a transient
64+
# ``server_default='0'`` to backfill existing rows, then drops it
65+
# so the application layer is the only source of truth. The
66+
# Python-side ``default=0`` is kept as a belt-and-braces fallback
67+
# for tests that build sessions without specifying a value.
6568
total_questions: Mapped[int] = mapped_column(
6669
Integer,
6770
default=0,
68-
server_default="0",
6971
nullable=False,
7072
)
7173

@@ -82,6 +84,11 @@ class QuestionModel(Base):
8284
``SqlAlchemySessionRepository.append_question`` uses a
8385
``SELECT ... FOR UPDATE`` lock on the sessions row to make the
8486
per-session number assignment atomic under concurrent streams.
87+
88+
Note: no separate single-column index on ``session_id`` — the unique
89+
constraint above already builds a B-tree whose leftmost column is
90+
``session_id``, which covers both ``WHERE session_id = ?`` lookups
91+
and the ``ORDER BY number`` scan used by ``list_questions``.
8592
"""
8693

8794
__tablename__ = "questions"
@@ -91,7 +98,6 @@ class QuestionModel(Base):
9198
"number",
9299
name="uq_questions_session_number",
93100
),
94-
Index("ix_questions_session_id", "session_id"),
95101
)
96102

97103
session_id: Mapped[uuid.UUID] = mapped_column(

apps/api/src/helprs/modules/comprehension/presentation/sse.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,13 @@ async def generator() -> AsyncIterator[bytes]:
152152
# it in memory for the duration of the stream.
153153
previous_texts: list[str] = []
154154

155-
for number in range(already_generated + 1, total + 1):
155+
client_gone = False
156+
for _loop_number in range(already_generated + 1, total + 1):
156157
# Give up early if the client went away (back button,
157158
# tab close). Checked every loop iteration so we
158159
# stop minting LLM tokens the user cannot see.
159160
if await request.is_disconnected():
160-
await logger.ainfo("sse_stream_client_disconnected", number=number)
161+
await logger.ainfo("sse_stream_client_disconnected", number=_loop_number)
161162
return
162163

163164
question_id = str(uuid.uuid4())
@@ -168,25 +169,54 @@ async def generator() -> AsyncIterator[bytes]:
168169
previous_questions=previous_texts,
169170
api_key=api_key,
170171
):
172+
# P10: check disconnect inside the token loop so we
173+
# stop streaming LLM tokens immediately when the
174+
# client goes away, not just between questions.
175+
# This matters because a single question can burn
176+
# thousands of BYOK-billed tokens.
177+
if await request.is_disconnected():
178+
await logger.ainfo(
179+
"sse_stream_client_disconnected_mid_token",
180+
number=_loop_number,
181+
tokens_streamed=len(parts),
182+
)
183+
client_gone = True
184+
break
171185
parts.append(token)
172186
yield _sse_frame(
173187
"question_token",
174188
{
175189
"question_id": question_id,
176190
"token": token,
177-
"number": number,
191+
"number": _loop_number,
178192
"total": total,
179193
},
180194
)
181195

196+
if client_gone:
197+
# Do NOT persist a half-streamed question. The
198+
# next reconnection (if any) will start fresh from
199+
# ``already_generated`` which the DB still reports.
200+
return
201+
202+
# P11: reject empty LLM output rather than persisting a
203+
# row with ``sha256("")``. An empty question is never a
204+
# legitimate outcome — surface it as an error frame.
205+
if not parts:
206+
raise RuntimeError("LLM yielded no tokens for question")
207+
182208
text = "".join(parts)
183209
text_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
184210
file_refs = extract_file_refs(diff, text)
185211

186212
# Fresh short-lived session per commit — the
187213
# request-scoped ``db`` is intentionally NOT reused.
188214
async with get_db_context() as tx:
189-
await SqlAlchemySessionRepository(tx).append_question(
215+
# P8: use the DB-assigned number from append_question
216+
# rather than the Python loop variable. Under concurrent
217+
# streams the assigned number may diverge from the loop
218+
# variable (see two-tab scenario in review findings D1).
219+
persisted = await SqlAlchemySessionRepository(tx).append_question(
190220
session_id=session_id,
191221
topic=Topic.ARCHITECTURE, # TODO(story-3.5): topic selection
192222
text_hash=text_hash,
@@ -199,23 +229,31 @@ async def generator() -> AsyncIterator[bytes]:
199229
{
200230
"question_id": question_id,
201231
"text": text,
202-
"number": number,
232+
"number": persisted.number,
203233
"total": total,
204234
"file_refs": file_refs,
205235
},
206236
)
207237
await logger.ainfo(
208238
"sse_stream_question_committed",
209-
number=number,
239+
number=persisted.number,
210240
total=total,
211241
file_refs_count=len(file_refs),
212242
)
213243

244+
# P9: report the actual persisted count rather than the
245+
# target ``total``. Opens a fresh short-lived session so
246+
# we don't reach back into ``db`` after the DB phase.
247+
async with get_db_context() as tx:
248+
actual_count = await SqlAlchemySessionRepository(tx).count_questions(
249+
session_id=session_id,
250+
)
251+
214252
yield _sse_frame(
215253
"done",
216-
{"session_id": session_id_str, "question_count": total},
254+
{"session_id": session_id_str, "question_count": actual_count},
217255
)
218-
await logger.ainfo("sse_stream_done", total=total)
256+
await logger.ainfo("sse_stream_done", question_count=actual_count, total=total)
219257

220258
except asyncio.CancelledError:
221259
# Client disconnect surfaced via task cancellation — log

apps/api/src/helprs/modules/identity/router.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@
2020
router = APIRouter(prefix="/auth", tags=["auth"])
2121

2222
GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"
23-
OAUTH_SCOPES = "read:user,user:email"
23+
# ``read:org`` is required so ``get_installations_for_user`` can call
24+
# ``GET /user/orgs`` to verify membership for Org-type installs (see
25+
# ``installation/service.py::get_installations_for_user``). User-type
26+
# installs do not need it. Added 2026-04-11 alongside the Epic-1
27+
# ``/user/installations`` regression fix surfaced by Story 3-3 QA.
28+
OAUTH_SCOPES = "read:user,user:email,read:org"
2429

2530

2631
@router.get("/github")

0 commit comments

Comments
 (0)