0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-11 02:59:07 +08:00
discourse/plugins/discourse-ai/spec/models/ai_agent_spec.rb
discourse-patch-triage[bot] a8ba97caf8
FIX: Bust cache so that group additions to AI helper can take effect [backport 2026.6] (#41603)
Backport of #41539 to release/2026.6.

---

Adding new groups to `Composer AI helper allowed groups` setting was not
taking effect due to a cache on agents.

This PR busts the agent cache when the the updated agent is AI helper
related.

Co-authored-by: Natalie Tay <natalie.tay@gmail.com>
2026-07-09 23:18:10 +01:00

534 lines
16 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe AiAgent do
subject(:basic_agent) do
AiAgent.new(
name: "test",
description: "test",
system_prompt: "test",
tools: [],
allowed_group_ids: [],
)
end
fab!(:llm_model)
fab!(:seeded_llm_model) { Fabricate(:llm_model, id: -1) }
before { enable_current_plugin }
it "clears AI helper prompt permissions after changes" do
agent = AiAgent.find(SiteSetting.ai_helper_proofreader_agent)
DiscourseAi::AiHelper::Assistant.prompt_cache[:value] = "cached prompts"
agent.update!(allowed_group_ids: [Group::AUTO_GROUPS[:staff]])
expect(DiscourseAi::AiHelper::Assistant.prompt_cache[:value]).to be_nil
end
it "keeps AI helper prompt permissions after unrelated changes" do
agent =
AiAgent.create!(
name: "unrelated agent",
description: "test",
system_prompt: "test",
tools: [],
allowed_group_ids: [],
)
DiscourseAi::AiHelper::Assistant.prompt_cache[:value] = "cached prompts"
agent.update!(allowed_group_ids: [Group::AUTO_GROUPS[:staff]])
expect(DiscourseAi::AiHelper::Assistant.prompt_cache[:value]).to eq("cached prompts")
end
it "validates context settings" do
expect(basic_agent.valid?).to eq(true)
basic_agent.max_context_posts = 0
expect(basic_agent.valid?).to eq(false)
expect(basic_agent.errors[:max_context_posts]).to eq(["must be greater than 0"])
basic_agent.max_context_posts = 1
expect(basic_agent.valid?).to eq(true)
basic_agent.max_context_posts = nil
expect(basic_agent.valid?).to eq(true)
end
it "validates tools" do
Fabricate(:ai_tool, id: 1)
Fabricate(:ai_tool, id: 2, name: "Archie search", tool_name: "search")
expect(basic_agent.valid?).to eq(true)
basic_agent.tools = %w[search image_generation]
expect(basic_agent.valid?).to eq(true)
basic_agent.tools = %w[search image_generation search]
expect(basic_agent.valid?).to eq(false)
expect(basic_agent.errors[:tools]).to eq(["Can not have duplicate tools"])
basic_agent.tools = [
["custom-1", { test: "test" }, false],
["custom-2", { test: "test" }, false],
]
expect(basic_agent.valid?).to eq(true)
expect(basic_agent.errors[:tools]).to eq([])
basic_agent.tools = [
["custom-1", { test: "test" }, false],
["custom-1", { test: "test" }, false],
]
expect(basic_agent.valid?).to eq(false)
expect(basic_agent.errors[:tools]).to eq(["Can not have duplicate tools"])
basic_agent.tools = [
["custom-1", { test: "test" }, false],
["custom-2", { test: "test" }, false],
"image_generation",
]
expect(basic_agent.valid?).to eq(true)
expect(basic_agent.errors[:tools]).to eq([])
basic_agent.tools = [
["custom-1", { test: "test" }, false],
["custom-2", { test: "test" }, false],
"Search",
]
expect(basic_agent.valid?).to eq(false)
expect(basic_agent.errors[:tools]).to eq(["Can not have duplicate tools"])
end
describe "provider-native tools" do
fab!(:gemini_model)
fab!(:openai_chat_model) do
Fabricate(:llm_model, url: "https://api.openai.com/v1/chat/completions")
end
it "requires a forced default LLM that supports the native tool" do
basic_agent.tools = ["native-web_search"]
# no forced default LLM
expect(basic_agent.valid?).to eq(false)
expect(basic_agent.errors[:tools]).to include(
I18n.t("discourse_ai.ai_bot.agents.native_tool_requires_forced_llm"),
)
# forced LLM whose provider does not support web search (chat completions)
basic_agent.default_llm = openai_chat_model
basic_agent.force_default_llm = true
expect(basic_agent.valid?).to eq(false)
expect(basic_agent.errors[:tools]).to include(
I18n.t("discourse_ai.ai_bot.agents.native_tool_unsupported_by_llm"),
)
# forced LLM that supports web search
basic_agent.default_llm = gemini_model
expect(basic_agent.valid?).to eq(true)
end
end
it "allows creation of user" do
user = basic_agent.create_user!
expect(user.username).to eq("test_bot")
expect(user.name).to eq("Test")
expect(user.bot?).to be(true)
expect(user.id).to be <= AiAgent::FIRST_AGENT_USER_ID
end
it "removes all rag embeddings when rag params change" do
agent =
AiAgent.create!(
name: "test",
description: "test",
system_prompt: "test",
tools: [],
allowed_group_ids: [],
rag_chunk_tokens: 10,
rag_chunk_overlap_tokens: 5,
)
id =
RagDocumentFragment.create!(
target: agent,
fragment: "test",
fragment_number: 1,
upload: Fabricate(:upload),
).id
agent.rag_chunk_tokens = 20
agent.save!
expect(RagDocumentFragment.exists?(id)).to eq(false)
end
it "defines singleton methods on system agent classes" do
forum_helper = AiAgent.find_by(name: "Forum Helper")
forum_helper.update!(
user_id: 1,
default_llm_id: llm_model.id,
max_context_posts: 3,
allow_topic_mentions: true,
allow_personal_messages: true,
allow_chat_channel_mentions: true,
allow_chat_direct_messages: true,
)
klass = forum_helper.class_instance
expect(klass.id).to eq(forum_helper.id)
expect(klass.system).to eq(true)
# tl 0 by default
expect(klass.allowed_group_ids).to eq([10])
expect(klass.user_id).to eq(1)
expect(klass.default_llm_id).to eq(llm_model.id)
expect(klass.max_context_posts).to eq(3)
expect(klass.allow_topic_mentions).to eq(true)
expect(klass.allow_personal_messages).to eq(true)
expect(klass.allow_chat_channel_mentions).to eq(true)
expect(klass.allow_chat_direct_messages).to eq(true)
end
it "defines singleton methods non agent classes" do
agent =
AiAgent.create!(
name: "test",
description: "test",
system_prompt: "test",
tools: [],
allowed_group_ids: [],
default_llm_id: llm_model.id,
max_context_posts: 3,
allow_topic_mentions: true,
allow_personal_messages: true,
allow_chat_channel_mentions: true,
allow_chat_direct_messages: true,
user_id: 1,
)
klass = agent.class_instance
expect(klass.id).to eq(agent.id)
expect(klass.system).to eq(false)
expect(klass.allowed_group_ids).to eq([])
expect(klass.user_id).to eq(1)
expect(klass.default_llm_id).to eq(llm_model.id)
expect(klass.max_context_posts).to eq(3)
expect(klass.allow_topic_mentions).to eq(true)
expect(klass.allow_personal_messages).to eq(true)
expect(klass.allow_chat_channel_mentions).to eq(true)
expect(klass.allow_chat_direct_messages).to eq(true)
end
it "attaches mcp tool classes for assigned servers" do
ai_mcp_server = Fabricate(:ai_mcp_server, name: "Jira")
agent =
AiAgent.create!(
name: "mcp_agent",
description: "test",
system_prompt: "test",
tools: [],
allowed_group_ids: [],
)
agent.ai_mcp_servers << ai_mcp_server
DiscourseAi::Mcp::ToolRegistry.stubs(:tool_classes_for_servers).returns(
[
DiscourseAi::Agents::Tools::Mcp.class_instance(
ai_mcp_server.id,
"search_issues",
{ "name" => "search_issues", "description" => "Search issues", "inputSchema" => {} },
),
],
)
klass = agent.class_instance
expect(klass.new.tools.map { |tool| tool.signature[:name] }).to include("search_issues")
end
it "passes selected MCP tool names to the registry" do
ai_mcp_server = Fabricate(:ai_mcp_server, name: "Jira")
agent =
AiAgent.create!(
name: "mcp_agent",
description: "test",
system_prompt: "test",
tools: [],
allowed_group_ids: [],
)
agent.ai_mcp_servers << ai_mcp_server
agent
.ai_agent_mcp_servers
.find_by!(ai_mcp_server_id: ai_mcp_server.id)
.update!(selected_tool_names: ["search_issues"])
DiscourseAi::Mcp::ToolRegistry
.expects(:tool_classes_for_servers)
.with(
[ai_mcp_server],
reserved_names: [],
selected_tool_names_by_server: {
ai_mcp_server.id => ["search_issues"],
},
)
.returns([])
agent.class_instance
end
it "does not allow setting allowing chat without a default_llm" do
agent =
AiAgent.create(
name: "test",
description: "test",
system_prompt: "test",
allowed_group_ids: [],
default_llm: nil,
allow_chat_channel_mentions: true,
)
expect(agent.valid?).to eq(false)
expect(agent.errors[:base]).to include(
I18n.t("discourse_ai.ai_bot.agents.default_llm_required"),
)
agent =
AiAgent.create(
name: "test",
description: "test",
system_prompt: "test",
allowed_group_ids: [],
default_llm: nil,
allow_chat_direct_messages: true,
)
expect(agent.valid?).to eq(false)
expect(agent.errors[:base]).to include(
I18n.t("discourse_ai.ai_bot.agents.default_llm_required"),
)
agent =
AiAgent.create(
name: "test",
description: "test",
system_prompt: "test",
allowed_group_ids: [],
default_llm: nil,
allow_topic_mentions: true,
)
expect(agent.valid?).to eq(false)
expect(agent.errors[:base]).to include(
I18n.t("discourse_ai.ai_bot.agents.default_llm_required"),
)
end
it "does not leak caches between sites" do
AiAgent.create!(
name: "pun_bot",
description: "you write puns",
system_prompt: "you are pun bot",
tools: ["ImageCommand"],
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]],
)
AiAgent.all_agents
expect(AiAgent.agent_cache[:value].length).to be > 0
RailsMultisite::ConnectionManagement.stubs(:current_db) { "abc" }
expect(AiAgent.agent_cache[:value]).to eq(nil)
end
describe ".find_by_id_from_cache" do
fab!(:agent) do
AiAgent.create!(
name: "cached_agent",
description: "test agent for cache",
system_prompt: "you are a test",
tools: [],
allowed_group_ids: [],
)
end
it "returns nil for blank agent_id" do
expect(AiAgent.find_by_id_from_cache(nil)).to eq(nil)
expect(AiAgent.find_by_id_from_cache("")).to eq(nil)
end
it "finds agent by id from cache" do
result = AiAgent.find_by_id_from_cache(agent.id)
expect(result).to be_present
expect(result.id).to eq(agent.id)
expect(result.name).to eq("cached_agent")
end
it "finds agent when id is provided as a string" do
result = AiAgent.find_by_id_from_cache(agent.id.to_s)
expect(result).to be_present
expect(result.id).to eq(agent.id)
end
it "returns nil for non-existent agent id" do
result = AiAgent.find_by_id_from_cache(999_999)
expect(result).to eq(nil)
end
it "finds disabled agents" do
agent.update!(enabled: false)
result = AiAgent.find_by_id_from_cache(agent.id)
expect(result).to be_present
expect(result.id).to eq(agent.id)
end
it "uses cache and avoids database queries after initial load" do
AiAgent.find_by_id_from_cache(agent.id)
query_count = track_sql_queries { AiAgent.find_by_id_from_cache(agent.id) }.count
expect(query_count).to eq(0)
end
it "falls back to database when cache is cleared after initial load" do
result_before = AiAgent.find_by_id_from_cache(agent.id)
expect(result_before).to be_present
AiAgent.agent_cache.flush!
query_count =
track_sql_queries do
result_after = AiAgent.find_by_id_from_cache(agent.id)
expect(result_after).to be_present
expect(result_after.id).to eq(agent.id)
expect(result_after.name).to eq("cached_agent")
end.count
expect(query_count).to be > 0
end
end
describe "system agent validations" do
let(:system_agent) do
AiAgent.create!(
name: "system_agent",
description: "system agent",
system_prompt: "system agent",
tools: %w[Search Time],
response_format: [{ key: "summary", type: "string" }],
examples: [%w[user_msg1 assistant_msg1], %w[user_msg2 assistant_msg2]],
system: true,
)
end
context "when modifying a system agent" do
it "allows changing tool options without allowing tool additions/removals" do
tools = [["Search", { "base_query" => "abc" }], ["Time"]]
system_agent.update!(tools: tools)
system_agent.reload
expect(system_agent.tools).to eq(tools)
invalid_tools = ["Time"]
system_agent.update(tools: invalid_tools)
expect(system_agent.errors[:base]).to include(
I18n.t("discourse_ai.ai_bot.agents.cannot_edit_system_agent"),
)
end
it "doesn't accept response format changes" do
new_format = [{ key: "summary2", type: "string" }]
expect { system_agent.update!(response_format: new_format) }.to raise_error(
ActiveRecord::RecordInvalid,
)
end
it "doesn't accept additional format changes" do
new_format = [{ key: "summary", type: "string" }, { key: "summary2", type: "string" }]
expect { system_agent.update!(response_format: new_format) }.to raise_error(
ActiveRecord::RecordInvalid,
)
end
it "doesn't accept changes to examples" do
other_examples = [%w[user_msg1 assistant_msg1]]
expect { system_agent.update!(examples: other_examples) }.to raise_error(
ActiveRecord::RecordInvalid,
)
end
end
end
describe "agentic execution mode defaults and propagation" do
it "assigns safe defaults to a agent created without agentic fields" do
agent =
AiAgent.create!(
name: "legacy_agent",
description: "no agentic fields set",
system_prompt: "test",
tools: [],
allowed_group_ids: [],
)
expect(agent.execution_mode).to eq("default")
expect(agent.max_turn_tokens).to be_nil
expect(agent.compression_threshold).to be_nil
klass = agent.class_instance
expect(klass.execution_mode).to eq("default")
expect(klass.max_turn_tokens).to be_nil
expect(klass.compression_threshold).to be_nil
end
it "requires compression_threshold for agentic mode but not for default mode" do
agent =
AiAgent.new(
name: "agentic_agent",
description: "test",
system_prompt: "test",
tools: [],
allowed_group_ids: [],
execution_mode: "agentic",
)
expect(agent.valid?).to eq(false)
expect(agent.errors[:compression_threshold]).to be_present
agent.compression_threshold = 75
expect(agent.valid?).to eq(true)
agent.execution_mode = "default"
agent.compression_threshold = nil
expect(agent.valid?).to eq(true)
end
end
describe "validates examples format" do
it "doesn't accept examples that are not arrays" do
basic_agent.examples = [1]
expect(basic_agent.valid?).to eq(false)
expect(basic_agent.errors[:examples].first).to eq(
I18n.t("discourse_ai.agents.malformed_examples"),
)
end
it "doesn't accept examples that don't come in pairs" do
basic_agent.examples = [%w[user_msg1]]
expect(basic_agent.valid?).to eq(false)
expect(basic_agent.errors[:examples].first).to eq(
I18n.t("discourse_ai.agents.malformed_examples"),
)
end
it "works when example is well formatted" do
basic_agent.examples = [%w[user_msg1 assistant1]]
expect(basic_agent.valid?).to eq(true)
end
end
end