Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/discord-bot/src/features/ResponseBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
shouldReopenFinalizedDelivery,
shouldPublishAssistantUpdate,
startsNewStreamDelivery,
shouldFinalizeStreamBeforeNewDelivery,
streamTipBodyForHeartbeat,
finalAnswerText,
parseDiscordThreadTitleBadgeState,
Expand Down Expand Up @@ -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,
Expand Down
87 changes: 86 additions & 1 deletion apps/discord-bot/src/features/ResponseBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down