@@ -259,6 +259,113 @@ def observe(profile):
259259 runtime .close ()
260260
261261
262+ @pytest .mark .parametrize ("group" , [False , True ])
263+ @pytest .mark .parametrize ("change" , ["revoke" , "replace" , "unrelated" ])
264+ def test_binding_change_while_identity_is_observed_uses_current_grant (ordinary , monkeypatch , group , change ): # noqa: F811
265+ from test_lark_private_conversations import connect , connect_group
266+ from loopx .extensions .lark .conversation_identity import lark_private_source
267+
268+ store , runtime , provider , transport = (connect_group if group else connect )(ordinary )
269+ bindings = transport .bindings
270+ binding = transport ._binding ("notes-app" )
271+ event = provider .topic ("grant-observation" , "Read project" ) if group else provider .event ("notes-app" , "grant-observation" , "Read project" )
272+ source = lark_private_source (provider_ref = binding ["provider_ref" ], event = event )
273+ observe , armed = bindings .observe , True
274+
275+ def changed (profile , ** kwargs ):
276+ nonlocal armed
277+ result = observe (profile , ** kwargs )
278+ if armed :
279+ armed = False
280+ if change == "revoke" :
281+ bindings .disconnect (binding ["binding_id" ], expected_revision = bindings .read ()["revision" ])
282+ else :
283+ target = "steward-app" if change == "unrelated" else "notes-app"
284+ if change == "replace" :
285+ provider .profile_apps [target ] = "cli_replaced_app"
286+ bindings .configure (transport_ref = target , project_ref = binding ["project_ref" ],
287+ executor_endpoint_id = "codex" , audience = "group" if group else None ,
288+ group_refs = binding .get ("group_refs" ), available_group_refs = binding .get ("group_refs" ))
289+ return result
290+
291+ monkeypatch .setattr (bindings , "observe" , changed )
292+ try :
293+ if change == "unrelated" :
294+ selected = bindings .resolve (binding_id = binding ["binding_id" ], ** source )
295+ assert selected ["binding" ] == binding
296+ else :
297+ with pytest .raises (ValueError , match = "binding|identity|authorized" ):
298+ bindings .resolve (binding_id = binding ["binding_id" ], ** source )
299+ assert not any (row ["enabled" ] and row ["binding_id" ] == binding ["binding_id" ]
300+ for row in bindings .read ()["bindings" ])
301+ assert not store .list_sessions () and not provider .writes
302+ finally :
303+ runtime .close ()
304+
305+
306+ @pytest .mark .parametrize ("revoke" , [False , True ])
307+ def test_prepared_stop_recovery_waits_for_route_fence_before_its_identity_probe (ordinary , monkeypatch , revoke ): # noqa: F811
308+ import threading
309+ from concurrent .futures import ThreadPoolExecutor
310+ from test_chat_completed_background_stop import _completed_with_later_answer
311+ from loopx .file_lock import exclusive_file_lock
312+
313+ store , runtime , provider , transport , root , first , wire = _completed_with_later_answer (ordinary , monkeypatch )
314+ stop = provider .topic ("fenced-recovery-stop" , "/stop" , root = root ["message_id" ])
315+ transport .admit ("notes-app" , stop , defer_background_stop = True )
316+ frozen = next (row for row in transport .core .pending () if row ["command" ] == "stop" )
317+ assert frozen ["status" ] == "prepared" and frozen ["target_recorded" ]
318+ fence = transport .core .root / "source-fences" / f"{ frozen ['binding_id' ]} .{ frozen ['source' ]['source_ref' ]} .json"
319+ started , observed = threading .Event (), threading .Event ()
320+ observe , finish = transport .bindings .observe , transport .core ._finish_background_stop
321+ before_native = []
322+
323+ def observing (* args , ** kwargs ):
324+ observed .set ()
325+ return observe (* args , ** kwargs )
326+
327+ def finishing (* args , ** kwargs ):
328+ before_native .append (sum ("auth" in call for call in provider .calls ))
329+ return finish (* args , ** kwargs )
330+
331+ def recover ():
332+ started .set ()
333+ transport .core .recover (request_ref = frozen ["request_ref" ])
334+
335+ monkeypatch .setattr (transport .bindings , "observe" , observing )
336+ monkeypatch .setattr (transport .core , "_finish_background_stop" , finishing )
337+ provider .calls .clear ()
338+ writes_before = list (provider .writes )
339+ receipts = {turn_id : store .load_turn (first ["session_id" ], turn_id )
340+ for turn_id in frozen ["background_turn_ids" ]}
341+ try :
342+ with ThreadPoolExecutor (max_workers = 1 ) as pool :
343+ with exclusive_file_lock (fence , operation = "hold_original_source_route" ):
344+ future = pool .submit (recover )
345+ assert started .wait (5 )
346+ assert not observed .wait (.1 ), "A pre-fence probe cannot authorize prepared recovery"
347+ if revoke :
348+ transport .bindings .disconnect (frozen ["binding_id" ], expected_revision = transport .bindings .read ()["revision" ])
349+ future .result (timeout = 10 )
350+ current = transport .core .read_request (frozen ["request_ref" ])
351+ if revoke :
352+ assert current ["status" ] == "prepared" and current ["background_turn_ids" ] == frozen ["background_turn_ids" ]
353+ assert not before_native and wire .children ["owned" ].poll () is None
354+ assert provider .writes == writes_before
355+ else :
356+ assert before_native == [1 ], "The fenced admission already owns the fresh identity proof"
357+ assert current ["status" ] == "command_completed" and current ["background_turn_ids" ] == [first ["turn_id" ]]
358+ assert wire .children ["owned" ].poll () is not None
359+ transport .reconcile ()
360+ assert sum ("停止" in text for _ , text in provider .writes ) == 1
361+ assert wire .children ["unowned" ].poll () is None
362+ assert all (store .load_turn (first ["session_id" ], turn_id ) == receipt
363+ for turn_id , receipt in receipts .items ())
364+ finally :
365+ wire .close ()
366+ runtime .close ()
367+
368+
262369@pytest .mark .parametrize ("resumed" , [False , True ])
263370def test_group_disconnect_stops_running_and_queued_turns_without_stopping_other_session (ordinary , resumed ): # noqa: F811
264371 from test_lark_private_conversations import connect_group
0 commit comments