|
| 1 | +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; |
| 2 | +import type { Message } from "telegraf/types"; |
| 3 | +import type { ConfigChatType, GptContextType } from "../../src/types"; |
| 4 | +import OpenAI from "openai"; |
| 5 | + |
| 6 | +const mockExecuteTools = jest.fn(); |
| 7 | +const mockAddToHistory = jest.fn(); |
| 8 | +const mockForgetHistory = jest.fn(); |
| 9 | +const mockSendTelegramMessage = jest.fn(); |
| 10 | +const mockBuildMessages = jest.fn(); |
| 11 | +const mockUseApi = jest.fn(); |
| 12 | +const mockUseLangfuse = jest.fn(); |
| 13 | +const mockObserveOpenAI = jest.fn(); |
| 14 | +const mockUseConfig = jest.fn(); |
| 15 | +const mockUseThreads = jest.fn(); |
| 16 | +const mockResolveChatTools = jest.fn(); |
| 17 | +const mockGetToolsPrompts = jest.fn(); |
| 18 | +const mockGetSystemMessage = jest.fn(); |
| 19 | +const mockReplaceUrl = jest.fn((s: string) => Promise.resolve(s)); |
| 20 | +const mockReplaceTool = jest.fn((s: string) => Promise.resolve(s)); |
| 21 | +const mockLog = jest.fn(); |
| 22 | + |
| 23 | +jest.unstable_mockModule("../../src/helpers/gpt/tools.ts", () => ({ |
| 24 | + executeTools: (...args: unknown[]) => mockExecuteTools(...args), |
| 25 | + resolveChatTools: (...args: unknown[]) => mockResolveChatTools(...args), |
| 26 | + getToolsPrompts: (...args: unknown[]) => mockGetToolsPrompts(...args), |
| 27 | + getToolsSystemMessages: jest.fn(), |
| 28 | +})); |
| 29 | + |
| 30 | +jest.unstable_mockModule("../../src/helpers/history.ts", () => ({ |
| 31 | + addToHistory: (...args: unknown[]) => mockAddToHistory(...args), |
| 32 | + forgetHistory: (...args: unknown[]) => mockForgetHistory(...args), |
| 33 | +})); |
| 34 | + |
| 35 | +jest.unstable_mockModule("../../src/telegram/send.ts", () => ({ |
| 36 | + sendTelegramMessage: (...args: unknown[]) => mockSendTelegramMessage(...args), |
| 37 | + getTelegramForwardedUser: jest.fn(), |
| 38 | + getFullName: jest.fn(), |
| 39 | + isAdminUser: jest.fn(), |
| 40 | +})); |
| 41 | + |
| 42 | +jest.unstable_mockModule("../../src/helpers/gpt/messages.ts", () => ({ |
| 43 | + buildMessages: (...args: unknown[]) => mockBuildMessages(...args), |
| 44 | + getSystemMessage: (...args: unknown[]) => mockGetSystemMessage(...args), |
| 45 | +})); |
| 46 | + |
| 47 | +jest.unstable_mockModule("../../src/helpers/placeholders.ts", () => ({ |
| 48 | + replaceUrlPlaceholders: (...args: unknown[]) => mockReplaceUrl(...args), |
| 49 | + replaceToolPlaceholders: (...args: unknown[]) => mockReplaceTool(...args), |
| 50 | +})); |
| 51 | + |
| 52 | +jest.unstable_mockModule("../../src/helpers/useApi.ts", () => ({ |
| 53 | + useApi: (...args: unknown[]) => mockUseApi(...args), |
| 54 | +})); |
| 55 | + |
| 56 | +jest.unstable_mockModule("../../src/helpers/useLangfuse.ts", () => ({ |
| 57 | + default: (...args: unknown[]) => mockUseLangfuse(...args), |
| 58 | +})); |
| 59 | + |
| 60 | +jest.unstable_mockModule("langfuse", () => ({ |
| 61 | + observeOpenAI: (...args: unknown[]) => mockObserveOpenAI(...args), |
| 62 | +})); |
| 63 | + |
| 64 | +jest.unstable_mockModule("../../src/config.ts", () => ({ |
| 65 | + useConfig: () => mockUseConfig(), |
| 66 | +})); |
| 67 | + |
| 68 | +jest.unstable_mockModule("../../src/threads.ts", () => ({ |
| 69 | + useThreads: () => mockUseThreads(), |
| 70 | +})); |
| 71 | + |
| 72 | +jest.unstable_mockModule("../../src/helpers.ts", () => ({ |
| 73 | + log: (...args: unknown[]) => mockLog(...args), |
| 74 | +})); |
| 75 | + |
| 76 | +let llm: typeof import("../../src/helpers/gpt/llm.ts"); |
| 77 | +let handleModelAnswer: typeof import("../../src/helpers/gpt/llm.ts").handleModelAnswer; |
| 78 | +let processToolResults: typeof import("../../src/helpers/gpt/llm.ts").processToolResults; |
| 79 | +let requestGptAnswer: typeof import("../../src/helpers/gpt/llm.ts").requestGptAnswer; |
| 80 | + |
| 81 | +const baseMsg: Message.TextMessage = { |
| 82 | + chat: { id: 1, type: "private" }, |
| 83 | + message_id: 1, |
| 84 | + text: "hi", |
| 85 | +} as Message.TextMessage; |
| 86 | + |
| 87 | +const chatConfig: ConfigChatType = { |
| 88 | + name: "chat", |
| 89 | + completionParams: {}, |
| 90 | + chatParams: {}, |
| 91 | + toolParams: {}, |
| 92 | + local_model: "loc", |
| 93 | +} as ConfigChatType; |
| 94 | + |
| 95 | +const baseContext: GptContextType = { |
| 96 | + thread: { id: 1, messages: [], msgs: [], completionParams: {} }, |
| 97 | + messages: [], |
| 98 | + systemMessage: "", |
| 99 | + chatTools: [], |
| 100 | + prompts: [], |
| 101 | + tools: [], |
| 102 | +} as GptContextType; |
| 103 | + |
| 104 | +let mockCreate: jest.Mock; |
| 105 | + |
| 106 | +beforeEach(async () => { |
| 107 | + jest.resetModules(); |
| 108 | + mockExecuteTools.mockReset(); |
| 109 | + mockAddToHistory.mockReset(); |
| 110 | + mockForgetHistory.mockReset(); |
| 111 | + mockSendTelegramMessage.mockReset(); |
| 112 | + mockBuildMessages.mockReset(); |
| 113 | + mockUseApi.mockReset(); |
| 114 | + mockUseLangfuse.mockReset(); |
| 115 | + mockObserveOpenAI.mockReset(); |
| 116 | + mockUseConfig.mockReset(); |
| 117 | + mockUseThreads.mockReset(); |
| 118 | + mockResolveChatTools.mockReset(); |
| 119 | + mockGetToolsPrompts.mockReset(); |
| 120 | + mockGetSystemMessage.mockReset(); |
| 121 | + mockReplaceUrl.mockReset(); |
| 122 | + mockReplaceTool.mockReset(); |
| 123 | + mockLog.mockReset(); |
| 124 | + |
| 125 | + mockUseThreads.mockReturnValue({}); |
| 126 | + mockResolveChatTools.mockResolvedValue([]); |
| 127 | + mockGetToolsPrompts.mockResolvedValue([]); |
| 128 | + mockGetSystemMessage.mockResolvedValue(""); |
| 129 | + mockBuildMessages.mockResolvedValue([]); |
| 130 | + mockUseLangfuse.mockReturnValue({ trace: undefined }); |
| 131 | + mockObserveOpenAI.mockImplementation((api) => api); |
| 132 | + mockUseConfig.mockReturnValue({ local_models: [], chats: [], auth: {} }); |
| 133 | + mockReplaceUrl.mockImplementation((s: string) => Promise.resolve(s)); |
| 134 | + mockReplaceTool.mockImplementation((s: string) => Promise.resolve(s)); |
| 135 | + |
| 136 | + mockCreate = jest.fn(); |
| 137 | + mockUseApi.mockReturnValue({ chat: { completions: { create: mockCreate } } }); |
| 138 | + |
| 139 | + llm = await import("../../src/helpers/gpt/llm.ts"); |
| 140 | + handleModelAnswer = llm.handleModelAnswer; |
| 141 | + processToolResults = llm.processToolResults; |
| 142 | + requestGptAnswer = llm.requestGptAnswer; |
| 143 | +}); |
| 144 | + |
| 145 | +describe("handleModelAnswer with tool result", () => { |
| 146 | + it("returns processed result when tools respond", async () => { |
| 147 | + const toolCall = { |
| 148 | + id: "1", |
| 149 | + type: "function", |
| 150 | + function: { name: "tool", arguments: "{}" }, |
| 151 | + }; |
| 152 | + const res: OpenAI.ChatCompletion = { |
| 153 | + choices: [{ message: { role: "assistant", tool_calls: [toolCall] } }], |
| 154 | + } as OpenAI.ChatCompletion; |
| 155 | + mockExecuteTools.mockResolvedValue([{ content: "ok" }]); |
| 156 | + mockCreate.mockResolvedValueOnce({ |
| 157 | + choices: [{ message: { content: "final" } }], |
| 158 | + }); |
| 159 | + |
| 160 | + const result = await handleModelAnswer({ |
| 161 | + msg: { ...baseMsg }, |
| 162 | + res, |
| 163 | + chatConfig, |
| 164 | + expressRes: undefined, |
| 165 | + gptContext: { ...baseContext }, |
| 166 | + }); |
| 167 | + |
| 168 | + expect(result.content).toBe("final"); |
| 169 | + expect(mockExecuteTools).toHaveBeenCalled(); |
| 170 | + expect(mockCreate).toHaveBeenCalled(); |
| 171 | + }); |
| 172 | +}); |
| 173 | + |
| 174 | +describe("processToolResults non forget", () => { |
| 175 | + it("calls llm and returns answer", async () => { |
| 176 | + const tool_res = [{ content: "done" }]; |
| 177 | + const messageAgent: OpenAI.ChatCompletionMessage = { |
| 178 | + role: "assistant", |
| 179 | + content: "", |
| 180 | + tool_calls: [ |
| 181 | + { |
| 182 | + id: "1", |
| 183 | + type: "function", |
| 184 | + function: { name: "tool", arguments: "{}" }, |
| 185 | + }, |
| 186 | + ], |
| 187 | + } as OpenAI.ChatCompletionMessage; |
| 188 | + mockCreate.mockResolvedValueOnce({ |
| 189 | + choices: [{ message: { content: "answer" } }], |
| 190 | + }); |
| 191 | + |
| 192 | + const res = await processToolResults({ |
| 193 | + tool_res, |
| 194 | + messageAgent, |
| 195 | + chatConfig, |
| 196 | + msg: { ...baseMsg }, |
| 197 | + expressRes: undefined, |
| 198 | + noSendTelegram: false, |
| 199 | + gptContext: { ...baseContext }, |
| 200 | + level: 1, |
| 201 | + }); |
| 202 | + |
| 203 | + expect(res.content).toBe("answer"); |
| 204 | + expect(mockSendTelegramMessage).toHaveBeenCalled(); |
| 205 | + expect(mockCreate).toHaveBeenCalled(); |
| 206 | + }); |
| 207 | +}); |
| 208 | + |
| 209 | +describe("runEvaluatorWorkflow via requestGptAnswer", () => { |
| 210 | + const evaluatorChat = { |
| 211 | + name: "url", |
| 212 | + agent_name: "url", |
| 213 | + id: 2, |
| 214 | + completionParams: { model: "loc" }, |
| 215 | + systemMessage: "Check for URL in answer. 1 - no url, 5 - url present", |
| 216 | + } as ConfigChatType; |
| 217 | + |
| 218 | + it("improves answer when score low", async () => { |
| 219 | + mockUseConfig.mockReturnValue({ |
| 220 | + local_models: [{ name: "loc", model: "loc" }], |
| 221 | + chats: [evaluatorChat], |
| 222 | + auth: {}, |
| 223 | + }); |
| 224 | + const responses = [ |
| 225 | + { choices: [{ message: { content: "initial" } }] }, |
| 226 | + { |
| 227 | + choices: [ |
| 228 | + { |
| 229 | + message: { |
| 230 | + content: JSON.stringify({ |
| 231 | + score: 2, |
| 232 | + justification: "bad", |
| 233 | + is_complete: false, |
| 234 | + }), |
| 235 | + }, |
| 236 | + }, |
| 237 | + ], |
| 238 | + }, |
| 239 | + { choices: [{ message: { content: "fixed" } }] }, |
| 240 | + { |
| 241 | + choices: [ |
| 242 | + { |
| 243 | + message: { |
| 244 | + content: JSON.stringify({ |
| 245 | + score: 5, |
| 246 | + justification: "good", |
| 247 | + is_complete: true, |
| 248 | + }), |
| 249 | + }, |
| 250 | + }, |
| 251 | + ], |
| 252 | + }, |
| 253 | + ]; |
| 254 | + mockCreate.mockImplementation(() => Promise.resolve(responses.shift())); |
| 255 | + |
| 256 | + const configWithEval = { |
| 257 | + ...chatConfig, |
| 258 | + evaluators: [{ agent_name: "url" }], |
| 259 | + } as ConfigChatType; |
| 260 | + const res = await requestGptAnswer({ ...baseMsg }, configWithEval); |
| 261 | + expect(res?.content).toBe("fixed"); |
| 262 | + expect(mockCreate).toHaveBeenCalledTimes(4); |
| 263 | + }); |
| 264 | + |
| 265 | + it("keeps answer when score high", async () => { |
| 266 | + mockUseConfig.mockReturnValue({ |
| 267 | + local_models: [{ name: "loc", model: "loc" }], |
| 268 | + chats: [evaluatorChat], |
| 269 | + auth: {}, |
| 270 | + }); |
| 271 | + const responses = [ |
| 272 | + { choices: [{ message: { content: "initial" } }] }, |
| 273 | + { |
| 274 | + choices: [ |
| 275 | + { |
| 276 | + message: { |
| 277 | + content: JSON.stringify({ |
| 278 | + score: 5, |
| 279 | + justification: "good", |
| 280 | + is_complete: true, |
| 281 | + }), |
| 282 | + }, |
| 283 | + }, |
| 284 | + ], |
| 285 | + }, |
| 286 | + ]; |
| 287 | + mockCreate.mockImplementation(() => Promise.resolve(responses.shift())); |
| 288 | + |
| 289 | + const configWithEval = { |
| 290 | + ...chatConfig, |
| 291 | + evaluators: [{ agent_name: "url" }], |
| 292 | + } as ConfigChatType; |
| 293 | + const res = await requestGptAnswer({ ...baseMsg }, configWithEval); |
| 294 | + expect(res?.content).toBe("initial"); |
| 295 | + expect(mockCreate).toHaveBeenCalledTimes(2); |
| 296 | + }); |
| 297 | +}); |
0 commit comments