Skip to content

Persist Telegram bridge turns: nested after() never fires#77

Merged
daedboi merged 1 commit into
masterfrom
fix/telegram-turn-persistence
Jul 8, 2026
Merged

Persist Telegram bridge turns: nested after() never fires#77
daedboi merged 1 commit into
masterfrom
fix/telegram-turn-persistence

Conversation

@daedboi

@daedboi daedboi commented Jul 8, 2026

Copy link
Copy Markdown
Member

What

Moves the chat route's persistence block (usage rollup + transcript save + spend meter + tier-1 cache write + telemetry flush) into a named persistTurn() and runs it inline before the stream closes for internal (Telegram bridge) calls, keeping after(persistTurn) for public calls.

Why

The Telegram webhook acks first and runs the turn inside its own after() callback, invoking the chat handler in-process. An after() registered from within another after() callback never fires, so every bridge turn streamed its answer to the user but silently dropped bumpUsage and saveTurn. Net effect: Telegram conversations never appeared in the dashboard (the owner-reported bug — the channel filters looked broken), and usage counters undercounted. This dates back to the ack-first redesign; it was masked until now by the schema drift that broke transcript writes for all channels.

Inline persistence is safe for the bridge because it consumes the stream to EOF — the writes are guaranteed to complete before the webhook invocation ends. Public widget/API clients keep the after() path, so their EOF is not delayed by DB writes.

Verification

  • Reproduced locally: webhook-driven DM turn completed (caches, spend, thread ref all written) with no conversation/usage rows — confirmed both were after()-only.
  • With the fix: the same webhook-driven turn persists a channel='telegram' conversation with linked user/assistant messages.
  • apps/api: typecheck, 85 tests, biome all green.

Summary by CodeRabbit

  • Bug Fixes
    • Improved chat message processing so usage and conversation history are saved more reliably after responses complete.
    • Fixed an issue affecting internal chat requests, ensuring they are handled consistently even when the usual follow-up callback is unavailable.
    • Kept public chat behavior unchanged while making completion handling more robust overall.

The Telegram bridge invokes the chat handler in-process from inside the
webhook's own after() callback. An after() registered there never runs,
so every bridge turn streamed its answer fine but silently dropped the
usage rollup and the transcript (bumpUsage + saveTurn lived only in the
chat route's after()). This dated back to the ack-first webhook redesign
and explains Telegram conversations missing from the dashboard.

Internal calls now run the persistence block inline before the stream
closes — the bridge reads to EOF, so completion is guaranteed. Public
calls keep after(), so widget/API clients see EOF without waiting on DB
writes. Verified end-to-end: a webhook-driven DM turn now lands as a
channel='telegram' conversation.
@vercel

vercel Bot commented Jul 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
orca-api Ready Ready Preview, Comment Jul 8, 2026 3:05pm

Request Review

@cursor

cursor Bot commented Jul 8, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes when persistence runs for internal vs public paths; inline await on the Telegram path could lengthen webhook after() work if DB is slow, but behavior for public callers is unchanged.

Overview
Fixes silent loss of dashboard conversations and usage for Telegram: the webhook runs the chat handler inside its own next/server after(), and persistence was also scheduled with after(), which does not run when nested—so bumpUsage and saveTurn never executed for bridge turns.

The post-stream work is refactored into persistTurn() (telemetry flush, spend meter, tier-1 cache write, usage rollup, transcript save). Internal calls (isInternalCall, cached once as internal) await persistTurn() in the stream finally block before controller.close(), which is safe because the bridge reads the NDJSON stream to EOF. Public widget/API traffic still uses after(persistTurn) so clients are not held on the connection for DB writes.

Reviewed by Cursor Bugbot for commit 91106ea. Configure here.

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 647f32c6-9503-499a-acd6-938acec2f6b6

📥 Commits

Reviewing files that changed from the base of the PR and between 5389fa5 and 91106ea.

📒 Files selected for processing (1)
  • apps/api/app/v1/chat/route.ts

📝 Walkthrough

Walkthrough

The POST /v1/chat handler now derives an internal boolean once and reuses it for request gating and channel selection. Post-stream persistence logic is extracted into a persistTurn() helper, invoked inline for internal calls and deferred via after(persistTurn) for non-internal calls.

Changes

Chat route persistence refactor

Layer / File(s) Summary
Reuse precomputed internal flag
apps/api/app/v1/chat/route.ts
requestGate now receives { internal } and channel selection reuses the same flag instead of calling isInternalCall(req) twice.
persistTurn helper and conditional invocation
apps/api/app/v1/chat/route.ts
A new persistTurn() helper centralizes telemetry/spend/cache rollups and saveTurn; it runs inline in the stream finally block for internal calls, and via after(persistTurn) guarded by !internal for public calls.

Estimated code review effort: 2 (Simple) | ~10 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ChatRoute
  participant PersistTurn
  participant After

  Client->>ChatRoute: POST /v1/chat
  ChatRoute->>ChatRoute: determine internal flag
  ChatRoute->>ChatRoute: stream response, finally block
  alt internal call
    ChatRoute->>PersistTurn: persistTurn() inline
    PersistTurn-->>ChatRoute: rollups + saveTurn complete
  else public call
    ChatRoute->>After: after(persistTurn)
    After->>PersistTurn: persistTurn() deferred
    PersistTurn-->>After: rollups + saveTurn complete
  end
Loading

Poem

A rabbit hopped through streaming code,
One flag to gate, one flag to load,
persistTurn now sits neat and small,
Internal hops before the call,
Public turns wait for after's cheer —
Tidy burrows, logic clear! 🐇✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: fixing Telegram bridge turn persistence caused by nested after() callbacks.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@daedboi
daedboi merged commit 21bcc49 into master Jul 8, 2026
11 checks passed
@daedboi
daedboi deleted the fix/telegram-turn-persistence branch July 8, 2026 15:10

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 91106eaebe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// INLINE before the stream closes: the bridge reads to EOF, so completion is
// guaranteed. Public calls keep after() below so their EOF isn't delayed by DB
// writes.
if (internal) await persistTurn();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Don't block Telegram's final edit on persistence

For internal Telegram turns, streamToTelegram only calls editFinal after the for await over res.body reaches EOF (apps/api/app/api/telegram/route.ts:364-388). Since this line waits for persistTurn() before controller.close(), any slow or hung transcript/usage/cache/telemetry write leaves users on “…” or a throttled draft even though the terminal answer has already been emitted; on cache-hit/refusal paths there are no deltas, so the placeholder is delayed entirely. Consider rendering the final answer before draining/persisting, while still keeping the invocation alive for persistence.

Useful? React with 👍 / 👎.

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