ollama_chat 0.0.113 → 0.0.115
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.
- checksums.yaml +4 -4
- data/CHANGES.md +120 -0
- data/README.md +7 -0
- data/bin/ollama_chat_log +1 -1
- data/bin/ollama_chat_send +3 -1
- data/lib/ollama_chat/chat.rb +30 -14
- data/lib/ollama_chat/commands.rb +36 -18
- data/lib/ollama_chat/compaction.rb +289 -0
- data/lib/ollama_chat/config_handling.rb +32 -0
- data/lib/ollama_chat/database/migrations/010_add_enabled_to_collections.rb +7 -0
- data/lib/ollama_chat/database/models/app_state.rb +48 -16
- data/lib/ollama_chat/database/models/session.rb +1 -2
- data/lib/ollama_chat/database.rb +4 -2
- data/lib/ollama_chat/information.rb +53 -6
- data/lib/ollama_chat/logging.rb +3 -2
- data/lib/ollama_chat/message.rb +22 -1
- data/lib/ollama_chat/message_format.rb +1 -2
- data/lib/ollama_chat/message_list.rb +223 -9
- data/lib/ollama_chat/model_handling.rb +1 -1
- data/lib/ollama_chat/oc.rb +17 -7
- data/lib/ollama_chat/ollama_chat_config/default_config.yml +72 -3
- data/lib/ollama_chat/parsing.rb +2 -1
- data/lib/ollama_chat/prompt_handling.rb +1 -1
- data/lib/ollama_chat/prompt_management.rb +1 -0
- data/lib/ollama_chat/rag_handling.rb +19 -5
- data/lib/ollama_chat/session_management.rb +5 -3
- data/lib/ollama_chat/token_estimator/crude.rb +1 -1
- data/lib/ollama_chat/token_estimator.rb +1 -1
- data/lib/ollama_chat/tools/concern.rb +17 -0
- data/lib/ollama_chat/tools/directory_structure.rb +11 -7
- data/lib/ollama_chat/tools/eval_ruby.rb +16 -6
- data/lib/ollama_chat/tools/execute_shell.rb +158 -0
- data/lib/ollama_chat/tools/get_rfc.rb +2 -0
- data/lib/ollama_chat/tools/lookup_group.rb +100 -0
- data/lib/ollama_chat/tools/paste_from_clipboard.rb +1 -1
- data/lib/ollama_chat/tools/patch_file.rb +23 -5
- data/lib/ollama_chat/tools/read_file.rb +1 -1
- data/lib/ollama_chat/tools/resolve_tag.rb +1 -1
- data/lib/ollama_chat/tools/search_knowledge.rb +7 -1
- data/lib/ollama_chat/tools/write_file.rb +25 -6
- data/lib/ollama_chat/tools.rb +2 -0
- data/lib/ollama_chat/utils/analyze_directory.rb +5 -4
- data/lib/ollama_chat/utils/fetcher.rb +1 -1
- data/lib/ollama_chat/utils/log_rotation.rb +29 -0
- data/lib/ollama_chat/utils/strip_ansi.rb +22 -0
- data/lib/ollama_chat/utils/tag_resolver.rb +3 -3
- data/lib/ollama_chat/utils.rb +2 -0
- data/lib/ollama_chat/version.rb +1 -1
- data/lib/ollama_chat.rb +7 -0
- data/ollama_chat.gemspec +5 -5
- data/spec/ollama_chat/commands_spec.rb +25 -0
- data/spec/ollama_chat/compaction_spec.rb +381 -0
- data/spec/ollama_chat/config_handling_spec.rb +60 -0
- data/spec/ollama_chat/message_list_spec.rb +229 -0
- data/spec/ollama_chat/message_spec.rb +143 -0
- data/spec/ollama_chat/rag_handling_spec.rb +3 -0
- data/spec/ollama_chat/system_prompt_management_spec.rb +0 -2
- data/spec/ollama_chat/tools/browse_spec.rb +7 -0
- data/spec/ollama_chat/tools/compute_bmi_spec.rb +15 -0
- data/spec/ollama_chat/tools/concern_spec.rb +77 -0
- data/spec/ollama_chat/tools/copy_to_clipboard_spec.rb +8 -0
- data/spec/ollama_chat/tools/delete_file_spec.rb +8 -0
- data/spec/ollama_chat/tools/directory_structure_spec.rb +4 -0
- data/spec/ollama_chat/tools/execute_grep_spec.rb +13 -0
- data/spec/ollama_chat/tools/execute_ri_spec.rb +8 -0
- data/spec/ollama_chat/tools/execute_shell_spec.rb +232 -0
- data/spec/ollama_chat/tools/file_context_spec.rb +5 -0
- data/spec/ollama_chat/tools/gem_path_lookup_spec.rb +2 -0
- data/spec/ollama_chat/tools/generate_image_spec.rb +12 -0
- data/spec/ollama_chat/tools/generate_password_spec.rb +5 -0
- data/spec/ollama_chat/tools/get_cve_spec.rb +4 -0
- data/spec/ollama_chat/tools/get_endoflife_spec.rb +4 -0
- data/spec/ollama_chat/tools/get_ghr_spec.rb +6 -0
- data/spec/ollama_chat/tools/get_location_spec.rb +4 -0
- data/spec/ollama_chat/tools/get_rfc_spec.rb +2 -0
- data/spec/ollama_chat/tools/get_time_spec.rb +2 -0
- data/spec/ollama_chat/tools/get_url_spec.rb +7 -0
- data/spec/ollama_chat/tools/lookup_group_spec.rb +111 -0
- data/spec/ollama_chat/tools/move_file_spec.rb +10 -0
- data/spec/ollama_chat/tools/open_file_in_editor_spec.rb +6 -0
- data/spec/ollama_chat/tools/paste_from_clipboard_spec.rb +5 -0
- data/spec/ollama_chat/tools/paste_into_editor_spec.rb +6 -0
- data/spec/ollama_chat/tools/patch_file_spec.rb +62 -0
- data/spec/ollama_chat/tools/read_file_spec.rb +10 -3
- data/spec/ollama_chat/tools/resolve_tag_spec.rb +46 -0
- data/spec/ollama_chat/tools/roll_dice_spec.rb +11 -0
- data/spec/ollama_chat/tools/run_tests_spec.rb +8 -0
- data/spec/ollama_chat/tools/search_knowledge_spec.rb +2 -0
- data/spec/ollama_chat/tools/write_file_spec.rb +98 -2
- data/spec/ollama_chat/utils/analyze_directory_spec.rb +46 -7
- data/spec/ollama_chat/utils/log_rotation_spec.rb +71 -0
- data/spec/spec_helper.rb +2 -1
- metadata +25 -1
|
@@ -123,6 +123,235 @@ describe OllamaChat::MessageList do
|
|
|
123
123
|
group = list.each_group.to_a.first
|
|
124
124
|
expect(group.map(&:content)).to eq ['first', 'second', 'third']
|
|
125
125
|
end
|
|
126
|
+
|
|
127
|
+
it 'filters messages correctly' do
|
|
128
|
+
list = described_class.new(chat)
|
|
129
|
+
uuid = 'filter-test'
|
|
130
|
+
list << OllamaChat::Message.new(role: 'user', content: 'first', group_uuid: uuid)
|
|
131
|
+
list << OllamaChat::Message.new(role: 'assistant', content: 'second', group_uuid: uuid)
|
|
132
|
+
list << OllamaChat::Message.new(role: 'user', content: 'third', group_uuid: uuid)
|
|
133
|
+
|
|
134
|
+
group = list.each_group(role: 'user').to_a.first
|
|
135
|
+
expect(group.map(&:content)).to eq ['first', 'third']
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
describe '#find_cut_point' do
|
|
140
|
+
before do
|
|
141
|
+
allow(chat).to receive(:think_strip).and_return double(on?: false)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def add_group(list, uuid, role, content)
|
|
145
|
+
list << OllamaChat::Message.new(
|
|
146
|
+
role:, content:, group_uuid: uuid
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'returns nil when there are no non-system messages' do
|
|
151
|
+
expect(list.find_cut_point(start: 0, keep_recent_tokens: 100)).to be_nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it 'returns the first non-system index when everything fits' do
|
|
155
|
+
add_group(list, 'g1', 'user', 'hello') # idx 1, 2 tok
|
|
156
|
+
add_group(list, 'g1', 'assistant', 'world') # idx 2, 2 tok
|
|
157
|
+
add_group(list, 'g2', 'user', 'hi') # idx 3, 1 tok
|
|
158
|
+
|
|
159
|
+
expect(list.find_cut_point(start: 0, keep_recent_tokens: 10)).to eq 1
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'cuts at the group whose addition reaches the budget' do
|
|
163
|
+
add_group(list, 'g1', 'user', 'hello') # idx 1, 2 tok
|
|
164
|
+
add_group(list, 'g1', 'assistant', 'world') # idx 2, 2 tok
|
|
165
|
+
add_group(list, 'g2', 'user', 'hi') # idx 3, 1 tok
|
|
166
|
+
|
|
167
|
+
# budget 4: walking back → 1 < 4, 3 < 4, 5 >= 4 → cut at idx 1
|
|
168
|
+
expect(list.find_cut_point(start: 0, keep_recent_tokens: 4)).to eq 1
|
|
169
|
+
|
|
170
|
+
# budget 1: walking back → 1 >= 1 → cut at idx 3
|
|
171
|
+
expect(list.find_cut_point(start: 0, keep_recent_tokens: 1)).to eq 3
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it 'treats multi-message groups as a single atomic unit' do
|
|
175
|
+
# Group A: 2 messages = 4 tokens
|
|
176
|
+
add_group(list, 'a', 'user', 'hello')
|
|
177
|
+
add_group(list, 'a', 'assistant', 'world')
|
|
178
|
+
# Group B: 1 message = 2 tokens
|
|
179
|
+
add_group(list, 'b', 'user', 'hi hi')
|
|
180
|
+
|
|
181
|
+
# budget 6: 4 + 2 = 6 >= 6 → cut at group A (idx 1)
|
|
182
|
+
expect(list.find_cut_point(start: 0, keep_recent_tokens: 6)).to eq 1
|
|
183
|
+
|
|
184
|
+
# budget 2: 2 >= 2 → cut at group B (idx 3)
|
|
185
|
+
expect(list.find_cut_point(start: 0, keep_recent_tokens: 2)).to eq 3
|
|
186
|
+
|
|
187
|
+
# budget 5: 2 < 5, 6 >= 5 → cut at group A (idx 1)
|
|
188
|
+
expect(list.find_cut_point(start: 0, keep_recent_tokens: 5)).to eq 1
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'skips system messages when building groups' do
|
|
192
|
+
add_group(list, 's2', 'system', 'extra system')
|
|
193
|
+
add_group(list, 'g1', 'user', 'hello')
|
|
194
|
+
|
|
195
|
+
expect(list.find_cut_point(start: 0, keep_recent_tokens: 10)).to eq 2
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it 'ignores messages before start' do
|
|
199
|
+
add_group(list, 'old', 'user', 'aaaaa') # idx 1, 2 tok
|
|
200
|
+
add_group(list, 'old', 'assistant', 'bbbbb') # idx 2, 2 tok
|
|
201
|
+
add_group(list, 'n1', 'user', 'hi') # idx 3, 1 tok
|
|
202
|
+
add_group(list, 'n2', 'user', 'yo') # idx 4, 1 tok
|
|
203
|
+
|
|
204
|
+
# start: 0 includes old group (4 tok):
|
|
205
|
+
# budget 3: walk back → n2:1<3, n1:2<3, old:6>=3 → cut at 1
|
|
206
|
+
expect(list.find_cut_point(start: 0, keep_recent_tokens: 3)).to eq 1
|
|
207
|
+
|
|
208
|
+
# start: 3 excludes old group: only n1(1)+n2(1)=2 in window
|
|
209
|
+
# budget 3: walk back → n2:1<3, n1:2<3 → no-op at 3
|
|
210
|
+
expect(list.find_cut_point(start: 3, keep_recent_tokens: 3)).to eq 3
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
describe '#find_summary' do
|
|
215
|
+
it 'returns nil when no summary exists' do
|
|
216
|
+
expect(list.find_summary).to be_nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it 'returns the summary message when present' do
|
|
220
|
+
summary = OllamaChat::Message.new(
|
|
221
|
+
role: 'tool', tool_name: 'summary',
|
|
222
|
+
content: '<summary>test</summary>', group_uuid: 's1'
|
|
223
|
+
)
|
|
224
|
+
list << OllamaChat::Message.new(
|
|
225
|
+
role: 'user', content: 'hello', group_uuid: 'g1'
|
|
226
|
+
)
|
|
227
|
+
list << summary
|
|
228
|
+
|
|
229
|
+
expect(list.find_summary).to be summary
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
describe '#compact!' do
|
|
234
|
+
before do
|
|
235
|
+
allow(chat).to receive(:think_strip).and_return double(on?: false)
|
|
236
|
+
allow(chat).to receive(:current_context_length).and_return 100
|
|
237
|
+
allow(chat).to receive(:compact_ratio_tokens)
|
|
238
|
+
.with(:keep_recent, 100).and_return 5
|
|
239
|
+
allow(chat).to receive(:context_usage)
|
|
240
|
+
.and_return '10.0 T of 100 T (10.0%)'
|
|
241
|
+
allow(chat).to receive(:conversation_length)
|
|
242
|
+
.and_return '1.0 KB / 0.3 KT'
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
it 'returns nil when there are no non-system groups' do
|
|
246
|
+
expect(list.compact!).to be_nil
|
|
247
|
+
expect(list.size).to eq 1
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
it 'returns self unchanged when only one group exists' do
|
|
251
|
+
list << OllamaChat::Message.new(
|
|
252
|
+
role: 'user', content: 'hello', group_uuid: 'g1'
|
|
253
|
+
)
|
|
254
|
+
expect(list.compact!).to be_nil
|
|
255
|
+
expect(list.size).to eq 2
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it 'inserts a summary message and preserves old messages' do
|
|
259
|
+
# g1: 1+1=2 tok, g2: 2+2=4 tok, g3: 1 tok → total 7
|
|
260
|
+
# budget 5: walk back → 1<5, 5>=5 → cut at g2.start=3
|
|
261
|
+
list << OllamaChat::Message.new(
|
|
262
|
+
role: 'user', content: 'a', group_uuid: 'g1'
|
|
263
|
+
)
|
|
264
|
+
list << OllamaChat::Message.new(
|
|
265
|
+
role: 'assistant', content: 'b', group_uuid: 'g1'
|
|
266
|
+
)
|
|
267
|
+
list << OllamaChat::Message.new(
|
|
268
|
+
role: 'user', content: 'hello', group_uuid: 'g2'
|
|
269
|
+
)
|
|
270
|
+
list << OllamaChat::Message.new(
|
|
271
|
+
role: 'assistant', content: 'world', group_uuid: 'g2'
|
|
272
|
+
)
|
|
273
|
+
list << OllamaChat::Message.new(
|
|
274
|
+
role: 'user', content: 'hi', group_uuid: 'g3'
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
expect(chat).to receive(:log).with(:info, /Compaction:/, kind_of(Hash)).at_least(:once)
|
|
278
|
+
|
|
279
|
+
expect(chat).to receive(:summarize_for_compaction).with(
|
|
280
|
+
hash_including(previous_summary: nil)
|
|
281
|
+
).and_return(['narrative', []])
|
|
282
|
+
|
|
283
|
+
before = list.size
|
|
284
|
+
list.compact!
|
|
285
|
+
|
|
286
|
+
expect(list.size).to eq before + 1
|
|
287
|
+
summary = list.messages.find { _1.tool_name == 'summary' }
|
|
288
|
+
expect(summary).not_to be_nil
|
|
289
|
+
expect(summary.role).to eq 'tool'
|
|
290
|
+
expect(summary.content).to eq 'narrative'
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
it 'passes existing summary as previous_summary on re-compaction' do
|
|
294
|
+
old_summary = OllamaChat::Message.new(
|
|
295
|
+
role: 'tool', tool_name: 'summary',
|
|
296
|
+
content: 'old',
|
|
297
|
+
group_uuid: 's1'
|
|
298
|
+
)
|
|
299
|
+
list << old_summary
|
|
300
|
+
list << OllamaChat::Message.new(
|
|
301
|
+
role: 'user', content: 'a', group_uuid: 'g1'
|
|
302
|
+
)
|
|
303
|
+
list << OllamaChat::Message.new(
|
|
304
|
+
role: 'assistant', content: 'b', group_uuid: 'g1'
|
|
305
|
+
)
|
|
306
|
+
list << OllamaChat::Message.new(
|
|
307
|
+
role: 'user', content: 'hello', group_uuid: 'g2'
|
|
308
|
+
)
|
|
309
|
+
list << OllamaChat::Message.new(
|
|
310
|
+
role: 'assistant', content: 'world', group_uuid: 'g2'
|
|
311
|
+
)
|
|
312
|
+
list << OllamaChat::Message.new(
|
|
313
|
+
role: 'user', content: 'hi', group_uuid: 'g3'
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
expect(chat).to receive(:log).with(:info, /Compaction:/, kind_of(Hash)).at_least(:once)
|
|
317
|
+
|
|
318
|
+
expect(chat).to receive(:summarize_for_compaction).with(
|
|
319
|
+
hash_including(previous_summary: old_summary)
|
|
320
|
+
).and_return(['new', []])
|
|
321
|
+
|
|
322
|
+
before = list.size
|
|
323
|
+
list.compact!
|
|
324
|
+
|
|
325
|
+
expect(list.size).to eq before
|
|
326
|
+
summaries = list.messages.select { _1.tool_name == 'summary' }
|
|
327
|
+
expect(summaries.size).to eq 1
|
|
328
|
+
expect(summaries.first.content).to eq 'new'
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
it 'calls sync' do
|
|
332
|
+
list << OllamaChat::Message.new(
|
|
333
|
+
role: 'user', content: 'a', group_uuid: 'g1'
|
|
334
|
+
)
|
|
335
|
+
list << OllamaChat::Message.new(
|
|
336
|
+
role: 'assistant', content: 'b', group_uuid: 'g1'
|
|
337
|
+
)
|
|
338
|
+
list << OllamaChat::Message.new(
|
|
339
|
+
role: 'user', content: 'hello', group_uuid: 'g2'
|
|
340
|
+
)
|
|
341
|
+
list << OllamaChat::Message.new(
|
|
342
|
+
role: 'assistant', content: 'world', group_uuid: 'g2'
|
|
343
|
+
)
|
|
344
|
+
list << OllamaChat::Message.new(
|
|
345
|
+
role: 'user', content: 'hi', group_uuid: 'g3'
|
|
346
|
+
)
|
|
347
|
+
allow(chat).to receive(:summarize_for_compaction)
|
|
348
|
+
.and_return(['x', []])
|
|
349
|
+
|
|
350
|
+
expect(chat).to receive(:log).with(:info, /Compaction:/, kind_of(Hash)).at_least(:once)
|
|
351
|
+
|
|
352
|
+
expect(list).to receive(:sync)
|
|
353
|
+
list.compact!
|
|
354
|
+
end
|
|
126
355
|
end
|
|
127
356
|
|
|
128
357
|
describe '.load_conversation' do
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
describe OllamaChat::Message do
|
|
2
|
+
describe 'OllamaChat::MessageMixin' do
|
|
3
|
+
let :group_uuid do
|
|
4
|
+
'0194a3f2-8c4d-7e1a-b3c1-2f9e5a6b7c8d'
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
let :message do
|
|
8
|
+
described_class.new(
|
|
9
|
+
role: 'user',
|
|
10
|
+
content: 'Hello World',
|
|
11
|
+
thinking: 'Let me think about this...',
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
describe '#token_estimate' do
|
|
16
|
+
it 'excludes thinking when strip_thinking is true' do
|
|
17
|
+
# "Hello World" is 11 bytes
|
|
18
|
+
# 11 / 3.5 = 3.14 → ceil = 4
|
|
19
|
+
es = message.token_estimate(strip_thinking: true)
|
|
20
|
+
expect(es).to be_a OllamaChat::TokenEstimator::Estimate
|
|
21
|
+
expect(es.bytes).to eq 11
|
|
22
|
+
expect(es.tokens).to eq 4
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'includes thinking content by default (strip_thinking: false)' do
|
|
26
|
+
es = message.token_estimate
|
|
27
|
+
# "Hello World" (11) + "Let me think about this..." (26) = 37 bytes
|
|
28
|
+
# 37 / 3.5 = 10.57 → ceil = 11
|
|
29
|
+
expect(es.bytes).to eq 37
|
|
30
|
+
expect(es.tokens).to eq 11
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'handles nil content gracefully' do
|
|
34
|
+
msg = described_class.new(role: 'user', content: nil)
|
|
35
|
+
es = msg.token_estimate
|
|
36
|
+
expect(es.bytes).to eq 0
|
|
37
|
+
expect(es.tokens).to eq 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'handles nil thinking gracefully' do
|
|
41
|
+
msg = described_class.new(role: 'user', content: 'Hi', thinking: nil)
|
|
42
|
+
es = msg.token_estimate
|
|
43
|
+
expect(es.bytes).to eq 2
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'handles both nil content and thinking' do
|
|
47
|
+
msg = described_class.new(role: 'user', content: nil, thinking: nil)
|
|
48
|
+
es = msg.token_estimate
|
|
49
|
+
expect(es.bytes).to eq 0
|
|
50
|
+
expect(es.tokens).to eq 0
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe '#group_time' do
|
|
55
|
+
it 'returns nil when group_uuid is missing' do
|
|
56
|
+
expect(message.group_time).to be_nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'extracts the timestamp from a UUIDv7' do
|
|
60
|
+
msg = described_class.new(
|
|
61
|
+
role: 'user', content: 'Hi', group_uuid:,
|
|
62
|
+
)
|
|
63
|
+
expected = Time.parse('2025-01-26 19:49:29+0100')
|
|
64
|
+
expect(msg.group_time).to be_within(5).of(expected)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'returns a Time close to now for a freshly generated UUID' do
|
|
68
|
+
msg = described_class.new(role: 'user', content: 'Hi')
|
|
69
|
+
msg.initialize_group_uuid
|
|
70
|
+
diff = (Time.now - msg.group_time).abs
|
|
71
|
+
expect(diff).to be < 5.0
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
describe '#initialize_group_uuid' do
|
|
76
|
+
it 'generates a UUIDv7 when missing' do
|
|
77
|
+
msg = described_class.new(role: 'user', content: 'Hi')
|
|
78
|
+
expect(msg.group_uuid).to be_nil
|
|
79
|
+
msg.initialize_group_uuid
|
|
80
|
+
expect(msg.group_uuid).to match(/\A[0-9a-f-]{36}\z/)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'preserves an existing group_uuid' do
|
|
84
|
+
uuid = '0194a3f2-8c4d-7e1a-b3c1-2f9e5a6b7c8d'
|
|
85
|
+
msg = described_class.new(role: 'user', content: 'Hi', group_uuid: uuid)
|
|
86
|
+
msg.initialize_group_uuid
|
|
87
|
+
expect(msg.group_uuid).to eq uuid
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'returns self for chaining' do
|
|
91
|
+
msg = described_class.new(role: 'user', content: 'Hi')
|
|
92
|
+
expect(msg.initialize_group_uuid).to be msg
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
describe '#tool?' do
|
|
97
|
+
it 'returns true when tool_name is present' do
|
|
98
|
+
msg = described_class.new(
|
|
99
|
+
role: 'tool', content: 'result', tool_name: 'read_file',
|
|
100
|
+
)
|
|
101
|
+
expect(msg.tool?).to be true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'returns false when tool_name is nil' do
|
|
105
|
+
expect(message.tool?).to be false
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
describe '#as_json' do
|
|
110
|
+
it 'includes sender_name and group_uuid when set' do
|
|
111
|
+
msg = described_class.new(
|
|
112
|
+
role: 'user', content: 'Hi', sender_name: 'Florian', group_uuid:
|
|
113
|
+
)
|
|
114
|
+
json = msg.as_json
|
|
115
|
+
expect(json[:sender_name]).to eq 'Florian'
|
|
116
|
+
expect(json[:group_uuid]).to eq group_uuid
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'omits nil sender_name and group_uuid' do
|
|
120
|
+
json = message.as_json
|
|
121
|
+
expect(json).not_to have_key(:sender_name)
|
|
122
|
+
expect(json).not_to have_key(:group_uuid)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'includes tool_calls when set' do
|
|
126
|
+
entries = [{ 'type' => 'tool', 'name' => 'read_file',
|
|
127
|
+
'summary' => 'Read /etc/hosts', 'uuid' => 'aaaa1234',
|
|
128
|
+
'time' => '2026-08-30 14:32' }]
|
|
129
|
+
msg = described_class.new(
|
|
130
|
+
role: 'tool', content: 'summary', tool_name: 'summary',
|
|
131
|
+
tool_calls: entries,
|
|
132
|
+
)
|
|
133
|
+
json = msg.as_json
|
|
134
|
+
expect(json[:tool_calls]).to eq entries
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it 'handles tool_calls as nil without crashing' do
|
|
138
|
+
json = message.as_json
|
|
139
|
+
expect(json[:tool_calls]).to be_nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -402,6 +402,7 @@ describe OllamaChat::RAGHandling do
|
|
|
402
402
|
'new desc', # description
|
|
403
403
|
'lib/**/*.rb spec/*', # patterns
|
|
404
404
|
)
|
|
405
|
+
expect(chat).to receive(:confirm?).and_return(false)
|
|
405
406
|
|
|
406
407
|
chat.edit_collection
|
|
407
408
|
|
|
@@ -422,6 +423,7 @@ describe OllamaChat::RAGHandling do
|
|
|
422
423
|
nil, # blank description
|
|
423
424
|
'', # empty patterns
|
|
424
425
|
)
|
|
426
|
+
expect(chat).to receive(:confirm?).and_return(false)
|
|
425
427
|
|
|
426
428
|
chat.edit_collection
|
|
427
429
|
|
|
@@ -437,6 +439,7 @@ describe OllamaChat::RAGHandling do
|
|
|
437
439
|
blk.call
|
|
438
440
|
end
|
|
439
441
|
expect(chat).to receive(:ask?).and_return('new d', '')
|
|
442
|
+
expect(chat).to receive(:confirm?).and_return(false)
|
|
440
443
|
expect_any_instance_of(col_model).to receive(:save).
|
|
441
444
|
and_raise(Sequel::Error, 'db error')
|
|
442
445
|
expect(STDERR).to receive(:puts).
|
|
@@ -44,6 +44,8 @@ describe OllamaChat::Tools::Browse do
|
|
|
44
44
|
"Opened \"https://www.example.com\" in browser."
|
|
45
45
|
)
|
|
46
46
|
expect(json.url).to eq 'https://www.example.com'
|
|
47
|
+
expect(described_class.summary_template(result:)).to eq \
|
|
48
|
+
"Opened \"https://www.example.com\" in browser."
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
it 'can handle execution errors gracefully' do
|
|
@@ -71,6 +73,8 @@ describe OllamaChat::Tools::Browse do
|
|
|
71
73
|
expect(json.message).to eq(
|
|
72
74
|
"Could not open \"https://nonexistent-domain-12345.com\" in browser."
|
|
73
75
|
)
|
|
76
|
+
expect(described_class.summary_template(result:)).to eq \
|
|
77
|
+
"Could not open \"https://nonexistent-domain-12345.com\" in browser."
|
|
74
78
|
end
|
|
75
79
|
|
|
76
80
|
it 'can handle execution exceptions gracefully' do
|
|
@@ -93,6 +97,7 @@ describe OllamaChat::Tools::Browse do
|
|
|
93
97
|
json = json_object(result)
|
|
94
98
|
expect(json.error).to eq 'RuntimeError'
|
|
95
99
|
expect(json.message).to eq 'some kind of exception'
|
|
100
|
+
expect(described_class.summary_template(result:)).to eq 'some kind of exception'
|
|
96
101
|
end
|
|
97
102
|
|
|
98
103
|
context 'when browser is configured' do
|
|
@@ -126,6 +131,8 @@ describe OllamaChat::Tools::Browse do
|
|
|
126
131
|
"Opened \"https://www.example.com\" in browser."
|
|
127
132
|
)
|
|
128
133
|
expect(json.url).to eq 'https://www.example.com'
|
|
134
|
+
expect(described_class.summary_template(result:)).to eq \
|
|
135
|
+
"Opened \"https://www.example.com\" in browser."
|
|
129
136
|
end
|
|
130
137
|
end
|
|
131
138
|
end
|
|
@@ -36,6 +36,9 @@ describe OllamaChat::Tools::ComputeBMI do
|
|
|
36
36
|
expect(json.message).to match(
|
|
37
37
|
/This BMI is 22\.8\d+, which falls into the Normal weight category\./
|
|
38
38
|
)
|
|
39
|
+
expect(described_class.summary_template(result:)).to match(
|
|
40
|
+
/This BMI is 22\.8\d+/,
|
|
41
|
+
)
|
|
39
42
|
end
|
|
40
43
|
|
|
41
44
|
it 'calculates BMI correctly for Underweight' do
|
|
@@ -55,6 +58,9 @@ describe OllamaChat::Tools::ComputeBMI do
|
|
|
55
58
|
expect(json.message).to match(
|
|
56
59
|
/This BMI is 17\.5\d+, which falls into the Underweight category\./
|
|
57
60
|
)
|
|
61
|
+
expect(described_class.summary_template(result:)).to match(
|
|
62
|
+
/This BMI is 17\.5\d+/,
|
|
63
|
+
)
|
|
58
64
|
end
|
|
59
65
|
end
|
|
60
66
|
|
|
@@ -76,6 +82,9 @@ describe OllamaChat::Tools::ComputeBMI do
|
|
|
76
82
|
expect(json.message).to match(
|
|
77
83
|
/This BMI is 21\.5\d+, which falls into the Normal weight category\./
|
|
78
84
|
)
|
|
85
|
+
expect(described_class.summary_template(result:)).to match(
|
|
86
|
+
/This BMI is 21\.5\d+/,
|
|
87
|
+
)
|
|
79
88
|
end
|
|
80
89
|
end
|
|
81
90
|
|
|
@@ -94,6 +103,9 @@ describe OllamaChat::Tools::ComputeBMI do
|
|
|
94
103
|
|
|
95
104
|
expect(json.error).to eq 'OllamaChat::ToolFunctionArgumentError'
|
|
96
105
|
expect(json.message).to include 'no weight given'
|
|
106
|
+
expect(described_class.summary_template(result:)).to include(
|
|
107
|
+
'no weight given',
|
|
108
|
+
)
|
|
97
109
|
end
|
|
98
110
|
|
|
99
111
|
it 'returns error when height is zero' do
|
|
@@ -110,6 +122,9 @@ describe OllamaChat::Tools::ComputeBMI do
|
|
|
110
122
|
|
|
111
123
|
expect(json.error).to eq 'OllamaChat::ToolFunctionArgumentError'
|
|
112
124
|
expect(json.message).to include 'Height must be greater than zero'
|
|
125
|
+
expect(described_class.summary_template(result:)).to include(
|
|
126
|
+
'Height must be greater than zero',
|
|
127
|
+
)
|
|
113
128
|
end
|
|
114
129
|
end
|
|
115
130
|
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
describe OllamaChat::Tools::Concern do
|
|
2
|
+
# Use a tool that does NOT override `summary_template` for base-class tests.
|
|
3
|
+
let :tool_class do
|
|
4
|
+
OllamaChat::Tools::GetTime
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
let :tool do
|
|
8
|
+
tool_class.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
describe '.summary_template' do
|
|
12
|
+
it 'extracts the message field from valid JSON' do
|
|
13
|
+
result = { message: 'The current time is 12:00 UTC.' }.to_json
|
|
14
|
+
expect(tool_class.summary_template(result:))
|
|
15
|
+
.to eq 'The current time is 12:00 UTC.'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'returns the generic fallback when message is absent' do
|
|
19
|
+
result = { data: 'no-message-key' }.to_json
|
|
20
|
+
expect(tool_class.summary_template(result:))
|
|
21
|
+
.to eq 'was called.'
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'returns the generic fallback for invalid JSON' do
|
|
25
|
+
expect(tool_class.summary_template(result: 'not json'))
|
|
26
|
+
.to eq 'was called.'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'returns the generic fallback for empty string' do
|
|
30
|
+
expect(tool_class.summary_template(result: ''))
|
|
31
|
+
.to eq 'was called.'
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe '#name' do
|
|
36
|
+
it 'returns the registered name' do
|
|
37
|
+
expect(tool.name).to eq tool_class.register_name
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe '#to_hash' do
|
|
42
|
+
it 'delegates to tool.to_hash' do
|
|
43
|
+
expect(tool.to_hash).to eq tool.tool.to_hash
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'returns a hash with a function key' do
|
|
47
|
+
expect(tool.to_hash).to have_key(:function)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe '#valid_json?' do
|
|
52
|
+
it 'returns a proc' do
|
|
53
|
+
expect(tool.valid_json?).to be_a Proc
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'parses valid JSON from a temp file' do
|
|
57
|
+
tmp = Tempfile.new('test.json')
|
|
58
|
+
tmp.write({ key: 'value' }.to_json)
|
|
59
|
+
tmp.rewind
|
|
60
|
+
|
|
61
|
+
expect(tool.valid_json?.call(tmp)).to eq({ key: 'value' }.to_json)
|
|
62
|
+
ensure
|
|
63
|
+
tmp&.close!
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'raises for empty content' do
|
|
67
|
+
tmp = Tempfile.new('empty.json')
|
|
68
|
+
tmp.write('')
|
|
69
|
+
tmp.rewind
|
|
70
|
+
|
|
71
|
+
expect { tool.valid_json?.call(tmp) }
|
|
72
|
+
.to raise_error(JSON::ParserError, 'require JSON data')
|
|
73
|
+
ensure
|
|
74
|
+
tmp&.close!
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -36,6 +36,7 @@ describe OllamaChat::Tools::CopyToClipboard do
|
|
|
36
36
|
json = json_object(result)
|
|
37
37
|
expect(json.error).to eq 'OllamaChat::ToolFunctionArgumentError'
|
|
38
38
|
expect(json.message).to eq 'no text given'
|
|
39
|
+
expect(described_class.summary_template(result:)).to eq 'no text given'
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
it 'can copy custom text to the clipboard' do
|
|
@@ -63,6 +64,8 @@ describe OllamaChat::Tools::CopyToClipboard do
|
|
|
63
64
|
expect(json.error).to be_nil # No exception was raised
|
|
64
65
|
expect(json.success).to be true
|
|
65
66
|
expect(json.message).to eq 'The provided text has been successfully copied to the system clipboard.'
|
|
67
|
+
expect(described_class.summary_template(result:))\
|
|
68
|
+
.to eq 'The provided text has been successfully copied to the system clipboard.'
|
|
66
69
|
end
|
|
67
70
|
|
|
68
71
|
it 'can be executed with edit option' do
|
|
@@ -89,6 +92,8 @@ describe OllamaChat::Tools::CopyToClipboard do
|
|
|
89
92
|
expect(json.error).to be_nil
|
|
90
93
|
expect(json.success).to be true
|
|
91
94
|
expect(json.message).to eq 'The provided text has been successfully copied to the system clipboard.'
|
|
95
|
+
expect(described_class.summary_template(result:))\
|
|
96
|
+
.to eq 'The provided text has been successfully copied to the system clipboard.'
|
|
92
97
|
end
|
|
93
98
|
|
|
94
99
|
it 'can handle execution errors gracefully' do
|
|
@@ -115,6 +120,8 @@ describe OllamaChat::Tools::CopyToClipboard do
|
|
|
115
120
|
json = json_object(result)
|
|
116
121
|
expect(json.error).to eq 'OllamaChat::OllamaChatError'
|
|
117
122
|
expect(json.message).to eq 'No response available to copy to the system clipboard.'
|
|
123
|
+
expect(described_class.summary_template(result:))\
|
|
124
|
+
.to eq 'No response available to copy to the system clipboard.'
|
|
118
125
|
end
|
|
119
126
|
|
|
120
127
|
it 'can handle execution exceptions gracefully' do
|
|
@@ -141,5 +148,6 @@ describe OllamaChat::Tools::CopyToClipboard do
|
|
|
141
148
|
json = json_object(result)
|
|
142
149
|
expect(json.error).to eq 'RuntimeError'
|
|
143
150
|
expect(json.message).to eq 'some kind of exception'
|
|
151
|
+
expect(described_class.summary_template(result:)).to eq 'some kind of exception'
|
|
144
152
|
end
|
|
145
153
|
end
|
|
@@ -39,6 +39,8 @@ describe OllamaChat::Tools::DeleteFile do
|
|
|
39
39
|
expect(json.success).to eq true
|
|
40
40
|
expect(json.path).to eq Pathname.new(file_path).expand_path.to_s
|
|
41
41
|
expect(json.backup).not_to be_nil
|
|
42
|
+
expect(described_class.summary_template(result:)).
|
|
43
|
+
to include('deleted successfully')
|
|
42
44
|
|
|
43
45
|
# Verify file was actually deleted
|
|
44
46
|
expect(File.exist?(file_path)).to be false
|
|
@@ -70,6 +72,8 @@ describe OllamaChat::Tools::DeleteFile do
|
|
|
70
72
|
expect(json.error).to eq 'OllamaChat::InvalidPathError'
|
|
71
73
|
expect(json.path).to eq '/etc/passwd'
|
|
72
74
|
expect(json.message).to include('is not within allowed directories')
|
|
75
|
+
expect(described_class.summary_template(result:)).
|
|
76
|
+
to include('is not within allowed directories')
|
|
73
77
|
end
|
|
74
78
|
|
|
75
79
|
it 'can handle execution errors gracefully when file does not exist' do
|
|
@@ -94,6 +98,8 @@ describe OllamaChat::Tools::DeleteFile do
|
|
|
94
98
|
# Either InvalidPathError (because check: :file fails) or Errno::ENOENT
|
|
95
99
|
expect(json.success).to be_falsey
|
|
96
100
|
expect(json.message).to include('Failed to delete file')
|
|
101
|
+
expect(described_class.summary_template(result:)).
|
|
102
|
+
to include('Failed to delete file')
|
|
97
103
|
end
|
|
98
104
|
|
|
99
105
|
it 'can handle exceptions gracefully' do
|
|
@@ -120,6 +126,8 @@ describe OllamaChat::Tools::DeleteFile do
|
|
|
120
126
|
json = json_object(result)
|
|
121
127
|
expect(json.error).to eq 'RuntimeError'
|
|
122
128
|
expect(json.message).to eq 'Failed to delete file, RuntimeError: Unexpected error'
|
|
129
|
+
expect(described_class.summary_template(result:)).to eq \
|
|
130
|
+
'Failed to delete file, RuntimeError: Unexpected error'
|
|
123
131
|
ensure
|
|
124
132
|
File.delete(file_path) if File.exist?(file_path)
|
|
125
133
|
end
|
|
@@ -32,6 +32,8 @@ describe OllamaChat::Tools::DirectoryStructure do
|
|
|
32
32
|
expect(result).to be_a(String)
|
|
33
33
|
json = json_object(result)
|
|
34
34
|
expect(json.size).to eq 24
|
|
35
|
+
expect(described_class.summary_template(result:)).to eq \
|
|
36
|
+
'was called.'
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
it 'can be executed successfully with no arguments (defaults)' do
|
|
@@ -76,6 +78,8 @@ describe OllamaChat::Tools::DirectoryStructure do
|
|
|
76
78
|
json = json_object(result)
|
|
77
79
|
expect(json.error).to eq 'Errno::ENOENT'
|
|
78
80
|
expect(json.message).to eq 'No such file or directory @ dir_initialize - /nonexistent/path'
|
|
81
|
+
expect(described_class.summary_template(result:)).to eq \
|
|
82
|
+
'No such file or directory @ dir_initialize - /nonexistent/path'
|
|
79
83
|
end
|
|
80
84
|
|
|
81
85
|
it 'can be converted to hash' do
|