1111 ApiBackend ,
1212 CliBackend ,
1313 InvocationResult ,
14+ InvocationTimeoutError ,
1415 UsageLimitError ,
1516 _calculate_cost_usd ,
1617 _get_backend ,
@@ -333,9 +334,39 @@ def fake_monotonic():
333334 patch .object (backend , "_get_client" , return_value = mock_client ), \
334335 patch .object (ApiBackend , "_execute_tool" , return_value = "output" ), \
335336 patch ("clayde.claude.time.monotonic" , side_effect = fake_monotonic ):
336- with pytest .raises (TimeoutError ):
337+ with pytest .raises (InvocationTimeoutError ):
337338 backend .invoke ("implement" , str (tmp_path ))
338339
340+ def test_tool_loop_timeout_saves_conversation (self , tmp_path ):
341+ (tmp_path / "CLAUDE.md" ).write_text ("identity" )
342+ conv_path = tmp_path / "conv.json"
343+ tool_block = _make_tool_use_block ("bash" , "tool-1" , {"command" : "echo loop" })
344+ tool_response = _make_tool_response ([tool_block ])
345+ mock_client = MagicMock ()
346+ mock_client .beta .messages .create .return_value = tool_response
347+ backend = ApiBackend ()
348+
349+ call_count = [0 ]
350+ def fake_monotonic ():
351+ call_count [0 ] += 1
352+ if call_count [0 ] <= 1 :
353+ return 0.0
354+ return 2000.0
355+
356+ with patch ("clayde.claude.APP_DIR" , tmp_path ), \
357+ patch ("clayde.claude.get_settings" , return_value = _mock_settings ()), \
358+ patch .object (backend , "_get_client" , return_value = mock_client ), \
359+ patch .object (ApiBackend , "_execute_tool" , return_value = "output" ), \
360+ patch ("clayde.claude.time.monotonic" , side_effect = fake_monotonic ), \
361+ patch ("clayde.claude.commit_wip" ) as mock_wip :
362+ with pytest .raises (InvocationTimeoutError ) as exc_info :
363+ backend .invoke ("implement" , str (tmp_path ),
364+ branch_name = "branch" , conversation_path = conv_path )
365+ mock_wip .assert_called_once_with (str (tmp_path ), "branch" )
366+
367+ assert conv_path .exists ()
368+ assert exc_info .value .cost_eur >= 0.0
369+
339370 def test_token_usage_accumulated_across_turns (self , tmp_path ):
340371 (tmp_path / "CLAUDE.md" ).write_text ("identity" )
341372 tool_block = _make_tool_use_block ("bash" , "t1" , {"command" : "echo x" })
@@ -652,14 +683,15 @@ def test_saves_session_id(self, tmp_path):
652683
653684 assert conv_path .exists ()
654685 data = json .loads (conv_path .read_text ())
686+ # Session ID from response overwrites the pre-generated one
655687 assert data ["session_id" ] == "my-session-id"
656688
657689 def test_resumes_from_session_id (self , tmp_path ):
658690 (tmp_path / "CLAUDE.md" ).write_text ("identity" )
659691 conv_path = tmp_path / "conv.json"
660692 conv_path .write_text (json .dumps ({"session_id" : "prev-session" }))
661693 mock_result = MagicMock ()
662- mock_result .stdout = self ._cli_json_output ("resumed" )
694+ mock_result .stdout = self ._cli_json_output ("resumed" , "prev-session" )
663695 mock_result .stderr = ""
664696 mock_result .returncode = 0
665697 backend = CliBackend ()
@@ -731,7 +763,7 @@ def test_limit_saves_session_before_raising(self, tmp_path):
731763 data = json .loads (conv_path .read_text ())
732764 assert data ["session_id" ] == "limit-session"
733765
734- def test_timeout_raises_usage_limit_error (self , tmp_path ):
766+ def test_timeout_raises_invocation_timeout_error (self , tmp_path ):
735767 (tmp_path / "CLAUDE.md" ).write_text ("identity" )
736768 backend = CliBackend ()
737769
@@ -740,10 +772,48 @@ def test_timeout_raises_usage_limit_error(self, tmp_path):
740772 patch ("clayde.claude._resolve_cli_bin" , return_value = "/usr/bin/claude" ), \
741773 patch ("clayde.claude.subprocess.run" , side_effect = __import__ ("subprocess" ).TimeoutExpired ("claude" , 1800 )), \
742774 patch ("clayde.claude.commit_wip" ) as mock_wip :
743- with pytest .raises (UsageLimitError ):
775+ with pytest .raises (InvocationTimeoutError ):
744776 backend .invoke ("prompt" , "/repo" , branch_name = "branch" )
745777 mock_wip .assert_called_once_with ("/repo" , "branch" )
746778
779+ def test_timeout_saves_session_id_for_resumption (self , tmp_path ):
780+ """When a fresh CLI session times out, the pre-generated session ID is saved for resumption."""
781+ (tmp_path / "CLAUDE.md" ).write_text ("identity" )
782+ conv_path = tmp_path / "conv.json"
783+ backend = CliBackend ()
784+
785+ with patch ("clayde.claude.APP_DIR" , tmp_path ), \
786+ patch ("clayde.claude.get_settings" , return_value = _mock_settings (backend = "cli" )), \
787+ patch ("clayde.claude._resolve_cli_bin" , return_value = "/usr/bin/claude" ), \
788+ patch ("clayde.claude.subprocess.run" , side_effect = __import__ ("subprocess" ).TimeoutExpired ("claude" , 1800 )), \
789+ patch ("clayde.claude.commit_wip" ):
790+ with pytest .raises (InvocationTimeoutError ):
791+ backend .invoke ("prompt" , "/repo" , branch_name = "branch" , conversation_path = conv_path )
792+
793+ # Session ID should be saved even though the process timed out
794+ assert conv_path .exists ()
795+ data = json .loads (conv_path .read_text ())
796+ assert data ["session_id" ] # a UUID was generated and saved
797+
798+ def test_timeout_preserves_session_id_for_resumed (self , tmp_path ):
799+ """When a resumed CLI session times out, the session ID is preserved for next resumption."""
800+ (tmp_path / "CLAUDE.md" ).write_text ("identity" )
801+ conv_path = tmp_path / "conv.json"
802+ conv_path .write_text (json .dumps ({"session_id" : "my-session" }))
803+ backend = CliBackend ()
804+
805+ with patch ("clayde.claude.APP_DIR" , tmp_path ), \
806+ patch ("clayde.claude.get_settings" , return_value = _mock_settings (backend = "cli" )), \
807+ patch ("clayde.claude._resolve_cli_bin" , return_value = "/usr/bin/claude" ), \
808+ patch ("clayde.claude.subprocess.run" , side_effect = __import__ ("subprocess" ).TimeoutExpired ("claude" , 1800 )), \
809+ patch ("clayde.claude.commit_wip" ):
810+ with pytest .raises (InvocationTimeoutError ):
811+ backend .invoke ("prompt" , "/repo" , branch_name = "branch" , conversation_path = conv_path )
812+
813+ # Session ID should still be in the conversation file
814+ data = json .loads (conv_path .read_text ())
815+ assert data ["session_id" ] == "my-session"
816+
747817 def test_fallback_on_non_json_stdout (self , tmp_path ):
748818 (tmp_path / "CLAUDE.md" ).write_text ("identity" )
749819 mock_result = MagicMock ()
@@ -760,8 +830,8 @@ def test_fallback_on_non_json_stdout(self, tmp_path):
760830
761831 assert result .output == "plain text output"
762832
763- def test_stale_session_retries_fresh (self , tmp_path ):
764- """When CLI reports 'No conversation found', delete conv file and retry without --resume ."""
833+ def test_stale_session_retries_with_new_session_id (self , tmp_path ):
834+ """When CLI reports 'No conversation found', retry with a new session ID ."""
765835 (tmp_path / "CLAUDE.md" ).write_text ("identity" )
766836 conv_path = tmp_path / "conv.json"
767837 conv_path .write_text (json .dumps ({"session_id" : "stale-session" }))
@@ -788,12 +858,13 @@ def test_stale_session_retries_fresh(self, tmp_path):
788858 result = backend .invoke ("prompt" , str (tmp_path ), conversation_path = conv_path )
789859
790860 assert result .output == "fresh output"
791- # First call should have --resume, second should not
861+ # First call should have --resume, second should have --session-id (new UUID)
792862 first_cmd = mock_run .call_args_list [0 ][0 ][0 ]
793863 second_cmd = mock_run .call_args_list [1 ][0 ][0 ]
794864 assert "--resume" in first_cmd
795865 assert "--resume" not in second_cmd
796- # Conv file should now have the new session ID
866+ assert "--session-id" in second_cmd
867+ # Conv file should now have the new session ID from the response
797868 data = json .loads (conv_path .read_text ())
798869 assert data ["session_id" ] == "new-session"
799870
0 commit comments