lex-llm 0.4.18 → 0.5.0

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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +13 -2
  3. data/B1b-conformance-kit.md +79 -0
  4. data/CHANGELOG.md +19 -0
  5. data/lex-llm.gemspec +2 -3
  6. data/lib/legion/extensions/llm/attachment.rb +1 -1
  7. data/lib/legion/extensions/llm/canonical/chunk.rb +184 -0
  8. data/lib/legion/extensions/llm/canonical/content_block.rb +126 -0
  9. data/lib/legion/extensions/llm/canonical/message.rb +125 -0
  10. data/lib/legion/extensions/llm/canonical/params.rb +61 -0
  11. data/lib/legion/extensions/llm/canonical/request.rb +117 -0
  12. data/lib/legion/extensions/llm/canonical/response.rb +124 -0
  13. data/lib/legion/extensions/llm/canonical/thinking.rb +81 -0
  14. data/lib/legion/extensions/llm/canonical/tool_call.rb +134 -0
  15. data/lib/legion/extensions/llm/canonical/tool_definition.rb +73 -0
  16. data/lib/legion/extensions/llm/canonical/usage.rb +61 -0
  17. data/lib/legion/extensions/llm/canonical.rb +49 -0
  18. data/lib/legion/extensions/llm/chat.rb +3 -5
  19. data/lib/legion/extensions/llm/connection.rb +5 -1
  20. data/lib/legion/extensions/llm/error.rb +3 -7
  21. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +1 -3
  22. data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -3
  23. data/lib/legion/extensions/llm/fleet/token_validator.rb +1 -3
  24. data/lib/legion/extensions/llm/model/info.rb +4 -6
  25. data/lib/legion/extensions/llm/models.rb +3 -3
  26. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +7 -3
  27. data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
  28. data/lib/legion/extensions/llm/stream_accumulator.rb +1 -1
  29. data/lib/legion/extensions/llm/streaming.rb +1 -3
  30. data/lib/legion/extensions/llm/tool.rb +1 -3
  31. data/lib/legion/extensions/llm/version.rb +1 -1
  32. data/lib/legion/extensions/llm.rb +118 -35
  33. data/spec/fixtures/ruby.mp3 +0 -0
  34. data/spec/fixtures/ruby.mp4 +0 -0
  35. data/spec/fixtures/ruby.png +0 -0
  36. data/spec/fixtures/ruby.txt +1 -0
  37. data/spec/fixtures/ruby.wav +0 -0
  38. data/spec/fixtures/ruby.xml +1 -0
  39. data/spec/fixtures/sample.pdf +0 -0
  40. data/spec/legion/extensions/llm/agent_spec.rb +179 -0
  41. data/spec/legion/extensions/llm/attachment_spec.rb +25 -0
  42. data/spec/legion/extensions/llm/auto_registration_spec.rb +38 -0
  43. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +285 -0
  44. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +179 -0
  45. data/spec/legion/extensions/llm/canonical/message_spec.rb +203 -0
  46. data/spec/legion/extensions/llm/canonical/params_spec.rb +159 -0
  47. data/spec/legion/extensions/llm/canonical/request_spec.rb +174 -0
  48. data/spec/legion/extensions/llm/canonical/response_spec.rb +234 -0
  49. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +151 -0
  50. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +191 -0
  51. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +174 -0
  52. data/spec/legion/extensions/llm/canonical/usage_spec.rb +138 -0
  53. data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
  54. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +269 -0
  55. data/spec/legion/extensions/llm/conformance/conformance.rb +51 -0
  56. data/spec/legion/extensions/llm/conformance/echo_translator.rb +56 -0
  57. data/spec/legion/extensions/llm/conformance/echo_translator_spec.rb +13 -0
  58. data/spec/legion/extensions/llm/conformance/fixtures/canonical_empty_response.json +13 -0
  59. data/spec/legion/extensions/llm/conformance/fixtures/canonical_error_response.json +19 -0
  60. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +81 -0
  61. data/spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json +101 -0
  62. data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +21 -0
  63. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +13 -0
  64. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json +13 -0
  65. data/spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json +36 -0
  66. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json +20 -0
  67. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json +26 -0
  68. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json +33 -0
  69. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json +42 -0
  70. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json +41 -0
  71. data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +14 -0
  72. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +18 -0
  73. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_response.json +17 -0
  74. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +75 -0
  75. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json +25 -0
  76. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +34 -0
  77. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +390 -0
  78. data/spec/legion/extensions/llm/connection_logging_spec.rb +53 -0
  79. data/spec/legion/extensions/llm/connection_retry_spec.rb +36 -0
  80. data/spec/legion/extensions/llm/context_spec.rb +127 -0
  81. data/spec/legion/extensions/llm/credential_sources_spec.rb +468 -0
  82. data/spec/legion/extensions/llm/error_middleware_spec.rb +102 -0
  83. data/spec/legion/extensions/llm/error_spec.rb +87 -0
  84. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +120 -0
  85. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +163 -0
  86. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +128 -0
  87. data/spec/legion/extensions/llm/fleet_messages_spec.rb +402 -0
  88. data/spec/legion/extensions/llm/gemspec_spec.rb +25 -0
  89. data/spec/legion/extensions/llm/message_spec.rb +64 -0
  90. data/spec/legion/extensions/llm/model/info_spec.rb +222 -0
  91. data/spec/legion/extensions/llm/models_spec.rb +104 -0
  92. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +203 -0
  93. data/spec/legion/extensions/llm/provider_contract_spec.rb +60 -0
  94. data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
  95. data/spec/legion/extensions/llm/provider_spec.rb +592 -0
  96. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +68 -0
  97. data/spec/legion/extensions/llm/registry_publisher_spec.rb +22 -0
  98. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +75 -0
  99. data/spec/legion/extensions/llm/responses/thinking_extractor_spec.rb +75 -0
  100. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +222 -0
  101. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +50 -0
  102. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +120 -0
  103. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +103 -0
  104. data/spec/legion/extensions/llm/streaming_spec.rb +108 -0
  105. data/spec/legion/extensions/llm/tool_spec.rb +94 -0
  106. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +60 -0
  107. data/spec/legion/extensions/llm/utils_spec.rb +113 -0
  108. data/spec/legion/extensions/llm_base_contract_spec.rb +110 -0
  109. data/spec/legion/extensions/llm_extension_spec.rb +78 -0
  110. data/spec/legion/extensions/llm_root_spec.rb +51 -0
  111. data/spec/spec_helper.rb +24 -0
  112. data/spec/support/fake_llm_provider.rb +148 -0
  113. data/spec/support/llm_configuration.rb +21 -0
  114. data/spec/support/rspec_configuration.rb +19 -0
  115. data/spec/support/simplecov_configuration.rb +20 -0
  116. metadata +96 -15
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Canonical::Chunk do
6
+ describe '.text_delta' do
7
+ it 'creates a text delta chunk' do
8
+ chunk = described_class.text_delta(delta: 'hello', request_id: 'req-1')
9
+
10
+ expect(chunk.type).to eq(:text_delta)
11
+ expect(chunk.delta).to eq('hello')
12
+ expect(chunk.request_id).to eq('req-1')
13
+ expect(chunk.index).to eq(0)
14
+ expect(chunk.timestamp).to be_a(Time)
15
+ end
16
+
17
+ it 'accepts block_index and item_id' do
18
+ chunk = described_class.text_delta(
19
+ delta: 'hello',
20
+ request_id: 'req-1',
21
+ block_index: 0,
22
+ item_id: 'item-1'
23
+ )
24
+
25
+ expect(chunk.block_index).to eq(0)
26
+ expect(chunk.item_id).to eq('item-1')
27
+ end
28
+ end
29
+
30
+ describe '.thinking_delta' do
31
+ it 'creates a thinking delta chunk' do
32
+ chunk = described_class.thinking_delta(
33
+ delta: 'reasoning',
34
+ request_id: 'req-1',
35
+ signature: 'sig-partial'
36
+ )
37
+
38
+ expect(chunk.type).to eq(:thinking_delta)
39
+ expect(chunk.delta).to eq('reasoning')
40
+ expect(chunk.signature).to eq('sig-partial')
41
+ end
42
+ end
43
+
44
+ describe '.tool_call_delta' do
45
+ it 'creates a tool call delta chunk' do
46
+ tool_call = Legion::Extensions::Llm::Canonical::ToolCall.build(name: 'search')
47
+ chunk = described_class.tool_call_delta(tool_call: tool_call, request_id: 'req-1')
48
+
49
+ expect(chunk.type).to eq(:tool_call_delta)
50
+ expect(chunk.tool_call).to eq(tool_call)
51
+ end
52
+ end
53
+
54
+ describe '.usage_chunk' do
55
+ it 'creates a usage chunk' do
56
+ usage = Legion::Extensions::Llm::Canonical::Usage.new(
57
+ input_tokens: 100, output_tokens: 50,
58
+ cache_read_tokens: nil, cache_write_tokens: nil,
59
+ thinking_tokens: nil, units: {}
60
+ )
61
+ chunk = described_class.usage_chunk(usage: usage, request_id: 'req-1')
62
+
63
+ expect(chunk.type).to eq(:usage)
64
+ expect(chunk.usage).to eq(usage)
65
+ end
66
+ end
67
+
68
+ describe '.done' do
69
+ it 'creates a done chunk' do
70
+ chunk = described_class.done(request_id: 'req-1', stop_reason: :end_turn)
71
+
72
+ expect(chunk.type).to eq(:done)
73
+ expect(chunk.stop_reason).to eq(:end_turn)
74
+ end
75
+
76
+ it 'includes usage when provided' do
77
+ usage = Legion::Extensions::Llm::Canonical::Usage.new(
78
+ input_tokens: 100, output_tokens: 50,
79
+ cache_read_tokens: nil, cache_write_tokens: nil,
80
+ thinking_tokens: nil, units: {}
81
+ )
82
+ chunk = described_class.done(request_id: 'req-1', usage: usage, stop_reason: :end_turn)
83
+
84
+ expect(chunk.usage).to eq(usage)
85
+ end
86
+ end
87
+
88
+ describe '.error_chunk' do
89
+ it 'creates an error chunk' do
90
+ chunk = described_class.error_chunk(error: 'timeout', request_id: 'req-1')
91
+
92
+ expect(chunk.type).to eq(:error)
93
+ expect(chunk.stop_reason).to eq(:error)
94
+ expect(chunk.metadata[:error]).to eq('timeout')
95
+ end
96
+
97
+ it 'merges additional metadata' do
98
+ chunk = described_class.error_chunk(
99
+ error: 'timeout',
100
+ request_id: 'req-1',
101
+ metadata: { provider: 'anthropic' }
102
+ )
103
+
104
+ expect(chunk.metadata[:error]).to eq('timeout')
105
+ expect(chunk.metadata[:provider]).to eq('anthropic')
106
+ end
107
+ end
108
+
109
+ describe '.from_hash' do
110
+ it 'parses from hash with symbol keys' do
111
+ chunk = described_class.from_hash(
112
+ type: :text_delta,
113
+ delta: 'hello',
114
+ request_id: 'req-1'
115
+ )
116
+
117
+ expect(chunk.type).to eq(:text_delta)
118
+ expect(chunk.delta).to eq('hello')
119
+ expect(chunk.request_id).to eq('req-1')
120
+ end
121
+
122
+ it 'normalizes type string to symbol' do
123
+ chunk = described_class.from_hash(type: 'text_delta', delta: 'hello', request_id: 'req-1')
124
+
125
+ expect(chunk.type).to eq(:text_delta)
126
+ end
127
+
128
+ it 'parses nested tool_call' do
129
+ chunk = described_class.from_hash(
130
+ type: :tool_call_delta,
131
+ request_id: 'req-1',
132
+ tool_call: { name: 'search', arguments: { query: 'test' } }
133
+ )
134
+
135
+ expect(chunk.tool_call).to be_a(Legion::Extensions::Llm::Canonical::ToolCall)
136
+ expect(chunk.tool_call.name).to eq('search')
137
+ end
138
+
139
+ it 'parses nested usage' do
140
+ chunk = described_class.from_hash(
141
+ type: :usage,
142
+ request_id: 'req-1',
143
+ usage: { input_tokens: 100, output_tokens: 50 }
144
+ )
145
+
146
+ expect(chunk.usage).to be_a(Legion::Extensions::Llm::Canonical::Usage)
147
+ end
148
+
149
+ it 'normalizes stop_reason' do
150
+ chunk = described_class.from_hash(
151
+ type: :done,
152
+ request_id: 'req-1',
153
+ stop_reason: 'end_turn'
154
+ )
155
+
156
+ expect(chunk.stop_reason).to eq(:end_turn)
157
+ end
158
+
159
+ it 'accepts finish_reason as alias' do
160
+ chunk = described_class.from_hash(
161
+ type: :done,
162
+ request_id: 'req-1',
163
+ finish_reason: 'tool_use'
164
+ )
165
+
166
+ expect(chunk.stop_reason).to eq(:tool_use)
167
+ end
168
+
169
+ it 'returns nil for nil source' do
170
+ expect(described_class.from_hash(nil)).to be_nil
171
+ end
172
+
173
+ it 'handles string keys' do
174
+ chunk = described_class.from_hash(
175
+ 'type' => 'text_delta',
176
+ 'delta' => 'hello',
177
+ 'request_id' => 'req-1'
178
+ )
179
+
180
+ expect(chunk.type).to eq(:text_delta)
181
+ expect(chunk.delta).to eq('hello')
182
+ end
183
+ end
184
+
185
+ describe '#to_h' do
186
+ it 'serializes to compact hash' do
187
+ chunk = described_class.text_delta(delta: 'hello', request_id: 'req-1')
188
+ hash = chunk.to_h
189
+
190
+ expect(hash).to include(type: :text_delta, delta: 'hello', request_id: 'req-1')
191
+ end
192
+
193
+ it 'serializes nested objects' do
194
+ tool_call = Legion::Extensions::Llm::Canonical::ToolCall.build(name: 'search')
195
+ chunk = described_class.tool_call_delta(tool_call: tool_call, request_id: 'req-1')
196
+ hash = chunk.to_h
197
+
198
+ expect(hash[:tool_call]).to be_a(Hash)
199
+ expect(hash[:tool_call][:name]).to eq('search')
200
+ end
201
+ end
202
+
203
+ describe 'type predicates' do
204
+ it 'identifies text_delta chunks' do
205
+ chunk = described_class.text_delta(delta: 'hello', request_id: 'req-1')
206
+ expect(chunk.text_delta?).to be true
207
+ expect(chunk.content?).to be true
208
+ end
209
+
210
+ it 'identifies thinking_delta chunks' do
211
+ chunk = described_class.thinking_delta(delta: 'reasoning', request_id: 'req-1')
212
+ expect(chunk.thinking_delta?).to be true
213
+ expect(chunk.content?).to be true
214
+ end
215
+
216
+ it 'identifies tool_call_delta chunks' do
217
+ tc = Legion::Extensions::Llm::Canonical::ToolCall.build(name: 'search')
218
+ chunk = described_class.tool_call_delta(tool_call: tc, request_id: 'req-1')
219
+ expect(chunk.tool_call_delta?).to be true
220
+ expect(chunk.content?).to be false
221
+ end
222
+
223
+ it 'identifies usage chunks' do
224
+ usage = Legion::Extensions::Llm::Canonical::Usage.new(
225
+ input_tokens: 100, output_tokens: 50,
226
+ cache_read_tokens: nil, cache_write_tokens: nil,
227
+ thinking_tokens: nil, units: {}
228
+ )
229
+ chunk = described_class.usage_chunk(usage: usage, request_id: 'req-1')
230
+ expect(chunk.usage?).to be true
231
+ end
232
+
233
+ it 'identifies done chunks' do
234
+ chunk = described_class.done(request_id: 'req-1')
235
+ expect(chunk.done?).to be true
236
+ end
237
+
238
+ it 'identifies error chunks' do
239
+ chunk = described_class.error_chunk(error: 'timeout', request_id: 'req-1')
240
+ expect(chunk.error?).to be true
241
+ end
242
+ end
243
+
244
+ describe 'CHUNK_TYPES' do
245
+ it 'includes all expected chunk types' do
246
+ expect(described_class::CHUNK_TYPES).to eq(
247
+ %i[text_delta thinking_delta tool_call_delta usage done error]
248
+ )
249
+ end
250
+ end
251
+
252
+ describe 'round-trip' do
253
+ it 'preserves text_delta through from_hash/to_h' do
254
+ original = {
255
+ type: 'text_delta',
256
+ delta: 'hello',
257
+ request_id: 'req-1',
258
+ block_index: 0,
259
+ item_id: 'item-1'
260
+ }
261
+ chunk = described_class.from_hash(original)
262
+ serialized = chunk.to_h
263
+
264
+ expect(serialized[:type]).to eq(:text_delta)
265
+ expect(serialized[:delta]).to eq('hello')
266
+ expect(serialized[:block_index]).to eq(0)
267
+ expect(serialized[:item_id]).to eq('item-1')
268
+ end
269
+
270
+ it 'preserves done chunk through from_hash/to_h' do
271
+ original = {
272
+ type: 'done',
273
+ request_id: 'req-1',
274
+ stop_reason: 'end_turn',
275
+ usage: { input_tokens: 100, output_tokens: 50 }
276
+ }
277
+ chunk = described_class.from_hash(original)
278
+ serialized = chunk.to_h
279
+
280
+ expect(serialized[:type]).to eq(:done)
281
+ expect(serialized[:stop_reason]).to eq(:end_turn)
282
+ expect(serialized[:usage]).to include(input_tokens: 100, output_tokens: 50)
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Canonical::ContentBlock do
6
+ describe '.text' do
7
+ it 'creates a text content block' do
8
+ block = described_class.text('hello world')
9
+
10
+ expect(block.type).to eq(:text)
11
+ expect(block.text).to eq('hello world')
12
+ end
13
+
14
+ it 'accepts cache_control' do
15
+ block = described_class.text('hello', cache_control: { type: 'ephemeral' })
16
+
17
+ expect(block.cache_control).to eq({ type: 'ephemeral' })
18
+ end
19
+ end
20
+
21
+ describe '.thinking' do
22
+ it 'creates a thinking content block' do
23
+ block = described_class.thinking('reasoning here')
24
+
25
+ expect(block.type).to eq(:thinking)
26
+ expect(block.text).to eq('reasoning here')
27
+ end
28
+ end
29
+
30
+ describe '.tool_use' do
31
+ it 'creates a tool_use content block' do
32
+ block = described_class.tool_use(id: 'toolu-1', name: 'search', input: { query: 'test' })
33
+
34
+ expect(block.type).to eq(:tool_use)
35
+ expect(block.id).to eq('toolu-1')
36
+ expect(block.name).to eq('search')
37
+ expect(block.input).to eq({ query: 'test' })
38
+ end
39
+ end
40
+
41
+ describe '.tool_result' do
42
+ it 'creates a tool_result content block' do
43
+ block = described_class.tool_result(tool_use_id: 'toolu-1', content: 'result')
44
+
45
+ expect(block.type).to eq(:tool_result)
46
+ expect(block.tool_use_id).to eq('toolu-1')
47
+ expect(block.text).to eq('result')
48
+ expect(block.is_error).to be false
49
+ end
50
+
51
+ it 'marks error results' do
52
+ block = described_class.tool_result(tool_use_id: 'toolu-1', content: 'error', is_error: true)
53
+
54
+ expect(block.is_error).to be true
55
+ end
56
+ end
57
+
58
+ describe '.image' do
59
+ it 'creates an image content block with media_type (G20a)' do
60
+ block = described_class.image(data: 'base64data', media_type: 'image/png')
61
+
62
+ expect(block.type).to eq(:image)
63
+ expect(block.data).to eq('base64data')
64
+ expect(block.media_type).to eq('image/png')
65
+ expect(block.source_type).to eq(:base64)
66
+ end
67
+
68
+ it 'accepts custom source_type and detail' do
69
+ block = described_class.image(data: 'img.png', media_type: 'image/png', source_type: :url, detail: 'high')
70
+
71
+ expect(block.source_type).to eq(:url)
72
+ expect(block.detail).to eq('high')
73
+ end
74
+ end
75
+
76
+ describe '.from_hash' do
77
+ it 'parses a content block from hash' do
78
+ block = described_class.from_hash(type: 'text', text: 'hello')
79
+
80
+ expect(block.type).to eq(:text)
81
+ expect(block.text).to eq('hello')
82
+ end
83
+
84
+ it 'handles string keys' do
85
+ block = described_class.from_hash('type' => 'thinking', 'text' => 'reasoning')
86
+
87
+ expect(block.type).to eq(:thinking)
88
+ expect(block.text).to eq('reasoning')
89
+ end
90
+
91
+ it 'returns nil for nil source' do
92
+ expect(described_class.from_hash(nil)).to be_nil
93
+ end
94
+
95
+ it 'preserves all fields' do
96
+ hash = {
97
+ type: 'tool_use',
98
+ id: 'toolu-1',
99
+ name: 'search',
100
+ input: { query: 'test' },
101
+ cache_control: { type: 'ephemeral' }
102
+ }
103
+ block = described_class.from_hash(hash)
104
+
105
+ expect(block.type).to eq(:tool_use)
106
+ expect(block.id).to eq('toolu-1')
107
+ expect(block.name).to eq('search')
108
+ expect(block.input).to eq({ query: 'test' })
109
+ expect(block.cache_control).to eq({ type: 'ephemeral' })
110
+ end
111
+ end
112
+
113
+ describe '#to_h' do
114
+ it 'serializes to compact hash' do
115
+ block = described_class.text('hello')
116
+ hash = block.to_h
117
+
118
+ expect(hash).to eq(type: :text, text: 'hello')
119
+ end
120
+
121
+ it 'omits nil values' do
122
+ block = described_class.new(
123
+ type: :text, text: 'hello', data: nil, source_type: nil,
124
+ media_type: nil, detail: nil, name: nil, file_id: nil,
125
+ id: nil, input: nil, tool_use_id: nil, is_error: nil,
126
+ source: nil, start_index: nil, end_index: nil,
127
+ code: nil, message: nil, cache_control: nil
128
+ )
129
+ hash = block.to_h
130
+
131
+ expect(hash).to eq(type: :text, text: 'hello')
132
+ end
133
+ end
134
+
135
+ describe 'type predicates' do
136
+ it 'identifies text blocks' do
137
+ block = described_class.text('hello')
138
+ expect(block.text?).to be true
139
+ expect(block.thinking?).to be false
140
+ expect(block.tool_use?).to be false
141
+ expect(block.tool_result?).to be false
142
+ end
143
+
144
+ it 'identifies thinking blocks' do
145
+ block = described_class.thinking('reasoning')
146
+ expect(block.thinking?).to be true
147
+ expect(block.text?).to be false
148
+ end
149
+
150
+ it 'identifies tool_use blocks' do
151
+ block = described_class.tool_use(id: '1', name: 'search', input: {})
152
+ expect(block.tool_use?).to be true
153
+ end
154
+
155
+ it 'identifies tool_result blocks' do
156
+ block = described_class.tool_result(tool_use_id: '1', content: 'result')
157
+ expect(block.tool_result?).to be true
158
+ end
159
+ end
160
+
161
+ describe 'CONTENT_BLOCK_TYPES' do
162
+ it 'includes all expected types' do
163
+ expect(described_class::CONTENT_BLOCK_TYPES).to include(
164
+ :text, :thinking, :tool_use, :tool_result, :image, :audio, :video
165
+ )
166
+ end
167
+ end
168
+
169
+ describe 'round-trip' do
170
+ it 'preserves text block through from_hash/to_h' do
171
+ original = { type: 'text', text: 'hello world' }
172
+ block = described_class.from_hash(original)
173
+ serialized = block.to_h
174
+
175
+ expect(serialized[:type]).to eq(:text)
176
+ expect(serialized[:text]).to eq('hello world')
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Canonical::Message do
6
+ describe '.build' do
7
+ it 'creates a message with required fields' do
8
+ msg = described_class.build(role: :user, content: 'hello')
9
+
10
+ expect(msg.role).to eq(:user)
11
+ expect(msg.content).to eq('hello')
12
+ expect(msg.id).to start_with('msg_')
13
+ expect(msg.status).to eq(:created)
14
+ expect(msg.version).to eq(1)
15
+ end
16
+
17
+ it 'accepts all fields' do
18
+ msg = described_class.build(
19
+ id: 'msg-1',
20
+ parent_id: 'msg-0',
21
+ role: :assistant,
22
+ content: 'response',
23
+ tool_calls: [],
24
+ conversation_id: 'conv-1'
25
+ )
26
+
27
+ expect(msg.id).to eq('msg-1')
28
+ expect(msg.parent_id).to eq('msg-0')
29
+ expect(msg.role).to eq(:assistant)
30
+ expect(msg.conversation_id).to eq('conv-1')
31
+ end
32
+
33
+ it 'normalizes string role to symbol' do
34
+ msg = described_class.build(role: 'user', content: 'hello')
35
+
36
+ expect(msg.role).to eq(:user)
37
+ end
38
+
39
+ it 'rejects invalid roles' do
40
+ expect do
41
+ described_class.build(role: :invalid, content: 'hello')
42
+ end.to raise_error(ArgumentError, /Invalid role/)
43
+ end
44
+
45
+ it 'defaults to user role' do
46
+ msg = described_class.build(content: 'hello')
47
+
48
+ expect(msg.role).to eq(:user)
49
+ end
50
+
51
+ it 'sets timestamp automatically' do
52
+ msg = described_class.build(content: 'hello')
53
+
54
+ expect(msg.timestamp).to be_a(Time)
55
+ end
56
+ end
57
+
58
+ describe '.from_hash' do
59
+ it 'parses from hash with symbol keys' do
60
+ msg = described_class.from_hash(role: :user, content: 'hello')
61
+
62
+ expect(msg.role).to eq(:user)
63
+ expect(msg.content).to eq('hello')
64
+ end
65
+
66
+ it 'normalizes string role to symbol' do
67
+ msg = described_class.from_hash(role: 'assistant', content: 'response')
68
+
69
+ expect(msg.role).to eq(:assistant)
70
+ end
71
+
72
+ it 'parses content blocks from array of hashes' do
73
+ msg = described_class.from_hash(
74
+ role: :user,
75
+ content: [{ type: 'text', text: 'hello' }]
76
+ )
77
+
78
+ expect(msg.content).to be_an(Array)
79
+ expect(msg.content.first).to be_a(Legion::Extensions::Llm::Canonical::ContentBlock)
80
+ expect(msg.content.first.type).to eq(:text)
81
+ end
82
+
83
+ it 'parses single content block from hash' do
84
+ msg = described_class.from_hash(
85
+ role: :user,
86
+ content: { type: 'text', text: 'hello' }
87
+ )
88
+
89
+ expect(msg.content).to be_a(Legion::Extensions::Llm::Canonical::ContentBlock)
90
+ end
91
+
92
+ it 'parses tool calls from array of hashes' do
93
+ msg = described_class.from_hash(
94
+ role: :assistant,
95
+ tool_calls: [{ name: 'search', arguments: { query: 'test' } }]
96
+ )
97
+
98
+ expect(msg.tool_calls).to be_an(Array)
99
+ expect(msg.tool_calls.first).to be_a(Legion::Extensions::Llm::Canonical::ToolCall)
100
+ end
101
+
102
+ it 'handles string keys' do
103
+ msg = described_class.from_hash('role' => 'user', 'content' => 'hello')
104
+
105
+ expect(msg.role).to eq(:user)
106
+ expect(msg.content).to eq('hello')
107
+ end
108
+
109
+ it 'returns nil for nil source' do
110
+ expect(described_class.from_hash(nil)).to be_nil
111
+ end
112
+ end
113
+
114
+ describe '.wrap' do
115
+ it 'passes through existing Message instances' do
116
+ msg = described_class.build(role: :user, content: 'hello')
117
+ wrapped = described_class.wrap(msg)
118
+
119
+ expect(wrapped).to eq(msg)
120
+ end
121
+
122
+ it 'parses Hash input' do
123
+ wrapped = described_class.wrap({ role: :user, content: 'hello' })
124
+
125
+ expect(wrapped).to be_a(described_class)
126
+ expect(wrapped.content).to eq('hello')
127
+ end
128
+
129
+ it 'returns nil for unsupported input' do
130
+ expect(described_class.wrap('hello')).to be_nil
131
+ end
132
+ end
133
+
134
+ describe '#text' do
135
+ it 'returns content when it is a String' do
136
+ msg = described_class.build(role: :user, content: 'hello')
137
+
138
+ expect(msg.text).to eq('hello')
139
+ end
140
+
141
+ it 'extracts text from ContentBlock array' do
142
+ blocks = [
143
+ Legion::Extensions::Llm::Canonical::ContentBlock.text('hello'),
144
+ Legion::Extensions::Llm::Canonical::ContentBlock.text(' world')
145
+ ]
146
+ msg = described_class.build(role: :user, content: blocks)
147
+
148
+ expect(msg.text).to eq('hello world')
149
+ end
150
+
151
+ it 'skips non-text blocks' do
152
+ blocks = [
153
+ Legion::Extensions::Llm::Canonical::ContentBlock.text('hello'),
154
+ Legion::Extensions::Llm::Canonical::ContentBlock.thinking('reasoning')
155
+ ]
156
+ msg = described_class.build(role: :user, content: blocks)
157
+
158
+ expect(msg.text).to eq('hello')
159
+ end
160
+
161
+ it 'returns empty string for nil content' do
162
+ msg = described_class.build(role: :user, content: nil)
163
+
164
+ expect(msg.text).to eq('')
165
+ end
166
+ end
167
+
168
+ describe '#to_h' do
169
+ it 'serializes to compact hash' do
170
+ msg = described_class.build(role: :user, content: 'hello')
171
+ hash = msg.to_h
172
+
173
+ expect(hash).to include(id: msg.id, role: :user, content: 'hello')
174
+ end
175
+ end
176
+
177
+ describe '#to_provider_hash' do
178
+ it 'returns minimal provider-facing hash' do
179
+ msg = described_class.build(role: :user, content: 'hello')
180
+ hash = msg.to_provider_hash
181
+
182
+ expect(hash).to eq(role: 'user', content: 'hello')
183
+ end
184
+ end
185
+
186
+ describe 'ROLES' do
187
+ it 'includes all expected roles' do
188
+ expect(described_class::ROLES).to eq(%i[system user assistant tool])
189
+ end
190
+ end
191
+
192
+ describe 'round-trip' do
193
+ it 'preserves values through from_hash/to_h' do
194
+ original = { role: 'user', content: 'hello', conversation_id: 'conv-1' }
195
+ msg = described_class.from_hash(original)
196
+ serialized = msg.to_h
197
+
198
+ expect(serialized[:role]).to eq(:user)
199
+ expect(serialized[:content]).to eq('hello')
200
+ expect(serialized[:conversation_id]).to eq('conv-1')
201
+ end
202
+ end
203
+ end