test_Chat.js (10607B)
1 /* Any copyright is dedicated to the Public Domain. 2 * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 4 do_get_profile(); 5 6 const { ChatConversation } = ChromeUtils.importESModule( 7 "moz-src:///browser/components/aiwindow/ui/modules/ChatConversation.sys.mjs" 8 ); 9 const { SYSTEM_PROMPT_TYPE, MESSAGE_ROLE } = ChromeUtils.importESModule( 10 "moz-src:///browser/components/aiwindow/ui/modules/ChatConstants.sys.mjs" 11 ); 12 const { Chat } = ChromeUtils.importESModule( 13 "moz-src:///browser/components/aiwindow/models/Chat.sys.mjs" 14 ); 15 const { MODEL_FEATURES, openAIEngine } = ChromeUtils.importESModule( 16 "moz-src:///browser/components/aiwindow/models/Utils.sys.mjs" 17 ); 18 19 const { sinon } = ChromeUtils.importESModule( 20 "resource://testing-common/Sinon.sys.mjs" 21 ); 22 23 // Prefs for aiwindow 24 const PREF_API_KEY = "browser.aiwindow.apiKey"; 25 const PREF_ENDPOINT = "browser.aiwindow.endpoint"; 26 const PREF_MODEL = "browser.aiwindow.model"; 27 28 // Clean prefs after all tests 29 registerCleanupFunction(() => { 30 for (let pref of [PREF_API_KEY, PREF_ENDPOINT, PREF_MODEL]) { 31 if (Services.prefs.prefHasUserValue(pref)) { 32 Services.prefs.clearUserPref(pref); 33 } 34 } 35 }); 36 37 add_task(async function test_Chat_real_tools_are_registered() { 38 Assert.strictEqual( 39 typeof Chat.toolMap.get_open_tabs, 40 "function", 41 "get_open_tabs should be registered in toolMap" 42 ); 43 Assert.strictEqual( 44 typeof Chat.toolMap.search_browsing_history, 45 "function", 46 "search_browsing_history should be registered in toolMap" 47 ); 48 Assert.strictEqual( 49 typeof Chat.toolMap.get_page_content, 50 "function", 51 "get_page_content should be registered in toolMap" 52 ); 53 }); 54 55 add_task( 56 async function test_openAIEngine_build_with_chat_feature_and_nonexistent_model() { 57 Services.prefs.setStringPref(PREF_API_KEY, "test-key-123"); 58 Services.prefs.setStringPref(PREF_ENDPOINT, "https://example.test/v1"); 59 Services.prefs.setStringPref(PREF_MODEL, "nonexistent-model"); 60 61 const sb = sinon.createSandbox(); 62 try { 63 const fakeEngineInstance = { 64 runWithGenerator() { 65 throw new Error("not used"); 66 }, 67 }; 68 const stub = sb 69 .stub(openAIEngine, "_createEngine") 70 .resolves(fakeEngineInstance); 71 72 const engine = await openAIEngine.build(MODEL_FEATURES.CHAT); 73 74 Assert.ok( 75 engine instanceof openAIEngine, 76 "Should return openAIEngine instance" 77 ); 78 Assert.strictEqual( 79 engine.engineInstance, 80 fakeEngineInstance, 81 "Should store engine instance" 82 ); 83 Assert.ok(stub.calledOnce, "_createEngine should be called once"); 84 85 const opts = stub.firstCall.args[0]; 86 Assert.equal(opts.apiKey, "test-key-123", "apiKey should come from pref"); 87 Assert.equal( 88 opts.baseURL, 89 "https://example.test/v1", 90 "baseURL should come from pref" 91 ); 92 Assert.equal( 93 opts.modelId, 94 "qwen3-235b-a22b-instruct-2507-maas", 95 "modelId should fallback to default" 96 ); 97 } finally { 98 sb.restore(); 99 } 100 } 101 ); 102 103 add_task(async function test_Chat_fetchWithHistory_streams_and_forwards_args() { 104 const sb = sinon.createSandbox(); 105 try { 106 let capturedArgs = null; 107 let capturedOptions = null; 108 109 // Fake openAIEngine instance that directly has runWithGenerator method 110 const fakeEngine = { 111 runWithGenerator(options) { 112 capturedArgs = options.args; 113 capturedOptions = options; 114 async function* gen() { 115 yield { text: "Hello" }; 116 yield { text: " from" }; 117 yield { text: " fake engine!" }; 118 yield {}; // ignored by Chat 119 // No toolCalls yielded, so loop will exit after first iteration 120 } 121 return gen(); 122 }, 123 getConfig() { 124 return {}; 125 }, 126 }; 127 128 sb.stub(openAIEngine, "build").resolves(fakeEngine); 129 // sb.stub(Chat, "_getFxAccountToken").resolves("mock_token"); 130 131 const conversation = new ChatConversation({ 132 title: "chat title", 133 description: "chat desc", 134 pageUrl: new URL("https://www.firefox.com"), 135 pageMeta: {}, 136 }); 137 conversation.addSystemMessage( 138 SYSTEM_PROMPT_TYPE.TEXT, 139 "You are helpful", 140 0 141 ); 142 conversation.addUserMessage("Hi there", "https://www.firefox.com", 0); 143 144 // Collect streamed output 145 let acc = ""; 146 for await (const chunk of Chat.fetchWithHistory(conversation)) { 147 if (typeof chunk === "string") { 148 acc += chunk; 149 } 150 } 151 152 Assert.equal( 153 acc, 154 "Hello from fake engine!", 155 "Should concatenate streamed chunks" 156 ); 157 Assert.deepEqual( 158 [capturedArgs[0].body, capturedArgs[1].body], 159 [conversation.messages[0].body, conversation.messages[1].body], 160 "Should forward messages as args to runWithGenerator()" 161 ); 162 Assert.deepEqual( 163 capturedOptions.streamOptions.enabled, 164 true, 165 "Should enable streaming in runWithGenerator()" 166 ); 167 } finally { 168 sb.restore(); 169 } 170 }); 171 172 add_task(async function test_Chat_fetchWithHistory_handles_tool_calls() { 173 const sb = sinon.createSandbox(); 174 try { 175 let callCount = 0; 176 const fakeEngine = { 177 runWithGenerator(_options) { 178 callCount++; 179 async function* gen() { 180 if (callCount === 1) { 181 // First call: yield text and tool call 182 yield { text: "I'll help you with that. " }; 183 yield { 184 toolCalls: [ 185 { 186 id: "call_123", 187 function: { 188 name: "test_tool", 189 arguments: JSON.stringify({ param: "value" }), 190 }, 191 }, 192 ], 193 }; 194 } else { 195 // Second call: after tool execution 196 yield { text: "Tool executed successfully!" }; 197 } 198 } 199 return gen(); 200 }, 201 getConfig() { 202 return {}; 203 }, 204 }; 205 206 // Mock tool function 207 Chat.toolMap.test_tool = sb.stub().resolves("tool result"); 208 209 sb.stub(openAIEngine, "build").resolves(fakeEngine); 210 // sb.stub(Chat, "_getFxAccountToken").resolves("mock_token"); 211 212 const conversation = new ChatConversation({ 213 title: "chat title", 214 description: "chat desc", 215 pageUrl: new URL("https://www.firefox.com"), 216 pageMeta: {}, 217 }); 218 conversation.addUserMessage( 219 "Use the test tool", 220 "https://www.firefox.com", 221 0 222 ); 223 224 let textOutput = ""; 225 for await (const chunk of Chat.fetchWithHistory(conversation)) { 226 if (typeof chunk === "string") { 227 textOutput += chunk; 228 } 229 } 230 231 const toolCalls = conversation.messages.filter( 232 message => 233 message.role === MESSAGE_ROLE.ASSISTANT && 234 message?.content?.type === "function" 235 ); 236 237 Assert.equal( 238 textOutput, 239 "I'll help you with that. Tool executed successfully!", 240 "Should yield text from both model calls" 241 ); 242 Assert.equal(toolCalls.length, 1, "Should have one tool call"); 243 Assert.ok( 244 toolCalls[0].content.body.tool_calls[0].function.name.includes( 245 "test_tool" 246 ), 247 "Tool call log should mention tool name" 248 ); 249 Assert.ok(Chat.toolMap.test_tool.calledOnce, "Tool should be called once"); 250 Assert.deepEqual( 251 Chat.toolMap.test_tool.firstCall.args[0], 252 { param: "value" }, 253 "Tool should receive correct parameters" 254 ); 255 Assert.equal( 256 callCount, 257 2, 258 "Engine should be called twice (initial + after tool)" 259 ); 260 } finally { 261 sb.restore(); 262 delete Chat.toolMap.test_tool; 263 } 264 }); 265 266 add_task( 267 async function test_Chat_fetchWithHistory_propagates_engine_build_error() { 268 const sb = sinon.createSandbox(); 269 try { 270 const err = new Error("engine build failed"); 271 sb.stub(openAIEngine, "build").rejects(err); 272 // sb.stub(Chat, "_getFxAccountToken").resolves("mock_token"); 273 274 const conversation = new ChatConversation({ 275 title: "chat title", 276 description: "chat desc", 277 pageUrl: new URL("https://www.firefox.com"), 278 pageMeta: {}, 279 }); 280 conversation.addUserMessage("Hi", "https://www.firefox.com", 0); 281 282 const consume = async () => { 283 for await (const _chunk of Chat.fetchWithHistory(conversation)) { 284 void _chunk; 285 } 286 }; 287 288 await Assert.rejects( 289 consume(), 290 e => e === err, 291 "Should propagate the same error thrown by openAIEngine.build" 292 ); 293 } finally { 294 sb.restore(); 295 } 296 } 297 ); 298 299 add_task( 300 async function test_Chat_fetchWithHistory_handles_invalid_tool_arguments() { 301 const sb = sinon.createSandbox(); 302 try { 303 let callCount = 0; 304 const fakeEngine = { 305 runWithGenerator(_options) { 306 callCount++; 307 async function* gen() { 308 if (callCount === 1) { 309 // First call: yield text and invalid tool call 310 yield { text: "Using tool with bad args: " }; 311 yield { 312 toolCalls: [ 313 { 314 id: "call_456", 315 function: { 316 name: "test_tool", 317 arguments: "invalid json {", 318 }, 319 }, 320 ], 321 }; 322 } else { 323 // Second call: no more tool calls, should exit loop 324 yield { text: "Done." }; 325 } 326 } 327 return gen(); 328 }, 329 getConfig() { 330 return {}; 331 }, 332 }; 333 334 Chat.toolMap.test_tool = sb.stub().resolves("should not be called"); 335 336 sb.stub(openAIEngine, "build").resolves(fakeEngine); 337 // sb.stub(Chat, "_getFxAccountToken").resolves("mock_token"); 338 339 const conversation = new ChatConversation({ 340 title: "chat title", 341 description: "chat desc", 342 pageUrl: new URL("https://www.firefox.com"), 343 pageMeta: {}, 344 }); 345 conversation.addUserMessage( 346 "Test bad JSON", 347 "https://www.firefox.com", 348 0 349 ); 350 351 let textOutput = ""; 352 for await (const chunk of Chat.fetchWithHistory(conversation)) { 353 if (typeof chunk === "string") { 354 textOutput += chunk; 355 } 356 } 357 358 Assert.equal( 359 textOutput, 360 "Using tool with bad args: Done.", 361 "Should yield text from both calls" 362 ); 363 Assert.ok( 364 Chat.toolMap.test_tool.notCalled, 365 "Tool should not be called with invalid JSON" 366 ); 367 } finally { 368 sb.restore(); 369 delete Chat.toolMap.test_tool; 370 } 371 } 372 );