Skip to content

Commit bb50f9b

Browse files
authored
Merge pull request #102 from patvice/sample-correct-stop-response
Support correct stop response coming from ruby llm message
2 parents f5e9b4f + 2296fdc commit bb50f9b

File tree

5 files changed

+222
-5
lines changed

5 files changed

+222
-5
lines changed

lib/ruby_llm/mcp/native/messages/responses.rb

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,20 @@ def roots_list(id:, roots_paths:)
3737
end
3838

3939
def sampling_create_message(id:, message:, model:)
40+
stop_reason = if message.respond_to?(:stop_reason) && message.stop_reason
41+
snake_to_camel(message.stop_reason)
42+
else
43+
"endTurn"
44+
end
45+
4046
{
4147
jsonrpc: JSONRPC_VERSION,
4248
id: id,
4349
result: {
4450
role: message.role,
4551
content: format_content(message.content),
4652
model: model,
47-
# TODO: We are going to assume it was a endTurn
48-
# Look into getting RubyLLM to expose stopReason in message response
49-
stopReason: "endTurn"
53+
stopReason: stop_reason
5054
}
5155
}
5256
end
@@ -89,6 +93,12 @@ def format_content(content)
8993
end
9094
end
9195
private_class_method :format_content
96+
97+
def snake_to_camel(str)
98+
parts = str.split("_")
99+
parts.first + parts[1..].map(&:capitalize).join
100+
end
101+
private_class_method :snake_to_camel
92102
end
93103
end
94104
end

spec/ruby_llm/mcp/cancellation_integration_spec.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,11 @@
5656

5757
client.start
5858

59+
# Wait for the tool to become available
60+
tool = wait_for_tool(client, "sample_with_cancellation")
61+
5962
# Call the tool in a thread so we can cancel it
6063
tool_thread = Thread.new do
61-
tool = client.tool("sample_with_cancellation")
6264
tool.execute
6365
end
6466

@@ -109,10 +111,12 @@
109111

110112
client.start
111113

114+
# Wait for the tool to become available
115+
tool = wait_for_tool(client, "sample_with_cancellation")
116+
112117
# Start multiple sampling requests
113118
threads = 3.times.map do
114119
Thread.new do
115-
tool = client.tool("sample_with_cancellation")
116120
tool.execute
117121
end
118122
end

spec/ruby_llm/mcp/native/messages_spec.rb

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,165 @@
504504
expect(body[:result][:content]).to be_nil
505505
end
506506
end
507+
508+
describe ".sampling_create_message" do
509+
# rubocop:disable RSpec/VerifiedDoubles
510+
let(:mock_content) do
511+
double("Content", text: "Hello, world!")
512+
end
513+
514+
context "when message has no stop_reason" do
515+
let(:mock_message) do
516+
double(
517+
"Message",
518+
role: "assistant",
519+
content: mock_content
520+
).tap do |msg|
521+
allow(msg).to receive(:respond_to?).with(:stop_reason).and_return(false)
522+
end
523+
end
524+
525+
let(:body) do
526+
described_class::Responses.sampling_create_message(
527+
id: "req-123",
528+
message: mock_message,
529+
model: "gpt-4"
530+
)
531+
end
532+
533+
it "creates a valid sampling response with default stopReason" do
534+
expect(body[:jsonrpc]).to eq("2.0")
535+
expect(body[:id]).to eq("req-123")
536+
expect(body[:result][:role]).to eq("assistant")
537+
expect(body[:result][:model]).to eq("gpt-4")
538+
expect(body[:result][:stopReason]).to eq("endTurn")
539+
end
540+
541+
it "validates against CreateMessageResult schema" do
542+
body_json = JSON.parse(body.to_json)
543+
response_json = {
544+
"jsonrpc" => "2.0",
545+
"id" => body_json["id"],
546+
"result" => body_json["result"]
547+
}
548+
errors = schemer.validate(response_json).to_a
549+
expect(errors).to be_empty, "Schema validation errors: #{errors.map(&:to_h)}"
550+
end
551+
end
552+
553+
context "when message has stop_reason nil" do
554+
let(:mock_message) do
555+
double(
556+
"Message",
557+
role: "assistant",
558+
content: mock_content,
559+
stop_reason: nil
560+
).tap do |msg|
561+
allow(msg).to receive(:respond_to?).with(:stop_reason).and_return(true)
562+
end
563+
end
564+
565+
let(:body) do
566+
described_class::Responses.sampling_create_message(
567+
id: "req-123",
568+
message: mock_message,
569+
model: "gpt-4"
570+
)
571+
end
572+
573+
it "uses default stopReason when nil" do
574+
expect(body[:result][:stopReason]).to eq("endTurn")
575+
end
576+
end
577+
578+
context "when message has stop_reason values" do
579+
shared_examples "converts stop_reason correctly" do |snake_case_value, camel_case_value|
580+
let(:mock_message) do
581+
double(
582+
"Message",
583+
role: "assistant",
584+
content: mock_content,
585+
stop_reason: snake_case_value
586+
).tap do |msg|
587+
allow(msg).to receive(:respond_to?).with(:stop_reason).and_return(true)
588+
end
589+
end
590+
591+
let(:body) do
592+
described_class::Responses.sampling_create_message(
593+
id: "req-123",
594+
message: mock_message,
595+
model: "gpt-4"
596+
)
597+
end
598+
599+
it "converts #{snake_case_value} to #{camel_case_value}" do
600+
expect(body[:result][:stopReason]).to eq(camel_case_value)
601+
end
602+
603+
it "validates against CreateMessageResult schema" do
604+
body_json = JSON.parse(body.to_json)
605+
response_json = {
606+
"jsonrpc" => "2.0",
607+
"id" => body_json["id"],
608+
"result" => body_json["result"]
609+
}
610+
errors = schemer.validate(response_json).to_a
611+
expect(errors).to be_empty, "Schema validation errors: #{errors.map(&:to_h)}"
612+
end
613+
end
614+
615+
context "with stop_reason: end_turn" do
616+
it_behaves_like "converts stop_reason correctly", "end_turn", "endTurn"
617+
end
618+
619+
context "with stop_reason: max_tokens" do
620+
it_behaves_like "converts stop_reason correctly", "max_tokens", "maxTokens"
621+
end
622+
623+
context "with stop_reason: stop_sequence" do
624+
it_behaves_like "converts stop_reason correctly", "stop_sequence", "stopSequence"
625+
end
626+
627+
context "with stop_reason: tool_use" do
628+
it_behaves_like "converts stop_reason correctly", "tool_use", "toolUse"
629+
end
630+
631+
context "with stop_reason: pause_turn" do
632+
it_behaves_like "converts stop_reason correctly", "pause_turn", "pauseTurn"
633+
end
634+
635+
context "with stop_reason: refusal" do
636+
it_behaves_like "converts stop_reason correctly", "refusal", "refusal"
637+
end
638+
end
639+
640+
context "with custom stop_reason values" do
641+
let(:mock_message) do
642+
double(
643+
"Message",
644+
role: "assistant",
645+
content: mock_content,
646+
stop_reason: "custom_stop_reason"
647+
).tap do |msg|
648+
allow(msg).to receive(:respond_to?).with(:stop_reason).and_return(true)
649+
end
650+
end
651+
652+
let(:body) do
653+
described_class::Responses.sampling_create_message(
654+
id: "req-123",
655+
message: mock_message,
656+
model: "gpt-4"
657+
)
658+
end
659+
660+
it "converts unknown snake_case values to camelCase" do
661+
expect(body[:result][:stopReason]).to eq("customStopReason")
662+
end
663+
end
664+
# rubocop:enable RSpec/VerifiedDoubles
665+
end
507666
end
508667

509668
describe "UUID generation consistency" do

spec/spec_helper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
require "mcp" if RUBY_VERSION >= "3.2.0"
2222

2323
require_relative "support/client_runner"
24+
require_relative "support/client_sync_helpers"
2425
require_relative "support/test_server_manager"
2526
require_relative "support/mcp_test_configuration"
2627
require_relative "support/simple_multiply_tool"
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# frozen_string_literal: true
2+
3+
# Client Synchronization Helpers
4+
#
5+
# This module provides helper methods for waiting on client state changes
6+
# and tool availability in tests.
7+
module ClientSyncHelpers
8+
# Wait for a specific tool to become available on a client
9+
#
10+
# @param client [RubyLLM::MCP::Client] The MCP client to check
11+
# @param tool_name [String] The name of the tool to wait for
12+
# @param max_wait_time [Integer, Float] Maximum time to wait in seconds (default: 5)
13+
# @return [RubyLLM::MCP::Tool] The tool instance when found
14+
# @raise [RuntimeError] If the tool is not found within the timeout period
15+
def wait_for_tool(client, tool_name, max_wait_time: 5)
16+
start_time = Time.now
17+
tool = nil
18+
19+
loop do
20+
tool = client.tool(tool_name)
21+
break if tool
22+
23+
elapsed = Time.now - start_time
24+
if elapsed > max_wait_time
25+
available_tools = begin
26+
client.tools.map(&:name).join(", ")
27+
rescue StandardError
28+
"Unable to fetch tools"
29+
end
30+
raise "Timeout waiting for tool '#{tool_name}' after #{elapsed.round(2)}s. \
31+
Available tools: #{available_tools}. Client alive: #{client.alive?}"
32+
end
33+
34+
sleep 0.1
35+
end
36+
37+
tool
38+
end
39+
end
40+
41+
RSpec.configure do |config|
42+
config.include ClientSyncHelpers
43+
end

0 commit comments

Comments
 (0)