@@ -117,3 +117,98 @@ def test_switch_then_send_message(profile_store):
117117 # send_message triggers _ensure_agent_ready which re-registers agent LLMs;
118118 # the switched LLM must not cause a duplicate registration error.
119119 conv .send_message ("hello" )
120+
121+
122+ @pytest .fixture ()
123+ def empty_profile_store (tmp_path , monkeypatch ):
124+ """Empty profile dir — simulates the agent-server sandbox where the
125+ app-server has never uploaded profile JSON. This is the real failure
126+ mode #3017 is fixing.
127+ """
128+ profile_dir = tmp_path / "profiles"
129+ profile_dir .mkdir ()
130+ monkeypatch .setattr (llm_profile_store , "_DEFAULT_PROFILE_DIR" , profile_dir )
131+ return profile_dir
132+
133+
134+ def test_switch_llm_swaps_when_store_empty (empty_profile_store ):
135+ """Real app-server case (#3017): profile is unknown to the sandbox FS,
136+ the app-server supplies the LLM directly, and the swap succeeds.
137+ """
138+ conv = _make_conversation ()
139+ inline = _make_llm ("inline-model" , "caller-supplied-id" )
140+
141+ conv .switch_llm (inline )
142+
143+ assert conv .agent .llm .model == "inline-model"
144+ # State must agree — agent_server reads agent.llm via _state.
145+ assert conv .state .agent .llm .model == "inline-model"
146+ # Caller's usage_id is preserved as the registry key.
147+ assert conv .agent .llm .usage_id == "caller-supplied-id"
148+ assert conv .llm_registry .get ("caller-supplied-id" ).model == "inline-model"
149+ # Cache-key must be repinned (regression guard for #2918 on the new path).
150+ assert conv .agent .llm ._prompt_cache_key == str (conv .id )
151+
152+
153+ def test_switch_llm_then_send_message (empty_profile_store ):
154+ """send_message triggers _ensure_agent_ready, which re-registers agent
155+ LLMs in the registry. switch_llm adds an entry under the caller's
156+ usage_id; this must not collide with the agent's own LLM
157+ re-registration on the next send_message().
158+ """
159+ conv = _make_conversation ()
160+ conv .switch_llm (_make_llm ("inline-model" , "x" ))
161+ conv .send_message ("hello" )
162+
163+
164+ def test_switch_between_two_llms (empty_profile_store ):
165+ """Consecutive switch_llm calls under distinct usage_ids each register
166+ their own slot and end up as the agent's LLM.
167+ """
168+ conv = _make_conversation ()
169+
170+ conv .switch_llm (_make_llm ("model-a" , "x" ))
171+ assert conv .agent .llm .model == "model-a"
172+
173+ conv .switch_llm (_make_llm ("model-b" , "y" ))
174+ assert conv .agent .llm .model == "model-b"
175+
176+
177+ def test_switch_llm_does_not_consult_store (empty_profile_store , monkeypatch ):
178+ """switch_llm must not hit LLMProfileStore.load — the caller is
179+ authoritative. Guards against a regression where the inline path
180+ silently falls through to disk IO.
181+ """
182+ calls : list [str ] = []
183+
184+ def _spy_load (self , name ):
185+ calls .append (name )
186+ raise FileNotFoundError (name )
187+
188+ monkeypatch .setattr (LLMProfileStore , "load" , _spy_load )
189+
190+ conv = _make_conversation ()
191+ conv .switch_llm (_make_llm ("inline-model" , "x" ))
192+
193+ assert calls == [], f"profile store was consulted: { calls } "
194+
195+
196+ def test_switch_profile_delegates_to_switch_llm (profile_store , monkeypatch ):
197+ """switch_profile loads from disk and delegates to switch_llm; the LLM
198+ handed off carries the canonical ``profile:{name}`` usage_id.
199+ """
200+ conv = _make_conversation ()
201+ seen : list [LLM ] = []
202+ real_switch_llm = conv .switch_llm
203+
204+ def _spy (llm ):
205+ seen .append (llm )
206+ real_switch_llm (llm )
207+
208+ monkeypatch .setattr (conv , "switch_llm" , _spy )
209+
210+ conv .switch_profile ("fast" )
211+
212+ assert len (seen ) == 1
213+ assert seen [0 ].usage_id == "profile:fast"
214+ assert seen [0 ].model == "fast-model"
0 commit comments