Skip to content

Commit 8e5bcf6

Browse files
fix(telegram): guard the normal collapse-bar fallback send too
ClawSweeper review finding: F3 only wrapped the cleanup-fallback bar send. The normal path applyProgressCollapseSummary awaited an unguarded durable send when finalizeToPreview could not edit in place, and sendPayload throws durable.error on delivery failure — so a cosmetic summary-bar flood-wait could propagate and fail an otherwise-complete turn (merge-risk: message-delivery). Route BOTH cosmetic-bar sends through one shared guarded helper postCosmeticSummaryBar (swallow + logVerbose), so neither the cleanup path nor the finalizeToPreview-miss path can fail turn delivery. Add the missing regression: no-live-message fallback bar send throwing keeps the turn alive and the final answer delivered. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 6c0446b commit 8e5bcf6

2 files changed

Lines changed: 65 additions & 9 deletions

File tree

extensions/telegram/src/bot-message-dispatch.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3435,6 +3435,51 @@ describe("dispatchTelegramMessage draft streaming", () => {
34353435
expect(answerDraftStream.clear).not.toHaveBeenCalled();
34363436
});
34373437

3438+
it("keeps the turn alive when the no-live-message fallback bar send throws", async () => {
3439+
// Sibling of the F3 cleanup-throw guard: applyProgressCollapseSummary posts
3440+
// the bar durably when finalizeToPreview cannot edit in place. That fallback
3441+
// send is cosmetic and runs AFTER the in-band final, so a flood-wait/network
3442+
// throw must be swallowed (postCosmeticSummaryBar), never failing the turn.
3443+
const answerDraftStream = createTestDraftStream({}); // no messageId -> edit fails -> durable post
3444+
const reasoningDraftStream = createTestDraftStream({});
3445+
createTelegramDraftStream
3446+
.mockImplementationOnce(() => answerDraftStream)
3447+
.mockImplementationOnce(() => reasoningDraftStream);
3448+
// Only the cosmetic bar send throws; the real final "Done" still delivers.
3449+
deliverReplies.mockImplementation(
3450+
async (params: { replies?: Array<{ text?: string }> }) => {
3451+
if (params.replies?.some((reply) => reply.text?.includes("⏱️"))) {
3452+
throw new Error("Too Many Requests: retry after 5");
3453+
}
3454+
return { delivered: true };
3455+
},
3456+
);
3457+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
3458+
async ({ dispatcherOptions, replyOptions }) => {
3459+
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
3460+
await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" });
3461+
return { queuedFinal: true };
3462+
},
3463+
);
3464+
3465+
let thrown: unknown;
3466+
try {
3467+
await dispatchWithContext({
3468+
context: createContext(),
3469+
streamMode: "progress",
3470+
telegramCfg: { streaming: { mode: "progress" } },
3471+
});
3472+
} catch (err) {
3473+
thrown = err;
3474+
}
3475+
3476+
expect(thrown).toBeUndefined();
3477+
// The bar fallback send was attempted (and swallowed); the final survived.
3478+
const texts = allDeliveredReplyTexts();
3479+
expect(texts.some((text) => text.includes("⏱️"))).toBe(true);
3480+
expect(texts).toContain("Done");
3481+
});
3482+
34383483
it("does not duplicate tool lines into the window under verbose", async () => {
34393484
// Invariant D2 (persistent XOR window): when the durable verbose lane owns
34403485
// tool messages, the window must render no tool line and must not count it.

extensions/telegram/src/bot-message-dispatch.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1963,6 +1963,19 @@ export const dispatchTelegramMessage = async ({
19631963
);
19641964
return line || undefined;
19651965
};
1966+
// The collapse summary bar is cosmetic and always reaches the user AFTER the
1967+
// real final answer (edited in place, or posted below it). A flood-wait /
1968+
// network throw from its durable send must never fail an otherwise-complete
1969+
// turn. Shared by BOTH bar-post fallbacks (the cleanup path and the
1970+
// finalizeToPreview-miss path) so neither can propagate a cosmetic failure
1971+
// into turn delivery; sendPayload throws durable.error on delivery failure.
1972+
const postCosmeticSummaryBar = async (line: string) => {
1973+
try {
1974+
await sendPayload({ text: line }, { durable: true, mirrorTranscript: false });
1975+
} catch (err) {
1976+
logVerbose(`telegram: collapse summary bar send failed: ${formatErrorMessage(err)}`);
1977+
}
1978+
};
19661979
// Post-turn collapse summary (Discord parity) as a durable standalone
19671980
// message. Used when there is no live window to collapse in place — the
19681981
// final answer posts below so the timeline reads thoughts/tools → summary →
@@ -1972,15 +1985,9 @@ export const dispatchTelegramMessage = async ({
19721985
if (!line) {
19731986
return;
19741987
}
1975-
// Cosmetic bar, posted from the cleanup fallback AFTER the real final is
1976-
// already delivered (message_tool_only/codex turns). A flood-wait/network
1977-
// throw here must never fail the turn — swallow and log. The once-guard
1988+
// Cleanup fallback bar (message_tool_only/codex turns): the once-guard
19781989
// already fired in resolveProgressCollapseSummaryLine, so no retry storm.
1979-
try {
1980-
await sendPayload({ text: line }, { durable: true, mirrorTranscript: false });
1981-
} catch (err) {
1982-
logVerbose(`telegram: collapse summary bar send failed: ${formatErrorMessage(err)}`);
1983-
}
1990+
await postCosmeticSummaryBar(line);
19841991
};
19851992
// Apply a pre-resolved bar line to the window: edit the live window message
19861993
// IN PLACE into the bar (no delete — deleting scroll-jumps the client), or
@@ -1998,7 +2005,11 @@ export const dispatchTelegramMessage = async ({
19982005
if (typeof messageId === "number") {
19992006
return "edited";
20002007
}
2001-
await sendPayload({ text: line }, { durable: true, mirrorTranscript: false });
2008+
// finalizeToPreview could not edit in place (no live window id, or a
2009+
// flood-wait/terminal edit): post the bar durably instead. This send is
2010+
// cosmetic and runs after the final answer, so a throw must not fail the
2011+
// turn — the shared guarded helper swallows and logs.
2012+
await postCosmeticSummaryBar(line);
20022013
return "posted";
20032014
};
20042015
// Reset answer-lane bookkeeping after a bar was edited/posted in place,

0 commit comments

Comments
 (0)