refactor(test): dedupe channel and monitor action suites
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user