lex-mesh 0.1.1 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/lex-mesh.gemspec +3 -1
  4. data/lib/legion/extensions/mesh/actors/pending_expiry.rb +37 -0
  5. data/lib/legion/extensions/mesh/actors/preference_listener.rb +44 -0
  6. data/lib/legion/extensions/mesh/actors/silence_watchdog.rb +37 -0
  7. data/lib/legion/extensions/mesh/client.rb +7 -2
  8. data/lib/legion/extensions/mesh/helpers/delegation.rb +126 -0
  9. data/lib/legion/extensions/mesh/helpers/peer_verify.rb +79 -0
  10. data/lib/legion/extensions/mesh/helpers/pending_requests.rb +57 -0
  11. data/lib/legion/extensions/mesh/helpers/registry.rb +12 -0
  12. data/lib/legion/extensions/mesh/runners/delegation.rb +87 -0
  13. data/lib/legion/extensions/mesh/runners/mesh.rb +20 -0
  14. data/lib/legion/extensions/mesh/runners/preferences.rb +120 -0
  15. data/lib/legion/extensions/mesh/transport/messages/mesh_departure.rb +34 -0
  16. data/lib/legion/extensions/mesh/transport/messages/preference_query.rb +34 -0
  17. data/lib/legion/extensions/mesh/transport/messages/preference_response.rb +34 -0
  18. data/lib/legion/extensions/mesh/transport/queues/preference.rb +27 -0
  19. data/lib/legion/extensions/mesh/version.rb +1 -1
  20. data/lib/legion/extensions/mesh.rb +12 -0
  21. data/spec/legion/extensions/mesh/actors/pending_expiry_spec.rb +57 -0
  22. data/spec/legion/extensions/mesh/actors/preference_listener_spec.rb +87 -0
  23. data/spec/legion/extensions/mesh/actors/silence_watchdog_spec.rb +55 -0
  24. data/spec/legion/extensions/mesh/client_spec.rb +40 -1
  25. data/spec/legion/extensions/mesh/helpers/delegation_spec.rb +125 -0
  26. data/spec/legion/extensions/mesh/helpers/peer_verify_spec.rb +76 -0
  27. data/spec/legion/extensions/mesh/helpers/pending_requests_spec.rb +80 -0
  28. data/spec/legion/extensions/mesh/helpers/registry_spec.rb +41 -0
  29. data/spec/legion/extensions/mesh/runners/delegation_spec.rb +84 -0
  30. data/spec/legion/extensions/mesh/runners/mesh_spec.rb +25 -0
  31. data/spec/legion/extensions/mesh/runners/preferences_spec.rb +164 -0
  32. data/spec/legion/extensions/mesh/transport/messages/mesh_departure_spec.rb +81 -0
  33. data/spec/legion/extensions/mesh/transport/messages/preference_query_spec.rb +85 -0
  34. data/spec/legion/extensions/mesh/transport/messages/preference_response_spec.rb +85 -0
  35. data/spec/legion/extensions/mesh/transport/queues/preference_spec.rb +61 -0
  36. metadata +41 -3
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'ed25519'
5
+
6
+ RSpec.describe Legion::Extensions::Mesh::Helpers::PeerVerify do
7
+ let(:signing_key) { Ed25519::SigningKey.generate }
8
+ let(:verify_key) { signing_key.verify_key }
9
+ let(:private_key_b64) { Base64.strict_encode64(signing_key.to_bytes) }
10
+ let(:public_key_b64) { Base64.strict_encode64(verify_key.to_bytes) }
11
+
12
+ let(:peer_config) do
13
+ [{ org_id: 'acme', public_key: "ed25519:#{public_key_b64}",
14
+ capabilities: %w[query task], rate_limit: 5 }]
15
+ end
16
+
17
+ before do
18
+ described_class.reset_counters!
19
+ stub_const('Legion::Settings', Module.new) unless defined?(Legion::Settings)
20
+ allow(Legion::Settings).to receive(:dig).with(:mesh, :trusted_peers).and_return(peer_config)
21
+ end
22
+
23
+ describe '.sign_message' do
24
+ it 'signs a payload with Ed25519' do
25
+ result = described_class.sign_message({ hello: 'world' }, private_key_b64)
26
+ expect(result[:signature]).to be_a(String)
27
+ expect(result[:payload]).to eq({ hello: 'world' })
28
+ end
29
+ end
30
+
31
+ describe '.verify_message' do
32
+ it 'verifies a validly signed message' do
33
+ signed = described_class.sign_message({ hello: 'world' }, private_key_b64)
34
+ result = described_class.verify_message(signed, org_id: 'acme')
35
+ expect(result[:valid]).to be true
36
+ expect(result[:org_id]).to eq('acme')
37
+ end
38
+
39
+ it 'rejects a tampered message' do
40
+ signed = described_class.sign_message({ hello: 'world' }, private_key_b64)
41
+ signed[:signed_bytes] = 'tampered data'
42
+ result = described_class.verify_message(signed, org_id: 'acme')
43
+ expect(result[:valid]).to be false
44
+ expect(result[:reason]).to eq(:invalid_signature)
45
+ end
46
+
47
+ it 'rejects unknown peers' do
48
+ signed = described_class.sign_message({ hello: 'world' }, private_key_b64)
49
+ result = described_class.verify_message(signed, org_id: 'unknown-org')
50
+ expect(result[:valid]).to be false
51
+ expect(result[:reason]).to eq(:unknown_peer)
52
+ end
53
+ end
54
+
55
+ describe '.check_rate_limit' do
56
+ it 'allows messages within limit' do
57
+ result = described_class.check_rate_limit('acme')
58
+ expect(result[:allowed]).to be true
59
+ end
60
+
61
+ it 'blocks messages over limit' do
62
+ 5.times { described_class.check_rate_limit('acme') }
63
+ result = described_class.check_rate_limit('acme')
64
+ expect(result[:allowed]).to be false
65
+ expect(result[:error]).to eq(:rate_limited)
66
+ end
67
+
68
+ it 'resets after window expires' do
69
+ 5.times { described_class.check_rate_limit('acme') }
70
+ counters = described_class.instance_variable_get(:@counters)
71
+ counters['acme'][:window_start] = Time.now.utc - 61
72
+ result = described_class.check_rate_limit('acme')
73
+ expect(result[:allowed]).to be true
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/mesh/helpers/pending_requests'
5
+
6
+ RSpec.describe Legion::Extensions::Mesh::Helpers::PendingRequests do
7
+ subject(:tracker) { described_class.new(default_ttl: 5) }
8
+
9
+ describe '#register' do
10
+ it 'stores a pending request' do
11
+ tracker.register(correlation_id: 'abc', callback: ->(_r) {})
12
+ expect(tracker.pending?('abc')).to be true
13
+ end
14
+
15
+ it 'increments pending_count' do
16
+ expect { tracker.register(correlation_id: 'x', callback: nil) }
17
+ .to change { tracker.pending_count }.by(1)
18
+ end
19
+ end
20
+
21
+ describe '#resolve' do
22
+ it 'invokes callback and removes entry' do
23
+ received = nil
24
+ tracker.register(correlation_id: 'r1', callback: ->(result) { received = result })
25
+ result = tracker.resolve(correlation_id: 'r1', result: { verbosity: :concise })
26
+ expect(result).to be true
27
+ expect(received).to eq({ verbosity: :concise })
28
+ expect(tracker.pending?('r1')).to be false
29
+ end
30
+
31
+ it 'returns false for unknown correlation_id' do
32
+ result = tracker.resolve(correlation_id: 'nonexistent', result: {})
33
+ expect(result).to be false
34
+ end
35
+
36
+ it 'handles nil callback gracefully' do
37
+ tracker.register(correlation_id: 'nil-cb', callback: nil)
38
+ expect { tracker.resolve(correlation_id: 'nil-cb', result: {}) }.not_to raise_error
39
+ end
40
+ end
41
+
42
+ describe '#pending?' do
43
+ it 'returns false for unregistered id' do
44
+ expect(tracker.pending?('missing')).to be false
45
+ end
46
+ end
47
+
48
+ describe '#pending_count' do
49
+ it 'returns correct count' do
50
+ tracker.register(correlation_id: 'a', callback: nil)
51
+ tracker.register(correlation_id: 'b', callback: nil)
52
+ expect(tracker.pending_count).to eq(2)
53
+ end
54
+ end
55
+
56
+ describe '#expire' do
57
+ it 'removes entries past TTL' do
58
+ tracker.register(correlation_id: 'old', callback: nil, ttl: 1)
59
+ entry = tracker.instance_variable_get(:@requests)['old']
60
+ entry[:registered_at] = Time.now - 10
61
+ expired = tracker.expire
62
+ expect(expired).to include('old')
63
+ expect(tracker.pending?('old')).to be false
64
+ end
65
+
66
+ it 'does not remove entries within TTL' do
67
+ tracker.register(correlation_id: 'fresh', callback: nil, ttl: 60)
68
+ tracker.expire
69
+ expect(tracker.pending?('fresh')).to be true
70
+ end
71
+
72
+ it 'returns array of expired correlation ids' do
73
+ tracker.register(correlation_id: 'e1', callback: nil, ttl: 1)
74
+ tracker.register(correlation_id: 'e2', callback: nil, ttl: 1)
75
+ tracker.instance_variable_get(:@requests).each_value { |e| e[:registered_at] = Time.now - 10 }
76
+ expired = tracker.expire
77
+ expect(expired).to contain_exactly('e1', 'e2')
78
+ end
79
+ end
80
+ end
@@ -272,6 +272,47 @@ RSpec.describe Legion::Extensions::Mesh::Helpers::Registry do
272
272
  end
273
273
  end
274
274
 
275
+ describe '#expire_silent_agents' do
276
+ it 'marks stale agents as offline' do
277
+ registry.register_agent('a1')
278
+ registry.agents['a1'][:last_seen] = Time.now.utc - 60
279
+ expired = registry.expire_silent_agents(timeout: 30)
280
+ expect(expired).to eq(['a1'])
281
+ expect(registry.agents['a1'][:status]).to eq(:offline)
282
+ end
283
+
284
+ it 'does not expire agents within timeout' do
285
+ registry.register_agent('a1')
286
+ expired = registry.expire_silent_agents(timeout: 30)
287
+ expect(expired).to eq([])
288
+ expect(registry.agents['a1'][:status]).to eq(:online)
289
+ end
290
+
291
+ it 'does not expire already-offline agents' do
292
+ registry.register_agent('a1')
293
+ registry.agents['a1'][:last_seen] = Time.now.utc - 60
294
+ registry.agents['a1'][:status] = :offline
295
+ expired = registry.expire_silent_agents(timeout: 30)
296
+ expect(expired).to eq([])
297
+ end
298
+
299
+ it 'returns multiple expired agent ids' do
300
+ registry.register_agent('a1')
301
+ registry.register_agent('a2')
302
+ registry.agents['a1'][:last_seen] = Time.now.utc - 60
303
+ registry.agents['a2'][:last_seen] = Time.now.utc - 60
304
+ expired = registry.expire_silent_agents(timeout: 30)
305
+ expect(expired).to contain_exactly('a1', 'a2')
306
+ end
307
+
308
+ it 'uses MESH_SILENCE_TIMEOUT as default' do
309
+ registry.register_agent('a1')
310
+ registry.agents['a1'][:last_seen] = Time.now.utc - 31
311
+ expired = registry.expire_silent_agents
312
+ expect(expired).to eq(['a1'])
313
+ end
314
+ end
315
+
275
316
  describe '#count' do
276
317
  it 'returns 0 for an empty registry' do
277
318
  expect(registry.count).to eq(0)
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Mesh::Runners::Delegation do
6
+ subject { Object.new.extend(described_class) }
7
+
8
+ before do
9
+ subject.instance_variable_set(:@delegation_tracker, nil)
10
+ end
11
+
12
+ describe '#delegate' do
13
+ it 'creates a delegation' do
14
+ result = subject.delegate(from: 'a', to: 'b', task_context: 'task-1', consent_level: :execute)
15
+ expect(result[:success]).to be true
16
+ expect(result[:delegation_id]).to start_with('del-')
17
+ expect(result[:depth]).to eq(0)
18
+ end
19
+
20
+ it 'returns failure for depth exceeded' do
21
+ d1 = subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
22
+ d2 = subject.delegate(from: 'b', to: 'c', task_context: 't', consent_level: :execute,
23
+ parent_delegation_id: d1[:delegation_id])
24
+ d3 = subject.delegate(from: 'c', to: 'd', task_context: 't', consent_level: :execute,
25
+ parent_delegation_id: d2[:delegation_id])
26
+ d4 = subject.delegate(from: 'd', to: 'e', task_context: 't', consent_level: :execute,
27
+ parent_delegation_id: d3[:delegation_id])
28
+ expect(d4[:success]).to be false
29
+ end
30
+ end
31
+
32
+ describe '#complete_delegation' do
33
+ it 'completes an active delegation' do
34
+ d = subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
35
+ result = subject.complete_delegation(delegation_id: d[:delegation_id])
36
+ expect(result[:success]).to be true
37
+ end
38
+
39
+ it 'fails for non-existent delegation' do
40
+ result = subject.complete_delegation(delegation_id: 'del-fake')
41
+ expect(result[:success]).to be false
42
+ end
43
+ end
44
+
45
+ describe '#revoke_delegation' do
46
+ it 'revokes an active delegation' do
47
+ d = subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
48
+ result = subject.revoke_delegation(delegation_id: d[:delegation_id])
49
+ expect(result[:success]).to be true
50
+ end
51
+ end
52
+
53
+ describe '#delegation_chain' do
54
+ it 'returns the full chain' do
55
+ d1 = subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
56
+ d2 = subject.delegate(from: 'b', to: 'c', task_context: 't', consent_level: :execute,
57
+ parent_delegation_id: d1[:delegation_id])
58
+ result = subject.delegation_chain(delegation_id: d2[:delegation_id])
59
+ expect(result[:success]).to be true
60
+ expect(result[:chain].size).to eq(2)
61
+ expect(result[:depth]).to eq(1)
62
+ end
63
+ end
64
+
65
+ describe '#agent_delegations' do
66
+ it 'lists delegations for an agent' do
67
+ subject.delegate(from: 'a', to: 'b', task_context: 't1', consent_level: :execute)
68
+ subject.delegate(from: 'a', to: 'c', task_context: 't2', consent_level: :execute)
69
+ result = subject.agent_delegations(agent_id: 'a')
70
+ expect(result[:success]).to be true
71
+ expect(result[:count]).to eq(2)
72
+ end
73
+ end
74
+
75
+ describe '#delegation_stats' do
76
+ it 'returns statistics' do
77
+ subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
78
+ result = subject.delegation_stats
79
+ expect(result[:success]).to be true
80
+ expect(result[:total]).to eq(1)
81
+ expect(result[:active]).to eq(1)
82
+ end
83
+ end
84
+ end
@@ -23,6 +23,22 @@ RSpec.describe Legion::Extensions::Mesh::Runners::Mesh do
23
23
  result = client.unregister(agent_id: 'unknown')
24
24
  expect(result[:error]).to eq(:not_found)
25
25
  end
26
+
27
+ it 'publishes mesh departure signal when transport available' do
28
+ client.register(agent_id: 'agent-1', capabilities: [:search])
29
+
30
+ mock_msg = instance_double('MeshDeparture', publish: nil)
31
+ stub_const('Legion::Extensions::Mesh::Transport::Messages::MeshDeparture', class_double('MeshDeparture', new: mock_msg))
32
+
33
+ client.unregister(agent_id: 'agent-1')
34
+ expect(mock_msg).to have_received(:publish)
35
+ end
36
+
37
+ it 'succeeds without transport available' do
38
+ client.register(agent_id: 'agent-1')
39
+ result = client.unregister(agent_id: 'agent-1')
40
+ expect(result[:unregistered]).to be true
41
+ end
26
42
  end
27
43
 
28
44
  describe '#send_message' do
@@ -65,4 +81,13 @@ RSpec.describe Legion::Extensions::Mesh::Runners::Mesh do
65
81
  expect(status[:online]).to eq(1)
66
82
  end
67
83
  end
84
+
85
+ describe '#expire_silent_agents' do
86
+ it 'returns expired agent count' do
87
+ client.register(agent_id: 'a1')
88
+ result = client.expire_silent_agents
89
+ expect(result[:success]).to be true
90
+ expect(result[:count]).to eq(0)
91
+ end
92
+ end
68
93
  end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/mesh/helpers/pending_requests'
5
+ require 'legion/extensions/mesh/helpers/preference_profile'
6
+ require 'legion/extensions/mesh/runners/preferences'
7
+
8
+ RSpec.describe Legion::Extensions::Mesh::Runners::Preferences do
9
+ let(:runner) { Object.new.extend(described_class) }
10
+
11
+ describe '#query_preferences' do
12
+ context 'without transport' do
13
+ it 'returns local_default profile' do
14
+ result = runner.query_preferences(target_agent_id: 'agent-99')
15
+ expect(result[:success]).to be true
16
+ expect(result[:source]).to eq(:local_default)
17
+ expect(result[:profile]).to be_a(Hash)
18
+ end
19
+
20
+ it 'includes default preference keys in profile' do
21
+ result = runner.query_preferences(target_agent_id: 'agent-99')
22
+ expect(result[:profile]).to have_key(:verbosity)
23
+ expect(result[:profile]).to have_key(:tone)
24
+ end
25
+ end
26
+
27
+ context 'with transport available' do
28
+ before do
29
+ stub_const('Legion::Transport::Connection', Class.new do
30
+ def self.session; end
31
+ end)
32
+ stub_const('Legion::Extensions::Mesh::Transport::Messages::PreferenceQuery',
33
+ Class.new do
34
+ def initialize(**_opts); end
35
+
36
+ def publish; end
37
+ end)
38
+ end
39
+
40
+ it 'returns pending status with correlation_id' do
41
+ result = runner.query_preferences(target_agent_id: 'agent-42')
42
+ expect(result[:success]).to be true
43
+ expect(result[:source]).to eq(:pending)
44
+ expect(result[:correlation_id]).to be_a(String)
45
+ expect(result[:correlation_id]).not_to be_empty
46
+ end
47
+
48
+ it 'registers a pending request' do
49
+ result = runner.query_preferences(target_agent_id: 'agent-42')
50
+ pending_req = runner.send(:pending_requests)
51
+ expect(pending_req.pending?(result[:correlation_id])).to be true
52
+ end
53
+ end
54
+ end
55
+
56
+ describe '#handle_preference_query' do
57
+ it 'returns local profile' do
58
+ result = runner.handle_preference_query(requesting_agent_id: 'agent-1')
59
+ expect(result[:success]).to be true
60
+ expect(result[:profile]).to be_a(Hash)
61
+ expect(result[:responding_agent_id]).not_to be_nil
62
+ end
63
+
64
+ it 'includes verbosity in profile' do
65
+ result = runner.handle_preference_query(requesting_agent_id: 'agent-1')
66
+ expect(result[:profile]).to have_key(:verbosity)
67
+ end
68
+ end
69
+
70
+ describe '#handle_preference_response' do
71
+ it 'resolves a pending request' do
72
+ received = nil
73
+ pending_req = runner.send(:pending_requests)
74
+ pending_req.register(
75
+ correlation_id: 'corr-1',
76
+ callback: ->(profile) { received = profile }
77
+ )
78
+ result = runner.handle_preference_response(
79
+ correlation_id: 'corr-1',
80
+ profile: { verbosity: :concise }
81
+ )
82
+ expect(result[:resolved]).to be true
83
+ expect(received).to eq({ verbosity: :concise })
84
+ end
85
+
86
+ it 'returns false for unknown correlation_id' do
87
+ result = runner.handle_preference_response(
88
+ correlation_id: 'unknown-corr',
89
+ profile: {}
90
+ )
91
+ expect(result[:resolved]).to be false
92
+ end
93
+ end
94
+
95
+ describe '#dispatch_preference_message' do
96
+ context 'with preference_query type' do
97
+ it 'calls handle_preference_query and returns profile' do
98
+ result = runner.dispatch_preference_message(
99
+ type: 'preference_query',
100
+ requesting_agent_id: 'agent-1',
101
+ correlation_id: 'corr-123'
102
+ )
103
+ expect(result[:success]).to be true
104
+ expect(result[:profile]).to be_a(Hash)
105
+ end
106
+ end
107
+
108
+ context 'with preference_response type' do
109
+ it 'resolves a pending request' do
110
+ pending_req = runner.send(:pending_requests)
111
+ pending_req.register(correlation_id: 'corr-abc', callback: ->(_p) {})
112
+ result = runner.dispatch_preference_message(
113
+ type: 'preference_response',
114
+ correlation_id: 'corr-abc',
115
+ profile: { verbosity: :concise }
116
+ )
117
+ expect(result[:resolved]).to be true
118
+ end
119
+
120
+ it 'returns false for unknown correlation_id' do
121
+ result = runner.dispatch_preference_message(
122
+ type: 'preference_response',
123
+ correlation_id: 'unknown',
124
+ profile: {}
125
+ )
126
+ expect(result[:resolved]).to be false
127
+ end
128
+ end
129
+
130
+ context 'with unknown type' do
131
+ it 'returns error' do
132
+ result = runner.dispatch_preference_message(type: 'bogus')
133
+ expect(result[:success]).to be false
134
+ expect(result[:error]).to include('unknown')
135
+ end
136
+ end
137
+
138
+ context 'with nil type' do
139
+ it 'returns error' do
140
+ result = runner.dispatch_preference_message(type: nil)
141
+ expect(result[:success]).to be false
142
+ end
143
+ end
144
+ end
145
+
146
+ describe '#expire_pending_requests' do
147
+ it 'cleans up expired entries and returns count' do
148
+ pending_req = runner.send(:pending_requests)
149
+ pending_req.register(correlation_id: 'old-1', callback: nil, ttl: 1)
150
+ pending_req.register(correlation_id: 'old-2', callback: nil, ttl: 1)
151
+ pending_req.instance_variable_get(:@requests).each_value { |e| e[:registered_at] = Time.now - 60 }
152
+
153
+ result = runner.expire_pending_requests
154
+ expect(result[:expired]).to eq(2)
155
+ expect(result[:correlation_ids]).to contain_exactly('old-1', 'old-2')
156
+ end
157
+
158
+ it 'returns zero when nothing expired' do
159
+ result = runner.expire_pending_requests
160
+ expect(result[:expired]).to eq(0)
161
+ expect(result[:correlation_ids]).to be_empty
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ unless defined?(Legion::Transport::Message)
6
+ module Legion
7
+ module Transport
8
+ class Message
9
+ def initialize(**options)
10
+ @options = options
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+ unless defined?(Legion::Transport::Exchanges::Node)
18
+ module Legion
19
+ module Transport
20
+ module Exchanges
21
+ class Node # rubocop:disable Lint/EmptyClass
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ require 'legion/extensions/mesh/transport/messages/mesh_departure'
29
+
30
+ RSpec.describe Legion::Extensions::Mesh::Transport::Messages::MeshDeparture do
31
+ subject(:msg) do
32
+ described_class.new(
33
+ agent_id: 'agent-42',
34
+ capabilities: %i[code_review search]
35
+ )
36
+ end
37
+
38
+ describe '#exchange' do
39
+ it 'returns the Node exchange' do
40
+ expect(msg.exchange).to eq(Legion::Transport::Exchanges::Node)
41
+ end
42
+ end
43
+
44
+ describe '#routing_key' do
45
+ it 'uses mesh.departure' do
46
+ expect(msg.routing_key).to eq('mesh.departure')
47
+ end
48
+ end
49
+
50
+ describe '#message' do
51
+ it 'includes type mesh_departure' do
52
+ expect(msg.message[:type]).to eq('mesh_departure')
53
+ end
54
+
55
+ it 'includes agent_id' do
56
+ expect(msg.message[:agent_id]).to eq('agent-42')
57
+ end
58
+
59
+ it 'includes capabilities' do
60
+ expect(msg.message[:capabilities]).to eq(%i[code_review search])
61
+ end
62
+
63
+ it 'includes departed_at timestamp' do
64
+ expect(msg.message[:departed_at]).to be_a(String)
65
+ end
66
+ end
67
+
68
+ describe '#type' do
69
+ it 'returns mesh_departure' do
70
+ expect(msg.type).to eq('mesh_departure')
71
+ end
72
+ end
73
+
74
+ describe 'empty capabilities default' do
75
+ subject(:msg_no_caps) { described_class.new(agent_id: 'x') }
76
+
77
+ it 'defaults capabilities to empty array' do
78
+ expect(msg_no_caps.message[:capabilities]).to eq([])
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ unless defined?(Legion::Transport::Message)
6
+ module Legion
7
+ module Transport
8
+ class Message
9
+ def initialize(**options)
10
+ @options = options
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+ unless defined?(Legion::Transport::Exchanges::Agent)
18
+ module Legion
19
+ module Transport
20
+ module Exchanges
21
+ class Agent
22
+ def self.name
23
+ 'Legion::Transport::Exchanges::Agent'
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ require 'legion/extensions/mesh/transport/messages/preference_query'
32
+
33
+ RSpec.describe Legion::Extensions::Mesh::Transport::Messages::PreferenceQuery do
34
+ subject(:msg) do
35
+ described_class.new(
36
+ target_agent_id: 'agent-42',
37
+ requesting_agent_id: 'agent-1',
38
+ domains: %i[verbosity tone]
39
+ )
40
+ end
41
+
42
+ describe '#exchange' do
43
+ it 'returns the Agent exchange' do
44
+ expect(msg.exchange).to eq(Legion::Transport::Exchanges::Agent)
45
+ end
46
+ end
47
+
48
+ describe '#routing_key' do
49
+ it 'routes to target agent' do
50
+ expect(msg.routing_key).to eq('agent.agent-42.preferences')
51
+ end
52
+ end
53
+
54
+ describe '#message' do
55
+ it 'includes type preference_query' do
56
+ expect(msg.message[:type]).to eq('preference_query')
57
+ end
58
+
59
+ it 'includes requesting_agent_id' do
60
+ expect(msg.message[:requesting_agent_id]).to eq('agent-1')
61
+ end
62
+
63
+ it 'includes domains' do
64
+ expect(msg.message[:domains]).to eq(%i[verbosity tone])
65
+ end
66
+
67
+ it 'includes requested_at timestamp' do
68
+ expect(msg.message[:requested_at]).to be_a(String)
69
+ end
70
+ end
71
+
72
+ describe '#type' do
73
+ it 'returns preference_query' do
74
+ expect(msg.type).to eq('preference_query')
75
+ end
76
+ end
77
+
78
+ describe 'empty domains default' do
79
+ subject(:msg_no_domains) { described_class.new(target_agent_id: 'x', requesting_agent_id: 'y') }
80
+
81
+ it 'defaults domains to empty array' do
82
+ expect(msg_no_domains.message[:domains]).to eq([])
83
+ end
84
+ end
85
+ end