Skip to content

fix(researcher): retired title model broke every new chat — plus tooltip, auto-resume, pyodide, and sprint-export fixes - #673

Open
nikola0x0 wants to merge 5 commits into
devfrom
fix/researcher-title-model-chat-crash
Open

fix(researcher): retired title model broke every new chat — plus tooltip, auto-resume, pyodide, and sprint-export fixes#673
nikola0x0 wants to merge 5 commits into
devfrom
fix/researcher-title-model-chat-crash

Conversation

@nikola0x0

Copy link
Copy Markdown
Collaborator

Summary

Fixes the production P0 where every new Researcher chat rendered as a failed response, plus four more defects found while verifying it live on researcher.demo.memwal.ai, and one UX gap in saved sprints.

The P0 (c031c472)

getTitleModel() hardcoded google/gemini-2.0-flash-001, which OpenRouter has retired — it 404s (No endpoints found) for our own production key. The unguarded await titlePromise inside createUIMessageStream's execute() then surfaced that rejection as an error part on the stream, so the client showed a fatal chat error even though the real answer had streamed fine underneath.

  • Title model moved to a TITLE_MODEL constant beside the curated list, pointed at gemini-2.5-flash
  • Title block wrapped in try/catch — ports the chatbot fix (334cf915) that researcher was missed from
  • Picker audit with the production key found claude-3.5-haiku / claude-3.5-sonnet also retired (3 of 6 selectable models were dead) — removed, with a regression test pinning all three retired ids

Production impact when found: 11 of 43 chats untitled; the error path live since the 08-14 deploy.

Also in this branch

  • 754327f4 — portal TooltipContent (sticky-header stacking context clipped every tooltip to a 2px sliver); single-line suggestion pills; collapse a duplicate unreachable DEFAULT_SUGGESTIONS
  • 39e102ae — stop autoResume firing against the stub stream route (always 204): raced in AI SDK 6.0.37 and threw Cannot read properties of undefined (reading 'state') when reopening any reply-less chat
  • 9db6db10 — delete the template-leftover pyodide <Script>: nothing calls it, multi-MB download per chat page, and a React error under Next 16
  • b9b57890 — sprint Copy-as-markdown / Download .md / expanded reading view (shared unit-tested buildSprintMarkdown); real page title (Researcher | Walrus Memory) instead of "Next.js Chatbot Template"

Verification

  • Local A/B against the exact prod condition (retired title model): with the guard the answer streams with no error frame; without it the error frame reproduces
  • Live OpenRouter checks with the production key: retired ids → 404, replacement → 200; full e2e with a real key generates a title end-to-end
  • Auto-resume fix verified on the repro chat (clean console; previously threw on every load)
  • Sprint actions verified in-browser: clipboard content, toast, dialog
  • tsc --noEmit clean on changed files; pnpm test:unit 14/14 (7 new tests)

Not in this branch / follow-ups

  • Deploy drift: prod currently runs ec6a9b81a from the unmerged hotfix/noter-mainnet-rpc-cors branch — this PR can't reach prod through normal promotion until that's reconciled
  • Prod has 6 chats with a user message and no persisted assistant row; that symptom did not reproduce locally and needs a post-deploy re-check
  • apps/chatbot shares the dead title model, (probably) the dead Anthropic picker entries, the unportaled tooltip, and the pyodide tag — needs its own branch

Found on production (researcher.demo.memwal.ai), then reproduced and A/B'd
locally against the same condition.

getTitleModel() hardcoded "google/gemini-2.0-flash-001", which OpenRouter has
retired — it returns 404 "No endpoints found" for our own production key. Every
other call site already used google/gemini-2.5-flash; this one was missed.
Because titlePromise was awaited with no try/catch inside createUIMessageStream's
execute(), the rejection reached the stream's onError, which injected an
{"type":"error"} part — so the client rendered a fatal chat error even though the
real answer had streamed successfully underneath it.

Production impact when this was written: 6 of 43 chats have a user message and no
assistant reply (4 of them on or after the 2026-08-14 deploy), and 11 of 43 chats
are still titled "New chat".

apps/chatbot already had this guard from 334cf91 and researcher was missed at the
time. This ports it, and also fixes the root cause that commit left in place: the
retired model id itself. Moving it to a TITLE_MODEL constant beside chatModels
keeps it from drifting away from the models we actually support.

Auditing the rest of the picker with the production key turned up two more retired
ids — anthropic/claude-3.5-haiku and anthropic/claude-3.5-sonnet 404 identically.
Three of the six selectable models were dead. Removed, and recorded in the new
unit test so they cannot be reintroduced by copy-paste.

Verified locally (docker pgvector + next dev, real OpenRouter key, PLAYWRIGHT
unset so getTitleModel is not swapped for the mock):
  - with the guard, retired title model:    answer streams, no error frame
  - without the guard, retired title model: answer streams, error frame injected
  - with the production key: title generates for real, no error frame
  - pnpm test:unit 11/11; tsc --noEmit clean on the changed files

Not addressed here: production also shows chats with no persisted assistant row at
all, which did not reproduce locally — the assistant message persisted in both arms
of the A/B. That may be the older deployed build or the unhandledRejection aborting
before onFinish, and is worth re-checking once this ships. apps/chatbot still
carries the same retired model ids.
Two UI defects on the empty-chat screen.

Tooltips rendered as a ~2px sliver under their trigger. Not overflow clipping —
every ancestor was overflow:visible. TooltipContent was not wrapped in
TooltipPrimitive.Portal, so it stayed inside the chat header, and `position:
sticky` always creates a stacking context. The content's z-50 therefore only
applied within that 50px-tall header, and the part of the tooltip below 50px
painted underneath the page content that follows the header in DOM order. Only
the overlapping sliver was visible. Wrapping in Portal moves it to the body and
out of the header's stacking context, matching what noter's tooltip.tsx already
does. Affects every tooltip in the app, not just the header ones.

Suggestion pills wrapped to two lines when the text was long, making one pill
taller than the rest. The pills now hold a single line at a fixed 42px (matching
the loading skeleton) and truncate with an ellipsis, with the full string in
`title` — sprint-generated suggestions are arbitrary length, so the copy change
alone would not have held.

Shortening the default prompts also surfaced a trap: DEFAULT_SUGGESTIONS was
declared twice, byte-identical, in suggested-actions.tsx and use-sprint-greeting.
Only the hook's copy is reachable, because useSprintGreeting always supplies the
fallback through the sprintSuggestions prop — editing the component's copy
changed nothing on screen. Collapsed to one exported constant.

Verified in the browser at each step: tooltip renders in full on both the
sidebar toggle and My Stuff; all four pills sit on one line at equal height.
tsc --noEmit clean on the changed files; pnpm test:unit 11/11.

apps/chatbot has the same unportaled tooltip.
Reopening a chat whose last message is from the user threw a client
TypeError: Cannot read properties of undefined (reading 'state'), from
AbstractChat.resumeStream in AI SDK 6.0.37.

Root cause (diagnosed via live repro on a chat with one user message and no
assistant reply): chat/[id]/page.tsx enabled autoResume, so useAutoResume
called resumeStream() on mount — but resumable streaming was never
implemented; api/chat/[id]/stream/route.ts is a stub that always returns
204. Overlapping resume attempts (easy under dev double-invoked effects, but
not inherently dev-only) race inside the SDK: one clears activeResponse
while the other reads its .state.

Two changes:
- autoResume={false} on the chat page — honest, since there is nothing to
  resume until the stream route is real.
- useAutoResume now guards with a ref so at most one resume attempt fires.
  The hook's "we intentionally run this once" comment was aspiration, not
  enforcement — the dep array does not guarantee it.

This path matters beyond dev: production has 6 chats with a user message and
no assistant reply (fallout tracked in c031c47), and reopening any of them
walks this exact code path.

Verified: the repro chat now loads with a clean console (previously threw on
every load). tsc --noEmit clean; pnpm test:unit 11/11.
app/(chat)/layout.tsx loaded pyodide from a CDN with strategy
"beforeInteractive" — a leftover from the Vercel ai-chatbot template's Python
code-runner. Nothing in this app calls it: there is no loadPyodide/runPython
reference anywhere, and no code-execution feature. It cost a multi-MB script
download on every chat page for nothing.

As of Next 16 it is also an error, not just waste: beforeInteractive inside a
nested layout renders a raw <script> tag in the React tree, and every page
under (chat) logs "Encountered a script tag while rendering React component"
(surfaced right after login, at Layout in app/(chat)/layout.tsx:13).

Removing the tag fixes the error and the dead download in one move. Verified:
chat loads with a clean console after login; tsc --noEmit clean.

apps/chatbot has the same tag in its (chat)/layout.tsx — but chatbot may
actually use pyodide for its code artifacts, so it needs its own check rather
than a blind copy of this deletion.
…page title

Saved sprints were read-only in the narrow My Stuff panel — no way to copy or
export a report short of drag-selecting the text.

- Copy / Download / Expand actions in the sprint detail header. Copy puts the
  full report on the clipboard as markdown (toast-confirmed); Download saves it
  as <title-slug>.md. Both go through one pure helper, buildSprintMarkdown
  (title + summary + report + References), so clipboard and file are
  byte-identical. Helper is unit-tested (3 tests, repo test:unit style).
- Expand opens the sprint in a wide centered dialog with the same
  Report/Citations/Sources tabs; the tabbed body is extracted into an internal
  SprintBody component rendered by both the panel and the dialog.
  onOpenAutoFocus is suppressed so the Copy button's tooltip doesn't pop over
  the title when the dialog opens.
- Page metadata was still the template's ("Next.js Chatbot Template",
  metadataBase chat.vercel.ai) — now "Researcher | Walrus Memory" with the
  real domain, on the local branch and prod alike.

Verified in the browser against a real saved sprint: copy toast + clipboard
content (starts "# <title>", full markdown), expanded dialog opens/closes
clean, tab title updated. tsc --noEmit clean; pnpm test:unit 14/14.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant