mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-08 23:33:57 +00:00
* Fix Slack retry for session init conflicts * fix(slack): separate native and relay retries * style(slack): make completion exits explicit --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
413 lines
13 KiB
TypeScript
413 lines
13 KiB
TypeScript
// Slack tests cover message handler plugin behavior.
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const enqueueMock = vi.fn(async (_entry: unknown) => {});
|
|
const flushKeyMock = vi.fn(async (_key: string) => {});
|
|
const onFlushCallbacks: Array<(entries: Array<Record<string, unknown>>) => Promise<void>> = [];
|
|
const prepareSlackMessageMock = vi.fn(async () => ({ ctxPayload: {} }));
|
|
const dispatchPreparedSlackMessageMock = vi.fn(async () => {});
|
|
const hasSlackInboundMessageDeliveryMock = vi.fn(async () => false);
|
|
const recordSlackInboundMessageDeliveriesMock = vi.fn(async () => {});
|
|
const resolveThreadTsMock = vi.fn(async ({ message }: { message: Record<string, unknown> }) => ({
|
|
...message,
|
|
}));
|
|
const { createSlackMessageHandler } = await import("./message-handler.js");
|
|
|
|
vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
|
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/channel-inbound")>(
|
|
"openclaw/plugin-sdk/channel-inbound",
|
|
);
|
|
return {
|
|
...actual,
|
|
createChannelInboundDebouncer: (params: {
|
|
onFlush: (entries: Array<Record<string, unknown>>) => Promise<void>;
|
|
}) => {
|
|
onFlushCallbacks.push(params.onFlush);
|
|
return {
|
|
debounceMs: 10,
|
|
debouncer: {
|
|
enqueue: (entry: unknown) => enqueueMock(entry),
|
|
flushKey: (key: string) => flushKeyMock(key),
|
|
},
|
|
};
|
|
},
|
|
shouldDebounceTextInbound: ({ hasMedia }: { hasMedia?: boolean }) => !hasMedia,
|
|
};
|
|
});
|
|
|
|
vi.mock("./thread-resolution.js", () => ({
|
|
createSlackThreadTsResolver: () => ({
|
|
resolve: (entry: { message: Record<string, unknown> }) => resolveThreadTsMock(entry),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("./message-handler/pipeline.runtime.js", () => ({
|
|
prepareSlackMessage: prepareSlackMessageMock,
|
|
dispatchPreparedSlackMessage: dispatchPreparedSlackMessageMock,
|
|
}));
|
|
|
|
vi.mock("./inbound-delivery-state.js", () => ({
|
|
hasSlackInboundMessageDelivery: hasSlackInboundMessageDeliveryMock,
|
|
recordSlackInboundMessageDeliveries: recordSlackInboundMessageDeliveriesMock,
|
|
}));
|
|
|
|
function createContext(overrides?: {
|
|
markMessageSeen?: (channel: string | undefined, ts: string | undefined) => boolean;
|
|
releaseSeenMessage?: (channel: string | undefined, ts: string | undefined) => void;
|
|
}) {
|
|
return {
|
|
cfg: {},
|
|
accountId: "default",
|
|
app: {
|
|
client: {},
|
|
},
|
|
runtime: {},
|
|
markMessageSeen: (channel: string | undefined, ts: string | undefined) =>
|
|
overrides?.markMessageSeen?.(channel, ts) ?? false,
|
|
releaseSeenMessage: (channel: string | undefined, ts: string | undefined) =>
|
|
overrides?.releaseSeenMessage?.(channel, ts),
|
|
} as Parameters<typeof createSlackMessageHandler>[0]["ctx"];
|
|
}
|
|
|
|
function createHandlerWithTracker(overrides?: {
|
|
markMessageSeen?: (channel: string | undefined, ts: string | undefined) => boolean;
|
|
releaseSeenMessage?: (channel: string | undefined, ts: string | undefined) => void;
|
|
}) {
|
|
const trackEvent = vi.fn();
|
|
const handler = createSlackMessageHandler({
|
|
ctx: createContext(overrides),
|
|
account: { accountId: "default" } as Parameters<typeof createSlackMessageHandler>[0]["account"],
|
|
trackEvent,
|
|
});
|
|
return { handler, trackEvent };
|
|
}
|
|
|
|
async function handleDirectMessage(
|
|
handler: ReturnType<typeof createHandlerWithTracker>["handler"],
|
|
) {
|
|
await handler(
|
|
{
|
|
type: "message",
|
|
channel: "D1",
|
|
ts: "123.456",
|
|
text: "hello",
|
|
} as never,
|
|
{ source: "message" },
|
|
);
|
|
}
|
|
|
|
describe("createSlackMessageHandler", () => {
|
|
beforeEach(() => {
|
|
enqueueMock.mockClear();
|
|
flushKeyMock.mockClear();
|
|
onFlushCallbacks.length = 0;
|
|
prepareSlackMessageMock.mockClear();
|
|
dispatchPreparedSlackMessageMock.mockClear();
|
|
hasSlackInboundMessageDeliveryMock.mockReset();
|
|
hasSlackInboundMessageDeliveryMock.mockResolvedValue(false);
|
|
recordSlackInboundMessageDeliveriesMock.mockClear();
|
|
resolveThreadTsMock.mockClear();
|
|
});
|
|
|
|
it("does not track invalid non-message events from the message stream", async () => {
|
|
const trackEvent = vi.fn();
|
|
const handler = createSlackMessageHandler({
|
|
ctx: createContext(),
|
|
account: { accountId: "default" } as Parameters<
|
|
typeof createSlackMessageHandler
|
|
>[0]["account"],
|
|
trackEvent,
|
|
});
|
|
|
|
await handler(
|
|
{
|
|
type: "reaction_added",
|
|
channel: "D1",
|
|
ts: "123.456",
|
|
} as never,
|
|
{ source: "message" },
|
|
);
|
|
|
|
expect(trackEvent).not.toHaveBeenCalled();
|
|
expect(resolveThreadTsMock).not.toHaveBeenCalled();
|
|
expect(enqueueMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not track duplicate messages that are already seen", async () => {
|
|
const { handler, trackEvent } = createHandlerWithTracker({ markMessageSeen: () => true });
|
|
|
|
await handleDirectMessage(handler);
|
|
|
|
expect(trackEvent).not.toHaveBeenCalled();
|
|
expect(resolveThreadTsMock).not.toHaveBeenCalled();
|
|
expect(enqueueMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("tracks accepted non-duplicate messages", async () => {
|
|
const { handler, trackEvent } = createHandlerWithTracker();
|
|
|
|
await handleDirectMessage(handler);
|
|
|
|
expect(trackEvent).toHaveBeenCalledTimes(1);
|
|
expect(resolveThreadTsMock).toHaveBeenCalledTimes(1);
|
|
expect(enqueueMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("accepts thread_broadcast messages from the message stream", async () => {
|
|
const { handler, trackEvent } = createHandlerWithTracker();
|
|
|
|
await handler(
|
|
{
|
|
type: "message",
|
|
subtype: "thread_broadcast",
|
|
channel: "C111",
|
|
user: "U111",
|
|
ts: "1709000000.000300",
|
|
text: "also send to channel",
|
|
thread_ts: "1709000000.000100",
|
|
} as never,
|
|
{ source: "message" },
|
|
);
|
|
|
|
expect(trackEvent).toHaveBeenCalledTimes(1);
|
|
expect(resolveThreadTsMock).toHaveBeenCalledTimes(1);
|
|
expect(enqueueMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("drops message subtypes that do not carry user message text", async () => {
|
|
const { handler, trackEvent } = createHandlerWithTracker();
|
|
|
|
await handler(
|
|
{
|
|
type: "message",
|
|
subtype: "channel_join",
|
|
channel: "C111",
|
|
user: "U111",
|
|
ts: "1709000000.000400",
|
|
text: "<@U111> joined the channel",
|
|
} as never,
|
|
{ source: "message" },
|
|
);
|
|
|
|
expect(trackEvent).not.toHaveBeenCalled();
|
|
expect(resolveThreadTsMock).not.toHaveBeenCalled();
|
|
expect(enqueueMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("flushes pending top-level buffered keys before immediate non-debounce follow-ups", async () => {
|
|
const handler = createSlackMessageHandler({
|
|
ctx: createContext(),
|
|
account: { accountId: "default" } as Parameters<
|
|
typeof createSlackMessageHandler
|
|
>[0]["account"],
|
|
});
|
|
|
|
await handler(
|
|
{
|
|
type: "message",
|
|
channel: "C111",
|
|
user: "U111",
|
|
ts: "1709000000.000100",
|
|
text: "first buffered text",
|
|
} as never,
|
|
{ source: "message" },
|
|
);
|
|
await handler(
|
|
{
|
|
type: "message",
|
|
subtype: "file_share",
|
|
channel: "C111",
|
|
user: "U111",
|
|
ts: "1709000000.000200",
|
|
text: "file follows",
|
|
files: [{ id: "F1" }],
|
|
} as never,
|
|
{ source: "message" },
|
|
);
|
|
|
|
expect(flushKeyMock).toHaveBeenCalledWith("slack:default:C111:1709000000.000100:U111");
|
|
});
|
|
|
|
it("waits for debounced dispatch completion when requested by relay delivery", async () => {
|
|
const { handler } = createHandlerWithTracker();
|
|
const handled = handler(
|
|
{
|
|
type: "message",
|
|
channel: "C111",
|
|
user: "U111",
|
|
ts: "1709000000.000500",
|
|
text: "relay message",
|
|
} as never,
|
|
{ source: "message", awaitDispatch: true },
|
|
);
|
|
|
|
await vi.waitFor(() => expect(enqueueMock).toHaveBeenCalledTimes(1));
|
|
const entry = enqueueMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
let settled = false;
|
|
void handled.then(() => {
|
|
settled = true;
|
|
});
|
|
await Promise.resolve();
|
|
expect(settled).toBe(false);
|
|
|
|
await onFlushCallbacks[0]?.([entry]);
|
|
await expect(handled).resolves.toBeUndefined();
|
|
expect(dispatchPreparedSlackMessageMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("propagates debounced dispatch failures to relay delivery", async () => {
|
|
dispatchPreparedSlackMessageMock.mockRejectedValueOnce(new Error("dispatch failed"));
|
|
const { handler } = createHandlerWithTracker();
|
|
const handled = handler(
|
|
{
|
|
type: "message",
|
|
channel: "C111",
|
|
user: "U111",
|
|
ts: "1709000000.000600",
|
|
text: "relay message",
|
|
} as never,
|
|
{ source: "message", awaitDispatch: true },
|
|
);
|
|
|
|
await vi.waitFor(() => expect(enqueueMock).toHaveBeenCalledTimes(1));
|
|
const entry = enqueueMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
const handledFailure = expect(handled).rejects.toThrow("dispatch failed");
|
|
const flushFailure = expect(onFlushCallbacks[0]?.([entry])).rejects.toThrow("dispatch failed");
|
|
await Promise.all([handledFailure, flushFailure]);
|
|
});
|
|
|
|
it("retries native session initialization conflicts through the delivery gates", async () => {
|
|
const releaseSeenMessage = vi.fn();
|
|
dispatchPreparedSlackMessageMock.mockRejectedValueOnce(
|
|
new Error("Slack dispatch failed", {
|
|
cause: new Error(
|
|
"reply session initialization conflicted for agent:main:main:thread:123.456",
|
|
),
|
|
}),
|
|
);
|
|
const { handler } = createHandlerWithTracker({ releaseSeenMessage });
|
|
await handler(
|
|
{
|
|
type: "message",
|
|
channel: "C111",
|
|
user: "U111",
|
|
ts: "1709000000.000700",
|
|
text: "native message",
|
|
} as never,
|
|
{ source: "message" },
|
|
);
|
|
|
|
const entry = enqueueMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
vi.useFakeTimers();
|
|
try {
|
|
await expect(onFlushCallbacks[0]?.([entry])).rejects.toThrow("Slack dispatch failed");
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
|
|
expect(releaseSeenMessage).toHaveBeenCalledWith("C111", "1709000000.000700");
|
|
expect(recordSlackInboundMessageDeliveriesMock).not.toHaveBeenCalled();
|
|
expect(hasSlackInboundMessageDeliveryMock).toHaveBeenCalledTimes(2);
|
|
expect(enqueueMock).toHaveBeenCalledTimes(2);
|
|
expect(enqueueMock.mock.calls[1]?.[0]).toMatchObject({
|
|
opts: {
|
|
retryAttempt: 1,
|
|
},
|
|
});
|
|
expect(enqueueMock.mock.calls[1]?.[0]).not.toHaveProperty("opts.dispatchCompletion");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("leaves relay session conflict retries to unacknowledged redelivery", async () => {
|
|
const releaseSeenMessage = vi.fn();
|
|
dispatchPreparedSlackMessageMock.mockRejectedValueOnce(
|
|
new Error("Slack dispatch failed", {
|
|
cause: new Error(
|
|
"reply session initialization conflicted for agent:main:main:thread:123.456",
|
|
),
|
|
}),
|
|
);
|
|
const { handler } = createHandlerWithTracker({ releaseSeenMessage });
|
|
const handled = handler(
|
|
{
|
|
type: "message",
|
|
channel: "C111",
|
|
user: "U111",
|
|
ts: "1709000000.000800",
|
|
text: "relay message",
|
|
} as never,
|
|
{ source: "message", awaitDispatch: true },
|
|
);
|
|
|
|
await vi.waitFor(() => expect(enqueueMock).toHaveBeenCalledTimes(1));
|
|
const entry = enqueueMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
vi.useFakeTimers();
|
|
try {
|
|
const handledFailure = expect(handled).rejects.toThrow("Slack dispatch failed");
|
|
const flushFailure = expect(onFlushCallbacks[0]?.([entry])).rejects.toThrow(
|
|
"Slack dispatch failed",
|
|
);
|
|
await Promise.all([handledFailure, flushFailure]);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
|
|
expect(releaseSeenMessage).toHaveBeenCalledWith("C111", "1709000000.000800");
|
|
expect(recordSlackInboundMessageDeliveriesMock).not.toHaveBeenCalled();
|
|
expect(enqueueMock).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("settles an already-delivered relay event without enqueueing", async () => {
|
|
hasSlackInboundMessageDeliveryMock.mockResolvedValueOnce(true);
|
|
const { handler } = createHandlerWithTracker();
|
|
|
|
await expect(
|
|
handler(
|
|
{
|
|
type: "message",
|
|
channel: "C111",
|
|
user: "U111",
|
|
ts: "1709000000.000850",
|
|
text: "relay replay",
|
|
} as never,
|
|
{ source: "message", awaitDispatch: true },
|
|
),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(enqueueMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips a native retry when another delivery already succeeded", async () => {
|
|
dispatchPreparedSlackMessageMock.mockRejectedValueOnce(
|
|
new Error("reply session initialization conflicted for agent:main:main:thread:123.456"),
|
|
);
|
|
hasSlackInboundMessageDeliveryMock.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
|
|
const { handler } = createHandlerWithTracker();
|
|
await handler(
|
|
{
|
|
type: "message",
|
|
channel: "C111",
|
|
user: "U111",
|
|
ts: "1709000000.000900",
|
|
text: "native message",
|
|
} as never,
|
|
{ source: "message" },
|
|
);
|
|
|
|
const entry = enqueueMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
vi.useFakeTimers();
|
|
try {
|
|
await expect(onFlushCallbacks[0]?.([entry])).rejects.toThrow(
|
|
"reply session initialization conflicted",
|
|
);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
|
|
expect(hasSlackInboundMessageDeliveryMock).toHaveBeenCalledTimes(2);
|
|
expect(enqueueMock).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|