openclaw / src /channels /message /lifecycle.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
5cb63c1 verified
Raw History Blame Contribute Delete
22.4 kB
// Message lifecycle tests cover channel message state transitions and notifications.
import { describe, expect, it, vi } from "vitest";
import {
createLiveMessageState,
defineFinalizableLivePreviewAdapter,
deliverFinalizableLivePreview,
deliverWithFinalizableLivePreviewAdapter,
markLiveMessagePreviewUpdated,
} from "./live.js";
import { createMessageReceiveContext } from "./receive.js";
type LivePreviewMediaPayload = { text?: string; mediaUrl: string };
type LivePreviewMediaEdit = { text?: string };
function requireMockCall(
mock: { mock: { calls: unknown[][] } },
callIndex: number,
label: string,
): unknown[] {
const resolvedIndex = callIndex < 0 ? mock.mock.calls.length + callIndex : callIndex;
const call = mock.mock.calls[resolvedIndex];
if (!call) {
throw new Error(`expected ${label} call ${callIndex}`);
}
return call;
}
describe("message lifecycle primitives", () => {
it("tracks live preview rendered batch updates", () => {
const preview = createLiveMessageState();
const rendered = {
payloads: [{ text: "draft" }],
plan: {
payloadCount: 1,
textCount: 1,
mediaCount: 0,
voiceCount: 0,
presentationCount: 0,
interactiveCount: 0,
channelDataCount: 0,
items: [{ index: 0, kinds: ["text"] as const, text: "draft", mediaUrls: [] }],
},
};
const updated = markLiveMessagePreviewUpdated(preview, rendered);
expect(updated.phase).toBe("previewing");
expect(updated.lastRendered).toBe(rendered);
});
it("finalizes live previews in place with preview receipts", async () => {
const editFinal = vi.fn(async () => undefined);
const deliverNormally = vi.fn(async () => undefined);
const onPreviewFinalized = vi.fn(async () => undefined);
const result = await deliverFinalizableLivePreview({
kind: "final",
payload: { text: "done" },
draft: {
flush: vi.fn(async () => undefined),
id: () => "preview-1",
seal: vi.fn(async () => undefined),
clear: vi.fn(async () => undefined),
},
buildFinalEdit: (payload) => ({ text: payload.text }),
editFinal,
deliverNormally,
onPreviewFinalized,
});
expect(result.kind).toBe("preview-finalized");
expect(editFinal).toHaveBeenCalledWith("preview-1", { text: "done" });
expect(deliverNormally).not.toHaveBeenCalled();
const liveState = result.liveState;
if (!liveState) {
throw new Error("expected finalized live state");
}
expect(liveState.phase).toBe("finalized");
expect(liveState.canFinalizeInPlace).toBe(false);
expect(liveState.receipt?.primaryPlatformMessageId).toBe("preview-1");
expect(liveState.receipt?.platformMessageIds).toEqual(["preview-1"]);
expect(onPreviewFinalized).toHaveBeenCalledTimes(1);
const [previewId, receiptArg, stateArg] = requireMockCall(
onPreviewFinalized,
0,
"preview finalized",
) as [string, { primaryPlatformMessageId?: string }, unknown];
expect(previewId).toBe("preview-1");
expect(receiptArg.primaryPlatformMessageId).toBe("preview-1");
expect(stateArg).toBe(liveState);
});
it("delivers supplemental payloads after finalizing live previews", async () => {
const editFinal = vi.fn(async () => undefined);
const deliverNormally = vi.fn(async () => undefined);
const deliverSupplemental = vi.fn(async () => true);
const result = await deliverFinalizableLivePreview<
LivePreviewMediaPayload,
string,
LivePreviewMediaEdit
>({
kind: "final",
payload: { text: "done", mediaUrl: "file:///tmp/reply.mp3" },
draft: {
flush: vi.fn(async () => undefined),
id: () => "preview-1",
seal: vi.fn(async () => undefined),
clear: vi.fn(async () => undefined),
},
buildFinalEdit: (payload) => ({ text: payload.text }),
buildSupplementalPayload: (payload) => ({ mediaUrl: payload.mediaUrl }),
editFinal,
deliverNormally,
deliverSupplemental,
});
expect(result.kind).toBe("preview-finalized");
expect(editFinal).toHaveBeenCalledWith("preview-1", { text: "done" });
expect(deliverNormally).not.toHaveBeenCalled();
expect(deliverSupplemental).toHaveBeenCalledWith({ mediaUrl: "file:///tmp/reply.mp3" });
});
it("falls back to normal supplemental delivery when its dedicated sender reports no send", async () => {
const deliverNormally = vi.fn(async () => true);
const deliverSupplemental = vi.fn(async () => false);
const result = await deliverFinalizableLivePreview<
LivePreviewMediaPayload,
string,
LivePreviewMediaEdit
>({
kind: "final",
payload: { text: "done", mediaUrl: "file:///tmp/reply.mp3" },
draft: {
flush: vi.fn(async () => undefined),
id: () => "preview-supplement-fallback",
clear: vi.fn(async () => undefined),
},
buildFinalEdit: (payload) => ({ text: payload.text }),
editFinal: vi.fn(async () => undefined),
buildSupplementalPayload: (payload) => ({ mediaUrl: payload.mediaUrl }),
deliverSupplemental,
deliverNormally,
});
expect(result.kind).toBe("preview-finalized");
expect(deliverSupplemental).toHaveBeenCalledWith({ mediaUrl: "file:///tmp/reply.mp3" });
expect(deliverNormally).toHaveBeenCalledWith({ mediaUrl: "file:///tmp/reply.mp3" });
});
it("uses normal delivery when a finalized preview has no supplemental sender", async () => {
const deliverNormally = vi.fn(async () => true);
const result = await deliverFinalizableLivePreview<
LivePreviewMediaPayload,
string,
LivePreviewMediaEdit
>({
kind: "final",
payload: { text: "done", mediaUrl: "file:///tmp/reply.mp3" },
draft: {
flush: vi.fn(async () => undefined),
id: () => "preview-no-supplement-sender",
clear: vi.fn(async () => undefined),
},
buildFinalEdit: (payload) => ({ text: payload.text }),
editFinal: vi.fn(async () => undefined),
buildSupplementalPayload: (payload) => ({ mediaUrl: payload.mediaUrl }),
deliverNormally,
});
expect(result.kind).toBe("preview-finalized");
expect(deliverNormally).toHaveBeenCalledWith({ mediaUrl: "file:///tmp/reply.mp3" });
});
it("surfaces supplemental delivery failure after both sender paths report no send", async () => {
await expect(
deliverFinalizableLivePreview<LivePreviewMediaPayload, string, LivePreviewMediaEdit>({
kind: "final",
payload: { text: "done", mediaUrl: "file:///tmp/reply.mp3" },
draft: {
flush: vi.fn(async () => undefined),
id: () => "preview-supplement-unsent",
clear: vi.fn(async () => undefined),
},
buildFinalEdit: (payload) => ({ text: payload.text }),
editFinal: vi.fn(async () => undefined),
buildSupplementalPayload: (payload) => ({ mediaUrl: payload.mediaUrl }),
deliverSupplemental: vi.fn(async () => false),
deliverNormally: vi.fn(async () => false),
}),
).rejects.toThrow("Live preview supplemental payload was not delivered");
});
it("treats live preview fallback delivery as terminal state", async () => {
const discardPending = vi.fn(async () => undefined);
const clear = vi.fn(async () => undefined);
const deliverNormally = vi.fn(async () => true);
const onNormalDelivered = vi.fn(async () => undefined);
const result = await deliverFinalizableLivePreview({
kind: "final",
payload: { text: "with media" },
draft: {
flush: vi.fn(async () => undefined),
id: () => "preview-2",
discardPending,
clear,
},
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => undefined),
deliverNormally,
onNormalDelivered,
});
expect(result.kind).toBe("normal-delivered");
expect(discardPending).toHaveBeenCalledTimes(1);
expect(deliverNormally).toHaveBeenCalledWith({ text: "with media" });
expect(onNormalDelivered).toHaveBeenCalledTimes(1);
expect(clear).toHaveBeenCalledTimes(1);
const liveState = result.liveState;
if (!liveState) {
throw new Error("expected fallback live state");
}
expect(liveState.phase).toBe("cancelled");
expect(liveState.canFinalizeInPlace).toBe(false);
});
it.each(["shared finalizer", "exported adapter"] as const)(
"preserves committed normal delivery when preview cleanup fails through the %s",
async (entrypoint) => {
const events: string[] = [];
const cleanupError = new Error("recipient text and fake-secret must stay private");
const draft = {
flush: vi.fn(async () => undefined),
id: () => "preview-cleanup-failure",
discardPending: vi.fn(async () => {
events.push("discard");
}),
clear: vi.fn(async () => {
events.push("clear");
throw cleanupError;
}),
};
const deliverNormally = vi.fn(async () => {
events.push("deliver");
return true;
});
const onNormalDelivered = vi.fn(async () => {
events.push("commit");
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const result =
entrypoint === "shared finalizer"
? await deliverFinalizableLivePreview({
kind: "final",
payload: { text: "already delivered" },
draft,
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => undefined),
deliverNormally,
onNormalDelivered,
})
: await deliverWithFinalizableLivePreviewAdapter({
kind: "final",
payload: { text: "already delivered" },
adapter: defineFinalizableLivePreviewAdapter({
draft,
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => undefined),
}),
deliverNormally,
onNormalDelivered,
});
expect(result.kind).toBe("normal-delivered");
expect(events).toEqual(["discard", "deliver", "commit", "clear"]);
expect(deliverNormally).toHaveBeenCalledTimes(1);
expect(onNormalDelivered).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledExactlyOnceWith(
"Live preview cleanup failed after delivery; a stale preview may remain",
);
} finally {
warn.mockRestore();
}
},
);
it("keeps intentionally suppressed fallback delivery skipped without post-delivery cleanup", async () => {
const clear = vi.fn(async () => {
throw new Error("suppressed reply must not be cleaned up as delivered");
});
const onNormalDelivered = vi.fn(async () => undefined);
const result = await deliverFinalizableLivePreview({
kind: "final",
payload: { text: "suppressed" },
draft: {
flush: vi.fn(async () => undefined),
id: () => "suppressed-preview",
discardPending: vi.fn(async () => undefined),
clear,
},
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => undefined),
deliverNormally: vi.fn(async () => false),
onNormalDelivered,
});
expect(result.kind).toBe("normal-skipped");
expect(onNormalDelivered).not.toHaveBeenCalled();
expect(clear).not.toHaveBeenCalled();
});
it("keeps preview cleanup failures fatal before normal delivery starts", async () => {
const deliverNormally = vi.fn(async () => true);
const onNormalDelivered = vi.fn(async () => undefined);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
await expect(
deliverFinalizableLivePreview({
kind: "final",
payload: { text: "not delivered" },
draft: {
flush: vi.fn(async () => undefined),
id: () => "pre-delivery-preview",
clear: vi.fn(async () => {
throw new Error("pre-delivery cleanup failed");
}),
},
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => undefined),
deliverNormally,
onNormalDelivered,
}),
).rejects.toThrow("pre-delivery cleanup failed");
expect(deliverNormally).not.toHaveBeenCalled();
expect(onNormalDelivered).not.toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
} finally {
warn.mockRestore();
}
});
it("preserves a delivery-commit failure when later preview cleanup also fails", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
await expect(
deliverFinalizableLivePreview({
kind: "final",
payload: { text: "already accepted" },
draft: {
flush: vi.fn(async () => undefined),
id: () => "commit-failure-preview",
discardPending: vi.fn(async () => undefined),
clear: vi.fn(async () => {
throw new Error("preview cleanup must not replace delivery failure");
}),
},
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => undefined),
deliverNormally: vi.fn(async () => true),
onNormalDelivered: vi.fn(async () => {
throw new Error("delivery commit failed");
}),
}),
).rejects.toThrow("delivery commit failed");
expect(warn).toHaveBeenCalledTimes(1);
} finally {
warn.mockRestore();
}
});
it("does not complete live preview fallback state when normal delivery throws", async () => {
const discardPending = vi.fn(async () => undefined);
const clear = vi.fn(async () => undefined);
const onNormalDelivered = vi.fn(async () => undefined);
await expect(
deliverFinalizableLivePreview({
kind: "final",
payload: { text: "with media" },
draft: {
flush: vi.fn(async () => undefined),
id: () => "preview-2",
discardPending,
clear,
},
buildFinalEdit: () => undefined,
editFinal: vi.fn(async () => undefined),
deliverNormally: vi.fn(async () => {
throw new Error("send failed");
}),
onNormalDelivered,
}),
).rejects.toThrow("send failed");
expect(discardPending).toHaveBeenCalledTimes(1);
expect(onNormalDelivered).not.toHaveBeenCalled();
expect(clear).not.toHaveBeenCalled();
});
it("delivers through finalizable live preview adapters", async () => {
const editFinal = vi.fn(async () => undefined);
const adapter = defineFinalizableLivePreviewAdapter({
draft: {
flush: vi.fn(async () => undefined),
id: () => "preview-adapter-1",
clear: vi.fn(async () => undefined),
},
buildFinalEdit: (payload: { text: string }) => ({ text: payload.text.toUpperCase() }),
editFinal,
});
const result = await deliverWithFinalizableLivePreviewAdapter({
kind: "final",
payload: { text: "done" },
adapter,
deliverNormally: vi.fn(async () => undefined),
});
expect(result.kind).toBe("preview-finalized");
expect(editFinal).toHaveBeenCalledWith("preview-adapter-1", { text: "DONE" });
});
it("lets live preview adapters resolve the committed platform id after final edit", async () => {
const adapter = defineFinalizableLivePreviewAdapter({
draft: {
flush: vi.fn(async () => undefined),
id: () => "preview-before-edit",
clear: vi.fn(async () => undefined),
},
buildFinalEdit: (payload: { text: string }) => ({ text: payload.text }),
editFinal: vi.fn(async () => undefined),
resolveFinalizedId: () => "message-after-edit",
});
const result = await deliverWithFinalizableLivePreviewAdapter({
kind: "final",
payload: { text: "done" },
adapter,
deliverNormally: vi.fn(async () => undefined),
});
expect(result.liveState?.receipt?.primaryPlatformMessageId).toBe("message-after-edit");
});
it("falls back to normal delivery when no live preview adapter is available", async () => {
const deliverNormally = vi.fn(async () => undefined);
const result = await deliverWithFinalizableLivePreviewAdapter({
kind: "final",
payload: { text: "plain" },
deliverNormally,
});
expect(result.kind).toBe("normal-delivered");
expect(deliverNormally).toHaveBeenCalledWith({ text: "plain" });
});
it("lets live preview adapters retain ambiguous failed final edits without fallback send", async () => {
const deliverNormally = vi.fn(async () => undefined);
const handlePreviewEditError = vi.fn(() => "retain" as const);
const editError = new Error("timeout after request");
const adapter = defineFinalizableLivePreviewAdapter({
draft: {
flush: vi.fn(async () => undefined),
id: () => "preview-maybe-final",
clear: vi.fn(async () => undefined),
},
buildFinalEdit: (payload: { text: string }) => ({ text: payload.text }),
editFinal: vi.fn(async () => {
throw editError;
}),
handlePreviewEditError,
});
const result = await deliverWithFinalizableLivePreviewAdapter({
kind: "final",
payload: { text: "done" },
adapter,
deliverNormally,
});
expect(result.kind).toBe("preview-retained");
expect(result.liveState?.phase).toBe("previewing");
expect(deliverNormally).not.toHaveBeenCalled();
expect(handlePreviewEditError).toHaveBeenCalledTimes(1);
const [editErrorContext] = requireMockCall(handlePreviewEditError, 0, "preview edit error") as [
{ error: unknown; id?: string; edit?: unknown; payload?: unknown },
];
expect(editErrorContext.error).toBe(editError);
expect(editErrorContext.id).toBe("preview-maybe-final");
expect(editErrorContext.edit).toEqual({ text: "done" });
expect(editErrorContext.payload).toEqual({ text: "done" });
});
it("does not fallback-send after a successful preview edit when finalization hooks fail", async () => {
const deliverNormally = vi.fn(async () => undefined);
const onPreviewFinalized = vi.fn(async () => {
throw new Error("receipt side effect failed");
});
const editFinal = vi.fn(async () => undefined);
await expect(
deliverFinalizableLivePreview({
kind: "final",
payload: { text: "done" },
draft: {
flush: vi.fn(async () => undefined),
id: () => "preview-finalized-before-hook",
seal: vi.fn(async () => undefined),
clear: vi.fn(async () => undefined),
},
buildFinalEdit: (payload) => ({ text: payload.text }),
editFinal,
deliverNormally,
onPreviewFinalized,
}),
).rejects.toThrow("receipt side effect failed");
expect(editFinal).toHaveBeenCalledWith("preview-finalized-before-hook", { text: "done" });
expect(deliverNormally).not.toHaveBeenCalled();
});
it("creates receive contexts with explicit ack policy defaults", () => {
const ctx = createMessageReceiveContext({
id: "rx-1",
channel: "telegram",
message: { text: "hello" },
receivedAt: 123,
});
expect(ctx.id).toBe("rx-1");
expect(ctx.channel).toBe("telegram");
expect(ctx.message).toEqual({ text: "hello" });
expect(ctx.ackPolicy).toBe("after_receive_record");
expect(ctx.ackState).toBe("pending");
expect(ctx.receivedAt).toBe(123);
});
it("acks and nacks receive contexts through explicit hooks", async () => {
const onAck = vi.fn(async () => undefined);
const onNack = vi.fn(async () => undefined);
const ctx = createMessageReceiveContext({
id: "rx-ack",
channel: "telegram",
message: { text: "hello" },
ackPolicy: "after_durable_send",
onAck,
onNack,
});
expect(ctx.shouldAckAfter("receive_record")).toBe(false);
expect(ctx.shouldAckAfter("durable_send")).toBe(true);
const beforeAck = Date.now();
await ctx.ack();
await ctx.ack();
expect(onAck).toHaveBeenCalledTimes(1);
expect(ctx.ackState).toBe("acked");
expect(ctx.ackedAt).toBeGreaterThanOrEqual(beforeAck);
const nackError = new Error("offset failed");
await ctx.nack(nackError);
await ctx.nack(new Error("duplicate failure"));
expect(onNack).toHaveBeenCalledTimes(1);
expect(onNack).toHaveBeenCalledWith(nackError);
expect(ctx.ackState).toBe("nacked");
expect(ctx.nackErrorMessage).toBe("offset failed");
});
it("retries nack callbacks after a failed attempt", async () => {
const onNack = vi
.fn<(error: unknown) => Promise<void>>()
.mockRejectedValueOnce(new Error("temporary failure"))
.mockResolvedValueOnce();
const ctx = createMessageReceiveContext({
id: "rx-nack-retry",
channel: "telegram",
message: { text: "hello" },
onNack,
});
await expect(ctx.nack(new Error("first failure"))).rejects.toThrow("temporary failure");
expect(ctx.ackState).toBe("pending");
const retryError = new Error("retry failure");
await ctx.nack(retryError);
expect(onNack).toHaveBeenCalledTimes(2);
expect(ctx.ackState).toBe("nacked");
expect(ctx.nackErrorMessage).toBe("retry failure");
});
it("coalesces overlapping nack callbacks and retains the first error", async () => {
let resolveNack: (() => void) | undefined;
const onNack = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveNack = resolve;
}),
);
const ctx = createMessageReceiveContext({
id: "rx-nack-overlap",
channel: "telegram",
message: { text: "hello" },
onNack,
});
const firstError = new Error("first failure");
const first = ctx.nack(firstError);
const duplicate = ctx.nack(new Error("duplicate failure"));
await vi.waitFor(() => expect(onNack).toHaveBeenCalledOnce());
resolveNack?.();
await Promise.all([first, duplicate]);
expect(onNack).toHaveBeenCalledWith(firstError);
expect(ctx.ackState).toBe("nacked");
expect(ctx.nackErrorMessage).toBe("first failure");
});
});