test: dedupe agent tests and session helpers

This commit is contained in:
Peter Steinberger
2026-02-22 17:11:17 +00:00
parent 415686244a
commit ad1072842e
31 changed files with 1021 additions and 1109 deletions

View File

@@ -15,6 +15,19 @@ vi.mock("../agent-scope.js", () => ({
import { createCronTool } from "./cron-tool.js";
describe("cron tool", () => {
function readGatewayCall(index = 0): { method?: string; params?: Record<string, unknown> } {
return (
(callGatewayMock.mock.calls[index]?.[0] as
| { method?: string; params?: Record<string, unknown> }
| undefined) ?? { method: undefined, params: undefined }
);
}
function readCronPayloadText(index = 0): string {
const params = readGatewayCall(index).params as { payload?: { text?: string } } | undefined;
return params?.payload?.text ?? "";
}
async function executeAddAndReadDelivery(params: {
callId: string;
agentSessionKey: string;
@@ -37,6 +50,39 @@ describe("cron tool", () => {
return call?.params?.delivery;
}
async function executeAddAndReadSessionKey(params: {
callId: string;
agentSessionKey: string;
jobSessionKey?: string;
}): Promise<string | undefined> {
const tool = createCronTool({ agentSessionKey: params.agentSessionKey });
await tool.execute(params.callId, {
action: "add",
job: {
name: "wake-up",
schedule: { at: new Date(123).toISOString() },
...(params.jobSessionKey ? { sessionKey: params.jobSessionKey } : {}),
payload: { kind: "systemEvent", text: "hello" },
},
});
const call = readGatewayCall();
const payload = call.params as { sessionKey?: string } | undefined;
return payload?.sessionKey;
}
async function executeAddWithContextMessages(callId: string, contextMessages: number) {
const tool = createCronTool({ agentSessionKey: "main" });
await tool.execute(callId, {
action: "add",
contextMessages,
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},
});
}
beforeEach(() => {
callGatewayMock.mockClear();
callGatewayMock.mockResolvedValue({ ok: true });
@@ -156,40 +202,22 @@ describe("cron tool", () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const callerSessionKey = "agent:main:discord:channel:ops";
const tool = createCronTool({ agentSessionKey: callerSessionKey });
await tool.execute("call-session-key", {
action: "add",
job: {
name: "wake-up",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "systemEvent", text: "hello" },
},
const sessionKey = await executeAddAndReadSessionKey({
callId: "call-session-key",
agentSessionKey: callerSessionKey,
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: { sessionKey?: string };
};
expect(call?.params?.sessionKey).toBe(callerSessionKey);
expect(sessionKey).toBe(callerSessionKey);
});
it("preserves explicit job.sessionKey on add", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createCronTool({ agentSessionKey: "agent:main:discord:channel:ops" });
await tool.execute("call-explicit-session-key", {
action: "add",
job: {
name: "wake-up",
schedule: { at: new Date(123).toISOString() },
sessionKey: "agent:main:telegram:group:-100123:topic:99",
payload: { kind: "systemEvent", text: "hello" },
},
const sessionKey = await executeAddAndReadSessionKey({
callId: "call-explicit-session-key",
agentSessionKey: "agent:main:discord:channel:ops",
jobSessionKey: "agent:main:telegram:group:-100123:topic:99",
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: { sessionKey?: string };
};
expect(call?.params?.sessionKey).toBe("agent:main:telegram:group:-100123:topic:99");
expect(sessionKey).toBe("agent:main:telegram:group:-100123:topic:99");
});
it("adds recent context for systemEvent reminders when contextMessages > 0", async () => {
@@ -206,30 +234,15 @@ describe("cron tool", () => {
})
.mockResolvedValueOnce({ ok: true });
const tool = createCronTool({ agentSessionKey: "main" });
await tool.execute("call3", {
action: "add",
contextMessages: 3,
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},
});
await executeAddWithContextMessages("call3", 3);
expect(callGatewayMock).toHaveBeenCalledTimes(2);
const historyCall = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: unknown;
};
const historyCall = readGatewayCall(0);
expect(historyCall.method).toBe("chat.history");
const cronCall = callGatewayMock.mock.calls[1]?.[0] as {
method?: string;
params?: { payload?: { text?: string } };
};
const cronCall = readGatewayCall(1);
expect(cronCall.method).toBe("cron.add");
const text = cronCall.params?.payload?.text ?? "";
const text = readCronPayloadText(1);
expect(text).toContain("Recent context:");
expect(text).toContain("User: Discussed Q2 budget");
expect(text).toContain("Assistant: We agreed to review on Tuesday.");
@@ -243,29 +256,15 @@ describe("cron tool", () => {
}));
callGatewayMock.mockResolvedValueOnce({ messages }).mockResolvedValueOnce({ ok: true });
const tool = createCronTool({ agentSessionKey: "main" });
await tool.execute("call5", {
action: "add",
contextMessages: 20,
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},
});
await executeAddWithContextMessages("call5", 20);
expect(callGatewayMock).toHaveBeenCalledTimes(2);
const historyCall = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: { limit?: number };
};
const historyCall = readGatewayCall(0);
expect(historyCall.method).toBe("chat.history");
expect(historyCall.params?.limit).toBe(10);
const historyParams = historyCall.params as { limit?: number } | undefined;
expect(historyParams?.limit).toBe(10);
const cronCall = callGatewayMock.mock.calls[1]?.[0] as {
params?: { payload?: { text?: string } };
};
const text = cronCall.params?.payload?.text ?? "";
const text = readCronPayloadText(1);
expect(text).not.toMatch(/Message 1\\b/);
expect(text).not.toMatch(/Message 2\\b/);
expect(text).toContain("Message 3");
@@ -287,12 +286,9 @@ describe("cron tool", () => {
// Should only call cron.add, not chat.history
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const cronCall = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: { payload?: { text?: string } };
};
const cronCall = readGatewayCall(0);
expect(cronCall.method).toBe("cron.add");
const text = cronCall.params?.payload?.text ?? "";
const text = readCronPayloadText(0);
expect(text).not.toContain("Recent context:");
});
@@ -462,42 +458,22 @@ describe("cron tool", () => {
it("does not infer delivery when mode is none", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" });
await tool.execute("call-none", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
delivery: { mode: "none" },
},
const delivery = await executeAddAndReadDelivery({
callId: "call-none",
agentSessionKey: "agent:main:discord:dm:buddy",
delivery: { mode: "none" },
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: { delivery?: { mode?: string; channel?: string; to?: string } };
};
expect(call?.params?.delivery).toEqual({ mode: "none" });
expect(delivery).toEqual({ mode: "none" });
});
it("does not infer announce delivery when mode is webhook", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" });
await tool.execute("call-webhook-explicit", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
},
const delivery = await executeAddAndReadDelivery({
callId: "call-webhook-explicit",
agentSessionKey: "agent:main:discord:dm:buddy",
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: { delivery?: { mode?: string; channel?: string; to?: string } };
};
expect(call?.params?.delivery).toEqual({
expect(delivery).toEqual({
mode: "webhook",
to: "https://example.invalid/cron-finished",
});

View File

@@ -32,6 +32,14 @@ function mockSendResult(overrides: { channel?: string; to?: string } = {}) {
} satisfies MessageActionRunResult);
}
function getToolProperties(tool: ReturnType<typeof createMessageTool>) {
return (tool.parameters as { properties?: Record<string, unknown> }).properties ?? {};
}
function getActionEnum(properties: Record<string, unknown>) {
return (properties.action as { enum?: string[] } | undefined)?.enum ?? [];
}
describe("message tool agent routing", () => {
it("derives agentId from the session key", async () => {
mockSendResult();
@@ -149,9 +157,8 @@ describe("message tool schema scoping", () => {
config: {} as never,
currentChannelProvider: "telegram",
});
const properties =
(tool.parameters as { properties?: Record<string, unknown> }).properties ?? {};
const actionEnum = (properties.action as { enum?: string[] } | undefined)?.enum ?? [];
const properties = getToolProperties(tool);
const actionEnum = getActionEnum(properties);
expect(properties.components).toBeUndefined();
expect(properties.buttons).toBeDefined();
@@ -179,9 +186,8 @@ describe("message tool schema scoping", () => {
config: {} as never,
currentChannelProvider: "discord",
});
const properties =
(tool.parameters as { properties?: Record<string, unknown> }).properties ?? {};
const actionEnum = (properties.action as { enum?: string[] } | undefined)?.enum ?? [];
const properties = getToolProperties(tool);
const actionEnum = getActionEnum(properties);
expect(properties.components).toBeDefined();
expect(properties.buttons).toBeUndefined();

View File

@@ -49,6 +49,30 @@ describe("handleSlackAction", () => {
} as OpenClawConfig;
}
function createReplyToFirstContext(hasRepliedRef: { value: boolean }) {
return {
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "first" as const,
hasRepliedRef,
};
}
async function resolveReadToken(cfg: OpenClawConfig): Promise<string | undefined> {
readSlackMessages.mockClear();
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg);
const opts = readSlackMessages.mock.calls[0]?.[1] as { token?: string } | undefined;
return opts?.token;
}
async function resolveSendToken(cfg: OpenClawConfig): Promise<string | undefined> {
sendSlackMessage.mockClear();
await handleSlackAction({ action: "sendMessage", to: "channel:C1", content: "Hello" }, cfg);
const opts = sendSlackMessage.mock.calls[0]?.[2] as { token?: string } | undefined;
return opts?.token;
}
beforeEach(() => {
vi.clearAllMocks();
});
@@ -285,12 +309,7 @@ describe("handleSlackAction", () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
sendSlackMessage.mockClear();
const hasRepliedRef = { value: false };
const context = {
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "first" as const,
hasRepliedRef,
};
const context = createReplyToFirstContext(hasRepliedRef);
// First message should be threaded
await handleSlackAction(
@@ -322,12 +341,7 @@ describe("handleSlackAction", () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
sendSlackMessage.mockClear();
const hasRepliedRef = { value: false };
const context = {
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "first" as const,
hasRepliedRef,
};
const context = createReplyToFirstContext(hasRepliedRef);
await handleSlackAction(
{
@@ -521,32 +535,21 @@ describe("handleSlackAction", () => {
const cfg = {
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
} as OpenClawConfig;
readSlackMessages.mockClear();
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg);
const opts = readSlackMessages.mock.calls[0]?.[1] as { token?: string } | undefined;
expect(opts?.token).toBe("xoxp-1");
expect(await resolveReadToken(cfg)).toBe("xoxp-1");
});
it("falls back to bot token for reads when user token missing", async () => {
const cfg = {
channels: { slack: { botToken: "xoxb-1" } },
} as OpenClawConfig;
readSlackMessages.mockClear();
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg);
const opts = readSlackMessages.mock.calls[0]?.[1] as { token?: string } | undefined;
expect(opts?.token).toBeUndefined();
expect(await resolveReadToken(cfg)).toBeUndefined();
});
it("uses bot token for writes when userTokenReadOnly is true", async () => {
const cfg = {
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
} as OpenClawConfig;
sendSlackMessage.mockClear();
await handleSlackAction({ action: "sendMessage", to: "channel:C1", content: "Hello" }, cfg);
const opts = sendSlackMessage.mock.calls[0]?.[2] as { token?: string } | undefined;
expect(opts?.token).toBeUndefined();
expect(await resolveSendToken(cfg)).toBeUndefined();
});
it("allows user token writes when bot token is missing", async () => {
@@ -555,10 +558,7 @@ describe("handleSlackAction", () => {
slack: { userToken: "xoxp-1", userTokenReadOnly: false },
},
} as OpenClawConfig;
sendSlackMessage.mockClear();
await handleSlackAction({ action: "sendMessage", to: "channel:C1", content: "Hello" }, cfg);
const opts = sendSlackMessage.mock.calls[0]?.[2] as { token?: string } | undefined;
expect(opts?.token).toBe("xoxp-1");
expect(await resolveSendToken(cfg)).toBe("xoxp-1");
});
it("returns all emojis when no limit is provided", async () => {

View File

@@ -468,6 +468,12 @@ function resolveSiteName(url: string | undefined): string | undefined {
}
}
async function throwWebSearchApiError(res: Response, providerLabel: string): Promise<never> {
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
const detail = detailResult.text;
throw new Error(`${providerLabel} API error (${res.status}): ${detail || res.statusText}`);
}
async function runPerplexitySearch(params: {
query: string;
apiKey: string;
@@ -508,9 +514,7 @@ async function runPerplexitySearch(params: {
});
if (!res.ok) {
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
const detail = detailResult.text;
throw new Error(`Perplexity API error (${res.status}): ${detail || res.statusText}`);
return throwWebSearchApiError(res, "Perplexity");
}
const data = (await res.json()) as PerplexitySearchResponse;
@@ -558,9 +562,7 @@ async function runGrokSearch(params: {
});
if (!res.ok) {
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
const detail = detailResult.text;
throw new Error(`xAI API error (${res.status}): ${detail || res.statusText}`);
return throwWebSearchApiError(res, "xAI");
}
const data = (await res.json()) as GrokSearchResponse;