mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
c56eaca14b
* fix(slack): preserve Markdown in progress drafts * fix(slack): retain escaping for tool progress * fix(slack): scope progress line escaping * fix(slack): preserve progress formatting by source * test(slack): assert plan refresh rendering * test(slack): keep progress regression coverage scoped * update tests --------- Co-authored-by: Sarah Fortune <sarah.fortune@gmail.com>
4842 lines
167 KiB
TypeScript
4842 lines
167 KiB
TypeScript
// Slack tests cover dispatch.preview fallback plugin behavior.
|
||
import type { GetReplyOptions, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
||
const FINAL_REPLY_TEXT = "final answer";
|
||
const THREAD_TS = "thread-1";
|
||
const SAME_TEXT = "same reply";
|
||
|
||
const getGlobalHookRunnerMock = vi.hoisted(() => vi.fn());
|
||
const createSlackDraftStreamMock = vi.fn();
|
||
const deliverRepliesMock = vi.fn(
|
||
async () => undefined as { messageId?: string; channelId?: string } | undefined,
|
||
);
|
||
const finalizeSlackPreviewEditMock = vi.fn(async () => {});
|
||
const normalizeSlackOutboundTextMock = vi.fn((value: string) => value.trim());
|
||
const postMessageMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
|
||
const chatUpdateMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
|
||
const recordSlackThreadParticipationMock = vi.fn();
|
||
const updateLastRouteMock = vi.fn(async () => {});
|
||
const appendSlackStreamMock = vi.fn(async () => {});
|
||
const startSlackStreamMock = vi.fn(async () => ({
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: true,
|
||
pendingText: "",
|
||
}));
|
||
const stopSlackStreamMock = vi.fn(async (_params?: unknown) => ({}) as { messageId?: string });
|
||
const emitSlackMessageSentHooksMock = vi.fn(() => {});
|
||
const reactSlackMessageMock = vi.fn(async () => {});
|
||
const removeSlackReactionMock = vi.fn(async () => {});
|
||
const logVerboseMock = vi.fn();
|
||
class TestSlackStreamNotDeliveredError extends Error {
|
||
readonly pendingText: string;
|
||
readonly slackCode: string;
|
||
constructor(pendingText: string, slackCode: string) {
|
||
super(`slack-stream not delivered: ${slackCode}`);
|
||
this.name = "SlackStreamNotDeliveredError";
|
||
this.pendingText = pendingText;
|
||
this.slackCode = slackCode;
|
||
}
|
||
}
|
||
let mockedNativeStreaming = false;
|
||
let mockedBlockStreamingEnabled: boolean | undefined = false;
|
||
let mockedSlackStreamingMode: "off" | "partial" | "block" | "progress" = "partial";
|
||
let mockedSlackDraftMode: "replace" | "status_final" | "append" = "append";
|
||
let mockedPinnedMainDmOwner: string | undefined;
|
||
let capturedReplyOptions: GetReplyOptions | undefined;
|
||
let capturedStatusReactionOptions: { enabled?: boolean; initialEmoji?: string } | undefined;
|
||
const statusReactionControllerMock = {
|
||
setQueued: vi.fn(async () => {}),
|
||
setThinking: vi.fn(async () => {}),
|
||
setTool: vi.fn(async () => {}),
|
||
setError: vi.fn(async () => {}),
|
||
setDone: vi.fn(async () => {}),
|
||
clear: vi.fn(async () => {}),
|
||
restoreInitial: vi.fn(async () => {}),
|
||
};
|
||
let mockedReplyThreadTs: string | undefined = THREAD_TS;
|
||
let mockedReplyThreadTsSequence: Array<string | undefined> | undefined;
|
||
let mockedSlackReplyBlocks: unknown[] | undefined;
|
||
let mockedSlackIsThreadReply = true;
|
||
let capturedTyping:
|
||
| {
|
||
start: () => Promise<void>;
|
||
stop?: () => Promise<void>;
|
||
onStartError: (err: unknown) => void;
|
||
onStopError?: (err: unknown) => void;
|
||
}
|
||
| undefined;
|
||
type TestReplyDispatchKind = "tool" | "block" | "final";
|
||
type TestReplyPayload = {
|
||
text?: string;
|
||
isError?: boolean;
|
||
isReasoning?: boolean;
|
||
mediaUrl?: string;
|
||
mediaUrls?: string[];
|
||
audioAsVoice?: boolean;
|
||
spokenText?: string;
|
||
ttsSupplement?: { spokenText: string; visibleTextAlreadyDelivered?: boolean };
|
||
presentation?: { blocks: unknown[] };
|
||
};
|
||
type TestDispatchCounts = Record<TestReplyDispatchKind, number>;
|
||
type TestDispatchSequenceEntry =
|
||
| {
|
||
kind: TestReplyDispatchKind;
|
||
payload: TestReplyPayload;
|
||
}
|
||
| { kind: "queued_followup" }
|
||
| { kind: "item"; progressText: string };
|
||
let mockedDispatchSequence: TestDispatchSequenceEntry[] = [];
|
||
let mockedQueuedDispatchCounts: TestDispatchCounts = { tool: 0, block: 0, final: 0 };
|
||
let mockedDispatcherCapturesDeliveryErrors = false;
|
||
|
||
let mockedProgressEvents: string[] = [];
|
||
let mockedEmptyProgressToolName: string | undefined;
|
||
let mockedReplyOptionEvents: Array<
|
||
| {
|
||
kind: "item";
|
||
itemId?: string;
|
||
toolCallId?: string;
|
||
itemKind?: string;
|
||
progressText?: string;
|
||
summary?: string;
|
||
title?: string;
|
||
name?: string;
|
||
phase?: string;
|
||
status?: string;
|
||
meta?: string;
|
||
}
|
||
| {
|
||
kind: "tool_start";
|
||
itemId?: string;
|
||
toolCallId?: string;
|
||
name: string;
|
||
phase?: string;
|
||
args?: Record<string, unknown>;
|
||
detailMode?: "explain" | "raw";
|
||
}
|
||
| {
|
||
kind: "patch";
|
||
itemId?: string;
|
||
toolCallId?: string;
|
||
phase?: string;
|
||
title?: string;
|
||
name?: string;
|
||
added?: string[];
|
||
modified?: string[];
|
||
deleted?: string[];
|
||
summary?: string;
|
||
}
|
||
| {
|
||
kind: "command_output";
|
||
itemId?: string;
|
||
toolCallId?: string;
|
||
phase?: string;
|
||
title?: string;
|
||
name?: string;
|
||
explanation?: string;
|
||
status?: string;
|
||
exitCode?: number | null;
|
||
}
|
||
| {
|
||
kind: "plan";
|
||
phase?: string;
|
||
explanation?: string;
|
||
steps: Array<{ step: string; status: "pending" | "in_progress" | "completed" }>;
|
||
}
|
||
| { kind: "concurrent_items"; progressTexts: string[] }
|
||
| { kind: "partial"; text: string }
|
||
| { kind: "assistant_start" }
|
||
| { kind: "reasoning"; text?: string; isReasoningSnapshot?: boolean }
|
||
| { kind: "reasoning_end" }
|
||
> = [];
|
||
|
||
function requireCapturedTyping() {
|
||
if (!capturedTyping) {
|
||
throw new Error("expected Slack typing callback");
|
||
}
|
||
return capturedTyping;
|
||
}
|
||
|
||
function createSlackPlatformError(error: string, details?: { needed?: string; provided?: string }) {
|
||
// Mirrors @slack/web-api 7.18.0 platformErrorFromResult: message plus structured result data.
|
||
return Object.assign(new Error(`An API error occurred: ${error}`), {
|
||
code: "slack_webapi_platform_error",
|
||
data: { ok: false, error, ...details },
|
||
});
|
||
}
|
||
|
||
function requireCapturedItemEventHandler() {
|
||
const handler = capturedReplyOptions?.onItemEvent;
|
||
if (!handler) {
|
||
throw new Error("expected Slack reply item event handler");
|
||
}
|
||
return handler;
|
||
}
|
||
|
||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||
if (typeof value !== "object" || value === null) {
|
||
throw new Error(`${label} was not an object`);
|
||
}
|
||
return value as Record<string, unknown>;
|
||
}
|
||
|
||
function expectRecordFields(record: Record<string, unknown>, fields: Record<string, unknown>) {
|
||
for (const [key, value] of Object.entries(fields)) {
|
||
expect(record[key]).toEqual(value);
|
||
}
|
||
}
|
||
|
||
function requireMockCall(mock: unknown, index: number, label: string): unknown[] {
|
||
const call = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls?.[index];
|
||
if (!call) {
|
||
throw new Error(`missing ${label} call ${index + 1}`);
|
||
}
|
||
return call;
|
||
}
|
||
|
||
function expectMockCallArgFields(
|
||
mock: unknown,
|
||
index: number,
|
||
label: string,
|
||
fields: Record<string, unknown>,
|
||
) {
|
||
expectRecordFields(requireRecord(requireMockCall(mock, index, label)[0], label), fields);
|
||
}
|
||
|
||
function expectNativeProgressStart(chunks: unknown[]) {
|
||
expect(postMessageMock).not.toHaveBeenCalled();
|
||
expect(chatUpdateMock).not.toHaveBeenCalled();
|
||
expectMockCallArgFields(startSlackStreamMock, 0, "native progress stream start", {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
taskDisplayMode: "plan",
|
||
chunks,
|
||
});
|
||
}
|
||
|
||
function expectNativeProgressAppend(index: number, chunks: unknown[]) {
|
||
expectMockCallArgFields(appendSlackStreamMock, index, "native progress stream append", {
|
||
chunks,
|
||
});
|
||
}
|
||
|
||
function planUpdate(title: string) {
|
||
return { type: "plan_update", title };
|
||
}
|
||
|
||
function taskUpdate(
|
||
id: unknown,
|
||
title: string,
|
||
status: "pending" | "in_progress" | "complete" | "error",
|
||
) {
|
||
return { type: "task_update", id, title, status };
|
||
}
|
||
|
||
function contentTaskId(prefix: string) {
|
||
return expect.stringMatching(new RegExp(`^${prefix}_[a-f0-9]{8}_1$`, "u"));
|
||
}
|
||
|
||
function collectNativeTaskUpdates() {
|
||
const chunks: unknown[] = [];
|
||
const collectChunks = (call: unknown[]) => {
|
||
const arg = requireRecord(call[0], "native progress call");
|
||
if (Array.isArray(arg.chunks)) {
|
||
chunks.push(...arg.chunks);
|
||
}
|
||
};
|
||
for (const call of startSlackStreamMock.mock.calls) {
|
||
collectChunks(call);
|
||
}
|
||
for (const call of appendSlackStreamMock.mock.calls) {
|
||
collectChunks(call);
|
||
}
|
||
for (const call of stopSlackStreamMock.mock.calls) {
|
||
collectChunks(call);
|
||
}
|
||
return chunks.flatMap((chunk) => {
|
||
const record = requireRecord(chunk, "native progress chunk");
|
||
return record.type === "task_update" ? [record] : [];
|
||
});
|
||
}
|
||
|
||
function expectDeliverReplyCall(index: number, text: string, fields?: Record<string, unknown>) {
|
||
const params = requireRecord(
|
||
requireMockCall(deliverRepliesMock, index, "deliver replies")[0],
|
||
"deliver replies params",
|
||
);
|
||
expectRecordFields(params, { replyThreadTs: THREAD_TS, ...fields });
|
||
expect(params.replies).toEqual([{ text }]);
|
||
}
|
||
|
||
const noop = () => {};
|
||
const noopAsync = async () => {};
|
||
function createDraftStreamStub() {
|
||
return {
|
||
update: vi.fn(),
|
||
flush: vi.fn(noopAsync),
|
||
clear: vi.fn(noopAsync),
|
||
discardPending: vi.fn(noopAsync),
|
||
seal: vi.fn(noopAsync),
|
||
stop: vi.fn(noop),
|
||
forceNewMessage: vi.fn(),
|
||
messageId: () => "171234.567",
|
||
channelId: () => "C123",
|
||
};
|
||
}
|
||
|
||
function createPreparedSlackMessage(params?: {
|
||
cfg?: Record<string, unknown>;
|
||
accountConfig?: Record<string, unknown>;
|
||
ctxPayload?: Record<string, unknown>;
|
||
message?: Partial<{
|
||
channel: string;
|
||
ts: string;
|
||
thread_ts?: string;
|
||
user: string;
|
||
bot_id: string;
|
||
event_ts: string;
|
||
}>;
|
||
channelConfig?: Record<string, unknown> | null;
|
||
replyToMode?: "off" | "first" | "all" | "batched";
|
||
isDirectMessage?: boolean;
|
||
route?: Partial<{
|
||
agentId: string;
|
||
accountId: string;
|
||
mainSessionKey: string;
|
||
sessionKey: string;
|
||
lastRoutePolicy: "main" | "session";
|
||
}>;
|
||
setSlackThreadStatus?: (params: {
|
||
channelId: string;
|
||
threadTs?: string;
|
||
status: string;
|
||
loadingMessages?: string[];
|
||
}) => Promise<void>;
|
||
typingReaction?: string;
|
||
ackReactionMessageTs?: string;
|
||
ackReactionPromise?: Promise<boolean> | null;
|
||
relayIdentity?: { username?: string; iconUrl?: string; iconEmoji?: string };
|
||
turnAdoptionLifecycle?: object;
|
||
eventScope?: {
|
||
apiAppId: string;
|
||
enterpriseId: string;
|
||
isEnterpriseInstall: true;
|
||
teamId: string;
|
||
client: Record<string, unknown>;
|
||
};
|
||
}) {
|
||
const routeSessionKey = params?.route?.sessionKey ?? "agent:agent-1:slack:C123";
|
||
const mainSessionKey = params?.route?.mainSessionKey ?? "main";
|
||
const lastRoutePolicy =
|
||
params?.route?.lastRoutePolicy ?? (routeSessionKey === mainSessionKey ? "main" : "session");
|
||
const message = {
|
||
channel: "C123",
|
||
ts: "171234.111",
|
||
thread_ts: THREAD_TS,
|
||
user: "U123",
|
||
...params?.message,
|
||
};
|
||
|
||
return {
|
||
ctx: {
|
||
cfg: params?.cfg ?? {},
|
||
runtime: {},
|
||
botToken: "xoxb-test",
|
||
app: { client: { chat: { postMessage: postMessageMock, update: chatUpdateMock } } },
|
||
teamId: "T1",
|
||
botUserId: "U_OPENCLAW",
|
||
botId: "B_OPENCLAW",
|
||
textLimit: 4000,
|
||
typingReaction: params?.typingReaction ?? "",
|
||
historyLimit: 0,
|
||
channelHistories: new Map(),
|
||
allowFrom: [],
|
||
setSlackThreadStatus: params?.setSlackThreadStatus ?? (async () => undefined),
|
||
},
|
||
account: {
|
||
accountId: "default",
|
||
config: params?.accountConfig ?? {},
|
||
},
|
||
relayIdentity: params?.relayIdentity,
|
||
turnAdoptionLifecycle: params?.turnAdoptionLifecycle,
|
||
eventScope: params?.eventScope,
|
||
message,
|
||
route: {
|
||
agentId: "agent-1",
|
||
accountId: "default",
|
||
mainSessionKey,
|
||
sessionKey: routeSessionKey,
|
||
lastRoutePolicy,
|
||
...params?.route,
|
||
},
|
||
channelConfig: params?.channelConfig ?? null,
|
||
replyTarget: `channel:${message.channel}`,
|
||
ctxPayload: {
|
||
MessageThreadId: THREAD_TS,
|
||
...params?.ctxPayload,
|
||
},
|
||
turn: {
|
||
storePath: "/tmp/slack-sessions.json",
|
||
record: {},
|
||
},
|
||
replyToMode: params?.replyToMode ?? "all",
|
||
isDirectMessage: params?.isDirectMessage ?? false,
|
||
isRoomish: false,
|
||
historyKey: "history-key",
|
||
preview: "",
|
||
ackReactionValue: "eyes",
|
||
ackReactionMessageTs: params?.ackReactionMessageTs,
|
||
ackReactionPromise: params?.ackReactionPromise ?? null,
|
||
} as never;
|
||
}
|
||
|
||
async function dispatchNativeProgressScenario(params: {
|
||
events: typeof mockedReplyOptionEvents;
|
||
finalPayload?: { text: string; isError?: boolean };
|
||
progress?: {
|
||
label?: string | false;
|
||
maxLineChars?: number;
|
||
nativeTaskCards?: true;
|
||
render?: "rich";
|
||
toolProgress?: boolean;
|
||
};
|
||
replyToMode?: "off" | "first" | "all" | "batched";
|
||
eventScope?: {
|
||
apiAppId: string;
|
||
enterpriseId: string;
|
||
isEnterpriseInstall: true;
|
||
teamId: string;
|
||
client: Record<string, unknown>;
|
||
};
|
||
}) {
|
||
mockedNativeStreaming = true;
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence =
|
||
params.finalPayload === undefined ? [] : [{ kind: "final", payload: params.finalPayload }];
|
||
mockedReplyOptionEvents = params.events;
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
replyToMode: params.replyToMode,
|
||
eventScope: params.eventScope,
|
||
accountConfig: {
|
||
streaming: {
|
||
mode: "progress",
|
||
progress: params.progress ?? { nativeTaskCards: true, render: "rich" },
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
}
|
||
|
||
vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
|
||
resolveHumanDelayConfig: () => undefined,
|
||
}));
|
||
|
||
vi.mock("openclaw/plugin-sdk/channel-feedback", () => ({
|
||
DEFAULT_TIMING: {
|
||
doneHoldMs: 0,
|
||
errorHoldMs: 0,
|
||
},
|
||
createStatusReactionController: (params: { enabled?: boolean; initialEmoji?: string }) => {
|
||
capturedStatusReactionOptions = params;
|
||
return statusReactionControllerMock;
|
||
},
|
||
logAckFailure: () => {},
|
||
logTypingFailure: () => {},
|
||
removeAckReactionAfterReply: () => {},
|
||
}));
|
||
|
||
vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-outbound")>();
|
||
return {
|
||
...actual,
|
||
createChannelProgressDraftCompositor: (
|
||
params: Parameters<typeof actual.createChannelProgressDraftCompositor>[0],
|
||
) =>
|
||
actual.createChannelProgressDraftCompositor({
|
||
...params,
|
||
// Gate timing lives in the compositor suite; dispatch tests exercise
|
||
// Slack rendering and delivery after work admits the draft.
|
||
setTimeoutFn: ((handler: () => void) => {
|
||
handler();
|
||
return 0 as never;
|
||
}) as unknown as typeof setTimeout,
|
||
clearTimeoutFn: (() => {}) as typeof clearTimeout,
|
||
}),
|
||
createChannelMessageReplyPipeline: (params: {
|
||
transformReplyPayload?: (payload: TestReplyPayload) => TestReplyPayload | null;
|
||
typing?: {
|
||
start: () => Promise<void>;
|
||
stop?: () => Promise<void>;
|
||
onStartError: (err: unknown) => void;
|
||
onStopError?: (err: unknown) => void;
|
||
};
|
||
}) => {
|
||
capturedTyping = params.typing;
|
||
return {
|
||
...(params.typing
|
||
? {
|
||
typingCallbacks: {
|
||
onReplyStart: params.typing.start,
|
||
onIdle: () => {
|
||
void params.typing?.stop?.();
|
||
},
|
||
},
|
||
}
|
||
: {}),
|
||
...(params.transformReplyPayload
|
||
? { transformReplyPayload: params.transformReplyPayload }
|
||
: {}),
|
||
onModelSelected: undefined,
|
||
};
|
||
},
|
||
resolveChannelMessageSourceReplyDeliveryMode: (params: {
|
||
cfg?: { messages?: { groupChat?: { visibleReplies?: string } } };
|
||
ctx?: { ChatType?: string; InboundEventKind?: string };
|
||
requested?: "automatic" | "message_tool_only";
|
||
}) => {
|
||
if (params.requested) {
|
||
return params.requested;
|
||
}
|
||
if (params.ctx?.InboundEventKind === "room_event") {
|
||
return "message_tool_only";
|
||
}
|
||
const chatType = params.ctx?.ChatType;
|
||
if (chatType === "group" || chatType === "channel") {
|
||
return params.cfg?.messages?.groupChat?.visibleReplies === "automatic"
|
||
? "automatic"
|
||
: "message_tool_only";
|
||
}
|
||
return "automatic";
|
||
},
|
||
resolveAgentOutboundIdentity: () => undefined,
|
||
buildChannelProgressDraftLine: (params: {
|
||
event?: string;
|
||
itemId?: string;
|
||
toolCallId?: string;
|
||
progressText?: string;
|
||
summary?: string;
|
||
explanation?: string;
|
||
title?: string;
|
||
name?: string;
|
||
status?: string;
|
||
exitCode?: number | null;
|
||
}) => {
|
||
if (params.event === "plan") {
|
||
return params.explanation
|
||
? {
|
||
kind: "plan",
|
||
text: `🗺️ Update Plan: ${params.explanation}`,
|
||
label: "Update Plan",
|
||
detail: params.explanation,
|
||
toolName: "update_plan",
|
||
}
|
||
: undefined;
|
||
}
|
||
if (params.event === "command-output") {
|
||
const status =
|
||
params.exitCode === 0
|
||
? "completed"
|
||
: params.exitCode != null
|
||
? `exit ${params.exitCode}`
|
||
: params.status;
|
||
const id = params.toolCallId ? `command:${params.toolCallId}` : params.itemId;
|
||
return {
|
||
kind: "command-output",
|
||
...(id ? { id } : {}),
|
||
text: status ?? params.title ?? params.name ?? "exec",
|
||
label: params.name ?? "exec",
|
||
...(status ? { status } : {}),
|
||
toolName: params.name ?? "exec",
|
||
};
|
||
}
|
||
const text = params.progressText ?? params.summary ?? params.title ?? params.name;
|
||
return text
|
||
? {
|
||
kind: "item",
|
||
...((params.itemId ?? params.toolCallId)
|
||
? { id: params.itemId ?? params.toolCallId }
|
||
: {}),
|
||
text,
|
||
label: params.title ?? params.name ?? "Update",
|
||
}
|
||
: undefined;
|
||
},
|
||
buildChannelProgressDraftLineForEntry: (
|
||
entry: {
|
||
streaming?: {
|
||
progress?: { commandText?: "raw" | "status" };
|
||
preview?: { commandText?: "raw" | "status" };
|
||
};
|
||
},
|
||
params: {
|
||
event?: string;
|
||
itemId?: string;
|
||
toolCallId?: string;
|
||
itemKind?: string;
|
||
args?: Record<string, unknown>;
|
||
meta?: string;
|
||
progressText?: string;
|
||
summary?: string;
|
||
title?: string;
|
||
name?: string;
|
||
},
|
||
) => {
|
||
if (params.event === "tool") {
|
||
if (params.name === mockedEmptyProgressToolName) {
|
||
return undefined;
|
||
}
|
||
const text = params.name;
|
||
return text
|
||
? {
|
||
kind: "tool",
|
||
...((params.itemId ?? params.toolCallId)
|
||
? { id: params.itemId ?? params.toolCallId }
|
||
: {}),
|
||
text,
|
||
label: params.name ?? "Tool",
|
||
...(typeof params.args?.command === "string" ? { detail: params.args.command } : {}),
|
||
toolName: params.name,
|
||
}
|
||
: undefined;
|
||
}
|
||
if (
|
||
params.itemKind === "analysis" &&
|
||
params.title === "Reasoning" &&
|
||
!params.meta &&
|
||
!params.summary &&
|
||
!params.progressText
|
||
) {
|
||
return undefined;
|
||
}
|
||
if (
|
||
(entry.streaming?.progress?.commandText ?? entry.streaming?.preview?.commandText) ===
|
||
"status" &&
|
||
(params.itemKind === "command" || params.name === "exec")
|
||
) {
|
||
const id = params.toolCallId ? `command:${params.toolCallId}` : params.itemId;
|
||
return {
|
||
kind: "item",
|
||
...(id ? { id } : {}),
|
||
text: "🛠️ Exec",
|
||
label: "Exec",
|
||
};
|
||
}
|
||
const text = params.progressText ?? params.summary ?? params.title ?? params.name;
|
||
const id =
|
||
params.itemKind === "command" || params.name === "exec"
|
||
? params.toolCallId
|
||
? `command:${params.toolCallId}`
|
||
: params.itemId
|
||
: undefined;
|
||
return text
|
||
? {
|
||
kind: "item",
|
||
...(id ? { id } : {}),
|
||
text,
|
||
label: params.title ?? params.name ?? "Update",
|
||
}
|
||
: undefined;
|
||
},
|
||
createChannelProgressDraftGate: (params: { onStart: () => void | Promise<void> }) => {
|
||
let started = false;
|
||
const startNow = async () => {
|
||
if (!started) {
|
||
started = true;
|
||
await params.onStart();
|
||
}
|
||
};
|
||
return {
|
||
get hasStarted() {
|
||
return started;
|
||
},
|
||
async noteWork() {
|
||
// Gate timing is covered by the SDK suite; these tests exercise the
|
||
// downstream Slack renderer after an explicit start.
|
||
await startNow();
|
||
return started;
|
||
},
|
||
startNow,
|
||
cancel() {},
|
||
};
|
||
},
|
||
formatChannelProgressDraftText: (params: {
|
||
entry?: { streaming?: { progress?: { label?: string | false; maxLines?: number } } };
|
||
lines: Array<
|
||
string | { text: string; icon?: string; detail?: string; status?: string; label: string }
|
||
>;
|
||
formatLine?: (line: string) => string;
|
||
}) => {
|
||
const label = params.entry?.streaming?.progress?.label;
|
||
const maxLines = params.entry?.streaming?.progress?.maxLines ?? 8;
|
||
const formatLine = params.formatLine ?? ((line: string) => line);
|
||
const lines = [
|
||
label === false ? undefined : (label ?? "Thinking"),
|
||
...params.lines.map((line) => {
|
||
const text =
|
||
typeof line === "string"
|
||
? line
|
||
: line.detail
|
||
? `${line.icon ?? ""} ${line.detail}`.trim()
|
||
: line.status
|
||
? `${line.icon ?? ""} ${line.status}`.trim()
|
||
: line.text;
|
||
const formatted = formatLine(text);
|
||
return /^\p{Extended_Pictographic}/u.test(text) ? formatted : `• ${formatted}`;
|
||
}),
|
||
]
|
||
.filter((line): line is string => Boolean(line))
|
||
.slice(-maxLines);
|
||
return lines.join("\n");
|
||
},
|
||
formatChannelProgressDraftLine: (params: {
|
||
progressText?: string;
|
||
summary?: string;
|
||
title?: string;
|
||
name?: string;
|
||
}) => params.progressText ?? params.summary ?? params.title ?? params.name,
|
||
formatChannelProgressDraftLineForEntry: (
|
||
_entry: unknown,
|
||
params: {
|
||
progressText?: string;
|
||
summary?: string;
|
||
title?: string;
|
||
name?: string;
|
||
},
|
||
) => params.progressText ?? params.summary ?? params.title ?? params.name,
|
||
resolveChannelProgressDraftMaxLines: (entry?: {
|
||
streaming?: { progress?: { maxLines?: number } };
|
||
}) => entry?.streaming?.progress?.maxLines ?? 8,
|
||
resolveChannelProgressDraftMaxLineChars: (entry?: {
|
||
streaming?: { progress?: { maxLineChars?: number } };
|
||
}) => entry?.streaming?.progress?.maxLineChars,
|
||
mergeChannelProgressDraftLine: <TLine extends string | { id?: string; text: string }>(
|
||
lines: TLine[],
|
||
line: TLine,
|
||
params: { maxLines: number },
|
||
) => {
|
||
const normalized = typeof line === "string" ? line.trim() : line.text.trim();
|
||
const lineId = typeof line === "object" ? line.id : undefined;
|
||
if (lineId) {
|
||
const index = lines.findIndex((entry) => typeof entry === "object" && entry.id === lineId);
|
||
if (index >= 0) {
|
||
const next = [...lines];
|
||
next[index] = line;
|
||
return next.slice(-params.maxLines);
|
||
}
|
||
return [...lines, line].slice(-params.maxLines);
|
||
}
|
||
const previous = lines.at(-1);
|
||
const previousText = typeof previous === "string" ? previous.trim() : previous?.text.trim();
|
||
return previousText === normalized ? lines : [...lines, line].slice(-params.maxLines);
|
||
},
|
||
resolveChannelProgressDraftRender: (entry?: {
|
||
streaming?: { progress?: { render?: "text" | "rich" } };
|
||
}) => entry?.streaming?.progress?.render ?? "text",
|
||
resolveChannelStreamingBlockEnabled: () => mockedBlockStreamingEnabled,
|
||
resolveChannelStreamingNativeTransport: () => mockedNativeStreaming,
|
||
resolveChannelStreamingPreviewToolProgress: (entry?: {
|
||
streaming?: { progress?: { toolProgress?: boolean }; preview?: { toolProgress?: boolean } };
|
||
}) =>
|
||
entry?.streaming?.progress?.toolProgress ?? entry?.streaming?.preview?.toolProgress ?? true,
|
||
resolveChannelStreamingSuppressDefaultToolProgressMessages: (
|
||
entry?: {
|
||
streaming?: {
|
||
mode?: string;
|
||
progress?: { toolProgress?: boolean };
|
||
preview?: { toolProgress?: boolean };
|
||
};
|
||
},
|
||
options?: {
|
||
draftStreamActive?: boolean;
|
||
previewStreamingEnabled?: boolean;
|
||
previewToolProgressEnabled?: boolean;
|
||
},
|
||
) => {
|
||
if (options?.draftStreamActive === false || options?.previewStreamingEnabled === false) {
|
||
return false;
|
||
}
|
||
if (entry?.streaming?.mode === "progress") {
|
||
return true;
|
||
}
|
||
if (options?.draftStreamActive === true) {
|
||
return true;
|
||
}
|
||
return options?.previewToolProgressEnabled ?? true;
|
||
},
|
||
isChannelProgressDraftWorkToolName: (name?: string) =>
|
||
Boolean(
|
||
name &&
|
||
![
|
||
"message",
|
||
"messages",
|
||
"reply",
|
||
"send",
|
||
"reaction",
|
||
"react",
|
||
"typing",
|
||
"update_plan",
|
||
].includes(name.toLowerCase()),
|
||
),
|
||
};
|
||
});
|
||
|
||
vi.mock("openclaw/plugin-sdk/reply-history", () => ({
|
||
clearHistoryEntriesIfEnabled: () => {},
|
||
createChannelHistoryWindow: () => ({
|
||
clear: () => {},
|
||
}),
|
||
}));
|
||
|
||
vi.mock("openclaw/plugin-sdk/reply-payload", () => ({
|
||
buildTtsSupplementMediaPayload: (payload: {
|
||
text?: string;
|
||
mediaUrl?: string;
|
||
mediaUrls?: string[];
|
||
audioAsVoice?: boolean;
|
||
spokenText?: string;
|
||
ttsSupplement?: { spokenText: string; visibleTextAlreadyDelivered?: boolean };
|
||
}) => {
|
||
const { text: _text, ...rest } = payload;
|
||
return rest;
|
||
},
|
||
getReplyPayloadTtsSupplement: (payload: {
|
||
mediaUrl?: string;
|
||
mediaUrls?: string[];
|
||
ttsSupplement?: { spokenText?: string; visibleTextAlreadyDelivered?: boolean };
|
||
}) => {
|
||
const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length);
|
||
const spokenText = payload.ttsSupplement?.spokenText?.trim();
|
||
return hasMedia && spokenText
|
||
? {
|
||
spokenText,
|
||
...(payload.ttsSupplement?.visibleTextAlreadyDelivered === true
|
||
? { visibleTextAlreadyDelivered: true }
|
||
: {}),
|
||
}
|
||
: undefined;
|
||
},
|
||
resolveSendableOutboundReplyParts: (
|
||
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] },
|
||
opts?: { text?: string },
|
||
) => {
|
||
const text = (opts?.text ?? payload.text ?? "").trim();
|
||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||
return {
|
||
text,
|
||
trimmedText: text,
|
||
hasText: text.length > 0,
|
||
hasMedia: mediaUrls.length > 0,
|
||
mediaUrls,
|
||
hasContent: text.length > 0 || mediaUrls.length > 0,
|
||
};
|
||
},
|
||
}));
|
||
|
||
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
|
||
danger: (message: string) => message,
|
||
logVerbose: logVerboseMock,
|
||
shouldLogVerbose: () => false,
|
||
}));
|
||
|
||
vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
|
||
return { ...actual, getGlobalHookRunner: getGlobalHookRunnerMock };
|
||
});
|
||
|
||
vi.mock("openclaw/plugin-sdk/security-runtime", () => ({
|
||
resolvePinnedMainDmOwnerFromAllowlist: () => mockedPinnedMainDmOwner,
|
||
}));
|
||
|
||
vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => ({
|
||
isRecord: (value: unknown): value is Record<string, unknown> =>
|
||
typeof value === "object" && value !== null && !Array.isArray(value),
|
||
normalizeOptionalLowercaseString: (value?: string) => value?.toLowerCase(),
|
||
normalizeOptionalString: (value?: string) => value,
|
||
}));
|
||
|
||
vi.mock("../../actions.js", () => ({
|
||
reactSlackMessage: reactSlackMessageMock,
|
||
removeSlackReaction: removeSlackReactionMock,
|
||
}));
|
||
|
||
vi.mock("../../draft-stream.js", () => ({
|
||
createSlackDraftStream: createSlackDraftStreamMock,
|
||
}));
|
||
|
||
vi.mock("../../format.js", () => ({
|
||
markdownToSlackMrkdwnChunks: (value: string) => [value],
|
||
normalizeSlackOutboundText: normalizeSlackOutboundTextMock,
|
||
}));
|
||
|
||
vi.mock("../../limits.js", () => ({
|
||
SLACK_TEXT_LIMIT: 4000,
|
||
SLACK_EDIT_TEXT_MAX_BYTES: 4000,
|
||
}));
|
||
|
||
vi.mock("../../sent-thread-cache.js", () => ({
|
||
recordSlackThreadParticipation: recordSlackThreadParticipationMock,
|
||
}));
|
||
|
||
vi.mock("../../stream-mode.js", () => ({
|
||
applyAppendOnlyStreamUpdate: ({ incoming }: { incoming: string }) => ({
|
||
changed: true,
|
||
rendered: incoming,
|
||
source: incoming,
|
||
}),
|
||
resolveSlackStreamingConfig: () => ({
|
||
mode: mockedSlackStreamingMode,
|
||
nativeStreaming: mockedNativeStreaming,
|
||
draftMode: mockedSlackDraftMode,
|
||
}),
|
||
}));
|
||
|
||
vi.mock("../../streaming.js", () => ({
|
||
appendSlackStream: appendSlackStreamMock,
|
||
markSlackStreamFallbackDelivered: (session: {
|
||
delivered: boolean;
|
||
pendingText: string;
|
||
stopped: boolean;
|
||
}) => {
|
||
session.pendingText = "";
|
||
session.stopped = !session.delivered;
|
||
},
|
||
SlackStreamNotDeliveredError: TestSlackStreamNotDeliveredError,
|
||
startSlackStream: startSlackStreamMock,
|
||
stopSlackStream: async (params: { session: { stopped: boolean } }) => {
|
||
params.session.stopped = true;
|
||
return await stopSlackStreamMock(params);
|
||
},
|
||
}));
|
||
|
||
vi.mock("../../message-sent-hook.js", () => ({
|
||
emitSlackMessageSentHooks: emitSlackMessageSentHooksMock,
|
||
}));
|
||
|
||
vi.mock("../../threading.js", () => ({
|
||
resolveSlackThreadTargets: () => ({
|
||
statusThreadTs: THREAD_TS,
|
||
isThreadReply: mockedSlackIsThreadReply,
|
||
}),
|
||
}));
|
||
|
||
vi.mock("../allow-list.js", () => ({
|
||
normalizeSlackAllowOwnerEntry: (value: string) => value,
|
||
}));
|
||
|
||
vi.mock("../config.runtime.js", () => ({
|
||
resolveStorePath: () => "/tmp/openclaw-store.json",
|
||
updateLastRoute: updateLastRouteMock,
|
||
}));
|
||
|
||
vi.mock("../replies.js", () => ({
|
||
createSlackReplyDeliveryPlan: () => ({
|
||
peekThreadTs: () =>
|
||
mockedReplyThreadTsSequence ? mockedReplyThreadTsSequence[0] : mockedReplyThreadTs,
|
||
nextThreadTs: () =>
|
||
mockedReplyThreadTsSequence ? mockedReplyThreadTsSequence.shift() : mockedReplyThreadTs,
|
||
markSent: () => {},
|
||
}),
|
||
deliverReplies: deliverRepliesMock,
|
||
readSlackReplyBlocks: () => mockedSlackReplyBlocks,
|
||
resolveDeliveredSlackReplyThreadTs: (params: {
|
||
replyToMode: "off" | "first" | "all" | "batched";
|
||
payloadReplyToId?: string;
|
||
replyThreadTs?: string;
|
||
}) =>
|
||
(params.replyToMode === "off" ? undefined : params.payloadReplyToId) ?? params.replyThreadTs,
|
||
resolveSlackThreadTs: () => mockedReplyThreadTs,
|
||
}));
|
||
|
||
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
|
||
type DispatchParams = Parameters<typeof actual.dispatchChannelInboundTurn>[0];
|
||
return {
|
||
...actual,
|
||
dispatchChannelInboundTurn: async (params: DispatchParams) => {
|
||
capturedReplyOptions = params.replyOptions as typeof capturedReplyOptions;
|
||
if (mockedReplyOptionEvents.length > 0) {
|
||
for (const entry of mockedReplyOptionEvents) {
|
||
if (entry.kind === "item") {
|
||
await params.replyOptions?.onItemEvent?.({
|
||
kind: entry.itemKind,
|
||
itemId: entry.itemId,
|
||
toolCallId: entry.toolCallId,
|
||
progressText: entry.progressText,
|
||
summary: entry.summary,
|
||
title: entry.title,
|
||
name: entry.name,
|
||
phase: entry.phase,
|
||
status: entry.status,
|
||
meta: entry.meta,
|
||
});
|
||
} else if (entry.kind === "command_output") {
|
||
await params.replyOptions?.onCommandOutput?.({
|
||
itemId: entry.itemId,
|
||
toolCallId: entry.toolCallId,
|
||
phase: entry.phase,
|
||
title: entry.title,
|
||
name: entry.name,
|
||
status: entry.status,
|
||
exitCode: entry.exitCode,
|
||
});
|
||
} else if (entry.kind === "tool_start") {
|
||
await params.replyOptions?.onToolStart?.({
|
||
itemId: entry.itemId,
|
||
toolCallId: entry.toolCallId,
|
||
name: entry.name,
|
||
phase: entry.phase,
|
||
args: entry.args,
|
||
detailMode: entry.detailMode,
|
||
});
|
||
} else if (entry.kind === "patch") {
|
||
await params.replyOptions?.onPatchSummary?.({
|
||
itemId: entry.itemId,
|
||
toolCallId: entry.toolCallId,
|
||
phase: entry.phase,
|
||
title: entry.title,
|
||
name: entry.name,
|
||
added: entry.added,
|
||
modified: entry.modified,
|
||
deleted: entry.deleted,
|
||
summary: entry.summary,
|
||
});
|
||
} else if (entry.kind === "plan") {
|
||
await params.replyOptions?.onPlanUpdate?.({
|
||
phase: entry.phase,
|
||
explanation: entry.explanation,
|
||
steps: entry.steps,
|
||
});
|
||
} else if (entry.kind === "concurrent_items") {
|
||
await Promise.all(
|
||
entry.progressTexts.map((progressText) =>
|
||
Promise.resolve(params.replyOptions?.onItemEvent?.({ progressText })),
|
||
),
|
||
);
|
||
} else if (entry.kind === "assistant_start") {
|
||
await params.replyOptions?.onAssistantMessageStart?.();
|
||
} else if (entry.kind === "reasoning") {
|
||
await params.replyOptions?.onReasoningStream?.({
|
||
text: entry.text,
|
||
isReasoningSnapshot: entry.isReasoningSnapshot,
|
||
});
|
||
} else if (entry.kind === "reasoning_end") {
|
||
await params.replyOptions?.onReasoningEnd?.();
|
||
} else {
|
||
await params.replyOptions?.onPartialReply?.({ text: entry.text });
|
||
}
|
||
}
|
||
} else {
|
||
for (const progressText of mockedProgressEvents) {
|
||
await params.replyOptions?.onItemEvent?.({ progressText });
|
||
}
|
||
}
|
||
for (const entry of mockedDispatchSequence) {
|
||
if (entry.kind === "queued_followup") {
|
||
await params.replyOptions?.onQueuedFollowupAdmitted?.();
|
||
continue;
|
||
}
|
||
if (entry.kind === "item") {
|
||
await params.replyOptions?.onItemEvent?.({ progressText: entry.progressText });
|
||
continue;
|
||
}
|
||
const payload = entry.payload as ReplyPayload;
|
||
const transformed = params.dispatcherOptions?.transformReplyPayload
|
||
? params.dispatcherOptions.transformReplyPayload(payload)
|
||
: payload;
|
||
if (!transformed) {
|
||
continue;
|
||
}
|
||
const deliverPayload = params.dispatcherOptions?.beforeDeliver
|
||
? await params.dispatcherOptions.beforeDeliver(transformed, { kind: entry.kind })
|
||
: transformed;
|
||
if (!deliverPayload) {
|
||
continue;
|
||
}
|
||
mockedQueuedDispatchCounts[entry.kind] += 1;
|
||
try {
|
||
await params.delivery.deliver(deliverPayload, { kind: entry.kind });
|
||
} catch (error) {
|
||
if (!mockedDispatcherCapturesDeliveryErrors) {
|
||
throw error;
|
||
}
|
||
mockedQueuedDispatchCounts[entry.kind] -= 1;
|
||
}
|
||
}
|
||
return {
|
||
admission: { kind: "dispatch" } as const,
|
||
dispatched: true as const,
|
||
ctxPayload: params.ctxPayload,
|
||
routeSessionKey: params.route.sessionKey,
|
||
dispatchResult: {
|
||
queuedFinal: false,
|
||
counts: { ...mockedQueuedDispatchCounts },
|
||
},
|
||
};
|
||
},
|
||
};
|
||
});
|
||
|
||
vi.mock("./preview-finalize.js", () => ({
|
||
finalizeSlackPreviewEdit: finalizeSlackPreviewEditMock,
|
||
}));
|
||
|
||
let dispatchPreparedSlackMessage: typeof import("./dispatch.js").dispatchPreparedSlackMessage;
|
||
|
||
describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||
beforeAll(async () => {
|
||
({ dispatchPreparedSlackMessage } = await import("./dispatch.js"));
|
||
});
|
||
|
||
beforeEach(() => {
|
||
createSlackDraftStreamMock.mockReset();
|
||
deliverRepliesMock.mockReset();
|
||
finalizeSlackPreviewEditMock.mockReset();
|
||
normalizeSlackOutboundTextMock.mockClear();
|
||
postMessageMock.mockClear();
|
||
chatUpdateMock.mockClear();
|
||
recordSlackThreadParticipationMock.mockReset();
|
||
updateLastRouteMock.mockReset();
|
||
appendSlackStreamMock.mockReset();
|
||
startSlackStreamMock.mockReset();
|
||
stopSlackStreamMock.mockReset();
|
||
reactSlackMessageMock.mockReset();
|
||
removeSlackReactionMock.mockReset();
|
||
logVerboseMock.mockReset();
|
||
getGlobalHookRunnerMock.mockReset().mockReturnValue(undefined);
|
||
for (const value of Object.values(statusReactionControllerMock)) {
|
||
value.mockClear();
|
||
}
|
||
mockedNativeStreaming = false;
|
||
mockedBlockStreamingEnabled = false;
|
||
mockedSlackStreamingMode = "partial";
|
||
mockedSlackDraftMode = "append";
|
||
mockedPinnedMainDmOwner = undefined;
|
||
capturedReplyOptions = undefined;
|
||
capturedStatusReactionOptions = undefined;
|
||
capturedTyping = undefined;
|
||
mockedReplyThreadTs = THREAD_TS;
|
||
mockedReplyThreadTsSequence = undefined;
|
||
mockedSlackReplyBlocks = undefined;
|
||
mockedSlackIsThreadReply = true;
|
||
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
|
||
mockedQueuedDispatchCounts = { tool: 0, block: 0, final: 0 };
|
||
mockedDispatcherCapturesDeliveryErrors = false;
|
||
mockedProgressEvents = [];
|
||
mockedEmptyProgressToolName = undefined;
|
||
mockedReplyOptionEvents = [];
|
||
|
||
createSlackDraftStreamMock.mockReturnValue(createDraftStreamStub());
|
||
finalizeSlackPreviewEditMock.mockRejectedValue(new Error("socket closed"));
|
||
startSlackStreamMock.mockResolvedValue({
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: true,
|
||
pendingText: "",
|
||
});
|
||
appendSlackStreamMock.mockResolvedValue(undefined);
|
||
stopSlackStreamMock.mockResolvedValue({});
|
||
emitSlackMessageSentHooksMock.mockClear();
|
||
});
|
||
|
||
it("forwards durable ingress ownership into reply options", async () => {
|
||
const turnAdoptionLifecycle = {
|
||
admission: "exclusive",
|
||
abortSignal: new AbortController().signal,
|
||
onAdopted: vi.fn(),
|
||
onDeferred: vi.fn(),
|
||
onAbandoned: vi.fn(),
|
||
};
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage({ turnAdoptionLifecycle }));
|
||
|
||
expect(capturedReplyOptions?.turnAdoptionLifecycle).toBe(turnAdoptionLifecycle);
|
||
});
|
||
|
||
it("preserves provider previews for observer-only hooks", async () => {
|
||
getGlobalHookRunnerMock.mockReturnValue({
|
||
hasHooks: vi.fn((hookName: string) => hookName === "message_sent"),
|
||
});
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(createSlackDraftStreamMock).toHaveBeenCalledTimes(1);
|
||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it.each([
|
||
{ label: "reply_payload_sending", hooks: ["reply_payload_sending"] },
|
||
{ label: "message_sending", hooks: ["message_sending"] },
|
||
{
|
||
label: "both modifying hooks",
|
||
hooks: ["reply_payload_sending", "message_sending"],
|
||
},
|
||
])("suppresses portable provider previews when $label is registered", async ({ hooks }) => {
|
||
const registered = new Set(hooks);
|
||
getGlobalHookRunnerMock.mockReturnValue({
|
||
hasHooks: vi.fn((hookName: string) => registered.has(hookName)),
|
||
});
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(createSlackDraftStreamMock).not.toHaveBeenCalled();
|
||
expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled();
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
});
|
||
|
||
it("suppresses native progress cards when a modifying hook is registered", async () => {
|
||
getGlobalHookRunnerMock.mockReturnValue({
|
||
hasHooks: vi.fn((hookName: string) => hookName === "message_sending"),
|
||
});
|
||
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [{ kind: "item", progressText: "private progress" }],
|
||
});
|
||
|
||
expect(createSlackDraftStreamMock).not.toHaveBeenCalled();
|
||
expect(startSlackStreamMock).not.toHaveBeenCalled();
|
||
expect(appendSlackStreamMock).not.toHaveBeenCalled();
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
});
|
||
|
||
it("preserves post-hook native answer streaming when a modifying hook is registered", async () => {
|
||
getGlobalHookRunnerMock.mockReturnValue({
|
||
hasHooks: vi.fn((hookName: string) => hookName === "reply_payload_sending"),
|
||
});
|
||
mockedNativeStreaming = true;
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(createSlackDraftStreamMock).not.toHaveBeenCalled();
|
||
expect(startSlackStreamMock).toHaveBeenCalledTimes(1);
|
||
expect(stopSlackStreamMock).toHaveBeenCalledTimes(1);
|
||
expect(deliverRepliesMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("falls back to normal delivery when preview finalize fails", async () => {
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
});
|
||
|
||
it.each([
|
||
{ name: "ASCII", text: "x".repeat(4_001) },
|
||
{ name: "UTF-8", text: "界".repeat(1_334) },
|
||
])(
|
||
"delivers the complete $name final when it exceeds Slack's edit byte limit",
|
||
async ({ text }) => {
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
mockedDispatchSequence = [{ kind: "final", payload: { text } }];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled();
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, text);
|
||
},
|
||
);
|
||
|
||
it("uses a disposable portable preview before custom-identity final delivery", async () => {
|
||
const relayIdentity = { username: "Nik Team Claw", iconEmoji: ":robot_face:" };
|
||
const draftStream = {
|
||
...createDraftStreamStub(),
|
||
clear: vi.fn(noopAsync),
|
||
discardPending: vi.fn(noopAsync),
|
||
};
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage({ relayIdentity }));
|
||
|
||
expect(createSlackDraftStreamMock).toHaveBeenCalledWith(
|
||
expect.not.objectContaining({ identity: expect.anything() }),
|
||
);
|
||
expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled();
|
||
expect(draftStream.discardPending).toHaveBeenCalled();
|
||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT, { identity: relayIdentity });
|
||
});
|
||
|
||
it("clears a disposable preview before custom-identity media delivery", async () => {
|
||
const relayIdentity = {
|
||
username: "Nik Team Claw",
|
||
iconUrl: "https://example.com/claw.png",
|
||
};
|
||
const draftStream = {
|
||
...createDraftStreamStub(),
|
||
clear: vi.fn(noopAsync),
|
||
discardPending: vi.fn(noopAsync),
|
||
};
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedDispatchSequence = [
|
||
{
|
||
kind: "final",
|
||
payload: { text: "Photo", mediaUrl: "https://example.com/a.png" },
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage({ relayIdentity }));
|
||
|
||
expect(createSlackDraftStreamMock).toHaveBeenCalledWith(
|
||
expect.not.objectContaining({ identity: expect.anything() }),
|
||
);
|
||
expect(draftStream.discardPending).toHaveBeenCalled();
|
||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||
expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled();
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
const params = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 0, "deliver replies")[0],
|
||
"deliver replies params",
|
||
);
|
||
expectRecordFields(params, { replyThreadTs: THREAD_TS, identity: relayIdentity });
|
||
expect(params.replies).toEqual([{ text: "Photo", mediaUrl: "https://example.com/a.png" }]);
|
||
});
|
||
|
||
it("restores TTS text before custom-identity supplement delivery", async () => {
|
||
const relayIdentity = { username: "Nik Team Claw" };
|
||
const draftStream = {
|
||
...createDraftStreamStub(),
|
||
clear: vi.fn(noopAsync),
|
||
discardPending: vi.fn(noopAsync),
|
||
};
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedDispatchSequence = [
|
||
{
|
||
kind: "final",
|
||
payload: {
|
||
mediaUrl: "https://example.com/tts.mp3",
|
||
audioAsVoice: true,
|
||
spokenText: "Spoken answer",
|
||
ttsSupplement: { spokenText: "Spoken answer" },
|
||
},
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage({ relayIdentity }));
|
||
|
||
expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled();
|
||
expect(draftStream.discardPending).toHaveBeenCalled();
|
||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||
const params = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 0, "deliver replies")[0],
|
||
"deliver replies params",
|
||
);
|
||
expectRecordFields(params, { replyThreadTs: THREAD_TS, identity: relayIdentity });
|
||
expect(params.replies).toEqual([
|
||
{
|
||
text: "Spoken answer",
|
||
mediaUrl: "https://example.com/tts.mp3",
|
||
audioAsVoice: true,
|
||
spokenText: "Spoken answer",
|
||
ttsSupplement: { spokenText: "Spoken answer" },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("uses supported native Slack streaming authorship when a custom identity is active", async () => {
|
||
mockedNativeStreaming = true;
|
||
const relayIdentity = { username: "Nik Team Claw" };
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage({ relayIdentity }));
|
||
|
||
expectMockCallArgFields(startSlackStreamMock, 0, "Slack stream start params", {
|
||
text: FINAL_REPLY_TEXT,
|
||
identity: relayIdentity,
|
||
});
|
||
expect(createSlackDraftStreamMock).not.toHaveBeenCalled();
|
||
expect(deliverRepliesMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("does not create a Slack thread for top-level messages when replyToMode is off", async () => {
|
||
mockedSlackStreamingMode = "off";
|
||
mockedSlackIsThreadReply = false;
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage({ replyToMode: "off" }));
|
||
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT, { replyThreadTs: undefined });
|
||
});
|
||
|
||
it("stays in an existing Slack thread when replyToMode is off", async () => {
|
||
mockedSlackStreamingMode = "off";
|
||
mockedSlackIsThreadReply = true;
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage({ replyToMode: "off" }));
|
||
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT, { replyThreadTs: THREAD_TS });
|
||
});
|
||
|
||
it("updates non-main DM last-route metadata on the prepared direct session", async () => {
|
||
mockedPinnedMainDmOwner = "U2";
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
cfg: { session: { dmScope: "per-channel-peer" } },
|
||
isDirectMessage: true,
|
||
message: {
|
||
channel: "D123",
|
||
user: "U1",
|
||
ts: "501.000",
|
||
thread_ts: "500.000",
|
||
},
|
||
route: {
|
||
agentId: "main",
|
||
mainSessionKey: "agent:main:main",
|
||
sessionKey: "agent:main:slack:direct:u1",
|
||
lastRoutePolicy: "session",
|
||
},
|
||
ctxPayload: {
|
||
MessageThreadId: "500.000",
|
||
SessionKey: "agent:main:slack:direct:u1",
|
||
},
|
||
}),
|
||
);
|
||
|
||
expect(updateLastRouteMock).toHaveBeenCalledWith({
|
||
storePath: "/tmp/openclaw-store.json",
|
||
sessionKey: "agent:main:slack:direct:u1",
|
||
deliveryContext: {
|
||
channel: "slack",
|
||
to: "user:U1",
|
||
accountId: "default",
|
||
threadId: "500.000",
|
||
},
|
||
ctx: {
|
||
MessageThreadId: "500.000",
|
||
SessionKey: "agent:main:slack:direct:u1",
|
||
},
|
||
});
|
||
});
|
||
|
||
it("uses DM transport thread metadata for last-route updates", async () => {
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
isDirectMessage: true,
|
||
message: {
|
||
channel: "D123",
|
||
user: "U1",
|
||
ts: "701.000",
|
||
thread_ts: "701.000",
|
||
},
|
||
route: {
|
||
agentId: "main",
|
||
mainSessionKey: "agent:main:main",
|
||
sessionKey: "agent:main:main",
|
||
lastRoutePolicy: "main",
|
||
},
|
||
ctxPayload: {
|
||
MessageThreadId: undefined,
|
||
ReplyToId: "701.000",
|
||
TransportThreadId: "701.000",
|
||
SessionKey: "agent:main:main",
|
||
},
|
||
}),
|
||
);
|
||
|
||
expect(updateLastRouteMock).toHaveBeenCalledWith({
|
||
storePath: "/tmp/openclaw-store.json",
|
||
sessionKey: "agent:main:main",
|
||
deliveryContext: {
|
||
channel: "slack",
|
||
to: "user:U1",
|
||
accountId: "default",
|
||
threadId: "701.000",
|
||
},
|
||
ctx: {
|
||
ReplyToId: "701.000",
|
||
TransportThreadId: "701.000",
|
||
SessionKey: "agent:main:main",
|
||
},
|
||
});
|
||
});
|
||
|
||
it("keeps default main-scope DM last-route metadata on the main session", async () => {
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
isDirectMessage: true,
|
||
message: {
|
||
channel: "D123",
|
||
user: "U1",
|
||
ts: "601.000",
|
||
thread_ts: "600.000",
|
||
},
|
||
route: {
|
||
agentId: "main",
|
||
mainSessionKey: "agent:main:main",
|
||
sessionKey: "agent:main:main",
|
||
lastRoutePolicy: "main",
|
||
},
|
||
ctxPayload: {
|
||
MessageThreadId: "600.000",
|
||
SessionKey: "agent:main:main",
|
||
},
|
||
}),
|
||
);
|
||
|
||
expect(updateLastRouteMock).toHaveBeenCalledWith({
|
||
storePath: "/tmp/openclaw-store.json",
|
||
sessionKey: "agent:main:main",
|
||
deliveryContext: {
|
||
channel: "slack",
|
||
to: "user:U1",
|
||
accountId: "default",
|
||
threadId: "600.000",
|
||
},
|
||
ctx: {
|
||
MessageThreadId: "600.000",
|
||
SessionKey: "agent:main:main",
|
||
},
|
||
});
|
||
});
|
||
|
||
it("finalizes fast draft preview text without sending a duplicate normal reply", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
mockedDispatchSequence = [{ kind: "final", payload: { text: "✅" } }];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(draftStream.flush).toHaveBeenCalledTimes(1);
|
||
expect(draftStream.seal).toHaveBeenCalledTimes(1);
|
||
expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "preview edit params", {
|
||
channelId: "C123",
|
||
messageId: "171234.567",
|
||
text: "✅",
|
||
});
|
||
expect(emitSlackMessageSentHooksMock).toHaveBeenCalledTimes(1);
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 0, "preview message_sent", {
|
||
content: "✅",
|
||
success: true,
|
||
messageId: "171234.567",
|
||
sessionKeyForInternalHooks: "agent:agent-1:slack:C123",
|
||
});
|
||
expect(deliverRepliesMock).not.toHaveBeenCalled();
|
||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("finalizes native chart blocks without re-escaping accessible preview text", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
const accessibleText =
|
||
"Quarterly results\n\nRevenue (bar chart)\n- <@U123>: Q1: 12; Q2: 18";
|
||
mockedSlackReplyBlocks = [
|
||
{
|
||
type: "section",
|
||
text: { type: "mrkdwn", text: "Quarterly results", verbatim: true },
|
||
},
|
||
{
|
||
type: "data_visualization",
|
||
title: "Revenue",
|
||
chart: {
|
||
type: "bar",
|
||
series: [
|
||
{
|
||
name: "<@U123>",
|
||
data: [
|
||
{ label: "Q1", value: 12 },
|
||
{ label: "Q2", value: 18 },
|
||
],
|
||
},
|
||
],
|
||
axis_config: { categories: ["Q1", "Q2"] },
|
||
},
|
||
},
|
||
];
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
mockedDispatchSequence = [
|
||
{
|
||
kind: "final",
|
||
payload: {
|
||
text: "Quarterly results",
|
||
presentation: {
|
||
blocks: [
|
||
{
|
||
type: "chart",
|
||
chartType: "bar",
|
||
title: "Revenue",
|
||
categories: ["Q1", "Q2"],
|
||
series: [{ name: "<@U123>", values: [12, 18] }],
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "chart preview edit params", {
|
||
channelId: "C123",
|
||
messageId: "171234.567",
|
||
text: accessibleText,
|
||
blocks: mockedSlackReplyBlocks,
|
||
threadTs: THREAD_TS,
|
||
});
|
||
expect(normalizeSlackOutboundTextMock).not.toHaveBeenCalled();
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 0, "chart preview message_sent", {
|
||
content: accessibleText,
|
||
success: true,
|
||
messageId: "171234.567",
|
||
});
|
||
expect(deliverRepliesMock).not.toHaveBeenCalled();
|
||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("normalizes only authored preview text when blocks own their fallback", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
mockedDispatchSequence = [
|
||
{
|
||
kind: "final",
|
||
payload: {
|
||
text: "**Summary**",
|
||
presentation: {
|
||
blocks: [
|
||
{
|
||
type: "buttons",
|
||
buttons: [
|
||
{
|
||
label: "Owner <@U123>",
|
||
action: { type: "callback", value: "owner" },
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(normalizeSlackOutboundTextMock).toHaveBeenCalledTimes(1);
|
||
expect(normalizeSlackOutboundTextMock).toHaveBeenCalledWith("**Summary**");
|
||
expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "block preview edit params", {
|
||
text: "**Summary**",
|
||
});
|
||
});
|
||
|
||
it("delivers split table fallbacks normally instead of hiding them in a preview edit", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
const payload = {
|
||
text: "Accounts",
|
||
presentation: {
|
||
blocks: [
|
||
{
|
||
type: "table",
|
||
caption: "Account owners",
|
||
headers: ["Owner"],
|
||
rows: Array.from({ length: 100 }, (_entry, index) => [
|
||
`owner-${String(index)}-${"x".repeat(110)}`,
|
||
]),
|
||
},
|
||
{
|
||
type: "buttons",
|
||
buttons: [{ label: "Refresh", value: "refresh" }],
|
||
},
|
||
],
|
||
},
|
||
};
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedDispatchSequence = [{ kind: "final", payload }];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled();
|
||
const delivered = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 0, "deliver replies")[0],
|
||
"deliver replies params",
|
||
);
|
||
expect(delivered.replies).toEqual([payload]);
|
||
});
|
||
|
||
it("keeps distinct split table fallbacks distinct in delivery tracking", async () => {
|
||
mockedSlackStreamingMode = "off";
|
||
const buildPayload = (owner: string) => ({
|
||
text: "Accounts",
|
||
presentation: {
|
||
blocks: [
|
||
{
|
||
type: "table",
|
||
caption: "Account owners",
|
||
headers: ["Owner"],
|
||
rows: Array.from({ length: 100 }, () => [`${owner}-${"x".repeat(110)}`]),
|
||
},
|
||
],
|
||
},
|
||
});
|
||
const firstPayload = buildPayload("Ada");
|
||
const secondPayload = buildPayload("Grace");
|
||
mockedDispatchSequence = [
|
||
{ kind: "final", payload: firstPayload },
|
||
{ kind: "final", payload: secondPayload },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(2);
|
||
const firstDelivery = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 0, "first table delivery")[0],
|
||
"first table delivery params",
|
||
);
|
||
const secondDelivery = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 1, "second table delivery")[0],
|
||
"second table delivery params",
|
||
);
|
||
expect(firstDelivery.replies).toEqual([firstPayload]);
|
||
expect(secondDelivery.replies).toEqual([secondPayload]);
|
||
});
|
||
|
||
it("does not clear a finalized Slack draft when a later tool warning is delivered", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
mockedDispatchSequence = [
|
||
{ kind: "final", payload: { text: "answer" } },
|
||
{ kind: "final", payload: { text: "⚠️ Apply Patch failed", isError: true } },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { mode: "partial", preview: { toolProgress: false } } },
|
||
}),
|
||
);
|
||
|
||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
|
||
expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "preview edit params", {
|
||
channelId: "C123",
|
||
messageId: "171234.567",
|
||
text: "answer",
|
||
});
|
||
const delivered = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 0, "deliver replies")[0],
|
||
"deliver replies params",
|
||
);
|
||
expect(delivered.replies).toEqual([{ text: "⚠️ Apply Patch failed", isError: true }]);
|
||
expect(draftStream.seal).toHaveBeenCalledTimes(1);
|
||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("does not reuse draft cleanup after a normally delivered final reply", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedDispatchSequence = [
|
||
{
|
||
kind: "final",
|
||
payload: { text: "answer", mediaUrl: "https://example.com/final.png" },
|
||
},
|
||
{ kind: "final", payload: { text: "late cleanup failed", isError: true } },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled();
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(2);
|
||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||
const firstDelivered = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 0, "deliver replies")[0],
|
||
"deliver replies params",
|
||
);
|
||
expect(firstDelivered.replies).toEqual([
|
||
{ text: "answer", mediaUrl: "https://example.com/final.png" },
|
||
]);
|
||
const lateDelivered = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 1, "deliver replies")[0],
|
||
"deliver replies params",
|
||
);
|
||
expect(lateDelivered.replies).toEqual([{ text: "late cleanup failed", isError: true }]);
|
||
});
|
||
|
||
it("suppresses block streaming when Slack draft preview streaming is active", async () => {
|
||
mockedBlockStreamingEnabled = true;
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(capturedReplyOptions?.disableBlockStreaming).toBe(true);
|
||
});
|
||
|
||
it("keeps Slack typing callbacks when channel replies are message-tool-only", async () => {
|
||
const setSlackThreadStatus = vi.fn(async () => undefined);
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
cfg: { messages: { groupChat: { visibleReplies: "message_tool" } } },
|
||
ctxPayload: { ChatType: "channel" },
|
||
setSlackThreadStatus,
|
||
typingReaction: "hourglass_flowing_sand",
|
||
}),
|
||
);
|
||
|
||
const typing = requireCapturedTyping();
|
||
expect(capturedReplyOptions?.disableBlockStreaming).toBe(true);
|
||
|
||
await typing.start();
|
||
await typing.stop?.();
|
||
|
||
expect(setSlackThreadStatus).toHaveBeenCalledWith({
|
||
channelId: "C123",
|
||
threadTs: THREAD_TS,
|
||
status: "is typing...",
|
||
eventScope: undefined,
|
||
});
|
||
expect(setSlackThreadStatus).toHaveBeenCalledWith({
|
||
channelId: "C123",
|
||
threadTs: THREAD_TS,
|
||
status: "",
|
||
eventScope: undefined,
|
||
});
|
||
const reactCall = requireMockCall(reactSlackMessageMock, 0, "react Slack message");
|
||
expect(reactCall[0]).toBe("C123");
|
||
expect(reactCall[1]).toBe("171234.111");
|
||
expect(reactCall[2]).toBe("hourglass_flowing_sand");
|
||
expect(requireRecord(reactCall[3], "react Slack message options").token).toBe("xoxb-test");
|
||
const removeReactionCall = requireMockCall(removeSlackReactionMock, 0, "remove Slack reaction");
|
||
expect(removeReactionCall[0]).toBe("C123");
|
||
expect(removeReactionCall[1]).toBe("171234.111");
|
||
expect(removeReactionCall[2]).toBe("hourglass_flowing_sand");
|
||
expect(requireRecord(removeReactionCall[3], "remove Slack reaction options").token).toBe(
|
||
"xoxb-test",
|
||
);
|
||
});
|
||
|
||
it("logs the formatted Slack error when adding the typing reaction fails", async () => {
|
||
reactSlackMessageMock.mockRejectedValueOnce(
|
||
createSlackPlatformError("missing_scope", {
|
||
needed: "reactions:write",
|
||
provided: "chat:write",
|
||
}),
|
||
);
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({ typingReaction: "hourglass_flowing_sand" }),
|
||
);
|
||
await expect(requireCapturedTyping().start()).resolves.toBeUndefined();
|
||
|
||
expect(logVerboseMock).toHaveBeenCalledWith(
|
||
"slack send: typing reaction failed: An API error occurred: missing_scope; code: slack_webapi_platform_error; slack error: missing_scope; needed: reactions:write; provided: chat:write",
|
||
);
|
||
});
|
||
|
||
it("logs the formatted Slack error when removing the typing reaction fails", async () => {
|
||
removeSlackReactionMock.mockRejectedValueOnce(createSlackPlatformError("invalid_auth"));
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({ typingReaction: "hourglass_flowing_sand" }),
|
||
);
|
||
const typing = requireCapturedTyping();
|
||
await typing.start();
|
||
await expect(typing.stop?.()).resolves.toBeUndefined();
|
||
|
||
expect(logVerboseMock).toHaveBeenCalledWith(
|
||
"slack send: typing reaction removal failed: An API error occurred: invalid_auth; code: slack_webapi_platform_error; slack error: invalid_auth",
|
||
);
|
||
});
|
||
|
||
it("keeps Slack status reactions when channel replies are message-tool-only", async () => {
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
cfg: {
|
||
messages: {
|
||
groupChat: { visibleReplies: "message_tool" },
|
||
statusReactions: { enabled: true },
|
||
},
|
||
},
|
||
ctxPayload: { ChatType: "channel" },
|
||
ackReactionMessageTs: "171234.111",
|
||
ackReactionPromise: Promise.resolve(true),
|
||
}),
|
||
);
|
||
|
||
expect(capturedReplyOptions?.disableBlockStreaming).toBe(true);
|
||
expect(capturedReplyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
|
||
expect(capturedReplyOptions?.allowToolLifecycleWhenProgressHidden).toBe(true);
|
||
expectRecordFields(requireRecord(capturedStatusReactionOptions, "status reaction options"), {
|
||
enabled: true,
|
||
initialEmoji: "eyes",
|
||
});
|
||
expect(statusReactionControllerMock.setQueued).toHaveBeenCalledTimes(1);
|
||
expect(statusReactionControllerMock.setDone).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("keeps Slack lifecycle reactions off by default when an ack reaction exists", async () => {
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
ackReactionMessageTs: "171234.111",
|
||
ackReactionPromise: Promise.resolve(true),
|
||
}),
|
||
);
|
||
|
||
expectRecordFields(requireRecord(capturedStatusReactionOptions, "status reaction options"), {
|
||
enabled: false,
|
||
initialEmoji: "eyes",
|
||
});
|
||
expect(statusReactionControllerMock.setQueued).not.toHaveBeenCalled();
|
||
expect(statusReactionControllerMock.setDone).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("keeps Slack lifecycle reactions off for ambient room-event acks", async () => {
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
cfg: { messages: { statusReactions: { enabled: true } } },
|
||
ctxPayload: { ChatType: "channel", InboundEventKind: "room_event" },
|
||
ackReactionMessageTs: "171234.111",
|
||
ackReactionPromise: Promise.resolve(true),
|
||
}),
|
||
);
|
||
|
||
expectRecordFields(requireRecord(capturedStatusReactionOptions, "status reaction options"), {
|
||
enabled: false,
|
||
initialEmoji: "eyes",
|
||
});
|
||
expect(statusReactionControllerMock.setQueued).not.toHaveBeenCalled();
|
||
expect(statusReactionControllerMock.setDone).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("suppresses Slack typing for ambient room events", async () => {
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
cfg: { messages: { groupChat: { visibleReplies: "automatic" } } },
|
||
ctxPayload: { ChatType: "channel", InboundEventKind: "room_event" },
|
||
}),
|
||
);
|
||
|
||
expect(capturedReplyOptions?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||
expect(capturedReplyOptions?.suppressTyping).toBe(true);
|
||
});
|
||
|
||
it("leaves Slack typing unsuppressed for normal channel turns", async () => {
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
cfg: { messages: { groupChat: { visibleReplies: "automatic" } } },
|
||
ctxPayload: { ChatType: "channel" },
|
||
}),
|
||
);
|
||
|
||
expect(capturedReplyOptions?.sourceReplyDeliveryMode).toBe("automatic");
|
||
expect(capturedReplyOptions?.suppressTyping).toBeUndefined();
|
||
});
|
||
|
||
it("escapes Slack mrkdwn in tool progress preview labels", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedDispatchSequence = [];
|
||
mockedProgressEvents = ["ran <!here> <@U123> *bold* `code` & done"];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { progress: { label: "Shelling" } } },
|
||
}),
|
||
);
|
||
|
||
expect(draftStream.update).toHaveBeenCalledWith(
|
||
"Shelling\n\n• ran <!here> <@U123> \\*bold\\* \\`code\\` & done",
|
||
);
|
||
});
|
||
|
||
it("shows reasoning text in Slack progress draft previews", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedDispatchSequence = [];
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedReplyOptionEvents = [
|
||
{ kind: "tool_start", name: "exec" },
|
||
{ kind: "item", itemKind: "analysis", title: "Reasoning" },
|
||
{ kind: "reasoning", text: "Reading" },
|
||
{ kind: "reasoning", text: " the Slack handler" },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
}),
|
||
);
|
||
|
||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||
["Shelling", "", "• exec", "🧠 _Reading the Slack handler_"].join("\n"),
|
||
);
|
||
const updates = draftStream.update.mock.calls.map((call) => String(call[0]));
|
||
expect(updates.join("\n")).not.toContain("Reasoning");
|
||
});
|
||
|
||
it("replaces Slack reasoning snapshots instead of appending duplicates", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedDispatchSequence = [];
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedReplyOptionEvents = [
|
||
{ kind: "tool_start", name: "exec" },
|
||
{ kind: "reasoning", text: "<think>Checking </think>", isReasoningSnapshot: true },
|
||
{
|
||
kind: "reasoning",
|
||
text: "<think>Reading\n\nChecking </think>",
|
||
isReasoningSnapshot: true,
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
}),
|
||
);
|
||
|
||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||
["Shelling", "", "• exec", "🧠 _Reading Checking_"].join("\n"),
|
||
);
|
||
const updates = draftStream.update.mock.calls.map((call) => String(call[0]));
|
||
expect(updates.join("\n")).not.toContain("Checking Reading");
|
||
});
|
||
|
||
it("extracts mm:think reasoning snapshots for Slack progress draft previews", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedDispatchSequence = [];
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedReplyOptionEvents = [
|
||
{
|
||
kind: "reasoning",
|
||
text: "<mm:think>Reading\nChecking</mm:think>",
|
||
isReasoningSnapshot: true,
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
}),
|
||
);
|
||
|
||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||
["Shelling", "", "🧠 _Reading Checking_"].join("\n"),
|
||
);
|
||
const updates = draftStream.update.mock.calls.map((call) => String(call[0]));
|
||
expect(updates.join("\n")).toContain("Reading Checking");
|
||
});
|
||
|
||
it("keeps plain Slack reasoning content that starts with Thinking", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedDispatchSequence = [];
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedReplyOptionEvents = [
|
||
{
|
||
kind: "reasoning",
|
||
text: "Thinking about Slack preview state",
|
||
isReasoningSnapshot: true,
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
}),
|
||
);
|
||
|
||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||
["Shelling", "", "🧠 _Thinking about Slack preview state_"].join("\n"),
|
||
);
|
||
});
|
||
|
||
it("honors Slack progress maxLines above the legacy eight-line cap", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedDispatchSequence = [];
|
||
mockedProgressEvents = Array.from({ length: 10 }, (_value, index) => `step ${index + 1}`);
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { progress: { label: "Shelling", maxLines: 10 } } },
|
||
}),
|
||
);
|
||
|
||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||
[
|
||
"• step 1",
|
||
"• step 2",
|
||
"• step 3",
|
||
"• step 4",
|
||
"• step 5",
|
||
"• step 6",
|
||
"• step 7",
|
||
"• step 8",
|
||
"• step 9",
|
||
"• step 10",
|
||
].join("\n"),
|
||
);
|
||
});
|
||
|
||
it("preserves Slack progress lines across status-final answer partials", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [];
|
||
mockedReplyOptionEvents = [
|
||
{ kind: "item", progressText: "tool one" },
|
||
{ kind: "partial", text: "partial answer" },
|
||
{ kind: "item", progressText: "tool two" },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { progress: { label: "Shelling" } } },
|
||
}),
|
||
);
|
||
|
||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||
["Shelling", "", "• tool one", "• tool two"].join("\n"),
|
||
);
|
||
});
|
||
|
||
it("renders rich status-final progress drafts as legacy Slack section blocks and finalizes once", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
|
||
mockedReplyOptionEvents = [
|
||
{ kind: "item", progressText: "tool one" },
|
||
{ kind: "partial", text: "partial answer" },
|
||
{ kind: "item", progressText: "tool two" },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { progress: { label: "Shelling", render: "rich" } } },
|
||
}),
|
||
);
|
||
|
||
expect(draftStream.update).toHaveBeenLastCalledWith({
|
||
text: ["Shelling", "", "• tool one", "• tool two"].join("\n"),
|
||
blocks: [
|
||
{
|
||
type: "section",
|
||
text: { type: "mrkdwn", text: "*Shelling*" },
|
||
},
|
||
{
|
||
type: "section",
|
||
fields: [
|
||
{ type: "mrkdwn", text: "• *Update*" },
|
||
{ type: "mrkdwn", text: "—" },
|
||
],
|
||
},
|
||
{
|
||
type: "section",
|
||
fields: [
|
||
{ type: "mrkdwn", text: "• *Update*" },
|
||
{ type: "mrkdwn", text: "—" },
|
||
],
|
||
},
|
||
],
|
||
});
|
||
expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "preview edit params", {
|
||
channelId: "C123",
|
||
messageId: "171234.567",
|
||
text: expect.stringMatching(/⏱️ \d+s$/),
|
||
});
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("keeps plan explanation in rich blocks with a fresh preamble", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [];
|
||
mockedReplyOptionEvents = [
|
||
{
|
||
kind: "item",
|
||
itemKind: "preamble",
|
||
itemId: "preamble-1",
|
||
progressText: "Checking the workspace",
|
||
},
|
||
{
|
||
kind: "plan",
|
||
phase: "update",
|
||
explanation: "Executing the checklist.",
|
||
steps: [{ step: "Patch", status: "in_progress" }],
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: {
|
||
streaming: { mode: "progress", progress: { label: "Shelling", render: "rich" } },
|
||
},
|
||
}),
|
||
);
|
||
|
||
expect(draftStream.update).toHaveBeenLastCalledWith({
|
||
text: ["Shelling", "", "Checking the workspace", "", "▸ Patch"].join("\n"),
|
||
blocks: [
|
||
{
|
||
type: "section",
|
||
text: { type: "mrkdwn", text: "*Shelling*" },
|
||
},
|
||
{
|
||
type: "section",
|
||
text: {
|
||
type: "mrkdwn",
|
||
text: "_Checking the workspace — Executing the checklist._",
|
||
},
|
||
},
|
||
{
|
||
type: "section",
|
||
text: { type: "mrkdwn", text: "▸ Patch" },
|
||
},
|
||
],
|
||
});
|
||
});
|
||
|
||
it("keeps unlabeled rich Slack progress drafts as legacy section blocks", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
|
||
mockedReplyOptionEvents = [
|
||
{ kind: "item", progressText: "tool one" },
|
||
{ kind: "partial", text: "partial answer" },
|
||
{ kind: "item", progressText: "tool two" },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { progress: { render: "rich" } } },
|
||
}),
|
||
);
|
||
|
||
expect(draftStream.update).toHaveBeenLastCalledWith({
|
||
text: ["Working", "", "• tool one", "• tool two"].join("\n"),
|
||
blocks: [
|
||
{
|
||
type: "section",
|
||
fields: [
|
||
{ type: "mrkdwn", text: "• *Update*" },
|
||
{ type: "mrkdwn", text: "—" },
|
||
],
|
||
},
|
||
{
|
||
type: "section",
|
||
fields: [
|
||
{ type: "mrkdwn", text: "• *Update*" },
|
||
{ type: "mrkdwn", text: "—" },
|
||
],
|
||
},
|
||
],
|
||
});
|
||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("sends a progress final fresh before collapsing its draft to a receipt", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
mockedReplyThreadTsSequence = [undefined];
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
|
||
mockedReplyOptionEvents = [
|
||
{
|
||
kind: "tool_start",
|
||
itemId: "tool-1",
|
||
name: "bash",
|
||
phase: "start",
|
||
args: { command: "pnpm test" },
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { mode: "progress", progress: { label: "Working" } } },
|
||
}),
|
||
);
|
||
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "progress receipt edit", {
|
||
channelId: "C123",
|
||
messageId: "171234.567",
|
||
text: expect.stringMatching(/^🛠️ 1 tool call · ⏱️ \d+s$/),
|
||
});
|
||
expect(deliverRepliesMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||
finalizeSlackPreviewEditMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
||
);
|
||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("leaves a progress draft untouched when the fresh final send fails", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
deliverRepliesMock.mockRejectedValueOnce(new Error("final send failed"));
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
|
||
mockedReplyOptionEvents = [{ kind: "item", progressText: "working" }];
|
||
|
||
await expect(
|
||
dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { mode: "progress", progress: { label: "Working" } } },
|
||
}),
|
||
),
|
||
).rejects.toThrow("final send failed");
|
||
|
||
expect(draftStream.update).toHaveBeenCalled();
|
||
expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled();
|
||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("keeps a progress draft for an error final without creating a receipt", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [{ kind: "final", payload: { text: "tool failed", isError: true } }];
|
||
mockedReplyOptionEvents = [{ kind: "item", progressText: "working" }];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { mode: "progress", progress: { label: "Working" } } },
|
||
}),
|
||
);
|
||
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled();
|
||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("mandatory E2E: streams native Slack progress with the newest meaningful plan title when no explicit label exists", async () => {
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [
|
||
{ kind: "item", progressText: "tool one" },
|
||
{ kind: "item", progressText: "tool two" },
|
||
{ kind: "item", progressText: "tool three" },
|
||
],
|
||
});
|
||
|
||
expect(createSlackDraftStreamMock).not.toHaveBeenCalled();
|
||
expectNativeProgressStart([
|
||
planUpdate("tool one"),
|
||
taskUpdate(contentTaskId("item"), "tool one", "in_progress"),
|
||
]);
|
||
expectNativeProgressAppend(0, [
|
||
planUpdate("tool two"),
|
||
taskUpdate(contentTaskId("item"), "tool one", "in_progress"),
|
||
taskUpdate(contentTaskId("item"), "tool two", "in_progress"),
|
||
]);
|
||
expectNativeProgressAppend(2, [
|
||
planUpdate("tool three"),
|
||
taskUpdate(contentTaskId("item"), "tool one", "complete"),
|
||
taskUpdate(contentTaskId("item"), "tool two", "complete"),
|
||
taskUpdate(contentTaskId("item"), "tool three", "complete"),
|
||
]);
|
||
expect(stopSlackStreamMock).toHaveBeenCalledTimes(1);
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
});
|
||
|
||
it("starts native Slack progress on a single tool item before final text and completes it once", async () => {
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [{ kind: "item", progressText: "slow tool" }],
|
||
});
|
||
|
||
expect(createSlackDraftStreamMock).not.toHaveBeenCalled();
|
||
expectNativeProgressStart([
|
||
planUpdate("slow tool"),
|
||
taskUpdate(contentTaskId("item"), "slow tool", "in_progress"),
|
||
]);
|
||
expectNativeProgressAppend(0, [
|
||
planUpdate("slow tool"),
|
||
taskUpdate(contentTaskId("item"), "slow tool", "complete"),
|
||
]);
|
||
expect(startSlackStreamMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||
appendSlackStreamMock.mock.invocationCallOrder[0] ?? 0,
|
||
);
|
||
expect(stopSlackStreamMock).toHaveBeenCalledTimes(1);
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
});
|
||
|
||
it("emits message_sent only once for native progress final replies (no double emit)", async () => {
|
||
// In native-progress mode the final answer is delivered through
|
||
// deliverNormally -> deliverReplies, which owns the message_sent emit. The
|
||
// dispatch-level stream finalizer must NOT also emit for the same final
|
||
// answer (regression for the streamedFinalContent double-emit bug).
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [{ kind: "item", progressText: "slow tool" }],
|
||
});
|
||
|
||
// Final routed through deliverReplies (the owner of the emit here)...
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
// ...and the stream WAS finalized, so the old code would have double-emitted.
|
||
expect(stopSlackStreamMock).toHaveBeenCalledTimes(1);
|
||
// The dispatch-level finalizer must not emit (deliverReplies already does).
|
||
expect(emitSlackMessageSentHooksMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("emits message_sent exactly once via the finalizer for a plain text-stream final reply", async () => {
|
||
// Plain text-stream mode (no native progress card): the final answer is
|
||
// flushed through the text stream and never goes through deliverReplies, so
|
||
// the dispatch finalizer owns the single message_sent emit. This is the
|
||
// positive counterpart to the native-progress no-double-emit regression.
|
||
mockedNativeStreaming = true;
|
||
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
|
||
startSlackStreamMock.mockResolvedValueOnce({
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: true,
|
||
pendingText: "",
|
||
});
|
||
stopSlackStreamMock.mockResolvedValueOnce({ messageId: "171234.567" });
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
isDirectMessage: true,
|
||
message: { channel: "D123" },
|
||
route: { sessionKey: "agent:agent-1:slack:direct:u123" },
|
||
ctxPayload: {
|
||
SessionKey: "agent:agent-1:slack:direct:u123:thread:thread-1",
|
||
To: "user:U123",
|
||
OriginatingTo: "user:U123",
|
||
},
|
||
}),
|
||
);
|
||
|
||
// The final was flushed through the stream, not deliverReplies.
|
||
expect(deliverRepliesMock).not.toHaveBeenCalled();
|
||
expect(stopSlackStreamMock).toHaveBeenCalledTimes(1);
|
||
// The finalizer emits exactly once, with success and the delivered Slack ts,
|
||
// and threads the canonical session key (mirrors the P2 fix in replies.ts:
|
||
// the dispatch finalizer emit must also carry session correlation).
|
||
expect(emitSlackMessageSentHooksMock).toHaveBeenCalledTimes(1);
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 0, "finalizer message_sent", {
|
||
content: FINAL_REPLY_TEXT,
|
||
success: true,
|
||
messageId: "171234.567",
|
||
to: "user:U123",
|
||
sessionKeyForInternalHooks: "agent:agent-1:slack:direct:u123:thread:thread-1",
|
||
});
|
||
});
|
||
|
||
it("routes split table fallbacks around native text streaming", async () => {
|
||
mockedNativeStreaming = true;
|
||
const payload = {
|
||
text: "Accounts",
|
||
presentation: {
|
||
blocks: [
|
||
{
|
||
type: "table",
|
||
caption: "Account owners",
|
||
headers: ["Owner"],
|
||
rows: Array.from({ length: 100 }, (_entry, index) => [
|
||
`owner-${String(index)}-${"x".repeat(110)}`,
|
||
]),
|
||
},
|
||
],
|
||
},
|
||
};
|
||
mockedDispatchSequence = [{ kind: "final", payload }];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(startSlackStreamMock).not.toHaveBeenCalled();
|
||
expect(appendSlackStreamMock).not.toHaveBeenCalled();
|
||
const delivered = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 0, "split table delivery")[0],
|
||
"split table delivery params",
|
||
);
|
||
expect(delivered.replies).toEqual([payload]);
|
||
});
|
||
|
||
it("emits message_sent for every final payload appended to one text stream", async () => {
|
||
mockedNativeStreaming = true;
|
||
mockedDispatchSequence = [
|
||
{ kind: "final", payload: { text: "answer" } },
|
||
{ kind: "final", payload: { text: "late warning", isError: true } },
|
||
];
|
||
startSlackStreamMock.mockResolvedValueOnce({
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: true,
|
||
pendingText: "",
|
||
});
|
||
stopSlackStreamMock.mockResolvedValueOnce({ messageId: "171234.890" });
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(deliverRepliesMock).not.toHaveBeenCalled();
|
||
expect(emitSlackMessageSentHooksMock).toHaveBeenCalledTimes(2);
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 0, "first final message_sent", {
|
||
content: "answer",
|
||
success: true,
|
||
messageId: "171234.890",
|
||
});
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 1, "second final message_sent", {
|
||
content: "late warning",
|
||
success: true,
|
||
messageId: "171234.890",
|
||
});
|
||
});
|
||
|
||
it("emits message_sent exactly once when stopSlackStream throws and the pending stream falls back", async () => {
|
||
// The combined fallback send defers hooks so dispatch can preserve the
|
||
// original final-payload boundary.
|
||
mockedNativeStreaming = true;
|
||
const session = {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: false,
|
||
pendingText: FINAL_REPLY_TEXT,
|
||
};
|
||
startSlackStreamMock.mockResolvedValueOnce(session);
|
||
stopSlackStreamMock.mockRejectedValueOnce(
|
||
new TestSlackStreamNotDeliveredError(FINAL_REPLY_TEXT, "user_not_found"),
|
||
);
|
||
deliverRepliesMock.mockResolvedValueOnce({
|
||
messageId: "171234.901",
|
||
channelId: "C123",
|
||
});
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
expect(emitSlackMessageSentHooksMock).toHaveBeenCalledTimes(1);
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 0, "fallback message_sent", {
|
||
content: FINAL_REPLY_TEXT,
|
||
success: true,
|
||
});
|
||
expect(
|
||
requireRecord(
|
||
requireMockCall(emitSlackMessageSentHooksMock, 0, "fallback message_sent")[0],
|
||
"fallback message_sent",
|
||
),
|
||
).not.toHaveProperty("messageId");
|
||
});
|
||
|
||
it("emits message_sent for a tool-only stream fallback", async () => {
|
||
mockedNativeStreaming = true;
|
||
mockedDispatchSequence = [{ kind: "tool", payload: { text: "tool output" } }];
|
||
const session = {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: false,
|
||
pendingText: "tool output",
|
||
};
|
||
startSlackStreamMock.mockResolvedValueOnce(session);
|
||
stopSlackStreamMock.mockRejectedValueOnce(
|
||
new TestSlackStreamNotDeliveredError("tool output", "user_not_found"),
|
||
);
|
||
deliverRepliesMock.mockResolvedValueOnce({
|
||
messageId: "171234.902",
|
||
channelId: "C123",
|
||
});
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, "tool output");
|
||
expect(emitSlackMessageSentHooksMock).toHaveBeenCalledTimes(1);
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 0, "tool fallback message_sent", {
|
||
content: "tool output",
|
||
success: true,
|
||
});
|
||
expect(
|
||
requireRecord(
|
||
requireMockCall(emitSlackMessageSentHooksMock, 0, "tool fallback message_sent")[0],
|
||
"tool fallback message_sent",
|
||
),
|
||
).not.toHaveProperty("messageId");
|
||
});
|
||
|
||
it("emits acknowledged finals before a pending stream suffix falls back", async () => {
|
||
mockedNativeStreaming = true;
|
||
mockedDispatchSequence = [
|
||
{ kind: "final", payload: { text: "already visible" } },
|
||
{ kind: "final", payload: { text: "still buffered" } },
|
||
];
|
||
const session = {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: true,
|
||
pendingText: "",
|
||
};
|
||
startSlackStreamMock.mockResolvedValueOnce(session);
|
||
appendSlackStreamMock.mockImplementationOnce(async () => {
|
||
session.pendingText = "\nstill buffered";
|
||
});
|
||
stopSlackStreamMock.mockRejectedValueOnce(
|
||
new TestSlackStreamNotDeliveredError("still buffered", "user_not_found"),
|
||
);
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(emitSlackMessageSentHooksMock).toHaveBeenCalledTimes(2);
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 0, "acknowledged message_sent", {
|
||
content: "already visible",
|
||
success: true,
|
||
});
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 1, "fallback message_sent", {
|
||
content: "still buffered",
|
||
success: true,
|
||
});
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, "still buffered");
|
||
expect(stopSlackStreamMock).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it("finalizes buffered finals natively before attempting fallback delivery", async () => {
|
||
mockedNativeStreaming = true;
|
||
mockedDispatchSequence = [
|
||
{ kind: "final", payload: { text: "already visible" } },
|
||
{ kind: "final", payload: { text: "buffered second" } },
|
||
{ kind: "final", payload: { text: "failing third" } },
|
||
];
|
||
const session = {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: true,
|
||
pendingText: "",
|
||
};
|
||
startSlackStreamMock.mockResolvedValueOnce(session);
|
||
appendSlackStreamMock
|
||
.mockImplementationOnce(async () => {
|
||
session.pendingText = "\nbuffered second";
|
||
})
|
||
.mockImplementationOnce(async () => {
|
||
session.pendingText += "\nfailing third";
|
||
throw new TestSlackStreamNotDeliveredError(
|
||
"buffered second\nfailing third",
|
||
"user_not_found",
|
||
);
|
||
});
|
||
stopSlackStreamMock.mockResolvedValueOnce({ messageId: "171234.999" });
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(deliverRepliesMock).not.toHaveBeenCalled();
|
||
expect(stopSlackStreamMock).toHaveBeenCalledTimes(1);
|
||
expect(emitSlackMessageSentHooksMock).toHaveBeenCalledTimes(3);
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 0, "first final message_sent", {
|
||
content: "already visible",
|
||
messageId: "171234.999",
|
||
success: true,
|
||
});
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 1, "second final message_sent", {
|
||
content: "buffered second",
|
||
messageId: "171234.999",
|
||
success: true,
|
||
});
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 2, "third final message_sent", {
|
||
content: "failing third",
|
||
messageId: "171234.999",
|
||
success: true,
|
||
});
|
||
});
|
||
|
||
it("emits one terminal failure per buffered final when native stop and fallback fail", async () => {
|
||
mockedNativeStreaming = true;
|
||
mockedDispatchSequence = [
|
||
{ kind: "final", payload: { text: "already visible" } },
|
||
{ kind: "final", payload: { text: "buffered second" } },
|
||
{ kind: "final", payload: { text: "failing third" } },
|
||
];
|
||
const session = {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: true,
|
||
pendingText: "",
|
||
};
|
||
startSlackStreamMock.mockResolvedValueOnce(session);
|
||
appendSlackStreamMock
|
||
.mockImplementationOnce(async () => {
|
||
session.pendingText = "\nbuffered second";
|
||
})
|
||
.mockImplementationOnce(async () => {
|
||
session.pendingText += "\nfailing third";
|
||
throw new TestSlackStreamNotDeliveredError(
|
||
"buffered second\nfailing third",
|
||
"user_not_found",
|
||
);
|
||
});
|
||
deliverRepliesMock.mockRejectedValueOnce(new Error("fallback send failed"));
|
||
stopSlackStreamMock.mockRejectedValueOnce(
|
||
new TestSlackStreamNotDeliveredError("buffered second\nfailing third", "user_not_found"),
|
||
);
|
||
|
||
await expect(dispatchPreparedSlackMessage(createPreparedSlackMessage())).rejects.toThrowError(
|
||
"slack-stream not delivered: user_not_found",
|
||
);
|
||
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, "buffered second\nfailing third");
|
||
expect(stopSlackStreamMock).toHaveBeenCalledTimes(1);
|
||
expect(emitSlackMessageSentHooksMock).toHaveBeenCalledTimes(3);
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 0, "acknowledged message_sent", {
|
||
content: "already visible",
|
||
success: true,
|
||
});
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 1, "buffered message_sent", {
|
||
content: "buffered second",
|
||
error: "fallback send failed",
|
||
success: false,
|
||
});
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 2, "failed-append message_sent", {
|
||
content: "failing third",
|
||
error: "fallback send failed",
|
||
success: false,
|
||
});
|
||
});
|
||
|
||
it("removes all failed buffered finals from production-style delivery counts", async () => {
|
||
mockedNativeStreaming = true;
|
||
mockedDispatcherCapturesDeliveryErrors = true;
|
||
mockedDispatchSequence = [
|
||
{ kind: "final", payload: { text: "first buffered" } },
|
||
{ kind: "final", payload: { text: "second buffered" } },
|
||
];
|
||
const session = {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: false,
|
||
pendingText: "first buffered",
|
||
};
|
||
startSlackStreamMock.mockResolvedValueOnce(session);
|
||
appendSlackStreamMock.mockImplementationOnce(async () => {
|
||
session.pendingText += "\nsecond buffered";
|
||
throw new TestSlackStreamNotDeliveredError(session.pendingText, "user_not_found");
|
||
});
|
||
stopSlackStreamMock.mockRejectedValueOnce(
|
||
new TestSlackStreamNotDeliveredError("first buffered\nsecond buffered", "user_not_found"),
|
||
);
|
||
deliverRepliesMock.mockRejectedValueOnce(new Error("fallback send failed"));
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
cfg: { messages: { statusReactions: { enabled: true } } },
|
||
ackReactionMessageTs: "171234.111",
|
||
ackReactionPromise: Promise.resolve(true),
|
||
}),
|
||
);
|
||
|
||
expect(emitSlackMessageSentHooksMock).toHaveBeenCalledTimes(2);
|
||
expect(statusReactionControllerMock.setDone).not.toHaveBeenCalled();
|
||
expect(statusReactionControllerMock.restoreInitial).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("removes all failed buffered tools from production-style delivery counts", async () => {
|
||
mockedNativeStreaming = true;
|
||
mockedDispatcherCapturesDeliveryErrors = true;
|
||
mockedDispatchSequence = [
|
||
{ kind: "tool", payload: { text: "first tool" } },
|
||
{ kind: "tool", payload: { text: "second tool" } },
|
||
];
|
||
const session = {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: false,
|
||
pendingText: "first tool",
|
||
};
|
||
startSlackStreamMock.mockResolvedValueOnce(session);
|
||
appendSlackStreamMock.mockImplementationOnce(async () => {
|
||
session.pendingText += "\nsecond tool";
|
||
throw new TestSlackStreamNotDeliveredError(session.pendingText, "user_not_found");
|
||
});
|
||
stopSlackStreamMock.mockRejectedValueOnce(
|
||
new TestSlackStreamNotDeliveredError("first tool\nsecond tool", "user_not_found"),
|
||
);
|
||
deliverRepliesMock.mockRejectedValueOnce(new Error("fallback send failed"));
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
cfg: { messages: { statusReactions: { enabled: true } } },
|
||
ackReactionMessageTs: "171234.111",
|
||
ackReactionPromise: Promise.resolve(true),
|
||
}),
|
||
);
|
||
|
||
expect(emitSlackMessageSentHooksMock).toHaveBeenCalledTimes(2);
|
||
expect(statusReactionControllerMock.setDone).not.toHaveBeenCalled();
|
||
expect(statusReactionControllerMock.restoreInitial).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("keeps a buffered final acknowledged when a later block flushes the stream", async () => {
|
||
mockedNativeStreaming = true;
|
||
mockedDispatchSequence = [
|
||
{ kind: "final", payload: { text: "buffered final" } },
|
||
{ kind: "block", payload: { text: "flushes both" } },
|
||
{ kind: "final", payload: { text: "pending tail" } },
|
||
];
|
||
const session = {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: false,
|
||
pendingText: "buffered final",
|
||
};
|
||
startSlackStreamMock.mockResolvedValueOnce(session);
|
||
appendSlackStreamMock
|
||
.mockImplementationOnce(async () => {
|
||
session.delivered = true;
|
||
session.pendingText = "";
|
||
})
|
||
.mockImplementationOnce(async () => {
|
||
session.pendingText = "\npending tail";
|
||
});
|
||
stopSlackStreamMock.mockRejectedValueOnce(
|
||
new TestSlackStreamNotDeliveredError("pending tail", "user_not_found"),
|
||
);
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(emitSlackMessageSentHooksMock).toHaveBeenCalledTimes(3);
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 0, "flushed final message_sent", {
|
||
content: "buffered final",
|
||
success: true,
|
||
});
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 1, "flushed block message_sent", {
|
||
content: "flushes both",
|
||
success: true,
|
||
});
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, "pending tail");
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 2, "fallback final message_sent", {
|
||
content: "pending tail",
|
||
success: true,
|
||
});
|
||
});
|
||
|
||
it("emits message_sent for each payload when an append flush falls back", async () => {
|
||
// appendSlackStream throwing SlackStreamNotDeliveredError mid-stream routes
|
||
// all buffered text through deliverReplies; the finalizer must not also emit.
|
||
mockedNativeStreaming = true;
|
||
mockedDispatchSequence = [
|
||
{ kind: "block", payload: { text: "first buffered" } },
|
||
{ kind: "final", payload: { text: "second flushes" } },
|
||
];
|
||
const session = {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: false,
|
||
pendingText: "first buffered",
|
||
};
|
||
startSlackStreamMock.mockResolvedValueOnce(session);
|
||
appendSlackStreamMock.mockImplementationOnce(async () => {
|
||
session.pendingText += "\nsecond flushes";
|
||
throw new TestSlackStreamNotDeliveredError(session.pendingText, "user_not_found");
|
||
});
|
||
stopSlackStreamMock.mockRejectedValueOnce(
|
||
new TestSlackStreamNotDeliveredError("first buffered\nsecond flushes", "user_not_found"),
|
||
);
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, "first buffered\nsecond flushes");
|
||
expect(stopSlackStreamMock).toHaveBeenCalledTimes(1);
|
||
expect(emitSlackMessageSentHooksMock).toHaveBeenCalledTimes(2);
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 0, "fallback block message_sent", {
|
||
content: "first buffered",
|
||
success: true,
|
||
});
|
||
expectMockCallArgFields(emitSlackMessageSentHooksMock, 1, "fallback final message_sent", {
|
||
content: "second flushes",
|
||
success: true,
|
||
});
|
||
});
|
||
|
||
it("does not start a text stream for native progress mode when no progress card exists", async () => {
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [],
|
||
});
|
||
|
||
expect(startSlackStreamMock).not.toHaveBeenCalled();
|
||
expect(appendSlackStreamMock).not.toHaveBeenCalled();
|
||
expect(stopSlackStreamMock).not.toHaveBeenCalled();
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
});
|
||
|
||
it("does not admit an empty non-work tool line into native progress", async () => {
|
||
mockedEmptyProgressToolName = "update_plan";
|
||
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [
|
||
{
|
||
kind: "item",
|
||
itemKind: "preamble",
|
||
itemId: "preamble-1",
|
||
progressText: "Planning the change",
|
||
},
|
||
{ kind: "tool_start", name: "update_plan", phase: "start" },
|
||
],
|
||
});
|
||
|
||
expect(startSlackStreamMock).not.toHaveBeenCalled();
|
||
expect(appendSlackStreamMock).not.toHaveBeenCalled();
|
||
expect(stopSlackStreamMock).not.toHaveBeenCalled();
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
});
|
||
|
||
it("starts native Slack progress from typed plan steps", async () => {
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [
|
||
{
|
||
kind: "plan",
|
||
phase: "update",
|
||
explanation: "Executing the checklist.",
|
||
steps: [
|
||
{ step: "Inspect", status: "completed" },
|
||
{ step: "Patch", status: "in_progress" },
|
||
{ step: "Test", status: "pending" },
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
expectNativeProgressStart([
|
||
planUpdate("Executing the checklist."),
|
||
taskUpdate("plan_step_1", "Inspect", "complete"),
|
||
taskUpdate("plan_step_2", "Patch", "in_progress"),
|
||
taskUpdate("plan_step_3", "Test", "pending"),
|
||
]);
|
||
});
|
||
|
||
it("keeps plan explanation in native chunks alongside a fresh preamble", async () => {
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [
|
||
{
|
||
kind: "item",
|
||
itemKind: "preamble",
|
||
itemId: "preamble-1",
|
||
progressText: "Checking the workspace",
|
||
},
|
||
{
|
||
kind: "plan",
|
||
phase: "update",
|
||
explanation: "Executing the checklist.",
|
||
steps: [{ step: "Patch", status: "in_progress" }],
|
||
},
|
||
],
|
||
});
|
||
|
||
expectNativeProgressStart([
|
||
planUpdate("Checking the workspace — Executing the checklist."),
|
||
taskUpdate("plan_step_1", "Patch", "in_progress"),
|
||
]);
|
||
});
|
||
|
||
it("starts native Slack progress from an explanation-only plan", async () => {
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [
|
||
{
|
||
kind: "plan",
|
||
phase: "update",
|
||
explanation: "Reviewing the implementation.",
|
||
steps: [],
|
||
},
|
||
],
|
||
});
|
||
|
||
expectNativeProgressStart([
|
||
planUpdate("Reviewing the implementation."),
|
||
taskUpdate(expect.any(String), "Update Plan — Reviewing the implementation.", "in_progress"),
|
||
]);
|
||
});
|
||
|
||
it("starts native Slack progress from a retained headline when tool rows are hidden", async () => {
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
progress: { label: false, nativeTaskCards: true, render: "rich", toolProgress: false },
|
||
events: [
|
||
{
|
||
kind: "item",
|
||
itemKind: "preamble",
|
||
itemId: "preamble-1",
|
||
progressText: "Checking the workspace",
|
||
},
|
||
{
|
||
kind: "tool_start",
|
||
itemId: "tool-1",
|
||
name: "bash",
|
||
phase: "start",
|
||
args: { command: "pnpm test" },
|
||
},
|
||
],
|
||
});
|
||
|
||
expectNativeProgressStart([planUpdate("Checking the workspace")]);
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("does not replace answer text with a late plan update", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedDispatchSequence = [];
|
||
mockedReplyOptionEvents = [
|
||
{ kind: "partial", text: "Answer started" },
|
||
{
|
||
kind: "plan",
|
||
phase: "update",
|
||
explanation: "Late plan",
|
||
steps: [{ step: "Should stay hidden", status: "in_progress" }],
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(draftStream.update).toHaveBeenCalledTimes(1);
|
||
expect(draftStream.update).toHaveBeenLastCalledWith("Answer started");
|
||
});
|
||
|
||
it("starts native Slack progress from the first running tool callback before final text", async () => {
|
||
const taskId = expect.stringMatching(/^exec_call_1_[a-f0-9]{8}$/);
|
||
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [
|
||
{
|
||
kind: "tool_start",
|
||
itemId: "exec-call-1",
|
||
toolCallId: "tool-call-1",
|
||
name: "bash",
|
||
phase: "start",
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(createSlackDraftStreamMock).not.toHaveBeenCalled();
|
||
expectNativeProgressStart([planUpdate("bash"), taskUpdate(taskId, "bash", "in_progress")]);
|
||
expectNativeProgressAppend(0, [planUpdate("bash"), taskUpdate(taskId, "bash", "complete")]);
|
||
expect(startSlackStreamMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||
appendSlackStreamMock.mock.invocationCallOrder[0] ?? 0,
|
||
);
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
});
|
||
|
||
it("uses the enterprise event team as the native progress stream fallback", async () => {
|
||
const eventClient = {
|
||
chat: { postMessage: postMessageMock, update: chatUpdateMock },
|
||
users: {
|
||
info: vi.fn<() => Promise<{ user: Record<string, never> }>>(async () => ({ user: {} })),
|
||
},
|
||
};
|
||
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [{ kind: "item", progressText: "checking" }],
|
||
eventScope: {
|
||
apiAppId: "A_TEST",
|
||
enterpriseId: "E_TEST",
|
||
isEnterpriseInstall: true,
|
||
teamId: "T_ENTERPRISE",
|
||
client: eventClient,
|
||
},
|
||
});
|
||
|
||
expectMockCallArgFields(startSlackStreamMock, 0, "enterprise progress stream start params", {
|
||
client: eventClient,
|
||
teamId: "T_ENTERPRISE",
|
||
});
|
||
});
|
||
|
||
it("reuses native Slack progress task identity across command item and output events", async () => {
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [
|
||
{
|
||
kind: "item",
|
||
itemId: "tool:call-1",
|
||
toolCallId: "call-1",
|
||
itemKind: "command",
|
||
name: "bash",
|
||
phase: "update",
|
||
status: "running",
|
||
progressText: "install dependencies",
|
||
},
|
||
{
|
||
kind: "command_output",
|
||
itemId: "tool:call-1-output",
|
||
toolCallId: "call-1",
|
||
name: "bash",
|
||
phase: "end",
|
||
exitCode: 0,
|
||
},
|
||
],
|
||
});
|
||
|
||
const taskUpdates = collectNativeTaskUpdates();
|
||
expect([...new Set(taskUpdates.map((task) => task.id))]).toEqual([
|
||
expect.stringMatching(/^command_call_1_[a-f0-9]{8}$/),
|
||
]);
|
||
expect(taskUpdates.at(0)?.id).toEqual(expect.stringMatching(/^command_call_1_[a-f0-9]{8}$/));
|
||
expect(taskUpdates).toContainEqual(
|
||
taskUpdate(taskUpdates.at(0)?.id, "bash — completed", "complete"),
|
||
);
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
});
|
||
|
||
it("suppresses terminal progress callbacks without their terminal phase", async () => {
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [
|
||
{ kind: "command_output", name: "bash", title: "must stay hidden", exitCode: 0 },
|
||
{ kind: "patch", name: "apply_patch", summary: "must stay hidden" },
|
||
],
|
||
});
|
||
|
||
expect(startSlackStreamMock).not.toHaveBeenCalled();
|
||
expect(appendSlackStreamMock).not.toHaveBeenCalled();
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
});
|
||
|
||
it("keeps duplicate-text native tool tasks as distinct rows", async () => {
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [
|
||
{
|
||
kind: "tool_start",
|
||
itemId: "tool-1",
|
||
name: "bash",
|
||
phase: "start",
|
||
args: { command: "pnpm test" },
|
||
},
|
||
{
|
||
kind: "tool_start",
|
||
itemId: "tool-2",
|
||
name: "bash",
|
||
phase: "start",
|
||
args: { command: "pnpm test" },
|
||
},
|
||
],
|
||
});
|
||
|
||
const inProgressTasks = collectNativeTaskUpdates().filter(
|
||
(task) => task.status === "in_progress",
|
||
);
|
||
expect([...new Set(inProgressTasks.map((task) => task.title))]).toHaveLength(1);
|
||
expect([...new Set(inProgressTasks.map((task) => task.id))]).toEqual([
|
||
expect.stringMatching(/^tool_1_[a-f0-9]{8}$/u),
|
||
expect.stringMatching(/^tool_2_[a-f0-9]{8}$/u),
|
||
]);
|
||
});
|
||
|
||
it("keeps one native Slack progress row across rolling reasoning snapshots", async () => {
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [
|
||
{ kind: "reasoning", text: "Checking", isReasoningSnapshot: true },
|
||
{
|
||
kind: "reasoning",
|
||
text: "Checking the Slack handler",
|
||
isReasoningSnapshot: true,
|
||
},
|
||
],
|
||
});
|
||
|
||
const taskUpdates = collectNativeTaskUpdates();
|
||
const reasoningTaskId = taskUpdates.at(0)?.id;
|
||
expect([...new Set(taskUpdates.map((task) => task.id))]).toEqual([
|
||
expect.stringMatching(/^reasoning_[a-f0-9]{8}$/u),
|
||
]);
|
||
expect(taskUpdates).toContainEqual(taskUpdate(reasoningTaskId, "Checking", "in_progress"));
|
||
expect(taskUpdates).toContainEqual(
|
||
taskUpdate(reasoningTaskId, "Checking the Slack handler", "in_progress"),
|
||
);
|
||
expect(taskUpdates).toContainEqual(
|
||
taskUpdate(reasoningTaskId, "Checking the Slack handler", "complete"),
|
||
);
|
||
});
|
||
|
||
it("keeps final fallback in the planned thread when native Slack progress start fails", async () => {
|
||
startSlackStreamMock.mockRejectedValueOnce(new Error("start stream failed"));
|
||
mockedReplyThreadTsSequence = [THREAD_TS, undefined];
|
||
|
||
await dispatchNativeProgressScenario({
|
||
replyToMode: "first",
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [{ kind: "item", progressText: "slow tool" }],
|
||
});
|
||
|
||
expect(startSlackStreamMock).toHaveBeenCalledTimes(1);
|
||
expect(appendSlackStreamMock).not.toHaveBeenCalled();
|
||
expect(stopSlackStreamMock).not.toHaveBeenCalled();
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
});
|
||
|
||
it("collapses a native progress stream to a receipt after its fresh final lands", async () => {
|
||
stopSlackStreamMock.mockResolvedValueOnce({ messageId: "171234.888" });
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [
|
||
{
|
||
kind: "tool_start",
|
||
itemId: "tool-1",
|
||
name: "bash",
|
||
phase: "start",
|
||
args: { command: "pnpm test" },
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "native progress receipt", {
|
||
channelId: "C123",
|
||
messageId: "171234.888",
|
||
text: expect.stringMatching(/^🛠️ 1 tool call · ⏱️ \d+s$/),
|
||
threadTs: THREAD_TS,
|
||
});
|
||
expect(deliverRepliesMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||
finalizeSlackPreviewEditMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
||
);
|
||
});
|
||
|
||
it("acknowledges a rotated native progress stream before the queued turn", async () => {
|
||
const firstSession = {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: true,
|
||
pendingText: "",
|
||
};
|
||
const secondSession = { ...firstSession };
|
||
startSlackStreamMock.mockResolvedValueOnce(firstSession).mockResolvedValueOnce(secondSession);
|
||
stopSlackStreamMock
|
||
.mockResolvedValueOnce({ messageId: "171234.701" })
|
||
.mockResolvedValueOnce({ messageId: "171234.702" });
|
||
finalizeSlackPreviewEditMock.mockResolvedValue(undefined);
|
||
mockedNativeStreaming = true;
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedReplyOptionEvents = [
|
||
{
|
||
kind: "tool_start",
|
||
itemId: "tool-1",
|
||
name: "bash",
|
||
phase: "start",
|
||
args: { command: "pnpm test" },
|
||
},
|
||
];
|
||
mockedDispatchSequence = [
|
||
{ kind: "final", payload: { text: "same answer" } },
|
||
{ kind: "queued_followup" },
|
||
{ kind: "item", progressText: "queued tool" },
|
||
{ kind: "final", payload: { text: "same answer" } },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: {
|
||
streaming: {
|
||
mode: "progress",
|
||
progress: { nativeTaskCards: true, render: "rich" },
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
|
||
expect(startSlackStreamMock).toHaveBeenCalledTimes(2);
|
||
expect(stopSlackStreamMock).toHaveBeenCalledTimes(2);
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(2);
|
||
expectDeliverReplyCall(0, "same answer");
|
||
expectDeliverReplyCall(1, "same answer");
|
||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(2);
|
||
expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "first queued-turn receipt", {
|
||
messageId: "171234.701",
|
||
text: expect.stringMatching(/^🛠️ 1 tool call · ⏱️ \d+s$/),
|
||
});
|
||
expectMockCallArgFields(finalizeSlackPreviewEditMock, 1, "second queued-turn receipt", {
|
||
messageId: "171234.702",
|
||
text: expect.stringMatching(/^⏱️ \d+s$/),
|
||
});
|
||
expect(finalizeSlackPreviewEditMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||
startSlackStreamMock.mock.invocationCallOrder[1] ?? 0,
|
||
);
|
||
expect(stopSlackStreamMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||
deliverRepliesMock.mock.invocationCallOrder[1] ?? 0,
|
||
);
|
||
});
|
||
|
||
it("settles a failed native progress rotation before starting the queued turn", async () => {
|
||
const firstSession = {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: true,
|
||
pendingText: "",
|
||
};
|
||
const secondSession = { ...firstSession };
|
||
startSlackStreamMock.mockResolvedValueOnce(firstSession).mockResolvedValueOnce(secondSession);
|
||
stopSlackStreamMock
|
||
.mockRejectedValueOnce(new Error("socket reset"))
|
||
.mockResolvedValueOnce({ messageId: "171234.702" });
|
||
finalizeSlackPreviewEditMock.mockResolvedValue(undefined);
|
||
mockedNativeStreaming = true;
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedReplyOptionEvents = [{ kind: "item", progressText: "first tool" }];
|
||
mockedDispatchSequence = [
|
||
{ kind: "final", payload: { text: "first answer" } },
|
||
{ kind: "queued_followup" },
|
||
{ kind: "item", progressText: "queued tool" },
|
||
{ kind: "final", payload: { text: "queued answer" } },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: {
|
||
streaming: {
|
||
mode: "progress",
|
||
progress: { nativeTaskCards: true, render: "rich" },
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
|
||
expect(startSlackStreamMock).toHaveBeenCalledTimes(2);
|
||
expect(stopSlackStreamMock).toHaveBeenCalledTimes(2);
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(2);
|
||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
|
||
expectMockCallArgFields(stopSlackStreamMock, 0, "failed rotated stream", {
|
||
session: firstSession,
|
||
});
|
||
expectMockCallArgFields(stopSlackStreamMock, 1, "queued stream cleanup", {
|
||
session: secondSession,
|
||
});
|
||
expect(logVerboseMock).toHaveBeenCalledWith(
|
||
"slack-stream: failed to rotate native progress stream (socket reset)",
|
||
);
|
||
const rotationFailureCall = logVerboseMock.mock.invocationCallOrder.find(
|
||
(_order, index) =>
|
||
logVerboseMock.mock.calls[index]?.[0] ===
|
||
"slack-stream: failed to rotate native progress stream (socket reset)",
|
||
);
|
||
expect(rotationFailureCall).toBeLessThan(startSlackStreamMock.mock.invocationCallOrder[1] ?? 0);
|
||
});
|
||
|
||
it("marks native Slack progress tasks as error when final text is an error", async () => {
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: "tool failed", isError: true },
|
||
events: [{ kind: "item", progressText: "failing tool" }],
|
||
});
|
||
|
||
expectNativeProgressStart([
|
||
planUpdate("failing tool"),
|
||
taskUpdate(contentTaskId("item"), "failing tool", "in_progress"),
|
||
]);
|
||
expectNativeProgressAppend(0, [
|
||
planUpdate("failing tool"),
|
||
taskUpdate(contentTaskId("item"), "failing tool", "error"),
|
||
]);
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled();
|
||
const deliverParams = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 0, "deliver replies")[0],
|
||
"deliver replies params",
|
||
);
|
||
expectRecordFields(deliverParams, { replyThreadTs: THREAD_TS });
|
||
expect(deliverParams.replies).toEqual([{ text: "tool failed", isError: true }]);
|
||
});
|
||
|
||
it("completes a native Slack progress plan even when no final text is sent", async () => {
|
||
await dispatchNativeProgressScenario({
|
||
events: [{ kind: "concurrent_items", progressTexts: ["tool one", "tool two", "tool three"] }],
|
||
});
|
||
|
||
expectNativeProgressStart([
|
||
planUpdate("tool three"),
|
||
taskUpdate(contentTaskId("item"), "tool one", "in_progress"),
|
||
taskUpdate(contentTaskId("item"), "tool two", "in_progress"),
|
||
taskUpdate(contentTaskId("item"), "tool three", "in_progress"),
|
||
]);
|
||
expect(appendSlackStreamMock).not.toHaveBeenCalled();
|
||
expect(deliverRepliesMock).not.toHaveBeenCalled();
|
||
expectMockCallArgFields(stopSlackStreamMock, 0, "native progress stream stop", {
|
||
chunks: [
|
||
planUpdate("tool three"),
|
||
taskUpdate(contentTaskId("item"), "tool one", "complete"),
|
||
taskUpdate(contentTaskId("item"), "tool two", "complete"),
|
||
taskUpdate(contentTaskId("item"), "tool three", "complete"),
|
||
],
|
||
});
|
||
});
|
||
|
||
it("mandatory E2E: preserves an explicit configured native Slack progress plan title", async () => {
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
progress: { label: "Shelling", nativeTaskCards: true, render: "rich" },
|
||
events: [
|
||
{ kind: "item", progressText: "tool one" },
|
||
{ kind: "item", progressText: "tool two" },
|
||
{ kind: "item", progressText: "tool three" },
|
||
],
|
||
});
|
||
|
||
expect(createSlackDraftStreamMock).not.toHaveBeenCalled();
|
||
expectNativeProgressStart([
|
||
planUpdate("Shelling"),
|
||
taskUpdate(contentTaskId("item"), "tool one", "in_progress"),
|
||
]);
|
||
expectNativeProgressAppend(2, [
|
||
planUpdate("Shelling"),
|
||
taskUpdate(contentTaskId("item"), "tool one", "complete"),
|
||
taskUpdate(contentTaskId("item"), "tool two", "complete"),
|
||
taskUpdate(contentTaskId("item"), "tool three", "complete"),
|
||
]);
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
});
|
||
|
||
it("passes configured native progress max line chars into stream chunks", async () => {
|
||
const taskId = expect.stringMatching(/^exec_call_1_[a-f0-9]{8}$/);
|
||
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
progress: { label: "Shelling", maxLineChars: 12, nativeTaskCards: true, render: "rich" },
|
||
events: [
|
||
{
|
||
kind: "tool_start",
|
||
itemId: "exec-call-1",
|
||
toolCallId: "tool-call-1",
|
||
name: "bash",
|
||
phase: "start",
|
||
args: { command: "1234567890abcdefghijklmnopqrstuvwxyz" },
|
||
},
|
||
],
|
||
});
|
||
|
||
expectNativeProgressStart([
|
||
planUpdate("Shelling"),
|
||
taskUpdate(taskId, "bash — 12345…uvwxyz", "in_progress"),
|
||
]);
|
||
expectNativeProgressAppend(0, [
|
||
planUpdate("Shelling"),
|
||
taskUpdate(taskId, "bash — 12345…uvwxyz", "complete"),
|
||
]);
|
||
});
|
||
|
||
it("preserves patch item identity in native Slack progress task updates", async () => {
|
||
const taskId = expect.stringMatching(/^patch_item_1_[a-f0-9]{8}$/);
|
||
|
||
await dispatchNativeProgressScenario({
|
||
finalPayload: { text: FINAL_REPLY_TEXT },
|
||
events: [
|
||
{
|
||
kind: "patch",
|
||
itemId: "patch:item-1",
|
||
toolCallId: "patch-call-1",
|
||
name: "apply_patch",
|
||
phase: "end",
|
||
summary: "updated Slack progress tests",
|
||
},
|
||
],
|
||
});
|
||
|
||
expectNativeProgressStart([
|
||
planUpdate("updated Slack progress tests"),
|
||
taskUpdate(taskId, "updated Slack progress tests", "in_progress"),
|
||
]);
|
||
expectNativeProgressAppend(0, [
|
||
planUpdate("updated Slack progress tests"),
|
||
taskUpdate(taskId, "updated Slack progress tests", "complete"),
|
||
]);
|
||
});
|
||
|
||
it("preserves the last rich Slack progress lines after a draft boundary status update", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
|
||
mockedReplyOptionEvents = [
|
||
{ kind: "item", progressText: "tool one" },
|
||
{ kind: "item", progressText: "tool two" },
|
||
{ kind: "assistant_start" },
|
||
{ kind: "partial", text: "partial answer" },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { progress: { label: "Shelling", render: "rich" } } },
|
||
}),
|
||
);
|
||
|
||
expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
|
||
expect(draftStream.update).toHaveBeenLastCalledWith({
|
||
text: ["Shelling", "", "• tool one", "• tool two"].join("\n"),
|
||
blocks: [
|
||
{
|
||
type: "section",
|
||
text: { type: "mrkdwn", text: "*Shelling*" },
|
||
},
|
||
{
|
||
type: "section",
|
||
fields: [
|
||
{ type: "mrkdwn", text: "• *Update*" },
|
||
{ type: "mrkdwn", text: "—" },
|
||
],
|
||
},
|
||
{
|
||
type: "section",
|
||
fields: [
|
||
{ type: "mrkdwn", text: "• *Update*" },
|
||
{ type: "mrkdwn", text: "—" },
|
||
],
|
||
},
|
||
],
|
||
});
|
||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("preserves text Slack progress lines after a draft boundary status update", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [];
|
||
mockedReplyOptionEvents = [
|
||
{ kind: "item", progressText: "tool one" },
|
||
{ kind: "item", progressText: "tool two" },
|
||
{ kind: "assistant_start" },
|
||
{ kind: "partial", text: "partial answer" },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { progress: { label: "Working" } } },
|
||
}),
|
||
);
|
||
|
||
expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
|
||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||
["Working", "", "• tool one", "• tool two"].join("\n"),
|
||
);
|
||
});
|
||
|
||
it("re-arms an isolated progress draft on an assistant boundary after final delivery", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedReplyOptionEvents = [{ kind: "item", progressText: "first turn" }];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { mode: "progress", progress: { label: "Working" } } },
|
||
}),
|
||
);
|
||
await capturedReplyOptions?.onAssistantMessageStart?.();
|
||
await requireCapturedItemEventHandler()({ progressText: "second turn" });
|
||
|
||
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
|
||
expect(draftStream.update).toHaveBeenLastCalledWith("Working\n\n• second turn");
|
||
});
|
||
|
||
it("re-arms an isolated progress draft when a queued followup is admitted", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedReplyOptionEvents = [{ kind: "item", progressText: "first turn" }];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { mode: "progress", progress: { label: "Working" } } },
|
||
}),
|
||
);
|
||
await capturedReplyOptions?.onQueuedFollowupAdmitted?.();
|
||
await requireCapturedItemEventHandler()({ progressText: "queued turn" });
|
||
|
||
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
|
||
expect(draftStream.update).toHaveBeenLastCalledWith("Working\n\n• queued turn");
|
||
});
|
||
|
||
it("re-arms queued progress after a silent turn without a final delivery", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [];
|
||
mockedReplyOptionEvents = [{ kind: "item", progressText: "silent turn" }];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { mode: "progress", progress: { label: "Working" } } },
|
||
}),
|
||
);
|
||
await capturedReplyOptions?.onQueuedFollowupAdmitted?.();
|
||
await requireCapturedItemEventHandler()({ progressText: "queued turn" });
|
||
|
||
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
|
||
expect(draftStream.update).toHaveBeenLastCalledWith("Working\n\n• queued turn");
|
||
});
|
||
|
||
it("forces a new draft message on assistant boundaries in partial mode", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedSlackStreamingMode = "partial";
|
||
mockedSlackDraftMode = "replace";
|
||
mockedDispatchSequence = [];
|
||
mockedReplyOptionEvents = [
|
||
{ kind: "partial", text: "first chunk" },
|
||
{ kind: "assistant_start" },
|
||
{ kind: "partial", text: "second chunk" },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage({}));
|
||
|
||
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("starts a new draft delivery target when a queued followup is admitted", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedSlackStreamingMode = "partial";
|
||
mockedSlackDraftMode = "replace";
|
||
mockedDispatchSequence = [];
|
||
mockedReplyOptionEvents = [{ kind: "partial", text: "first reply" }];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage({}));
|
||
await capturedReplyOptions?.onQueuedFollowupAdmitted?.();
|
||
|
||
expect(draftStream.flush).toHaveBeenCalledTimes(1);
|
||
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("can hide raw Slack command progress text by config", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [];
|
||
mockedReplyOptionEvents = [
|
||
{
|
||
kind: "item",
|
||
itemKind: "command",
|
||
name: "exec",
|
||
progressText: "exec pnpm test -- --watch=false",
|
||
},
|
||
{ kind: "item", progressText: "done" },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: {
|
||
streaming: { mode: "progress", progress: { label: "Shelling", commandText: "status" } },
|
||
},
|
||
}),
|
||
);
|
||
|
||
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n🛠️ Exec\n• done");
|
||
expect(draftStream.update.mock.calls.flat().join("\n")).not.toContain("pnpm test");
|
||
});
|
||
|
||
it("suppresses standalone Slack tool progress when progress lines are disabled", async () => {
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { mode: "progress", progress: { toolProgress: false } } },
|
||
}),
|
||
);
|
||
|
||
expect(capturedReplyOptions?.suppressDefaultToolProgressMessages).toBe(true);
|
||
await requireCapturedItemEventHandler()({ progressText: "hidden progress" });
|
||
});
|
||
|
||
it("keeps only the latest Slack commentary when tool progress is disabled", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [];
|
||
mockedReplyOptionEvents = [
|
||
{
|
||
kind: "tool_start",
|
||
itemId: "tool-1",
|
||
name: "bash",
|
||
phase: "start",
|
||
args: { command: "pnpm test" },
|
||
},
|
||
{
|
||
kind: "item",
|
||
itemKind: "preamble",
|
||
itemId: "preamble-1",
|
||
progressText: "Checking the Slack event path",
|
||
},
|
||
{
|
||
kind: "item",
|
||
itemKind: "preamble",
|
||
itemId: "preamble-2",
|
||
progressText: "Preparing the smallest fix",
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: {
|
||
streaming: {
|
||
mode: "progress",
|
||
progress: { label: false, commentary: true, toolProgress: false, maxLines: 1 },
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
|
||
expect(capturedReplyOptions?.commentaryProgressEnabled).toBe(true);
|
||
expect(capturedReplyOptions?.suppressDefaultToolProgressMessages).toBe(true);
|
||
expect(draftStream.update).toHaveBeenLastCalledWith("💬 Preparing the smallest fix");
|
||
expect(draftStream.update.mock.calls.flat().join("\n")).not.toContain("pnpm test");
|
||
|
||
const updateCount = draftStream.update.mock.calls.length;
|
||
capturedReplyOptions?.onVerboseProgressVisibility?.(() => true);
|
||
await requireCapturedItemEventHandler()({
|
||
kind: "preamble",
|
||
itemId: "preamble-3",
|
||
progressText: "Delivered by the verbose lane",
|
||
});
|
||
expect(draftStream.update).toHaveBeenCalledTimes(updateCount);
|
||
});
|
||
|
||
it("preserves Markdown in Slack commentary drafts for the outbound renderer", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [];
|
||
mockedReplyOptionEvents = [
|
||
{
|
||
kind: "item",
|
||
itemKind: "preamble",
|
||
itemId: "preamble-1",
|
||
progressText: "I’m using the `monorepo` skill on Linux x86_64.",
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: {
|
||
streaming: {
|
||
mode: "progress",
|
||
progress: { label: false, commentary: true, toolProgress: false },
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
|
||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||
"💬 I’m using the `monorepo` skill on Linux x86_64.",
|
||
);
|
||
});
|
||
|
||
it("uses the enterprise event client for Slack commentary drafts", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [];
|
||
mockedReplyOptionEvents = [
|
||
{
|
||
kind: "item",
|
||
itemKind: "preamble",
|
||
itemId: "preamble-1",
|
||
progressText: "Checking the Enterprise event path",
|
||
},
|
||
{
|
||
kind: "item",
|
||
itemKind: "preamble",
|
||
itemId: "preamble-2",
|
||
progressText: "Using the scoped listener client",
|
||
},
|
||
];
|
||
const eventClient = {
|
||
chat: { postMessage: postMessageMock, update: chatUpdateMock },
|
||
};
|
||
const eventScope = {
|
||
apiAppId: "A_TEST",
|
||
enterpriseId: "E_TEST",
|
||
isEnterpriseInstall: true as const,
|
||
teamId: "T_ENTERPRISE",
|
||
client: eventClient,
|
||
};
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: {
|
||
enterpriseOrgInstall: true,
|
||
streaming: {
|
||
mode: "progress",
|
||
progress: { label: false, commentary: true, toolProgress: false, maxLines: 1 },
|
||
},
|
||
},
|
||
eventScope,
|
||
}),
|
||
);
|
||
|
||
expect(createSlackDraftStreamMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({ eventScope }),
|
||
);
|
||
expect(draftStream.update).toHaveBeenLastCalledWith("💬 Using the scoped listener client");
|
||
});
|
||
|
||
it("renders the latest Slack preamble as the status headline by default", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [];
|
||
mockedReplyOptionEvents = [
|
||
{
|
||
kind: "tool_start",
|
||
itemId: "tool-1",
|
||
name: "bash",
|
||
phase: "start",
|
||
args: { command: "pnpm test" },
|
||
},
|
||
{
|
||
kind: "item",
|
||
itemKind: "preamble",
|
||
itemId: "preamble-1",
|
||
progressText: "Checking the legacy Slack path",
|
||
},
|
||
{
|
||
kind: "item",
|
||
itemKind: "preamble",
|
||
itemId: "preamble-2",
|
||
progressText: "Keeping the released behavior",
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: {
|
||
streaming: { mode: "progress", progress: { label: false, maxLines: 1 } },
|
||
},
|
||
}),
|
||
);
|
||
|
||
expect(capturedReplyOptions?.commentaryProgressEnabled).toBeUndefined();
|
||
expect(capturedReplyOptions?.onVerboseProgressVisibility).toBeUndefined();
|
||
expect(capturedReplyOptions?.progressPreambleEnabled).toBe(true);
|
||
expect(draftStream.update).toHaveBeenLastCalledWith(
|
||
"Keeping the released behavior\n\n• pnpm test",
|
||
);
|
||
});
|
||
|
||
it("preserves Slack preamble previews outside progress mode", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedSlackStreamingMode = "partial";
|
||
mockedSlackDraftMode = "replace";
|
||
mockedDispatchSequence = [];
|
||
mockedReplyOptionEvents = [
|
||
{
|
||
kind: "item",
|
||
itemKind: "preamble",
|
||
itemId: "preamble-1",
|
||
progressText: "Keeping the partial preview path",
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: {
|
||
streaming: { mode: "partial", progress: { label: false } },
|
||
},
|
||
}),
|
||
);
|
||
|
||
expect(capturedReplyOptions?.commentaryProgressEnabled).toBeUndefined();
|
||
expect(draftStream.update).toHaveBeenLastCalledWith("• Keeping the partial preview path");
|
||
});
|
||
|
||
it("preserves Slack reasoning previews outside status-final mode", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedSlackStreamingMode = "partial";
|
||
mockedSlackDraftMode = "replace";
|
||
mockedDispatchSequence = [];
|
||
mockedReplyOptionEvents = [
|
||
{ kind: "reasoning", text: "Reading" },
|
||
{ kind: "reasoning", text: " the Slack handler" },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: {
|
||
streaming: { mode: "partial", progress: { label: false } },
|
||
},
|
||
}),
|
||
);
|
||
|
||
expect(draftStream.update).toHaveBeenLastCalledWith("• Reading the Slack handler");
|
||
});
|
||
|
||
it("keeps preamble headlines and tool progress when commentary is disabled", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [];
|
||
mockedReplyOptionEvents = [
|
||
{
|
||
kind: "tool_start",
|
||
itemId: "tool-1",
|
||
name: "bash",
|
||
phase: "start",
|
||
args: { command: "pnpm test" },
|
||
},
|
||
{
|
||
kind: "item",
|
||
itemKind: "preamble",
|
||
itemId: "preamble-1",
|
||
progressText: "Hidden commentary",
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: {
|
||
streaming: {
|
||
mode: "progress",
|
||
progress: { label: false, commentary: false, toolProgress: true },
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
|
||
const updates = draftStream.update.mock.calls.flat().join("\n");
|
||
expect(capturedReplyOptions?.commentaryProgressEnabled).toBeUndefined();
|
||
expect(updates).toContain("pnpm test");
|
||
expect(updates).toContain("Hidden commentary");
|
||
});
|
||
|
||
it("does not create a blank Slack progress draft when label and lines are disabled", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [];
|
||
mockedReplyOptionEvents = [
|
||
{ kind: "item", progressText: "tool one" },
|
||
{ kind: "item", progressText: "tool two" },
|
||
{ kind: "partial", text: "partial answer" },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: {
|
||
streaming: { mode: "progress", progress: { label: false, toolProgress: false } },
|
||
},
|
||
}),
|
||
);
|
||
|
||
expect(capturedReplyOptions?.suppressDefaultToolProgressMessages).toBe(true);
|
||
expect(draftStream.update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("preserves hidden-title rich Slack progress drafts when the label is hidden", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
mockedSlackStreamingMode = "progress";
|
||
mockedSlackDraftMode = "status_final";
|
||
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
|
||
mockedReplyOptionEvents = [
|
||
{ kind: "item", progressText: "tool one" },
|
||
{ kind: "partial", text: "partial answer" },
|
||
{ kind: "item", progressText: "tool two" },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { progress: { label: false, render: "rich" } } },
|
||
}),
|
||
);
|
||
|
||
expect(draftStream.update).toHaveBeenLastCalledWith({
|
||
text: ["• tool one", "• tool two"].join("\n"),
|
||
blocks: [
|
||
{
|
||
type: "section",
|
||
fields: [
|
||
{ type: "mrkdwn", text: "• *Update*" },
|
||
{ type: "mrkdwn", text: "—" },
|
||
],
|
||
},
|
||
{
|
||
type: "section",
|
||
fields: [
|
||
{ type: "mrkdwn", text: "• *Update*" },
|
||
{ type: "mrkdwn", text: "—" },
|
||
],
|
||
},
|
||
],
|
||
});
|
||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("suppresses standalone Slack tool progress when partial preview lines are disabled", async () => {
|
||
mockedSlackStreamingMode = "partial";
|
||
mockedSlackDraftMode = "replace";
|
||
mockedDispatchSequence = [];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
accountConfig: { streaming: { mode: "partial", preview: { toolProgress: false } } },
|
||
}),
|
||
);
|
||
|
||
expect(capturedReplyOptions?.suppressDefaultToolProgressMessages).toBe(true);
|
||
await requireCapturedItemEventHandler()({ progressText: "hidden partial progress" });
|
||
});
|
||
|
||
it("starts native streams in the first-reply thread for top-level channel messages", async () => {
|
||
mockedNativeStreaming = true;
|
||
mockedReplyThreadTs = "171234.111";
|
||
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
message: { thread_ts: undefined },
|
||
replyToMode: "all",
|
||
}),
|
||
);
|
||
|
||
expectMockCallArgFields(startSlackStreamMock, 0, "Slack stream start params", {
|
||
channel: "C123",
|
||
threadTs: "171234.111",
|
||
text: FINAL_REPLY_TEXT,
|
||
});
|
||
expect(deliverRepliesMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("uses the enterprise event team as the native text stream fallback", async () => {
|
||
mockedNativeStreaming = true;
|
||
const eventClient = {
|
||
chat: { postMessage: postMessageMock, update: chatUpdateMock },
|
||
users: {
|
||
info: vi.fn<() => Promise<{ user: Record<string, never> }>>(async () => ({ user: {} })),
|
||
},
|
||
};
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
eventScope: {
|
||
apiAppId: "A_TEST",
|
||
enterpriseId: "E_TEST",
|
||
isEnterpriseInstall: true,
|
||
teamId: "T_ENTERPRISE",
|
||
client: eventClient,
|
||
},
|
||
}),
|
||
);
|
||
|
||
expectMockCallArgFields(startSlackStreamMock, 0, "enterprise Slack stream start params", {
|
||
client: eventClient,
|
||
teamId: "T_ENTERPRISE",
|
||
});
|
||
});
|
||
|
||
it("resolves and caches the native stream recipient team per enterprise client", async () => {
|
||
mockedNativeStreaming = true;
|
||
const usersInfo = vi.fn(async () => ({ user: { team_id: "T_RECIPIENT" } }));
|
||
const eventClient = {
|
||
chat: { postMessage: postMessageMock, update: chatUpdateMock },
|
||
users: { info: usersInfo },
|
||
};
|
||
const eventScope = {
|
||
apiAppId: "A_TEST",
|
||
enterpriseId: "E_TEST",
|
||
isEnterpriseInstall: true as const,
|
||
teamId: "T_ENTERPRISE",
|
||
client: eventClient,
|
||
};
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage({ eventScope }));
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage({ eventScope }));
|
||
|
||
expect(usersInfo).toHaveBeenCalledTimes(1);
|
||
expect(usersInfo).toHaveBeenCalledWith({ token: "xoxb-test", user: "U123" });
|
||
expectMockCallArgFields(startSlackStreamMock, 0, "first enterprise Slack stream start", {
|
||
client: eventClient,
|
||
teamId: "T_RECIPIENT",
|
||
});
|
||
expectMockCallArgFields(startSlackStreamMock, 1, "cached enterprise Slack stream start", {
|
||
client: eventClient,
|
||
teamId: "T_RECIPIENT",
|
||
});
|
||
});
|
||
|
||
it("suppresses reasoning payloads before Slack native streaming delivery", async () => {
|
||
mockedNativeStreaming = true;
|
||
mockedDispatchSequence = [
|
||
{ kind: "block", payload: { text: "hidden", isReasoning: true } },
|
||
{ kind: "final", payload: { text: FINAL_REPLY_TEXT } },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(startSlackStreamMock).toHaveBeenCalledTimes(1);
|
||
expectMockCallArgFields(startSlackStreamMock, 0, "Slack stream start params", {
|
||
text: FINAL_REPLY_TEXT,
|
||
});
|
||
expect(appendSlackStreamMock).not.toHaveBeenCalled();
|
||
expect(deliverRepliesMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("suppresses reasoning payloads in the non-streaming delivery path", async () => {
|
||
mockedNativeStreaming = false;
|
||
mockedDispatchSequence = [
|
||
{ kind: "block", payload: { text: "Reasoning:\n_hidden_", isReasoning: true } },
|
||
{ kind: "final", payload: { text: FINAL_REPLY_TEXT } },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
});
|
||
|
||
it("does not count suppressed reasoning-only payloads as delivered", async () => {
|
||
mockedNativeStreaming = false;
|
||
mockedDispatchSequence = [
|
||
{ kind: "final", payload: { text: "Reasoning:\n_hidden_", isReasoning: true } },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
cfg: {
|
||
messages: {
|
||
statusReactions: { enabled: true },
|
||
},
|
||
},
|
||
ackReactionMessageTs: "171234.111",
|
||
ackReactionPromise: Promise.resolve(true),
|
||
}),
|
||
);
|
||
|
||
expect(deliverRepliesMock).not.toHaveBeenCalled();
|
||
expect(statusReactionControllerMock.setDone).not.toHaveBeenCalled();
|
||
expect(statusReactionControllerMock.restoreInitial).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("does not consume first-reply delivery state for suppressed reasoning payloads", async () => {
|
||
mockedNativeStreaming = false;
|
||
mockedReplyThreadTsSequence = [THREAD_TS, undefined];
|
||
mockedDispatchSequence = [
|
||
{ kind: "block", payload: { text: "hidden", isReasoning: true } },
|
||
{ kind: "final", payload: { text: FINAL_REPLY_TEXT } },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
replyToMode: "first",
|
||
}),
|
||
);
|
||
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT, { replyThreadTs: THREAD_TS });
|
||
});
|
||
|
||
it("suppresses reasoning payloads in non-streaming delivery when mixed with tool payloads", async () => {
|
||
mockedNativeStreaming = false;
|
||
mockedDispatchSequence = [
|
||
{ kind: "tool", payload: { text: "tool result" } },
|
||
{ kind: "block", payload: { text: "Let me think about this...", isReasoning: true } },
|
||
{ kind: "block", payload: { text: "I need to consider...", isReasoning: true } },
|
||
{ kind: "final", payload: { text: FINAL_REPLY_TEXT } },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(2);
|
||
expectDeliverReplyCall(0, "tool result");
|
||
expectDeliverReplyCall(1, FINAL_REPLY_TEXT);
|
||
});
|
||
|
||
it("suppresses reasoning payloads via deliverNormally fallback from streaming errors", async () => {
|
||
mockedNativeStreaming = true;
|
||
mockedDispatchSequence = [
|
||
{ kind: "block", payload: { text: "Let me analyze...", isReasoning: true } },
|
||
{ kind: "final", payload: { text: FINAL_REPLY_TEXT } },
|
||
];
|
||
startSlackStreamMock.mockRejectedValueOnce(new Error("stream setup failed"));
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
for (const call of deliverRepliesMock.mock.calls) {
|
||
const params = (call as unknown[])[0] as {
|
||
replies: Array<{ isReasoning?: boolean }>;
|
||
};
|
||
for (const reply of params.replies) {
|
||
expect(reply.isReasoning).not.toBe(true);
|
||
}
|
||
}
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
});
|
||
|
||
it("keeps same-content tool and final payloads distinct after preview fallback", async () => {
|
||
mockedDispatchSequence = [
|
||
{ kind: "tool", payload: { text: SAME_TEXT } },
|
||
{ kind: "final", payload: { text: SAME_TEXT } },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(2);
|
||
expectDeliverReplyCall(0, SAME_TEXT);
|
||
expectDeliverReplyCall(1, SAME_TEXT);
|
||
});
|
||
|
||
it("keeps multi-part block replies in the first reply thread after the plan is consumed", async () => {
|
||
mockedReplyThreadTsSequence = [THREAD_TS, undefined];
|
||
mockedDispatchSequence = [
|
||
{ kind: "block", payload: { text: "first block" } },
|
||
{ kind: "block", payload: { text: "second block" } },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
replyToMode: "first",
|
||
}),
|
||
);
|
||
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(2);
|
||
expectDeliverReplyCall(0, "first block");
|
||
expectDeliverReplyCall(1, "second block");
|
||
});
|
||
|
||
it("does not flush draft previews for media finals before normal delivery", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
mockedDispatchSequence = [
|
||
{
|
||
kind: "final",
|
||
payload: { text: "Photo", mediaUrl: "https://example.com/a.png" },
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(draftStream.flush).not.toHaveBeenCalled();
|
||
expect(draftStream.discardPending).toHaveBeenCalled();
|
||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||
expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled();
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("keeps the preview and sends media-only for TTS supplement finals", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
mockedReplyThreadTsSequence = [undefined];
|
||
mockedDispatchSequence = [
|
||
{
|
||
kind: "final",
|
||
payload: {
|
||
mediaUrl: "https://example.com/tts.mp3",
|
||
audioAsVoice: true,
|
||
spokenText: "Spoken answer",
|
||
ttsSupplement: { spokenText: "Spoken answer" },
|
||
},
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(draftStream.flush).toHaveBeenCalledTimes(1);
|
||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||
expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "preview edit params", {
|
||
channelId: "C123",
|
||
messageId: "171234.567",
|
||
text: "Spoken answer",
|
||
});
|
||
const delivered = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 0, "deliver replies")[0],
|
||
"deliver replies params",
|
||
);
|
||
expectRecordFields(delivered, { replyThreadTs: THREAD_TS });
|
||
expect(delivered.replies).toEqual([
|
||
{
|
||
mediaUrl: "https://example.com/tts.mp3",
|
||
audioAsVoice: true,
|
||
spokenText: "Spoken answer",
|
||
ttsSupplement: { spokenText: "Spoken answer" },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("delivers complete oversized TTS text together with its media", async () => {
|
||
const spokenText = "x".repeat(4_001);
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
mockedDispatchSequence = [
|
||
{
|
||
kind: "final",
|
||
payload: {
|
||
mediaUrl: "https://example.com/tts.mp3",
|
||
audioAsVoice: true,
|
||
spokenText,
|
||
ttsSupplement: { spokenText },
|
||
},
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled();
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
const delivered = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 0, "deliver replies")[0],
|
||
"deliver replies params",
|
||
);
|
||
expect(delivered.replies).toEqual([
|
||
{
|
||
text: spokenText,
|
||
mediaUrl: "https://example.com/tts.mp3",
|
||
audioAsVoice: true,
|
||
spokenText,
|
||
ttsSupplement: { spokenText },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("defers hooks and suppresses duplicate TTS finals when flush creates the preview id", async () => {
|
||
let flushed = false;
|
||
const draftStream = {
|
||
...createDraftStreamStub(),
|
||
flush: vi.fn(async () => {
|
||
flushed = true;
|
||
}),
|
||
clear: vi.fn(noopAsync),
|
||
discardPending: vi.fn(noopAsync),
|
||
seal: vi.fn(noopAsync),
|
||
messageId: () => (flushed ? "171234.567" : undefined),
|
||
};
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
mockedSlackIsThreadReply = false;
|
||
mockedReplyThreadTsSequence = [undefined, undefined];
|
||
const payload = {
|
||
text: "Spoken answer",
|
||
mediaUrl: "https://example.com/tts.mp3",
|
||
audioAsVoice: true,
|
||
spokenText: "Spoken answer",
|
||
ttsSupplement: { spokenText: "Spoken answer" },
|
||
};
|
||
mockedDispatchSequence = [
|
||
{ kind: "final", payload },
|
||
{ kind: "final", payload },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
message: { thread_ts: undefined },
|
||
replyToMode: "first",
|
||
}),
|
||
);
|
||
|
||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
const delivered = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 0, "deliver replies")[0],
|
||
"deliver replies params",
|
||
);
|
||
expectRecordFields(delivered, { replyThreadTs: THREAD_TS });
|
||
expect(emitSlackMessageSentHooksMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("suppresses duplicate TTS supplement finals after preview finalization", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
|
||
mockedSlackIsThreadReply = false;
|
||
mockedReplyThreadTsSequence = [undefined];
|
||
const payload = {
|
||
text: "Spoken answer",
|
||
mediaUrl: "https://example.com/tts.mp3",
|
||
audioAsVoice: true,
|
||
spokenText: "Spoken answer",
|
||
ttsSupplement: { spokenText: "Spoken answer" },
|
||
};
|
||
mockedDispatchSequence = [
|
||
{ kind: "final", payload },
|
||
{ kind: "final", payload },
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(
|
||
createPreparedSlackMessage({
|
||
message: { thread_ts: undefined },
|
||
replyToMode: "first",
|
||
}),
|
||
);
|
||
|
||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
const delivered = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 0, "deliver replies")[0],
|
||
"deliver replies params",
|
||
);
|
||
expectRecordFields(delivered, { replyThreadTs: THREAD_TS });
|
||
expect(delivered.replies).toEqual([
|
||
{
|
||
mediaUrl: "https://example.com/tts.mp3",
|
||
audioAsVoice: true,
|
||
spokenText: "Spoken answer",
|
||
ttsSupplement: { spokenText: "Spoken answer" },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("falls back with visible text when TTS supplement preview finalization fails", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedReplyThreadTsSequence = [undefined];
|
||
mockedDispatchSequence = [
|
||
{
|
||
kind: "final",
|
||
payload: {
|
||
mediaUrl: "https://example.com/tts.mp3",
|
||
audioAsVoice: true,
|
||
spokenText: "Spoken answer",
|
||
ttsSupplement: { spokenText: "Spoken answer" },
|
||
},
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledTimes(1);
|
||
expect(draftStream.discardPending).toHaveBeenCalled();
|
||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||
const delivered = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 0, "deliver replies")[0],
|
||
"deliver replies params",
|
||
);
|
||
expectRecordFields(delivered, { replyThreadTs: THREAD_TS });
|
||
expect(delivered.replies).toEqual([
|
||
{
|
||
text: "Spoken answer",
|
||
mediaUrl: "https://example.com/tts.mp3",
|
||
audioAsVoice: true,
|
||
spokenText: "Spoken answer",
|
||
ttsSupplement: { spokenText: "Spoken answer" },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("keeps chart semantics singular when TTS preview finalization fails", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
mockedSlackReplyBlocks = [
|
||
{
|
||
type: "section",
|
||
text: { type: "mrkdwn", text: "Spoken answer", verbatim: true },
|
||
},
|
||
{
|
||
type: "data_visualization",
|
||
title: "Revenue",
|
||
chart: {
|
||
type: "bar",
|
||
series: [
|
||
{
|
||
name: "<@U123>",
|
||
data: [
|
||
{ label: "Q1", value: 12 },
|
||
{ label: "Q2", value: 18 },
|
||
],
|
||
},
|
||
],
|
||
axis_config: { categories: ["Q1", "Q2"] },
|
||
},
|
||
},
|
||
];
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedReplyThreadTsSequence = [undefined];
|
||
mockedDispatchSequence = [
|
||
{
|
||
kind: "final",
|
||
payload: {
|
||
mediaUrl: "https://example.com/tts.mp3",
|
||
audioAsVoice: true,
|
||
spokenText: "Spoken answer",
|
||
ttsSupplement: { spokenText: "Spoken answer" },
|
||
presentation: {
|
||
blocks: [
|
||
{
|
||
type: "chart",
|
||
chartType: "bar",
|
||
title: "Revenue",
|
||
categories: ["Q1", "Q2"],
|
||
series: [{ name: "<@U123>", values: [12, 18] }],
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "chart TTS preview edit params", {
|
||
text: "Spoken answer\n\nRevenue (bar chart)\n- <@U123>: Q1: 12; Q2: 18",
|
||
blocks: mockedSlackReplyBlocks,
|
||
});
|
||
expect(normalizeSlackOutboundTextMock).not.toHaveBeenCalled();
|
||
|
||
const delivered = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 0, "deliver replies")[0],
|
||
"deliver replies params",
|
||
);
|
||
expect(delivered.replies).toEqual([
|
||
{
|
||
text: "Spoken answer",
|
||
mediaUrl: "https://example.com/tts.mp3",
|
||
audioAsVoice: true,
|
||
spokenText: "Spoken answer",
|
||
ttsSupplement: { spokenText: "Spoken answer" },
|
||
presentation: {
|
||
blocks: [
|
||
{
|
||
type: "chart",
|
||
chartType: "bar",
|
||
title: "Revenue",
|
||
categories: ["Q1", "Q2"],
|
||
series: [{ name: "<@U123>", values: [12, 18] }],
|
||
},
|
||
],
|
||
},
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("falls back with visible text when TTS supplement preview has no message id", async () => {
|
||
const draftStream = {
|
||
...createDraftStreamStub(),
|
||
flush: vi.fn(noopAsync),
|
||
clear: vi.fn(noopAsync),
|
||
discardPending: vi.fn(noopAsync),
|
||
seal: vi.fn(noopAsync),
|
||
messageId: () => undefined,
|
||
};
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedDispatchSequence = [
|
||
{
|
||
kind: "final",
|
||
payload: {
|
||
mediaUrl: "https://example.com/tts.mp3",
|
||
audioAsVoice: true,
|
||
spokenText: "Spoken answer",
|
||
ttsSupplement: { spokenText: "Spoken answer" },
|
||
},
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled();
|
||
expect(draftStream.discardPending).toHaveBeenCalled();
|
||
const delivered = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 0, "deliver replies")[0],
|
||
"deliver replies params",
|
||
);
|
||
expect(delivered.replies).toEqual([
|
||
{
|
||
text: "Spoken answer",
|
||
mediaUrl: "https://example.com/tts.mp3",
|
||
audioAsVoice: true,
|
||
spokenText: "Spoken answer",
|
||
ttsSupplement: { spokenText: "Spoken answer" },
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("keeps already-delivered TTS supplements audio-only without a draft preview", async () => {
|
||
mockedSlackStreamingMode = "off";
|
||
mockedBlockStreamingEnabled = true;
|
||
mockedDispatchSequence = [
|
||
{
|
||
kind: "final",
|
||
payload: {
|
||
mediaUrl: "https://example.com/tts.mp3",
|
||
audioAsVoice: true,
|
||
spokenText: "Spoken answer",
|
||
ttsSupplement: {
|
||
spokenText: "Spoken answer",
|
||
visibleTextAlreadyDelivered: true,
|
||
},
|
||
},
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled();
|
||
const delivered = requireRecord(
|
||
requireMockCall(deliverRepliesMock, 0, "deliver replies")[0],
|
||
"deliver replies params",
|
||
);
|
||
expect(delivered.replies).toEqual([
|
||
{
|
||
mediaUrl: "https://example.com/tts.mp3",
|
||
audioAsVoice: true,
|
||
spokenText: "Spoken answer",
|
||
ttsSupplement: {
|
||
spokenText: "Spoken answer",
|
||
visibleTextAlreadyDelivered: true,
|
||
},
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("does not flush draft previews for error finals before normal delivery", async () => {
|
||
const draftStream = createDraftStreamStub();
|
||
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
|
||
mockedDispatchSequence = [
|
||
{
|
||
kind: "final",
|
||
payload: { text: "Something failed", isError: true },
|
||
},
|
||
];
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(draftStream.flush).not.toHaveBeenCalled();
|
||
expect(draftStream.discardPending).toHaveBeenCalled();
|
||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||
expect(finalizeSlackPreviewEditMock).not.toHaveBeenCalled();
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("routes pending native stream text through chunked sender when finalize fails before the SDK buffer flushes", async () => {
|
||
mockedNativeStreaming = true;
|
||
const session = {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: false,
|
||
pendingText: FINAL_REPLY_TEXT,
|
||
};
|
||
startSlackStreamMock.mockResolvedValueOnce(session);
|
||
stopSlackStreamMock.mockRejectedValueOnce(
|
||
new TestSlackStreamNotDeliveredError(FINAL_REPLY_TEXT, "user_not_found"),
|
||
);
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(postMessageMock).not.toHaveBeenCalled();
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||
expect(session.stopped).toBe(true);
|
||
});
|
||
|
||
it("routes pending native stream text through chunked sender for unexpected finalize failures", async () => {
|
||
mockedNativeStreaming = true;
|
||
const session = {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: false,
|
||
pendingText: FINAL_REPLY_TEXT,
|
||
};
|
||
startSlackStreamMock.mockResolvedValueOnce(session);
|
||
stopSlackStreamMock.mockRejectedValueOnce(
|
||
new TestSlackStreamNotDeliveredError(
|
||
FINAL_REPLY_TEXT,
|
||
"method_not_supported_for_channel_type",
|
||
),
|
||
);
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(postMessageMock).not.toHaveBeenCalled();
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expect(deliverRepliesMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
replyThreadTs: THREAD_TS,
|
||
replies: [expect.objectContaining({ text: FINAL_REPLY_TEXT })],
|
||
}),
|
||
);
|
||
expect(session.stopped).toBe(true);
|
||
});
|
||
|
||
it("fails dispatch when an unexpected finalize fallback cannot deliver a buffered tail", async () => {
|
||
mockedNativeStreaming = true;
|
||
const session = {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: true,
|
||
pendingText: "buffered tail",
|
||
};
|
||
startSlackStreamMock.mockResolvedValueOnce(session);
|
||
stopSlackStreamMock.mockRejectedValueOnce(
|
||
new TestSlackStreamNotDeliveredError(
|
||
"buffered tail",
|
||
"method_not_supported_for_channel_type",
|
||
),
|
||
);
|
||
deliverRepliesMock.mockRejectedValueOnce(new Error("fallback send failed"));
|
||
|
||
await expect(dispatchPreparedSlackMessage(createPreparedSlackMessage())).rejects.toThrowError(
|
||
"slack-stream not delivered: method_not_supported_for_channel_type",
|
||
);
|
||
|
||
expectDeliverReplyCall(0, "buffered tail");
|
||
expect(recordSlackThreadParticipationMock).toHaveBeenCalledWith(
|
||
expect.any(String),
|
||
"C123",
|
||
THREAD_TS,
|
||
expect.any(Object),
|
||
);
|
||
});
|
||
|
||
it("routes all pending native stream text through chunked sender when an append flush fails", async () => {
|
||
mockedNativeStreaming = true;
|
||
mockedDispatchSequence = [
|
||
{ kind: "block", payload: { text: "first buffered" } },
|
||
{ kind: "final", payload: { text: "second flushes" } },
|
||
];
|
||
const session = {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: false,
|
||
pendingText: "first buffered",
|
||
};
|
||
startSlackStreamMock.mockResolvedValueOnce(session);
|
||
appendSlackStreamMock.mockImplementationOnce(async () => {
|
||
session.pendingText += "\nsecond flushes";
|
||
throw new TestSlackStreamNotDeliveredError(session.pendingText, "user_not_found");
|
||
});
|
||
stopSlackStreamMock.mockRejectedValueOnce(
|
||
new TestSlackStreamNotDeliveredError("first buffered\nsecond flushes", "user_not_found"),
|
||
);
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(postMessageMock).not.toHaveBeenCalled();
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, "first buffered\nsecond flushes");
|
||
expect(stopSlackStreamMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("forwards oversized pending stream text to the chunked sender intact (chunking is the sender's responsibility)", async () => {
|
||
mockedNativeStreaming = true;
|
||
// SLACK_TEXT_LIMIT mocks to 4000; use > 1 message worth of content.
|
||
const oversized = "x".repeat(8500);
|
||
const session = {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: false,
|
||
pendingText: oversized,
|
||
};
|
||
startSlackStreamMock.mockResolvedValueOnce(session);
|
||
stopSlackStreamMock.mockRejectedValueOnce(
|
||
new TestSlackStreamNotDeliveredError(oversized, "team_not_found"),
|
||
);
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
expect(postMessageMock).not.toHaveBeenCalled();
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, oversized, { textLimit: 4000 });
|
||
expect(session.stopped).toBe(true);
|
||
});
|
||
|
||
it("routes full pendingText (earlier buffered + failing chunk) through chunked sender on non-benign append failure", async () => {
|
||
mockedNativeStreaming = true;
|
||
mockedDispatchSequence = [
|
||
{ kind: "block", payload: { text: "first buffered" } },
|
||
{ kind: "final", payload: { text: "second payload" } },
|
||
];
|
||
const session = {
|
||
channel: "C123",
|
||
threadTs: THREAD_TS,
|
||
stopped: false,
|
||
delivered: false,
|
||
pendingText: "first buffered",
|
||
};
|
||
startSlackStreamMock.mockResolvedValueOnce(session);
|
||
// Non-benign error (plain Error, NOT SlackStreamNotDeliveredError).
|
||
// appendSlackStream mutates pendingText BEFORE throwing so the full
|
||
// buffer (earlier chunk + current chunk) must be preserved and routed
|
||
// through the chunked fallback - not dropped or partially re-sent.
|
||
appendSlackStreamMock.mockImplementationOnce(async () => {
|
||
session.pendingText += "\nsecond payload";
|
||
throw new Error("network socket closed");
|
||
});
|
||
stopSlackStreamMock.mockRejectedValueOnce(new Error("stop failed"));
|
||
|
||
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
|
||
|
||
// Chunked fallback sent the FULL pendingText, not just the failing
|
||
// payload (so the earlier buffered chunk is not dropped).
|
||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||
expectDeliverReplyCall(0, "first buffered\nsecond payload");
|
||
// Session was retired after fallback, so finalization cannot resend the
|
||
// Slack SDK's retained private buffer.
|
||
expect(session.pendingText).toBe("");
|
||
expect(session.stopped).toBe(true);
|
||
expect(stopSlackStreamMock).toHaveBeenCalledTimes(1);
|
||
// No raw postMessage path was invoked.
|
||
expect(postMessageMock).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|