Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions tests/helpers/gptTools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,114 @@ describe("executeTools", () => {
const res = await tools.executeTools(toolCalls, [], cfg, baseMsg);
expect(res).toEqual([{ content: "Tool not found: missing" }]);
});

it("formats expertizeme search params and calls tool", async () => {
const toolCalls: ChatCompletionMessageToolCall[] = [
{
id: "1",
type: "function",
function: {
name: "expertizeme_search_items",
arguments: JSON.stringify({
query: "foo",
filters: [{ field: "title", operator: "not", value: ["a", "b"] }],
sortField: "date",
sortDirection: "desc",
groupBy: "author",
}),
},
},
];

const toolFn = jest.fn().mockResolvedValue({ content: "ok" });
const chatTools: ChatToolType[] = [
{
name: "expertizeme_search_items",
module: {
description: "",
call: () => ({
functions: { get: () => toolFn, toolSpecs: { function: {} } },
}),
},
},
];
const cfg: ConfigChatType = { ...baseConfig, chatParams: {} };
await tools.executeTools(toolCalls, chatTools, cfg, baseMsg);
expect(toolFn).toHaveBeenCalledTimes(1);
const callArgs = mockSendToHttp.mock.calls[0];
expect(callArgs[1]).toContain("**Title**: not a or b");
});

it("retries tool once on 400 error", async () => {
const toolCalls: ChatCompletionMessageToolCall[] = [
{
id: "1",
type: "function",
function: { name: "foo", arguments: "{}" },
},
];
const error = new Error("Invalid parameter");
(error as Error & { status: number }).status = 400;
const toolFn = jest
.fn()
.mockRejectedValueOnce(error)
.mockResolvedValue({ content: "done" });
const chatTools: ChatToolType[] = [
{
name: "foo",
module: {
description: "",
call: () => ({
functions: { get: () => toolFn, toolSpecs: { function: {} } },
}),
},
},
];
const cfg: ConfigChatType = { ...baseConfig, chatParams: {} };
const res = await tools.executeTools(toolCalls, chatTools, cfg, baseMsg);
expect(res[0].content).toBe("done");
expect(toolFn).toHaveBeenCalledTimes(2);
});
});

describe("chatAsTool", () => {
it("throws when agent missing", () => {
mockUseConfig.mockReturnValue({ chats: [] });
const msg = { ...baseMsg };
const chatTool = tools.chatAsTool({
agent_name: "missing",
name: "tool",
description: "d",
msg,
prompt_append: "",
});
expect(() =>
chatTool.module.call(baseConfig, { id: 1 } as ThreadStateType),
).toThrow("Agent not found: missing");
});

it("sends answer and stops on first tool", async () => {
const agentCfg = { ...baseConfig, agent_name: "agent2" };
mockUseConfig.mockReturnValue({ chats: [agentCfg] });
mockRequestGptAnswer.mockResolvedValue({ content: "hi" });
const msg: Message.TextMessage = { ...baseMsg, text: "q" };
const chatTool = tools.chatAsTool({
agent_name: "agent2",
name: "tool",
description: "d",
tool_use_behavior: "stop_on_first_tool",
prompt_append: "",
msg,
});
const module = chatTool.module.call(baseConfig, {
id: 1,
} as ThreadStateType);
const fn = module.functions.get();
const res = await fn('{"input":"hi"}');
expect(res.content).toBe("");
expect(mockRequestGptAnswer).toHaveBeenCalledWith(msg, agentCfg);
expect(mockSendTelegramMessage).toHaveBeenCalledTimes(3);
const lastCall = mockSendTelegramMessage.mock.calls.pop();
expect(lastCall[4]).toBe(baseConfig);
});
});
172 changes: 172 additions & 0 deletions tests/helpers/llm.extras.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals";
import type { Message } from "telegraf/types";
import type { ConfigChatType, ThreadStateType } from "../../src/types";
import OpenAI from "openai";

const mockUseApi = jest.fn();
const mockUseLangfuse = jest.fn();
const mockObserveOpenAI = jest.fn();
const mockAddToHistory = jest.fn();
const mockForgetHistory = jest.fn();

jest.unstable_mockModule("../../src/helpers/useApi.ts", () => ({
useApi: (...args: unknown[]) => mockUseApi(...args),
}));

jest.unstable_mockModule("../../src/helpers/useLangfuse.ts", () => ({
default: (...args: unknown[]) => mockUseLangfuse(...args),
}));

jest.unstable_mockModule("langfuse", () => ({
observeOpenAI: (...args: unknown[]) => mockObserveOpenAI(...args),
}));

jest.unstable_mockModule("../../src/helpers/history.ts", () => ({
addToHistory: (...args: unknown[]) => mockAddToHistory(...args),
forgetHistory: (...args: unknown[]) => mockForgetHistory(...args),
}));

let llm: typeof import("../../src/helpers/gpt/llm.ts");

const baseMsg: Message.TextMessage = {
chat: { id: 1, type: "private", title: "chat" },
message_id: 1,
text: "hi",
from: { username: "u" },
} as Message.TextMessage;

const chatConfig: ConfigChatType = {
name: "chat",
completionParams: {},
chatParams: {},
toolParams: {},
local_model: "model",
} as ConfigChatType;

beforeEach(async () => {
jest.resetModules();
mockUseApi.mockReset();
mockUseLangfuse.mockReset();
mockObserveOpenAI.mockReset();
mockUseLangfuse.mockReturnValue({ trace: undefined });
const api = {
chat: {
completions: {
create: jest
.fn()
.mockResolvedValue({ choices: [{ message: { content: "a" } }] }),
},
},
};
const apiObserved = {
chat: {
completions: {
create: jest
.fn()
.mockResolvedValue({ choices: [{ message: { content: "b" } }] }),
},
},
};
mockUseApi.mockReturnValue(api);
mockObserveOpenAI.mockReturnValue(apiObserved);
llm = await import("../../src/helpers/gpt/llm.ts");
});

describe("llmCall", () => {
it("calls API directly when no trace", async () => {
mockUseLangfuse.mockReturnValue({ trace: undefined });
const params = { messages: [], model: "m" } as OpenAI.ChatCompletionCreateParams;
const result = await llm.llmCall({
apiParams: params,
msg: { ...baseMsg },
chatConfig,
});
expect(mockUseApi).toHaveBeenCalledWith("model");
expect(mockObserveOpenAI).not.toHaveBeenCalled();
expect(result.res).toEqual({ choices: [{ message: { content: "a" } }] });
});

it("wraps api when trace exists", async () => {
mockUseLangfuse.mockReturnValue({ trace: {} });
const params = { messages: [], model: "m" } as OpenAI.ChatCompletionCreateParams;
const result = await llm.llmCall({
apiParams: params,
msg: { ...baseMsg },
chatConfig,
generationName: "gen",
localModel: "other",
});
expect(mockUseApi).toHaveBeenCalledWith("other");
expect(mockObserveOpenAI).toHaveBeenCalled();
expect(result.res).toEqual({ choices: [{ message: { content: "b" } }] });
});
});

describe("requestGptAnswer", () => {
const threads: Record<number, ThreadStateType> = {};
const mockUseThreads = jest.fn(() => threads);
const mockResolveChatTools = jest.fn();
const mockGetToolsPrompts = jest.fn();
const mockGetSystemMessage = jest.fn();
const mockBuildMessages = jest.fn();
const mockReplaceUrl = jest.fn((s: string) => Promise.resolve(s));
const mockReplaceTool = jest.fn((s: string) => Promise.resolve(s));
const mockForward = jest.fn();

jest.unstable_mockModule("../../src/threads.ts", () => ({
useThreads: () => mockUseThreads(),
}));
jest.unstable_mockModule("../../src/helpers/gpt/tools.ts", () => ({
executeTools: jest.fn(),
resolveChatTools: (...args: unknown[]) => mockResolveChatTools(...args),
getToolsPrompts: (...args: unknown[]) => mockGetToolsPrompts(...args),
getToolsSystemMessages: jest.fn(),
}));
jest.unstable_mockModule("../../src/helpers/gpt/messages.ts", () => ({
getSystemMessage: (...args: unknown[]) => mockGetSystemMessage(...args),
buildMessages: (...args: unknown[]) => mockBuildMessages(...args),
}));
jest.unstable_mockModule("../../src/helpers/placeholders.ts", () => ({
replaceUrlPlaceholders: (...args: unknown[]) => mockReplaceUrl(...args),
replaceToolPlaceholders: (...args: unknown[]) => mockReplaceTool(...args),
}));
jest.unstable_mockModule("../../src/telegram/send.ts", () => ({
getTelegramForwardedUser: (...args: unknown[]) => mockForward(...args),
sendTelegramMessage: jest.fn(),
getFullName: jest.fn(),
isAdminUser: jest.fn(),
}));

let requestGptAnswer: typeof llm.requestGptAnswer;

beforeEach(async () => {
jest.resetModules();
mockUseLangfuse.mockReturnValue({ trace: undefined });
mockUseThreads.mockClear();
mockResolveChatTools.mockResolvedValue([]);
mockGetToolsPrompts.mockResolvedValue([]);
mockGetSystemMessage.mockResolvedValue("sys {date}");
mockBuildMessages.mockResolvedValue([]);
mockForward.mockReturnValue("Bob");
const mod = await import("../../src/helpers/gpt/llm.ts");
requestGptAnswer = mod.requestGptAnswer;
});

it("returns undefined when no text", async () => {
const msg = {
chat: { id: 1, type: "private" },
message_id: 1,
} as Message.TextMessage;
const res = await requestGptAnswer(msg, chatConfig);
expect(res).toBeUndefined();
});

it("adds forwarded name and creates thread", async () => {
const msg: Message.TextMessage = { ...baseMsg };
const res = await requestGptAnswer(msg, chatConfig);
expect(mockForward).toHaveBeenCalledWith(msg, chatConfig);
expect(msg.text).toBe("Переслано от: Bob\nhi");
expect(threads[1]).toBeDefined();
expect(res).toEqual({ content: "a" });
});
});
20 changes: 12 additions & 8 deletions tests/helpers/stt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { jest, describe, it, expect, beforeEach } from "@jest/globals";

const mockTmpNameSync = jest.fn();
const mockExec = jest.fn(
(cmd: string, cb: (err: unknown, out: string) => void) => cb(null, "")
(cmd: string, cb: (err: unknown, out: string) => void) => cb(null, ""),
);
const mockAccess = jest.fn();
const mockReadFile = jest.fn();
Expand Down Expand Up @@ -55,7 +55,7 @@ describe("convertToMp3", () => {
expect(mockTmpNameSync).toHaveBeenCalled();
expect(mockExec).toHaveBeenCalledWith(
expect.stringContaining("ffmpeg"),
expect.any(Function)
expect.any(Function),
);
expect(res).toBe("/tmp/file.mp3");
});
Expand All @@ -73,7 +73,7 @@ describe("detectAudioFileLanguage", () => {
const res = await stt.detectAudioFileLanguage("/tmp/file.mp3");
expect(fetch).toHaveBeenCalledWith(
"http://base/detect-language",
expect.objectContaining({ method: "POST" })
expect.objectContaining({ method: "POST" }),
);
expect(res).toEqual({ lang: "en" });
});
Expand All @@ -89,7 +89,7 @@ describe("detectAudioFileLanguage", () => {
headers: { entries: () => [] },
} as never);
await expect(stt.detectAudioFileLanguage("/tmp/file.mp3")).rejects.toThrow(
"HTTP error! status: 400, body: err"
"HTTP error! status: 400, body: err",
);
});
});
Expand All @@ -101,7 +101,9 @@ describe("sendAudioWhisper", () => {
(fetch as unknown as jest.Mock)
.mockResolvedValueOnce({
ok: true,
text: jest.fn().mockResolvedValue('{"detected_language":"en"}' as never),
text: jest
.fn()
.mockResolvedValue('{"detected_language":"en"}' as never),
headers: { entries: () => [] },
} as never)
.mockResolvedValueOnce({
Expand All @@ -112,7 +114,7 @@ describe("sendAudioWhisper", () => {
const res = await stt.sendAudioWhisper({ mp3Path: "p", prompt: "hi" });
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("/asr?"),
expect.objectContaining({ method: "POST" })
expect.objectContaining({ method: "POST" }),
);
expect(res).toEqual({ text: "ok" });
});
Expand All @@ -123,7 +125,9 @@ describe("sendAudioWhisper", () => {
(fetch as unknown as jest.Mock)
.mockResolvedValueOnce({
ok: true,
text: jest.fn().mockResolvedValue('{"detected_language":"en"}' as never),
text: jest
.fn()
.mockResolvedValue('{"detected_language":"en"}' as never),
headers: { entries: () => [] },
} as never)
.mockResolvedValueOnce({
Expand All @@ -133,7 +137,7 @@ describe("sendAudioWhisper", () => {
headers: { entries: () => [] },
} as never);
await expect(
stt.sendAudioWhisper({ mp3Path: "p", prompt: "hi" })
stt.sendAudioWhisper({ mp3Path: "p", prompt: "hi" }),
).rejects.toThrow("HTTP error! status: 500, body: oops");
});
});
Loading