@@ -169,7 +169,8 @@ def test_readme_documents_the_combined_local_flow(self):
169169
170170class HookBehaviorTests (unittest .TestCase ):
171171 def run_hook (self , script , payload , tmpdir , path = None ):
172- env = dict (os .environ , TMPDIR = tmpdir )
172+ # HOME is pinned so tests never read the real ~/.composio.
173+ env = dict (os .environ , TMPDIR = tmpdir , HOME = tmpdir )
173174 if path is not None :
174175 env ["PATH" ] = path
175176 return subprocess .run (
@@ -182,11 +183,20 @@ def run_hook(self, script, payload, tmpdir, path=None):
182183 check = False ,
183184 )
184185
185- def fake_composio (self , tmpdir , stdout = "" , exit_code = 0 , toolkits = None ):
186+ def fake_composio (self , tmpdir , stdout = "" , exit_code = 0 , toolkits = None , version = None ):
186187 bindir = pathlib .Path (tmpdir ) / "bin"
187188 bindir .mkdir (exist_ok = True )
188189 script = bindir / "composio"
189190 lines = ["#!/usr/bin/env bash" ]
191+ if version is not None :
192+ lines .extend (
193+ [
194+ 'if [ "$1" = "version" ]; then' ,
195+ f" printf '%s\\ n' { shlex .quote (version )} " ,
196+ " exit 0" ,
197+ "fi" ,
198+ ]
199+ )
190200 if toolkits is not None :
191201 lines .extend (
192202 [
@@ -246,6 +256,59 @@ def test_session_start_offers_cli_install_in_terminal_context(self):
246256 self .assertNotIn ("composio execute" , context )
247257 self .assertNotIn ("composio search" , context )
248258
259+ NUDGE = "newer Composio CLI"
260+
261+ def write_update_cache (self , tmpdir , content ):
262+ cache_dir = pathlib .Path (tmpdir ) / ".composio"
263+ cache_dir .mkdir (exist_ok = True )
264+ (cache_dir / "update-check.json" ).write_text (content , encoding = "utf-8" )
265+
266+ def session_context (self , tmpdir , path ):
267+ result = self .run_hook (
268+ "session-start.sh" , {"hook_event_name" : "SessionStart" }, tmpdir , path
269+ )
270+ self .assertEqual (0 , result .returncode , result .stderr )
271+ return json .loads (result .stdout )["hookSpecificOutput" ]["additionalContext" ]
272+
273+ def test_session_start_nudges_upgrade_for_older_stable_cli (self ):
274+ with tempfile .TemporaryDirectory () as tmpdir :
275+ self .write_update_cache (
276+ tmpdir ,
277+ json .dumps (
278+ {"lastChecked" : "2026-07-24T00:00:00.000Z" , "latestVersion" : "0.2.32" }
279+ ),
280+ )
281+ path = self .fake_composio (
282+ tmpdir , stdout = '{"authenticated": true}' , version = "0.2.30"
283+ )
284+ context = self .session_context (tmpdir , path )
285+ self .assertIn ("local Composio CLI is available and signed in" , context )
286+ self .assertIn ("A newer Composio CLI (0.2.32) is available" , context )
287+ self .assertIn ("composio upgrade" , context )
288+
289+ def test_session_start_upgrade_nudge_degrades_to_silence (self ):
290+ cache = json .dumps (
291+ {"lastChecked" : "2026-07-24T00:00:00.000Z" , "latestVersion" : "0.2.32" }
292+ )
293+ silent_cases = [
294+ ("beta-installed" , cache , "0.2.32-beta.289" ),
295+ ("up-to-date" , cache , "0.2.32" ),
296+ ("installed-newer" , cache , "0.3.0" ),
297+ ("prerelease-latest" , cache .replace ("0.2.32" , "0.3.0-beta.1" ), "0.2.31" ),
298+ ("malformed-cache" , "not json at all {{{" , "0.2.30" ),
299+ ("cache-missing" , None , "0.2.30" ),
300+ ]
301+ for name , cache_content , installed in silent_cases :
302+ with self .subTest (name ):
303+ with tempfile .TemporaryDirectory () as tmpdir :
304+ if cache_content is not None :
305+ self .write_update_cache (tmpdir , cache_content )
306+ path = self .fake_composio (
307+ tmpdir , stdout = '{"authenticated": true}' , version = installed
308+ )
309+ context = self .session_context (tmpdir , path )
310+ self .assertNotIn (self .NUDGE , context )
311+
249312 def test_prompt_hook_is_surface_aware_for_cached_toolkits (self ):
250313 with tempfile .TemporaryDirectory () as tmpdir :
251314 cache = pathlib .Path (tmpdir ) / "composio-plugin-toolkits.cache"
0 commit comments