-
Notifications
You must be signed in to change notification settings - Fork 160
Expand file tree
/
Copy pathtest_conversation_service.py
More file actions
1865 lines (1578 loc) · 75.6 KB
/
test_conversation_service.py
File metadata and controls
1865 lines (1578 loc) · 75.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
from pydantic import SecretStr
from openhands.agent_server.conversation_service import ConversationService
from openhands.agent_server.event_service import EventService
from openhands.agent_server.models import (
ConversationPage,
ConversationSortOrder,
StartConversationRequest,
StoredConversation,
UpdateConversationRequest,
)
from openhands.agent_server.utils import safe_rmtree as _safe_rmtree
from openhands.sdk import LLM, Agent
from openhands.sdk.conversation.state import (
ConversationExecutionStatus,
ConversationState,
)
from openhands.sdk.secret import SecretSource, StaticSecret
from openhands.sdk.security.confirmation_policy import NeverConfirm
from openhands.sdk.workspace import LocalWorkspace
@pytest.fixture
def mock_event_service():
"""Create a mock EventService with stored conversation data."""
service = AsyncMock(spec=EventService)
return service
@pytest.fixture
def sample_stored_conversation():
"""Create a sample StoredConversation for testing."""
return StoredConversation(
id=uuid4(),
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir="workspace/project"),
confirmation_policy=NeverConfirm(),
initial_message=None,
metrics=None,
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=UTC),
updated_at=datetime(2025, 1, 1, 12, 30, 0, tzinfo=UTC),
)
@pytest.fixture
def conversation_service():
"""Create a ConversationService instance for testing."""
with tempfile.TemporaryDirectory() as temp_dir:
service = ConversationService(
conversations_dir=Path(temp_dir) / "conversations",
)
# Initialize the _event_services dict to simulate an active service
service._event_services = {}
yield service
class TestConversationServiceSearchConversations:
"""Test cases for ConversationService.search_conversations method."""
@pytest.mark.asyncio
async def test_search_conversations_inactive_service(self, conversation_service):
"""Test that search_conversations raises ValueError when service is inactive."""
conversation_service._event_services = None
with pytest.raises(ValueError, match="inactive_service"):
await conversation_service.search_conversations()
@pytest.mark.asyncio
async def test_search_conversations_empty_result(self, conversation_service):
"""Test search_conversations with no conversations."""
result = await conversation_service.search_conversations()
assert isinstance(result, ConversationPage)
assert result.items == []
assert result.next_page_id is None
@pytest.mark.asyncio
async def test_search_conversations_basic(
self, conversation_service, sample_stored_conversation
):
"""Test basic search_conversations functionality."""
# Create mock event service
mock_service = AsyncMock(spec=EventService)
mock_service.stored = sample_stored_conversation
mock_state = ConversationState(
id=sample_stored_conversation.id,
agent=sample_stored_conversation.agent,
workspace=sample_stored_conversation.workspace,
execution_status=ConversationExecutionStatus.IDLE,
confirmation_policy=sample_stored_conversation.confirmation_policy,
)
mock_service.get_state.return_value = mock_state
conversation_id = sample_stored_conversation.id
conversation_service._event_services[conversation_id] = mock_service
result = await conversation_service.search_conversations()
assert len(result.items) == 1
assert result.items[0].id == conversation_id
assert result.items[0].execution_status == ConversationExecutionStatus.IDLE
assert result.next_page_id is None
@pytest.mark.asyncio
async def test_search_conversations_status_filter(self, conversation_service):
"""Test filtering conversations by status."""
# Create multiple conversations with different statuses
conversations = []
for i, status in enumerate(
[
ConversationExecutionStatus.IDLE,
ConversationExecutionStatus.RUNNING,
ConversationExecutionStatus.FINISHED,
]
):
stored_conv = StoredConversation(
id=uuid4(),
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir="workspace/project"),
confirmation_policy=NeverConfirm(),
initial_message=None,
metrics=None,
created_at=datetime(2025, 1, 1, 12, i, 0, tzinfo=UTC),
updated_at=datetime(2025, 1, 1, 12, i + 30, 0, tzinfo=UTC),
)
mock_service = AsyncMock(spec=EventService)
mock_service.stored = stored_conv
mock_state = ConversationState(
id=stored_conv.id,
agent=stored_conv.agent,
workspace=stored_conv.workspace,
execution_status=status,
confirmation_policy=stored_conv.confirmation_policy,
)
mock_service.get_state.return_value = mock_state
conversation_service._event_services[stored_conv.id] = mock_service
conversations.append((stored_conv.id, status))
# Test filtering by IDLE status
result = await conversation_service.search_conversations(
execution_status=ConversationExecutionStatus.IDLE
)
assert len(result.items) == 1
assert result.items[0].execution_status == ConversationExecutionStatus.IDLE
# Test filtering by RUNNING status
result = await conversation_service.search_conversations(
execution_status=ConversationExecutionStatus.RUNNING
)
assert len(result.items) == 1
assert result.items[0].execution_status == ConversationExecutionStatus.RUNNING
# Test filtering by non-existent status
result = await conversation_service.search_conversations(
execution_status=ConversationExecutionStatus.ERROR
)
assert len(result.items) == 0
@pytest.mark.asyncio
async def test_search_conversations_sorting(self, conversation_service):
"""Test sorting conversations by different criteria."""
# Create conversations with different timestamps
conversations = []
for i in range(3):
stored_conv = StoredConversation(
id=uuid4(),
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir="workspace/project"),
confirmation_policy=NeverConfirm(),
initial_message=None,
metrics=None,
created_at=datetime(
2025, 1, i + 1, 12, 0, 0, tzinfo=UTC
), # Different days
updated_at=datetime(2025, 1, i + 1, 12, 30, 0, tzinfo=UTC),
)
mock_service = AsyncMock(spec=EventService)
mock_service.stored = stored_conv
mock_state = ConversationState(
id=stored_conv.id,
agent=stored_conv.agent,
workspace=stored_conv.workspace,
execution_status=ConversationExecutionStatus.IDLE,
confirmation_policy=stored_conv.confirmation_policy,
)
mock_service.get_state.return_value = mock_state
conversation_service._event_services[stored_conv.id] = mock_service
conversations.append(stored_conv)
# Test CREATED_AT (ascending)
result = await conversation_service.search_conversations(
sort_order=ConversationSortOrder.CREATED_AT
)
assert len(result.items) == 3
assert (
result.items[0].created_at
< result.items[1].created_at
< result.items[2].created_at
)
# Test CREATED_AT_DESC (descending) - default
result = await conversation_service.search_conversations(
sort_order=ConversationSortOrder.CREATED_AT_DESC
)
assert len(result.items) == 3
assert (
result.items[0].created_at
> result.items[1].created_at
> result.items[2].created_at
)
# Test UPDATED_AT (ascending)
result = await conversation_service.search_conversations(
sort_order=ConversationSortOrder.UPDATED_AT
)
assert len(result.items) == 3
assert (
result.items[0].updated_at
< result.items[1].updated_at
< result.items[2].updated_at
)
# Test UPDATED_AT_DESC (descending)
result = await conversation_service.search_conversations(
sort_order=ConversationSortOrder.UPDATED_AT_DESC
)
assert len(result.items) == 3
assert (
result.items[0].updated_at
> result.items[1].updated_at
> result.items[2].updated_at
)
@pytest.mark.asyncio
async def test_search_conversations_pagination(self, conversation_service):
"""Test pagination functionality."""
# Create 5 conversations
conversation_ids = []
for i in range(5):
stored_conv = StoredConversation(
id=uuid4(),
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir="workspace/project"),
confirmation_policy=NeverConfirm(),
initial_message=None,
metrics=None,
created_at=datetime(2025, 1, 1, 12, i, 0, tzinfo=UTC),
updated_at=datetime(2025, 1, 1, 12, i + 30, 0, tzinfo=UTC),
)
mock_service = AsyncMock(spec=EventService)
mock_service.stored = stored_conv
mock_state = ConversationState(
id=stored_conv.id,
agent=stored_conv.agent,
workspace=stored_conv.workspace,
execution_status=ConversationExecutionStatus.IDLE,
confirmation_policy=stored_conv.confirmation_policy,
)
mock_service.get_state.return_value = mock_state
conversation_service._event_services[stored_conv.id] = mock_service
conversation_ids.append(stored_conv.id)
# Test first page with limit 2
result = await conversation_service.search_conversations(limit=2)
assert len(result.items) == 2
assert result.next_page_id is not None
# Test second page using next_page_id
result = await conversation_service.search_conversations(
page_id=result.next_page_id, limit=2
)
assert len(result.items) == 2
assert result.next_page_id is not None
# Test last page
result = await conversation_service.search_conversations(
page_id=result.next_page_id, limit=2
)
assert len(result.items) == 1 # Only one item left
assert result.next_page_id is None
@pytest.mark.asyncio
async def test_search_conversations_combined_filter_and_sort(
self, conversation_service
):
"""Test combining status filtering with sorting."""
# Create conversations with mixed statuses and timestamps
conversations_data = [
(
ConversationExecutionStatus.IDLE,
datetime(2025, 1, 1, 12, 0, 0, tzinfo=UTC),
),
(
ConversationExecutionStatus.RUNNING,
datetime(2025, 1, 2, 12, 0, 0, tzinfo=UTC),
),
(
ConversationExecutionStatus.IDLE,
datetime(2025, 1, 3, 12, 0, 0, tzinfo=UTC),
),
(
ConversationExecutionStatus.FINISHED,
datetime(2025, 1, 4, 12, 0, 0, tzinfo=UTC),
),
]
for status, created_at in conversations_data:
stored_conv = StoredConversation(
id=uuid4(),
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir="workspace/project"),
confirmation_policy=NeverConfirm(),
initial_message=None,
metrics=None,
created_at=created_at,
updated_at=created_at,
)
mock_service = AsyncMock(spec=EventService)
mock_service.stored = stored_conv
mock_state = ConversationState(
id=stored_conv.id,
agent=stored_conv.agent,
workspace=stored_conv.workspace,
execution_status=status,
confirmation_policy=stored_conv.confirmation_policy,
)
mock_service.get_state.return_value = mock_state
conversation_service._event_services[stored_conv.id] = mock_service
# Filter by IDLE status and sort by CREATED_AT_DESC
result = await conversation_service.search_conversations(
execution_status=ConversationExecutionStatus.IDLE,
sort_order=ConversationSortOrder.CREATED_AT_DESC,
)
assert len(result.items) == 2 # Two IDLE conversations
# Should be sorted by created_at descending (newest first)
assert result.items[0].created_at > result.items[1].created_at
@pytest.mark.asyncio
async def test_search_conversations_invalid_page_id(
self, conversation_service, sample_stored_conversation
):
"""Test search_conversations with invalid page_id."""
mock_service = AsyncMock(spec=EventService)
mock_service.stored = sample_stored_conversation
mock_state = ConversationState(
id=sample_stored_conversation.id,
agent=sample_stored_conversation.agent,
workspace=sample_stored_conversation.workspace,
execution_status=ConversationExecutionStatus.IDLE,
confirmation_policy=sample_stored_conversation.confirmation_policy,
)
mock_service.get_state.return_value = mock_state
conversation_service._event_services[sample_stored_conversation.id] = (
mock_service
)
# Use a non-existent page_id
invalid_page_id = uuid4().hex
result = await conversation_service.search_conversations(
page_id=invalid_page_id
)
# Should return all items since page_id doesn't match any conversation
assert len(result.items) == 1
assert result.next_page_id is None
class TestConversationServiceCountConversations:
"""Test cases for ConversationService.count_conversations method."""
@pytest.mark.asyncio
async def test_count_conversations_inactive_service(self, conversation_service):
"""Test that count_conversations raises ValueError when service is inactive."""
conversation_service._event_services = None
with pytest.raises(ValueError, match="inactive_service"):
await conversation_service.count_conversations()
@pytest.mark.asyncio
async def test_count_conversations_empty_result(self, conversation_service):
"""Test count_conversations with no conversations."""
result = await conversation_service.count_conversations()
assert result == 0
@pytest.mark.asyncio
async def test_count_conversations_basic(
self, conversation_service, sample_stored_conversation
):
"""Test basic count_conversations functionality."""
# Create mock event service
mock_service = AsyncMock(spec=EventService)
mock_service.stored = sample_stored_conversation
mock_state = ConversationState(
id=sample_stored_conversation.id,
agent=sample_stored_conversation.agent,
workspace=sample_stored_conversation.workspace,
execution_status=ConversationExecutionStatus.IDLE,
confirmation_policy=sample_stored_conversation.confirmation_policy,
)
mock_service.get_state.return_value = mock_state
conversation_id = sample_stored_conversation.id
conversation_service._event_services[conversation_id] = mock_service
result = await conversation_service.count_conversations()
assert result == 1
@pytest.mark.asyncio
async def test_count_conversations_status_filter(self, conversation_service):
"""Test counting conversations with status filter."""
# Create multiple conversations with different statuses
statuses = [
ConversationExecutionStatus.IDLE,
ConversationExecutionStatus.RUNNING,
ConversationExecutionStatus.FINISHED,
ConversationExecutionStatus.IDLE, # Another IDLE one
]
for i, status in enumerate(statuses):
stored_conv = StoredConversation(
id=uuid4(),
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir="workspace/project"),
confirmation_policy=NeverConfirm(),
initial_message=None,
metrics=None,
created_at=datetime(2025, 1, 1, 12, i, 0, tzinfo=UTC),
updated_at=datetime(2025, 1, 1, 12, i + 30, 0, tzinfo=UTC),
)
mock_service = AsyncMock(spec=EventService)
mock_service.stored = stored_conv
mock_state = ConversationState(
id=stored_conv.id,
agent=stored_conv.agent,
workspace=stored_conv.workspace,
execution_status=status,
confirmation_policy=stored_conv.confirmation_policy,
)
mock_service.get_state.return_value = mock_state
conversation_service._event_services[stored_conv.id] = mock_service
# Test counting all conversations
result = await conversation_service.count_conversations()
assert result == 4
# Test counting by IDLE status (should be 2)
result = await conversation_service.count_conversations(
execution_status=ConversationExecutionStatus.IDLE
)
assert result == 2
# Test counting by RUNNING status (should be 1)
result = await conversation_service.count_conversations(
execution_status=ConversationExecutionStatus.RUNNING
)
assert result == 1
# Test counting by non-existent status (should be 0)
result = await conversation_service.count_conversations(
execution_status=ConversationExecutionStatus.ERROR
)
assert result == 0
class TestConversationServiceStartConversation:
"""Test cases for ConversationService.start_conversation method."""
@pytest.mark.asyncio
async def test_start_conversation_with_secrets(self, conversation_service):
"""Test that secrets are passed to new conversations when starting."""
# Create test secrets
test_secrets: dict[str, SecretSource] = {
"api_key": StaticSecret(value=SecretStr("secret-api-key-123")),
"database_url": StaticSecret(
value=SecretStr("postgresql://user:pass@host:5432/db")
),
}
# Create a start conversation request with secrets
with tempfile.TemporaryDirectory() as temp_dir:
request = StartConversationRequest(
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir=temp_dir),
confirmation_policy=NeverConfirm(),
secrets=test_secrets,
)
# Mock the EventService constructor and start method
with patch(
"openhands.agent_server.conversation_service.EventService"
) as mock_event_service_class:
mock_event_service = AsyncMock(spec=EventService)
mock_event_service_class.return_value = mock_event_service
# Mock the state that would be returned
mock_state = ConversationState(
id=uuid4(),
agent=request.agent,
workspace=request.workspace,
execution_status=ConversationExecutionStatus.IDLE,
confirmation_policy=request.confirmation_policy,
)
mock_event_service.get_state.return_value = mock_state
mock_event_service.stored = StoredConversation(
id=mock_state.id,
**request.model_dump(),
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
# Start the conversation
result, _ = await conversation_service.start_conversation(request)
# Verify EventService was created with the correct parameters
mock_event_service_class.assert_called_once()
call_args = mock_event_service_class.call_args
stored_conversation = call_args.kwargs["stored"]
# Verify that secrets were passed to the stored conversation
assert stored_conversation.secrets == test_secrets
assert "api_key" in stored_conversation.secrets
assert "database_url" in stored_conversation.secrets
assert (
stored_conversation.secrets["api_key"].get_value()
== "secret-api-key-123"
)
assert (
stored_conversation.secrets["database_url"].get_value()
== "postgresql://user:pass@host:5432/db"
)
# Verify the conversation was started
mock_event_service.start.assert_called_once()
# Verify the result
assert result.id == mock_state.id
assert result.execution_status == ConversationExecutionStatus.IDLE
@pytest.mark.asyncio
async def test_start_conversation_without_secrets(self, conversation_service):
"""Test that conversations can be started without secrets."""
# Create a start conversation request without secrets
with tempfile.TemporaryDirectory() as temp_dir:
request = StartConversationRequest(
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir=temp_dir),
confirmation_policy=NeverConfirm(),
)
# Mock the EventService constructor and start method
with patch(
"openhands.agent_server.conversation_service.EventService"
) as mock_event_service_class:
mock_event_service = AsyncMock(spec=EventService)
mock_event_service_class.return_value = mock_event_service
# Mock the state that would be returned
mock_state = ConversationState(
id=uuid4(),
agent=request.agent,
workspace=request.workspace,
execution_status=ConversationExecutionStatus.IDLE,
confirmation_policy=request.confirmation_policy,
)
mock_event_service.get_state.return_value = mock_state
mock_event_service.stored = StoredConversation(
id=mock_state.id,
**request.model_dump(),
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
# Start the conversation
result, _ = await conversation_service.start_conversation(request)
# Verify EventService was created with the correct parameters
mock_event_service_class.assert_called_once()
call_args = mock_event_service_class.call_args
stored_conversation = call_args.kwargs["stored"]
# Verify that secrets is an empty dict (default)
assert stored_conversation.secrets == {}
# Verify the conversation was started
mock_event_service.start.assert_called_once()
# Verify the result
assert result.id == mock_state.id
assert result.execution_status == ConversationExecutionStatus.IDLE
@pytest.mark.asyncio
async def test_start_conversation_with_custom_id(self, conversation_service):
"""Test that conversations can be started with a custom conversation_id."""
custom_id = uuid4()
# Create a start conversation request with custom conversation_id
with tempfile.TemporaryDirectory() as temp_dir:
request = StartConversationRequest(
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir=temp_dir),
confirmation_policy=NeverConfirm(),
conversation_id=custom_id,
)
result, is_new = await conversation_service.start_conversation(request)
assert result.id == custom_id
assert is_new
@pytest.mark.asyncio
async def test_start_conversation_with_duplicate_id(self, conversation_service):
"""Test duplicate conversation ids are detected."""
custom_id = uuid4()
# Create a start conversation request with custom conversation_id
with tempfile.TemporaryDirectory() as temp_dir:
request = StartConversationRequest(
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir=temp_dir),
confirmation_policy=NeverConfirm(),
conversation_id=custom_id,
)
result, is_new = await conversation_service.start_conversation(request)
assert result.id == custom_id
assert is_new
duplicate_request = StartConversationRequest(
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir=temp_dir),
confirmation_policy=NeverConfirm(),
conversation_id=custom_id,
)
result, is_new = await conversation_service.start_conversation(
duplicate_request
)
assert result.id == custom_id
assert not is_new
@pytest.mark.asyncio
async def test_start_conversation_reuse_checks_is_open(self, conversation_service):
"""Test that conversation reuse checks if event service is open."""
custom_id = uuid4()
# Create a mock event service that exists but is not open
mock_event_service = AsyncMock(spec=EventService)
mock_event_service.is_open.return_value = False
conversation_service._event_services[custom_id] = mock_event_service
with tempfile.TemporaryDirectory() as temp_dir:
request = StartConversationRequest(
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir=temp_dir),
confirmation_policy=NeverConfirm(),
conversation_id=custom_id,
)
# Mock the _start_event_service method to avoid actual startup
with patch.object(
conversation_service, "_start_event_service"
) as mock_start:
mock_new_service = AsyncMock(spec=EventService)
mock_new_service.stored = StoredConversation(
id=custom_id,
agent=request.agent,
workspace=request.workspace,
confirmation_policy=request.confirmation_policy,
initial_message=request.initial_message,
metrics=None,
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=UTC),
updated_at=datetime(2025, 1, 1, 12, 30, 0, tzinfo=UTC),
)
mock_state = ConversationState(
id=custom_id,
agent=request.agent,
workspace=request.workspace,
execution_status=ConversationExecutionStatus.IDLE,
confirmation_policy=request.confirmation_policy,
)
mock_new_service.get_state.return_value = mock_state
mock_start.return_value = mock_new_service
result, is_new = await conversation_service.start_conversation(request)
# Should create a new conversation since existing one is not open
assert result.id == custom_id
assert is_new
mock_start.assert_called_once()
@pytest.mark.asyncio
async def test_start_conversation_reuse_when_open(self, conversation_service):
"""Test that conversation is reused when event service is open."""
custom_id = uuid4()
# Create a mock event service that exists and is open
mock_event_service = AsyncMock(spec=EventService)
mock_event_service.is_open.return_value = True
mock_event_service.stored = StoredConversation(
id=custom_id,
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir="workspace/project"),
confirmation_policy=NeverConfirm(),
initial_message=None,
metrics=None,
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=UTC),
updated_at=datetime(2025, 1, 1, 12, 30, 0, tzinfo=UTC),
)
mock_state = ConversationState(
id=custom_id,
agent=mock_event_service.stored.agent,
workspace=mock_event_service.stored.workspace,
execution_status=ConversationExecutionStatus.IDLE,
confirmation_policy=mock_event_service.stored.confirmation_policy,
)
mock_event_service.get_state.return_value = mock_state
conversation_service._event_services[custom_id] = mock_event_service
with tempfile.TemporaryDirectory() as temp_dir:
request = StartConversationRequest(
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir=temp_dir),
confirmation_policy=NeverConfirm(),
conversation_id=custom_id,
)
# Mock the _start_event_service method to ensure it's not called
with patch.object(
conversation_service, "_start_event_service"
) as mock_start:
result, is_new = await conversation_service.start_conversation(request)
# Should reuse existing conversation since it's open
assert result.id == custom_id
assert not is_new
mock_start.assert_not_called()
@pytest.mark.asyncio
async def test_start_event_service_failure_cleanup(self, conversation_service):
"""Test that event service is cleaned up when startup fails."""
with tempfile.TemporaryDirectory() as temp_dir:
stored = StoredConversation(
id=uuid4(),
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir=temp_dir),
confirmation_policy=NeverConfirm(),
initial_message=None,
metrics=None,
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=UTC),
updated_at=datetime(2025, 1, 1, 12, 30, 0, tzinfo=UTC),
)
# Mock EventService to simulate startup failure
with patch(
"openhands.agent_server.conversation_service.EventService"
) as mock_event_service_class:
mock_event_service = AsyncMock()
mock_event_service.start.side_effect = Exception("Startup failed")
mock_event_service.close = AsyncMock()
mock_event_service_class.return_value = mock_event_service
# Attempt to start event service should fail and clean up
with pytest.raises(Exception, match="Startup failed"):
await conversation_service._start_event_service(stored)
# Verify cleanup was called
mock_event_service.close.assert_called_once()
# Verify event service was not stored
assert stored.id not in conversation_service._event_services
@pytest.mark.asyncio
async def test_start_event_service_success_stores_service(
self, conversation_service
):
"""Test that event service is stored only after successful startup."""
with tempfile.TemporaryDirectory() as temp_dir:
stored = StoredConversation(
id=uuid4(),
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir=temp_dir),
confirmation_policy=NeverConfirm(),
initial_message=None,
metrics=None,
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=UTC),
updated_at=datetime(2025, 1, 1, 12, 30, 0, tzinfo=UTC),
)
# Mock EventService to simulate successful startup
with patch(
"openhands.agent_server.conversation_service.EventService"
) as mock_event_service_class:
mock_event_service = AsyncMock()
mock_event_service.start = AsyncMock() # Successful startup
mock_event_service_class.return_value = mock_event_service
# Start event service should succeed
result = await conversation_service._start_event_service(stored)
# Verify startup was called
mock_event_service.start.assert_called_once()
# Verify event service was stored after successful startup
assert stored.id in conversation_service._event_services
assert (
conversation_service._event_services[stored.id]
== mock_event_service
)
assert result == mock_event_service
class TestConversationServiceUpdateConversation:
"""Test cases for ConversationService.update_conversation method."""
@pytest.mark.asyncio
async def test_update_conversation_success(
self, conversation_service, sample_stored_conversation
):
"""Test successful update of conversation title."""
# Create mock event service
mock_service = AsyncMock(spec=EventService)
mock_service.stored = sample_stored_conversation
mock_state = ConversationState(
id=sample_stored_conversation.id,
agent=sample_stored_conversation.agent,
workspace=sample_stored_conversation.workspace,
execution_status=ConversationExecutionStatus.IDLE,
confirmation_policy=sample_stored_conversation.confirmation_policy,
)
mock_service.get_state.return_value = mock_state
conversation_id = sample_stored_conversation.id
conversation_service._event_services[conversation_id] = mock_service
# Update the title
new_title = "My Updated Conversation Title"
request = UpdateConversationRequest(title=new_title)
result = await conversation_service.update_conversation(
conversation_id, request
)
# Verify update was successful
assert result is True
assert mock_service.stored.title == new_title
mock_service.save_meta.assert_called_once()
@pytest.mark.asyncio
async def test_update_conversation_strips_whitespace(
self, conversation_service, sample_stored_conversation
):
"""Test that update_conversation strips leading/trailing whitespace."""
mock_service = AsyncMock(spec=EventService)
mock_service.stored = sample_stored_conversation
mock_state = ConversationState(
id=sample_stored_conversation.id,
agent=sample_stored_conversation.agent,
workspace=sample_stored_conversation.workspace,
execution_status=ConversationExecutionStatus.IDLE,
confirmation_policy=sample_stored_conversation.confirmation_policy,
)
mock_service.get_state.return_value = mock_state
conversation_id = sample_stored_conversation.id
conversation_service._event_services[conversation_id] = mock_service
# Update with title that has whitespace
new_title = " Whitespace Test "
request = UpdateConversationRequest(title=new_title)
result = await conversation_service.update_conversation(
conversation_id, request
)
# Verify whitespace was stripped
assert result is True
assert mock_service.stored.title == "Whitespace Test"
mock_service.save_meta.assert_called_once()
@pytest.mark.asyncio
async def test_update_conversation_not_found(self, conversation_service):
"""Test updating a non-existent conversation returns False."""
non_existent_id = uuid4()
request = UpdateConversationRequest(title="New Title")
result = await conversation_service.update_conversation(
non_existent_id, request
)
assert result is False
@pytest.mark.asyncio
async def test_update_conversation_inactive_service(self, conversation_service):
"""Test that update_conversation raises ValueError when service is inactive."""
conversation_service._event_services = None
request = UpdateConversationRequest(title="New Title")
with pytest.raises(ValueError, match="inactive_service"):
await conversation_service.update_conversation(uuid4(), request)
@pytest.mark.asyncio
async def test_update_conversation_notifies_webhooks(
self, conversation_service, sample_stored_conversation
):
"""Test that updating a conversation triggers webhook notifications."""
# Create mock event service
mock_service = AsyncMock(spec=EventService)
mock_service.stored = sample_stored_conversation
mock_state = ConversationState(
id=sample_stored_conversation.id,
agent=sample_stored_conversation.agent,
workspace=sample_stored_conversation.workspace,
execution_status=ConversationExecutionStatus.IDLE,
confirmation_policy=sample_stored_conversation.confirmation_policy,
)
mock_service.get_state.return_value = mock_state
conversation_id = sample_stored_conversation.id
conversation_service._event_services[conversation_id] = mock_service
# Mock webhook notification
with patch.object(
conversation_service, "_notify_conversation_webhooks", new=AsyncMock()
) as mock_notify:
new_title = "Updated Title for Webhook Test"
request = UpdateConversationRequest(title=new_title)
result = await conversation_service.update_conversation(
conversation_id, request
)
# Verify webhook was called
assert result is True
mock_notify.assert_called_once()
# Verify the conversation info passed to webhook has the updated title
call_args = mock_notify.call_args[0]
conversation_info = call_args[0]
assert conversation_info.title == new_title
@pytest.mark.asyncio
async def test_update_conversation_persists_changes(
self, conversation_service, sample_stored_conversation
):
"""Test that title changes are persisted to disk."""
mock_service = AsyncMock(spec=EventService)
mock_service.stored = sample_stored_conversation
mock_state = ConversationState(
id=sample_stored_conversation.id,
agent=sample_stored_conversation.agent,
workspace=sample_stored_conversation.workspace,
execution_status=ConversationExecutionStatus.IDLE,
confirmation_policy=sample_stored_conversation.confirmation_policy,
)
mock_service.get_state.return_value = mock_state
conversation_id = sample_stored_conversation.id
conversation_service._event_services[conversation_id] = mock_service
# Initial title should be None
assert mock_service.stored.title is None
# Update the title
new_title = "Persisted Title"
request = UpdateConversationRequest(title=new_title)
await conversation_service.update_conversation(conversation_id, request)
# Verify save_meta was called to persist changes
mock_service.save_meta.assert_called_once()
# Verify the stored conversation has the new title
assert mock_service.stored.title == new_title
@pytest.mark.asyncio
async def test_update_conversation_multiple_times(
self, conversation_service, sample_stored_conversation
):
"""Test updating the same conversation multiple times."""
mock_service = AsyncMock(spec=EventService)
mock_service.stored = sample_stored_conversation
mock_state = ConversationState(
id=sample_stored_conversation.id,
agent=sample_stored_conversation.agent,
workspace=sample_stored_conversation.workspace,
execution_status=ConversationExecutionStatus.IDLE,
confirmation_policy=sample_stored_conversation.confirmation_policy,