mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-06-19 07:43:46 +08:00
Adds a fourth kind of agent tool: provider-native built-in tools that the LLM provider executes server-side, rather than tools Discourse runs and feeds back. The first one is web search, supported on Gemini (Google Search grounding), OpenAI (web search via the Responses API) and Anthropic (Claude web search). Native tools are stored on the agent's `tools` column with a `native-` prefix, flow to the prompt as a separate `native_tools` list (never as runnable Tool classes), and each provider dialect renders them into its own request payload. Response processors already ignore the server-side tool/grounding blocks, so the bot loop never tries to execute them. They are only selectable when the agent forces a default LLM whose provider supports the tool; this is enforced both in the editor UI (filtered by the selected LLM's `supported_native_tools`) and by server-side validation. Also fixes the Gemini endpoint sending `function_calling_config` without any `function_declarations`, which the API rejects when only native tools are present. --------- Co-authored-by: Sam Saffron <sam.saffron@gmail.com>
964 lines
30 KiB
Ruby
Vendored
964 lines
30 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
describe DiscourseAi::Completions::PromptMessagesBuilder do
|
|
let(:builder) { DiscourseAi::Completions::PromptMessagesBuilder.new }
|
|
fab!(:user)
|
|
fab!(:admin)
|
|
fab!(:bot_user, :user)
|
|
fab!(:other_user, :user)
|
|
|
|
fab!(:image_upload1) do
|
|
Fabricate(:upload, user: user, original_filename: "image.png", extension: "png")
|
|
end
|
|
fab!(:image_upload2) do
|
|
Fabricate(:upload, user: user, original_filename: "image.png", extension: "png")
|
|
end
|
|
let(:document_upload) do
|
|
Fabricate(:upload, user: user, original_filename: "notes.txt", extension: "txt")
|
|
end
|
|
|
|
before do
|
|
enable_current_plugin
|
|
SiteSetting.authorized_extensions = "*"
|
|
end
|
|
|
|
it "correctly merges user messages with uploads" do
|
|
builder.push(type: :user, content: "Hello", id: "Alice", upload_ids: [1])
|
|
builder.push(type: :user, content: "World", id: "Bob", upload_ids: [2])
|
|
|
|
messages = builder.to_a
|
|
|
|
# Check the structure of the merged message
|
|
expect(messages.length).to eq(1)
|
|
expect(messages[0][:type]).to eq(:user)
|
|
|
|
# The content should contain the text and both uploads
|
|
content = messages[0][:content]
|
|
expect(content).to be_an(Array)
|
|
expect(content[0]).to eq("Alice: Hello")
|
|
expect(content[1]).to eq({ upload_id: 1 })
|
|
expect(content[2]).to eq("\nBob: World")
|
|
expect(content[3]).to eq({ upload_id: 2 })
|
|
end
|
|
|
|
it "should allow merging user messages" do
|
|
builder.push(type: :user, content: "Hello", id: "Alice")
|
|
builder.push(type: :user, content: "World", id: "Bob")
|
|
|
|
expect(builder.to_a).to eq([{ type: :user, content: "Alice: Hello\nBob: World" }])
|
|
end
|
|
|
|
it "should allow adding uploads" do
|
|
builder.push(type: :user, content: "Hello", name: "Alice", upload_ids: [1, 2])
|
|
|
|
expect(builder.to_a).to eq(
|
|
[{ type: :user, content: ["Hello", { upload_id: 1 }, { upload_id: 2 }], name: "Alice" }],
|
|
)
|
|
end
|
|
|
|
it "should support function calls" do
|
|
builder.push(type: :user, content: "Echo 123 please", name: "Alice")
|
|
builder.push(type: :tool_call, content: "echo(123)", name: "echo", id: 1)
|
|
builder.push(type: :tool, content: "123", name: "echo", id: 1)
|
|
builder.push(type: :user, content: "Hello", name: "Alice")
|
|
expected = [
|
|
{ type: :user, content: "Echo 123 please", name: "Alice" },
|
|
{ type: :tool_call, content: "echo(123)", name: "echo", id: "1" },
|
|
{ type: :tool, content: "123", name: "echo", id: "1" },
|
|
{ type: :user, content: "Hello", name: "Alice" },
|
|
]
|
|
expect(builder.to_a).to eq(expected)
|
|
end
|
|
|
|
it "should drop a tool call if it is not followed by tool" do
|
|
builder.push(type: :user, content: "Echo 123 please", id: "Alice")
|
|
builder.push(type: :tool_call, content: "echo(123)", name: "echo", id: 1)
|
|
builder.push(type: :user, content: "OK", id: "James")
|
|
|
|
expected = [{ type: :user, content: "Alice: Echo 123 please\nJames: OK" }]
|
|
expect(builder.to_a).to eq(expected)
|
|
end
|
|
|
|
it "should format messages for topic style" do
|
|
# Create a topic with tags
|
|
topic = Fabricate(:topic, title: "This is an Example Topic")
|
|
|
|
# Add tags to the topic
|
|
topic.tags = [Fabricate(:tag, name: "tag1"), Fabricate(:tag, name: "tag2")]
|
|
topic.save!
|
|
|
|
builder.topic = topic
|
|
builder.push(type: :user, content: "I like frogs", id: "Bob")
|
|
builder.push(type: :user, content: "How do I solve this?", id: "Alice")
|
|
|
|
result = builder.to_a(style: :topic)
|
|
|
|
content = result[0][:content]
|
|
|
|
expect(content).to include("This is an Example Topic")
|
|
expect(content).to include("tag1")
|
|
expect(content).to include("tag2")
|
|
expect(content).to include("Bob: I like frogs")
|
|
expect(content).to include("Alice")
|
|
expect(content).to include("How do I solve this")
|
|
end
|
|
|
|
describe "chat context posts in direct messages" do
|
|
fab!(:dm_channel) { Fabricate(:direct_message_channel, users: [user, bot_user]) }
|
|
fab!(:dm_message) do
|
|
Fabricate(
|
|
:chat_message,
|
|
chat_channel: dm_channel,
|
|
user: user,
|
|
message: "I have a question about the topic",
|
|
)
|
|
end
|
|
|
|
fab!(:topic) { Fabricate(:topic, title: "Important topic for context") }
|
|
fab!(:post1) { Fabricate(:post, topic: topic, user: other_user, raw: "This is the first post") }
|
|
fab!(:post2) { Fabricate(:post, topic: topic, user: user, raw: "And here's a follow-up") }
|
|
|
|
it "correctly includes topic posts as context in direct message channels" do
|
|
context =
|
|
described_class.messages_from_chat(
|
|
dm_message,
|
|
channel: dm_channel,
|
|
context_post_ids: [post1.id, post2.id],
|
|
max_messages: 10,
|
|
include_uploads: false,
|
|
bot_user_ids: [bot_user.id],
|
|
instruction_message: nil,
|
|
)
|
|
|
|
expect(context.length).to eq(1)
|
|
content = context.first[:content]
|
|
|
|
# First part should contain the context intro
|
|
expect(content).to include("You are replying inside a Discourse chat")
|
|
expect(content).to include(
|
|
"This chat is in the context of the Discourse topic 'Important topic for context'",
|
|
)
|
|
expect(content).to include(post1.username)
|
|
expect(content).to include("This is the first post")
|
|
expect(content).to include(post2.username)
|
|
expect(content).to include("And here's a follow-up")
|
|
|
|
# Last part should have the user's message
|
|
expect(content).to include("I have a question about the topic")
|
|
end
|
|
|
|
it "includes uploads from context posts when include_uploads is true" do
|
|
upload = Fabricate(:upload, user: user, original_filename: "image.png", extension: "png")
|
|
UploadReference.create!(target: post1, upload: upload)
|
|
|
|
context =
|
|
described_class.messages_from_chat(
|
|
dm_message,
|
|
channel: dm_channel,
|
|
context_post_ids: [post1.id],
|
|
max_messages: 10,
|
|
include_uploads: true,
|
|
bot_user_ids: [bot_user.id],
|
|
instruction_message: nil,
|
|
)
|
|
|
|
# Verify the upload reference is included
|
|
upload_hashes = context.first[:content].select { |item| item.is_a?(Hash) && item[:upload_id] }
|
|
expect(upload_hashes).to be_present
|
|
expect(upload_hashes.first[:upload_id]).to eq(upload.id)
|
|
end
|
|
|
|
context "with a secure upload the chat guardian cannot see" do
|
|
fab!(:secure_upload) do
|
|
private_category = Fabricate(:private_category, group: Fabricate(:group))
|
|
private_topic = Fabricate(:topic, category: private_category)
|
|
private_post = Fabricate(:post, topic: private_topic)
|
|
Fabricate(
|
|
:secure_upload,
|
|
original_filename: "secure.png",
|
|
extension: "png",
|
|
access_control_post: private_post,
|
|
)
|
|
end
|
|
|
|
it "drops the upload from the chat context" do
|
|
SiteSetting.embedded_media_post_allowed_groups = Group::AUTO_GROUPS[:everyone]
|
|
|
|
post_with_secure_upload =
|
|
create_post(
|
|
topic_id: topic.id,
|
|
user: user,
|
|
raw: "Look at this ",
|
|
)
|
|
|
|
builder = described_class.new
|
|
builder.set_chat_context_posts(
|
|
[post_with_secure_upload.id],
|
|
Guardian.new(user),
|
|
include_image_uploads: true,
|
|
include_document_uploads: false,
|
|
)
|
|
|
|
upload_hashes =
|
|
builder.chat_context_posts.select { |item| item.is_a?(Hash) && item[:upload_id] }
|
|
expect(upload_hashes).to be_empty
|
|
end
|
|
end
|
|
end
|
|
|
|
describe ".messages_from_chat" do
|
|
fab!(:dm_channel) { Fabricate(:direct_message_channel, users: [user, bot_user]) }
|
|
fab!(:dm_message1) do
|
|
Fabricate(:chat_message, chat_channel: dm_channel, user: user, message: "Hello bot")
|
|
end
|
|
fab!(:dm_message2) do
|
|
Fabricate(:chat_message, chat_channel: dm_channel, user: bot_user, message: "Hello human")
|
|
end
|
|
fab!(:dm_message3) do
|
|
Fabricate(:chat_message, chat_channel: dm_channel, user: user, message: "How are you?")
|
|
end
|
|
|
|
fab!(:public_channel, :category_channel)
|
|
fab!(:public_message1) do
|
|
Fabricate(:chat_message, chat_channel: public_channel, user: user, message: "Hello everyone")
|
|
end
|
|
fab!(:public_message2) do
|
|
Fabricate(:chat_message, chat_channel: public_channel, user: bot_user, message: "Hi there")
|
|
end
|
|
|
|
fab!(:thread_original) do
|
|
Fabricate(:chat_message, chat_channel: public_channel, user: user, message: "Thread starter")
|
|
end
|
|
fab!(:thread) do
|
|
Fabricate(:chat_thread, channel: public_channel, original_message: thread_original)
|
|
end
|
|
fab!(:thread_reply1) do
|
|
Fabricate(
|
|
:chat_message,
|
|
chat_channel: public_channel,
|
|
user: other_user,
|
|
message: "Thread reply",
|
|
thread: thread,
|
|
)
|
|
end
|
|
|
|
fab!(:upload) { Fabricate(:upload, user: user) }
|
|
fab!(:message_with_upload) do
|
|
Fabricate(
|
|
:chat_message,
|
|
chat_channel: dm_channel,
|
|
user: user,
|
|
message: "Check this image",
|
|
upload_ids: [upload.id],
|
|
)
|
|
end
|
|
|
|
it "processes messages from direct message channels" do
|
|
context =
|
|
described_class.messages_from_chat(
|
|
dm_message3,
|
|
channel: dm_channel,
|
|
context_post_ids: nil,
|
|
max_messages: 10,
|
|
include_uploads: false,
|
|
bot_user_ids: [bot_user.id],
|
|
instruction_message: nil,
|
|
)
|
|
|
|
# this is all we got cause it is assuming threading
|
|
expect(context).to eq([{ type: :user, content: "How are you?", id: user.username }])
|
|
end
|
|
|
|
it "includes uploads when include_uploads is true" do
|
|
message_with_upload.reload
|
|
expect(message_with_upload.uploads).to include(upload)
|
|
|
|
context =
|
|
described_class.messages_from_chat(
|
|
message_with_upload,
|
|
channel: dm_channel,
|
|
context_post_ids: nil,
|
|
max_messages: 10,
|
|
include_uploads: true,
|
|
bot_user_ids: [bot_user.id],
|
|
instruction_message: nil,
|
|
)
|
|
|
|
# Find the message with upload
|
|
message =
|
|
context.find do |m|
|
|
m[:content] ==
|
|
["Check this image -- uploaded(#{upload.short_url})", { upload_id: upload.id }]
|
|
end
|
|
expect(message).to be_present
|
|
end
|
|
|
|
it "doesn't include uploads when include_uploads is false" do
|
|
# Make sure the upload is associated with the message
|
|
message_with_upload.reload
|
|
expect(message_with_upload.uploads).to include(upload)
|
|
|
|
context =
|
|
described_class.messages_from_chat(
|
|
message_with_upload,
|
|
channel: dm_channel,
|
|
context_post_ids: nil,
|
|
max_messages: 10,
|
|
include_uploads: false,
|
|
bot_user_ids: [bot_user.id],
|
|
instruction_message: nil,
|
|
)
|
|
|
|
# Find the message with upload
|
|
message =
|
|
context.find { |m| m[:content] == "Check this image -- uploaded(#{upload.short_url})" }
|
|
expect(message).to be_present
|
|
expect(message[:upload_ids]).to be_nil
|
|
end
|
|
|
|
it "can include document uploads while excluding image uploads" do
|
|
message_with_mixed_uploads =
|
|
Fabricate(
|
|
:chat_message,
|
|
chat_channel: dm_channel,
|
|
user: user,
|
|
message: "Check these files",
|
|
upload_ids: [image_upload1.id, document_upload.id],
|
|
)
|
|
|
|
context =
|
|
described_class.messages_from_chat(
|
|
message_with_mixed_uploads,
|
|
channel: dm_channel,
|
|
context_post_ids: nil,
|
|
max_messages: 10,
|
|
include_image_uploads: false,
|
|
include_document_uploads: true,
|
|
allowed_attachment_types: ["txt"],
|
|
bot_user_ids: [bot_user.id],
|
|
instruction_message: nil,
|
|
)
|
|
|
|
expect(context.first[:content]).to eq(
|
|
[
|
|
"Check these files -- uploaded(#{image_upload1.short_url}, #{document_upload.short_url})",
|
|
{ upload_id: document_upload.id },
|
|
],
|
|
)
|
|
end
|
|
|
|
it "can include image uploads while excluding document uploads" do
|
|
message_with_mixed_uploads =
|
|
Fabricate(
|
|
:chat_message,
|
|
chat_channel: dm_channel,
|
|
user: user,
|
|
message: "Check these files",
|
|
upload_ids: [image_upload1.id, document_upload.id],
|
|
)
|
|
|
|
context =
|
|
described_class.messages_from_chat(
|
|
message_with_mixed_uploads,
|
|
channel: dm_channel,
|
|
context_post_ids: nil,
|
|
max_messages: 10,
|
|
include_image_uploads: true,
|
|
include_document_uploads: false,
|
|
bot_user_ids: [bot_user.id],
|
|
instruction_message: nil,
|
|
)
|
|
|
|
expect(context.first[:content]).to eq(
|
|
[
|
|
"Check these files -- uploaded(#{image_upload1.short_url}, #{document_upload.short_url})",
|
|
{ upload_id: image_upload1.id },
|
|
],
|
|
)
|
|
end
|
|
|
|
context "with a secure upload attached to a message the chat guardian cannot see" do
|
|
fab!(:secure_upload) do
|
|
private_category = Fabricate(:private_category, group: Fabricate(:group))
|
|
private_topic = Fabricate(:topic, category: private_category)
|
|
private_post = Fabricate(:post, topic: private_topic)
|
|
Fabricate(
|
|
:secure_upload,
|
|
original_filename: "secure.png",
|
|
extension: "png",
|
|
access_control_post: private_post,
|
|
)
|
|
end
|
|
fab!(:message_with_secure_upload) do
|
|
Fabricate(
|
|
:chat_message,
|
|
chat_channel: dm_channel,
|
|
user: user,
|
|
message: "Look at this",
|
|
upload_ids: [secure_upload.id],
|
|
)
|
|
end
|
|
|
|
it "drops the upload from the prompt" do
|
|
context =
|
|
described_class.messages_from_chat(
|
|
message_with_secure_upload,
|
|
channel: dm_channel,
|
|
context_post_ids: nil,
|
|
max_messages: 10,
|
|
include_uploads: true,
|
|
bot_user_ids: [bot_user.id],
|
|
instruction_message: nil,
|
|
)
|
|
|
|
content = Array(context.first[:content])
|
|
upload_hashes = content.select { |item| item.is_a?(Hash) && item[:upload_id] }
|
|
expect(upload_hashes).to be_empty
|
|
end
|
|
end
|
|
|
|
it "properly handles uploads in public channels with multiple users" do
|
|
_first_message =
|
|
Fabricate(:chat_message, chat_channel: public_channel, user: user, message: "First message")
|
|
|
|
_message_with_upload =
|
|
Fabricate(
|
|
:chat_message,
|
|
chat_channel: public_channel,
|
|
user: other_user,
|
|
message: "Message with image",
|
|
upload_ids: [upload.id],
|
|
)
|
|
|
|
last_message =
|
|
Fabricate(:chat_message, chat_channel: public_channel, user: user, message: "Final message")
|
|
|
|
context =
|
|
described_class.messages_from_chat(
|
|
last_message,
|
|
channel: public_channel,
|
|
context_post_ids: nil,
|
|
max_messages: 3,
|
|
include_uploads: true,
|
|
bot_user_ids: [bot_user.id],
|
|
instruction_message: nil,
|
|
)
|
|
|
|
expect(context.length).to eq(1)
|
|
content = context.first[:content]
|
|
|
|
expect(content.length).to eq(3)
|
|
expect(content[0]).to include("First message")
|
|
expect(content[0]).to include("Message with image")
|
|
expect(content[1]).to include({ upload_id: upload.id })
|
|
expect(content[2]).to include("Final message")
|
|
end
|
|
end
|
|
|
|
describe "upload limits in messages_from_chat" do
|
|
fab!(:test_channel, :category_channel)
|
|
fab!(:test_user, :user)
|
|
|
|
# Create MAX_CHAT_UPLOADS + 1 uploads
|
|
fab!(:uploads) do
|
|
(described_class::MAX_CHAT_UPLOADS + 1).times.map do |i|
|
|
Fabricate(:upload, user: test_user, original_filename: "image#{i}.png", extension: "png")
|
|
end
|
|
end
|
|
|
|
# Create MAX_CHAT_UPLOADS + 1 messages with those uploads
|
|
fab!(:messages_with_uploads) do
|
|
uploads.map do |upload|
|
|
Fabricate(
|
|
:chat_message,
|
|
chat_channel: test_channel,
|
|
user: test_user,
|
|
message: "Message with upload #{upload.id}",
|
|
).tap do |msg|
|
|
UploadReference.create!(target: msg, upload: upload)
|
|
msg.update!(upload_ids: [upload.id])
|
|
end
|
|
end
|
|
end
|
|
|
|
let(:max_uploads) { described_class::MAX_CHAT_UPLOADS }
|
|
|
|
it "limits uploads to MAX_CHAT_UPLOADS in the final result" do
|
|
last_message = messages_with_uploads.last
|
|
|
|
# Make sure uploads are properly associated
|
|
messages_with_uploads.each_with_index do |msg, i|
|
|
expect(msg.uploads.first.id).to eq(uploads[i].id)
|
|
end
|
|
|
|
context =
|
|
described_class.messages_from_chat(
|
|
last_message,
|
|
channel: test_channel,
|
|
context_post_ids: nil,
|
|
max_messages: messages_with_uploads.size,
|
|
include_uploads: true,
|
|
bot_user_ids: [],
|
|
instruction_message: nil,
|
|
)
|
|
|
|
# We should have one message containing all message content
|
|
expect(context.length).to eq(1)
|
|
content = context.first[:content]
|
|
|
|
# Count the upload hashes in the content
|
|
upload_hashes = content.select { |item| item.is_a?(Hash) && item[:upload_id] }
|
|
|
|
# Should have exactly MAX_CHAT_UPLOADS upload references
|
|
expect(upload_hashes.size).to eq(max_uploads)
|
|
|
|
# The most recent uploads should be preserved (not the oldest)
|
|
expected_upload_ids = uploads.last(max_uploads).map(&:id)
|
|
actual_upload_ids = upload_hashes.map { |h| h[:upload_id] }
|
|
expect(actual_upload_ids).to match_array(expected_upload_ids)
|
|
end
|
|
|
|
it "filters disallowed documents before applying upload limits" do
|
|
allowed_upload =
|
|
Fabricate(:upload, user: test_user, original_filename: "notes.txt", extension: "txt")
|
|
disallowed_uploads =
|
|
described_class::MAX_CHAT_UPLOADS.times.map do |i|
|
|
Fabricate(
|
|
:upload,
|
|
user: test_user,
|
|
original_filename: "archive#{i}.zip",
|
|
extension: "zip",
|
|
)
|
|
end
|
|
mixed_uploads = [allowed_upload, *disallowed_uploads]
|
|
|
|
messages =
|
|
mixed_uploads.map do |upload|
|
|
Fabricate(
|
|
:chat_message,
|
|
chat_channel: test_channel,
|
|
user: test_user,
|
|
message: "Message with upload #{upload.id}",
|
|
).tap do |msg|
|
|
UploadReference.create!(target: msg, upload: upload)
|
|
msg.update!(upload_ids: [upload.id])
|
|
end
|
|
end
|
|
|
|
context =
|
|
described_class.messages_from_chat(
|
|
messages.last,
|
|
channel: test_channel,
|
|
context_post_ids: nil,
|
|
max_messages: messages.size,
|
|
include_image_uploads: false,
|
|
include_document_uploads: true,
|
|
allowed_attachment_types: ["txt"],
|
|
bot_user_ids: [],
|
|
instruction_message: nil,
|
|
)
|
|
|
|
upload_hashes = context.first[:content].select { |item| item.is_a?(Hash) && item[:upload_id] }
|
|
|
|
expect(upload_hashes).to eq([{ upload_id: allowed_upload.id }])
|
|
end
|
|
end
|
|
|
|
describe ".messages_from_post" do
|
|
fab!(:pm) do
|
|
Fabricate(
|
|
:private_message_topic,
|
|
title: "This is my special PM",
|
|
user: user,
|
|
topic_allowed_users: [
|
|
Fabricate.build(:topic_allowed_user, user: user),
|
|
Fabricate.build(:topic_allowed_user, user: bot_user),
|
|
],
|
|
)
|
|
end
|
|
fab!(:first_post) do
|
|
Fabricate(:post, topic: pm, user: user, post_number: 1, raw: "This is a reply by the user")
|
|
end
|
|
fab!(:second_post) do
|
|
Fabricate(:post, topic: pm, user: bot_user, post_number: 2, raw: "This is a bot reply")
|
|
end
|
|
fab!(:third_post) do
|
|
Fabricate(
|
|
:post,
|
|
topic: pm,
|
|
user: user,
|
|
post_number: 3,
|
|
raw: "This is a second reply by the user",
|
|
)
|
|
end
|
|
|
|
it "provides rich context for for style topic messages" do
|
|
freeze_time
|
|
|
|
user.update!(trust_level: 2, created_at: 1.year.ago)
|
|
admin.update!(trust_level: 4, created_at: 1.month.ago)
|
|
user.user_stat.update!(post_count: 10, days_visited: 50)
|
|
|
|
reply_to_first_post =
|
|
Fabricate(
|
|
:post,
|
|
topic: pm,
|
|
user: admin,
|
|
reply_to_post_number: first_post.post_number,
|
|
raw: "This is a reply to the first post",
|
|
)
|
|
|
|
context =
|
|
described_class.messages_from_post(
|
|
reply_to_first_post,
|
|
style: :topic,
|
|
max_posts: 10,
|
|
bot_usernames: [bot_user.username],
|
|
include_uploads: false,
|
|
)
|
|
|
|
expect(context.length).to eq(1)
|
|
content = context[0][:content]
|
|
|
|
expect(content).to include(user.name)
|
|
expect(content).to include("Trust level 2")
|
|
expect(content).to include("account age: 1 year")
|
|
|
|
# I am mixed on asserting everything cause the test
|
|
# will be brittle, but open to changing this
|
|
end
|
|
|
|
it "includes document post uploads independently from image uploads" do
|
|
UploadReference.create!(target: third_post, upload: image_upload1)
|
|
UploadReference.create!(target: third_post, upload: document_upload)
|
|
|
|
context =
|
|
described_class.messages_from_post(
|
|
third_post,
|
|
max_posts: 1,
|
|
bot_usernames: [bot_user.username],
|
|
include_image_uploads: false,
|
|
include_document_uploads: true,
|
|
allowed_attachment_types: ["txt"],
|
|
)
|
|
|
|
expect(context).to contain_exactly(
|
|
{
|
|
type: :user,
|
|
id: user.username,
|
|
content: [third_post.raw, { upload_id: document_upload.id }],
|
|
},
|
|
)
|
|
end
|
|
|
|
it "includes image post uploads independently from document uploads" do
|
|
UploadReference.create!(target: third_post, upload: image_upload1)
|
|
UploadReference.create!(target: third_post, upload: document_upload)
|
|
|
|
context =
|
|
described_class.messages_from_post(
|
|
third_post,
|
|
max_posts: 1,
|
|
bot_usernames: [bot_user.username],
|
|
include_image_uploads: true,
|
|
include_document_uploads: false,
|
|
)
|
|
|
|
expect(context).to contain_exactly(
|
|
{
|
|
type: :user,
|
|
id: user.username,
|
|
content: [third_post.raw, { upload_id: image_upload1.id }],
|
|
},
|
|
)
|
|
end
|
|
|
|
it "handles uploads correctly in topic style messages (and times)" do
|
|
freeze_time 32.days.ago
|
|
|
|
# Use Discourse's upload format in the post raw content
|
|
upload_markdown = ""
|
|
|
|
post_with_upload =
|
|
Fabricate(
|
|
:post,
|
|
topic: pm,
|
|
user: admin,
|
|
raw: "This is the original #{upload_markdown} I just added",
|
|
)
|
|
|
|
UploadReference.create!(target: post_with_upload, upload: image_upload1)
|
|
|
|
upload2_markdown = ""
|
|
|
|
freeze_time 32.days.from_now
|
|
|
|
post2_with_upload =
|
|
Fabricate(
|
|
:post,
|
|
topic: pm,
|
|
user: admin,
|
|
raw: "This post has a different image #{upload2_markdown} I just added",
|
|
)
|
|
|
|
UploadReference.create!(target: post2_with_upload, upload: image_upload2)
|
|
|
|
messages =
|
|
described_class.messages_from_post(
|
|
post2_with_upload,
|
|
style: :topic,
|
|
max_posts: 3,
|
|
bot_usernames: [bot_user.username],
|
|
include_uploads: true,
|
|
)
|
|
|
|
# this is not quite ideal yet, images are attached at the end of the post
|
|
# long term we may want to extract them out using a regex and create N parts
|
|
# so people can talk about multiple images in a single post
|
|
# this is the initial ground work though
|
|
|
|
expect(messages.length).to eq(1)
|
|
content = messages[0][:content]
|
|
|
|
# first part
|
|
# first image
|
|
# second part
|
|
# second image
|
|
expect(content.length).to eq(4)
|
|
expect(content[0]).to include("This is the original")
|
|
expect(content[0]).to include("(1 month ago)")
|
|
expect(content[1]).to eq({ upload_id: image_upload1.id })
|
|
expect(content[2]).to include("different image")
|
|
expect(content[3]).to eq({ upload_id: image_upload2.id })
|
|
end
|
|
|
|
context "with limited context" do
|
|
it "respects max_context_posts" do
|
|
context =
|
|
described_class.messages_from_post(
|
|
third_post,
|
|
max_posts: 1,
|
|
bot_usernames: [bot_user.username],
|
|
include_uploads: false,
|
|
)
|
|
|
|
expect(context).to contain_exactly(
|
|
*[{ type: :user, id: user.username, content: third_post.raw }],
|
|
)
|
|
end
|
|
end
|
|
|
|
it "includes previous posts ordered by post_number" do
|
|
context =
|
|
described_class.messages_from_post(
|
|
third_post,
|
|
max_posts: 10,
|
|
bot_usernames: [bot_user.username],
|
|
include_uploads: false,
|
|
)
|
|
|
|
expect(context).to eq(
|
|
[
|
|
{ type: :user, content: "This is a reply by the user", id: user.username },
|
|
{ type: :model, content: "This is a bot reply" },
|
|
{ type: :user, content: "This is a second reply by the user", id: user.username },
|
|
],
|
|
)
|
|
end
|
|
|
|
it "handles uploads correctly in topic style messages (and times)" do
|
|
freeze_time 32.days.ago
|
|
|
|
# Use Discourse's upload format in the post raw content
|
|
upload_markdown = ""
|
|
|
|
post1 =
|
|
Fabricate(
|
|
:post,
|
|
topic: pm,
|
|
user: admin,
|
|
raw: "This is the original #{upload_markdown} I just added",
|
|
)
|
|
|
|
UploadReference.create!(target: post1, upload: image_upload1)
|
|
|
|
long_title = "A" * 40
|
|
upload2_markdown = ""
|
|
|
|
freeze_time 32.days.from_now
|
|
|
|
post2_with_upload =
|
|
Fabricate(
|
|
:post,
|
|
topic: pm,
|
|
user: admin,
|
|
raw: "This post has a different image #{upload2_markdown} I just added",
|
|
)
|
|
|
|
UploadReference.create!(target: post2_with_upload, upload: image_upload2)
|
|
|
|
messages =
|
|
described_class.messages_from_post(
|
|
post2_with_upload,
|
|
style: :topic,
|
|
max_posts: 3,
|
|
bot_usernames: [bot_user.username],
|
|
include_uploads: true,
|
|
)
|
|
|
|
expect(messages.length).to eq(1)
|
|
content = messages[0][:content]
|
|
|
|
upload_hashes = content.select { |c| c.is_a?(Hash) }
|
|
expect(upload_hashes).to include(
|
|
{ upload_id: image_upload1.id },
|
|
{ upload_id: image_upload2.id },
|
|
)
|
|
|
|
text = content.select { |c| c.is_a?(String) }.join(" ")
|
|
|
|
expect(text).to include("This is the original")
|
|
expect(text).to include("(1 month ago)")
|
|
expect(text).to include("#{upload_markdown}")
|
|
expect(text).to include("#{upload2_markdown}")
|
|
end
|
|
|
|
it "only include regular posts" do
|
|
first_post.update!(post_type: Post.types[:whisper])
|
|
|
|
context =
|
|
described_class.messages_from_post(
|
|
third_post,
|
|
max_posts: 10,
|
|
bot_usernames: [bot_user.username],
|
|
include_uploads: false,
|
|
)
|
|
|
|
# skips leading model reply which makes no sense cause first post was whisper
|
|
expect(context).to eq(
|
|
[{ type: :user, content: "This is a second reply by the user", id: user.username }],
|
|
)
|
|
end
|
|
|
|
context "with a secure upload not visible to the triggering user" do
|
|
fab!(:secure_upload) do
|
|
private_category = Fabricate(:private_category, group: Fabricate(:group))
|
|
private_topic = Fabricate(:topic, category: private_category)
|
|
private_post = Fabricate(:post, topic: private_topic)
|
|
Fabricate(
|
|
:secure_upload,
|
|
original_filename: "secret.png",
|
|
extension: "png",
|
|
access_control_post: private_post,
|
|
)
|
|
end
|
|
|
|
before { SiteSetting.embedded_media_post_allowed_groups = Group::AUTO_GROUPS[:everyone] }
|
|
|
|
it "drops the upload from the prompt" do
|
|
post_with_secure_upload =
|
|
create_post(
|
|
topic_id: pm.id,
|
|
user: user,
|
|
raw: "Look at this ",
|
|
)
|
|
|
|
context =
|
|
described_class.messages_from_post(
|
|
post_with_secure_upload,
|
|
max_posts: 1,
|
|
bot_usernames: [bot_user.username],
|
|
include_image_uploads: true,
|
|
include_document_uploads: false,
|
|
)
|
|
|
|
expect(context).to contain_exactly(
|
|
{ type: :user, id: user.username, content: post_with_secure_upload.raw },
|
|
)
|
|
end
|
|
end
|
|
|
|
context "with custom prompts" do
|
|
it "When post custom prompt is present, we use that instead of the post content" do
|
|
custom_prompt = [
|
|
[
|
|
{ name: "time", arguments: { name: "time", timezone: "Buenos Aires" } }.to_json,
|
|
"time",
|
|
"tool_call",
|
|
],
|
|
[
|
|
{ args: { timezone: "Buenos Aires" }, time: "2023-12-14 17:24:00 -0300" }.to_json,
|
|
"time",
|
|
"tool",
|
|
],
|
|
["I replied to the time command", bot_user.username],
|
|
]
|
|
|
|
PostCustomPrompt.create!(post: second_post, custom_prompt: custom_prompt)
|
|
|
|
context =
|
|
described_class.messages_from_post(
|
|
third_post,
|
|
max_posts: 10,
|
|
bot_usernames: [bot_user.username],
|
|
include_uploads: false,
|
|
)
|
|
|
|
expect(context).to eq(
|
|
[
|
|
{ type: :user, content: "This is a reply by the user", id: user.username },
|
|
{ type: :tool_call, content: custom_prompt.first.first, id: "time" },
|
|
{ type: :tool, id: "time", content: custom_prompt.second.first },
|
|
{ type: :model, content: custom_prompt.third.first },
|
|
{ type: :user, content: "This is a second reply by the user", id: user.username },
|
|
],
|
|
)
|
|
end
|
|
it "normalizes saved thinking provider info" do
|
|
custom_prompt = [
|
|
[
|
|
"Grounded answer",
|
|
bot_user.username,
|
|
nil,
|
|
nil,
|
|
{
|
|
"message" => "Web search: OpenAI news",
|
|
"provider_info" => {
|
|
"gemini" => {
|
|
"grounding_metadata" => {
|
|
"webSearchQueries" => ["OpenAI news"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
]
|
|
|
|
PostCustomPrompt.create!(post: second_post, custom_prompt: custom_prompt)
|
|
|
|
context =
|
|
described_class.messages_from_post(
|
|
third_post,
|
|
max_posts: 10,
|
|
bot_usernames: [bot_user.username],
|
|
include_uploads: false,
|
|
)
|
|
|
|
expect(context).to include(
|
|
{
|
|
type: :model,
|
|
content: "Grounded answer",
|
|
thinking: "Web search: OpenAI news",
|
|
thinking_provider_info: {
|
|
gemini: {
|
|
grounding_metadata: {
|
|
webSearchQueries: ["OpenAI news"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|