From 91106eaebed71bc0c4816651c4e6e859d2e01b5c Mon Sep 17 00:00:00 2001 From: daedboi <87483308+daedboi@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:04:33 +0300 Subject: [PATCH] Persist bridge turns inline: nested after() never fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/app/v1/chat/route.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/api/app/v1/chat/route.ts b/apps/api/app/v1/chat/route.ts index 83fa290..7eeab59 100644 --- a/apps/api/app/v1/chat/route.ts +++ b/apps/api/app/v1/chat/route.ts @@ -113,7 +113,8 @@ export async function POST(req: Request): Promise { const cors = corsHeaders(req); // An in-process caller (the Telegram bridge, proven by the per-boot token) skips the // origin/key checks and rate-limits on the upstream user id it names in the bucket header. - const gate = await requestGate(req, { internal: isInternalCall(req) }); + const internal = isInternalCall(req); + const gate = await requestGate(req, { internal }); if (!gate.ok) return Response.json({ error: gate.reason }, { status: gate.status, headers: { ...cors, ...gate.headers } }); @@ -135,7 +136,7 @@ export async function POST(req: Request): Promise { // per-app breakdown. telegram is server-derived (only the in-process bridge holds the // internal token); browsers always send an Origin on a cross-site POST, so an Origin-less // call is server-to-server API traffic. Client-declared bits are analytics, not trust. - const channel: Channel = isInternalCall(req) + const channel: Channel = internal ? "telegram" : body.channel === "playground" ? "playground" @@ -491,6 +492,13 @@ export async function POST(req: Request): Promise { } } finally { send({ type: "done" }); + // The Telegram bridge invokes this handler from inside its own after() callback, + // and an after() registered there NEVER fires — which silently dropped usage + // rollups and transcripts for every bridge turn. Internal calls therefore persist + // 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(); // close() also throws once the client has disconnected (already-closed controller); // swallow it like send() so an abort doesn't reject out of start(). try { @@ -507,7 +515,7 @@ export async function POST(req: Request): Promise { // After the streamed response finishes: flush spans, record spend, write the cache, roll up // usage, persist the turn. All best-effort and independent — allSettled so a failure in one // never skips the others (or affects the already-streamed response). - after(async () => { + async function persistTurn(): Promise { await Promise.allSettled([ flushTelemetry(), // Shared spend meter is best-effort; the in-memory per-instance meter still applies. @@ -536,7 +544,8 @@ export async function POST(req: Request): Promise { ) : Promise.resolve(), ]); - }); + } + if (!internal) after(persistTurn); return new Response(stream, { headers: { ...cors, ...gate.headers, "content-type": "application/x-ndjson", "cache-control": "no-store" },