test: clear feishu reply dispatcher broad matchers

This commit is contained in:
Peter Steinberger
2026-05-10 11:44:17 +01:00
parent b8a5d76f97
commit d1317cda46

View File

@@ -199,6 +199,64 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
expect(isRecord(value), `${label} must be an object`).toBe(true);
return value as Record<string, unknown>;
}
function expectRecordFields(
value: unknown,
label: string,
expected: Record<string, unknown>,
): Record<string, unknown> {
const record = requireRecord(value, label);
for (const [key, expectedValue] of Object.entries(expected)) {
expect(record[key], `${label}.${key}`).toEqual(expectedValue);
}
return record;
}
function expectMockArgFields(
mock: ReturnType<typeof vi.fn>,
label: string,
expected: Record<string, unknown>,
callIndex = 0,
argIndex = 0,
): Record<string, unknown> {
return expectRecordFields(mock.mock.calls[callIndex]?.[argIndex], label, expected);
}
function expectLastMockArgFields(
mock: ReturnType<typeof vi.fn>,
label: string,
expected: Record<string, unknown>,
argIndex = 0,
): Record<string, unknown> {
const callIndex = mock.mock.calls.length - 1;
return expectMockArgFields(mock, label, expected, callIndex, argIndex);
}
function expectStreamingStartOptions(
instanceIndex: number,
expected: Record<string, unknown>,
): Record<string, unknown> {
const start = streamingInstances[instanceIndex]?.start;
expect(start, "streaming instance must exist").toBeDefined();
expect(start.mock.calls[0]?.[0]).toBe("oc_chat");
expect(start.mock.calls[0]?.[1]).toBe("chat_id");
return expectRecordFields(start.mock.calls[0]?.[2], "streaming start options", expected);
}
function streamingUpdateTexts(instanceIndex = 0): string[] {
return streamingInstances[instanceIndex].update.mock.calls.map((call: unknown[]) =>
typeof call[0] === "string" ? call[0] : "",
);
}
it("skips typing indicator when account typingIndicator is disabled", async () => {
resolveFeishuAccountMock.mockReturnValue({
accountId: "main",
@@ -272,11 +330,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
await options.onReplyStart?.();
expect(addTypingIndicatorMock).toHaveBeenCalledTimes(1);
expect(addTypingIndicatorMock).toHaveBeenCalledWith(
expect.objectContaining({
messageId: "om_parent",
}),
);
expectMockArgFields(addTypingIndicatorMock, "typing indicator params", {
messageId: "om_parent",
});
});
it("keeps auto mode plain text on non-streaming send path", async () => {
@@ -413,17 +469,13 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].start).toHaveBeenCalledWith(
"oc_chat",
"chat_id",
expect.objectContaining({
replyToMessageId: undefined,
replyInThread: undefined,
rootId: "om_root_topic",
header: { title: "agent", template: "blue" },
note: "Agent: agent",
}),
);
expectStreamingStartOptions(0, {
replyToMessageId: undefined,
replyInThread: undefined,
rootId: "om_root_topic",
header: { title: "agent", template: "blue" },
note: "Agent: agent",
});
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
@@ -545,11 +597,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/a.png",
}),
);
expectMockArgFields(sendMediaFeishuMock, "media send params", {
mediaUrl: "https://example.com/a.png",
});
});
it("suppresses duplicate final text while still sending media", async () => {
@@ -561,17 +611,13 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
);
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMessageFeishuMock).toHaveBeenLastCalledWith(
expect.objectContaining({
text: "plain final",
}),
);
expectLastMockArgFields(sendMessageFeishuMock, "message send params", {
text: "plain final",
});
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/a.png",
}),
);
expectMockArgFields(sendMediaFeishuMock, "media send params", {
mediaUrl: "https://example.com/a.png",
});
});
it("keeps distinct non-streaming final payloads", async () => {
@@ -580,13 +626,16 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
await options.deliver({ text: "actual answer body" }, { kind: "final" });
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(2);
expect(sendMessageFeishuMock).toHaveBeenNthCalledWith(
expectMockArgFields(sendMessageFeishuMock, "first message send params", {
text: "notice header",
});
expectMockArgFields(
sendMessageFeishuMock,
"second message send params",
{
text: "actual answer body",
},
1,
expect.objectContaining({ text: "notice header" }),
);
expect(sendMessageFeishuMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ text: "actual answer body" }),
);
});
@@ -707,12 +756,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "oc_chat",
mediaUrl: "https://example.com/a.png",
}),
);
expectMockArgFields(sendMediaFeishuMock, "media send params", {
to: "oc_chat",
mediaUrl: "https://example.com/a.png",
});
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
});
@@ -724,12 +771,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
{ kind: "final" },
);
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
}),
);
expectMockArgFields(sendMediaFeishuMock, "media send params", {
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
});
});
it("suppresses duplicate text when final replies send voice media", async () => {
@@ -746,12 +791,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
}),
);
expectMockArgFields(sendMediaFeishuMock, "media send params", {
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
});
});
it("sends skipped voice text when final voice media degrades to a file attachment", async () => {
@@ -771,18 +814,14 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
);
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
}),
);
expectMockArgFields(sendMediaFeishuMock, "media send params", {
mediaUrl: "https://example.com/reply.mp3",
audioAsVoice: true,
});
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
text: "spoken reply",
}),
);
expectMockArgFields(sendMessageFeishuMock, "message send params", {
text: "spoken reply",
});
});
it("suppresses duplicate text for native voice media without audioAsVoice", async () => {
@@ -797,11 +836,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/reply.opus?download=1",
}),
);
expectMockArgFields(sendMediaFeishuMock, "media send params", {
mediaUrl: "https://example.com/reply.opus?download=1",
});
});
it("preserves captions for regular audio attachments", async () => {
@@ -815,17 +852,13 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
);
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
text: "caption text",
}),
);
expectMockArgFields(sendMessageFeishuMock, "message send params", {
text: "caption text",
});
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/song.mp3",
}),
);
expectMockArgFields(sendMediaFeishuMock, "media send params", {
mediaUrl: "https://example.com/song.mp3",
});
});
it("keeps skipped voice text in the upload failure fallback", async () => {
@@ -842,11 +875,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
);
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
text: "spoken reply\n\n📎 https://example.com/reply.mp3",
}),
);
expectMockArgFields(sendMessageFeishuMock, "message send params", {
text: "spoken reply\n\n📎 https://example.com/reply.mp3",
});
});
it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => {
@@ -858,11 +889,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/a.png",
}),
);
expectMockArgFields(sendMediaFeishuMock, "media send params", {
mediaUrl: "https://example.com/a.png",
});
});
it("sends attachments after streaming final markdown replies", async () => {
@@ -879,11 +908,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
mediaUrl: "https://example.com/a.png",
}),
);
expectMockArgFields(sendMediaFeishuMock, "media send params", {
mediaUrl: "https://example.com/a.png",
});
});
it("passes replyInThread to sendMessageFeishu for plain text", async () => {
@@ -893,12 +920,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
});
await options.deliver({ text: "plain text" }, { kind: "final" });
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_msg",
replyInThread: true,
}),
);
expectMockArgFields(sendMessageFeishuMock, "message send params", {
replyToMessageId: "om_msg",
replyInThread: true,
});
});
it("passes replyInThread to sendStructuredCardFeishu for card text", async () => {
@@ -919,12 +944,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
});
await options.deliver({ text: "card text" }, { kind: "final" });
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_msg",
replyInThread: true,
}),
);
expectMockArgFields(sendStructuredCardFeishuMock, "structured card params", {
replyToMessageId: "om_msg",
replyInThread: true,
});
});
it("streams reasoning content as blockquote before answer", async () => {
@@ -1074,16 +1097,12 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].start).toHaveBeenCalledWith(
"oc_chat",
"chat_id",
expect.objectContaining({
replyToMessageId: "om_msg",
replyInThread: true,
header: { title: "agent", template: "blue" },
note: "Agent: agent",
}),
);
expectStreamingStartOptions(0, {
replyToMessageId: "om_msg",
replyInThread: true,
header: { title: "agent", template: "blue" },
note: "Agent: agent",
});
});
it("uses streaming cards for thread replies and keeps topic metadata", async () => {
@@ -1097,15 +1116,11 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].start).toHaveBeenCalledWith(
"oc_chat",
"chat_id",
expect.objectContaining({
replyToMessageId: "om_msg",
replyInThread: true,
rootId: "om_root_topic",
}),
);
expectStreamingStartOptions(0, {
replyToMessageId: "om_msg",
replyInThread: true,
rootId: "om_root_topic",
});
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
});
@@ -1128,13 +1143,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
await options.deliver({ text: "streamed card" }, { kind: "final" });
await options.onIdle?.();
expect(streamingInstances[0].start).toHaveBeenCalledWith(
"oc_chat",
"chat_id",
expect.objectContaining({
header: undefined,
}),
);
expectStreamingStartOptions(0, {
header: undefined,
});
resolveFeishuAccountMock.mockReturnValue({
accountId: "main",
@@ -1153,11 +1164,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
});
await staticOptions.deliver({ text: "static card" }, { kind: "final" });
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
header: undefined,
}),
);
expectLastMockArgFields(sendStructuredCardFeishuMock, "structured card params", {
header: undefined,
});
});
it("shows shared transient tool status on streaming cards but omits it from the final close", async () => {
@@ -1180,10 +1189,8 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
result.replyOptions.onPartialReply?.({ text: "final answer" });
await options.onIdle?.();
const updateTexts = streamingInstances[0].update.mock.calls.map((call: unknown[]) =>
typeof call[0] === "string" ? call[0] : "",
);
expect(updateTexts).toEqual(expect.arrayContaining([expect.stringContaining("🔎 Web Search")]));
const updateTexts = streamingUpdateTexts();
expect(updateTexts.some((text) => text.includes("🔎 Web Search"))).toBe(true);
expect(streamingInstances[0].close).toHaveBeenCalledWith("final answer", {
note: "Agent: agent",
});
@@ -1213,14 +1220,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
result.replyOptions.onPartialReply?.({ text: "final answer" });
await options.onIdle?.();
const updateTexts = streamingInstances[0].update.mock.calls.map((call: unknown[]) =>
typeof call[0] === "string" ? call[0] : "",
);
expect(updateTexts).toEqual(
expect.arrayContaining([
expect.stringContaining("🛠️ run tests, `pnpm test -- --watch=false`"),
]),
);
const updateTexts = streamingUpdateTexts();
expect(
updateTexts.some((text) => text.includes("🛠️ run tests, `pnpm test -- --watch=false`")),
).toBe(true);
});
it("omits message-like tools from streaming card status", async () => {
@@ -1243,10 +1246,8 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
result.replyOptions.onPartialReply?.({ text: "final answer" });
await options.onIdle?.();
const updateTexts = streamingInstances[0].update.mock.calls.map((call: unknown[]) =>
typeof call[0] === "string" ? call[0] : "",
);
expect(updateTexts).not.toEqual(expect.arrayContaining([expect.stringContaining("Message")]));
const updateTexts = streamingUpdateTexts();
expect(updateTexts.some((text) => text.includes("Message"))).toBe(false);
});
it("does not suppress a later final after error closeout", async () => {
@@ -1366,12 +1367,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
});
await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_msg",
replyInThread: true,
}),
);
expectMockArgFields(sendMediaFeishuMock, "media send params", {
replyToMessageId: "om_msg",
replyInThread: true,
});
});
it("backs off streaming retries after start() throws (HTTP 400)", async () => {
@@ -1406,7 +1405,12 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
// Wait for the async error to propagate
await vi.waitFor(() => {
expect(errorMock).toHaveBeenCalledWith(expect.stringContaining("streaming start failed"));
expect(
errorMock.mock.calls.some(
([message]) =>
typeof message === "string" && message.includes("streaming start failed"),
),
).toBe(true);
});
expect(streamingInstances).toHaveLength(1);
expect(sendStructuredCardFeishuMock).toHaveBeenCalledTimes(1);