refactor(test): dedupe channel and monitor action suites

This commit is contained in:
Peter Steinberger
2026-02-18 04:48:51 +00:00
parent 31f83c86b2
commit a69e7682c1
11 changed files with 506 additions and 789 deletions

View File

@@ -79,24 +79,33 @@ vi.mock("../infra/provider-usage.js", () => ({
import "./test-helpers/fast-core-tools.js";
import { createOpenClawTools } from "./openclaw-tools.js";
function resetSessionStore(store: Record<string, unknown>) {
loadSessionStoreMock.mockReset();
updateSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue(store);
}
function getSessionStatusTool(agentSessionKey = "main") {
const tool = createOpenClawTools({ agentSessionKey }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing session_status tool");
}
return tool;
}
describe("session_status tool", () => {
it("returns a status card for the current session", async () => {
loadSessionStoreMock.mockReset();
updateSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
resetSessionStore({
main: {
sessionId: "s1",
updatedAt: 10,
},
});
const tool = createOpenClawTools({ agentSessionKey: "main" }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing session_status tool");
}
const tool = getSessionStatusTool();
const result = await tool.execute("call1", {});
const details = result.details as { ok?: boolean; statusText?: string };
@@ -107,19 +116,11 @@ describe("session_status tool", () => {
});
it("errors for unknown session keys", async () => {
loadSessionStoreMock.mockReset();
updateSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
resetSessionStore({
main: { sessionId: "s1", updatedAt: 10 },
});
const tool = createOpenClawTools({ agentSessionKey: "main" }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing session_status tool");
}
const tool = getSessionStatusTool();
await expect(tool.execute("call2", { sessionKey: "nope" })).rejects.toThrow(
"Unknown sessionId",
@@ -128,23 +129,15 @@ describe("session_status tool", () => {
});
it("resolves sessionId inputs", async () => {
loadSessionStoreMock.mockReset();
updateSessionStoreMock.mockReset();
const sessionId = "sess-main";
loadSessionStoreMock.mockReturnValue({
resetSessionStore({
"agent:main:main": {
sessionId,
updatedAt: 10,
},
});
const tool = createOpenClawTools({ agentSessionKey: "main" }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing session_status tool");
}
const tool = getSessionStatusTool();
const result = await tool.execute("call3", { sessionKey: sessionId });
const details = result.details as { ok?: boolean; sessionKey?: string };
@@ -153,22 +146,14 @@ describe("session_status tool", () => {
});
it("uses non-standard session keys without sessionId resolution", async () => {
loadSessionStoreMock.mockReset();
updateSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
resetSessionStore({
"temp:slug-generator": {
sessionId: "sess-temp",
updatedAt: 10,
},
});
const tool = createOpenClawTools({ agentSessionKey: "main" }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing session_status tool");
}
const tool = getSessionStatusTool();
const result = await tool.execute("call4", { sessionKey: "temp:slug-generator" });
const details = result.details as { ok?: boolean; sessionKey?: string };
@@ -177,22 +162,14 @@ describe("session_status tool", () => {
});
it("blocks cross-agent session_status without agent-to-agent access", async () => {
loadSessionStoreMock.mockReset();
updateSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
resetSessionStore({
"agent:other:main": {
sessionId: "s2",
updatedAt: 10,
},
});
const tool = createOpenClawTools({ agentSessionKey: "agent:main:main" }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing session_status tool");
}
const tool = getSessionStatusTool("agent:main:main");
await expect(tool.execute("call5", { sessionKey: "agent:other:main" })).rejects.toThrow(
"Agent-to-agent status is disabled",
@@ -228,13 +205,7 @@ describe("session_status tool", () => {
},
);
const tool = createOpenClawTools({ agentSessionKey: "agent:support:main" }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing session_status tool");
}
const tool = getSessionStatusTool("agent:support:main");
const result = await tool.execute("call6", { sessionKey: "main" });
const details = result.details as { ok?: boolean; sessionKey?: string };
@@ -243,9 +214,7 @@ describe("session_status tool", () => {
});
it("resets per-session model override via model=default", async () => {
loadSessionStoreMock.mockReset();
updateSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
resetSessionStore({
main: {
sessionId: "s1",
updatedAt: 10,
@@ -255,13 +224,7 @@ describe("session_status tool", () => {
},
});
const tool = createOpenClawTools({ agentSessionKey: "main" }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing session_status tool");
}
const tool = getSessionStatusTool();
await tool.execute("call3", { model: "default" });
expect(updateSessionStoreMock).toHaveBeenCalled();

View File

@@ -5,60 +5,55 @@ import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
import { handleDiscordAction } from "./discord-actions.js";
const createChannelDiscord = vi.fn(async () => ({
id: "new-channel",
name: "test",
type: 0,
const discordSendMocks = vi.hoisted(() => ({
banMemberDiscord: vi.fn(async () => ({})),
createChannelDiscord: vi.fn(async () => ({
id: "new-channel",
name: "test",
type: 0,
})),
createThreadDiscord: vi.fn(async () => ({})),
deleteChannelDiscord: vi.fn(async () => ({ ok: true, channelId: "C1" })),
deleteMessageDiscord: vi.fn(async () => ({})),
editChannelDiscord: vi.fn(async () => ({
id: "C1",
name: "edited",
})),
editMessageDiscord: vi.fn(async () => ({})),
fetchChannelPermissionsDiscord: vi.fn(async () => ({})),
fetchMessageDiscord: vi.fn(async () => ({})),
fetchReactionsDiscord: vi.fn(async () => ({})),
kickMemberDiscord: vi.fn(async () => ({})),
listGuildChannelsDiscord: vi.fn(async () => []),
listPinsDiscord: vi.fn(async () => ({})),
listThreadsDiscord: vi.fn(async () => ({})),
moveChannelDiscord: vi.fn(async () => ({ ok: true })),
pinMessageDiscord: vi.fn(async () => ({})),
reactMessageDiscord: vi.fn(async () => ({})),
readMessagesDiscord: vi.fn(async () => []),
removeChannelPermissionDiscord: vi.fn(async () => ({ ok: true })),
removeOwnReactionsDiscord: vi.fn(async () => ({ removed: ["👍"] })),
removeReactionDiscord: vi.fn(async () => ({})),
searchMessagesDiscord: vi.fn(async () => ({})),
sendMessageDiscord: vi.fn(async () => ({})),
sendPollDiscord: vi.fn(async () => ({})),
sendStickerDiscord: vi.fn(async () => ({})),
sendVoiceMessageDiscord: vi.fn(async () => ({})),
setChannelPermissionDiscord: vi.fn(async () => ({ ok: true })),
timeoutMemberDiscord: vi.fn(async () => ({})),
unpinMessageDiscord: vi.fn(async () => ({})),
}));
const createThreadDiscord = vi.fn(async () => ({}));
const deleteChannelDiscord = vi.fn(async () => ({ ok: true, channelId: "C1" }));
const deleteMessageDiscord = vi.fn(async () => ({}));
const editChannelDiscord = vi.fn(async () => ({
id: "C1",
name: "edited",
}));
const editMessageDiscord = vi.fn(async () => ({}));
const fetchMessageDiscord = vi.fn(async () => ({}));
const fetchChannelPermissionsDiscord = vi.fn(async () => ({}));
const fetchReactionsDiscord = vi.fn(async () => ({}));
const listGuildChannelsDiscord = vi.fn(async () => []);
const listPinsDiscord = vi.fn(async () => ({}));
const listThreadsDiscord = vi.fn(async () => ({}));
const moveChannelDiscord = vi.fn(async () => ({ ok: true }));
const pinMessageDiscord = vi.fn(async () => ({}));
const reactMessageDiscord = vi.fn(async () => ({}));
const readMessagesDiscord = vi.fn(async () => []);
const removeChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
const removeOwnReactionsDiscord = vi.fn(async () => ({ removed: ["👍"] }));
const removeReactionDiscord = vi.fn(async () => ({}));
const searchMessagesDiscord = vi.fn(async () => ({}));
const sendMessageDiscord = vi.fn(async () => ({}));
const sendVoiceMessageDiscord = vi.fn(async () => ({}));
const sendPollDiscord = vi.fn(async () => ({}));
const sendStickerDiscord = vi.fn(async () => ({}));
const setChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
const unpinMessageDiscord = vi.fn(async () => ({}));
const timeoutMemberDiscord = vi.fn(async () => ({}));
const kickMemberDiscord = vi.fn(async () => ({}));
const banMemberDiscord = vi.fn(async () => ({}));
vi.mock("../../discord/send.js", () => ({
banMemberDiscord,
const {
createChannelDiscord,
createThreadDiscord,
deleteChannelDiscord,
deleteMessageDiscord,
editChannelDiscord,
editMessageDiscord,
fetchMessageDiscord,
fetchChannelPermissionsDiscord,
fetchReactionsDiscord,
kickMemberDiscord,
listGuildChannelsDiscord,
listPinsDiscord,
listThreadsDiscord,
moveChannelDiscord,
pinMessageDiscord,
reactMessageDiscord,
readMessagesDiscord,
removeChannelPermissionDiscord,
@@ -67,11 +62,12 @@ vi.mock("../../discord/send.js", () => ({
searchMessagesDiscord,
sendMessageDiscord,
sendVoiceMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
setChannelPermissionDiscord,
timeoutMemberDiscord,
unpinMessageDiscord,
} = discordSendMocks;
vi.mock("../../discord/send.js", () => ({
...discordSendMocks,
}));
const enableAllActions = () => true;
@@ -388,35 +384,15 @@ describe("handleDiscordGuildAction - channel management", () => {
});
});
it("clears the channel parent when parentId is null", async () => {
it.each([
["parentId is null", { parentId: null }],
["clearParent is true", { clearParent: true }],
])("clears the channel parent when %s", async (_label, payload) => {
await handleDiscordGuildAction(
"channelEdit",
{
channelId: "C1",
parentId: null,
},
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith({
channelId: "C1",
name: undefined,
topic: undefined,
position: undefined,
parentId: null,
nsfw: undefined,
rateLimitPerUser: undefined,
archived: undefined,
locked: undefined,
autoArchiveDuration: undefined,
});
});
it("clears the channel parent when clearParent is true", async () => {
await handleDiscordGuildAction(
"channelEdit",
{
channelId: "C1",
clearParent: true,
...payload,
},
channelsEnabled,
);
@@ -458,31 +434,16 @@ describe("handleDiscordGuildAction - channel management", () => {
});
});
it("clears the channel parent on move when parentId is null", async () => {
it.each([
["parentId is null", { parentId: null }],
["clearParent is true", { clearParent: true }],
])("clears the channel parent on move when %s", async (_label, payload) => {
await handleDiscordGuildAction(
"channelMove",
{
guildId: "G1",
channelId: "C1",
parentId: null,
},
channelsEnabled,
);
expect(moveChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
channelId: "C1",
parentId: null,
position: undefined,
});
});
it("clears the channel parent on move when clearParent is true", async () => {
await handleDiscordGuildAction(
"channelMove",
{
guildId: "G1",
channelId: "C1",
clearParent: true,
...payload,
},
channelsEnabled,
);

View File

@@ -63,6 +63,25 @@ function expectFirstSlackAction(expected: Record<string, unknown>) {
expect(params).toMatchObject(expected);
}
function expectModerationActions(actions: string[]) {
expect(actions).toContain("timeout");
expect(actions).toContain("kick");
expect(actions).toContain("ban");
}
async function expectSlackSendRejected(params: Record<string, unknown>, error: RegExp) {
const { cfg, actions } = slackHarness();
await expect(
actions.handleAction?.({
channel: "slack",
action: "send",
cfg,
params,
}),
).rejects.toThrow(error);
expect(handleSlackAction).not.toHaveBeenCalled();
}
beforeEach(() => {
vi.clearAllMocks();
});
@@ -98,9 +117,7 @@ describe("discord message actions", () => {
} as OpenClawConfig;
const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
expect(actions).toContain("timeout");
expect(actions).toContain("kick");
expect(actions).toContain("ban");
expectModerationActions(actions);
});
it("lists moderation when one account enables and another omits", () => {
@@ -116,9 +133,7 @@ describe("discord message actions", () => {
} as OpenClawConfig;
const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
expect(actions).toContain("timeout");
expect(actions).toContain("kick");
expect(actions).toContain("ban");
expectModerationActions(actions);
});
it("omits moderation when all accounts omit it", () => {
@@ -765,58 +780,37 @@ describe("slack actions adapter", () => {
});
it("rejects invalid blocks JSON for send", async () => {
const { cfg, actions } = slackHarness();
await expect(
actions.handleAction?.({
channel: "slack",
action: "send",
cfg,
params: {
to: "channel:C1",
message: "",
blocks: "{bad-json",
},
}),
).rejects.toThrow(/blocks must be valid JSON/i);
expect(handleSlackAction).not.toHaveBeenCalled();
await expectSlackSendRejected(
{
to: "channel:C1",
message: "",
blocks: "{bad-json",
},
/blocks must be valid JSON/i,
);
});
it("rejects empty blocks arrays for send", async () => {
const { cfg, actions } = slackHarness();
await expect(
actions.handleAction?.({
channel: "slack",
action: "send",
cfg,
params: {
to: "channel:C1",
message: "",
blocks: "[]",
},
}),
).rejects.toThrow(/at least one block/i);
expect(handleSlackAction).not.toHaveBeenCalled();
await expectSlackSendRejected(
{
to: "channel:C1",
message: "",
blocks: "[]",
},
/at least one block/i,
);
});
it("rejects send when both blocks and media are provided", async () => {
const { cfg, actions } = slackHarness();
await expect(
actions.handleAction?.({
channel: "slack",
action: "send",
cfg,
params: {
to: "channel:C1",
message: "",
media: "https://example.com/image.png",
blocks: JSON.stringify([{ type: "divider" }]),
},
}),
).rejects.toThrow(/does not support blocks with media/i);
expect(handleSlackAction).not.toHaveBeenCalled();
await expectSlackSendRejected(
{
to: "channel:C1",
message: "",
media: "https://example.com/image.png",
blocks: JSON.stringify([{ type: "divider" }]),
},
/does not support blocks with media/i,
);
});
it("forwards blocks JSON for edit", async () => {

View File

@@ -71,36 +71,48 @@ async function runRepair(cfg: OpenClawConfig) {
await maybeRepairGatewayServiceConfig(cfg, "local", makeDoctorIo(), makeDoctorPrompts());
}
const gatewayProgramArguments = [
"/usr/bin/node",
"/usr/local/bin/openclaw",
"gateway",
"--port",
"18789",
];
function setupGatewayTokenRepairScenario(expectedToken: string) {
mocks.readCommand.mockResolvedValue({
programArguments: gatewayProgramArguments,
environment: {
OPENCLAW_GATEWAY_TOKEN: "stale-token",
},
});
mocks.auditGatewayServiceConfig.mockResolvedValue({
ok: false,
issues: [
{
code: "gateway-token-mismatch",
message: "Gateway service OPENCLAW_GATEWAY_TOKEN does not match gateway.auth.token",
level: "recommended",
},
],
});
mocks.buildGatewayInstallPlan.mockResolvedValue({
programArguments: gatewayProgramArguments,
workingDirectory: "/tmp",
environment: {
OPENCLAW_GATEWAY_TOKEN: expectedToken,
},
});
mocks.install.mockResolvedValue(undefined);
}
describe("maybeRepairGatewayServiceConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("treats gateway.auth.token as source of truth for service token repairs", async () => {
mocks.readCommand.mockResolvedValue({
programArguments: ["/usr/bin/node", "/usr/local/bin/openclaw", "gateway", "--port", "18789"],
environment: {
OPENCLAW_GATEWAY_TOKEN: "stale-token",
},
});
mocks.auditGatewayServiceConfig.mockResolvedValue({
ok: false,
issues: [
{
code: "gateway-token-mismatch",
message: "Gateway service OPENCLAW_GATEWAY_TOKEN does not match gateway.auth.token",
level: "recommended",
},
],
});
mocks.buildGatewayInstallPlan.mockResolvedValue({
programArguments: ["/usr/bin/node", "/usr/local/bin/openclaw", "gateway", "--port", "18789"],
workingDirectory: "/tmp",
environment: {
OPENCLAW_GATEWAY_TOKEN: "config-token",
},
});
mocks.install.mockResolvedValue(undefined);
setupGatewayTokenRepairScenario("config-token");
const cfg: OpenClawConfig = {
gateway: {
@@ -130,42 +142,7 @@ describe("maybeRepairGatewayServiceConfig", () => {
const previousToken = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token";
try {
mocks.readCommand.mockResolvedValue({
programArguments: [
"/usr/bin/node",
"/usr/local/bin/openclaw",
"gateway",
"--port",
"18789",
],
environment: {
OPENCLAW_GATEWAY_TOKEN: "stale-token",
},
});
mocks.auditGatewayServiceConfig.mockResolvedValue({
ok: false,
issues: [
{
code: "gateway-token-mismatch",
message: "Gateway service OPENCLAW_GATEWAY_TOKEN does not match gateway.auth.token",
level: "recommended",
},
],
});
mocks.buildGatewayInstallPlan.mockResolvedValue({
programArguments: [
"/usr/bin/node",
"/usr/local/bin/openclaw",
"gateway",
"--port",
"18789",
],
workingDirectory: "/tmp",
environment: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
},
});
mocks.install.mockResolvedValue(undefined);
setupGatewayTokenRepairScenario("env-token");
const cfg: OpenClawConfig = {
gateway: {},

View File

@@ -351,10 +351,10 @@ describe("discord component interactions", () => {
expect(resolveDiscordComponentEntry({ id: "btn_1", consume: false })).not.toBeNull();
});
it("routes modal submissions with field values", async () => {
async function runModalSubmission(params?: { reusable?: boolean }) {
registerDiscordComponentEntries({
entries: [],
modals: [createModalEntry()],
modals: [createModalEntry({ reusable: params?.reusable ?? false })],
});
const modal = createDiscordComponentModal(
@@ -365,6 +365,11 @@ describe("discord component interactions", () => {
const { interaction, acknowledge } = createModalInteraction();
await modal.run(interaction, { mid: "mdl_1" } as ComponentData);
return { acknowledge };
}
it("routes modal submissions with field values", async () => {
const { acknowledge } = await runModalSubmission();
expect(acknowledge).toHaveBeenCalledTimes(1);
expect(lastDispatchCtx?.BodyForAgent).toContain('Form "Details" submitted.');
@@ -376,19 +381,7 @@ describe("discord component interactions", () => {
});
it("keeps reusable modal entries active after submission", async () => {
registerDiscordComponentEntries({
entries: [],
modals: [createModalEntry({ reusable: true })],
});
const modal = createDiscordComponentModal(
createComponentContext({
discordConfig: createDiscordConfig({ replyToMode: "all" }),
}),
);
const { interaction, acknowledge } = createModalInteraction();
await modal.run(interaction, { mid: "mdl_1" } as ComponentData);
const { acknowledge } = await runModalSubmission({ reusable: true });
expect(acknowledge).toHaveBeenCalledTimes(1);
expect(resolveDiscordModalEntry({ id: "mdl_1", consume: false })).not.toBeNull();
@@ -790,27 +783,34 @@ describe("maybeCreateDiscordAutoThread", () => {
});
describe("resolveDiscordAutoThreadReplyPlan", () => {
it("switches delivery + session context to the created thread", async () => {
const client = {
rest: { post: async () => ({ id: "thread" }) },
} as unknown as Client;
const plan = await resolveDiscordAutoThreadReplyPlan({
client,
function createAutoThreadPlanParams(overrides?: {
client?: Client;
channelConfig?: DiscordChannelConfigResolved;
threadChannel?: { id: string } | null;
}) {
return {
client:
overrides?.client ??
({ rest: { post: async () => ({ id: "thread" }) } } as unknown as Client),
message: {
id: "m1",
channelId: "parent",
} as unknown as import("./listeners.js").DiscordMessageEvent["message"],
isGuildMessage: true,
channelConfig: {
autoThread: true,
} as unknown as DiscordChannelConfigResolved,
threadChannel: null,
channelConfig:
overrides?.channelConfig ??
({ autoThread: true } as unknown as DiscordChannelConfigResolved),
threadChannel: overrides?.threadChannel ?? null,
baseText: "hello",
combinedBody: "hello",
replyToMode: "all",
replyToMode: "all" as const,
agentId: "agent",
channel: "discord",
});
channel: "discord" as const,
};
}
it("switches delivery + session context to the created thread", async () => {
const plan = await resolveDiscordAutoThreadReplyPlan(createAutoThreadPlanParams());
expect(plan.deliverTarget).toBe("channel:thread");
expect(plan.replyReference.use()).toBeUndefined();
expect(plan.autoThreadContext?.SessionKey).toBe(
@@ -823,24 +823,11 @@ describe("resolveDiscordAutoThreadReplyPlan", () => {
});
it("routes replies to an existing thread channel", async () => {
const client = { rest: { post: async () => ({ id: "thread" }) } } as unknown as Client;
const plan = await resolveDiscordAutoThreadReplyPlan({
client,
message: {
id: "m1",
channelId: "parent",
} as unknown as import("./listeners.js").DiscordMessageEvent["message"],
isGuildMessage: true,
channelConfig: {
autoThread: true,
} as unknown as DiscordChannelConfigResolved,
threadChannel: { id: "thread" },
baseText: "hello",
combinedBody: "hello",
replyToMode: "all",
agentId: "agent",
channel: "discord",
});
const plan = await resolveDiscordAutoThreadReplyPlan(
createAutoThreadPlanParams({
threadChannel: { id: "thread" },
}),
);
expect(plan.deliverTarget).toBe("channel:thread");
expect(plan.replyTarget).toBe("channel:thread");
expect(plan.replyReference.use()).toBe("m1");
@@ -848,24 +835,11 @@ describe("resolveDiscordAutoThreadReplyPlan", () => {
});
it("does nothing when autoThread is disabled", async () => {
const client = { rest: { post: async () => ({ id: "thread" }) } } as unknown as Client;
const plan = await resolveDiscordAutoThreadReplyPlan({
client,
message: {
id: "m1",
channelId: "parent",
} as unknown as import("./listeners.js").DiscordMessageEvent["message"],
isGuildMessage: true,
channelConfig: {
autoThread: false,
} as unknown as DiscordChannelConfigResolved,
threadChannel: null,
baseText: "hello",
combinedBody: "hello",
replyToMode: "all",
agentId: "agent",
channel: "discord",
});
const plan = await resolveDiscordAutoThreadReplyPlan(
createAutoThreadPlanParams({
channelConfig: { autoThread: false } as unknown as DiscordChannelConfigResolved,
}),
);
expect(plan.deliverTarget).toBe("channel:parent");
expect(plan.autoThreadContext).toBeNull();
});

View File

@@ -170,6 +170,7 @@ beforeEach(() => {
});
const resolveGatewayToken = (): string => TEST_GATEWAY_TOKEN;
const gatewayAuthHeaders = () => ({ authorization: `Bearer ${resolveGatewayToken()}` });
const allowAgentsListForMain = () => {
cfg = {
@@ -229,16 +230,29 @@ const invokeTool = async (params: {
});
};
const invokeAgentsListAuthed = async (params: { sessionKey?: string } = {}) =>
invokeAgentsList({
port: sharedPort,
headers: gatewayAuthHeaders(),
sessionKey: params.sessionKey,
});
const invokeToolAuthed = async (params: {
tool: string;
args?: Record<string, unknown>;
action?: string;
sessionKey?: string;
}) =>
invokeTool({
port: sharedPort,
headers: gatewayAuthHeaders(),
...params,
});
describe("POST /tools/invoke", () => {
it("invokes a tool and returns {ok:true,result}", async () => {
allowAgentsListForMain();
const token = resolveGatewayToken();
const res = await invokeAgentsList({
port: sharedPort,
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
const res = await invokeAgentsListAuthed({ sessionKey: "main" });
expect(res.status).toBe(200);
const body = await res.json();
@@ -253,13 +267,7 @@ describe("POST /tools/invoke", () => {
tools: { profile: "minimal", alsoAllow: ["agents_list"] },
};
const token = resolveGatewayToken();
const resProfile = await invokeAgentsList({
port: sharedPort,
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
const resProfile = await invokeAgentsListAuthed({ sessionKey: "main" });
expect(resProfile.status).toBe(200);
const profileBody = await resProfile.json();
@@ -270,11 +278,7 @@ describe("POST /tools/invoke", () => {
tools: { alsoAllow: ["agents_list"] },
};
const resImplicit = await invokeAgentsList({
port: sharedPort,
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
const resImplicit = await invokeAgentsListAuthed({ sessionKey: "main" });
expect(resImplicit.status).toBe(200);
const implicitBody = await resImplicit.json();
expect(implicitBody.ok).toBe(true);
@@ -289,12 +293,7 @@ describe("POST /tools/invoke", () => {
allowAgentsListForMain();
pluginHttpHandlers = [async (req, res) => pluginHandler(req, res)];
const token = resolveGatewayToken();
const res = await invokeAgentsList({
port: sharedPort,
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
const res = await invokeAgentsListAuthed({ sessionKey: "main" });
expect(res.status).toBe(200);
expect(pluginHandler).not.toHaveBeenCalled();
@@ -315,13 +314,7 @@ describe("POST /tools/invoke", () => {
],
},
};
const token = resolveGatewayToken();
const denyRes = await invokeAgentsList({
port: sharedPort,
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
const denyRes = await invokeAgentsListAuthed({ sessionKey: "main" });
expect(denyRes.status).toBe(404);
allowAgentsListForMain();
@@ -330,11 +323,7 @@ describe("POST /tools/invoke", () => {
tools: { profile: "minimal" },
};
const profileRes = await invokeAgentsList({
port: sharedPort,
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
const profileRes = await invokeAgentsListAuthed({ sessionKey: "main" });
expect(profileRes.status).toBe(404);
});
@@ -352,13 +341,9 @@ describe("POST /tools/invoke", () => {
},
};
const token = resolveGatewayToken();
const res = await invokeTool({
port: sharedPort,
const res = await invokeToolAuthed({
tool: "sessions_spawn",
args: { task: "test" },
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
@@ -376,12 +361,8 @@ describe("POST /tools/invoke", () => {
},
};
const token = resolveGatewayToken();
const res = await invokeTool({
port: sharedPort,
const res = await invokeToolAuthed({
tool: "sessions_send",
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
@@ -396,12 +377,8 @@ describe("POST /tools/invoke", () => {
},
};
const token = resolveGatewayToken();
const res = await invokeTool({
port: sharedPort,
const res = await invokeToolAuthed({
tool: "gateway",
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
@@ -417,12 +394,8 @@ describe("POST /tools/invoke", () => {
gateway: { tools: { allow: ["gateway"] } },
};
const token = resolveGatewayToken();
const res = await invokeTool({
port: sharedPort,
const res = await invokeToolAuthed({
tool: "gateway",
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
@@ -442,12 +415,8 @@ describe("POST /tools/invoke", () => {
gateway: { tools: { allow: ["gateway"], deny: ["gateway"] } },
};
const token = resolveGatewayToken();
const res = await invokeTool({
port: sharedPort,
const res = await invokeToolAuthed({
tool: "gateway",
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
@@ -477,19 +446,10 @@ describe("POST /tools/invoke", () => {
session: { mainKey: "primary" },
};
const token = resolveGatewayToken();
const resDefault = await invokeAgentsList({
port: sharedPort,
headers: { authorization: `Bearer ${token}` },
});
const resDefault = await invokeAgentsListAuthed();
expect(resDefault.status).toBe(200);
const resMain = await invokeAgentsList({
port: sharedPort,
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
const resMain = await invokeAgentsListAuthed({ sessionKey: "main" });
expect(resMain.status).toBe(200);
});
@@ -501,13 +461,9 @@ describe("POST /tools/invoke", () => {
},
};
const token = resolveGatewayToken();
const inputRes = await invokeTool({
port: sharedPort,
const inputRes = await invokeToolAuthed({
tool: "tools_invoke_test",
args: { mode: "input" },
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
expect(inputRes.status).toBe(400);
@@ -516,11 +472,9 @@ describe("POST /tools/invoke", () => {
expect(inputBody.error?.type).toBe("tool_error");
expect(inputBody.error?.message).toBe("mode invalid");
const crashRes = await invokeTool({
port: sharedPort,
const crashRes = await invokeToolAuthed({
tool: "tools_invoke_test",
args: { mode: "crash" },
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
expect(crashRes.status).toBe(500);

View File

@@ -50,31 +50,34 @@ describe("heartbeat transcript pruning", () => {
});
}
it("prunes transcript when heartbeat returns HEARTBEAT_OK", async () => {
async function runTranscriptScenario(params: {
sessionId: string;
reply: {
text: string;
usage: {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
};
};
expectPruned: boolean;
}) {
await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
const sessionKey = resolveMainSessionKey(undefined);
const sessionId = "test-session-prune";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
// Create a transcript with some existing content
const originalContent = await createTranscriptWithContent(transcriptPath, sessionId);
const transcriptPath = path.join(tmpDir, `${params.sessionId}.jsonl`);
const originalContent = await createTranscriptWithContent(transcriptPath, params.sessionId);
const originalSize = (await fs.stat(transcriptPath)).size;
// Seed session store
await seedSessionStore(storePath, sessionKey, {
sessionId,
sessionId: params.sessionId,
lastChannel: "telegram",
lastProvider: "telegram",
lastTo: "user123",
});
// Mock reply to return HEARTBEAT_OK (which triggers pruning)
replySpy.mockResolvedValueOnce({
text: "HEARTBEAT_OK",
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
});
replySpy.mockResolvedValueOnce(params.reply);
// Run heartbeat
const cfg = {
version: 1,
model: "test-model",
@@ -90,57 +93,36 @@ describe("heartbeat transcript pruning", () => {
deps: { sendTelegram: vi.fn() },
});
// Verify transcript was truncated back to original size
const finalContent = await fs.readFile(transcriptPath, "utf-8");
expect(finalContent).toBe(originalContent);
const finalSize = (await fs.stat(transcriptPath)).size;
expect(finalSize).toBe(originalSize);
if (params.expectPruned) {
const finalContent = await fs.readFile(transcriptPath, "utf-8");
expect(finalContent).toBe(originalContent);
expect(finalSize).toBe(originalSize);
return;
}
expect(finalSize).toBeGreaterThanOrEqual(originalSize);
});
}
it("prunes transcript when heartbeat returns HEARTBEAT_OK", async () => {
await runTranscriptScenario({
sessionId: "test-session-prune",
reply: {
text: "HEARTBEAT_OK",
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
},
expectPruned: true,
});
});
it("does not prune transcript when heartbeat returns meaningful content", async () => {
await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
const sessionKey = resolveMainSessionKey(undefined);
const sessionId = "test-session-no-prune";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
// Create a transcript with some existing content
await createTranscriptWithContent(transcriptPath, sessionId);
const originalSize = (await fs.stat(transcriptPath)).size;
// Seed session store
await seedSessionStore(storePath, sessionKey, {
sessionId,
lastChannel: "telegram",
lastProvider: "telegram",
lastTo: "user123",
});
// Mock reply to return meaningful content (should NOT trigger pruning)
replySpy.mockResolvedValueOnce({
await runTranscriptScenario({
sessionId: "test-session-no-prune",
reply: {
text: "Alert: Something needs your attention!",
usage: { inputTokens: 10, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0 },
});
// Run heartbeat
const cfg = {
version: 1,
model: "test-model",
agent: { workspace: tmpDir },
sessionStore: storePath,
channels: { telegram: {} },
} as unknown as OpenClawConfig;
await runHeartbeatOnce({
agentId: undefined,
reason: "test",
cfg,
deps: { sendTelegram: vi.fn() },
});
// Verify transcript was NOT truncated (it may have grown with new entries)
const finalSize = (await fs.stat(transcriptPath)).size;
expect(finalSize).toBeGreaterThanOrEqual(originalSize);
},
expectPruned: false,
});
});
});

View File

@@ -46,6 +46,28 @@ async function createInstalledNpmPluginFixture(params: {
};
}
function createSinglePluginEntries(pluginId = "my-plugin") {
return {
[pluginId]: { enabled: true },
};
}
function createSinglePluginWithEmptySlotsConfig(): OpenClawConfig {
return {
plugins: {
entries: createSinglePluginEntries(),
slots: {},
},
};
}
async function createPluginDirFixture(baseDir: string, pluginId = "my-plugin") {
const pluginDir = path.join(baseDir, pluginId);
await fs.mkdir(pluginDir, { recursive: true });
await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin");
return pluginDir;
}
describe("removePluginFromConfig", () => {
it("removes plugin from entries", () => {
const config: OpenClawConfig = {
@@ -175,14 +197,7 @@ describe("removePluginFromConfig", () => {
});
it("removes plugins object when uninstall leaves only empty slots", () => {
const config: OpenClawConfig = {
plugins: {
entries: {
"my-plugin": { enabled: true },
},
slots: {},
},
};
const config = createSinglePluginWithEmptySlotsConfig();
const { config: result } = removePluginFromConfig(config, "my-plugin");
@@ -190,14 +205,7 @@ describe("removePluginFromConfig", () => {
});
it("cleans up empty slots object", () => {
const config: OpenClawConfig = {
plugins: {
entries: {
"my-plugin": { enabled: true },
},
slots: {},
},
};
const config = createSinglePluginWithEmptySlotsConfig();
const { config: result } = removePluginFromConfig(config, "my-plugin");
@@ -345,15 +353,11 @@ describe("uninstallPlugin", () => {
});
it("preserves directory for linked plugins", async () => {
const pluginDir = path.join(tempDir, "my-plugin");
await fs.mkdir(pluginDir, { recursive: true });
await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin");
const pluginDir = await createPluginDirFixture(tempDir);
const config: OpenClawConfig = {
plugins: {
entries: {
"my-plugin": { enabled: true },
},
entries: createSinglePluginEntries(),
installs: {
"my-plugin": {
source: "path",
@@ -383,15 +387,11 @@ describe("uninstallPlugin", () => {
});
it("does not delete directory when deleteFiles is false", async () => {
const pluginDir = path.join(tempDir, "my-plugin");
await fs.mkdir(pluginDir, { recursive: true });
await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin");
const pluginDir = await createPluginDirFixture(tempDir);
const config: OpenClawConfig = {
plugins: {
entries: {
"my-plugin": { enabled: true },
},
entries: createSinglePluginEntries(),
installs: {
"my-plugin": {
source: "npm",

View File

@@ -8,6 +8,26 @@ vi.mock("../../auto-reply/commands-registry.js", () => {
const reportExternalCommand = { key: "reportexternal", nativeName: "reportexternal" };
const reportLongCommand = { key: "reportlong", nativeName: "reportlong" };
const unsafeConfirmCommand = { key: "unsafeconfirm", nativeName: "unsafeconfirm" };
const periodArg = { name: "period", description: "period" };
const hasNonEmptyArgValue = (values: unknown, key: string) => {
const raw =
typeof values === "object" && values !== null
? (values as Record<string, unknown>)[key]
: undefined;
return typeof raw === "string" && raw.trim().length > 0;
};
const resolvePeriodMenu = (
params: { args?: { values?: unknown } },
choices: Array<{
value: string;
label: string;
}>,
) => {
if (hasNonEmptyArgValue(params.args?.values, "period")) {
return null;
}
return { arg: periodArg, choices };
};
return {
buildCommandTextFromArgs: (
@@ -92,53 +112,32 @@ vi.mock("../../auto-reply/commands-registry.js", () => {
args?: { values?: unknown };
}) => {
if (params.command?.key === "report") {
const values = (params.args?.values ?? {}) as Record<string, unknown>;
if (typeof values.period === "string" && values.period.trim()) {
return null;
}
return {
arg: { name: "period", description: "period" },
choices: [
{ value: "day", label: "day" },
{ value: "week", label: "week" },
{ value: "month", label: "month" },
{ value: "quarter", label: "quarter" },
{ value: "year", label: "year" },
{ value: "all", label: "all" },
],
};
return resolvePeriodMenu(params, [
{ value: "day", label: "day" },
{ value: "week", label: "week" },
{ value: "month", label: "month" },
{ value: "quarter", label: "quarter" },
{ value: "year", label: "year" },
{ value: "all", label: "all" },
]);
}
if (params.command?.key === "reportlong") {
const values = (params.args?.values ?? {}) as Record<string, unknown>;
if (typeof values.period === "string" && values.period.trim()) {
return null;
}
return {
arg: { name: "period", description: "period" },
choices: [
{ value: "day", label: "day" },
{ value: "week", label: "week" },
{ value: "month", label: "month" },
{ value: "quarter", label: "quarter" },
{ value: "year", label: "year" },
{ value: "x".repeat(90), label: "long" },
],
};
return resolvePeriodMenu(params, [
{ value: "day", label: "day" },
{ value: "week", label: "week" },
{ value: "month", label: "month" },
{ value: "quarter", label: "quarter" },
{ value: "year", label: "year" },
{ value: "x".repeat(90), label: "long" },
]);
}
if (params.command?.key === "reportcompact") {
const values = (params.args?.values ?? {}) as Record<string, unknown>;
if (typeof values.period === "string" && values.period.trim()) {
return null;
}
return {
arg: { name: "period", description: "period" },
choices: [
{ value: "day", label: "day" },
{ value: "week", label: "week" },
{ value: "month", label: "month" },
{ value: "quarter", label: "quarter" },
],
};
return resolvePeriodMenu(params, [
{ value: "day", label: "day" },
{ value: "week", label: "week" },
{ value: "month", label: "month" },
{ value: "quarter", label: "quarter" },
]);
}
if (params.command?.key === "reportexternal") {
return {
@@ -651,6 +650,28 @@ async function runSlashHandler(params: {
return { respond, ack };
}
async function registerAndRunPolicySlash(params: {
harness: ReturnType<typeof createPolicyHarness>;
command?: Partial<{
user_id: string;
user_name: string;
channel_id: string;
channel_name: string;
text: string;
trigger_id: string;
}>;
}) {
await registerCommands(params.harness.ctx, params.harness.account);
return await runSlashHandler({
commands: params.harness.commands,
command: {
channel_id: params.command?.channel_id ?? params.harness.channelId,
channel_name: params.command?.channel_name ?? params.harness.channelName,
...params.command,
},
});
}
function expectChannelBlockedResponse(respond: ReturnType<typeof vi.fn>) {
expect(dispatchMock).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith({
@@ -669,21 +690,13 @@ function expectUnauthorizedResponse(respond: ReturnType<typeof vi.fn>) {
describe("slack slash commands channel policy", () => {
it("allows unlisted channels when groupPolicy is open", async () => {
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
const harness = createPolicyHarness({
groupPolicy: "open",
channelsConfig: { C_LISTED: { requireMention: true } },
channelId: "C_UNLISTED",
channelName: "unlisted",
});
await registerCommands(ctx, account);
const { respond } = await runSlashHandler({
commands,
command: {
channel_id: channelId,
channel_name: channelName,
},
});
const { respond } = await registerAndRunPolicySlash({ harness });
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(respond).not.toHaveBeenCalledWith(
@@ -692,41 +705,25 @@ describe("slack slash commands channel policy", () => {
});
it("blocks explicitly denied channels when groupPolicy is open", async () => {
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
const harness = createPolicyHarness({
groupPolicy: "open",
channelsConfig: { C_DENIED: { allow: false } },
channelId: "C_DENIED",
channelName: "denied",
});
await registerCommands(ctx, account);
const { respond } = await runSlashHandler({
commands,
command: {
channel_id: channelId,
channel_name: channelName,
},
});
const { respond } = await registerAndRunPolicySlash({ harness });
expectChannelBlockedResponse(respond);
});
it("blocks unlisted channels when groupPolicy is allowlist", async () => {
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
const harness = createPolicyHarness({
groupPolicy: "allowlist",
channelsConfig: { C_LISTED: { requireMention: true } },
channelId: "C_UNLISTED",
channelName: "unlisted",
});
await registerCommands(ctx, account);
const { respond } = await runSlashHandler({
commands,
command: {
channel_id: channelId,
channel_name: channelName,
},
});
const { respond } = await registerAndRunPolicySlash({ harness });
expectChannelBlockedResponse(respond);
});
@@ -734,36 +731,26 @@ describe("slack slash commands channel policy", () => {
describe("slack slash commands access groups", () => {
it("fails closed when channel type lookup returns empty for channels", async () => {
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
const harness = createPolicyHarness({
allowFrom: [],
channelId: "C_UNKNOWN",
channelName: "unknown",
resolveChannelName: async () => ({}),
});
await registerCommands(ctx, account);
const { respond } = await runSlashHandler({
commands,
command: {
channel_id: channelId,
channel_name: channelName,
},
});
const { respond } = await registerAndRunPolicySlash({ harness });
expectUnauthorizedResponse(respond);
});
it("still treats D-prefixed channel ids as DMs when lookup fails", async () => {
const { commands, ctx, account } = createPolicyHarness({
const harness = createPolicyHarness({
allowFrom: [],
channelId: "D123",
channelName: "notdirectmessage",
resolveChannelName: async () => ({}),
});
await registerCommands(ctx, account);
const { respond } = await runSlashHandler({
commands,
const { respond } = await registerAndRunPolicySlash({
harness,
command: {
channel_id: "D123",
channel_name: "notdirectmessage",
@@ -781,16 +768,14 @@ describe("slack slash commands access groups", () => {
});
it("computes CommandAuthorized for DM slash commands when dmPolicy is open", async () => {
const { commands, ctx, account } = createPolicyHarness({
const harness = createPolicyHarness({
allowFrom: ["U_OWNER"],
channelId: "D999",
channelName: "directmessage",
resolveChannelName: async () => ({ name: "directmessage", type: "im" }),
});
await registerCommands(ctx, account);
await runSlashHandler({
commands,
await registerAndRunPolicySlash({
harness,
command: {
user_id: "U_ATTACKER",
user_name: "Mallory",
@@ -807,21 +792,13 @@ describe("slack slash commands access groups", () => {
});
it("enforces access-group gating when lookup fails for private channels", async () => {
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
const harness = createPolicyHarness({
allowFrom: [],
channelId: "G123",
channelName: "private",
resolveChannelName: async () => ({}),
});
await registerCommands(ctx, account);
const { respond } = await runSlashHandler({
commands,
command: {
channel_id: channelId,
channel_name: channelName,
},
});
const { respond } = await registerAndRunPolicySlash({ harness });
expectUnauthorizedResponse(respond);
});

View File

@@ -27,6 +27,8 @@ vi.mock("../sticker-cache.js", () => ({
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const { resolveMedia } = await import("./delivery.js");
const MAX_MEDIA_BYTES = 10_000_000;
const BOT_TOKEN = "tok123";
function makeCtx(
mediaField: "voice" | "audio" | "photo" | "video",
@@ -61,6 +63,25 @@ function makeCtx(
};
}
function setupTransientGetFileRetry() {
const getFile = vi
.fn()
.mockRejectedValueOnce(new Error("Network request for 'getFile' failed!"))
.mockResolvedValueOnce({ file_path: "voice/file_0.oga" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("audio"),
contentType: "audio/ogg",
fileName: "file_0.oga",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/file_0.oga",
contentType: "audio/ogg",
});
return getFile;
}
describe("resolveMedia getFile retry", () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -73,22 +94,8 @@ describe("resolveMedia getFile retry", () => {
});
it("retries getFile on transient failure and succeeds on second attempt", async () => {
const getFile = vi
.fn()
.mockRejectedValueOnce(new Error("Network request for 'getFile' failed!"))
.mockResolvedValueOnce({ file_path: "voice/file_0.oga" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("audio"),
contentType: "audio/ogg",
fileName: "file_0.oga",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/file_0.oga",
contentType: "audio/ogg",
});
const promise = resolveMedia(makeCtx("voice", getFile), 10_000_000, "tok123");
const getFile = setupTransientGetFileRetry();
const promise = resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
await vi.advanceTimersByTimeAsync(5000);
const result = await promise;
@@ -101,7 +108,7 @@ describe("resolveMedia getFile retry", () => {
it("returns null when all getFile retries fail so message is not dropped", async () => {
const getFile = vi.fn().mockRejectedValue(new Error("Network request for 'getFile' failed!"));
const promise = resolveMedia(makeCtx("voice", getFile), 10_000_000, "tok123");
const promise = resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
await vi.advanceTimersByTimeAsync(15000);
const result = await promise;
@@ -113,34 +120,26 @@ describe("resolveMedia getFile retry", () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "voice/file_0.oga" });
fetchRemoteMedia.mockRejectedValueOnce(new Error("download failed"));
await expect(resolveMedia(makeCtx("voice", getFile), 10_000_000, "tok123")).rejects.toThrow(
"download failed",
);
await expect(
resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN),
).rejects.toThrow("download failed");
expect(getFile).toHaveBeenCalledTimes(1);
});
it("returns null for photo when getFile exhausts retries", async () => {
const getFile = vi.fn().mockRejectedValue(new Error("HttpError: Network error"));
it.each(["photo", "video"] as const)(
"returns null for %s when getFile exhausts retries",
async (mediaField) => {
const getFile = vi.fn().mockRejectedValue(new Error("HttpError: Network error"));
const promise = resolveMedia(makeCtx("photo", getFile), 10_000_000, "tok123");
await vi.advanceTimersByTimeAsync(15000);
const result = await promise;
const promise = resolveMedia(makeCtx(mediaField, getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
await vi.advanceTimersByTimeAsync(15000);
const result = await promise;
expect(getFile).toHaveBeenCalledTimes(3);
expect(result).toBeNull();
});
it("returns null for video when getFile exhausts retries", async () => {
const getFile = vi.fn().mockRejectedValue(new Error("HttpError: Network error"));
const promise = resolveMedia(makeCtx("video", getFile), 10_000_000, "tok123");
await vi.advanceTimersByTimeAsync(15000);
const result = await promise;
expect(getFile).toHaveBeenCalledTimes(3);
expect(result).toBeNull();
});
expect(getFile).toHaveBeenCalledTimes(3);
expect(result).toBeNull();
},
);
it("does not retry 'file is too big' error (400 Bad Request) and returns null", async () => {
// Simulate Telegram Bot API error when file exceeds 20MB limit
@@ -149,7 +148,7 @@ describe("resolveMedia getFile retry", () => {
);
const getFile = vi.fn().mockRejectedValue(fileTooBigError);
const result = await resolveMedia(makeCtx("video", getFile), 10_000_000, "tok123");
const result = await resolveMedia(makeCtx("video", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
// Should NOT retry - "file is too big" is a permanent error, not transient
expect(getFile).toHaveBeenCalledTimes(1);
@@ -165,54 +164,30 @@ describe("resolveMedia getFile retry", () => {
);
const getFile = vi.fn().mockRejectedValue(fileTooBigError);
const result = await resolveMedia(makeCtx("video", getFile), 10_000_000, "tok123");
const result = await resolveMedia(makeCtx("video", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
expect(getFile).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
});
it("returns null for audio when file is too big", async () => {
const fileTooBigError = new Error(
"GrammyError: Call to 'getFile' failed! (400: Bad Request: file is too big)",
);
const getFile = vi.fn().mockRejectedValue(fileTooBigError);
it.each(["audio", "voice"] as const)(
"returns null for %s when file is too big",
async (mediaField) => {
const fileTooBigError = new Error(
"GrammyError: Call to 'getFile' failed! (400: Bad Request: file is too big)",
);
const getFile = vi.fn().mockRejectedValue(fileTooBigError);
const result = await resolveMedia(makeCtx("audio", getFile), 10_000_000, "tok123");
const result = await resolveMedia(makeCtx(mediaField, getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
expect(getFile).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
});
it("returns null for voice when file is too big", async () => {
const fileTooBigError = new Error(
"GrammyError: Call to 'getFile' failed! (400: Bad Request: file is too big)",
);
const getFile = vi.fn().mockRejectedValue(fileTooBigError);
const result = await resolveMedia(makeCtx("voice", getFile), 10_000_000, "tok123");
expect(getFile).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
});
expect(getFile).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
},
);
it("still retries transient errors even after encountering file too big in different call", async () => {
// First call with transient error should retry
const getFile = vi
.fn()
.mockRejectedValueOnce(new Error("Network request for 'getFile' failed!"))
.mockResolvedValueOnce({ file_path: "voice/file_0.oga" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("audio"),
contentType: "audio/ogg",
fileName: "file_0.oga",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/file_0.oga",
contentType: "audio/ogg",
});
const promise = resolveMedia(makeCtx("voice", getFile), 10_000_000, "tok123");
const getFile = setupTransientGetFileRetry();
const promise = resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
await vi.advanceTimersByTimeAsync(5000);
const result = await promise;

View File

@@ -83,14 +83,33 @@ describe("tui-event-handlers: handleAgentEvent", () => {
};
};
it("processes tool events when runId matches activeChatRunId (even if sessionId differs)", () => {
const state = makeState({ currentSessionId: "session-xyz", activeChatRunId: "run-123" });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
const createHandlersHarness = (params?: {
state?: Partial<TuiStateAccess>;
chatLog?: HandlerChatLog;
}) => {
const state = makeState(params?.state);
const context = makeContext(state);
const chatLog = (params?.chatLog ?? context.chatLog) as MockChatLog & HandlerChatLog;
const handlers = createEventHandlers({
chatLog,
tui,
tui: context.tui,
state,
setActivityStatus,
setActivityStatus: context.setActivityStatus,
loadHistory: context.loadHistory,
isLocalRunId: context.isLocalRunId,
forgetLocalRunId: context.forgetLocalRunId,
});
return {
...context,
state,
chatLog,
...handlers,
};
};
it("processes tool events when runId matches activeChatRunId (even if sessionId differs)", () => {
const { chatLog, tui, handleAgentEvent } = createHandlersHarness({
state: { currentSessionId: "session-xyz", activeChatRunId: "run-123" },
});
const evt: AgentEvent = {
@@ -111,13 +130,8 @@ describe("tui-event-handlers: handleAgentEvent", () => {
});
it("ignores tool events when runId does not match activeChatRunId", () => {
const state = makeState({ activeChatRunId: "run-1" });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
const { chatLog, tui, handleAgentEvent } = createHandlersHarness({
state: { activeChatRunId: "run-1" },
});
const evt: AgentEvent = {
@@ -134,20 +148,17 @@ describe("tui-event-handlers: handleAgentEvent", () => {
});
it("processes lifecycle events when runId matches activeChatRunId", () => {
const state = makeState({ activeChatRunId: "run-9" });
const { tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
chatLog: {
startTool: vi.fn(),
updateToolResult: vi.fn(),
addSystem: vi.fn(),
updateAssistant: vi.fn(),
finalizeAssistant: vi.fn(),
dropAssistant: vi.fn(),
} as unknown as HandlerChatLog,
tui,
state,
setActivityStatus,
const chatLog = {
startTool: vi.fn(),
updateToolResult: vi.fn(),
addSystem: vi.fn(),
updateAssistant: vi.fn(),
finalizeAssistant: vi.fn(),
dropAssistant: vi.fn(),
} as unknown as HandlerChatLog;
const { tui, setActivityStatus, handleAgentEvent } = createHandlersHarness({
state: { activeChatRunId: "run-9" },
chatLog,
});
const evt: AgentEvent = {
@@ -163,13 +174,8 @@ describe("tui-event-handlers: handleAgentEvent", () => {
});
it("captures runId from chat events when activeChatRunId is unset", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
const { state, chatLog, handleChatEvent, handleAgentEvent } = createHandlersHarness({
state: { activeChatRunId: null },
});
const chatEvt: ChatEvent = {
@@ -195,13 +201,8 @@ describe("tui-event-handlers: handleAgentEvent", () => {
});
it("clears run mapping when the session changes", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
const { state, chatLog, tui, handleChatEvent, handleAgentEvent } = createHandlersHarness({
state: { activeChatRunId: null },
});
handleChatEvent({
@@ -226,13 +227,8 @@ describe("tui-event-handlers: handleAgentEvent", () => {
});
it("accepts tool events after chat final for the same run", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
const { state, chatLog, tui, handleChatEvent, handleAgentEvent } = createHandlersHarness({
state: { activeChatRunId: null },
});
handleChatEvent({
@@ -253,14 +249,10 @@ describe("tui-event-handlers: handleAgentEvent", () => {
});
it("ignores lifecycle updates for non-active runs in the same session", () => {
const state = makeState({ activeChatRunId: "run-active" });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
});
const { state, tui, setActivityStatus, handleChatEvent, handleAgentEvent } =
createHandlersHarness({
state: { activeChatRunId: "run-active" },
});
handleChatEvent({
runId: "run-other",
@@ -282,16 +274,11 @@ describe("tui-event-handlers: handleAgentEvent", () => {
});
it("suppresses tool events when verbose is off", () => {
const state = makeState({
activeChatRunId: "run-123",
sessionInfo: { verboseLevel: "off" },
});
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
const { chatLog, tui, handleAgentEvent } = createHandlersHarness({
state: {
activeChatRunId: "run-123",
sessionInfo: { verboseLevel: "off" },
},
});
handleAgentEvent({
@@ -305,16 +292,11 @@ describe("tui-event-handlers: handleAgentEvent", () => {
});
it("omits tool output when verbose is on (non-full)", () => {
const state = makeState({
activeChatRunId: "run-123",
sessionInfo: { verboseLevel: "on" },
});
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
const { chatLog, handleAgentEvent } = createHandlersHarness({
state: {
activeChatRunId: "run-123",
sessionInfo: { verboseLevel: "on" },
},
});
handleAgentEvent({
@@ -349,17 +331,8 @@ describe("tui-event-handlers: handleAgentEvent", () => {
});
it("refreshes history after a non-local chat final", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus, loadHistory, isLocalRunId, forgetLocalRunId } =
makeContext(state);
const { handleChatEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
loadHistory,
isLocalRunId,
forgetLocalRunId,
const { state, loadHistory, handleChatEvent } = createHandlersHarness({
state: { activeChatRunId: null },
});
handleChatEvent({
@@ -373,18 +346,10 @@ describe("tui-event-handlers: handleAgentEvent", () => {
});
function createConcurrentRunHarness(localContent = "partial") {
const state = makeState({ activeChatRunId: "run-active" });
const { chatLog, tui, setActivityStatus, loadHistory, isLocalRunId, forgetLocalRunId } =
makeContext(state);
const { handleChatEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
loadHistory,
isLocalRunId,
forgetLocalRunId,
});
const { state, chatLog, setActivityStatus, loadHistory, handleChatEvent } =
createHandlersHarness({
state: { activeChatRunId: "run-active" },
});
handleChatEvent({
runId: "run-active",
@@ -446,13 +411,8 @@ describe("tui-event-handlers: handleAgentEvent", () => {
});
it("drops streaming assistant when chat final has no message", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent } = createEventHandlers({
chatLog,
tui,
state,
setActivityStatus,
const { state, chatLog, handleChatEvent } = createHandlersHarness({
state: { activeChatRunId: null },
});
handleChatEvent({