Skip to content

Commit 150053b

Browse files
fix(slack): route stream-fallback delivery through chunked sender (follow-up to #70370) (#71124)
* fix(slack): route stream-fallback delivery through chunked sender deliverPendingStreamFallback was calling chat.postMessage directly for err.pendingText, which bypasses the chunked reply path used everywhere else. For Slack Connect cases where appendSlackStream throws SlackStreamNotDeliveredError with a large pending buffer, the single raw post could fail (msg_too_long) and drop the unsent tail. Two changes: 1. deliverPendingStreamFallback now routes through deliverReplies so long pendingText is chunked by the normal sender and the fallback honors the configured replyToMode / identity. 2. The non-benign streaming-error branch in deliverWithStreaming now clears the session via markSlackStreamFallbackDelivered before falling back to deliverNormally. Without this, pendingText stays populated and the post-loop finalize (stopSlackStream → SlackStreamNotDeliveredError → fallback) re-posts the same chunk that deliverNormally already sent. Addresses the three Codex P1 findings on #70370 about bypassing the chunked sender, and the related "avoid reposting buffered text after append fallback" P1 about duplicate delivery. Tests updated to assert deliverReplies routing (instead of raw postMessage) and a new case covers the non-benign-error dedup. Follow-up to #70370. * fix(slack): preserve pending buffered text on non-benign stream errors Address Codex P1 on #71124: `markSlackStreamFallbackDelivered` was clearing `pendingText` before `deliverNormally` ran, so any earlier buffered chunk was lost. E.g. chunk A buffered in the SDK, then appending chunk B throws a generic network error → previous fix dropped A+B and only sent B via `deliverNormally`, silently truncating the final reply. Route the full buffered `pendingText` through `deliverPendingStreamFallback` with a synthetic `SlackStreamNotDeliveredError`, then skip `deliverNormally` entirely (pendingText already contains this payload's text, per `appendSlackStream` accumulating before throw). If the chunked fallback fails, fall back to `deliverNormally` so at least the current payload lands. Test updated to assert the full pendingText ("first buffered\nsecond payload") gets routed through the chunked sender, not the chunk-B-only partial send. * fix(slack): harden stream fallback docs and chunking test (#71124) --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent b0c9810 commit 150053b

5 files changed

Lines changed: 150 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ Docs: https://docs.openclaw.ai
9494
- Matrix/CLI: pass resolved runtime config into verify commands, so `openclaw matrix verify status` and sibling verify subcommands no longer crash before acquiring the Matrix client. Fixes #70992. (#71102) Thanks @luyao618.
9595
- Gateway/startup: await startup sidecars before channel monitors report ready, reducing Discord and plugin startup races while still keeping gateway boot observability intact.
9696
- Plugins/Google Meet: report required manual actions for Chrome joins, use browser automation for Meet entry, and persist the private-WS node opt-in so paired-node realtime sessions keep their intended network policy.
97+
- Slack: route native stream fallback replies through the normal chunked sender so long buffered Slack Connect responses are not dropped or duplicated. (#71124) Thanks @martingarramon.
9798

9899
## 2026.4.23
99100

extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
409409
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
410410
});
411411

412-
it("posts pending native stream text when finalize fails before the SDK buffer flushes", async () => {
412+
it("routes pending native stream text through chunked sender when finalize fails before the SDK buffer flushes", async () => {
413413
mockedNativeStreaming = true;
414414
const session = {
415415
channel: "C123",
@@ -425,17 +425,18 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
425425

426426
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
427427

428-
expect(deliverRepliesMock).not.toHaveBeenCalled();
429-
expect(postMessageMock).toHaveBeenCalledTimes(1);
430-
expect(postMessageMock).toHaveBeenCalledWith({
431-
channel: "C123",
432-
thread_ts: THREAD_TS,
433-
text: FINAL_REPLY_TEXT,
434-
});
428+
expect(postMessageMock).not.toHaveBeenCalled();
429+
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
430+
expect(deliverRepliesMock).toHaveBeenCalledWith(
431+
expect.objectContaining({
432+
replyThreadTs: THREAD_TS,
433+
replies: [expect.objectContaining({ text: FINAL_REPLY_TEXT })],
434+
}),
435+
);
435436
expect(session.stopped).toBe(true);
436437
});
437438

438-
it("posts all pending native stream text when an append flush fails", async () => {
439+
it("routes all pending native stream text through chunked sender when an append flush fails", async () => {
439440
mockedNativeStreaming = true;
440441
mockedDispatchSequence = [
441442
{ kind: "block", payload: { text: "first buffered" } },
@@ -456,13 +457,87 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
456457

457458
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
458459

459-
expect(deliverRepliesMock).not.toHaveBeenCalled();
460-
expect(postMessageMock).toHaveBeenCalledTimes(1);
461-
expect(postMessageMock).toHaveBeenCalledWith({
460+
expect(postMessageMock).not.toHaveBeenCalled();
461+
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
462+
expect(deliverRepliesMock).toHaveBeenCalledWith(
463+
expect.objectContaining({
464+
replyThreadTs: THREAD_TS,
465+
replies: [expect.objectContaining({ text: "first buffered\nsecond flushes" })],
466+
}),
467+
);
468+
expect(stopSlackStreamMock).not.toHaveBeenCalled();
469+
});
470+
471+
it("forwards oversized pending stream text to the chunked sender intact (chunking is the sender's responsibility)", async () => {
472+
mockedNativeStreaming = true;
473+
// SLACK_TEXT_LIMIT mocks to 4000; use > 1 message worth of content.
474+
const oversized = "x".repeat(8500);
475+
const session = {
462476
channel: "C123",
463-
thread_ts: THREAD_TS,
464-
text: "first buffered\nsecond flushes",
477+
threadTs: THREAD_TS,
478+
stopped: false,
479+
delivered: false,
480+
pendingText: oversized,
481+
};
482+
startSlackStreamMock.mockResolvedValueOnce(session);
483+
stopSlackStreamMock.mockRejectedValueOnce(
484+
new TestSlackStreamNotDeliveredError(oversized, "team_not_found"),
485+
);
486+
487+
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
488+
489+
expect(postMessageMock).not.toHaveBeenCalled();
490+
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
491+
expect(deliverRepliesMock).toHaveBeenCalledWith(
492+
expect.objectContaining({
493+
replyThreadTs: THREAD_TS,
494+
textLimit: 4000,
495+
replies: [expect.objectContaining({ text: oversized })],
496+
}),
497+
);
498+
expect(session.stopped).toBe(true);
499+
});
500+
501+
it("routes full pendingText (earlier buffered + failing chunk) through chunked sender on non-benign append failure", async () => {
502+
mockedNativeStreaming = true;
503+
mockedDispatchSequence = [
504+
{ kind: "block", payload: { text: "first buffered" } },
505+
{ kind: "final", payload: { text: "second payload" } },
506+
];
507+
const session = {
508+
channel: "C123",
509+
threadTs: THREAD_TS,
510+
stopped: false,
511+
delivered: false,
512+
pendingText: "first buffered",
513+
};
514+
startSlackStreamMock.mockResolvedValueOnce(session);
515+
// Non-benign error (plain Error, NOT SlackStreamNotDeliveredError).
516+
// appendSlackStream mutates pendingText BEFORE throwing so the full
517+
// buffer (earlier chunk + current chunk) must be preserved and routed
518+
// through the chunked fallback - not dropped or partially re-sent.
519+
appendSlackStreamMock.mockImplementationOnce(async () => {
520+
session.pendingText += "\nsecond payload";
521+
throw new Error("network socket closed");
465522
});
523+
524+
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
525+
526+
// Chunked fallback sent the FULL pendingText, not just the failing
527+
// payload (so the earlier buffered chunk is not dropped).
528+
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
529+
expect(deliverRepliesMock).toHaveBeenCalledWith(
530+
expect.objectContaining({
531+
replyThreadTs: THREAD_TS,
532+
replies: [expect.objectContaining({ text: "first buffered\nsecond payload" })],
533+
}),
534+
);
535+
// Session was marked fallback-delivered by deliverPendingStreamFallback,
536+
// so finalize skips stopSlackStream.
537+
expect(session.pendingText).toBe("");
538+
expect(session.stopped).toBe(true);
466539
expect(stopSlackStreamMock).not.toHaveBeenCalled();
540+
// No raw postMessage path was invoked.
541+
expect(postMessageMock).not.toHaveBeenCalled();
467542
});
468543
});

extensions/slack/src/monitor/message-handler/dispatch.ts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -436,31 +436,39 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
436436
err: SlackStreamNotDeliveredError,
437437
): Promise<boolean> => {
438438
// The Slack SDK still owns this text in-memory; no streaming API call has
439-
// acknowledged it. Send it once through normal chat.postMessage.
439+
// acknowledged it. Route through deliverReplies so pendingText that
440+
// exceeds Slack's per-message text limit still lands (a single
441+
// chat.postMessage would have failed with msg_too_long), and so the
442+
// fallback respects the configured replyToMode/identity the same way
443+
// normal replies do.
440444
const fallbackText = err.pendingText.trim();
441445
if (!fallbackText) {
442446
return false;
443447
}
444448
try {
445-
// Rename-bind to dodge eslint-plugin-unicorn/require-post-message-target-origin
446-
// which cannot distinguish Slack chat.postMessage from window.postMessage.
447-
const postChatMessage = ctx.app.client.chat.postMessage.bind(ctx.app.client.chat);
448-
await postChatMessage({
449-
channel: session.channel,
450-
thread_ts: session.threadTs,
451-
text: fallbackText,
449+
await deliverReplies({
450+
cfg: ctx.cfg,
451+
replies: [{ text: fallbackText } as ReplyPayload],
452+
target: prepared.replyTarget,
453+
token: ctx.botToken,
454+
accountId: account.accountId,
455+
runtime,
456+
textLimit: ctx.textLimit,
457+
replyThreadTs: session.threadTs,
458+
replyToMode: prepared.replyToMode,
459+
...(slackIdentity ? { identity: slackIdentity } : {}),
452460
});
453461
markSlackStreamFallbackDelivered(session);
454462
observedReplyDelivery = true;
455463
usedReplyThreadTs ??= session.threadTs;
456464
logVerbose(
457-
`slack-stream: streamed delivery failed (${err.slackCode}); delivered ${fallbackText.length} chars via chat.postMessage fallback`,
465+
`slack-stream: streamed delivery failed (${err.slackCode}); delivered ${fallbackText.length} chars via deliverReplies fallback`,
458466
);
459467
return true;
460468
} catch (postErr) {
461469
runtime.error?.(
462470
danger(
463-
`slack-stream: fallback chat.postMessage failed after ${err.slackCode}: ${formatErrorMessage(postErr)}`,
471+
`slack-stream: fallback deliverReplies failed after ${err.slackCode}: ${formatErrorMessage(postErr)}`,
464472
),
465473
);
466474
return false;
@@ -636,6 +644,29 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
636644
danger(`slack-stream: streaming API call failed: ${formatErrorMessage(err)}, falling back`),
637645
);
638646
streamFailed = true;
647+
// Non-benign streaming errors leave `pendingText` populated with every
648+
// buffered chunk since the last flush (appendSlackStream accumulates
649+
// into pendingText BEFORE the SDK call, so the failing chunk is
650+
// included too). Route the full buffer through the chunked fallback so
651+
// earlier chunks aren't lost, then skip deliverNormally - pendingText
652+
// already contains this payload's text.
653+
if (streamSession && streamSession.pendingText) {
654+
const bufferedFallbackErr = new SlackStreamNotDeliveredError(
655+
streamSession.pendingText,
656+
"unknown",
657+
);
658+
const delivered = await deliverPendingStreamFallback(streamSession, bufferedFallbackErr);
659+
if (delivered) {
660+
replyPlan.markSent();
661+
deliveryTracker.markDelivered({
662+
kind: params.kind,
663+
payload: params.payload,
664+
threadTs: streamSession.threadTs,
665+
textOverride: text,
666+
});
667+
return;
668+
}
669+
}
639670
await deliverNormally({
640671
payload: params.payload,
641672
kind: params.kind,

extensions/slack/src/send.blocks.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,23 @@ describe("sendMessageSlack chunking", () => {
7474
}),
7575
);
7676
});
77+
78+
it("splits oversized fallback text through the normal Slack sender", async () => {
79+
const client = createSlackSendTestClient();
80+
const message = "a".repeat(8500);
81+
82+
await sendMessageSlack("channel:C123", message, {
83+
token: "xoxb-test",
84+
cfg: SLACK_TEST_CFG,
85+
client,
86+
});
87+
88+
const postedTexts = client.chat.postMessage.mock.calls.map((call) => call[0].text);
89+
90+
expect(postedTexts).toHaveLength(2);
91+
expect(postedTexts.every((text) => typeof text === "string" && text.length <= 8000)).toBe(true);
92+
expect(postedTexts.join("")).toBe(message);
93+
});
7794
});
7895

7996
describe("sendMessageSlack blocks", () => {

extensions/slack/src/streaming.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export type StopSlackStreamParams = {
7474
* Thrown when Slack rejects a stream flush/finalize with a recipient-resolution
7575
* error (see {@link BENIGN_SLACK_FINALIZE_ERROR_CODES}) while text is still
7676
* only buffered locally by the Slack SDK. Carries the pending text so the
77-
* caller can deliver it via a normal `chat.postMessage`.
77+
* caller can deliver it via the normal Slack reply path.
7878
*/
7979
export class SlackStreamNotDeliveredError extends Error {
8080
readonly pendingText: string;
@@ -205,7 +205,7 @@ export async function appendSlackStream(params: AppendSlackStreamParams): Promis
205205
* If the same benign error fires while text is still only buffered locally
206206
* (e.g. short replies that never exceeded the SDK's buffer_size), this
207207
* function throws a {@link SlackStreamNotDeliveredError} carrying that pending
208-
* text so the caller can deliver it via `chat.postMessage`.
208+
* text so the caller can deliver it through the normal Slack reply path.
209209
*
210210
* All other errors propagate unchanged.
211211
*/

0 commit comments

Comments
 (0)