lex-llm 0.7.3 → 0.8.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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +52 -67
  8. data/lib/legion/extensions/llm/canonical/params.rb +59 -33
  9. data/lib/legion/extensions/llm/canonical/request.rb +67 -54
  10. data/lib/legion/extensions/llm/canonical/response.rb +61 -76
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
  13. data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
  14. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  15. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  16. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  17. data/lib/legion/extensions/llm/canonical.rb +6 -5
  18. data/lib/legion/extensions/llm/configuration.rb +40 -10
  19. data/lib/legion/extensions/llm/connection.rb +8 -30
  20. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  21. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  22. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  23. data/lib/legion/extensions/llm/error.rb +0 -14
  24. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  25. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  26. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  27. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  28. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -153
  29. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  30. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
  31. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  32. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  33. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  34. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  35. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  36. data/lib/legion/extensions/llm/inventory/records.rb +82 -99
  37. data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
  38. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  39. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
  40. data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
  41. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
  42. data/lib/legion/extensions/llm/provider.rb +192 -315
  43. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  44. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  45. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  46. data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
  47. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  48. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  49. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  50. data/lib/legion/extensions/llm/streaming.rb +50 -35
  51. data/lib/legion/extensions/llm/taxonomies.rb +29 -17
  52. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  53. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  54. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  55. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  56. data/lib/legion/extensions/llm/utils.rb +23 -5
  57. data/lib/legion/extensions/llm/version.rb +1 -1
  58. data/lib/legion/extensions/llm.rb +10 -98
  59. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  60. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  61. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  62. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  63. data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
  64. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  65. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  66. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
  67. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  68. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  69. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  70. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  71. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  72. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  73. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  74. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  75. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
  76. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
  77. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  78. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  79. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  80. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  81. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +178 -67
  82. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  83. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
  84. data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
  85. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  86. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  87. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  88. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  89. data/spec/legion/extensions/llm/inventory/records_spec.rb +129 -41
  90. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
  91. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  92. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  93. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +312 -0
  94. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
  95. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
  96. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  97. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  98. data/spec/legion/extensions/llm/provider_spec.rb +98 -78
  99. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  100. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  101. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  102. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -16
  103. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  104. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  105. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  106. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  107. data/spec/support/fake_llm_provider.rb +45 -39
  108. data/spec/support/fake_ssot_harness.rb +7 -2
  109. data/spec/support/ssot_registry_helpers.rb +3 -2
  110. metadata +15 -54
  111. data/lib/legion/extensions/llm/agent.rb +0 -366
  112. data/lib/legion/extensions/llm/aliases.json +0 -436
  113. data/lib/legion/extensions/llm/aliases.rb +0 -67
  114. data/lib/legion/extensions/llm/attachment.rb +0 -229
  115. data/lib/legion/extensions/llm/chat.rb +0 -354
  116. data/lib/legion/extensions/llm/chunk.rb +0 -10
  117. data/lib/legion/extensions/llm/content.rb +0 -81
  118. data/lib/legion/extensions/llm/context.rb +0 -33
  119. data/lib/legion/extensions/llm/embedding.rb +0 -33
  120. data/lib/legion/extensions/llm/image.rb +0 -109
  121. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  122. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
  123. data/lib/legion/extensions/llm/message.rb +0 -118
  124. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  125. data/lib/legion/extensions/llm/model/info.rb +0 -286
  126. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  127. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  128. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  129. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  130. data/lib/legion/extensions/llm/model.rb +0 -11
  131. data/lib/legion/extensions/llm/models.json +0 -57313
  132. data/lib/legion/extensions/llm/models.rb +0 -530
  133. data/lib/legion/extensions/llm/models_schema.json +0 -168
  134. data/lib/legion/extensions/llm/moderation.rb +0 -60
  135. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  136. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  137. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  138. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  139. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  140. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  141. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  142. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  143. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  144. data/lib/legion/extensions/llm/thinking.rb +0 -53
  145. data/lib/legion/extensions/llm/tokens.rb +0 -51
  146. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  147. data/lib/legion/extensions/llm/transcription.rb +0 -39
  148. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  149. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  150. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  151. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  152. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  153. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  154. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
  155. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  156. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  157. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  158. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  159. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  160. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  161. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  162. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  163. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
@@ -1,189 +1,109 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require_relative '../conformance/conformance'
4
5
 
5
6
  RSpec.describe Legion::Extensions::Llm::Canonical::Thinking do
6
- describe '.from_hash' do
7
- it 'returns a Thinking instance with content and signature' do
8
- thinking = described_class.from_hash(content: 'reasoning here', signature: 'sig-abc')
9
-
10
- expect(thinking).to be_a(described_class)
11
- expect(thinking.content).to eq('reasoning here')
12
- expect(thinking.signature).to eq('sig-abc')
13
- end
7
+ let(:type_class) { described_class }
8
+ let(:auto_generated_members) { [] }
9
+ let(:type_source) do
10
+ { content: 'reasoning here', signature: 'sig-abc', metadata: { origin: 'provider' } }
11
+ end
14
12
 
15
- it 'handles string keys' do
16
- thinking = described_class.from_hash('content' => 'reasoning', 'signature' => 'sig-123')
13
+ it_behaves_like 'a canonical type'
17
14
 
15
+ describe 'T5/T4 — construction law' do
16
+ it 'builds from keyword args' do
17
+ thinking = described_class.build(content: 'reasoning', signature: 'sig')
18
18
  expect(thinking.content).to eq('reasoning')
19
- expect(thinking.signature).to eq('sig-123')
20
- end
21
-
22
- it 'returns nil for nil source' do
23
- expect(described_class.from_hash(nil)).to be_nil
24
- end
25
-
26
- it 'returns nil for empty content and signature' do
27
- result = described_class.from_hash(content: '', signature: '')
28
-
29
- expect(result).to be_nil
30
- end
31
-
32
- it 'returns nil when both fields are nil' do
33
- result = described_class.from_hash(content: nil, signature: nil)
34
-
35
- expect(result).to be_nil
19
+ expect(thinking.signature).to eq('sig')
36
20
  end
37
21
 
38
- it 'returns instance with only content' do
39
- thinking = described_class.from_hash(content: 'just reasoning')
40
-
41
- expect(thinking.content).to eq('just reasoning')
22
+ it 'normalizes empty strings to nil (absence, not data — 04 §8)' do
23
+ thinking = described_class.build(content: '', signature: '')
24
+ expect(thinking.content).to be_nil
42
25
  expect(thinking.signature).to be_nil
26
+ expect(thinking.empty?).to be(true)
43
27
  end
44
28
 
45
- it 'returns instance with only signature' do
46
- thinking = described_class.from_hash(signature: 'sig-only')
29
+ it 'raises on a wrong-class member' do
30
+ expect { described_class.build(content: 42) }
31
+ .to raise_error(ArgumentError, /content expected String, got Integer/)
32
+ end
47
33
 
48
- expect(thinking.content).to be_nil
49
- expect(thinking.signature).to eq('sig-only')
34
+ it 'survives build → to_h → JSON → from_hash (T4 signature)' do
35
+ thinking = described_class.build(content: 'x', signature: 'sig-1')
36
+ round_tripped = described_class.from_hash(Legion::JSON.load(Legion::JSON.dump(thinking.to_h)))
37
+ expect(round_tripped.content).to eq('x')
38
+ expect(round_tripped.signature).to eq('sig-1')
50
39
  end
51
40
  end
52
41
 
53
- describe '#to_h' do
54
- it 'serializes to compact hash' do
55
- thinking = described_class.new(content: 'reasoning', signature: 'sig-1')
56
- hash = thinking.to_h
42
+ describe Legion::Extensions::Llm::Canonical::Thinking::Config do
43
+ subject(:config_class) { described_class }
57
44
 
58
- expect(hash).to eq(content: 'reasoning', signature: 'sig-1')
45
+ let(:type_class) { described_class }
46
+ let(:auto_generated_members) { [] }
47
+ let(:type_source) do
48
+ { effort: 'high', budget: 4096, metadata: { source: 'client' } }
59
49
  end
60
50
 
61
- it 'omits nil values' do
62
- thinking = described_class.new(content: 'reasoning', signature: nil)
63
- hash = thinking.to_h
51
+ it_behaves_like 'a canonical type'
64
52
 
65
- expect(hash).to eq(content: 'reasoning')
53
+ it 'converts from a plain class to a Data with one name (04 §8)' do
54
+ expect(config_class).to be_a(Class)
55
+ expect(config_class.new(effort: 'low', budget: nil, metadata: {})).to be_a(config_class)
66
56
  end
67
- end
68
57
 
69
- describe '#empty?' do
70
- it 'returns true when both fields are nil' do
71
- thinking = described_class.new(content: nil, signature: nil)
72
- expect(thinking.empty?).to be true
58
+ it 'normalizes symbol effort to String' do
59
+ config = config_class.build(effort: :low)
60
+ expect(config.effort).to eq('low')
73
61
  end
74
62
 
75
- it 'returns false when content is present' do
76
- thinking = described_class.new(content: 'reasoning', signature: nil)
77
- expect(thinking.empty?).to be false
63
+ it 'raises on a wrong-class effort' do
64
+ expect { config_class.build(effort: 3) }
65
+ .to raise_error(ArgumentError, /effort expected String, got Integer/)
78
66
  end
79
- end
80
-
81
- describe 'round-trip' do
82
- it 'preserves content and signature through from_hash/to_h' do
83
- original = { content: 'deep reasoning', signature: 'sig-xyz' }
84
- thinking = described_class.from_hash(original)
85
- serialized = thinking.to_h
86
-
87
- expect(serialized).to eq(original)
88
- end
89
- end
90
-
91
- describe '::Config' do
92
- let(:config_class) { Legion::Extensions::Llm::Canonical::Thinking::Config }
93
-
94
- describe '.build' do
95
- it 'creates a config with effort and budget' do
96
- config = config_class.build(effort: 'high', budget: 10_000)
97
67
 
98
- expect(config.effort).to eq('high')
99
- expect(config.budget).to eq(10_000)
100
- expect(config.enabled?).to be true
68
+ describe 'H1/M4 — effort is a closed enum, validated in every constructor' do
69
+ it 'rejects an unrecognized effort in build, from_hash, and .new' do
70
+ expect { config_class.build(effort: 'banana') }
71
+ .to raise_error(ArgumentError, /Invalid effort: "banana"/)
72
+ expect { config_class.from_hash(effort: 'banana') }
73
+ .to raise_error(ArgumentError, /Invalid effort: "banana"/)
74
+ expect { config_class.new(effort: 'banana') }
75
+ .to raise_error(ArgumentError, /Invalid effort: "banana"/)
101
76
  end
102
77
 
103
- it 'converts symbol effort to string' do
104
- config = config_class.build(effort: :high)
105
-
106
- expect(config.effort).to eq('high')
78
+ it 'accepts the enum case-insensitively (symbol or string)' do
79
+ expect(config_class.build(effort: :HIGH).effort).to eq('high')
80
+ expect(config_class.new(effort: 'Medium').effort).to eq('medium')
107
81
  end
108
82
 
109
- it 'creates disabled config when no values' do
110
- config = config_class.build
111
-
112
- expect(config.enabled?).to be false
83
+ it 'resolved_budget derives from the validated effort (no silent medium fallback)' do
84
+ expect(config_class.new(effort: 'low').resolved_budget).to eq(1024)
85
+ expect(config_class.build(budget: 123).resolved_budget).to eq(123)
86
+ expect(config_class.build.resolved_budget).to be_nil
113
87
  end
114
88
  end
115
89
 
116
- describe '.from_hash' do
117
- it 'parses config from hash' do
118
- config = config_class.from_hash(effort: 'medium', budget: 5000)
119
-
120
- expect(config.effort).to eq('medium')
121
- expect(config.budget).to eq(5000)
122
- end
123
-
124
- it 'handles string keys' do
125
- config = config_class.from_hash('effort' => 'low')
126
-
127
- expect(config.effort).to eq('low')
128
- end
129
-
130
- it 'returns nil for nil source' do
131
- expect(config_class.from_hash(nil)).to be_nil
132
- end
133
-
134
- it 'returns nil for empty hash' do
135
- expect(config_class.from_hash({})).to be_nil
136
- end
90
+ it 'keeps the effort<->budget SSOT conversions' do
91
+ expect(config_class.build(effort: 'low').resolved_budget).to eq(1024)
92
+ expect(config_class.build(effort: 'medium').resolved_budget).to eq(8192)
93
+ expect(config_class.build(effort: 'high').resolved_budget).to eq(16_384)
94
+ expect(config_class.build(budget: 512).resolved_effort).to eq('low')
95
+ expect(config_class.build(budget: 8192).resolved_effort).to eq('medium')
96
+ expect(config_class.build(budget: 16_384).resolved_effort).to eq('high')
137
97
  end
138
98
 
139
- describe '#to_h' do
140
- it 'serializes to compact hash' do
141
- config = config_class.build(effort: 'high', budget: 10_000)
142
- expect(config.to_h).to eq(effort: 'high', budget: 10_000)
143
- end
144
-
145
- it 'omits nil values' do
146
- config = config_class.build(effort: 'low')
147
- expect(config.to_h).to eq(effort: 'low')
148
- end
99
+ it 'is enabled only when an axis is set' do
100
+ expect(config_class.build(effort: 'low').enabled?).to be(true)
101
+ expect(config_class.build(budget: 1).enabled?).to be(true)
102
+ expect(config_class.build.enabled?).to be(false)
149
103
  end
150
104
 
151
- # SSOT / one-oracle for cross-provider thinking (NxN best-effort): a client
152
- # dialect supplies only ONE axis (Anthropic = budget_tokens only, OpenAI =
153
- # effort only). Every provider translator must still get a usable value for
154
- # whichever axis IT needs, so thinking survives ANY client×provider pair
155
- # instead of being silently dropped. The conversion lives HERE (the canonical
156
- # config), not scattered/one-directional in each translator. Derived accessors
157
- # do NOT mutate stored state or #to_h — they only fill the gap on read.
158
- describe 'derived cross-axis accessors' do
159
- it 'derives budget from effort when only effort was set' do
160
- expect(config_class.build(effort: 'low').resolved_budget).to eq(1024)
161
- expect(config_class.build(effort: 'medium').resolved_budget).to eq(8192)
162
- expect(config_class.build(effort: 'high').resolved_budget).to eq(16_384)
163
- end
164
-
165
- it 'derives effort from budget by band (< medium=low, < high=medium, else high)' do
166
- expect(config_class.build(budget: 500).resolved_effort).to eq('low') # < 8192
167
- expect(config_class.build(budget: 10_000).resolved_effort).to eq('medium') # 8192..16383
168
- expect(config_class.build(budget: 20_000).resolved_effort).to eq('high') # >= 16384
169
- end
170
-
171
- it 'prefers the explicitly-set value over derivation' do
172
- c = config_class.build(effort: 'high', budget: 2048)
173
- expect(c.resolved_budget).to eq(2048)
174
- expect(c.resolved_effort).to eq('high')
175
- end
176
-
177
- it 'returns nil derivations when neither axis is set' do
178
- c = config_class.build
179
- expect(c.resolved_budget).to be_nil
180
- expect(c.resolved_effort).to be_nil
181
- end
182
-
183
- it 'does not fabricate the missing axis in to_h (stored state stays faithful)' do
184
- expect(config_class.build(effort: 'high').to_h).to eq(effort: 'high')
185
- expect(config_class.build(budget: 2048).to_h).to eq(budget: 2048)
186
- end
105
+ it 'to_h is faithful to what was set (no fabricated axis)' do
106
+ expect(config_class.build(effort: 'low').to_h).to eq(effort: 'low', metadata: {})
187
107
  end
188
108
  end
189
109
  end
@@ -1,191 +1,88 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require_relative '../conformance/conformance'
4
5
 
5
6
  RSpec.describe Legion::Extensions::Llm::Canonical::ToolCall do
6
- describe '.build' do
7
- it 'creates a tool call with required fields' do
8
- tc = described_class.build(name: 'search', arguments: { query: 'test' })
9
-
10
- expect(tc.name).to eq('search')
11
- expect(tc.arguments).to eq({ query: 'test' })
12
- expect(tc.id).to start_with('call_')
13
- expect(tc.arguments).to be_a(Hash)
14
- end
15
-
16
- it 'generates a random id' do
17
- tc1 = described_class.build(name: 'search')
18
- tc2 = described_class.build(name: 'search')
19
-
20
- expect(tc1.id).not_to eq(tc2.id)
21
- end
22
-
23
- it 'accepts all fields' do
24
- tc = described_class.build(
25
- id: 'call-1',
26
- exchange_id: 'ex-1',
27
- name: 'search',
28
- arguments: { query: 'test' },
29
- source: :registry,
30
- status: :pending,
31
- data_handling_classification: :public,
32
- policy_decision: :allowed
33
- )
34
-
35
- expect(tc.id).to eq('call-1')
36
- expect(tc.exchange_id).to eq('ex-1')
37
- expect(tc.source).to eq(:registry)
38
- expect(tc.status).to eq(:pending)
39
- expect(tc.data_handling_classification).to eq(:public)
40
- expect(tc.policy_decision).to eq(:allowed)
41
- end
42
-
43
- it 'defaults arguments to empty hash' do
44
- tc = described_class.build(name: 'search')
45
-
46
- expect(tc.arguments).to eq({})
47
- end
7
+ let(:type_class) { described_class }
8
+ let(:auto_generated_members) { [:id] }
9
+ let(:type_source) do
10
+ {
11
+ id: 'call_1',
12
+ name: 'get_weather',
13
+ arguments: { location: 'San Francisco' },
14
+ source: 'client',
15
+ status: 'pending',
16
+ category: 'weather',
17
+ metadata: { origin: 'model' }
18
+ }
48
19
  end
49
20
 
50
- describe '.from_hash' do
51
- it 'parses from hash with symbol keys' do
52
- tc = described_class.from_hash(
53
- id: 'call-1',
54
- name: 'search',
55
- arguments: { query: 'test' },
56
- source: :registry
57
- )
21
+ it_behaves_like 'a canonical type'
58
22
 
59
- expect(tc.id).to eq('call-1')
60
- expect(tc.name).to eq('search')
61
- expect(tc.arguments).to eq({ query: 'test' })
62
- expect(tc.source).to eq(:registry)
23
+ describe 'H1 — .new is as strict as the factories' do
24
+ it 'rejects a poison source enum' do
25
+ expect { described_class.new(name: 'x', source: :bogus) }
26
+ .to raise_error(ArgumentError, /Invalid source: :bogus/)
63
27
  end
64
28
 
65
- it 'normalizes string source to symbol' do
66
- tc = described_class.from_hash(name: 'search', source: 'registry')
67
-
68
- expect(tc.source).to eq(:registry)
69
- end
70
-
71
- it 'normalizes string status to symbol' do
72
- tc = described_class.from_hash(name: 'search', status: 'success')
73
-
74
- expect(tc.status).to eq(:success)
75
- end
76
-
77
- it 'parses JSON string arguments to symbol keys per Legion::JSON convention' do
78
- tc = described_class.from_hash(
79
- name: 'search',
80
- arguments: '{"query":"test","limit":10}'
81
- )
82
-
83
- expect(tc.arguments).to eq({ query: 'test', limit: 10 })
84
- end
85
-
86
- it 'handles string keys' do
87
- tc = described_class.from_hash('name' => 'search', 'arguments' => '{}')
88
-
89
- expect(tc.name).to eq('search')
90
- expect(tc.arguments).to eq({})
91
- end
92
-
93
- it 'returns nil for nil source' do
94
- expect(described_class.from_hash(nil)).to be_nil
95
- end
96
- end
97
-
98
- describe '#with_result' do
99
- it 'returns a new tool call with result' do
100
- tc = described_class.build(name: 'search', source: :registry)
101
- result_tc = tc.with_result(result: { hits: 5 }, status: :success, duration_ms: 100)
102
-
103
- expect(result_tc.result).to eq({ hits: 5 })
104
- expect(result_tc.status).to eq(:success)
105
- expect(result_tc.duration_ms).to eq(100)
106
- expect(result_tc.finished_at).to be_a(Time)
107
- end
108
-
109
- it 'sets error on error status' do
110
- tc = described_class.build(name: 'search')
111
- result_tc = tc.with_result(result: 'not found', status: :error)
112
-
113
- expect(result_tc.error).to eq('not found')
114
- expect(result_tc.status).to eq(:error)
29
+ it 'rejects JSON-string arguments (Hash only, O03a)' do
30
+ expect { described_class.new(name: 'x', arguments: '{"a":1}') }
31
+ .to raise_error(ArgumentError, /arguments expected Hash, got String/)
115
32
  end
116
33
  end
117
34
 
118
- describe 'predicates' do
119
- it 'identifies successful calls' do
120
- tc = described_class.build(name: 'search', status: :success)
121
- expect(tc.success?).to be true
122
- expect(tc.error?).to be false
35
+ describe 'T5 — enum law (declared enums enforced in both factories)' do
36
+ it 'validates source against SOURCE_VALUES' do
37
+ expect { described_class.build(name: 'x', source: 'client') }.not_to raise_error
38
+ expect(described_class.build(name: 'x', source: 'client').source).to eq(:client)
39
+ expect { described_class.build(name: 'x', source: :bogus) }
40
+ .to raise_error(ArgumentError, /Invalid source: :bogus/)
41
+ expect { described_class.from_hash(name: 'x', source: 'nope') }
42
+ .to raise_error(ArgumentError, /Invalid source: :nope/)
123
43
  end
124
44
 
125
- it 'identifies error calls' do
126
- tc = described_class.build(name: 'search', status: :error)
127
- expect(tc.error?).to be true
128
- expect(tc.success?).to be false
45
+ it 'validates status against STATUS_VALUES' do
46
+ expect(described_class.build(name: 'x', status: 'running').status).to eq(:running)
47
+ expect { described_class.build(name: 'x', status: :finished) }
48
+ .to raise_error(ArgumentError, /Invalid status: :finished/)
49
+ expect { described_class.from_hash(name: 'x', status: 'done') }
50
+ .to raise_error(ArgumentError, /Invalid status: :done/)
129
51
  end
130
52
  end
131
53
 
132
- describe '#to_h' do
133
- it 'serializes to compact hash' do
134
- tc = described_class.build(name: 'search', arguments: { query: 'test' })
135
- hash = tc.to_h
136
-
137
- expect(hash).to include(id: tc.id, name: 'search', arguments: { query: 'test' })
54
+ describe 'O03a — arguments Hash only' do
55
+ it 'does not parse JSON-string arguments (the parse + rescue are deleted)' do
56
+ expect { described_class.build(name: 'x', arguments: '{"a":1}') }
57
+ .to raise_error(ArgumentError, /arguments expected Hash, got String/)
58
+ expect { described_class.from_hash(name: 'x', arguments: '{\"a\":1}') }
59
+ .to raise_error(ArgumentError, /arguments expected Hash, got String/)
138
60
  end
139
- end
140
-
141
- describe '#to_audit_hash' do
142
- it 'includes compliance fields' do
143
- tc = described_class.build(
144
- name: 'search',
145
- source: :registry,
146
- data_handling_classification: :public,
147
- policy_decision: :allowed
148
- )
149
- hash = tc.to_audit_hash
150
61
 
151
- expect(hash).to include(
152
- name: 'search',
153
- source: :registry,
154
- data_handling_classification: :public,
155
- policy_decision: :allowed
156
- )
62
+ it 'defaults nil arguments to {} (documented no-arguments default)' do
63
+ expect(described_class.build(name: 'x').arguments).to eq({})
64
+ expect(described_class.from_hash(name: 'x').arguments).to eq({})
157
65
  end
158
66
  end
159
67
 
160
- describe 'SOURCE_VALUES' do
161
- it 'includes all expected source types' do
162
- expect(described_class::SOURCE_VALUES).to eq(%i[client registry special extension mcp])
163
- end
164
- end
68
+ describe 'with_result (canonical mutation-free update)' do
69
+ it 'attaches the result and normalizes error' do
70
+ tc = described_class.build(name: 'x', id: 'c1', status: 'running')
71
+ done = tc.with_result(result: 'ok', status: :success, duration_ms: 12)
72
+ expect(done.result).to eq('ok')
73
+ expect(done.status).to eq(:success)
74
+ expect(done.error).to be_nil
75
+ expect(done.success?).to be(true)
165
76
 
166
- describe 'STATUS_VALUES' do
167
- it 'includes all expected status types' do
168
- expect(described_class::STATUS_VALUES).to eq(%i[pending running success error])
77
+ failed = tc.with_result(result: 'boom', status: :error)
78
+ expect(failed.error).to eq('boom')
79
+ expect(failed.error?).to be(true)
169
80
  end
170
- end
171
-
172
- describe 'round-trip' do
173
- it 'preserves values through from_hash/to_h' do
174
- original = {
175
- id: 'call-1',
176
- name: 'search',
177
- arguments: { query: 'test' },
178
- source: 'registry',
179
- status: 'pending'
180
- }
181
- tc = described_class.from_hash(original)
182
- serialized = tc.to_h
183
81
 
184
- expect(serialized[:id]).to eq('call-1')
185
- expect(serialized[:name]).to eq('search')
186
- expect(serialized[:arguments]).to eq({ query: 'test' })
187
- expect(serialized[:source]).to eq(:registry)
188
- expect(serialized[:status]).to eq(:pending)
82
+ it 'validates the result status enum' do
83
+ tc = described_class.build(name: 'x', id: 'c1', status: 'running')
84
+ expect { tc.with_result(result: 'r', status: :bogus) }
85
+ .to raise_error(ArgumentError, /Invalid status: :bogus/)
189
86
  end
190
87
  end
191
88
  end