diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index c27075a9a539ad7445dc67ad7e57c189ad298077..84c6891f372693049fb3faccfb8c3e85c08f2331 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -36,6 +36,27 @@ in [Removed Items](../removed_items.md). The `Query` type contains the API's top-level entry points for all executable queries. +### `Query.aiMessages` + +Find AI messages. + +WARNING: +**Introduced** in 16.1. +This feature is an Experiment. It can be changed or removed at any time. + +Returns [`AiCachedMessageTypeConnection!`](#aicachedmessagetypeconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#connection-pagination-arguments): +`before: String`, `after: String`, `first: Int`, `last: Int`. + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="queryaimessagesrequestids"></a>`requestIds` | [`[ID!]`](#id) | Array of request IDs to fetch. | +| <a id="queryaimessagesroles"></a>`roles` | [`[AiCachedMessageRole!]`](#aicachedmessagerole) | Array of request IDs to fetch. | + ### `Query.boardList` Find an issue board list. @@ -7244,6 +7265,29 @@ The edge type for [`AgentConfiguration`](#agentconfiguration). | <a id="agentconfigurationedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | | <a id="agentconfigurationedgenode"></a>`node` | [`AgentConfiguration`](#agentconfiguration) | The item at the end of the edge. | +#### `AiCachedMessageTypeConnection` + +The connection type for [`AiCachedMessageType`](#aicachedmessagetype). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="aicachedmessagetypeconnectionedges"></a>`edges` | [`[AiCachedMessageTypeEdge]`](#aicachedmessagetypeedge) | A list of edges. | +| <a id="aicachedmessagetypeconnectionnodes"></a>`nodes` | [`[AiCachedMessageType]`](#aicachedmessagetype) | A list of nodes. | +| <a id="aicachedmessagetypeconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +#### `AiCachedMessageTypeEdge` + +The edge type for [`AiCachedMessageType`](#aicachedmessagetype). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="aicachedmessagetypeedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | +| <a id="aicachedmessagetypeedgenode"></a>`node` | [`AiCachedMessageType`](#aicachedmessagetype) | The item at the end of the edge. | + #### `AiMessageTypeConnection` The connection type for [`AiMessageType`](#aimessagetype). @@ -11686,6 +11730,19 @@ Information about a connected Agent. | <a id="agentmetadatapodnamespace"></a>`podNamespace` | [`String`](#string) | Namespace of the pod running the Agent. | | <a id="agentmetadataversion"></a>`version` | [`String`](#string) | Agent version tag. | +### `AiCachedMessageType` + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="aicachedmessagetypecontent"></a>`content` | [`String`](#string) | Content of the message. Can be null for user requests or failed responses. | +| <a id="aicachedmessagetypeerrors"></a>`errors` | [`[String!]!`](#string) | Errors that occurred while asynchronously fetching an AI(assistant) response. | +| <a id="aicachedmessagetypeid"></a>`id` | [`ID`](#id) | UUID of the message. | +| <a id="aicachedmessagetyperequestid"></a>`requestId` | [`ID`](#id) | UUID of the original request message. | +| <a id="aicachedmessagetyperole"></a>`role` | [`AiCachedMessageRole!`](#aicachedmessagerole) | Message role. | +| <a id="aicachedmessagetypetimestamp"></a>`timestamp` | [`Time!`](#time) | Message timestamp. | + ### `AiMessageType` #### Fields @@ -23859,6 +23916,15 @@ Agent token statuses. | <a id="agenttokenstatusactive"></a>`ACTIVE` | Active agent token. | | <a id="agenttokenstatusrevoked"></a>`REVOKED` | Revoked agent token. | +### `AiCachedMessageRole` + +Roles to filter in chat message. + +| Value | Description | +| ----- | ----------- | +| <a id="aicachedmessageroleassistant"></a>`ASSISTANT` | Filter only AI responses. | +| <a id="aicachedmessageroleuser"></a>`USER` | Filter only user messages. | + ### `AlertManagementAlertSort` Values for sorting alerts. diff --git a/ee/app/graphql/ee/types/query_type.rb b/ee/app/graphql/ee/types/query_type.rb index c50fee320784a83a5162573144e0381492f26848..bf7ac0f4f7b756bc554b99a6f601ed3989ba1886 100644 --- a/ee/app/graphql/ee/types/query_type.rb +++ b/ee/app/graphql/ee/types/query_type.rb @@ -97,6 +97,11 @@ module QueryType null: true, description: 'Instance level external audit event destinations.', resolver: ::Resolvers::AuditEvents::InstanceExternalAuditEventDestinationsResolver + + field :ai_messages, ::Types::Ai::CachedMessageType.connection_type, + resolver: ::Resolvers::Ai::MessagesResolver, + alpha: { milestone: '16.1' }, + description: 'Find AI messages.' end def vulnerability(id:) diff --git a/ee/app/graphql/resolvers/ai/messages_resolver.rb b/ee/app/graphql/resolvers/ai/messages_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..15b704f31f723f5ca28929c895124fe00dca9ff6 --- /dev/null +++ b/ee/app/graphql/resolvers/ai/messages_resolver.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Resolvers + module Ai + class MessagesResolver < BaseResolver + type Types::Ai::CachedMessageType, null: false + + argument :request_ids, [GraphQL::Types::ID], + required: false, + description: 'Array of request IDs to fetch.' + + argument :roles, [Types::Ai::CachedMessageRoleEnum], + required: false, + description: 'Array of request IDs to fetch.' + + def resolve(**args) + return [] unless current_user && Feature.enabled?(:ai_redis_cache, current_user) + + ::Gitlab::Llm::Cache.new(current_user).find_all(args) + end + end + end +end diff --git a/ee/app/graphql/types/ai/cached_message_role_enum.rb b/ee/app/graphql/types/ai/cached_message_role_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..4d3c264e25bf65e3d2eb4ea1f6c3bb581ff0dcad --- /dev/null +++ b/ee/app/graphql/types/ai/cached_message_role_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Ai + class CachedMessageRoleEnum < BaseEnum + graphql_name 'AiCachedMessageRole' + description 'Roles to filter in chat message.' + + value 'USER', 'Filter only user messages.', value: 'user' + value 'ASSISTANT', 'Filter only AI responses.', value: 'assistant' + end + end +end diff --git a/ee/app/graphql/types/ai/cached_message_type.rb b/ee/app/graphql/types/ai/cached_message_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..ac5f2e75c086d7dfa0ea9365d8f71f66884431ca --- /dev/null +++ b/ee/app/graphql/types/ai/cached_message_type.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Types + module Ai + # rubocop: disable Graphql/AuthorizeTypes + class CachedMessageType < Types::BaseObject + graphql_name 'AiCachedMessageType' + + field :id, + GraphQL::Types::ID, + description: 'UUID of the message.' + + field :request_id, + GraphQL::Types::ID, + description: 'UUID of the original request message.' + + field :content, + GraphQL::Types::String, + null: true, + description: 'Content of the message. Can be null for user requests or failed responses.' + + field :role, + Types::Ai::CachedMessageRoleEnum, + null: false, + description: 'Message role.' + + field :timestamp, + Types::TimeType, + null: false, + description: 'Message timestamp.' + + field :errors, + [GraphQL::Types::String], + null: false, + description: 'Errors that occurred while asynchronously fetching an AI(assistant) response.' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/ee/app/services/llm/base_service.rb b/ee/app/services/llm/base_service.rb index c714b419591785135a642ce9ac366da663024d82..88e7631d7932358ea42b4c888743134219883f27 100644 --- a/ee/app/services/llm/base_service.rb +++ b/ee/app/services/llm/base_service.rb @@ -49,7 +49,7 @@ def perform_async(user, resource, action_name, options) ::Llm::CompletionWorker.perform_async(user.id, resource.id, resource.class.name, action_name, options) - payload = { request_id: request_id } + payload = { request_id: request_id, role: 'user' } ::Gitlab::Llm::Cache.new(user).add(payload) success(payload) diff --git a/ee/lib/gitlab/llm/cache.rb b/ee/lib/gitlab/llm/cache.rb index 253ae345cae5f7c9e41b7025896d5d2d4f91e49d..30774c1b17c0b9b21180482c6f0abbb4e1d941dd 100644 --- a/ee/lib/gitlab/llm/cache.rb +++ b/ee/lib/gitlab/llm/cache.rb @@ -21,22 +21,22 @@ def add(payload) return unless Feature.enabled?(:ai_redis_cache, user) data = { + id: SecureRandom.uuid, request_id: payload[:request_id], - timestamp: Time.now.to_i + timestamp: Time.current.to_s, + role: payload[:role] || 'user' } - data[:response_body] = payload[:response_body][0, MAX_TEXT_LIMIT] if payload[:response_body] + data[:content] = payload[:content][0, MAX_TEXT_LIMIT] if payload[:content] data[:error] = payload[:errors].join(". ") if payload[:errors] cache_data(data) end - def get(request_id) - all.find { |data| data['request_id'] == request_id && data['response_body'].present? } - end - - def all + def find_all(filters = {}) with_redis do |redis| - redis.xrange(key).map { |_id, data| data } + redis.xrange(key).filter_map do |_id, data| + CachedMessage.new(data) if matches_filters?(data, filters) + end end end @@ -58,6 +58,13 @@ def key def with_redis(&block) Gitlab::Redis::Chat.with(&block) # rubocop: disable CodeReuse/ActiveRecord end + + def matches_filters?(data, filters) + return false if filters[:roles] && filters[:roles].exclude?(data['role']) + return false if filters[:request_ids] && filters[:request_ids].exclude?(data['request_id']) + + data + end end end end diff --git a/ee/lib/gitlab/llm/cached_message.rb b/ee/lib/gitlab/llm/cached_message.rb new file mode 100644 index 0000000000000000000000000000000000000000..8b2f0194143636247db07648bb02737c03c6cc3d --- /dev/null +++ b/ee/lib/gitlab/llm/cached_message.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Gitlab + module Llm + class CachedMessage + attr_reader :id, :request_id, :content, :role, :timestamp, :error + + def initialize(data) + @id = data['id'] + @request_id = data['request_id'] + @content = data['content'] + @role = data['role'] + @error = data['error'] + @timestamp = Time.zone.parse(data['timestamp']) + end + + def to_global_id + ::Gitlab::GlobalId.build(self) + end + + def errors + Array.wrap(error) + end + end + end +end diff --git a/ee/lib/gitlab/llm/graphql_subscription_response_service.rb b/ee/lib/gitlab/llm/graphql_subscription_response_service.rb index 1c0d23a25d21f04623c78239df43640f3ca30c4a..c412b5064069db56a139c6ee7c607d137d0dcf38 100644 --- a/ee/lib/gitlab/llm/graphql_subscription_response_service.rb +++ b/ee/lib/gitlab/llm/graphql_subscription_response_service.rb @@ -20,7 +20,8 @@ def execute model_name: resource.class.name, # todo: do we need to sanitize/refine this response in any ways? response_body: generate_response_body(response_modifier.response_body), - errors: response_modifier.errors + errors: response_modifier.errors, + role: 'assistant' } logger.debug( @@ -28,7 +29,9 @@ def execute data: data ) - Gitlab::Llm::Cache.new(user).add(data.slice(:request_id, :response_body, :errors)) + Gitlab::Llm::Cache.new(user).add( + data.slice(:request_id, :errors, :role).merge(content: data[:response_body]) + ) GraphqlTriggers.ai_completion_response(user.to_global_id, resource.to_global_id, data) end diff --git a/ee/spec/graphql/types/query_type_spec.rb b/ee/spec/graphql/types/query_type_spec.rb index 5e0d128490cbf63949452f7590a154c308396f65..bc4aaa204e7c1fbed1d930c8b5a937f8e9cd5aad 100644 --- a/ee/spec/graphql/types/query_type_spec.rb +++ b/ee/spec/graphql/types/query_type_spec.rb @@ -7,6 +7,7 @@ specify do expected_ee_fields = [ + :ai_messages, :ci_catalog_resources, :ci_minutes_usage, :current_license, diff --git a/ee/spec/lib/gitlab/llm/cache_spec.rb b/ee/spec/lib/gitlab/llm/cache_spec.rb index 133652a7e32a4d832d1a476cb45df95af495e72c..9c1469312adb968adc659c9d45f332ca97a5eb30 100644 --- a/ee/spec/lib/gitlab/llm/cache_spec.rb +++ b/ee/spec/lib/gitlab/llm/cache_spec.rb @@ -2,16 +2,17 @@ require 'spec_helper' -RSpec.describe Gitlab::Llm::Cache, :clean_gitlab_redis_cache, feature_category: :no_category do # rubocop: disable RSpec/InvalidFeatureCategory +RSpec.describe Gitlab::Llm::Cache, :clean_gitlab_redis_cache, feature_category: :shared do let_it_be(:user) { create(:user) } - let(:uuid) { 'uuid' } - let(:timestamp) { Time.now.to_i.to_s } + let(:request_id) { 'uuid' } + let(:timestamp) { Time.current.to_s } let(:payload) do { timestamp: timestamp, - request_id: uuid, + request_id: request_id, errors: ['some error1', 'another error'], - response_body: 'response' + role: 'user', + content: 'response' } end @@ -21,20 +22,25 @@ other_user = create(:user) other_cache = described_class.new(other_user) - other_cache.add(payload.merge(response_body: 'other user unrelated cache')) + other_cache.add(payload.merge(content: 'other user unrelated cache')) end describe '#add' do - it 'adds new message' do - expect(subject.all).to be_empty + it 'adds new message', :aggregate_failures do + uuid = 'unique_id' + + expect(SecureRandom).to receive(:uuid).once.and_return(uuid) + expect(subject.find_all).to be_empty subject.add(payload) - expected_data = payload - .except(:errors) - .merge(error: 'some error1. another error') - .with_indifferent_access - expect(subject.all).to eq([expected_data.with_indifferent_access]) + last = subject.find_all.last + expect(last.id).to eq(uuid) + expect(last.request_id).to eq(request_id) + expect(last.errors).to eq(['some error1. another error']) + expect(last.content).to eq('response') + expect(last.role).to eq('user') + expect(last.timestamp).not_to be_nil end context 'when ai_redis_cache is disabled' do @@ -43,11 +49,11 @@ end it 'does not add new message' do - expect(subject.all).to be_empty + expect(subject.find_all).to be_empty subject.add(payload) - expect(subject.all).to be_empty + expect(subject.find_all).to be_empty end end @@ -57,69 +63,45 @@ end it 'removes oldes messages if we reach maximum message limit' do - subject.add(payload.merge(response_body: 'msg1')) - subject.add(payload.merge(response_body: 'msg2')) + subject.add(payload.merge(content: 'msg1')) + subject.add(payload.merge(content: 'msg2')) - expect(subject.all).to match([ - a_hash_including('response_body' => 'msg1'), - a_hash_including('response_body' => 'msg2') - ]) + expect(subject.find_all.map(&:content)).to eq(%w[msg1 msg2]) - subject.add(payload.merge(response_body: 'msg3')) + subject.add(payload.merge(content: 'msg3')) - expect(subject.all).to match([ - a_hash_including('response_body' => 'msg2'), - a_hash_including('response_body' => 'msg3') - ]) + expect(subject.find_all.map(&:content)).to eq(%w[msg2 msg3]) end end end - describe '#get' do - context 'when there is both request and response' do - before do - subject.add(payload.merge(response_body: nil)) - subject.add(payload.merge(response_body: 'msg')) - end - - it 'gets response by request id' do - data = subject.get(uuid) + describe '#find_all' do + let(:filters) { {} } - expect(data).not_to be_nil - expect(data['response_body']).to eq('msg') - end + before do + subject.add(payload.merge(content: 'msg1', role: 'user', request_id: '1')) + subject.add(payload.merge(content: 'msg2', role: 'assistant', request_id: '2')) + subject.add(payload.merge(content: 'msg3', role: 'assistant', request_id: '3')) end - context 'when there is only request' do - before do - subject.add(payload.merge(response_body: nil)) - end - - it 'returns nil' do - data = subject.get(uuid) - - expect(data).to be_nil - end + it 'returns all records for this user' do + expect(subject.find_all(filters).map(&:content)).to eq(%w[msg1 msg2 msg3]) end - context 'when there is no record with this request id' do - it 'returns nil' do - data = subject.get(uuid) + context 'when filtering by role' do + let(:filters) { { roles: ['user'] } } - expect(data).to be_nil + it 'returns only records for this role' do + expect(subject.find_all(filters).map(&:content)).to eq(%w[msg1]) end end - end - describe '#all' do - it 'returns all records for this user' do - subject.add(payload.merge(response_body: 'msg1')) - subject.add(payload.merge(response_body: 'msg2')) + context 'when filtering by request_ids' do + let(:filters) { { request_ids: %w[2 3] } } - expect(subject.all).to match([ - a_hash_including('response_body' => 'msg1'), - a_hash_including('response_body' => 'msg2') - ]) + it 'returns only records with the same request_id' do + expect(subject.find_all(filters).map(&:content)).to eq(%w[msg2 msg3]) + end end end end diff --git a/ee/spec/lib/gitlab/llm/cached_message_spec.rb b/ee/spec/lib/gitlab/llm/cached_message_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3348f53d1a3fe08b80d036a53c6ffe539082010a --- /dev/null +++ b/ee/spec/lib/gitlab/llm/cached_message_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Llm::CachedMessage, feature_category: :shared do + let(:timestamp) { Time.current } + let(:data) do + { + 'timestamp' => timestamp.to_s, + 'id' => 'uuid', + 'request_id' => 'original_request_id', + 'error' => 'some error1. another error', + 'role' => 'user', + 'content' => 'response' + } + end + + subject { described_class.new(data) } + + describe '#to_global_id' do + it 'returns global ID' do + expect(subject.to_global_id.to_s).to eq('gid://gitlab/Gitlab::Llm::CachedMessage/uuid') + end + end + + describe '#errors' do + it 'returns message error wrapped as an array' do + expect(subject.errors).to eq([data['error']]) + end + end +end diff --git a/ee/spec/lib/gitlab/llm/graphql_subscription_response_service_spec.rb b/ee/spec/lib/gitlab/llm/graphql_subscription_response_service_spec.rb index 88ae7f21d5a16b7d409ac19d3ecda75a878778bc..3130c1b283e2ef03eb2404848ba5b4b8d4f7523c 100644 --- a/ee/spec/lib/gitlab/llm/graphql_subscription_response_service_spec.rb +++ b/ee/spec/lib/gitlab/llm/graphql_subscription_response_service_spec.rb @@ -41,6 +41,7 @@ model_name: resource.class.name, response_body: response_body, request_id: 'uuid', + role: 'assistant', errors: [] } end @@ -59,7 +60,8 @@ it 'caches response' do expect_next_instance_of(::Gitlab::Llm::Cache) do |cache| - expect(cache).to receive(:add).with(payload.slice(:request_id, :response_body, :errors)) + expect(cache).to receive(:add) + .with(payload.slice(:request_id, :errors, :role).merge(content: payload[:response_body])) end subject diff --git a/ee/spec/lib/gitlab/llm/open_ai/completions/generate_test_file_spec.rb b/ee/spec/lib/gitlab/llm/open_ai/completions/generate_test_file_spec.rb index f5631466783e95bc72261c4669e1a56a74dcb3d3..b95e33134c87f51ea510326d1894c6f3cf2cf3aa 100644 --- a/ee/spec/lib/gitlab/llm/open_ai/completions/generate_test_file_spec.rb +++ b/ee/spec/lib/gitlab/llm/open_ai/completions/generate_test_file_spec.rb @@ -62,13 +62,14 @@ uuid = 'uuid' - expect(SecureRandom).to receive(:uuid).and_return(uuid) + expect(SecureRandom).to receive(:uuid).twice.and_return(uuid) data = { id: uuid, model_name: 'MergeRequest', response_body: content, - request_id: 'uuid', + request_id: uuid, + role: 'assistant', errors: [] } diff --git a/ee/spec/requests/api/graphql/ai_messages_spec.rb b/ee/spec/requests/api/graphql/ai_messages_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a7a4210cb49a5be491fff126f5b91f8d6823fb38 --- /dev/null +++ b/ee/spec/requests/api/graphql/ai_messages_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Querying user AI messages', :clean_gitlab_redis_cache, feature_category: :shared do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:other_user) { create(:user) } + + let(:fields) do + <<~GRAPHQL + nodes { + requestId + content + role + timestamp + errors + } + GRAPHQL + end + + let(:arguments) { { requestIds: 'uuid1' } } + let(:query) { graphql_query_for('aiMessages', arguments, fields) } + + subject { graphql_data.dig('aiMessages', 'nodes') } + + before do + ::Gitlab::Llm::Cache.new(user).add(request_id: 'uuid1', role: 'user') + ::Gitlab::Llm::Cache.new(user).add(request_id: 'uuid1', role: 'assistant', content: 'response') + # should not be included in response because it's for other user + ::Gitlab::Llm::Cache.new(other_user).add(request_id: 'uuid1', role: 'user') + end + + context 'when user is not logged in' do + let(:current_user) { nil } + + it 'returns an empty array' do + post_graphql(query, current_user: current_user) + + expect(subject).to be_empty + end + end + + context 'when user is logged in' do + let(:current_user) { user } + + it 'returns user messages', :freeze_time do + post_graphql(query, current_user: current_user) + + expect(subject).to eq([ + { 'requestId' => 'uuid1', 'content' => nil, 'role' => 'USER', 'errors' => [], + 'timestamp' => Time.current.iso8601 }, + { 'requestId' => 'uuid1', 'content' => 'response', 'role' => 'ASSISTANT', 'errors' => [], + 'timestamp' => Time.current.iso8601 } + ]) + end + + context 'when ai_redis_cache is disabled' do + before do + stub_feature_flags(ai_redis_cache: false) + end + + it 'returns an empty array' do + post_graphql(query, current_user: current_user) + + expect(subject).to be_empty + end + end + end +end diff --git a/ee/spec/support/shared_examples/services/llm/async_service_shared_examples.rb b/ee/spec/support/shared_examples/services/llm/async_service_shared_examples.rb index 813b3c6b872a1e4d6491dbb007db7c42bc170b59..f039261ed16f46f2120f4a5ff14014e250a33f29 100644 --- a/ee/spec/support/shared_examples/services/llm/async_service_shared_examples.rb +++ b/ee/spec/support/shared_examples/services/llm/async_service_shared_examples.rb @@ -4,7 +4,7 @@ it 'executes the service asynchronously' do expected_options = options.merge(request_id: 'uuid') - expect(SecureRandom).to receive(:uuid).once.and_return('uuid') + expect(SecureRandom).to receive(:uuid).twice.and_return('uuid') expect(::Llm::CompletionWorker) .to receive(:perform_async) .with(user.id, resource.id, resource.class.name, action_name, expected_options) @@ -15,7 +15,7 @@ it 'caches request' do expect(SecureRandom).to receive(:uuid).once.and_return('uuid') expect_next_instance_of(::Gitlab::Llm::Cache) do |cache| - expect(cache).to receive(:add).with({ request_id: 'uuid' }) + expect(cache).to receive(:add).with({ request_id: 'uuid', role: 'user' }) end subject.execute