todo-agent 0.3.1__tar.gz → 0.3.3__tar.gz

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 (66) hide show
  1. {todo_agent-0.3.1 → todo_agent-0.3.3}/PKG-INFO +3 -3
  2. {todo_agent-0.3.1 → todo_agent-0.3.3}/README.md +2 -2
  3. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/test_core/test_conversation_manager.py +2 -2
  4. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/test_core/test_todo_manager.py +138 -71
  5. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/test_infrastructure/test_calendar_utils.py +2 -2
  6. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/test_infrastructure/test_inference.py +6 -4
  7. todo_agent-0.3.3/tests/test_infrastructure/test_ollama_client.py +198 -0
  8. todo_agent-0.3.3/tests/test_infrastructure/test_openrouter_client.py +266 -0
  9. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/test_infrastructure/test_todo_shell.py +66 -19
  10. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/test_interface/test_cli.py +23 -7
  11. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/test_interface/test_tools.py +27 -0
  12. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/_version.py +3 -3
  13. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/core/conversation_manager.py +1 -1
  14. todo_agent-0.3.3/todo_agent/core/exceptions.py +78 -0
  15. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/core/todo_manager.py +127 -56
  16. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/infrastructure/calendar_utils.py +2 -4
  17. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/infrastructure/inference.py +158 -52
  18. todo_agent-0.3.3/todo_agent/infrastructure/llm_client.py +319 -0
  19. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/infrastructure/ollama_client.py +77 -76
  20. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/infrastructure/openrouter_client.py +77 -72
  21. todo_agent-0.3.3/todo_agent/infrastructure/prompts/system_prompt.txt +91 -0
  22. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/infrastructure/todo_shell.py +37 -27
  23. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/interface/cli.py +129 -19
  24. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/interface/formatters.py +25 -0
  25. todo_agent-0.3.3/todo_agent/interface/progress.py +69 -0
  26. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/interface/tools.py +142 -23
  27. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent.egg-info/PKG-INFO +3 -3
  28. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent.egg-info/SOURCES.txt +1 -0
  29. todo_agent-0.3.1/tests/test_infrastructure/test_ollama_client.py +0 -139
  30. todo_agent-0.3.1/tests/test_infrastructure/test_openrouter_client.py +0 -292
  31. todo_agent-0.3.1/todo_agent/core/exceptions.py +0 -27
  32. todo_agent-0.3.1/todo_agent/infrastructure/llm_client.py +0 -62
  33. todo_agent-0.3.1/todo_agent/infrastructure/prompts/system_prompt.txt +0 -399
  34. {todo_agent-0.3.1 → todo_agent-0.3.3}/.gitignore +0 -0
  35. {todo_agent-0.3.1 → todo_agent-0.3.3}/LICENSE +0 -0
  36. {todo_agent-0.3.1 → todo_agent-0.3.3}/MANIFEST.in +0 -0
  37. {todo_agent-0.3.1 → todo_agent-0.3.3}/Makefile +0 -0
  38. {todo_agent-0.3.1 → todo_agent-0.3.3}/docs/publishing.md +0 -0
  39. {todo_agent-0.3.1 → todo_agent-0.3.3}/pyproject.toml +0 -0
  40. {todo_agent-0.3.1 → todo_agent-0.3.3}/requirements-dev.txt +0 -0
  41. {todo_agent-0.3.1 → todo_agent-0.3.3}/requirements.txt +0 -0
  42. {todo_agent-0.3.1 → todo_agent-0.3.3}/setup.cfg +0 -0
  43. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/__init__.py +0 -0
  44. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/test_core/__init__.py +0 -0
  45. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/test_infrastructure/__init__.py +0 -0
  46. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/test_infrastructure/test_config.py +0 -0
  47. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/test_infrastructure/test_llm_client_factory.py +0 -0
  48. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/test_infrastructure/test_token_counter.py +0 -0
  49. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/test_interface/__init__.py +0 -0
  50. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/test_interface/test_formatters.py +0 -0
  51. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/test_linting.py +0 -0
  52. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/test_logger.py +0 -0
  53. {todo_agent-0.3.1 → todo_agent-0.3.3}/tests/test_main.py +0 -0
  54. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/__init__.py +0 -0
  55. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/core/__init__.py +0 -0
  56. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/infrastructure/__init__.py +0 -0
  57. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/infrastructure/config.py +0 -0
  58. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/infrastructure/llm_client_factory.py +0 -0
  59. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/infrastructure/logger.py +0 -0
  60. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/infrastructure/token_counter.py +0 -0
  61. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/interface/__init__.py +0 -0
  62. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent/main.py +0 -0
  63. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent.egg-info/dependency_links.txt +0 -0
  64. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent.egg-info/entry_points.txt +0 -0
  65. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent.egg-info/requires.txt +0 -0
  66. {todo_agent-0.3.1 → todo_agent-0.3.3}/todo_agent.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: todo-agent
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: A natural language interface for todo.sh task management
5
5
  Author: codeprimate
6
6
  Maintainer: codeprimate
@@ -68,7 +68,7 @@ todo-agent "show my work tasks"
68
68
 
69
69
  **Get intelligent insights** beyond basic task lists. It organizes tasks strategically, suggests priorities, and recommends optimal timing based on your patterns.
70
70
 
71
- **Work smarter** with automatic duplicate detection, recurring task handling, and calendar-aware scheduling.
71
+ **Work smarter** with automatic duplicate detection and calendar-aware scheduling.
72
72
 
73
73
  **Choose your privacy** - use cloud AI (OpenRouter) or run locally (Ollama).
74
74
 
@@ -163,7 +163,7 @@ todo-agent "what tasks are blocking other work?"
163
163
  ### Natural Language Intelligence
164
164
  ```bash
165
165
  todo-agent "add dentist appointment next Monday"
166
- todo-agent "set up recurring daily vitamin reminder"
166
+
167
167
  todo-agent "move all completed tasks to archive"
168
168
  todo-agent "show me tasks I can do from home"
169
169
  ```
@@ -21,7 +21,7 @@ todo-agent "show my work tasks"
21
21
 
22
22
  **Get intelligent insights** beyond basic task lists. It organizes tasks strategically, suggests priorities, and recommends optimal timing based on your patterns.
23
23
 
24
- **Work smarter** with automatic duplicate detection, recurring task handling, and calendar-aware scheduling.
24
+ **Work smarter** with automatic duplicate detection and calendar-aware scheduling.
25
25
 
26
26
  **Choose your privacy** - use cloud AI (OpenRouter) or run locally (Ollama).
27
27
 
@@ -116,7 +116,7 @@ todo-agent "what tasks are blocking other work?"
116
116
  ### Natural Language Intelligence
117
117
  ```bash
118
118
  todo-agent "add dentist appointment next Monday"
119
- todo-agent "set up recurring daily vitamin reminder"
119
+
120
120
  todo-agent "move all completed tasks to archive"
121
121
  todo-agent "show me tasks I can do from home"
122
122
  ```
@@ -14,8 +14,8 @@ class TestConversationManager:
14
14
  """Test ConversationManager initialization."""
15
15
  manager = ConversationManager()
16
16
  assert len(manager.history) == 0
17
- assert manager.max_tokens == 32000
18
- assert manager.max_messages == 50
17
+ assert manager.max_tokens == 64000
18
+ assert manager.max_messages == 100
19
19
  assert manager.system_prompt is None
20
20
 
21
21
  def test_add_message(self):
@@ -148,44 +148,6 @@ class TestTodoManager(unittest.TestCase):
148
148
  self.todo_manager.set_due_date(1, "2025/1/5")
149
149
  self.assertIn("Invalid due date format", str(context.exception))
150
150
 
151
- def test_add_task_with_valid_recurring_daily(self):
152
- """Test adding a task with valid daily recurring format."""
153
- self.todo_shell.add.return_value = "Test task"
154
- result = self.todo_manager.add_task("Test task", recurring="rec:daily")
155
- self.assertEqual(result, "Added task: Test task rec:daily")
156
- self.todo_shell.add.assert_called_once_with("Test task rec:daily")
157
-
158
- def test_add_task_with_valid_recurring_weekly_interval(self):
159
- """Test adding a task with valid weekly recurring format with interval."""
160
- self.todo_shell.add.return_value = "Test task"
161
- result = self.todo_manager.add_task("Test task", recurring="rec:weekly:2")
162
- self.assertEqual(result, "Added task: Test task rec:weekly:2")
163
- self.todo_shell.add.assert_called_once_with("Test task rec:weekly:2")
164
-
165
- def test_add_task_with_invalid_recurring_format(self):
166
- """Test adding a task with invalid recurring format raises ValueError."""
167
- with self.assertRaises(ValueError) as context:
168
- self.todo_manager.add_task("Test task", recurring="invalid")
169
- self.assertIn("Invalid recurring format", str(context.exception))
170
-
171
- def test_add_task_with_invalid_recurring_frequency(self):
172
- """Test adding a task with invalid recurring frequency raises ValueError."""
173
- with self.assertRaises(ValueError) as context:
174
- self.todo_manager.add_task("Test task", recurring="rec:invalid")
175
- self.assertIn("Invalid frequency", str(context.exception))
176
-
177
- def test_add_task_with_invalid_recurring_interval(self):
178
- """Test adding a task with invalid recurring interval raises ValueError."""
179
- with self.assertRaises(ValueError) as context:
180
- self.todo_manager.add_task("Test task", recurring="rec:weekly:invalid")
181
- self.assertIn("Invalid interval", str(context.exception))
182
-
183
- def test_add_task_with_zero_recurring_interval(self):
184
- """Test adding a task with zero recurring interval raises ValueError."""
185
- with self.assertRaises(ValueError) as context:
186
- self.todo_manager.add_task("Test task", recurring="rec:weekly:0")
187
- self.assertIn("Must be a positive integer", str(context.exception))
188
-
189
151
  def test_add_task_with_invalid_duration_format(self):
190
152
  """Test adding a task with invalid duration format raises ValueError."""
191
153
  with self.assertRaises(ValueError) as context:
@@ -216,24 +178,6 @@ class TestTodoManager(unittest.TestCase):
216
178
  self.todo_manager.add_task("Test task", duration="")
217
179
  self.assertIn("Duration must be a non-empty string", str(context.exception))
218
180
 
219
- def test_add_task_with_all_parameters_including_recurring(self):
220
- """Test adding a task with all parameters including recurring."""
221
- self.todo_shell.add.return_value = "Test task"
222
- result = self.todo_manager.add_task(
223
- "Test task",
224
- priority="A",
225
- project="work",
226
- context="office",
227
- due="2024-01-15",
228
- recurring="rec:daily",
229
- )
230
- self.assertEqual(
231
- result, "Added task: (A) Test task +work @office due:2024-01-15 rec:daily"
232
- )
233
- self.todo_shell.add.assert_called_once_with(
234
- "(A) Test task +work @office due:2024-01-15 rec:daily"
235
- )
236
-
237
181
  def test_add_task_with_duration(self):
238
182
  """Test adding a task with duration parameter."""
239
183
  self.todo_shell.add.return_value = "Test task"
@@ -253,15 +197,14 @@ class TestTodoManager(unittest.TestCase):
253
197
  project="work",
254
198
  context="office",
255
199
  due="2024-01-15",
256
- recurring="rec:daily",
257
200
  duration="2h",
258
201
  )
259
202
  self.assertEqual(
260
203
  result,
261
- "Added task: (A) Test task +work @office due:2024-01-15 rec:daily duration:2h",
204
+ "Added task: (A) Test task +work @office due:2024-01-15 duration:2h",
262
205
  )
263
206
  self.todo_shell.add.assert_called_once_with(
264
- "(A) Test task +work @office due:2024-01-15 rec:daily duration:2h"
207
+ "(A) Test task +work @office due:2024-01-15 duration:2h"
265
208
  )
266
209
 
267
210
  def test_list_tasks(self):
@@ -269,7 +212,7 @@ class TestTodoManager(unittest.TestCase):
269
212
  self.todo_shell.list_tasks.return_value = "1. Task 1\n2. Task 2"
270
213
  result = self.todo_manager.list_tasks()
271
214
  self.assertEqual(result, "1. Task 1\n2. Task 2")
272
- self.todo_shell.list_tasks.assert_called_once_with(None)
215
+ self.todo_shell.list_tasks.assert_called_once_with(None, suppress_color=True)
273
216
 
274
217
  def test_complete_task(self):
275
218
  """Test completing a task."""
@@ -285,28 +228,36 @@ class TestTodoManager(unittest.TestCase):
285
228
  )
286
229
  result = self.todo_manager.list_completed_tasks()
287
230
  self.assertEqual(result, "1. Completed task 1\n2. Completed task 2")
288
- self.todo_shell.list_completed.assert_called_once_with(None)
231
+ self.todo_shell.list_completed.assert_called_once_with(
232
+ None, suppress_color=True
233
+ )
289
234
 
290
235
  def test_list_completed_tasks_with_project_filter(self):
291
236
  """Test listing completed tasks with project filter."""
292
237
  self.todo_shell.list_completed.return_value = "1. Work task completed"
293
238
  result = self.todo_manager.list_completed_tasks(project="work")
294
239
  self.assertEqual(result, "1. Work task completed")
295
- self.todo_shell.list_completed.assert_called_once_with("+work")
240
+ self.todo_shell.list_completed.assert_called_once_with(
241
+ "+work", suppress_color=True
242
+ )
296
243
 
297
244
  def test_list_completed_tasks_with_context_filter(self):
298
245
  """Test listing completed tasks with context filter."""
299
246
  self.todo_shell.list_completed.return_value = "1. Office task completed"
300
247
  result = self.todo_manager.list_completed_tasks(context="office")
301
248
  self.assertEqual(result, "1. Office task completed")
302
- self.todo_shell.list_completed.assert_called_once_with("@office")
249
+ self.todo_shell.list_completed.assert_called_once_with(
250
+ "@office", suppress_color=True
251
+ )
303
252
 
304
253
  def test_list_completed_tasks_with_text_search(self):
305
254
  """Test listing completed tasks with text search."""
306
255
  self.todo_shell.list_completed.return_value = "1. Review task completed"
307
256
  result = self.todo_manager.list_completed_tasks(text_search="review")
308
257
  self.assertEqual(result, "1. Review task completed")
309
- self.todo_shell.list_completed.assert_called_once_with("review")
258
+ self.todo_shell.list_completed.assert_called_once_with(
259
+ "review", suppress_color=True
260
+ )
310
261
 
311
262
  def test_list_completed_tasks_with_date_filters(self):
312
263
  """Test listing completed tasks with date filters."""
@@ -316,14 +267,18 @@ class TestTodoManager(unittest.TestCase):
316
267
  )
317
268
  self.assertEqual(result, "1. Task completed in date range")
318
269
  # With both date_from and date_to, we use the year-month pattern from date_from
319
- self.todo_shell.list_completed.assert_called_once_with("2025-08")
270
+ self.todo_shell.list_completed.assert_called_once_with(
271
+ "2025-08", suppress_color=True
272
+ )
320
273
 
321
274
  def test_list_completed_tasks_with_date_from_only(self):
322
275
  """Test listing completed tasks with only date_from filter."""
323
276
  self.todo_shell.list_completed.return_value = "1. Task completed from date"
324
277
  result = self.todo_manager.list_completed_tasks(date_from="2025-08-01")
325
278
  self.assertEqual(result, "1. Task completed from date")
326
- self.todo_shell.list_completed.assert_called_once_with("2025-08-01")
279
+ self.todo_shell.list_completed.assert_called_once_with(
280
+ "2025-08-01", suppress_color=True
281
+ )
327
282
 
328
283
  def test_list_completed_tasks_with_date_to_only(self):
329
284
  """Test listing completed tasks with only date_to filter."""
@@ -331,7 +286,9 @@ class TestTodoManager(unittest.TestCase):
331
286
  result = self.todo_manager.list_completed_tasks(date_to="2025-08-31")
332
287
  self.assertEqual(result, "1. Task completed in month")
333
288
  # With only date_to, we use the year-month pattern
334
- self.todo_shell.list_completed.assert_called_once_with("2025-08")
289
+ self.todo_shell.list_completed.assert_called_once_with(
290
+ "2025-08", suppress_color=True
291
+ )
335
292
 
336
293
  def test_list_completed_tasks_with_multiple_filters(self):
337
294
  """Test listing completed tasks with multiple filters."""
@@ -342,21 +299,27 @@ class TestTodoManager(unittest.TestCase):
342
299
  project="work", context="office", text_search="review"
343
300
  )
344
301
  self.assertEqual(result, "1. Work task from office completed")
345
- self.todo_shell.list_completed.assert_called_once_with("+work @office review")
302
+ self.todo_shell.list_completed.assert_called_once_with(
303
+ "+work @office review", suppress_color=True
304
+ )
346
305
 
347
306
  def test_list_completed_tasks_with_raw_filter(self):
348
307
  """Test listing completed tasks with raw filter string."""
349
308
  self.todo_shell.list_completed.return_value = "1. Custom filtered task"
350
309
  result = self.todo_manager.list_completed_tasks(filter="+urgent @home")
351
310
  self.assertEqual(result, "1. Custom filtered task")
352
- self.todo_shell.list_completed.assert_called_once_with("+urgent @home")
311
+ self.todo_shell.list_completed.assert_called_once_with(
312
+ "+urgent @home", suppress_color=True
313
+ )
353
314
 
354
315
  def test_list_completed_tasks_no_results(self):
355
316
  """Test listing completed tasks when no results found."""
356
317
  self.todo_shell.list_completed.return_value = ""
357
318
  result = self.todo_manager.list_completed_tasks(project="nonexistent")
358
319
  self.assertEqual(result, "No completed tasks found matching the criteria.")
359
- self.todo_shell.list_completed.assert_called_once_with("+nonexistent")
320
+ self.todo_shell.list_completed.assert_called_once_with(
321
+ "+nonexistent", suppress_color=True
322
+ )
360
323
 
361
324
  def test_add_task_sanitizes_inputs(self):
362
325
  """Test that add_task sanitizes inputs to prevent duplicates."""
@@ -421,7 +384,6 @@ class TestTodoManager(unittest.TestCase):
421
384
  "projects": ["+project1", "+project1", "+project2"], # Duplicate project1
422
385
  "contexts": ["@context1", "@context1", "@context2"], # Duplicate context1
423
386
  "due": "2024-01-01",
424
- "recurring": None,
425
387
  "other_tags": [],
426
388
  }
427
389
 
@@ -599,6 +561,111 @@ class TestTodoManager(unittest.TestCase):
599
561
  assert components["contexts"].count("@context1") == 1
600
562
  assert components["contexts"].count("@context2") == 1
601
563
 
564
+ def test_get_current_datetime(self):
565
+ """Test getting current date and time."""
566
+ with patch("todo_agent.core.todo_manager.datetime") as mock_datetime:
567
+ mock_now = Mock()
568
+ mock_now.strftime.side_effect = lambda fmt: {
569
+ "%Y-%m-%d %H:%M:%S": "2025-01-15 10:30:00",
570
+ "%A, %B %d, %Y at %I:%M %p": "Wednesday, January 15, 2025 at 10:30 AM",
571
+ }[fmt]
572
+ mock_now.isocalendar.return_value = (2025, 3, 3)
573
+ mock_now.astimezone.return_value.tzinfo = Mock()
574
+ mock_datetime.now.return_value = mock_now
575
+
576
+ result = self.todo_manager.get_current_datetime()
577
+ self.assertIn("2025-01-15 10:30:00", result)
578
+ self.assertIn("Week 3", result)
579
+
580
+ def test_created_completed_task_basic(self):
581
+ """Test creating and immediately completing a task."""
582
+ # Mock the todo_shell methods
583
+ self.todo_shell.add.return_value = "Task added"
584
+ self.todo_shell.list_tasks.return_value = "1 Test task\n2 Another task"
585
+ self.todo_shell.complete.return_value = "Task completed"
586
+
587
+ result = self.todo_manager.created_completed_task("Test task")
588
+
589
+ # Verify the task was added
590
+ self.todo_shell.add.assert_called_once_with("Test task")
591
+ # Verify the task was completed (should find task number 1 since it contains "Test task")
592
+ self.todo_shell.complete.assert_called_once_with(1)
593
+ # Verify the result message
594
+ self.assertIn("Created and completed task: Test task", result)
595
+
596
+ def test_created_completed_task_with_project_and_context(self):
597
+ """Test creating and completing a task with project and context."""
598
+ self.todo_shell.add.return_value = "Task added"
599
+ self.todo_shell.list_tasks.return_value = "1 Test task\n2 Another task"
600
+ self.todo_shell.complete.return_value = "Task completed"
601
+
602
+ result = self.todo_manager.created_completed_task(
603
+ "Test task", project="work", context="office"
604
+ )
605
+
606
+ # Verify the task was added with project and context
607
+ self.todo_shell.add.assert_called_once_with("Test task +work @office")
608
+ # Verify the task was completed (should find task number 1 since it contains "Test task")
609
+ self.todo_shell.complete.assert_called_once_with(1)
610
+ self.assertIn("+work @office", result)
611
+
612
+ def test_created_completed_task_with_custom_date(self):
613
+ """Test creating and completing a task with a custom completion date."""
614
+ self.todo_shell.add.return_value = "Task added"
615
+ self.todo_shell.list_tasks.return_value = "1 Test task"
616
+ self.todo_shell.complete.return_value = "Task completed"
617
+
618
+ result = self.todo_manager.created_completed_task(
619
+ "Test task", completion_date="2025-01-10"
620
+ )
621
+
622
+ # Verify the task was completed (should find task number 1 since it contains "Test task")
623
+ self.todo_shell.complete.assert_called_once_with(1)
624
+ self.assertIn("completed on 2025-01-10", result)
625
+
626
+ def test_created_completed_task_with_invalid_date(self):
627
+ """Test that invalid completion date raises ValueError."""
628
+ with self.assertRaises(ValueError) as context:
629
+ self.todo_manager.created_completed_task(
630
+ "Test task", completion_date="invalid-date"
631
+ )
632
+ self.assertIn("Invalid completion date format", str(context.exception))
633
+
634
+ def test_created_completed_task_sanitizes_project_and_context(self):
635
+ """Test that project and context with existing symbols are properly sanitized."""
636
+ self.todo_shell.add.return_value = "Task added"
637
+ self.todo_shell.list_tasks.return_value = "1 Test task"
638
+ self.todo_shell.complete.return_value = "Task completed"
639
+
640
+ result = self.todo_manager.created_completed_task(
641
+ "Test task", project="+work", context="@office"
642
+ )
643
+
644
+ # Verify the task was added with properly sanitized project and context
645
+ self.todo_shell.add.assert_called_once_with("Test task +work @office")
646
+ self.assertIn("+work @office", result)
647
+
648
+ def test_created_completed_task_with_empty_project_after_sanitization(self):
649
+ """Test that empty project after sanitization raises ValueError."""
650
+ with self.assertRaises(ValueError) as context:
651
+ self.todo_manager.created_completed_task("Test task", project="+")
652
+ self.assertIn("Project name cannot be empty", str(context.exception))
653
+
654
+ def test_created_completed_task_with_empty_context_after_sanitization(self):
655
+ """Test that empty context after sanitization raises ValueError."""
656
+ with self.assertRaises(ValueError) as context:
657
+ self.todo_manager.created_completed_task("Test task", context="@")
658
+ self.assertIn("Context name cannot be empty", str(context.exception))
659
+
660
+ def test_created_completed_task_no_tasks_after_addition(self):
661
+ """Test that RuntimeError is raised when no tasks are found after addition."""
662
+ self.todo_shell.add.return_value = "Task added"
663
+ self.todo_shell.list_tasks.return_value = "" # No tasks found
664
+
665
+ with self.assertRaises(RuntimeError) as context:
666
+ self.todo_manager.created_completed_task("Test task")
667
+ self.assertIn("Failed to add task", str(context.exception))
668
+
602
669
 
603
670
  if __name__ == "__main__":
604
671
  unittest.main()
@@ -17,7 +17,7 @@ class TestCalendarUtils:
17
17
 
18
18
  def test_get_calendar_output_success(self):
19
19
  """Test successful calendar output generation."""
20
- mock_output = "Calendar output from cal -3"
20
+ mock_output = "Calendar output from cal"
21
21
 
22
22
  with patch("subprocess.run") as mock_run:
23
23
  mock_result = Mock()
@@ -29,7 +29,7 @@ class TestCalendarUtils:
29
29
  assert result == mock_output
30
30
  assert len(result) > 0
31
31
  mock_run.assert_called_once_with(
32
- ["cal", "-3"], capture_output=True, text=True, check=True
32
+ ["cal"], capture_output=True, text=True, check=True
33
33
  )
34
34
 
35
35
  def test_get_calendar_output_fallback(self):
@@ -104,7 +104,9 @@ class TestInference:
104
104
  def test_system_prompt_datetime_interpolation(self):
105
105
  """Test that current datetime is properly interpolated into system prompt."""
106
106
  # Mock the file reading
107
- mock_prompt_content = "CURRENT DATE/TIME: {current_datetime}\n{tools_section}"
107
+ mock_prompt_content = (
108
+ "CURRENT DATE/TIME: {current_datetime}\nCALENDAR: {calendar_output}"
109
+ )
108
110
 
109
111
  with patch("builtins.open", mock_open(read_data=mock_prompt_content)):
110
112
  # Call the method that loads the system prompt
@@ -122,9 +124,9 @@ class TestInference:
122
124
  "System prompt should contain properly formatted datetime"
123
125
  )
124
126
 
125
- # Verify that tools_section is also interpolated
126
- assert "{tools_section}" not in result
127
- assert "list_tasks" in result # Should contain tool information
127
+ # Verify that calendar_output is also interpolated
128
+ assert "{calendar_output}" not in result
129
+ assert "September" in result # Should contain calendar month information
128
130
 
129
131
  def test_process_request_with_tool_calls(self):
130
132
  """Test request processing with tool calls."""
@@ -0,0 +1,198 @@
1
+ """
2
+ Tests for OllamaClient.
3
+ """
4
+
5
+ import json
6
+ from unittest.mock import Mock, mock_open, patch
7
+
8
+ import pytest
9
+
10
+ from todo_agent.infrastructure.config import Config
11
+ from todo_agent.infrastructure.ollama_client import OllamaClient
12
+
13
+
14
+ class TestOllamaClient:
15
+ """Test OllamaClient functionality."""
16
+
17
+ def setup_method(self):
18
+ """Set up test fixtures."""
19
+ self.config = Config()
20
+ self.config.provider = "ollama"
21
+ self.config.ollama_base_url = "http://localhost:11434"
22
+ self.config.ollama_model = "llama3.2"
23
+
24
+ # Patch the dependencies that are now initialized in the parent class
25
+ with patch("todo_agent.infrastructure.llm_client.Logger") as mock_logger, patch(
26
+ "todo_agent.infrastructure.llm_client.get_token_counter"
27
+ ) as mock_token_counter:
28
+ mock_logger.return_value = Mock()
29
+ mock_token_counter.return_value = Mock()
30
+
31
+ self.client = OllamaClient(self.config)
32
+
33
+ def test_initialization(self):
34
+ """Test client initialization."""
35
+ assert self.client.config == self.config
36
+ assert self.client.base_url == "http://localhost:11434"
37
+ assert self.client.model == "llama3.2"
38
+ assert self.client.logger is not None
39
+ assert self.client.token_counter is not None
40
+
41
+ def test_get_model_name(self):
42
+ """Test getting model name."""
43
+ assert self.client.get_model_name() == "llama3.2"
44
+
45
+ def test_get_provider_name(self):
46
+ """Test getting provider name."""
47
+ assert self.client.get_provider_name() == "ollama"
48
+
49
+ def test_get_request_timeout(self):
50
+ """Test getting request timeout."""
51
+ assert self.client.get_request_timeout() == 120 # 2 minutes for Ollama
52
+
53
+ def test_get_request_headers(self):
54
+ """Test request headers generation."""
55
+ headers = self.client._get_request_headers()
56
+ assert headers == {"Content-Type": "application/json"}
57
+
58
+ def test_get_request_payload(self):
59
+ """Test request payload generation."""
60
+ messages = [{"role": "user", "content": "Hello"}]
61
+ tools = [{"name": "test_tool", "description": "A test tool"}]
62
+
63
+ payload = self.client._get_request_payload(messages, tools)
64
+ expected = {
65
+ "model": "llama3.2",
66
+ "messages": messages,
67
+ "tools": tools,
68
+ "stream": False,
69
+ }
70
+ assert payload == expected
71
+
72
+ def test_get_api_endpoint(self):
73
+ """Test API endpoint generation."""
74
+ endpoint = self.client._get_api_endpoint()
75
+ assert endpoint == "http://localhost:11434/api/chat"
76
+
77
+ @patch("todo_agent.infrastructure.ollama_client.OllamaClient._make_http_request")
78
+ def test_chat_with_tools_success(self, mock_make_request):
79
+ """Test successful chat with tools."""
80
+ # Mock the common HTTP request method
81
+ expected_response = {
82
+ "message": {"content": "Here are your tasks", "tool_calls": []}
83
+ }
84
+ mock_make_request.return_value = expected_response
85
+
86
+ messages = [{"role": "user", "content": "List my tasks"}]
87
+ tools = [{"function": {"name": "list_tasks", "description": "List tasks"}}]
88
+
89
+ response = self.client.chat_with_tools(messages, tools)
90
+
91
+ assert response == expected_response
92
+ mock_make_request.assert_called_once_with(messages, tools)
93
+
94
+ @patch("todo_agent.infrastructure.ollama_client.OllamaClient._make_http_request")
95
+ def test_chat_with_tools_api_error(self, mock_make_request):
96
+ """Test chat with tools when API returns error."""
97
+ # Mock error response from the common method
98
+ error_response = {
99
+ "error": True,
100
+ "error_type": "general_error",
101
+ "provider": "ollama",
102
+ "status_code": 500,
103
+ "raw_error": "Internal Server Error",
104
+ }
105
+ mock_make_request.return_value = error_response
106
+
107
+ messages = [{"role": "user", "content": "List my tasks"}]
108
+ tools = [{"function": {"name": "list_tasks", "description": "List tasks"}}]
109
+
110
+ response = self.client.chat_with_tools(messages, tools)
111
+ assert response == error_response
112
+
113
+ def test_extract_tool_calls_with_tools(self):
114
+ """Test extracting tool calls from response with tools."""
115
+ response = {
116
+ "message": {
117
+ "content": "I'll help you with that",
118
+ "tool_calls": [
119
+ {
120
+ "id": "call_1",
121
+ "function": {"name": "list_tasks", "arguments": "{}"},
122
+ }
123
+ ],
124
+ }
125
+ }
126
+
127
+ tool_calls = self.client.extract_tool_calls(response)
128
+ assert len(tool_calls) == 1
129
+ assert tool_calls[0]["id"] == "call_1"
130
+ assert tool_calls[0]["function"]["name"] == "list_tasks"
131
+
132
+ def test_extract_tool_calls_without_tools(self):
133
+ """Test extracting tool calls from response without tools."""
134
+ response = {
135
+ "message": {
136
+ "content": "I'll help you with that",
137
+ }
138
+ }
139
+
140
+ tool_calls = self.client.extract_tool_calls(response)
141
+ assert len(tool_calls) == 0
142
+
143
+ def test_extract_tool_calls_with_error_response(self):
144
+ """Test extracting tool calls from error response."""
145
+ error_response = {"error": True, "error_type": "timeout", "provider": "ollama"}
146
+
147
+ tool_calls = self.client.extract_tool_calls(error_response)
148
+ assert len(tool_calls) == 0
149
+
150
+ def test_extract_content_with_content(self):
151
+ """Test extracting content from response with content."""
152
+ response = {
153
+ "message": {
154
+ "content": "Here are your tasks",
155
+ }
156
+ }
157
+
158
+ content = self.client.extract_content(response)
159
+ assert content == "Here are your tasks"
160
+
161
+ def test_extract_content_without_content(self):
162
+ """Test extracting content from response without content."""
163
+ response = {"message": {}}
164
+
165
+ content = self.client.extract_content(response)
166
+ assert content == ""
167
+
168
+ def test_extract_content_empty_response(self):
169
+ """Test extracting content from empty response."""
170
+ response = {}
171
+
172
+ content = self.client.extract_content(response)
173
+ assert content == ""
174
+
175
+ def test_extract_content_with_error_response(self):
176
+ """Test extracting content from error response."""
177
+ error_response = {"error": True, "error_type": "timeout", "provider": "ollama"}
178
+
179
+ content = self.client.extract_content(error_response)
180
+ assert content == ""
181
+
182
+ def test_process_response(self):
183
+ """Test response processing and logging."""
184
+ response_data = {
185
+ "message": {
186
+ "content": "Hello",
187
+ "tool_calls": [{"id": "call_1", "function": {"name": "test_tool"}}],
188
+ }
189
+ }
190
+
191
+ with patch.object(self.client.logger, "info") as mock_info, patch.object(
192
+ self.client.logger, "debug"
193
+ ) as mock_debug:
194
+ self.client._process_response(response_data, 0.0)
195
+
196
+ # Should log response details
197
+ assert mock_info.call_count >= 1
198
+ assert mock_debug.call_count >= 1