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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGES.md +120 -0
  3. data/README.md +7 -0
  4. data/bin/ollama_chat_log +1 -1
  5. data/bin/ollama_chat_send +3 -1
  6. data/lib/ollama_chat/chat.rb +30 -14
  7. data/lib/ollama_chat/commands.rb +36 -18
  8. data/lib/ollama_chat/compaction.rb +289 -0
  9. data/lib/ollama_chat/config_handling.rb +32 -0
  10. data/lib/ollama_chat/database/migrations/010_add_enabled_to_collections.rb +7 -0
  11. data/lib/ollama_chat/database/models/app_state.rb +48 -16
  12. data/lib/ollama_chat/database/models/session.rb +1 -2
  13. data/lib/ollama_chat/database.rb +4 -2
  14. data/lib/ollama_chat/information.rb +53 -6
  15. data/lib/ollama_chat/logging.rb +3 -2
  16. data/lib/ollama_chat/message.rb +22 -1
  17. data/lib/ollama_chat/message_format.rb +1 -2
  18. data/lib/ollama_chat/message_list.rb +223 -9
  19. data/lib/ollama_chat/model_handling.rb +1 -1
  20. data/lib/ollama_chat/oc.rb +17 -7
  21. data/lib/ollama_chat/ollama_chat_config/default_config.yml +72 -3
  22. data/lib/ollama_chat/parsing.rb +2 -1
  23. data/lib/ollama_chat/prompt_handling.rb +1 -1
  24. data/lib/ollama_chat/prompt_management.rb +1 -0
  25. data/lib/ollama_chat/rag_handling.rb +19 -5
  26. data/lib/ollama_chat/session_management.rb +5 -3
  27. data/lib/ollama_chat/token_estimator/crude.rb +1 -1
  28. data/lib/ollama_chat/token_estimator.rb +1 -1
  29. data/lib/ollama_chat/tools/concern.rb +17 -0
  30. data/lib/ollama_chat/tools/directory_structure.rb +11 -7
  31. data/lib/ollama_chat/tools/eval_ruby.rb +16 -6
  32. data/lib/ollama_chat/tools/execute_shell.rb +158 -0
  33. data/lib/ollama_chat/tools/get_rfc.rb +2 -0
  34. data/lib/ollama_chat/tools/lookup_group.rb +100 -0
  35. data/lib/ollama_chat/tools/paste_from_clipboard.rb +1 -1
  36. data/lib/ollama_chat/tools/patch_file.rb +23 -5
  37. data/lib/ollama_chat/tools/read_file.rb +1 -1
  38. data/lib/ollama_chat/tools/resolve_tag.rb +1 -1
  39. data/lib/ollama_chat/tools/search_knowledge.rb +7 -1
  40. data/lib/ollama_chat/tools/write_file.rb +25 -6
  41. data/lib/ollama_chat/tools.rb +2 -0
  42. data/lib/ollama_chat/utils/analyze_directory.rb +5 -4
  43. data/lib/ollama_chat/utils/fetcher.rb +1 -1
  44. data/lib/ollama_chat/utils/log_rotation.rb +29 -0
  45. data/lib/ollama_chat/utils/strip_ansi.rb +22 -0
  46. data/lib/ollama_chat/utils/tag_resolver.rb +3 -3
  47. data/lib/ollama_chat/utils.rb +2 -0
  48. data/lib/ollama_chat/version.rb +1 -1
  49. data/lib/ollama_chat.rb +7 -0
  50. data/ollama_chat.gemspec +5 -5
  51. data/spec/ollama_chat/commands_spec.rb +25 -0
  52. data/spec/ollama_chat/compaction_spec.rb +381 -0
  53. data/spec/ollama_chat/config_handling_spec.rb +60 -0
  54. data/spec/ollama_chat/message_list_spec.rb +229 -0
  55. data/spec/ollama_chat/message_spec.rb +143 -0
  56. data/spec/ollama_chat/rag_handling_spec.rb +3 -0
  57. data/spec/ollama_chat/system_prompt_management_spec.rb +0 -2
  58. data/spec/ollama_chat/tools/browse_spec.rb +7 -0
  59. data/spec/ollama_chat/tools/compute_bmi_spec.rb +15 -0
  60. data/spec/ollama_chat/tools/concern_spec.rb +77 -0
  61. data/spec/ollama_chat/tools/copy_to_clipboard_spec.rb +8 -0
  62. data/spec/ollama_chat/tools/delete_file_spec.rb +8 -0
  63. data/spec/ollama_chat/tools/directory_structure_spec.rb +4 -0
  64. data/spec/ollama_chat/tools/execute_grep_spec.rb +13 -0
  65. data/spec/ollama_chat/tools/execute_ri_spec.rb +8 -0
  66. data/spec/ollama_chat/tools/execute_shell_spec.rb +232 -0
  67. data/spec/ollama_chat/tools/file_context_spec.rb +5 -0
  68. data/spec/ollama_chat/tools/gem_path_lookup_spec.rb +2 -0
  69. data/spec/ollama_chat/tools/generate_image_spec.rb +12 -0
  70. data/spec/ollama_chat/tools/generate_password_spec.rb +5 -0
  71. data/spec/ollama_chat/tools/get_cve_spec.rb +4 -0
  72. data/spec/ollama_chat/tools/get_endoflife_spec.rb +4 -0
  73. data/spec/ollama_chat/tools/get_ghr_spec.rb +6 -0
  74. data/spec/ollama_chat/tools/get_location_spec.rb +4 -0
  75. data/spec/ollama_chat/tools/get_rfc_spec.rb +2 -0
  76. data/spec/ollama_chat/tools/get_time_spec.rb +2 -0
  77. data/spec/ollama_chat/tools/get_url_spec.rb +7 -0
  78. data/spec/ollama_chat/tools/lookup_group_spec.rb +111 -0
  79. data/spec/ollama_chat/tools/move_file_spec.rb +10 -0
  80. data/spec/ollama_chat/tools/open_file_in_editor_spec.rb +6 -0
  81. data/spec/ollama_chat/tools/paste_from_clipboard_spec.rb +5 -0
  82. data/spec/ollama_chat/tools/paste_into_editor_spec.rb +6 -0
  83. data/spec/ollama_chat/tools/patch_file_spec.rb +62 -0
  84. data/spec/ollama_chat/tools/read_file_spec.rb +10 -3
  85. data/spec/ollama_chat/tools/resolve_tag_spec.rb +46 -0
  86. data/spec/ollama_chat/tools/roll_dice_spec.rb +11 -0
  87. data/spec/ollama_chat/tools/run_tests_spec.rb +8 -0
  88. data/spec/ollama_chat/tools/search_knowledge_spec.rb +2 -0
  89. data/spec/ollama_chat/tools/write_file_spec.rb +98 -2
  90. data/spec/ollama_chat/utils/analyze_directory_spec.rb +46 -7
  91. data/spec/ollama_chat/utils/log_rotation_spec.rb +71 -0
  92. data/spec/spec_helper.rb +2 -1
  93. 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).
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  describe OllamaChat::SystemPromptManagement do
4
2
  let :chat do
5
3
  OllamaChat::Chat.new(argv: chat_default_config).expose
@@ -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