From 4e4a78de7a0511437783d98c5e438374c93ec438 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Mon, 27 Jul 2026 08:19:27 +0200 Subject: [PATCH] fix(discord): finalize prior stream tip before queue-drain Working epoch When a queued follow-up starts, Discord clears the live tip for a new Working ack. If the prior turn's real answer only lived in that tip, it vanished. Promote substantial tip body to a durable final first. Pairs with server turnId rebind fix for the same race. --- .../src/features/ResponseBridge.test.ts | 38 ++++++++ .../src/features/ResponseBridge.ts | 87 ++++++++++++++++++- 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/apps/discord-bot/src/features/ResponseBridge.test.ts b/apps/discord-bot/src/features/ResponseBridge.test.ts index ec7c61eb283..6fedd190a2c 100644 --- a/apps/discord-bot/src/features/ResponseBridge.test.ts +++ b/apps/discord-bot/src/features/ResponseBridge.test.ts @@ -50,6 +50,7 @@ import { shouldReopenFinalizedDelivery, shouldPublishAssistantUpdate, startsNewStreamDelivery, + shouldFinalizeStreamBeforeNewDelivery, streamTipBodyForHeartbeat, finalAnswerText, parseDiscordThreadTitleBadgeState, @@ -1111,6 +1112,43 @@ describe("Working tip lifecycle contracts", () => { ).toBe(false); }); + it("finalizes substantial prior tip body before a new delivery (queue-drain race)", () => { + expect( + shouldFinalizeStreamBeforeNewDelivery({ + startsNewDelivery: true, + lastAssistantText: + "**Yes — the bug is almost entirely a naming/dual-use problem.** Rename + return type.", + t3AssistantMessageId: "assistant:run:segment:5", + finalizedTurnId: null, + currentTurnId: "turn-prior", + }), + ).toBe(true); + }); + + it("does not finalize empty Working placeholders before a new delivery", () => { + expect( + shouldFinalizeStreamBeforeNewDelivery({ + startsNewDelivery: true, + lastAssistantText: "_Working.._", + t3AssistantMessageId: "assistant:placeholder", + finalizedTurnId: null, + currentTurnId: "turn-prior", + }), + ).toBe(false); + }); + + it("does not re-finalize when the prior tip turn is already finalized", () => { + expect( + shouldFinalizeStreamBeforeNewDelivery({ + startsNewDelivery: true, + lastAssistantText: "Already posted final answer body", + t3AssistantMessageId: "assistant:run:segment:5", + finalizedTurnId: "turn-prior", + currentTurnId: "turn-prior", + }), + ).toBe(false); + }); + it("on new delivery keeps only the newest tip slot (avoids 10008 on prior tips)", () => { const tips = activeStreamTipIdsForDelivery({ startsNewDelivery: true, diff --git a/apps/discord-bot/src/features/ResponseBridge.ts b/apps/discord-bot/src/features/ResponseBridge.ts index b482f2042d1..82a56cecabb 100644 --- a/apps/discord-bot/src/features/ResponseBridge.ts +++ b/apps/discord-bot/src/features/ResponseBridge.ts @@ -723,6 +723,42 @@ export function startsNewStreamDelivery(input: { ); } +/** + * When a new user turn / Working ack starts, any substantial stream tip body + * that was never finalized must be posted as a durable final **before** the + * tip is cleared. Queue-drain races finish the prior answer into the tip and + * immediately open the next Working epoch — without this, the final only + * lived as an editable tip and is deleted when the new epoch starts. + * + * Mirrors the server/web queue-drain final orphan problem (turnId restamp / + * fold rehome): Discord's presentation boundary is the Working tip lifecycle. + */ +export function shouldFinalizeStreamBeforeNewDelivery(input: { + readonly startsNewDelivery: boolean; + readonly lastAssistantText: string; + readonly t3AssistantMessageId: string | null; + readonly finalizedTurnId: string | null; + readonly currentTurnId: string | null; +}): boolean { + if (!input.startsNewDelivery) return false; + if (input.t3AssistantMessageId === null || input.t3AssistantMessageId.trim() === "") { + return false; + } + // Already finalized this tip's turn — nothing left to promote. + if ( + input.finalizedTurnId !== null && + input.currentTurnId !== null && + input.finalizedTurnId === input.currentTurnId + ) { + return false; + } + const text = input.lastAssistantText.trim(); + if (text === "" || text === "…") return false; + // Working-only placeholder with no prose yet. + if (/^_Working/i.test(text) && text.length < 40) return false; + return true; +} + /** * Pure state patch when a Discord Working.. ack is adopted for a new user turn * (or mid-turn steer) on a reused bridge. Clears prior stream body and points the @@ -3024,7 +3060,7 @@ export const runBridge = ( }) => Effect.gen(function* () { const { turnId, t3MessageId, text, streaming, images, worktreePath } = args; - const state = yield* Ref.get(stateRef); + let state = yield* Ref.get(stateRef); const reopensFinalizedDelivery = shouldReopenFinalizedDelivery({ finalizedTurnId: state.finalizedTurnId, currentAssistantMessageId: state.t3AssistantMessageId, @@ -3100,6 +3136,55 @@ export const runBridge = ( seededWorkingAckPending: state.seededWorkingAckPending, }); + // Queue drain / new Working: promote the prior tip body to a durable final + // before we wipe tip state. Otherwise the real answer only lived as an + // editable tip and vanishes when the next epoch starts (t3vm + // 16feaadd… / segment:5 lost under Working). + if ( + shouldFinalizeStreamBeforeNewDelivery({ + startsNewDelivery, + lastAssistantText: state.lastAssistantText, + t3AssistantMessageId: state.t3AssistantMessageId, + finalizedTurnId: state.finalizedTurnId, + currentTurnId: state.currentTurnId, + }) + ) { + const priorText = state.lastAssistantText; + const priorAssistantId = state.t3AssistantMessageId!; + const priorTurnId = state.currentTurnId; + yield* Effect.logInfo( + "Finalizing prior stream tip before new delivery (queue-drain / turn boundary)", + { + t3ThreadId: input.t3ThreadId, + priorTurnId, + priorAssistantId, + nextTurnId: turnId, + textLen: priorText.length, + }, + ); + yield* finalizeAssistantMessage({ + turnId: priorTurnId, + t3MessageId: priorAssistantId, + text: priorText, + images: [], + streamHistoryText: priorText, + worktreePath, + }).pipe( + Effect.catchCause((cause) => + Effect.logError("Failed to finalize prior stream before new delivery", { + t3ThreadId: input.t3ThreadId, + priorTurnId, + priorAssistantId, + cause: formatAlertCause(cause, 300), + }), + ), + Effect.asVoid, + ); + // Re-read state after finalize (tips deleted, lastFinalized updated). + const afterPriorFinalize = yield* Ref.get(stateRef); + state = afterPriorFinalize; + } + // Multi-step agents open a new assistant id per bubble while the turn runs. // Keep the same Discord tip(s) and edit them — never delete/recreate mid-turn // while we still own the channel tip.