# frozen_string_literal: true RSpec.describe AiTool do fab!(:llm_model) { Fabricate(:llm_model, name: "claude-2") } let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } fab!(:topic) fab!(:post) { Fabricate(:post, topic: topic, raw: "bananas are a tasty fruit") } fab!(:bot_user) { Discourse.system_user } def create_tool( parameters: nil, script: nil, rag_chunk_tokens: nil, rag_chunk_overlap_tokens: nil ) AiTool.create!( name: "test #{SecureRandom.uuid}", tool_name: "test_#{SecureRandom.uuid.underscore}", description: "test", parameters: parameters || [{ name: "query", type: "string", description: "perform a search" }], script: script || "function invoke(params) { return params; }", created_by_id: 1, summary: "Test tool summary", rag_chunk_tokens: rag_chunk_tokens || 374, rag_chunk_overlap_tokens: rag_chunk_overlap_tokens || 10, ) end it "it can run a basic tool" do tool = create_tool expect(tool.signature).to eq( { name: tool.tool_name, description: "test", parameters: [{ name: "query", type: "string", description: "perform a search" }], }, ) runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil) expect(runner.invoke).to eq("query" => "test") end it "can base64 encode binary HTTP responses" do # Create binary data with all possible byte values (0-255) binary_data = (0..255).map(&:chr).join expected_base64 = Base64.strict_encode64(binary_data) script = <<~JS function invoke(params) { const result = http.post("https://example.com/binary", { body: "test", base64Encode: true }); return result.body; } JS tool = create_tool(script: script) runner = tool.runner({}, llm: nil, bot_user: nil) stub_request(:post, "https://example.com/binary").to_return( status: 200, body: binary_data, headers: { }, ) result = runner.invoke expect(result).to eq(expected_base64) # Verify we can decode back to original binary data expect(Base64.strict_decode64(result).bytes).to eq((0..255).to_a) end it "can base64 encode binary GET responses" do # Create binary data with all possible byte values (0-255) binary_data = (0..255).map(&:chr).join expected_base64 = Base64.strict_encode64(binary_data) script = <<~JS function invoke(params) { const result = http.get("https://example.com/binary", { base64Encode: true }); return result.body; } JS tool = create_tool(script: script) runner = tool.runner({}, llm: nil, bot_user: nil) stub_request(:get, "https://example.com/binary").to_return( status: 200, body: binary_data, headers: { }, ) result = runner.invoke expect(result).to eq(expected_base64) # Verify we can decode back to original binary data expect(Base64.strict_decode64(result).bytes).to eq((0..255).to_a) end it "can perform HTTP requests with various verbs" do %i[post put delete patch].each do |verb| script = <<~JS function invoke(params) { result = http.#{verb}("https://example.com/api", { headers: { TestHeader: "TestValue" }, body: JSON.stringify({ data: params.data }) } ); return result.body; } JS tool = create_tool(script: script) runner = tool.runner({ "data" => "test data" }, llm: nil, bot_user: nil) stub_request(verb, "https://example.com/api").with( body: "{\"data\":\"test data\"}", headers: { "Accept" => "*/*", "Testheader" => "TestValue", "User-Agent" => "Discourse AI Bot 1.0 (https://www.discourse.org)", }, ).to_return(status: 200, body: "Success", headers: {}) result = runner.invoke expect(result).to eq("Success") end end it "can perform GET HTTP requests, with 1 param" do script = <<~JS function invoke(params) { result = http.get("https://example.com/" + params.query); return result.body; } JS tool = create_tool(script: script) runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil) stub_request(:get, "https://example.com/test").with( headers: { "Accept" => "*/*", "User-Agent" => "Discourse AI Bot 1.0 (https://www.discourse.org)", }, ).to_return(status: 200, body: "Hello World", headers: {}) result = runner.invoke expect(result).to eq("Hello World") end it "is limited to MAX http requests" do script = <<~JS function invoke(params) { let i = 0; while (i < 21) { http.get("https://example.com/"); i += 1; } return "will not happen"; } JS tool = create_tool(script: script) runner = tool.runner({}, llm: nil, bot_user: nil) stub_request(:get, "https://example.com/").to_return( status: 200, body: "Hello World", headers: { }, ) expect { runner.invoke }.to raise_error(DiscourseAi::Personas::ToolRunner::TooManyRequestsError) end it "can perform GET HTTP requests" do script = <<~JS function invoke(params) { result = http.get("https://example.com/" + params.query, { headers: { TestHeader: "TestValue" } } ); return result.body; } JS tool = create_tool(script: script) runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil) stub_request(:get, "https://example.com/test").with( headers: { "Accept" => "*/*", "Testheader" => "TestValue", "User-Agent" => "Discourse AI Bot 1.0 (https://www.discourse.org)", }, ).to_return(status: 200, body: "Hello World", headers: {}) result = runner.invoke expect(result).to eq("Hello World") end it "will not timeout on slow HTTP reqs" do script = <<~JS function invoke(params) { result = http.get("https://example.com/" + params.query, { headers: { TestHeader: "TestValue" } } ); return result.body; } JS tool = create_tool(script: script) runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil) stub_request(:get, "https://example.com/test").to_return do sleep 0.01 { status: 200, body: "Hello World", headers: {} } end tool = create_tool(script: script) runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil) runner.timeout = 10 result = runner.invoke expect(result).to eq("Hello World") end it "has access to llm truncation tools" do script = <<~JS function invoke(params) { return llm.truncate("Hello World", 1); } JS tool = create_tool(script: script) runner = tool.runner({}, llm: llm, bot_user: nil) result = runner.invoke expect(result).to eq("Hello") end it "is able to run llm completions" do script = <<~JS function invoke(params) { return llm.generate("question two") + llm.generate( { messages: [ { type: "system", content: "system message" }, { type: "user", content: "user message" } ]} ); } JS tool = create_tool(script: script) result = nil prompts = nil responses = ["Hello ", "World"] DiscourseAi::Completions::Llm.with_prepared_responses(responses) do |_, _, _prompts| runner = tool.runner({}, llm: llm, bot_user: nil) result = runner.invoke prompts = _prompts end prompt = DiscourseAi::Completions::Prompt.new( "system message", messages: [{ type: :user, content: "user message" }], ) expect(result).to eq("Hello World") expect(prompts[0]).to eq("question two") expect(prompts[1]).to eq(prompt) end it "can timeout slow JS" do script = <<~JS function invoke(params) { while (true) {} } JS tool = create_tool(script: script) runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil) runner.timeout = 5 result = runner.invoke expect(result[:error]).to eq("Script terminated due to timeout") end context "when defining RAG fragments" do fab!(:cloudflare_embedding_def) before do SiteSetting.authorized_extensions = "txt" SiteSetting.ai_embeddings_selected_model = cloudflare_embedding_def.id SiteSetting.ai_embeddings_enabled = true Jobs.run_immediately! end def create_upload(content, filename) upload = nil Tempfile.create(filename) do |file| file.write(content) file.rewind upload = UploadCreator.new(file, filename).create_for(Discourse.system_user.id) end upload end def stub_embeddings # this is a trick, we get ever increasing embeddings, this gives us in turn # 100% consistent search results @counter = 0 stub_request(:post, cloudflare_embedding_def.url).to_return( status: 200, body: lambda { |req| { result: { data: [([@counter += 2] * 1024)] } }.to_json }, headers: { }, ) end it "allows search within uploads" do stub_embeddings upload1 = create_upload(<<~TXT, "test.txt") 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 TXT upload2 = create_upload(<<~TXT, "test.txt") 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 TXT tool = create_tool(rag_chunk_tokens: 10, rag_chunk_overlap_tokens: 4, script: <<~JS) function invoke(params) { let result1 = index.search("testing a search", { limit: 1 }); let result2 = index.search("testing another search", { limit: 3, filenames: ["test.txt"] }); return [result1, result2]; } JS RagDocumentFragment.link_target_and_uploads(tool, [upload1.id, upload2.id]) result = tool.runner({}, llm: nil, bot_user: nil).invoke expected = [ [{ "fragment" => "44 45 46 47 48 49 50", "metadata" => nil }], [ { "fragment" => "44 45 46 47 48 49 50", "metadata" => nil }, { "fragment" => "36 37 38 39 40 41 42 43 44 45", "metadata" => nil }, { "fragment" => "30 31 32 33 34 35 36 37", "metadata" => nil }, ], ] expect(result).to eq(expected) # will force a reindex tool.rag_chunk_tokens = 5 tool.rag_chunk_overlap_tokens = 2 tool.save! # this part of the API is a bit awkward, maybe we should do it # automatically RagDocumentFragment.update_target_uploads(tool, [upload1.id, upload2.id]) result = tool.runner({}, llm: nil, bot_user: nil).invoke # this is flaking, it is not critical cause it relies on vector search # that may not be 100% deterministic # expected = [ # [{ "fragment" => "48 49 50", "metadata" => nil }], # [ # { "fragment" => "48 49 50", "metadata" => nil }, # { "fragment" => "45 46 47", "metadata" => nil }, # { "fragment" => "42 43 44", "metadata" => nil }, # ], # ] expect(result.length).to eq(2) expect(result[0][0]["fragment"].length).to eq(8) expect(result[1].length).to eq(3) end end context "when using the topic API" do it "can fetch topic details" do script = <<~JS function invoke(params) { return discourse.getTopic(params.topic_id); } JS tool = create_tool(script: script) runner = tool.runner({ "topic_id" => topic.id }, llm: nil, bot_user: nil) result = runner.invoke expect(result["id"]).to eq(topic.id) expect(result["title"]).to eq(topic.title) expect(result["archetype"]).to eq("regular") expect(result["posts_count"]).to eq(1) end end context "when using the post API" do it "can fetch post details" do script = <<~JS function invoke(params) { const post = discourse.getPost(params.post_id); return { post: post, topic: post.topic } } JS tool = create_tool(script: script) runner = tool.runner({ "post_id" => post.id }, llm: nil, bot_user: nil) result = runner.invoke post_hash = result["post"] topic_hash = result["topic"] expect(post_hash["id"]).to eq(post.id) expect(post_hash["topic_id"]).to eq(topic.id) expect(post_hash["raw"]).to eq(post.raw) expect(topic_hash["id"]).to eq(topic.id) end end context "when using the search API" do before { SearchIndexer.enable } after { SearchIndexer.disable } it "can perform a discourse search" do SearchIndexer.index(topic, force: true) SearchIndexer.index(post, force: true) script = <<~JS function invoke(params) { return discourse.search({ search_query: params.query }); } JS tool = create_tool(script: script) runner = tool.runner({ "query" => "banana" }, llm: nil, bot_user: nil) result = runner.invoke expect(result["rows"].length).to be > 0 expect(result["rows"].first["title"]).to eq(topic.title) end end context "when using the chat API" do before(:each) do skip "Chat plugin tests skipped because Chat module is not defined." unless defined?(Chat) SiteSetting.chat_enabled = true end fab!(:chat_user) { Fabricate(:user) } fab!(:chat_channel) do Fabricate(:chat_channel).tap do |channel| Fabricate( :user_chat_channel_membership, user: chat_user, chat_channel: channel, following: true, ) end end it "can create a chat message" do script = <<~JS function invoke(params) { return discourse.createChatMessage({ channel_name: params.channel_name, username: params.username, message: params.message }); } JS tool = create_tool(script: script) runner = tool.runner( { "channel_name" => chat_channel.name, "username" => chat_user.username, "message" => "Hello from the tool!", }, llm: nil, bot_user: bot_user, # The user *running* the tool doesn't affect sender ) initial_message_count = Chat::Message.count result = runner.invoke expect(result["success"]).to eq(true), "Tool invocation failed: #{result["error"]}" expect(result["message"]).to eq("Hello from the tool!") expect(result["created_at"]).to be_present expect(result).not_to have_key("error") # Verify message was actually created in the database expect(Chat::Message.count).to eq(initial_message_count + 1) created_message = Chat::Message.find_by(id: result["message_id"]) expect(created_message).not_to be_nil expect(created_message.message).to eq("Hello from the tool!") expect(created_message.user_id).to eq(chat_user.id) # Message is sent AS the specified user expect(created_message.chat_channel_id).to eq(chat_channel.id) end it "can create a chat message using channel slug" do chat_channel.update!(name: "My Test Channel", slug: "my-test-channel") expect(chat_channel.slug).to eq("my-test-channel") script = <<~JS function invoke(params) { return discourse.createChatMessage({ channel_name: params.channel_slug, // Using slug here username: params.username, message: params.message }); } JS tool = create_tool(script: script) runner = tool.runner( { "channel_slug" => chat_channel.slug, "username" => chat_user.username, "message" => "Hello via slug!", }, llm: nil, bot_user: bot_user, ) result = runner.invoke expect(result["success"]).to eq(true), "Tool invocation failed: #{result["error"]}" # see: https://github.com/rubyjs/mini_racer/issues/348 # expect(result["message_id"]).to be_a(Integer) created_message = Chat::Message.find_by(id: result["message_id"]) expect(created_message).not_to be_nil expect(created_message.message).to eq("Hello via slug!") expect(created_message.chat_channel_id).to eq(chat_channel.id) end it "returns an error if the channel is not found" do script = <<~JS function invoke(params) { return discourse.createChatMessage({ channel_name: "non_existent_channel", username: params.username, message: params.message }); } JS tool = create_tool(script: script) runner = tool.runner( { "username" => chat_user.username, "message" => "Test" }, llm: nil, bot_user: bot_user, ) initial_message_count = Chat::Message.count expect { runner.invoke }.to raise_error( MiniRacer::RuntimeError, /Channel not found: non_existent_channel/, ) expect(Chat::Message.count).to eq(initial_message_count) # Verify no message created end it "returns an error if the user is not found" do script = <<~JS function invoke(params) { return discourse.createChatMessage({ channel_name: params.channel_name, username: "non_existent_user", message: params.message }); } JS tool = create_tool(script: script) runner = tool.runner( { "channel_name" => chat_channel.name, "message" => "Test" }, llm: nil, bot_user: bot_user, ) initial_message_count = Chat::Message.count expect { runner.invoke }.to raise_error( MiniRacer::RuntimeError, /User not found: non_existent_user/, ) expect(Chat::Message.count).to eq(initial_message_count) # Verify no message created end end context "when updating personas" do fab!(:ai_persona) do Fabricate(:ai_persona, name: "TestPersona", system_prompt: "Original prompt") end it "can update a persona with proper permissions" do script = <<~JS function invoke(params) { return discourse.updatePersona(params.persona_name, { system_prompt: params.new_prompt, temperature: 0.7, top_p: 0.9 }); } JS tool = create_tool(script: script) runner = tool.runner( { persona_name: "TestPersona", new_prompt: "Updated system prompt" }, llm: nil, bot_user: bot_user, ) result = runner.invoke expect(result["success"]).to eq(true) expect(result["persona"]["system_prompt"]).to eq("Updated system prompt") expect(result["persona"]["temperature"]).to eq(0.7) ai_persona.reload expect(ai_persona.system_prompt).to eq("Updated system prompt") expect(ai_persona.temperature).to eq(0.7) expect(ai_persona.top_p).to eq(0.9) end end context "when fetching persona information" do fab!(:ai_persona) do Fabricate( :ai_persona, name: "TestPersona", description: "Test description", system_prompt: "Test system prompt", temperature: 0.8, top_p: 0.9, vision_enabled: true, tools: ["Search", ["WebSearch", { param: "value" }, true]], ) end it "can fetch a persona by name" do script = <<~JS function invoke(params) { const persona = discourse.getPersona(params.persona_name); return persona; } JS tool = create_tool(script: script) runner = tool.runner({ persona_name: "TestPersona" }, llm: nil, bot_user: bot_user) result = runner.invoke expect(result["id"]).to eq(ai_persona.id) expect(result["name"]).to eq("TestPersona") expect(result["description"]).to eq("Test description") expect(result["system_prompt"]).to eq("Test system prompt") expect(result["temperature"]).to eq(0.8) expect(result["top_p"]).to eq(0.9) expect(result["vision_enabled"]).to eq(true) expect(result["tools"]).to include("Search") expect(result["tools"][1]).to be_a(Array) end it "raises an error when the persona doesn't exist" do script = <<~JS function invoke(params) { return discourse.getPersona("NonExistentPersona"); } JS tool = create_tool(script: script) runner = tool.runner({}, llm: nil, bot_user: bot_user) expect { runner.invoke }.to raise_error(MiniRacer::RuntimeError, /Persona not found/) end it "can update a persona after fetching it" do script = <<~JS function invoke(params) { const persona = discourse.getPersona("TestPersona"); return persona.update({ system_prompt: "Updated through getPersona().update()", temperature: 0.5 }); } JS tool = create_tool(script: script) runner = tool.runner({}, llm: nil, bot_user: bot_user) result = runner.invoke expect(result["success"]).to eq(true) ai_persona.reload expect(ai_persona.system_prompt).to eq("Updated through getPersona().update()") expect(ai_persona.temperature).to eq(0.5) end end it "can use sleep function with limits" do script = <<~JS function invoke(params) { let results = []; for (let i = 0; i < 3; i++) { let result = sleep(1); // 1ms sleep results.push(result); } return results; } JS tool = create_tool(script: script) runner = tool.runner({}, llm: nil, bot_user: nil) result = runner.invoke expect(result).to eq([{ "slept" => 1 }, { "slept" => 1 }, { "slept" => 1 }]) end let(:jpg) { plugin_file_from_fixtures("1x1.jpg") } describe "upload base64 encoding" do it "can get base64 data from upload ID and short URL" do upload = UploadCreator.new(jpg, "1x1.jpg").create_for(Discourse.system_user.id) # Test with upload ID script_id = <<~JS function invoke(params) { return upload.getBase64(params.upload_id, params.max_pixels); } JS tool = create_tool(script: script_id) runner = tool.runner( { "upload_id" => upload.id, "max_pixels" => 1_000_000 }, llm: nil, bot_user: nil, ) result_id = runner.invoke expect(result_id).to be_present expect(result_id).to be_a(String) expect(result_id.length).to be > 0 # Test with short URL script_url = <<~JS function invoke(params) { return upload.getBase64(params.short_url, params.max_pixels); } JS tool = create_tool(script: script_url) runner = tool.runner( { "short_url" => upload.short_url, "max_pixels" => 1_000_000 }, llm: nil, bot_user: nil, ) result_url = runner.invoke expect(result_url).to be_present expect(result_url).to be_a(String) expect(result_url).to eq(result_id) # Should return same base64 data # Test with invalid upload ID script_invalid = <<~JS function invoke(params) { return upload.getBase64(99999); } JS tool = create_tool(script: script_invalid) runner = tool.runner({}, llm: nil, bot_user: nil) result_invalid = runner.invoke expect(result_invalid).to be_nil end end describe "upload URL resolution" do it "can resolve upload short URLs to public URLs" do upload = Fabricate( :upload, sha1: "abcdef1234567890abcdef1234567890abcdef12", url: "/uploads/default/original/1X/test.jpg", original_filename: "test.jpg", ) script = <<~JS function invoke(params) { return upload.getUrl(params.short_url); } JS tool = create_tool(script: script) runner = tool.runner({ "short_url" => upload.short_url }, llm: nil, bot_user: nil) result = runner.invoke expect(result).to eq(GlobalPath.full_cdn_url(upload.url)) end it "returns null for invalid upload short URLs" do script = <<~JS function invoke(params) { return upload.getUrl(params.short_url); } JS tool = create_tool(script: script) runner = tool.runner({ "short_url" => "upload://invalid" }, llm: nil, bot_user: nil) result = runner.invoke expect(result).to be_nil end it "returns null for non-existent uploads" do script = <<~JS function invoke(params) { return upload.getUrl(params.short_url); } JS tool = create_tool(script: script) runner = tool.runner( { "short_url" => "upload://hwmUkTAL9mwhQuRMLsXw6tvDi5C.jpeg" }, llm: nil, bot_user: nil, ) result = runner.invoke expect(result).to be_nil end end context "when creating staged users" do it "can create a staged user" do script = <<~JS function invoke(params) { return discourse.createStagedUser({ email: params.email, username: params.username, name: params.name }); } JS tool = create_tool(script: script) runner = tool.runner( { email: "testuser@example.com", username: "testuser123", name: "Test User" }, llm: nil, bot_user: nil, ) result = runner.invoke expect(result["success"]).to eq(true) expect(result["username"]).to eq("testuser123") expect(result["email"]).to eq("testuser@example.com") user = User.find_by(id: result["user_id"]) expect(user).not_to be_nil expect(user.staged).to eq(true) expect(user.username).to eq("testuser123") expect(user.email).to eq("testuser@example.com") expect(user.name).to eq("Test User") end it "returns an error if user already exists" do existing_user = Fabricate(:user, email: "existing@example.com", username: "existinguser") script = <<~JS function invoke(params) { try { return discourse.createStagedUser({ email: params.email, username: params.username }); } catch (e) { return { error: e.message }; } } JS tool = create_tool(script: script) runner = tool.runner( { email: existing_user.email, username: "newusername" }, llm: nil, bot_user: nil, ) result = runner.invoke expect(result["error"]).to eq("User already exists") end end context "when creating topics" do fab!(:category) fab!(:user) { Fabricate(:admin) } it "can create a topic" do script = <<~JS function invoke(params) { return discourse.createTopic({ category_id: params.category_id, title: params.title, raw: params.raw, username: params.username, tags: params.tags }); } JS tool = create_tool(script: script) runner = tool.runner( { category_id: category.id, title: "Test Topic Title", raw: "This is the content of the test topic", username: user.username, tags: %w[test example], }, llm: nil, bot_user: nil, ) result = runner.invoke expect(result["success"]).to eq(true) expect(result["topic_id"]).to be_present expect(result["post_id"]).to be_present topic = Topic.find_by(id: result["topic_id"]) expect(topic).not_to be_nil expect(topic.title).to eq("Test Topic Title") expect(topic.category_id).to eq(category.id) expect(topic.user_id).to eq(user.id) expect(topic.archetype).to eq("regular") expect(topic.tags.pluck(:name)).to contain_exactly("test", "example") post = Post.find_by(id: result["post_id"]) expect(post).not_to be_nil expect(post.raw).to eq("This is the content of the test topic") end it "can create a topic without username (uses system user)" do script = <<~JS function invoke(params) { return discourse.createTopic({ category_id: params.category_id, title: params.title, raw: params.raw }); } JS tool = create_tool(script: script) runner = tool.runner( { category_id: category.id, title: "System User Topic", raw: "Created by system" }, llm: nil, bot_user: nil, ) result = runner.invoke expect(result["success"]).to eq(true) topic = Topic.find_by(id: result["topic_id"]) expect(topic.user_id).to eq(Discourse.system_user.id) end it "returns an error for invalid category" do script = <<~JS function invoke(params) { return discourse.createTopic({ category_id: 99999, title: "Test", raw: "Test" }); } JS tool = create_tool(script: script) runner = tool.runner({}, llm: nil, bot_user: nil) expect { runner.invoke }.to raise_error(MiniRacer::RuntimeError, /Category not found/) end end context "when creating posts" do fab!(:topic) { Fabricate(:post).topic } fab!(:user) it "can create a post in a topic" do script = <<~JS function invoke(params) { return discourse.createPost({ topic_id: params.topic_id, raw: params.raw, username: params.username }); } JS tool = create_tool(script: script) runner = tool.runner( { topic_id: topic.id, raw: "This is a reply to the topic", username: user.username }, llm: nil, bot_user: nil, ) result = runner.invoke expect(result["success"]).to eq(true) expect(result["post_id"]).to be_present expect(result["post_number"]).to be > 1 post = Post.find_by(id: result["post_id"]) expect(post).not_to be_nil expect(post.raw).to eq("This is a reply to the topic") expect(post.topic_id).to eq(topic.id) expect(post.user_id).to eq(user.id) end it "can create a reply to a specific post" do _original_post = Fabricate(:post, topic: topic, post_number: 2) script = <<~JS function invoke(params) { return discourse.createPost({ topic_id: params.topic_id, raw: params.raw, reply_to_post_number: params.reply_to_post_number }); } JS tool = create_tool(script: script) runner = tool.runner( { topic_id: topic.id, raw: "This is a reply to post #2", reply_to_post_number: 2 }, llm: nil, bot_user: nil, ) result = runner.invoke expect(result["success"]).to eq(true) post = Post.find_by(id: result["post_id"]) expect(post.reply_to_post_number).to eq(2) end it "returns an error for invalid topic" do script = <<~JS function invoke(params) { return discourse.createPost({ topic_id: 99999, raw: "Test" }); } JS tool = create_tool(script: script) runner = tool.runner({}, llm: nil, bot_user: nil) expect { runner.invoke }.to raise_error(MiniRacer::RuntimeError, /Topic not found/) end end context "when seeding a category with topics" do fab!(:category) it "can seed a category with a topic and post" do script = <<~JS function invoke(params) { // Create a staged user const user = discourse.createStagedUser({ email: 'testuser@example.com', username: 'testuser', name: 'Test User' }); // Create a topic const topic = discourse.createTopic({ category_name: params.category_name, title: 'Test Topic 123 123 123', raw: 'This is the initial post content.', username: user.username }); // Add an extra post to the topic const post = discourse.createPost({ topic_id: topic.topic_id, raw: 'This is a reply to the topic.', username: user.username }); return { success: true, user: user, topic: topic, post: post }; } JS tool = create_tool(script: script) runner = tool.runner({ category_name: category.name }, llm: nil, bot_user: nil) result = runner.invoke expect(result["success"]).to eq(true) user = User.find_by(username: "testuser") expect(user).not_to be_nil expect(user.staged).to eq(true) topic = Topic.find_by(id: result["topic"]["topic_id"]) expect(topic).not_to be_nil expect(topic.category_id).to eq(category.id) expect(topic.posts.count).to eq(2) end end end