refactor(slack): split message dispatch (#113602)

This commit is contained in:
Peter Steinberger
2026-07-25 03:43:24 -07:00
committed by GitHub
parent 1defbc5473
commit 64daf8dd23
8 changed files with 2177 additions and 1857 deletions

View File

@@ -266,7 +266,6 @@ extensions/slack/src/monitor/events/interactions.block-actions.ts
extensions/slack/src/monitor/events/interactions.test.ts
extensions/slack/src/monitor/media.test.ts
extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts
extensions/slack/src/monitor/message-handler/dispatch.ts
extensions/slack/src/monitor/message-handler/prepare.test.ts
extensions/slack/src/monitor/message-handler/prepare.ts
extensions/slack/src/monitor/provider.ts

View File

@@ -0,0 +1,274 @@
import type { ChannelBotLoopProtectionFacts } from "openclaw/plugin-sdk/channel-inbound";
import { resolveChannelProgressDraftConfig } from "openclaw/plugin-sdk/channel-outbound";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import type { ReplyDispatchKind, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveSlackReplyRenderPlan } from "../../reply-blocks.js";
import type { SlackMessageEvent } from "../../types.js";
import { readSlackReplyBlocks, resolveSlackThreadTs } from "../replies.js";
import { resolveSlackTimestampMs } from "./timestamp.js";
import type { PreparedSlackMessage } from "./types.js";
function resolveSlackMessageTimestampMs(message: SlackMessageEvent): number | undefined {
const ts = message.event_ts ?? message.ts;
return resolveSlackTimestampMs(ts);
}
export function resolveSlackBotLoopProtection(
prepared: PreparedSlackMessage,
): ChannelBotLoopProtectionFacts | undefined {
const senderBotId = prepared.message.bot_id;
if (!senderBotId) {
return undefined;
}
const receiverBotId = prepared.ctx.botId || prepared.ctx.botUserId;
if (
!receiverBotId ||
senderBotId === prepared.ctx.botId ||
prepared.message.user === prepared.ctx.botUserId
) {
return undefined;
}
return {
scopeId: prepared.route.accountId,
conversationId: prepared.message.channel,
senderId: senderBotId,
receiverId: receiverBotId,
config: mergePairLoopGuardConfig(
prepared.account.config.botLoopProtection,
prepared.channelConfig?.botLoopProtection,
),
defaultsConfig: prepared.ctx.cfg.channels?.defaults?.botLoopProtection,
defaultEnabled: true,
nowMs: resolveSlackMessageTimestampMs(prepared.message),
};
}
export function isSlackStreamingEnabled(params: {
mode: "off" | "partial" | "block" | "progress";
nativeStreaming: boolean;
nativeProgressTaskCards?: boolean;
}): boolean {
if (params.mode === "partial") {
return params.nativeStreaming;
}
if (params.mode === "progress") {
return params.nativeStreaming && params.nativeProgressTaskCards === true;
}
return false;
}
export function shouldEnableSlackPreviewStreaming(params: {
mode: "off" | "partial" | "block" | "progress";
}): boolean {
return params.mode !== "off";
}
export function shouldInitializeSlackDraftStream(params: {
previewStreamingEnabled: boolean;
useStreaming: boolean;
}): boolean {
return params.previewStreamingEnabled && !params.useStreaming;
}
export function resolveSlackDisableBlockStreaming(params: {
useStreaming: boolean;
shouldUseDraftStream: boolean;
blockStreamingEnabled: boolean | undefined;
}): boolean | undefined {
if (params.useStreaming || params.shouldUseDraftStream) {
return true;
}
return typeof params.blockStreamingEnabled === "boolean"
? !params.blockStreamingEnabled
: undefined;
}
export function resolveExplicitSlackProgressTitle(
entry: Parameters<typeof resolveChannelProgressDraftConfig>[0],
): string | undefined {
const label = resolveChannelProgressDraftConfig(entry).label;
if (typeof label !== "string") {
return undefined;
}
const trimmed = label.trim();
return trimmed && trimmed.toLowerCase() !== "auto" ? trimmed : undefined;
}
export function resolveSlackNativeProgressTaskCards(
entry: Parameters<typeof resolveChannelProgressDraftConfig>[0],
): boolean {
const streaming = entry?.streaming;
if (!streaming || typeof streaming !== "object" || Array.isArray(streaming)) {
return false;
}
const progressConfig = (streaming as Record<string, unknown>).progress;
return (
Boolean(progressConfig) &&
typeof progressConfig === "object" &&
!Array.isArray(progressConfig) &&
(progressConfig as { nativeTaskCards?: unknown }).nativeTaskCards === true
);
}
export function resolveSlackStreamingThreadHint(params: {
replyToMode: "off" | "first" | "all" | "batched";
incomingThreadTs: string | undefined;
messageTs: string | undefined;
isThreadReply?: boolean;
}): string | undefined {
return resolveSlackThreadTs({
replyToMode: params.replyToMode,
incomingThreadTs: params.incomingThreadTs,
messageTs: params.messageTs,
hasReplied: false,
isThreadReply: params.isThreadReply,
});
}
export type SlackEventDeliveryAttempt = {
kind: ReplyDispatchKind;
payload: ReplyPayload;
threadTs?: string;
textOverride?: string;
};
const SLACK_STREAM_RECIPIENT_TEAM_CACHE_MAX = 2000;
const slackStreamRecipientTeamCaches = new WeakMap<object, Map<string, string>>();
function getSlackStreamRecipientTeamCache(client: object): Map<string, string> {
const existing = slackStreamRecipientTeamCaches.get(client);
if (existing) {
return existing;
}
const cache = new Map<string, string>();
slackStreamRecipientTeamCaches.set(client, cache);
return cache;
}
function buildSlackEventDeliveryKey(params: SlackEventDeliveryAttempt): string | null {
const reply = resolveSendableOutboundReplyParts(params.payload, {
text: params.textOverride,
});
const renderPlan = resolveSlackReplyRenderPlan(
params.payload,
params.textOverride ?? params.payload.text,
);
const plannedBlocks =
renderPlan.mode === "single" ? renderPlan.blocks : renderPlan.blockPart?.blocks;
const slackBlocks = readSlackReplyBlocks(params.payload) ?? plannedBlocks;
const renderedText = renderPlan.mode === "single" ? renderPlan.text : renderPlan.fallbackText;
if (!reply.hasContent && !slackBlocks?.length && !renderedText.trim()) {
return null;
}
return JSON.stringify({
kind: params.kind,
threadTs: params.threadTs ?? "",
replyToId: params.payload.replyToId ?? null,
text: renderedText || reply.trimmedText,
mediaUrls: reply.mediaUrls,
blocks: slackBlocks ?? null,
});
}
function readSlackStreamRecipientTeamCache(params: {
client: object;
fallbackTeamId?: string;
userId?: string;
}): string | undefined {
if (!params.fallbackTeamId || !params.userId) {
return undefined;
}
const cacheKey = `${params.fallbackTeamId}:${params.userId}`;
const cache = getSlackStreamRecipientTeamCache(params.client);
const cached = cache.get(cacheKey);
if (!cached) {
return undefined;
}
cache.delete(cacheKey);
cache.set(cacheKey, cached);
return cached;
}
function rememberSlackStreamRecipientTeam(params: {
client: object;
fallbackTeamId?: string;
userId?: string;
teamId: string;
}): void {
if (!params.fallbackTeamId || !params.userId) {
return;
}
const cacheKey = `${params.fallbackTeamId}:${params.userId}`;
const cache = getSlackStreamRecipientTeamCache(params.client);
if (cache.has(cacheKey)) {
cache.delete(cacheKey);
}
cache.set(cacheKey, params.teamId);
if (cache.size > SLACK_STREAM_RECIPIENT_TEAM_CACHE_MAX) {
const oldest = cache.keys().next().value;
if (oldest) {
cache.delete(oldest);
}
}
}
export function createSlackEventDeliveryTracker() {
const deliveredKeys = new Set<string>();
return {
hasDelivered(params: SlackEventDeliveryAttempt) {
const key = buildSlackEventDeliveryKey(params);
return key ? deliveredKeys.has(key) : false;
},
markDelivered(params: SlackEventDeliveryAttempt) {
const key = buildSlackEventDeliveryKey(params);
if (key) {
deliveredKeys.add(key);
}
},
};
}
export function shouldUseStreaming(params: {
streamingEnabled: boolean;
threadTs: string | undefined;
}): boolean {
if (!params.streamingEnabled) {
return false;
}
if (!params.threadTs) {
logVerbose("slack-stream: streaming disabled — no reply thread target available");
return false;
}
return true;
}
export async function resolveSlackStreamRecipientTeamId(params: {
client: Pick<PreparedSlackMessage["ctx"]["app"]["client"], "users">;
token: string;
userId?: PreparedSlackMessage["message"]["user"];
fallbackTeamId?: string;
}): Promise<string | undefined> {
const cachedTeamId = readSlackStreamRecipientTeamCache(params);
if (cachedTeamId) {
return cachedTeamId;
}
if (params.userId) {
try {
const info = await params.client.users.info({
token: params.token,
user: params.userId,
});
const teamId = info.user?.team_id ?? info.user?.profile?.team;
if (teamId) {
rememberSlackStreamRecipientTeam({ ...params, teamId });
return teamId;
}
} catch (err) {
logVerbose(`slack-stream: users.info team lookup failed (${formatErrorMessage(err)})`);
}
}
return params.fallbackTeamId;
}

View File

@@ -0,0 +1,23 @@
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { formatSlackError } from "../../errors.js";
import type { SlackDispatchSetup } from "./dispatch-setup.js";
import { finalizeSlackPreviewEdit } from "./preview-finalize.js";
export async function collapseSlackProgressReceipt(params: {
setup: Pick<SlackDispatchSetup, "account" | "ctx" | "slackClient">;
edit: Omit<Parameters<typeof finalizeSlackPreviewEdit>[0], "client" | "token" | "accountId">;
}): Promise<boolean> {
const { account, ctx, slackClient } = params.setup;
try {
await finalizeSlackPreviewEdit({
client: slackClient,
token: ctx.botToken,
accountId: account.accountId,
...params.edit,
});
return true;
} catch (err) {
logVerbose(`slack: progress receipt edit failed (${formatSlackError(err)})`);
return false;
}
}

View File

@@ -0,0 +1,83 @@
import {
buildChannelProgressDraftLine,
type AgentPlanStep,
type ChannelProgressDraftCompositorLine,
type ChannelProgressDraftCompositorSnapshot,
type ChannelProgressDraftLine,
} from "openclaw/plugin-sdk/channel-outbound";
import {
buildSlackProgressStreamStartChunks,
buildSlackProgressStreamUpdateChunks,
} from "../../progress-blocks.js";
export function resolveStructuredProgressLines(
lines: readonly ChannelProgressDraftCompositorLine[],
): ChannelProgressDraftLine[] {
return lines.map((line) => {
if (typeof line !== "string") {
return line;
}
const reasoning = line.startsWith("🧠 ");
const text = line
.replace(/^(?:🧠|💬)\s+/u, "")
.replace(/^_(.*)_$/su, "$1")
.trim();
return {
// Reasoning snapshots replace one rolling row; text-based ids would orphan it each delta.
...(reasoning ? { id: "reasoning" } : {}),
kind: "item",
text,
label: reasoning ? "Reasoning" : "Update",
prefix: false,
};
});
}
// Native cards derive from the compositor snapshot. Empty plans fall back
// to line tasks, and reconciliation retires rows from the prior source.
export function resolveNativeProgressPlan(
snapshot: ChannelProgressDraftCompositorSnapshot,
): readonly AgentPlanStep[] | undefined {
return snapshot.plan?.length ? snapshot.plan : undefined;
}
export function resolveNativeProgressLines(
snapshot: ChannelProgressDraftCompositorSnapshot,
): ChannelProgressDraftLine[] {
const lines = resolveStructuredProgressLines(snapshot.lines);
if (snapshot.plan?.length || !snapshot.planExplanation) {
return lines;
}
const explanationLine = buildChannelProgressDraftLine({
event: "plan",
phase: "update",
explanation: snapshot.planExplanation,
});
return explanationLine ? [...lines, explanationLine] : lines;
}
export function combineProgressHeadlineAndExplanation(
headline: string | undefined,
explanation: string | undefined,
): string | undefined {
return headline && explanation && headline !== explanation
? `${headline}${explanation}`
: (headline ?? explanation);
}
export function buildNativeProgressChunks(params: {
snapshot: ChannelProgressDraftCompositorSnapshot;
streamStarted: boolean;
title?: string;
maxLineChars?: number;
}) {
const input = {
title: params.title,
lines: resolveNativeProgressLines(params.snapshot),
plan: resolveNativeProgressPlan(params.snapshot),
maxLineChars: params.maxLineChars,
};
return params.streamStarted
? buildSlackProgressStreamUpdateChunks(input)
: buildSlackProgressStreamStartChunks(input);
}

View File

@@ -0,0 +1,671 @@
import type { AgentPlanStep } from "openclaw/plugin-sdk/channel-outbound";
import {
createChannelProgressDraftCompositor,
createChannelProgressReceiptTracker,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine,
resolveChannelProgressDraftMaxLines,
resolveChannelProgressDraftMaxLineChars,
resolveChannelProgressDraftRender,
resolveChannelStreamingPreviewToolProgress,
resolveChannelStreamingSuppressDefaultToolProgressMessages,
type ChannelProgressDraftCompositorSnapshot,
type ChannelProgressDraftLine,
} from "openclaw/plugin-sdk/channel-outbound";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { createSlackDraftStream } from "../../draft-stream.js";
import { formatSlackError } from "../../errors.js";
import { SLACK_TEXT_LIMIT } from "../../limits.js";
import {
buildSlackProgressDraftBlocks,
buildSlackProgressStreamCompletionChunks,
buildSlackProgressStreamStartChunks,
buildSlackProgressStreamUpdateChunks,
reconcileSlackNativeTaskChunks,
type SlackNativeTaskSnapshot,
} from "../../progress-blocks.js";
import { applyAppendOnlyStreamUpdate } from "../../stream-mode.js";
import {
appendSlackStream,
startSlackStream,
stopSlackStream,
type SlackStreamSession,
} from "../../streaming.js";
import { escapeSlackMrkdwn } from "../mrkdwn.js";
import {
resolveExplicitSlackProgressTitle,
resolveSlackStreamRecipientTeamId,
} from "./dispatch-helpers.js";
import { collapseSlackProgressReceipt } from "./dispatch-progress-io.js";
import {
buildNativeProgressChunks as buildRenderedNativeProgressChunks,
combineProgressHeadlineAndExplanation,
resolveNativeProgressLines,
resolveNativeProgressPlan,
resolveStructuredProgressLines,
} from "./dispatch-progress-render.js";
import type { SlackDispatchSetup } from "./dispatch-setup.js";
import type { SlackStreamingDeliveryRuntime } from "./dispatch-streaming.js";
export function createSlackProgressRuntime(runtimeParams: {
setup: SlackDispatchSetup;
delivery: SlackStreamingDeliveryRuntime;
resetPreviewDeliveryState: () => void;
}) {
const { setup, delivery, resetPreviewDeliveryState } = runtimeParams;
const {
account,
cfg,
ctx,
message,
prepared,
replyPlan,
runtime,
slackClient,
slackIdentity,
slackMessageMetadata,
slackStreaming,
slackStreamFallbackTeamId,
shouldUseDraftStream,
useStreaming,
previewStreamingEnabled,
} = setup;
const draftStream = shouldUseDraftStream
? createSlackDraftStream({
target: prepared.replyTarget,
cfg,
token: ctx.botToken,
accountId: account.accountId,
...(prepared.eventScope ? { eventScope: prepared.eventScope } : {}),
identity: slackIdentity,
...(slackMessageMetadata ? { metadata: slackMessageMetadata } : {}),
maxChars: Math.min(ctx.textLimit, SLACK_TEXT_LIMIT),
resolveThreadTs: () => {
const ts = replyPlan.peekThreadTs();
if (ts) {
delivery.usedReplyThreadTs ??= ts;
}
return ts;
},
log: logVerbose,
warn: logVerbose,
})
: undefined;
let hasStreamedMessage = false;
const streamMode = slackStreaming.draftMode;
const useNativeProgressStreaming = useStreaming && slackStreaming.mode === "progress";
const progressDraftActive = Boolean(draftStream) || useNativeProgressStreaming;
const previewToolProgressEnabled =
progressDraftActive && resolveChannelStreamingPreviewToolProgress(account.config);
let shouldYieldDraftProgress: () => boolean = () => false;
const suppressDefaultToolProgressMessages =
resolveChannelStreamingSuppressDefaultToolProgressMessages(account.config, {
draftStreamActive: Boolean(draftStream) || useNativeProgressStreaming,
previewToolProgressEnabled,
previewStreamingEnabled,
});
let previewToolProgressSuppressed = false;
let legacyPreviewToolProgressLines: ChannelProgressDraftLine[] = [];
// Last task rows emitted to the native stream; reconciliation terminalizes
// ids that drop out (plan shrinks, tool-line <-> plan source switches).
let nativeTaskState: SlackNativeTaskSnapshot = new Map();
let appendRenderedText = "";
let appendSourceText = "";
let nativeProgressCompletionSent = false;
// Terminal status of the turn's final payload; completion retries and
// queued rotation must not repaint an errored turn as complete.
let nativeProgressTerminalStatus: "complete" | "error" = "complete";
let nativeProgressChunkKey: string | undefined;
const progressReceipt = createChannelProgressReceiptTracker();
let progressReceiptCollapsed = false;
let pendingNativeProgressReceipt: string | undefined;
const progressSeed = `${account.accountId}:${message.channel}`;
const useRichProgressDraft =
streamMode === "status_final" && resolveChannelProgressDraftRender(account.config) === "rich";
const explicitProgressTitle = resolveExplicitSlackProgressTitle(account.config);
const progressDraftMaxLineChars = resolveChannelProgressDraftMaxLineChars(account.config);
const waitForNativeProgressStreamStart = async (): Promise<boolean> => {
if (delivery.streamSession || !delivery.nativeProgressStreamStartPromise) {
return true;
}
try {
await delivery.nativeProgressStreamStartPromise;
} catch {
delivery.streamFailed = true;
return false;
}
return !delivery.streamFailed;
};
const appendNativeProgressCompletion = async (isError: boolean) => {
const session = delivery.streamSession;
if (isError) {
nativeProgressTerminalStatus = "error";
}
if (!session || nativeProgressCompletionSent) {
return;
}
const chunks = buildNativeProgressCompletionChunks(isError ? "error" : "complete");
if (!chunks?.length) {
return;
}
try {
await appendSlackStream({ session, chunks });
nativeProgressCompletionSent = true;
delivery.observedReplyDelivery ||= session.delivered;
} catch (err) {
delivery.streamFailed = true;
runtime.error?.(
danger(`slack-stream: native progress completion failed: ${formatSlackError(err)}`),
);
}
};
const buildNativeProgressChunks = (snapshot: ChannelProgressDraftCompositorSnapshot) =>
buildRenderedNativeProgressChunks({
snapshot,
streamStarted: Boolean(delivery.streamSession),
title: combineProgressHeadlineAndExplanation(
explicitProgressTitle ?? snapshot.statusHeadline,
snapshot.planExplanation,
),
maxLineChars: progressDraftMaxLineChars,
});
const resolveNativeProgressTitle = (snapshot: ChannelProgressDraftCompositorSnapshot) =>
combineProgressHeadlineAndExplanation(
explicitProgressTitle ?? snapshot.statusHeadline,
snapshot.planExplanation,
);
const markNativeProgressDelivered = (session: SlackStreamSession, threadTs?: string) => {
if (session.delivered) {
delivery.observedReplyDelivery = true;
}
if (threadTs) {
delivery.usedReplyThreadTs ??= threadTs;
delivery.rememberDeliveredThreadTs("block", threadTs);
}
};
const startNativeProgressStream = async (
chunks: NonNullable<ReturnType<typeof buildSlackProgressStreamStartChunks>>,
chunkKey: string,
) => {
const streamThreadTs = replyPlan.nextThreadTs();
if (!streamThreadTs) {
logVerbose(
"slack-stream: no reply thread target for native progress stream start, falling back",
);
delivery.streamFailed = true;
return;
}
delivery.nativeProgressStreamThreadTs = streamThreadTs;
const startPromise = (async () => {
const session = await startSlackStream({
client: slackClient,
channel: message.channel,
threadTs: streamThreadTs,
chunks,
taskDisplayMode: "plan",
...(slackIdentity ? { identity: slackIdentity } : {}),
teamId: await resolveSlackStreamRecipientTeamId({
client: slackClient,
token: ctx.botToken,
userId: message.user,
fallbackTeamId: slackStreamFallbackTeamId,
}),
userId: message.user,
});
delivery.streamSession = session;
return session;
})();
delivery.nativeProgressStreamStartPromise = startPromise;
let startedSession: SlackStreamSession | null;
try {
startedSession = await startPromise;
} finally {
if (delivery.nativeProgressStreamStartPromise === startPromise) {
delivery.nativeProgressStreamStartPromise = null;
}
}
if (startedSession) {
markNativeProgressDelivered(startedSession, streamThreadTs);
}
nativeProgressChunkKey = chunkKey;
replyPlan.markSent();
};
const appendNativeProgressStream = async (
chunks: NonNullable<ReturnType<typeof buildSlackProgressStreamUpdateChunks>>,
chunkKey: string,
) => {
if (!delivery.streamSession) {
return;
}
await appendSlackStream({ session: delivery.streamSession, chunks });
markNativeProgressDelivered(delivery.streamSession);
nativeProgressChunkKey = chunkKey;
};
const updateNativeProgressStream = async () => {
const snapshot = progressDraft.getSnapshot();
const progressLines = resolveNativeProgressLines(snapshot);
const hasRetirableNativeTasks = [...nativeTaskState.values()].some(
(task) => task.status !== "complete" && task.status !== "error",
);
if (
!useNativeProgressStreaming ||
delivery.streamFailed ||
(progressLines.length === 0 &&
!snapshot.plan?.length &&
!snapshot.statusHeadline &&
!explicitProgressTitle &&
!hasRetirableNativeTasks)
) {
return;
}
const canContinue = await waitForNativeProgressStreamStart();
if (!canContinue) {
return;
}
const reconciled = reconcileSlackNativeTaskChunks({
previousTasks: nativeTaskState,
chunks: buildNativeProgressChunks(snapshot),
});
const chunks = reconciled.chunks;
if (!chunks?.length) {
return;
}
const chunkKey = JSON.stringify(chunks);
if (chunkKey === nativeProgressChunkKey) {
return;
}
try {
if (!delivery.streamSession) {
await startNativeProgressStream(chunks, chunkKey);
} else {
await appendNativeProgressStream(chunks, chunkKey);
}
// Commit only after Slack accepted the chunks; a failed emit must retry
// the same reconciliation against the previous snapshot.
nativeTaskState = reconciled.tasks;
} catch (err) {
runtime.error?.(
danger(
`slack-stream: native progress stream failed: ${formatSlackError(err)}, falling back`,
),
);
delivery.streamFailed = true;
}
};
const resetProgressTurnState = () => {
progressReceipt.reset();
progressReceiptCollapsed = false;
pendingNativeProgressReceipt = undefined;
};
const collapseProgressReceipt = async (
params: Parameters<typeof collapseSlackProgressReceipt>[0]["edit"],
) => {
progressReceiptCollapsed = await collapseSlackProgressReceipt({ setup, edit: params });
};
const progressDraft = createChannelProgressDraftCompositor({
entry: account.config,
mode: slackStreaming.mode,
active: progressDraftActive && streamMode === "status_final",
seed: progressSeed,
formatLine: escapeSlackMrkdwn,
reasoningLinePrefix: "🧠 ",
commentaryLinePrefix: "💬 ",
reasoningGate: previewToolProgressEnabled,
commentaryItalics: false,
updateOnLineChange: useNativeProgressStreaming || useRichProgressDraft,
update: async (previewText, options) => {
if (useNativeProgressStreaming) {
await updateNativeProgressStream();
return;
}
if (!draftStream) {
return;
}
const snapshot = progressDraft.getSnapshot();
const structuredLines = resolveStructuredProgressLines(options?.lines ?? snapshot.lines);
const richNarration = combineProgressHeadlineAndExplanation(
snapshot.statusHeadline,
snapshot.planExplanation,
);
const richProgressBlocks = useRichProgressDraft
? buildSlackProgressDraftBlocks({
title: explicitProgressTitle,
lines: structuredLines,
plan: snapshot.plan,
narration: richNarration,
maxLineChars: progressDraftMaxLineChars,
})
: undefined;
draftStream.update(
useRichProgressDraft && richProgressBlocks
? { text: previewText, blocks: richProgressBlocks }
: previewText,
);
hasStreamedMessage = true;
if (options?.flush) {
await draftStream.flush();
}
},
});
const commentaryProgressEnabled = progressDraft.commentaryProgressEnabled;
const buildNativeProgressCompletionChunks = (finalInProgressStatus: "complete" | "error") => {
const snapshot = progressDraft.getSnapshot();
const lines = resolveNativeProgressLines(snapshot);
const hasRetirableNativeTasks = [...nativeTaskState.values()].some(
(task) => task.status !== "complete" && task.status !== "error",
);
if (lines.length === 0 && !snapshot.plan?.length && !hasRetirableNativeTasks) {
return undefined;
}
return reconcileSlackNativeTaskChunks({
previousTasks: nativeTaskState,
chunks: buildSlackProgressStreamCompletionChunks({
title: resolveNativeProgressTitle(snapshot),
lines,
plan: resolveNativeProgressPlan(snapshot),
maxLineChars: progressDraftMaxLineChars,
finalInProgressStatus,
}),
}).chunks;
};
const finishNativeProgressTurn = async (
completionChunks: ReturnType<typeof buildNativeProgressCompletionChunks>,
) => {
if (delivery.nativeProgressStreamStartPromise) {
await delivery.nativeProgressStreamStartPromise.catch(() => null);
}
const session = delivery.streamSession;
if (session && !session.stopped) {
try {
if (completionChunks?.length) {
nativeProgressCompletionSent = true;
}
const stopResult = await stopSlackStream({
session,
...(completionChunks?.length ? { chunks: completionChunks } : {}),
...(slackMessageMetadata ? { metadata: slackMessageMetadata } : {}),
});
delivery.acknowledgeStoppedStreamedDeliveries(session, stopResult?.messageId);
if (pendingNativeProgressReceipt && stopResult?.messageId) {
await collapseProgressReceipt({
channelId: session.channel,
messageId: stopResult.messageId,
text: pendingNativeProgressReceipt,
threadTs: session.threadTs,
});
}
} catch (err) {
const error = formatSlackError(err);
// stopSlackStream makes the one-shot session terminal before throwing.
// Settle delivery bookkeeping before releasing that handle.
delivery.emitAcknowledgedStreamedDeliveries();
delivery.emitFailedPendingStreamedDeliveries(error);
logVerbose(`slack-stream: failed to rotate native progress stream (${error})`);
}
}
delivery.streamSession = null;
delivery.nativeProgressStreamStartPromise = null;
delivery.nativeProgressStreamThreadTs = undefined;
delivery.streamFailed = false;
};
const pushPlanProgress = async (steps?: AgentPlanStep[], explanation?: string) => {
if (streamMode === "status_final") {
await progressDraft.pushPlanProgress(steps, { explanation });
return;
}
if (previewToolProgressSuppressed || !draftStream) {
return;
}
const text = formatChannelProgressDraftText({
entry: account.config,
lines: legacyPreviewToolProgressLines,
seed: progressSeed,
formatLine: escapeSlackMrkdwn,
narration: explanation,
plan: steps,
});
if (text) {
draftStream.update(text);
hasStreamedMessage = true;
}
};
const pushPreviewProgress = async (
line?: ChannelProgressDraftLine,
options?: { toolName?: string },
) => {
if (!draftStream && !useNativeProgressStreaming) {
return;
}
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
return;
}
const normalized = line?.text.replace(/\s+/g, " ").trim();
if (streamMode === "status_final") {
if (!line || !normalized) {
await progressDraft.noteActivity();
return;
}
await progressDraft.pushToolProgress(line, options);
return;
}
if (
!line ||
!normalized ||
!draftStream ||
!previewToolProgressEnabled ||
previewToolProgressSuppressed
) {
return;
}
const nextLines = mergeChannelProgressDraftLine(legacyPreviewToolProgressLines, line, {
maxLines: resolveChannelProgressDraftMaxLines(account.config),
});
if (nextLines === legacyPreviewToolProgressLines) {
return;
}
legacyPreviewToolProgressLines = nextLines;
draftStream.update(
formatChannelProgressDraftText({
entry: account.config,
lines: legacyPreviewToolProgressLines,
seed: progressSeed,
formatLine: escapeSlackMrkdwn,
}),
);
hasStreamedMessage = true;
};
const updateDraftFromPartial = (text?: string) => {
const trimmed = text?.trimEnd();
if (!trimmed) {
return;
}
if (streamMode === "append") {
previewToolProgressSuppressed = true;
legacyPreviewToolProgressLines = [];
const next = applyAppendOnlyStreamUpdate({
incoming: trimmed,
rendered: appendRenderedText,
source: appendSourceText,
});
appendRenderedText = next.rendered;
appendSourceText = next.source;
if (!next.changed) {
return;
}
draftStream?.update(next.rendered);
hasStreamedMessage = true;
return;
}
if (streamMode === "status_final") {
return;
}
previewToolProgressSuppressed = true;
legacyPreviewToolProgressLines = [];
draftStream?.update(trimmed);
hasStreamedMessage = true;
};
const pushReasoningProgress = async (payload?: {
text?: string;
isReasoningSnapshot?: boolean;
}) => {
if (!payload?.text) {
return;
}
if (streamMode !== "status_final") {
const normalized = progressDraft
.mergeReasoningProgress(payload.text, {
snapshot: payload.isReasoningSnapshot === true,
})
.replace(/^_(.*)_$/su, "$1")
.trim();
if (!normalized) {
return;
}
await pushPreviewProgress({
id: "reasoning",
kind: "item",
text: normalized,
label: "Reasoning",
});
return;
}
progressReceipt.noteReasoning();
await progressDraft.pushReasoningProgress(payload.text, {
snapshot: payload.isReasoningSnapshot === true,
});
};
const resetDraftDeliveryState = () => {
hasStreamedMessage = false;
appendRenderedText = "";
appendSourceText = "";
};
const resetDraftProgressState = () => {
progressDraft.resetReasoningProgress();
previewToolProgressSuppressed = false;
legacyPreviewToolProgressLines = [];
};
const beginNewProgressTurn = async (options?: { force?: boolean }) => {
const completionChunks =
useNativeProgressStreaming && !nativeProgressCompletionSent
? buildNativeProgressCompletionChunks(nativeProgressTerminalStatus)
: undefined;
if (!progressDraft.beginNewTurn(options)) {
return false;
}
// Native messages are one-shot streams. Stop the prior turn before the
// reset compositor can publish the queued turn's first snapshot.
if (useNativeProgressStreaming) {
await finishNativeProgressTurn(completionChunks);
} else {
draftStream?.forceNewMessage();
}
resetProgressTurnState();
nativeTaskState = new Map();
nativeProgressCompletionSent = false;
nativeProgressTerminalStatus = "complete";
nativeProgressChunkKey = undefined;
// A re-armed turn is a new visible reply: it must not dedupe against or
// inherit delivery state from the settled turn (mirrors queued admission).
resetPreviewDeliveryState();
delivery.resetDeliveryTracker();
progressReceiptCollapsed = false;
return true;
};
const onDraftBoundary =
!shouldUseDraftStream && !useNativeProgressStreaming
? undefined
: async () => {
if (streamMode === "status_final") {
await beginNewProgressTurn();
return;
}
if (hasStreamedMessage) {
draftStream?.forceNewMessage();
}
resetDraftDeliveryState();
resetDraftProgressState();
};
const onQueuedFollowupAdmitted =
!shouldUseDraftStream && !useNativeProgressStreaming
? undefined
: async () => {
// A queued input is a new visible reply even though it drains through
// this turn's callbacks. Do not let it edit or dedupe against this run.
await draftStream?.flush();
resetPreviewDeliveryState();
if (streamMode === "status_final") {
await beginNewProgressTurn({ force: true });
} else {
draftStream?.forceNewMessage();
}
delivery.resetDeliveryTracker();
resetDraftDeliveryState();
resetDraftProgressState();
};
return {
draftStream,
streamMode,
useNativeProgressStreaming,
progressDraftActive,
previewToolProgressEnabled,
suppressDefaultToolProgressMessages,
progressDraft,
commentaryProgressEnabled,
progressReceipt,
get progressReceiptCollapsed() {
return progressReceiptCollapsed;
},
get pendingNativeProgressReceipt() {
return pendingNativeProgressReceipt;
},
set pendingNativeProgressReceipt(value: string | undefined) {
pendingNativeProgressReceipt = value;
},
get nativeProgressCompletionSent() {
return nativeProgressCompletionSent;
},
set nativeProgressCompletionSent(value: boolean) {
nativeProgressCompletionSent = value;
},
get nativeProgressTerminalStatus() {
return nativeProgressTerminalStatus;
},
appendNativeProgressCompletion,
beginNewProgressTurn,
buildNativeProgressCompletionChunks,
collapseProgressReceipt,
onDraftBoundary,
onQueuedFollowupAdmitted,
pushPlanProgress,
pushPreviewProgress,
pushReasoningProgress,
updateDraftFromPartial,
waitForNativeProgressStreamStart,
setShouldYieldDraftProgress: (value: () => boolean) => {
shouldYieldDraftProgress = value;
},
shouldYieldDraftProgress: () => shouldYieldDraftProgress(),
};
}

View File

@@ -0,0 +1,359 @@
import {
createStatusReactionController,
DEFAULT_TIMING,
logAckFailure,
logTypingFailure,
type StatusReactionAdapter,
} from "openclaw/plugin-sdk/channel-feedback";
import {
createChannelMessageReplyPipeline,
resolveAgentOutboundIdentity,
resolveChannelMessageSourceReplyDeliveryMode,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingNativeTransport,
} from "openclaw/plugin-sdk/channel-outbound";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { reactSlackMessage, removeSlackReaction } from "../../actions.js";
import { formatSlackError } from "../../errors.js";
import {
compileSlackInteractiveReplies,
isSlackInteractiveRepliesEnabled,
} from "../../interactive-replies.js";
import { resolveSlackStreamingConfig } from "../../stream-mode.js";
import { resolveSlackThreadTargets } from "../../threading.js";
import { normalizeSlackAllowOwnerEntry } from "../allow-list.js";
import { resolveStorePath, updateLastRoute } from "../config.runtime.js";
import { createSlackReplyDeliveryPlan } from "../replies.js";
import {
isSlackStreamingEnabled,
resolveSlackDisableBlockStreaming,
resolveSlackNativeProgressTaskCards,
resolveSlackStreamingThreadHint,
shouldEnableSlackPreviewStreaming,
shouldInitializeSlackDraftStream,
shouldUseStreaming,
} from "./dispatch-helpers.js";
import type { PreparedSlackMessage } from "./types.js";
export async function createSlackDispatchSetup(prepared: PreparedSlackMessage) {
const { ctx, account, message, route } = prepared;
const slackClient = prepared.eventScope?.client ?? ctx.app.client;
const slackStreamFallbackTeamId = prepared.eventScope?.teamId ?? ctx.teamId;
const cfg = ctx.cfg;
const runtime = ctx.runtime;
// Resolve agent identity for Slack chat:write.customize overrides.
const outboundIdentity = resolveAgentOutboundIdentity(cfg, route.agentId);
const slackIdentity = outboundIdentity
? {
username: outboundIdentity.name,
iconUrl: outboundIdentity.avatarUrl,
iconEmoji: outboundIdentity.emoji,
}
: prepared.relayIdentity;
if (prepared.isDirectMessage) {
const sessionCfg = cfg.session;
const storePath = resolveStorePath(sessionCfg?.store, {
agentId: route.agentId,
});
const pinnedMainDmOwner = resolvePinnedMainDmOwnerFromAllowlist({
dmScope: cfg.session?.dmScope,
allowFrom: ctx.allowFrom,
normalizeEntry: normalizeSlackAllowOwnerEntry,
});
const senderRecipient = normalizeOptionalLowercaseString(message.user);
const inboundLastRouteSessionKey = resolveInboundLastRouteSessionKey({
route,
sessionKey: prepared.ctxPayload.SessionKey ?? route.sessionKey,
});
const skipMainUpdate =
inboundLastRouteSessionKey === route.mainSessionKey &&
pinnedMainDmOwner &&
senderRecipient &&
normalizeOptionalLowercaseString(pinnedMainDmOwner) !== senderRecipient;
if (skipMainUpdate) {
logVerbose(
`slack: skip main-session last route for ${senderRecipient} (pinned owner ${pinnedMainDmOwner})`,
);
} else {
await updateLastRoute({
storePath,
sessionKey: inboundLastRouteSessionKey,
deliveryContext: {
channel: "slack",
to: `user:${message.user}`,
accountId: route.accountId,
threadId: prepared.ctxPayload.MessageThreadId ?? prepared.ctxPayload.TransportThreadId,
},
ctx: prepared.ctxPayload,
});
}
}
const threadTargets = resolveSlackThreadTargets({
message,
replyToMode: prepared.replyToMode,
});
const forcedReplyThreadTs = prepared.forcedReplyThreadTs;
const slackMessageMetadata = prepared.slackMessageMetadata;
const statusThreadTs = forcedReplyThreadTs ?? threadTargets.statusThreadTs;
const isThreadReply = threadTargets.isThreadReply;
const replyDeliveryMode = forcedReplyThreadTs ? "off" : prepared.replyToMode;
const sourceReplyDeliveryMode = resolveChannelMessageSourceReplyDeliveryMode({
cfg,
ctx: prepared.ctxPayload,
});
const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only";
const suppressRoomEventTyping = prepared.ctxPayload.InboundEventKind === "room_event";
// Shared context for the `message_sent` plugin hook emitted on each delivered
// reply (both the `deliverReplies` paths and the native-streaming finalizer).
const messageSentHookTarget =
prepared.ctxPayload.OriginatingTo ?? prepared.ctxPayload.To ?? prepared.replyTarget;
const messageSentHookContext = {
sessionKeyForInternalHooks: prepared.ctxPayload.SessionKey ?? route.sessionKey,
isGroup: prepared.isRoomish,
groupId: prepared.isRoomish ? message.channel : undefined,
};
const messageSentDeliveryHookContext = {
...messageSentHookContext,
messageSentHookTarget,
};
const reactionMessageTs = prepared.ackReactionMessageTs;
const messageTs = message.ts ?? message.event_ts;
const incomingThreadTs = message.thread_ts;
let didSetStatus = false;
const statusReactionsEnabled =
prepared.ctxPayload.InboundEventKind !== "room_event" &&
Boolean(prepared.ackReactionPromise) &&
Boolean(reactionMessageTs) &&
cfg.messages?.statusReactions?.enabled === true;
const slackStatusAdapter: StatusReactionAdapter = {
setReaction: async (emoji) => {
await reactSlackMessage(message.channel, reactionMessageTs ?? "", emoji, {
token: ctx.botToken,
client: slackClient,
}).catch((err: unknown) => {
if (formatErrorMessage(err).includes("already_reacted")) {
return;
}
throw err;
});
},
removeReaction: async (emoji) => {
await removeSlackReaction(message.channel, reactionMessageTs ?? "", emoji, {
token: ctx.botToken,
client: slackClient,
}).catch((err: unknown) => {
if (formatErrorMessage(err).includes("no_reaction")) {
return;
}
throw err;
});
},
};
const statusReactions = createStatusReactionController({
enabled: statusReactionsEnabled,
adapter: slackStatusAdapter,
initialEmoji: prepared.ackReactionValue || "eyes",
emojis: undefined,
timing: DEFAULT_TIMING,
onError: (err) => {
logAckFailure({
log: logVerbose,
channel: "slack",
target: `${message.channel}/${message.ts}`,
error: err,
});
},
});
if (statusReactionsEnabled) {
void statusReactions.setQueued();
}
// Shared mutable ref for "replyToMode=first". Both tool + auto-reply flows
// mark this to ensure only the first reply is threaded.
const hasRepliedRef = { value: false };
const replyPlan = createSlackReplyDeliveryPlan({
replyToMode: replyDeliveryMode,
incomingThreadTs: forcedReplyThreadTs ?? incomingThreadTs,
messageTs,
hasRepliedRef,
isThreadReply: Boolean(forcedReplyThreadTs) || isThreadReply,
});
const typingTarget = statusThreadTs ? `${message.channel}/${statusThreadTs}` : message.channel;
const typingReaction = ctx.typingReaction;
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
cfg,
agentId: route.agentId,
channel: "slack",
accountId: route.accountId,
transformReplyPayload: (payload) => {
if (payload.isReasoning === true) {
return null;
}
return isSlackInteractiveRepliesEnabled({ cfg, accountId: route.accountId })
? compileSlackInteractiveReplies(payload)
: payload;
},
typing: {
start: async () => {
didSetStatus = true;
await ctx.setSlackThreadStatus({
channelId: message.channel,
threadTs: statusThreadTs,
status: "is typing...",
eventScope: prepared.eventScope,
});
if (typingReaction && message.ts) {
await reactSlackMessage(message.channel, message.ts, typingReaction, {
token: ctx.botToken,
client: slackClient,
}).catch((err: unknown) => {
logVerbose(`slack send: typing reaction failed: ${formatSlackError(err)}`);
});
}
},
stop: async () => {
if (!didSetStatus) {
return;
}
didSetStatus = false;
await ctx.setSlackThreadStatus({
channelId: message.channel,
threadTs: statusThreadTs,
status: "",
eventScope: prepared.eventScope,
});
if (typingReaction && message.ts) {
await removeSlackReaction(message.channel, message.ts, typingReaction, {
token: ctx.botToken,
client: slackClient,
}).catch((err: unknown) => {
logVerbose(`slack send: typing reaction removal failed: ${formatSlackError(err)}`);
});
}
},
onStartError: (err) => {
logTypingFailure({
log: (messageValue) => runtime.error?.(danger(messageValue)),
channel: "slack",
action: "start",
target: typingTarget,
error: err,
});
},
onStopError: (err) => {
logTypingFailure({
log: (messageLocal) => runtime.error?.(danger(messageLocal)),
channel: "slack",
action: "stop",
target: typingTarget,
error: err,
});
},
},
});
const slackStreaming = resolveSlackStreamingConfig({
streaming: account.config.streaming,
nativeStreaming: resolveChannelStreamingNativeTransport(account.config),
});
const streamThreadHint =
forcedReplyThreadTs ??
resolveSlackStreamingThreadHint({
replyToMode: replyDeliveryMode,
incomingThreadTs,
messageTs,
isThreadReply,
});
const previewStreamingEnabled =
!sourceRepliesAreToolOnly &&
shouldEnableSlackPreviewStreaming({
mode: slackStreaming.mode,
});
const hasSlackCustomIdentity = Boolean(
slackIdentity?.username || slackIdentity?.iconUrl || slackIdentity?.iconEmoji,
);
const streamingEnabled =
!sourceRepliesAreToolOnly &&
isSlackStreamingEnabled({
mode: slackStreaming.mode,
nativeStreaming: slackStreaming.nativeStreaming,
nativeProgressTaskCards: resolveSlackNativeProgressTaskCards(account.config),
});
const useStreaming = shouldUseStreaming({
streamingEnabled,
threadTs: streamThreadHint,
});
// chat.update cannot preserve custom authorship. Use native streaming when
// possible; otherwise keep identity intact with one final postMessage.
const shouldUseDraftStream =
!hasSlackCustomIdentity &&
shouldInitializeSlackDraftStream({
previewStreamingEnabled,
useStreaming,
});
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
const disableBlockStreaming = sourceRepliesAreToolOnly
? true
: resolveSlackDisableBlockStreaming({
useStreaming,
shouldUseDraftStream,
blockStreamingEnabled,
});
const onSlackDeliveryError = (err: unknown, info: { kind: string }) => {
runtime.error?.(danger(`slack ${info.kind} reply failed: ${formatSlackError(err)}`));
replyPipeline.typingCallbacks?.onIdle?.();
};
return {
prepared,
ctx,
account,
message,
route,
slackClient,
slackStreamFallbackTeamId,
cfg,
runtime,
slackIdentity,
forcedReplyThreadTs,
slackMessageMetadata,
statusThreadTs,
isThreadReply,
replyDeliveryMode,
sourceReplyDeliveryMode,
sourceRepliesAreToolOnly,
suppressRoomEventTyping,
messageSentHookTarget,
messageSentHookContext,
messageSentDeliveryHookContext,
incomingThreadTs,
messageTs,
statusReactionsEnabled,
statusReactions,
hasRepliedRef,
replyPlan,
onModelSelected,
replyPipeline,
slackStreaming,
streamThreadHint,
previewStreamingEnabled,
shouldUseDraftStream,
disableBlockStreaming,
useStreaming,
onSlackDeliveryError,
};
}
export type SlackDispatchSetup = Awaited<ReturnType<typeof createSlackDispatchSetup>>;

View File

@@ -0,0 +1,607 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import type { ReplyDispatchKind, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { formatSlackError } from "../../errors.js";
import { emitSlackMessageSentHooks } from "../../message-sent-hook.js";
import { resolveSlackReplyRenderPlan } from "../../reply-blocks.js";
import {
appendSlackStream,
markSlackStreamFallbackDelivered,
SlackStreamNotDeliveredError,
startSlackStream,
stopSlackStream,
type SlackStreamSession,
} from "../../streaming.js";
import {
deliverReplies,
readSlackReplyBlocks,
resolveDeliveredSlackReplyThreadTs,
} from "../replies.js";
import {
createSlackEventDeliveryTracker,
resolveSlackStreamRecipientTeamId,
type SlackEventDeliveryAttempt,
} from "./dispatch-helpers.js";
import type { SlackDispatchSetup } from "./dispatch-setup.js";
export function createSlackStreamingDeliveryRuntime(setup: SlackDispatchSetup) {
const {
account,
ctx,
forcedReplyThreadTs,
isThreadReply,
message,
messageSentDeliveryHookContext,
messageSentHookContext,
messageSentHookTarget,
prepared,
replyDeliveryMode,
replyPlan,
runtime,
slackClient,
slackIdentity,
slackMessageMetadata,
slackStreamFallbackTeamId,
} = setup;
const state = {
streamSession: null as SlackStreamSession | null,
nativeProgressStreamStartPromise: null as Promise<SlackStreamSession | null> | null,
nativeProgressStreamThreadTs: undefined as string | undefined,
streamFailed: false,
usedReplyThreadTs: undefined as string | undefined,
usedBlockReplyThreadTs: undefined as string | undefined,
observedReplyDelivery: false,
observedFinalReplyDelivery: false,
};
// Reply payloads routed through the native text stream. Track Slack
// acknowledgement separately because a later buffered suffix can fall back
// after earlier payloads are already visible.
const streamedDeliveries: Array<{
kind: ReplyDispatchKind;
content: string;
acknowledged: boolean;
outcome?: "success" | "failure";
}> = [];
const streamedFailuresOwnedByDispatcher: Record<ReplyDispatchKind, number> = {
tool: 0,
block: 0,
final: 0,
};
const refreshStreamedAcknowledgements = (session: SlackStreamSession) => {
if (session.pendingText.length === 0) {
for (const delivery of streamedDeliveries) {
delivery.acknowledged = true;
}
}
};
const recordStreamedDelivery = (kind: ReplyDispatchKind, content: string) => {
const delivery: (typeof streamedDeliveries)[number] = {
kind,
content,
acknowledged: false,
};
streamedDeliveries.push(delivery);
return delivery;
};
const rememberStreamedDelivery = (
kind: ReplyDispatchKind,
content: string,
session: SlackStreamSession,
) => {
recordStreamedDelivery(kind, content);
refreshStreamedAcknowledgements(session);
};
const emitAcknowledgedStreamedDeliveries = (messageId?: string) => {
for (const delivery of streamedDeliveries) {
if (!delivery.acknowledged || delivery.outcome) {
continue;
}
emitSlackMessageSentHooks({
...messageSentHookContext,
to: messageSentHookTarget,
accountId: account.accountId,
content: delivery.content,
success: true,
...(messageId ? { messageId } : {}),
});
delivery.outcome = "success";
}
};
const acknowledgeStoppedStreamedDeliveries = (
session: SlackStreamSession,
messageId?: string,
) => {
refreshStreamedAcknowledgements(session);
for (const delivery of streamedDeliveries) {
delivery.acknowledged = true;
}
emitAcknowledgedStreamedDeliveries(messageId);
};
const emitFailedPendingStreamedDeliveries = (error: string) => {
for (const delivery of streamedDeliveries) {
if (delivery.acknowledged || delivery.outcome) {
continue;
}
emitSlackMessageSentHooks({
...messageSentHookContext,
to: messageSentHookTarget,
accountId: account.accountId,
content: delivery.content,
success: false,
error,
});
delivery.outcome = "failure";
}
};
const emitSuccessfulPendingStreamedDeliveries = (messageId?: string) => {
for (const delivery of streamedDeliveries) {
if (delivery.acknowledged || delivery.outcome) {
continue;
}
emitSlackMessageSentHooks({
...messageSentHookContext,
to: messageSentHookTarget,
accountId: account.accountId,
content: delivery.content,
success: true,
...(messageId ? { messageId } : {}),
});
delivery.outcome = "success";
}
};
let deliveryTracker = createSlackEventDeliveryTracker();
const markPreviewPayloadDelivered = (params: {
kind: ReplyDispatchKind;
payload: ReplyPayload;
threadTs: string | undefined;
}) => {
deliveryTracker.markDelivered(params);
// Single-use reply modes move later same-turn payloads off the preview
// thread, so protect both delivery keys from duplicates.
const nextThreadTs = replyPlan.peekThreadTs();
if (nextThreadTs !== params.threadTs) {
deliveryTracker.markDelivered({ ...params, threadTs: nextThreadTs });
}
};
const resolveDeliveryThreadTs = (params: {
kind: ReplyDispatchKind;
forcedThreadTs?: string;
}): string | undefined => {
const plannedThreadTs = params.forcedThreadTs ? undefined : replyPlan.nextThreadTs();
return (
params.forcedThreadTs ??
plannedThreadTs ??
(params.kind === "block" ? state.usedBlockReplyThreadTs : undefined)
);
};
const rememberDeliveredThreadTs = (
kind: ReplyDispatchKind,
deliveredThreadTs: string | undefined,
) => {
if (!deliveredThreadTs) {
return;
}
state.usedReplyThreadTs ??= deliveredThreadTs;
if (kind === "block") {
state.usedBlockReplyThreadTs = deliveredThreadTs;
}
};
const deliverPendingStreamFallback = async (
session: SlackStreamSession,
err: SlackStreamNotDeliveredError,
): Promise<boolean> => {
let fallbackError = err;
if (!session.stopped) {
try {
const stopResult = await stopSlackStream({
session,
...(slackMessageMetadata ? { metadata: slackMessageMetadata } : {}),
});
acknowledgeStoppedStreamedDeliveries(session, stopResult.messageId);
state.observedReplyDelivery = true;
state.usedReplyThreadTs ??= session.threadTs;
return true;
} catch (stopErr) {
if (stopErr instanceof SlackStreamNotDeliveredError) {
fallbackError = stopErr;
} else {
runtime.error?.(
danger(
`slack-stream: failed to finalize buffered text before fallback: ${formatSlackError(stopErr)}`,
),
);
}
}
}
emitAcknowledgedStreamedDeliveries();
// The Slack SDK still owns this text in-memory; no streaming API call has
// acknowledged it. Route through deliverReplies so pendingText that
// exceeds Slack's per-message text limit still lands (a single
// chat.postMessage would have failed with msg_too_long), and so the
// fallback respects the configured replyToMode/identity the same way
// normal replies do.
const fallbackText = fallbackError.pendingText.trim();
if (!fallbackText) {
return false;
}
try {
await deliverReplies({
cfg: ctx.cfg,
replies: [{ text: fallbackText } as ReplyPayload],
target: prepared.replyTarget,
token: ctx.botToken,
accountId: account.accountId,
runtime,
textLimit: ctx.textLimit,
mediaMaxBytes: ctx.mediaMaxBytes,
replyThreadTs: session.threadTs,
replyToMode: replyDeliveryMode,
...(slackIdentity ? { identity: slackIdentity } : {}),
...(slackMessageMetadata ? { metadata: slackMessageMetadata } : {}),
...messageSentDeliveryHookContext,
deferMessageSentHooks: true,
...(prepared.eventScope ? { eventScope: prepared.eventScope } : {}),
});
markSlackStreamFallbackDelivered(session);
if (!session.stopped) {
try {
await stopSlackStream({
session,
...(slackMessageMetadata ? { metadata: slackMessageMetadata } : {}),
});
} catch (finalizeErr) {
runtime.error?.(
danger(
`slack-stream: failed to finalize native stream after fallback delivery: ${formatSlackError(finalizeErr)}`,
),
);
}
}
// The combined fallback can span multiple logical payloads and Slack
// chunks, so no single message `ts` correctly identifies every event.
emitSuccessfulPendingStreamedDeliveries();
state.observedReplyDelivery = true;
state.usedReplyThreadTs ??= session.threadTs;
logVerbose(
`slack-stream: streamed delivery failed (${fallbackError.slackCode}); delivered ${fallbackText.length} chars via deliverReplies fallback`,
);
return true;
} catch (postErr) {
emitFailedPendingStreamedDeliveries(formatErrorMessage(postErr));
runtime.error?.(
danger(
`slack-stream: fallback deliverReplies failed after ${fallbackError.slackCode}: ${formatErrorMessage(postErr)}`,
),
);
return false;
}
};
const deliverNormally = async (params: {
payload: ReplyPayload;
kind: ReplyDispatchKind;
forcedThreadTs?: string;
}): Promise<string | undefined> => {
if (params.payload.isReasoning === true) {
return undefined;
}
const replyThreadTs = resolveDeliveryThreadTs(params);
const deliveryReplyThreadTs =
replyDeliveryMode === "off" && !forcedReplyThreadTs && !isThreadReply
? undefined
: replyThreadTs;
if (
deliveryTracker.hasDelivered({
kind: params.kind,
payload: params.payload,
threadTs: deliveryReplyThreadTs,
})
) {
logVerbose("slack: suppressed duplicate normal delivery within the same turn");
return deliveryReplyThreadTs;
}
await deliverReplies({
cfg: ctx.cfg,
replies: [params.payload],
target: prepared.replyTarget,
token: ctx.botToken,
accountId: account.accountId,
runtime,
textLimit: ctx.textLimit,
mediaMaxBytes: ctx.mediaMaxBytes,
replyThreadTs: deliveryReplyThreadTs,
replyToMode: replyDeliveryMode,
...(slackIdentity ? { identity: slackIdentity } : {}),
...(slackMessageMetadata ? { metadata: slackMessageMetadata } : {}),
...messageSentDeliveryHookContext,
...(prepared.eventScope ? { eventScope: prepared.eventScope } : {}),
});
state.observedReplyDelivery = true;
if (params.kind === "final") {
state.observedFinalReplyDelivery = true;
}
const deliveredThreadTs = resolveDeliveredSlackReplyThreadTs({
replyToMode: replyDeliveryMode,
payloadReplyToId: params.payload.replyToId,
replyThreadTs: deliveryReplyThreadTs,
});
// Record the thread ts only after confirmed delivery success.
rememberDeliveredThreadTs(params.kind, deliveredThreadTs);
replyPlan.markSent();
deliveryTracker.markDelivered({
kind: params.kind,
payload: params.payload,
threadTs: deliveryReplyThreadTs,
});
return deliveryReplyThreadTs;
};
const deliverBufferedStreamFallback = async (params: {
session: SlackStreamSession;
err: SlackStreamNotDeliveredError;
payload: ReplyPayload;
kind: ReplyDispatchKind;
textOverride: string;
}): Promise<boolean> => {
const delivered = await deliverPendingStreamFallback(params.session, params.err);
if (!delivered) {
// The reply dispatcher will charge the currently executing payload as
// failed; earlier buffered payloads need separate reconciliation below.
streamedFailuresOwnedByDispatcher[params.kind] += 1;
return false;
}
replyPlan.markSent();
if (params.kind === "final") {
state.observedFinalReplyDelivery = true;
}
deliveryTracker.markDelivered({
kind: params.kind,
payload: params.payload,
threadTs: params.session.threadTs,
textOverride: params.textOverride,
});
rememberDeliveredThreadTs(params.kind, params.session.threadTs);
return true;
};
const deliverWithStreaming = async (params: {
payload: ReplyPayload;
kind: ReplyDispatchKind;
}): Promise<void> => {
if (params.payload.isReasoning === true) {
return;
}
const reply = resolveSendableOutboundReplyParts(params.payload);
const renderPlan = resolveSlackReplyRenderPlan(params.payload);
const plannedBlocks =
renderPlan.mode === "single" ? renderPlan.blocks : renderPlan.blockPart?.blocks;
if (
state.streamFailed ||
reply.hasMedia ||
renderPlan.mode === "split" ||
Boolean(plannedBlocks?.length) ||
readSlackReplyBlocks(params.payload)?.length ||
!reply.hasText
) {
await deliverNormally({
payload: params.payload,
kind: params.kind,
forcedThreadTs: state.streamSession?.threadTs ?? state.nativeProgressStreamThreadTs,
});
return;
}
const text = reply.trimmedText;
let plannedThreadTs: string | undefined;
try {
if (!state.streamSession && state.nativeProgressStreamStartPromise) {
await state.nativeProgressStreamStartPromise;
}
if (state.streamFailed) {
await deliverNormally({
payload: params.payload,
kind: params.kind,
forcedThreadTs: state.streamSession?.threadTs ?? state.nativeProgressStreamThreadTs,
});
return;
}
if (!state.streamSession) {
const streamThreadTs = replyPlan.nextThreadTs();
plannedThreadTs = streamThreadTs;
if (!streamThreadTs) {
logVerbose(
"slack-stream: no reply thread target for stream start, falling back to normal delivery",
);
state.streamFailed = true;
await deliverNormally({
payload: params.payload,
kind: params.kind,
});
return;
}
if (
deliveryTracker.hasDelivered({
kind: params.kind,
payload: params.payload,
threadTs: streamThreadTs,
textOverride: text,
})
) {
logVerbose("slack-stream: suppressed duplicate stream start payload");
return;
}
state.streamSession = await startSlackStream({
client: slackClient,
channel: message.channel,
threadTs: streamThreadTs,
text,
...(slackIdentity ? { identity: slackIdentity } : {}),
teamId: await resolveSlackStreamRecipientTeamId({
client: slackClient,
token: ctx.botToken,
userId: message.user,
fallbackTeamId: slackStreamFallbackTeamId,
}),
userId: message.user,
});
refreshStreamedAcknowledgements(state.streamSession);
// startSlackStream may only buffer locally. Count delivery only after
// the SDK reports a real Slack response.
if (state.streamSession.delivered) {
state.observedReplyDelivery = true;
if (params.kind === "final") {
state.observedFinalReplyDelivery = true;
}
}
// Remember the reply text delivered through the text stream so the
// `message_sent` hook can fire after stopSlackStream flushes it.
// Only the text-stream path captures this; every deliverNormally branch
// already emits via deliverReplies, so capturing there would
// double-emit for the same payload.
if (text) {
rememberStreamedDelivery(params.kind, text, state.streamSession);
}
rememberDeliveredThreadTs(params.kind, streamThreadTs);
replyPlan.markSent();
deliveryTracker.markDelivered({
kind: params.kind,
payload: params.payload,
threadTs: streamThreadTs,
textOverride: text,
});
return;
}
if (
deliveryTracker.hasDelivered({
kind: params.kind,
payload: params.payload,
threadTs: state.streamSession.threadTs,
textOverride: text,
})
) {
logVerbose("slack-stream: suppressed duplicate append payload");
return;
}
if (text) {
// appendSlackStream buffers text before attempting the Slack flush.
// Record first so a later successful stop can acknowledge a thrown append.
recordStreamedDelivery(params.kind, text);
}
await appendSlackStream({
session: state.streamSession,
text: "\n" + text,
});
refreshStreamedAcknowledgements(state.streamSession);
// appendSlackStream also buffers locally below the SDK threshold; avoid
// optimistic "done" status until Slack acknowledges a flush.
if (state.streamSession.delivered) {
state.observedReplyDelivery = true;
if (params.kind === "final") {
state.observedFinalReplyDelivery = true;
}
}
deliveryTracker.markDelivered({
kind: params.kind,
payload: params.payload,
threadTs: state.streamSession.threadTs,
textOverride: text,
});
} catch (err) {
if (err instanceof SlackStreamNotDeliveredError) {
state.streamFailed = true;
if (state.streamSession) {
const delivered = await deliverBufferedStreamFallback({
session: state.streamSession,
err,
payload: params.payload,
kind: params.kind,
textOverride: text,
});
if (delivered) {
return;
}
throw err;
}
await deliverNormally({
payload: params.payload,
kind: params.kind,
forcedThreadTs: plannedThreadTs,
});
return;
}
runtime.error?.(
danger(`slack-stream: streaming API call failed: ${formatSlackError(err)}, falling back`),
);
state.streamFailed = true;
// Non-benign streaming errors leave `pendingText` populated with every
// buffered chunk since the last flush (appendSlackStream accumulates
// into pendingText BEFORE the SDK call, so the failing chunk is
// included too). Route the full buffer through the chunked fallback so
// earlier chunks aren't lost, then skip deliverNormally - pendingText
// already contains this payload's text.
if (state.streamSession && state.streamSession.pendingText) {
const bufferedFallbackErr = new SlackStreamNotDeliveredError(
state.streamSession.pendingText,
"unknown",
);
const delivered = await deliverBufferedStreamFallback({
session: state.streamSession,
err: bufferedFallbackErr,
payload: params.payload,
kind: params.kind,
textOverride: text,
});
if (delivered) {
return;
}
throw err;
}
await deliverNormally({
payload: params.payload,
kind: params.kind,
forcedThreadTs:
state.streamSession?.threadTs ?? state.nativeProgressStreamThreadTs ?? plannedThreadTs,
});
}
};
const reconcileCounts = (counts: Partial<Record<ReplyDispatchKind, number>>) => {
const next = { ...counts };
let changed = false;
for (const kind of ["tool", "block", "final"] as const) {
const failedStreamedCount = streamedDeliveries.filter(
(delivery) => delivery.kind === kind && delivery.outcome === "failure",
).length;
const additionalFailedStreamed = Math.max(
0,
failedStreamedCount - streamedFailuresOwnedByDispatcher[kind],
);
if (additionalFailedStreamed > 0) {
next[kind] = Math.max(0, (next[kind] ?? 0) - additionalFailedStreamed);
changed = true;
}
}
return changed ? next : counts;
};
return Object.assign(state, {
acknowledgeStoppedStreamedDeliveries,
deliverNormally,
deliverPendingStreamFallback,
deliverWithStreaming,
emitAcknowledgedStreamedDeliveries,
emitFailedPendingStreamedDeliveries,
hasDelivered: (params: SlackEventDeliveryAttempt) => deliveryTracker.hasDelivered(params),
markPreviewPayloadDelivered,
rememberDeliveredThreadTs,
reconcileCounts,
resetDeliveryTracker: () => {
deliveryTracker = createSlackEventDeliveryTracker();
},
});
}
export type SlackStreamingDeliveryRuntime = ReturnType<typeof createSlackStreamingDeliveryRuntime>;

File diff suppressed because it is too large Load Diff