2026-02-17 08:43:16 -05:00
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
2026-03-10 13:30:57 -05:00
|
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
2026-02-17 08:43:16 -05:00
|
|
|
import type { RuntimeEnv } from "../../runtime.js";
|
|
|
|
|
import { deliverDiscordReply } from "./reply-delivery.js";
|
2026-02-21 16:14:55 +01:00
|
|
|
import {
|
|
|
|
|
__testing as threadBindingTesting,
|
|
|
|
|
createThreadBindingManager,
|
|
|
|
|
} from "./thread-bindings.js";
|
2026-02-17 08:43:16 -05:00
|
|
|
|
|
|
|
|
const sendMessageDiscordMock = vi.hoisted(() => vi.fn());
|
|
|
|
|
const sendVoiceMessageDiscordMock = vi.hoisted(() => vi.fn());
|
2026-02-21 16:14:55 +01:00
|
|
|
const sendWebhookMessageDiscordMock = vi.hoisted(() => vi.fn());
|
2026-03-03 10:17:33 -06:00
|
|
|
const sendDiscordTextMock = vi.hoisted(() => vi.fn());
|
2026-02-17 08:43:16 -05:00
|
|
|
|
|
|
|
|
vi.mock("../send.js", () => ({
|
|
|
|
|
sendMessageDiscord: (...args: unknown[]) => sendMessageDiscordMock(...args),
|
|
|
|
|
sendVoiceMessageDiscord: (...args: unknown[]) => sendVoiceMessageDiscordMock(...args),
|
2026-02-21 16:14:55 +01:00
|
|
|
sendWebhookMessageDiscord: (...args: unknown[]) => sendWebhookMessageDiscordMock(...args),
|
2026-02-17 08:43:16 -05:00
|
|
|
}));
|
|
|
|
|
|
2026-03-03 10:17:33 -06:00
|
|
|
vi.mock("../send.shared.js", () => ({
|
|
|
|
|
sendDiscordText: (...args: unknown[]) => sendDiscordTextMock(...args),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-02-17 08:43:16 -05:00
|
|
|
describe("deliverDiscordReply", () => {
|
|
|
|
|
const runtime = {} as RuntimeEnv;
|
2026-03-10 13:30:57 -05:00
|
|
|
const cfg = {
|
|
|
|
|
channels: { discord: { token: "test-token" } },
|
|
|
|
|
} as OpenClawConfig;
|
2026-03-13 22:27:33 +00:00
|
|
|
const expectBotSendRetrySuccess = async (status: number, message: string) => {
|
|
|
|
|
sendMessageDiscordMock
|
|
|
|
|
.mockRejectedValueOnce(Object.assign(new Error(message), { status }))
|
|
|
|
|
.mockResolvedValueOnce({ messageId: "msg-1", channelId: "channel-1" });
|
|
|
|
|
|
|
|
|
|
await deliverDiscordReply({
|
|
|
|
|
replies: [{ text: "retry me" }],
|
|
|
|
|
target: "channel:123",
|
|
|
|
|
token: "token",
|
|
|
|
|
runtime,
|
|
|
|
|
cfg,
|
|
|
|
|
textLimit: 2000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(2);
|
|
|
|
|
};
|
2026-02-22 07:37:54 +00:00
|
|
|
const createBoundThreadBindings = async (
|
|
|
|
|
overrides: Partial<{
|
|
|
|
|
threadId: string;
|
|
|
|
|
channelId: string;
|
|
|
|
|
targetSessionKey: string;
|
|
|
|
|
agentId: string;
|
|
|
|
|
label: string;
|
|
|
|
|
webhookId: string;
|
|
|
|
|
webhookToken: string;
|
|
|
|
|
introText: string;
|
|
|
|
|
}> = {},
|
|
|
|
|
) => {
|
|
|
|
|
const threadBindings = createThreadBindingManager({
|
|
|
|
|
accountId: "default",
|
|
|
|
|
persist: false,
|
|
|
|
|
enableSweeper: false,
|
|
|
|
|
});
|
|
|
|
|
await threadBindings.bindTarget({
|
|
|
|
|
threadId: "thread-1",
|
|
|
|
|
channelId: "parent-1",
|
|
|
|
|
targetKind: "subagent",
|
|
|
|
|
targetSessionKey: "agent:main:subagent:child",
|
|
|
|
|
agentId: "main",
|
|
|
|
|
webhookId: "wh_1",
|
|
|
|
|
webhookToken: "tok_1",
|
|
|
|
|
introText: "",
|
|
|
|
|
...overrides,
|
|
|
|
|
});
|
|
|
|
|
return threadBindings;
|
|
|
|
|
};
|
2026-02-17 08:43:16 -05:00
|
|
|
|
|
|
|
|
beforeEach(() => {
|
2026-02-22 00:11:51 +00:00
|
|
|
sendMessageDiscordMock.mockClear().mockResolvedValue({
|
2026-02-17 08:43:16 -05:00
|
|
|
messageId: "msg-1",
|
|
|
|
|
channelId: "channel-1",
|
|
|
|
|
});
|
2026-02-22 00:11:51 +00:00
|
|
|
sendVoiceMessageDiscordMock.mockClear().mockResolvedValue({
|
2026-02-17 08:43:16 -05:00
|
|
|
messageId: "voice-1",
|
|
|
|
|
channelId: "channel-1",
|
|
|
|
|
});
|
2026-02-22 00:11:51 +00:00
|
|
|
sendWebhookMessageDiscordMock.mockClear().mockResolvedValue({
|
2026-02-21 16:14:55 +01:00
|
|
|
messageId: "webhook-1",
|
|
|
|
|
channelId: "thread-1",
|
|
|
|
|
});
|
2026-03-03 10:17:33 -06:00
|
|
|
sendDiscordTextMock.mockClear().mockResolvedValue({
|
|
|
|
|
id: "msg-direct-1",
|
|
|
|
|
channel_id: "channel-1",
|
|
|
|
|
});
|
2026-02-21 16:14:55 +01:00
|
|
|
threadBindingTesting.resetThreadBindingsForTests();
|
2026-02-17 08:43:16 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("routes audioAsVoice payloads through the voice API and sends text separately", async () => {
|
|
|
|
|
await deliverDiscordReply({
|
|
|
|
|
replies: [
|
|
|
|
|
{
|
|
|
|
|
text: "Hello there",
|
|
|
|
|
mediaUrls: ["https://example.com/voice.ogg", "https://example.com/extra.mp3"],
|
|
|
|
|
audioAsVoice: true,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
target: "channel:123",
|
|
|
|
|
token: "token",
|
|
|
|
|
runtime,
|
2026-03-10 13:30:57 -05:00
|
|
|
cfg,
|
2026-02-17 08:43:16 -05:00
|
|
|
textLimit: 2000,
|
|
|
|
|
replyToId: "reply-1",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(sendVoiceMessageDiscordMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(sendVoiceMessageDiscordMock).toHaveBeenCalledWith(
|
|
|
|
|
"channel:123",
|
|
|
|
|
"https://example.com/voice.ogg",
|
|
|
|
|
expect.objectContaining({ token: "token", replyTo: "reply-1" }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(2);
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenNthCalledWith(
|
|
|
|
|
1,
|
|
|
|
|
"channel:123",
|
|
|
|
|
"Hello there",
|
fix(subagent): harden read-tool overflow guards and sticky reply threading (#19508)
* fix(gateway): avoid premature agent.wait completion on transient errors
* fix(agent): preemptively guard tool results against context overflow
* fix: harden tool-result context guard and add message_id metadata
* fix: use importOriginal in session-key mock to include DEFAULT_ACCOUNT_ID
The run.skill-filter test was mocking ../../routing/session-key.js with only
buildAgentMainSessionKey and normalizeAgentId, but the module also exports
DEFAULT_ACCOUNT_ID which is required transitively by src/web/auth-store.ts.
Switch to importOriginal pattern so all real exports are preserved alongside
the mocked functions.
* pi-runner: guard accumulated tool-result overflow in transformContext
* PI runner: compact overflowing tool-result context
* Subagent: harden tool-result context recovery
* Enhance tool-result context handling by adding support for legacy tool outputs and improving character estimation for message truncation. This includes a new function to create legacy tool results and updates to existing functions to better manage context overflow scenarios.
* Enhance iMessage handling by adding reply tag support in send functions and tests. This includes modifications to prepend or rewrite reply tags based on provided replyToId, ensuring proper message formatting for replies.
* Enhance message delivery across multiple channels by implementing sticky reply context for chunked messages. This includes preserving reply references in Discord, Telegram, and iMessage, ensuring that follow-up messages maintain their intended reply targets. Additionally, improve handling of reply tags in system prompts and tests to support consistent reply behavior.
* Enhance read tool functionality by implementing auto-paging across chunks when no explicit limit is provided, scaling output budget based on model context window. Additionally, add tests for adaptive reading behavior and capped continuation guidance for large outputs. Update related functions to support these features.
* Refine tool-result context management by stripping oversized read-tool details payloads during compaction, ensuring repeated read calls do not bypass context limits. Introduce new utility functions for handling truncation content and enhance character estimation for tool results. Add tests to validate the removal of excessive details in context overflow scenarios.
* Refine message delivery logic in Matrix and Telegram by introducing a flag to track if a text chunk was sent. This ensures that replies are only marked as delivered when a text chunk has been successfully sent, improving the accuracy of reply handling in both channels.
* fix: tighten reply threading coverage and prep fixes (#19508) (thanks @tyler6204)
2026-02-17 15:32:52 -08:00
|
|
|
expect.objectContaining({ token: "token", replyTo: "reply-1" }),
|
2026-02-17 08:43:16 -05:00
|
|
|
);
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenNthCalledWith(
|
|
|
|
|
2,
|
|
|
|
|
"channel:123",
|
|
|
|
|
"",
|
fix(subagent): harden read-tool overflow guards and sticky reply threading (#19508)
* fix(gateway): avoid premature agent.wait completion on transient errors
* fix(agent): preemptively guard tool results against context overflow
* fix: harden tool-result context guard and add message_id metadata
* fix: use importOriginal in session-key mock to include DEFAULT_ACCOUNT_ID
The run.skill-filter test was mocking ../../routing/session-key.js with only
buildAgentMainSessionKey and normalizeAgentId, but the module also exports
DEFAULT_ACCOUNT_ID which is required transitively by src/web/auth-store.ts.
Switch to importOriginal pattern so all real exports are preserved alongside
the mocked functions.
* pi-runner: guard accumulated tool-result overflow in transformContext
* PI runner: compact overflowing tool-result context
* Subagent: harden tool-result context recovery
* Enhance tool-result context handling by adding support for legacy tool outputs and improving character estimation for message truncation. This includes a new function to create legacy tool results and updates to existing functions to better manage context overflow scenarios.
* Enhance iMessage handling by adding reply tag support in send functions and tests. This includes modifications to prepend or rewrite reply tags based on provided replyToId, ensuring proper message formatting for replies.
* Enhance message delivery across multiple channels by implementing sticky reply context for chunked messages. This includes preserving reply references in Discord, Telegram, and iMessage, ensuring that follow-up messages maintain their intended reply targets. Additionally, improve handling of reply tags in system prompts and tests to support consistent reply behavior.
* Enhance read tool functionality by implementing auto-paging across chunks when no explicit limit is provided, scaling output budget based on model context window. Additionally, add tests for adaptive reading behavior and capped continuation guidance for large outputs. Update related functions to support these features.
* Refine tool-result context management by stripping oversized read-tool details payloads during compaction, ensuring repeated read calls do not bypass context limits. Introduce new utility functions for handling truncation content and enhance character estimation for tool results. Add tests to validate the removal of excessive details in context overflow scenarios.
* Refine message delivery logic in Matrix and Telegram by introducing a flag to track if a text chunk was sent. This ensures that replies are only marked as delivered when a text chunk has been successfully sent, improving the accuracy of reply handling in both channels.
* fix: tighten reply threading coverage and prep fixes (#19508) (thanks @tyler6204)
2026-02-17 15:32:52 -08:00
|
|
|
expect.objectContaining({
|
|
|
|
|
token: "token",
|
|
|
|
|
mediaUrl: "https://example.com/extra.mp3",
|
|
|
|
|
replyTo: "reply-1",
|
|
|
|
|
}),
|
2026-02-17 08:43:16 -05:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("skips follow-up text when the voice payload text is blank", async () => {
|
|
|
|
|
await deliverDiscordReply({
|
|
|
|
|
replies: [
|
|
|
|
|
{
|
|
|
|
|
text: " ",
|
|
|
|
|
mediaUrl: "https://example.com/voice.ogg",
|
|
|
|
|
audioAsVoice: true,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
target: "channel:456",
|
|
|
|
|
token: "token",
|
|
|
|
|
runtime,
|
2026-03-10 13:30:57 -05:00
|
|
|
cfg,
|
2026-02-17 08:43:16 -05:00
|
|
|
textLimit: 2000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(sendVoiceMessageDiscordMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(sendMessageDiscordMock).not.toHaveBeenCalled();
|
|
|
|
|
});
|
2026-02-20 16:37:06 -06:00
|
|
|
|
2026-03-03 11:10:26 -06:00
|
|
|
it("passes mediaLocalRoots through media sends", async () => {
|
|
|
|
|
const mediaLocalRoots = ["/tmp/workspace-agent"] as const;
|
|
|
|
|
await deliverDiscordReply({
|
|
|
|
|
replies: [
|
|
|
|
|
{
|
|
|
|
|
text: "Media reply",
|
|
|
|
|
mediaUrls: ["https://example.com/first.png", "https://example.com/second.png"],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
target: "channel:654",
|
|
|
|
|
token: "token",
|
|
|
|
|
runtime,
|
2026-03-10 13:30:57 -05:00
|
|
|
cfg,
|
2026-03-03 11:10:26 -06:00
|
|
|
textLimit: 2000,
|
|
|
|
|
mediaLocalRoots,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(2);
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenNthCalledWith(
|
|
|
|
|
1,
|
|
|
|
|
"channel:654",
|
|
|
|
|
"Media reply",
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
token: "token",
|
|
|
|
|
mediaUrl: "https://example.com/first.png",
|
|
|
|
|
mediaLocalRoots,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenNthCalledWith(
|
|
|
|
|
2,
|
|
|
|
|
"channel:654",
|
|
|
|
|
"",
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
token: "token",
|
|
|
|
|
mediaUrl: "https://example.com/second.png",
|
|
|
|
|
mediaLocalRoots,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-10 13:30:57 -05:00
|
|
|
it("forwards cfg to Discord send helpers", async () => {
|
|
|
|
|
await deliverDiscordReply({
|
|
|
|
|
replies: [{ text: "cfg path" }],
|
|
|
|
|
target: "channel:101",
|
|
|
|
|
token: "token",
|
|
|
|
|
runtime,
|
|
|
|
|
cfg,
|
|
|
|
|
textLimit: 2000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(sendMessageDiscordMock.mock.calls[0]?.[2]?.cfg).toBe(cfg);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-20 16:37:06 -06:00
|
|
|
it("uses replyToId only for the first chunk when replyToMode is first", async () => {
|
|
|
|
|
await deliverDiscordReply({
|
|
|
|
|
replies: [
|
|
|
|
|
{
|
|
|
|
|
text: "1234567890",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
target: "channel:789",
|
|
|
|
|
token: "token",
|
|
|
|
|
runtime,
|
2026-03-10 13:30:57 -05:00
|
|
|
cfg,
|
2026-02-20 16:37:06 -06:00
|
|
|
textLimit: 5,
|
|
|
|
|
replyToId: "reply-1",
|
|
|
|
|
replyToMode: "first",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(2);
|
|
|
|
|
expect(sendMessageDiscordMock.mock.calls[0]?.[2]?.replyTo).toBe("reply-1");
|
|
|
|
|
expect(sendMessageDiscordMock.mock.calls[1]?.[2]?.replyTo).toBeUndefined();
|
|
|
|
|
});
|
2026-02-21 16:14:55 +01:00
|
|
|
|
|
|
|
|
it("does not consume replyToId for replyToMode=first on whitespace-only payloads", async () => {
|
|
|
|
|
await deliverDiscordReply({
|
|
|
|
|
replies: [{ text: " " }, { text: "actual reply" }],
|
|
|
|
|
target: "channel:789",
|
|
|
|
|
token: "token",
|
|
|
|
|
runtime,
|
2026-03-10 13:30:57 -05:00
|
|
|
cfg,
|
2026-02-21 16:14:55 +01:00
|
|
|
textLimit: 2000,
|
|
|
|
|
replyToId: "reply-1",
|
|
|
|
|
replyToMode: "first",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenCalledWith(
|
|
|
|
|
"channel:789",
|
|
|
|
|
"actual reply",
|
|
|
|
|
expect.objectContaining({ token: "token", replyTo: "reply-1" }),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-26 11:00:09 +01:00
|
|
|
it("preserves leading whitespace in delivered text chunks", async () => {
|
|
|
|
|
await deliverDiscordReply({
|
|
|
|
|
replies: [{ text: " leading text" }],
|
|
|
|
|
target: "channel:789",
|
|
|
|
|
token: "token",
|
|
|
|
|
runtime,
|
2026-03-10 13:30:57 -05:00
|
|
|
cfg,
|
2026-02-26 11:00:09 +01:00
|
|
|
textLimit: 2000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenCalledWith(
|
|
|
|
|
"channel:789",
|
|
|
|
|
" leading text",
|
|
|
|
|
expect.objectContaining({ token: "token" }),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-03 10:17:33 -06:00
|
|
|
it("sends text chunks in order via sendDiscordText when rest is provided", async () => {
|
|
|
|
|
const fakeRest = {} as import("@buape/carbon").RequestClient;
|
|
|
|
|
const callOrder: string[] = [];
|
|
|
|
|
sendDiscordTextMock.mockImplementation(
|
|
|
|
|
async (_rest: unknown, _channelId: unknown, text: string) => {
|
|
|
|
|
callOrder.push(text);
|
|
|
|
|
return { id: `msg-${callOrder.length}`, channel_id: "789" };
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await deliverDiscordReply({
|
|
|
|
|
replies: [{ text: "1234567890" }],
|
|
|
|
|
target: "channel:789",
|
|
|
|
|
token: "token",
|
|
|
|
|
rest: fakeRest,
|
|
|
|
|
runtime,
|
2026-03-10 13:30:57 -05:00
|
|
|
cfg,
|
2026-03-03 10:17:33 -06:00
|
|
|
textLimit: 5,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(sendMessageDiscordMock).not.toHaveBeenCalled();
|
|
|
|
|
expect(sendDiscordTextMock).toHaveBeenCalledTimes(2);
|
|
|
|
|
expect(callOrder).toEqual(["12345", "67890"]);
|
|
|
|
|
expect(sendDiscordTextMock.mock.calls[0]?.[1]).toBe("789");
|
|
|
|
|
expect(sendDiscordTextMock.mock.calls[1]?.[1]).toBe("789");
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-09 22:30:24 +00:00
|
|
|
it("passes maxLinesPerMessage and chunkMode through the fast path", async () => {
|
|
|
|
|
const fakeRest = {} as import("@buape/carbon").RequestClient;
|
|
|
|
|
|
|
|
|
|
await deliverDiscordReply({
|
|
|
|
|
replies: [{ text: Array.from({ length: 18 }, (_, index) => `line ${index + 1}`).join("\n") }],
|
|
|
|
|
target: "channel:789",
|
|
|
|
|
token: "token",
|
|
|
|
|
rest: fakeRest,
|
|
|
|
|
runtime,
|
2026-03-10 13:30:57 -05:00
|
|
|
cfg,
|
2026-03-09 22:30:24 +00:00
|
|
|
textLimit: 2000,
|
|
|
|
|
maxLinesPerMessage: 120,
|
|
|
|
|
chunkMode: "newline",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(sendMessageDiscordMock).not.toHaveBeenCalled();
|
|
|
|
|
expect(sendDiscordTextMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
const firstSendDiscordTextCall = sendDiscordTextMock.mock.calls[0];
|
|
|
|
|
const [, , , , , maxLinesPerMessageArg, , , chunkModeArg] = firstSendDiscordTextCall ?? [];
|
|
|
|
|
|
|
|
|
|
expect(maxLinesPerMessageArg).toBe(120);
|
|
|
|
|
expect(chunkModeArg).toBe("newline");
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-03 10:17:33 -06:00
|
|
|
it("falls back to sendMessageDiscord when rest is not provided", async () => {
|
|
|
|
|
await deliverDiscordReply({
|
|
|
|
|
replies: [{ text: "single chunk" }],
|
|
|
|
|
target: "channel:789",
|
|
|
|
|
token: "token",
|
|
|
|
|
runtime,
|
2026-03-10 13:30:57 -05:00
|
|
|
cfg,
|
2026-03-03 10:17:33 -06:00
|
|
|
textLimit: 2000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(sendDiscordTextMock).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("retries bot send on 429 rate limit then succeeds", async () => {
|
2026-03-13 22:27:33 +00:00
|
|
|
await expectBotSendRetrySuccess(429, "rate limited");
|
2026-03-03 10:17:33 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("retries bot send on 500 server error then succeeds", async () => {
|
2026-03-13 22:27:33 +00:00
|
|
|
await expectBotSendRetrySuccess(500, "internal");
|
2026-03-03 10:17:33 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not retry on 4xx client errors", async () => {
|
|
|
|
|
const clientErr = Object.assign(new Error("bad request"), { status: 400 });
|
|
|
|
|
sendMessageDiscordMock.mockRejectedValueOnce(clientErr);
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
deliverDiscordReply({
|
|
|
|
|
replies: [{ text: "fail" }],
|
|
|
|
|
target: "channel:123",
|
|
|
|
|
token: "token",
|
|
|
|
|
runtime,
|
2026-03-10 13:30:57 -05:00
|
|
|
cfg,
|
2026-03-03 10:17:33 -06:00
|
|
|
textLimit: 2000,
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toThrow("bad request");
|
|
|
|
|
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("throws after exhausting retry attempts", async () => {
|
|
|
|
|
const rateLimitErr = Object.assign(new Error("rate limited"), { status: 429 });
|
|
|
|
|
sendMessageDiscordMock.mockRejectedValue(rateLimitErr);
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
deliverDiscordReply({
|
|
|
|
|
replies: [{ text: "persistent failure" }],
|
|
|
|
|
target: "channel:123",
|
|
|
|
|
token: "token",
|
|
|
|
|
runtime,
|
2026-03-10 13:30:57 -05:00
|
|
|
cfg,
|
2026-03-03 10:17:33 -06:00
|
|
|
textLimit: 2000,
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toThrow("rate limited");
|
|
|
|
|
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("delivers remaining chunks after a mid-sequence retry", async () => {
|
|
|
|
|
sendMessageDiscordMock
|
|
|
|
|
.mockResolvedValueOnce({ messageId: "c1" })
|
|
|
|
|
.mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 }))
|
|
|
|
|
.mockResolvedValueOnce({ messageId: "c2-retry" })
|
|
|
|
|
.mockResolvedValueOnce({ messageId: "c3" });
|
|
|
|
|
|
|
|
|
|
await deliverDiscordReply({
|
|
|
|
|
replies: [{ text: "A".repeat(6) }],
|
|
|
|
|
target: "channel:123",
|
|
|
|
|
token: "token",
|
|
|
|
|
runtime,
|
2026-03-10 13:30:57 -05:00
|
|
|
cfg,
|
2026-03-03 10:17:33 -06:00
|
|
|
textLimit: 2,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(4);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-21 16:14:55 +01:00
|
|
|
it("sends bound-session text replies through webhook delivery", async () => {
|
2026-02-22 07:37:54 +00:00
|
|
|
const threadBindings = await createBoundThreadBindings({ label: "codex-refactor" });
|
2026-02-21 16:14:55 +01:00
|
|
|
|
|
|
|
|
await deliverDiscordReply({
|
|
|
|
|
replies: [{ text: "Hello from subagent" }],
|
|
|
|
|
target: "channel:thread-1",
|
|
|
|
|
token: "token",
|
|
|
|
|
runtime,
|
2026-03-10 13:30:57 -05:00
|
|
|
cfg,
|
2026-02-21 16:14:55 +01:00
|
|
|
textLimit: 2000,
|
|
|
|
|
replyToId: "reply-1",
|
|
|
|
|
sessionKey: "agent:main:subagent:child",
|
|
|
|
|
threadBindings,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(sendWebhookMessageDiscordMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(sendWebhookMessageDiscordMock).toHaveBeenCalledWith(
|
|
|
|
|
"Hello from subagent",
|
|
|
|
|
expect.objectContaining({
|
2026-03-10 13:30:57 -05:00
|
|
|
cfg,
|
2026-02-21 16:14:55 +01:00
|
|
|
webhookId: "wh_1",
|
|
|
|
|
webhookToken: "tok_1",
|
|
|
|
|
accountId: "default",
|
|
|
|
|
threadId: "thread-1",
|
|
|
|
|
replyTo: "reply-1",
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
expect(sendMessageDiscordMock).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-27 10:02:39 +01:00
|
|
|
it("touches bound-thread activity after outbound delivery", async () => {
|
|
|
|
|
vi.useFakeTimers();
|
|
|
|
|
try {
|
|
|
|
|
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
|
|
|
|
const threadBindings = await createBoundThreadBindings();
|
|
|
|
|
vi.setSystemTime(new Date("2026-02-20T00:02:00.000Z"));
|
|
|
|
|
|
|
|
|
|
await deliverDiscordReply({
|
|
|
|
|
replies: [{ text: "Activity ping" }],
|
|
|
|
|
target: "channel:thread-1",
|
|
|
|
|
token: "token",
|
|
|
|
|
runtime,
|
2026-03-10 13:30:57 -05:00
|
|
|
cfg,
|
2026-02-27 10:02:39 +01:00
|
|
|
textLimit: 2000,
|
|
|
|
|
sessionKey: "agent:main:subagent:child",
|
|
|
|
|
threadBindings,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(threadBindings.getByThreadId("thread-1")?.lastActivityAt).toBe(
|
|
|
|
|
new Date("2026-02-20T00:02:00.000Z").getTime(),
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
vi.useRealTimers();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-21 16:14:55 +01:00
|
|
|
it("falls back to bot send when webhook delivery fails", async () => {
|
2026-02-22 07:37:54 +00:00
|
|
|
const threadBindings = await createBoundThreadBindings();
|
2026-02-21 16:14:55 +01:00
|
|
|
sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited"));
|
|
|
|
|
|
|
|
|
|
await deliverDiscordReply({
|
|
|
|
|
replies: [{ text: "Fallback path" }],
|
|
|
|
|
target: "channel:thread-1",
|
|
|
|
|
token: "token",
|
|
|
|
|
accountId: "default",
|
|
|
|
|
runtime,
|
2026-03-10 13:30:57 -05:00
|
|
|
cfg,
|
2026-02-21 16:14:55 +01:00
|
|
|
textLimit: 2000,
|
|
|
|
|
sessionKey: "agent:main:subagent:child",
|
|
|
|
|
threadBindings,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(sendWebhookMessageDiscordMock).toHaveBeenCalledTimes(1);
|
2026-03-10 13:30:57 -05:00
|
|
|
expect(sendWebhookMessageDiscordMock.mock.calls[0]?.[1]?.cfg).toBe(cfg);
|
2026-02-21 16:14:55 +01:00
|
|
|
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenCalledWith(
|
|
|
|
|
"channel:thread-1",
|
|
|
|
|
"Fallback path",
|
|
|
|
|
expect.objectContaining({ token: "token", accountId: "default" }),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not use thread webhook when outbound target is not a bound thread", async () => {
|
2026-02-22 07:37:54 +00:00
|
|
|
const threadBindings = await createBoundThreadBindings();
|
2026-02-21 16:14:55 +01:00
|
|
|
|
|
|
|
|
await deliverDiscordReply({
|
|
|
|
|
replies: [{ text: "Parent channel delivery" }],
|
|
|
|
|
target: "channel:parent-1",
|
|
|
|
|
token: "token",
|
|
|
|
|
accountId: "default",
|
|
|
|
|
runtime,
|
2026-03-10 13:30:57 -05:00
|
|
|
cfg,
|
2026-02-21 16:14:55 +01:00
|
|
|
textLimit: 2000,
|
|
|
|
|
sessionKey: "agent:main:subagent:child",
|
|
|
|
|
threadBindings,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(sendWebhookMessageDiscordMock).not.toHaveBeenCalled();
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(sendMessageDiscordMock).toHaveBeenCalledWith(
|
|
|
|
|
"channel:parent-1",
|
|
|
|
|
"Parent channel delivery",
|
|
|
|
|
expect.objectContaining({ token: "token", accountId: "default" }),
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-02-17 08:43:16 -05:00
|
|
|
});
|