ara-cli 0.1.10.0__py3-none-any.whl → 0.1.13.3__py3-none-any.whl

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 (140) hide show
  1. ara_cli/__init__.py +51 -6
  2. ara_cli/__main__.py +270 -103
  3. ara_cli/ara_command_action.py +106 -63
  4. ara_cli/ara_config.py +187 -128
  5. ara_cli/ara_subcommands/__init__.py +0 -0
  6. ara_cli/ara_subcommands/autofix.py +26 -0
  7. ara_cli/ara_subcommands/chat.py +27 -0
  8. ara_cli/ara_subcommands/classifier_directory.py +16 -0
  9. ara_cli/ara_subcommands/common.py +100 -0
  10. ara_cli/ara_subcommands/config.py +221 -0
  11. ara_cli/ara_subcommands/convert.py +43 -0
  12. ara_cli/ara_subcommands/create.py +75 -0
  13. ara_cli/ara_subcommands/delete.py +22 -0
  14. ara_cli/ara_subcommands/extract.py +22 -0
  15. ara_cli/ara_subcommands/fetch.py +41 -0
  16. ara_cli/ara_subcommands/fetch_agents.py +22 -0
  17. ara_cli/ara_subcommands/fetch_scripts.py +19 -0
  18. ara_cli/ara_subcommands/fetch_templates.py +19 -0
  19. ara_cli/ara_subcommands/list.py +139 -0
  20. ara_cli/ara_subcommands/list_tags.py +25 -0
  21. ara_cli/ara_subcommands/load.py +48 -0
  22. ara_cli/ara_subcommands/prompt.py +136 -0
  23. ara_cli/ara_subcommands/read.py +47 -0
  24. ara_cli/ara_subcommands/read_status.py +20 -0
  25. ara_cli/ara_subcommands/read_user.py +20 -0
  26. ara_cli/ara_subcommands/reconnect.py +27 -0
  27. ara_cli/ara_subcommands/rename.py +22 -0
  28. ara_cli/ara_subcommands/scan.py +14 -0
  29. ara_cli/ara_subcommands/set_status.py +22 -0
  30. ara_cli/ara_subcommands/set_user.py +22 -0
  31. ara_cli/ara_subcommands/template.py +16 -0
  32. ara_cli/artefact_autofix.py +154 -63
  33. ara_cli/artefact_converter.py +256 -0
  34. ara_cli/artefact_models/artefact_model.py +106 -25
  35. ara_cli/artefact_models/artefact_templates.py +20 -10
  36. ara_cli/artefact_models/epic_artefact_model.py +11 -2
  37. ara_cli/artefact_models/feature_artefact_model.py +31 -1
  38. ara_cli/artefact_models/userstory_artefact_model.py +15 -3
  39. ara_cli/artefact_scan.py +2 -2
  40. ara_cli/chat.py +283 -80
  41. ara_cli/chat_agent/__init__.py +0 -0
  42. ara_cli/chat_agent/agent_process_manager.py +155 -0
  43. ara_cli/chat_script_runner/__init__.py +0 -0
  44. ara_cli/chat_script_runner/script_completer.py +23 -0
  45. ara_cli/chat_script_runner/script_finder.py +41 -0
  46. ara_cli/chat_script_runner/script_lister.py +36 -0
  47. ara_cli/chat_script_runner/script_runner.py +36 -0
  48. ara_cli/chat_web_search/__init__.py +0 -0
  49. ara_cli/chat_web_search/web_search.py +263 -0
  50. ara_cli/commands/agent_run_command.py +98 -0
  51. ara_cli/commands/fetch_agents_command.py +106 -0
  52. ara_cli/commands/fetch_scripts_command.py +43 -0
  53. ara_cli/commands/fetch_templates_command.py +39 -0
  54. ara_cli/commands/fetch_templates_commands.py +39 -0
  55. ara_cli/commands/list_agents_command.py +39 -0
  56. ara_cli/commands/read_command.py +17 -4
  57. ara_cli/completers.py +180 -0
  58. ara_cli/constants.py +2 -0
  59. ara_cli/directory_navigator.py +37 -4
  60. ara_cli/file_loaders/text_file_loader.py +2 -2
  61. ara_cli/global_file_lister.py +5 -15
  62. ara_cli/llm_utils.py +58 -0
  63. ara_cli/prompt_chat.py +20 -4
  64. ara_cli/prompt_extractor.py +199 -76
  65. ara_cli/prompt_handler.py +160 -59
  66. ara_cli/tag_extractor.py +38 -18
  67. ara_cli/template_loader.py +3 -2
  68. ara_cli/template_manager.py +52 -21
  69. ara_cli/templates/global-scripts/hello_global.py +1 -0
  70. ara_cli/templates/prompt-modules/commands/add_scenarios_for_new_behaviour.feature_creation_agent.commands.md +1 -0
  71. ara_cli/templates/prompt-modules/commands/align_feature_with_implementation_changes.interview_agent.commands.md +1 -0
  72. ara_cli/templates/prompt-modules/commands/analyze_codebase_and_plan_tasks.interview_agent.commands.md +1 -0
  73. ara_cli/templates/prompt-modules/commands/choose_best_parent_artefact.interview_agent.commands.md +1 -0
  74. ara_cli/templates/prompt-modules/commands/create_tasks_from_artefact_content.interview_agent.commands.md +1 -0
  75. ara_cli/templates/prompt-modules/commands/create_tests_for_uncovered_modules.test_generation_agent.commands.md +1 -0
  76. ara_cli/templates/prompt-modules/commands/derive_features_from_video_description.feature_creation_agent.commands.md +1 -0
  77. ara_cli/templates/prompt-modules/commands/describe_agent_capabilities.agent.commands.md +1 -0
  78. ara_cli/templates/prompt-modules/commands/empty.commands.md +2 -12
  79. ara_cli/templates/prompt-modules/commands/execute_scoped_todos_in_task.interview_agent.commands.md +1 -0
  80. ara_cli/templates/prompt-modules/commands/explain_single_file_purpose.interview_agent.commands.md +1 -0
  81. ara_cli/templates/prompt-modules/commands/extract_file_information_bullets.interview_agent.commands.md +1 -0
  82. ara_cli/templates/prompt-modules/commands/extract_general.commands.md +12 -0
  83. ara_cli/templates/prompt-modules/commands/extract_markdown.commands.md +11 -0
  84. ara_cli/templates/prompt-modules/commands/extract_python.commands.md +13 -0
  85. ara_cli/templates/prompt-modules/commands/feature_add_or_modifiy_specified_behavior.commands.md +36 -0
  86. ara_cli/templates/prompt-modules/commands/feature_generate_initial_specified_bevahior.commands.md +53 -0
  87. ara_cli/templates/prompt-modules/commands/fix_failing_behave_step_definitions.interview_agent.commands.md +1 -0
  88. ara_cli/templates/prompt-modules/commands/fix_failing_pytest_tests.interview_agent.commands.md +1 -0
  89. ara_cli/templates/prompt-modules/commands/general_instruction_policy.commands.md +47 -0
  90. ara_cli/templates/prompt-modules/commands/generate_and_fix_pytest_tests.test_generation_agent.commands.md +1 -0
  91. ara_cli/templates/prompt-modules/commands/prompt_template_tech_stack_transformer.commands.md +95 -0
  92. ara_cli/templates/prompt-modules/commands/python_bug_fixing_code.commands.md +34 -0
  93. ara_cli/templates/prompt-modules/commands/python_generate_code.commands.md +27 -0
  94. ara_cli/templates/prompt-modules/commands/python_refactoring_code.commands.md +39 -0
  95. ara_cli/templates/prompt-modules/commands/python_step_definitions_generation_and_fixing.commands.md +40 -0
  96. ara_cli/templates/prompt-modules/commands/python_unittest_generation_and_fixing.commands.md +48 -0
  97. ara_cli/templates/prompt-modules/commands/suggest_next_story_child_tasks.interview_agent.commands.md +1 -0
  98. ara_cli/templates/prompt-modules/commands/summarize_or_transcribe_media.interview_agent.commands.md +1 -0
  99. ara_cli/templates/prompt-modules/commands/update_feature_to_match_implementation.feature_creation_agent.commands.md +1 -0
  100. ara_cli/templates/prompt-modules/commands/update_user_story_with_requirements.interview_agent.commands.md +1 -0
  101. ara_cli/version.py +1 -1
  102. {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/METADATA +34 -1
  103. {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/RECORD +123 -54
  104. tests/test_ara_command_action.py +31 -19
  105. tests/test_ara_config.py +177 -90
  106. tests/test_artefact_autofix.py +170 -97
  107. tests/test_artefact_autofix_integration.py +495 -0
  108. tests/test_artefact_converter.py +357 -0
  109. tests/test_artefact_extraction.py +564 -0
  110. tests/test_artefact_scan.py +1 -1
  111. tests/test_chat.py +162 -126
  112. tests/test_chat_givens_images.py +603 -0
  113. tests/test_chat_script_runner.py +454 -0
  114. tests/test_global_file_lister.py +1 -1
  115. tests/test_llm_utils.py +164 -0
  116. tests/test_prompt_chat.py +343 -0
  117. tests/test_prompt_extractor.py +683 -0
  118. tests/test_prompt_handler.py +12 -4
  119. tests/test_tag_extractor.py +19 -13
  120. tests/test_web_search.py +467 -0
  121. ara_cli/ara_command_parser.py +0 -605
  122. ara_cli/templates/prompt-modules/blueprints/complete_pytest_unittest.blueprint.md +0 -27
  123. ara_cli/templates/prompt-modules/blueprints/task_todo_list_implement_feature_BDD_way.blueprint.md +0 -30
  124. ara_cli/templates/prompt-modules/commands/artefact_classification.commands.md +0 -9
  125. ara_cli/templates/prompt-modules/commands/artefact_extension.commands.md +0 -17
  126. ara_cli/templates/prompt-modules/commands/artefact_formulation.commands.md +0 -14
  127. ara_cli/templates/prompt-modules/commands/behave_step_generation.commands.md +0 -102
  128. ara_cli/templates/prompt-modules/commands/code_generation_complex.commands.md +0 -20
  129. ara_cli/templates/prompt-modules/commands/code_generation_simple.commands.md +0 -13
  130. ara_cli/templates/prompt-modules/commands/error_fixing.commands.md +0 -20
  131. ara_cli/templates/prompt-modules/commands/feature_file_update.commands.md +0 -18
  132. ara_cli/templates/prompt-modules/commands/feature_formulation.commands.md +0 -43
  133. ara_cli/templates/prompt-modules/commands/js_code_generation_simple.commands.md +0 -13
  134. ara_cli/templates/prompt-modules/commands/refactoring.commands.md +0 -15
  135. ara_cli/templates/prompt-modules/commands/refactoring_analysis.commands.md +0 -9
  136. ara_cli/templates/prompt-modules/commands/reverse_engineer_feature_file.commands.md +0 -15
  137. ara_cli/templates/prompt-modules/commands/reverse_engineer_program_flow.commands.md +0 -19
  138. {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/WHEEL +0 -0
  139. {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/entry_points.txt +0 -0
  140. {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/top_level.txt +0 -0
@@ -698,16 +698,24 @@ class TestArtefactAndTemplateHandling:
698
698
 
699
699
  mock_collect.return_value = ("### GIVENS\ncontent", [{"type": "image_url"}])
700
700
 
701
- final_message_list = [{'role': 'user', 'content': ['### GIVENS\ncontent', {'type': 'image_url'}]}]
702
- mock_append_images.return_value = final_message_list
701
+ # append_images_to_message returns a single dict, not a list of dicts.
702
+ returned_message_dict = {'role': 'user', 'content': ['### GIVENS\ncontent', {'type': 'image_url'}]}
703
+ mock_append_images.return_value = returned_message_dict
703
704
 
704
705
  mock_send.return_value = iter([MagicMock(choices=[MagicMock(delta=MagicMock(content="llm response"))])])
705
706
 
706
707
  prompt_handler.create_and_send_custom_prompt(self.mock_classifier, self.mock_param)
707
708
 
708
709
  mock_collect.assert_called_once()
709
- mock_append_images.assert_called_once_with([{'role': 'user', 'content': '### GIVENS\ncontent'}], [{'type': 'image_url'}])
710
- mock_send.assert_called_once_with(final_message_list)
710
+
711
+ # Assert that append_images_to_message was called with a single dict (the bug fix)
712
+ mock_append_images.assert_called_once_with(
713
+ {'role': 'user', 'content': '### GIVENS\ncontent'},
714
+ [{'type': 'image_url'}]
715
+ )
716
+
717
+ # Assert that send_prompt was called with a list containing the dict returned from append_images_to_message
718
+ mock_send.assert_called_once_with([returned_message_dict])
711
719
 
712
720
  log_file = self.root / "ara" / self.mock_classifier / f"{self.mock_param}.data" / f"{self.mock_classifier}.prompt_log.md"
713
721
  assert "llm response" in log_file.read_text()
@@ -8,10 +8,11 @@ from ara_cli.list_filter import ListFilter
8
8
  def artefact():
9
9
  """Fixture to create a mock artefact object."""
10
10
  class Artefact:
11
- def __init__(self, tags, status, users, path="dummy.md", content=""):
11
+ def __init__(self, tags, status, users, author="creator_unknown", path="dummy.md", content=""):
12
12
  self.tags = tags
13
13
  self.status = status
14
14
  self.users = users
15
+ self.author = author
15
16
  self.path = path
16
17
  self.content = content
17
18
  return Artefact
@@ -21,33 +22,33 @@ def artefact():
21
22
  (
22
23
  False, False, None,
23
24
  {'artefacts': [
24
- (['tag1', 'tag2'], 'in-progress', ['user1']),
25
- (['tag3'], 'done', ['user2'])
25
+ (['tag1', 'tag2'], 'in-progress', ['user1'], "creator_unknown"),
26
+ (['tag3'], 'done', ['user2'], "creator_unknown")
26
27
  ]},
27
- ['done', 'in-progress', 'tag1', 'tag2', 'tag3', 'user_user1', 'user_user2']
28
+ ['creator_unknown', 'done', 'in-progress', 'tag1', 'tag2', 'tag3', 'user_user1', 'user_user2']
28
29
  ),
29
30
  (
30
31
  False, True, None,
31
32
  {'artefacts': [
32
- (['project_a', 'priority_high'], None, ['user1']),
33
- (['feature_x'], 'done', ['user2'])
33
+ (['project_a', 'priority_high'], None, ['user1'], "creator_unknown"),
34
+ (['feature_x'], 'done', ['user2'], "creator_unknown")
34
35
  ]},
35
36
  ['project_a']
36
37
  ),
37
38
  (
38
- False, False, ListFilter(include_tags=['@kritik']),
39
+ False, False, ListFilter(include_tags=['kritik']),
39
40
  {'artefacts': [
40
- (['release', 'kritik'], 'review', ['dev1']),
41
- (['bugfix'], 'to-do', ['dev2'])
41
+ (['release', 'kritik'], 'review', ['dev1'], "creator_unknown"),
42
+ (['bugfix'], 'to-do', ['dev2'], "creator_unknown")
42
43
  ]},
43
- ['kritik', 'release', 'review', 'user_dev1']
44
+ ['creator_unknown', 'kritik', 'release', 'review', 'user_dev1']
44
45
  ),
45
46
  (
46
47
  True, False, None,
47
48
  {'artefacts': [
48
- (['tag3'], 'status2', ['user3'])
49
+ (['tag3'], 'status2', ['user3'], "creator_unknown")
49
50
  ]},
50
- ['status2', 'tag3', 'user_user3']
51
+ ['creator_unknown', 'status2', 'tag3', 'user_user3']
51
52
  ),
52
53
  (
53
54
  False, False, None,
@@ -80,4 +81,9 @@ def test_extract_tags(mock_directory_navigator, mock_artefact_reader, artefact,
80
81
 
81
82
  mock_artefact_reader.read_artefacts.assert_called_once()
82
83
 
83
- assert sorted(result) == sorted(expected_tags)
84
+ # Convert dictionary result to flat list for comparison
85
+ actual_tags = []
86
+ for group in result.values():
87
+ actual_tags.extend(group)
88
+
89
+ assert sorted(actual_tags) == sorted(expected_tags)
@@ -0,0 +1,467 @@
1
+ """
2
+ Unit tests for chat_web_search/web_search.py
3
+
4
+ Provides full test coverage for web search functionality.
5
+ """
6
+
7
+ import pytest
8
+ from unittest.mock import patch, MagicMock
9
+ from ara_cli.chat_web_search.web_search import (
10
+ is_web_search_supported,
11
+ get_supported_models_message,
12
+ _get_raw_model_name,
13
+ _deduplicate_citations,
14
+ _format_citations,
15
+ _create_chunk,
16
+ _extract_openai_citations,
17
+ _extract_anthropic_text_citations,
18
+ _extract_anthropic_search_results,
19
+ perform_openai_web_search,
20
+ perform_anthropic_web_search,
21
+ perform_web_search_completion,
22
+ OPENAI_WEB_SEARCH_MODELS,
23
+ ANTHROPIC_WEB_SEARCH_MODELS,
24
+ )
25
+ from ara_cli.error_handler import AraError
26
+
27
+
28
+ # =============================================================================
29
+ # Tests for is_web_search_supported
30
+ # =============================================================================
31
+
32
+
33
+ class TestIsWebSearchSupported:
34
+ """Tests for is_web_search_supported function."""
35
+
36
+ @pytest.mark.parametrize(
37
+ "model",
38
+ [
39
+ "gpt-5",
40
+ "gpt-5.1",
41
+ "o3",
42
+ "o4-mini",
43
+ "openai/gpt-5",
44
+ "openai/o4-mini",
45
+ "gpt-5-search-api",
46
+ "gpt-4o-search-preview",
47
+ ],
48
+ )
49
+ def test_openai_models_supported(self, model):
50
+ """OpenAI web search models are supported."""
51
+ supported, provider = is_web_search_supported(model)
52
+ assert supported is True
53
+ assert provider == "openai"
54
+
55
+ @pytest.mark.parametrize(
56
+ "model",
57
+ [
58
+ "claude-sonnet-4-5-20250929",
59
+ "claude-sonnet-4-20250514",
60
+ "anthropic/claude-sonnet-4-5-20250929",
61
+ "claude-haiku-4-5-20251001",
62
+ "claude-opus-4-20250514",
63
+ ],
64
+ )
65
+ def test_anthropic_models_supported(self, model):
66
+ """Anthropic web search models are supported."""
67
+ supported, provider = is_web_search_supported(model)
68
+ assert supported is True
69
+ assert provider == "anthropic"
70
+
71
+ def test_unsupported_model_returns_false(self):
72
+ """Unsupported models return False."""
73
+ supported, provider = is_web_search_supported("gpt-4o")
74
+ assert supported is False
75
+ assert provider is None
76
+
77
+
78
+ # =============================================================================
79
+ # Tests for get_supported_models_message
80
+ # =============================================================================
81
+
82
+
83
+ class TestGetSupportedModelsMessage:
84
+ """Tests for get_supported_models_message function."""
85
+
86
+ def test_includes_model_name(self):
87
+ """Message includes the unsupported model name."""
88
+ result = get_supported_models_message("unsupported-model")
89
+ assert "unsupported-model" in result
90
+
91
+ def test_lists_openai_models(self):
92
+ """Message lists OpenAI models."""
93
+ result = get_supported_models_message("test")
94
+ assert "gpt-5" in result
95
+ assert "OpenAI" in result
96
+
97
+ def test_lists_anthropic_models(self):
98
+ """Message lists Anthropic models."""
99
+ result = get_supported_models_message("test")
100
+ assert "claude" in result
101
+ assert "Anthropic" in result
102
+
103
+
104
+ # =============================================================================
105
+ # Tests for _get_raw_model_name
106
+ # =============================================================================
107
+
108
+
109
+ class TestGetRawModelName:
110
+ """Tests for _get_raw_model_name function."""
111
+
112
+ def test_strips_openai_prefix(self):
113
+ """Strips openai/ prefix."""
114
+ result = _get_raw_model_name("openai/gpt-5")
115
+ assert result == "gpt-5"
116
+
117
+ def test_strips_anthropic_prefix(self):
118
+ """Strips anthropic/ prefix."""
119
+ result = _get_raw_model_name("anthropic/claude-sonnet-4-20250514")
120
+ assert result == "claude-sonnet-4-20250514"
121
+
122
+ def test_returns_unchanged_without_prefix(self):
123
+ """Returns model unchanged when no prefix."""
124
+ result = _get_raw_model_name("gpt-5")
125
+ assert result == "gpt-5"
126
+
127
+
128
+ # =============================================================================
129
+ # Tests for _deduplicate_citations
130
+ # =============================================================================
131
+
132
+
133
+ class TestDeduplicateCitations:
134
+ """Tests for _deduplicate_citations function."""
135
+
136
+ def test_removes_duplicate_urls(self):
137
+ """Removes citations with duplicate URLs."""
138
+ citations = [
139
+ {"title": "First", "url": "https://example.com"},
140
+ {"title": "Second", "url": "https://example.com"},
141
+ {"title": "Third", "url": "https://other.com"},
142
+ ]
143
+ result = _deduplicate_citations(citations)
144
+ assert len(result) == 2
145
+ assert result[0]["title"] == "First"
146
+ assert result[1]["title"] == "Third"
147
+
148
+ def test_preserves_order(self):
149
+ """Preserves order of first occurrences."""
150
+ citations = [
151
+ {"title": "A", "url": "https://a.com"},
152
+ {"title": "B", "url": "https://b.com"},
153
+ {"title": "C", "url": "https://c.com"},
154
+ ]
155
+ result = _deduplicate_citations(citations)
156
+ assert [c["title"] for c in result] == ["A", "B", "C"]
157
+
158
+ def test_handles_empty_list(self):
159
+ """Handles empty citation list."""
160
+ result = _deduplicate_citations([])
161
+ assert result == []
162
+
163
+ def test_skips_empty_urls(self):
164
+ """Skips citations with empty URLs."""
165
+ citations = [
166
+ {"title": "First", "url": ""},
167
+ {"title": "Second", "url": "https://example.com"},
168
+ ]
169
+ result = _deduplicate_citations(citations)
170
+ assert len(result) == 1
171
+ assert result[0]["title"] == "Second"
172
+
173
+
174
+ # =============================================================================
175
+ # Tests for _format_citations
176
+ # =============================================================================
177
+
178
+
179
+ class TestFormatCitations:
180
+ """Tests for _format_citations function."""
181
+
182
+ def test_formats_markdown_links(self):
183
+ """Formats citations as markdown links."""
184
+ citations = [
185
+ {"title": "Example Site", "url": "https://example.com"},
186
+ ]
187
+ result = _format_citations(citations)
188
+ assert "[Example Site](https://example.com)" in result
189
+
190
+ def test_includes_sources_header(self):
191
+ """Includes Sources header."""
192
+ citations = [{"title": "Test", "url": "https://test.com"}]
193
+ result = _format_citations(citations)
194
+ assert "**Sources:**" in result
195
+
196
+ def test_numbers_citations(self):
197
+ """Numbers each citation."""
198
+ citations = [
199
+ {"title": "First", "url": "https://first.com"},
200
+ {"title": "Second", "url": "https://second.com"},
201
+ ]
202
+ result = _format_citations(citations)
203
+ assert "1. [First]" in result
204
+ assert "2. [Second]" in result
205
+
206
+ def test_returns_empty_for_no_citations(self):
207
+ """Returns empty string when no citations."""
208
+ result = _format_citations([])
209
+ assert result == ""
210
+
211
+ def test_handles_missing_url(self):
212
+ """Handles citations without URL."""
213
+ citations = [{"title": "No URL", "url": ""}]
214
+ result = _format_citations(citations)
215
+ assert result == "" # Empty URL is filtered by deduplicate
216
+
217
+
218
+ # =============================================================================
219
+ # Tests for _create_chunk
220
+ # =============================================================================
221
+
222
+
223
+ class TestCreateChunk:
224
+ """Tests for _create_chunk function."""
225
+
226
+ def test_creates_mock_chunk_with_content(self):
227
+ """Creates mock chunk with content accessible via choices."""
228
+ chunk = _create_chunk("test content")
229
+ assert chunk.choices[0].delta.content == "test content"
230
+
231
+
232
+ # =============================================================================
233
+ # Tests for _extract_openai_citations
234
+ # =============================================================================
235
+
236
+
237
+ class TestExtractOpenaiCitations:
238
+ """Tests for _extract_openai_citations function."""
239
+
240
+ def test_extracts_url_citations(self):
241
+ """Extracts URL citations from response."""
242
+ mock_annotation = MagicMock()
243
+ mock_annotation.type = "url_citation"
244
+ mock_annotation.title = "Test Title"
245
+ mock_annotation.url = "https://test.com"
246
+
247
+ mock_content_item = MagicMock()
248
+ mock_content_item.annotations = [mock_annotation]
249
+
250
+ mock_output_item = MagicMock()
251
+ mock_output_item.type = "message"
252
+ mock_output_item.content = [mock_content_item]
253
+
254
+ mock_response = MagicMock()
255
+ mock_response.output = [mock_output_item]
256
+
257
+ result = _extract_openai_citations(mock_response)
258
+
259
+ assert len(result) == 1
260
+ assert result[0]["title"] == "Test Title"
261
+ assert result[0]["url"] == "https://test.com"
262
+
263
+ def test_returns_empty_for_no_output(self):
264
+ """Returns empty list when no output."""
265
+ mock_response = MagicMock()
266
+ mock_response.output = None
267
+
268
+ result = _extract_openai_citations(mock_response)
269
+
270
+ assert result == []
271
+
272
+
273
+ # =============================================================================
274
+ # Tests for _extract_anthropic_text_citations
275
+ # =============================================================================
276
+
277
+
278
+ class TestExtractAnthropicTextCitations:
279
+ """Tests for _extract_anthropic_text_citations function."""
280
+
281
+ def test_extracts_citations_with_url(self):
282
+ """Extracts citations that have URL attribute."""
283
+ mock_citation = MagicMock()
284
+ mock_citation.url = "https://example.com"
285
+ mock_citation.title = "Example"
286
+
287
+ mock_content_block = MagicMock()
288
+ mock_content_block.citations = [mock_citation]
289
+
290
+ result = _extract_anthropic_text_citations(mock_content_block)
291
+
292
+ assert len(result) == 1
293
+ assert result[0]["url"] == "https://example.com"
294
+
295
+ def test_returns_empty_for_no_citations(self):
296
+ """Returns empty list when no citations."""
297
+ mock_content_block = MagicMock()
298
+ mock_content_block.citations = None
299
+
300
+ result = _extract_anthropic_text_citations(mock_content_block)
301
+
302
+ assert result == []
303
+
304
+
305
+ # =============================================================================
306
+ # Tests for _extract_anthropic_search_results
307
+ # =============================================================================
308
+
309
+
310
+ class TestExtractAnthropicSearchResults:
311
+ """Tests for _extract_anthropic_search_results function."""
312
+
313
+ def test_extracts_web_search_results(self):
314
+ """Extracts web search result citations."""
315
+ mock_result = MagicMock()
316
+ mock_result.type = "web_search_result"
317
+ mock_result.title = "Search Result"
318
+ mock_result.url = "https://search.com"
319
+
320
+ mock_content_block = MagicMock()
321
+ mock_content_block.content = [mock_result]
322
+
323
+ result = _extract_anthropic_search_results(mock_content_block)
324
+
325
+ assert len(result) == 1
326
+ assert result[0]["title"] == "Search Result"
327
+ assert result[0]["url"] == "https://search.com"
328
+
329
+ def test_skips_non_search_results(self):
330
+ """Skips items that aren't web_search_result type."""
331
+ mock_result = MagicMock()
332
+ mock_result.type = "other_type"
333
+
334
+ mock_content_block = MagicMock()
335
+ mock_content_block.content = [mock_result]
336
+
337
+ result = _extract_anthropic_search_results(mock_content_block)
338
+
339
+ assert result == []
340
+
341
+
342
+ # =============================================================================
343
+ # Tests for perform_openai_web_search
344
+ # =============================================================================
345
+
346
+
347
+ class TestPerformOpenaiWebSearch:
348
+ """Tests for perform_openai_web_search function."""
349
+
350
+ @patch("openai.OpenAI")
351
+ @patch("os.getenv", return_value="test-api-key")
352
+ def test_uses_chat_completions_for_search_models(
353
+ self, mock_getenv, mock_openai_class
354
+ ):
355
+ """Uses Chat Completions API for search models."""
356
+ mock_client = MagicMock()
357
+ mock_openai_class.return_value = mock_client
358
+
359
+ mock_chunk = MagicMock()
360
+ mock_chunk.choices = [MagicMock(delta=MagicMock(content="response"))]
361
+ mock_client.chat.completions.create.return_value = [mock_chunk]
362
+
363
+ results = list(perform_openai_web_search("test query", "gpt-5-search-api"))
364
+
365
+ mock_client.chat.completions.create.assert_called_once()
366
+
367
+ @patch("openai.OpenAI")
368
+ @patch("os.getenv", return_value="test-api-key")
369
+ def test_uses_responses_api_for_other_models(self, mock_getenv, mock_openai_class):
370
+ """Uses Responses API for non-search models."""
371
+ mock_client = MagicMock()
372
+ mock_openai_class.return_value = mock_client
373
+
374
+ mock_response = MagicMock()
375
+ mock_response.output_text = "response text"
376
+ mock_response.output = []
377
+ mock_client.responses.create.return_value = mock_response
378
+
379
+ results = list(perform_openai_web_search("test query", "gpt-5"))
380
+
381
+ mock_client.responses.create.assert_called_once()
382
+
383
+
384
+ # =============================================================================
385
+ # Tests for perform_anthropic_web_search
386
+ # =============================================================================
387
+
388
+
389
+ class TestPerformAnthropicWebSearch:
390
+ """Tests for perform_anthropic_web_search function."""
391
+
392
+ @patch("anthropic.Anthropic")
393
+ @patch("os.getenv", return_value="test-api-key")
394
+ def test_creates_message_with_web_search_tool(
395
+ self, mock_getenv, mock_anthropic_class
396
+ ):
397
+ """Creates message with web_search tool."""
398
+ mock_client = MagicMock()
399
+ mock_anthropic_class.return_value = mock_client
400
+
401
+ mock_text_block = MagicMock()
402
+ mock_text_block.type = "text"
403
+ mock_text_block.text = "response text"
404
+ mock_text_block.citations = None
405
+
406
+ mock_response = MagicMock()
407
+ mock_response.content = [mock_text_block]
408
+ mock_client.messages.create.return_value = mock_response
409
+
410
+ results = list(
411
+ perform_anthropic_web_search("test query", "claude-sonnet-4-20250514")
412
+ )
413
+
414
+ mock_client.messages.create.assert_called_once()
415
+ call_args = mock_client.messages.create.call_args
416
+ assert any("web_search" in str(arg) for arg in call_args)
417
+
418
+
419
+ # =============================================================================
420
+ # Tests for perform_web_search_completion
421
+ # =============================================================================
422
+
423
+
424
+ class TestPerformWebSearchCompletion:
425
+ """Tests for perform_web_search_completion function."""
426
+
427
+ @patch("ara_cli.chat_web_search.web_search.LLMSingleton")
428
+ def test_raises_for_unsupported_model(self, mock_singleton):
429
+ """Raises AraError for unsupported model."""
430
+ mock_instance = MagicMock()
431
+ mock_instance.get_config_by_purpose.return_value = {"model": "gpt-4o"}
432
+ mock_singleton.get_instance.return_value = mock_instance
433
+
434
+ with pytest.raises(AraError) as exc_info:
435
+ list(perform_web_search_completion("test query"))
436
+
437
+ assert "not supported" in str(exc_info.value)
438
+
439
+ @patch("ara_cli.chat_web_search.web_search.perform_openai_web_search")
440
+ @patch("ara_cli.chat_web_search.web_search.LLMSingleton")
441
+ def test_uses_openai_for_openai_models(self, mock_singleton, mock_openai_search):
442
+ """Uses OpenAI search for OpenAI models."""
443
+ mock_instance = MagicMock()
444
+ mock_instance.get_config_by_purpose.return_value = {"model": "gpt-5"}
445
+ mock_singleton.get_instance.return_value = mock_instance
446
+ mock_openai_search.return_value = iter([])
447
+
448
+ list(perform_web_search_completion("test query"))
449
+
450
+ mock_openai_search.assert_called_once()
451
+
452
+ @patch("ara_cli.chat_web_search.web_search.perform_anthropic_web_search")
453
+ @patch("ara_cli.chat_web_search.web_search.LLMSingleton")
454
+ def test_uses_anthropic_for_anthropic_models(
455
+ self, mock_singleton, mock_anthropic_search
456
+ ):
457
+ """Uses Anthropic search for Anthropic models."""
458
+ mock_instance = MagicMock()
459
+ mock_instance.get_config_by_purpose.return_value = {
460
+ "model": "claude-sonnet-4-20250514"
461
+ }
462
+ mock_singleton.get_instance.return_value = mock_instance
463
+ mock_anthropic_search.return_value = iter([])
464
+
465
+ list(perform_web_search_completion("test query"))
466
+
467
+ mock_anthropic_search.assert_called_once()