@@ -88,21 +88,27 @@ jest.mock("./components/Chat/ChatWindow", () => {
8888 const MockChatWindow = ( {
8989 onNewAttack,
9090 activeTarget,
91+ attackResultId,
9192 conversationId,
93+ activeConversationId,
9294 onConversationCreated,
9395 onSelectConversation,
9496 labels,
9597 } : {
9698 onNewAttack : ( ) => void ;
9799 activeTarget : unknown ;
100+ attackResultId : string | null ;
98101 conversationId : string | null ;
102+ activeConversationId : string | null ;
99103 onConversationCreated : ( attackResultId : string , conversationId : string ) => void ;
100104 onSelectConversation : ( convId : string ) => void ;
101105 labels : Record < string , string > ;
102106 } ) => {
103107 return (
104108 < div data-testid = "chat-window" >
109+ < span data-testid = "attack-result-id" > { attackResultId ?? "none" } </ span >
105110 < span data-testid = "conversation-id" > { conversationId ?? "none" } </ span >
111+ < span data-testid = "active-conversation-id" > { activeConversationId ?? "none" } </ span >
106112 < span data-testid = "has-target" > { activeTarget ? "yes" : "no" } </ span >
107113 < span data-testid = "labels-operator" > { labels . operator ?? "" } </ span >
108114 < span data-testid = "labels-json" > { JSON . stringify ( labels ) } </ span >
@@ -181,6 +187,12 @@ jest.mock("./components/History/AttackHistory", () => {
181187 >
182188 Open Attack
183189 </ button >
190+ < button
191+ onClick = { ( ) => onOpenAttack ( "ar-attack-2" ) }
192+ data-testid = "open-attack-2"
193+ >
194+ Open Attack 2
195+ </ button >
184196 </ div >
185197 ) ;
186198 } ;
@@ -419,6 +431,58 @@ describe("App", () => {
419431 await waitFor ( ( ) => expect ( screen . getByTestId ( "conversation-id" ) ) . toHaveTextContent ( "none" ) ) ;
420432 } ) ;
421433
434+ it ( "clears activeConversationId synchronously before fetching a new attack" , async ( ) => {
435+ // Repro: in attack A the user branched into a related conversation, so
436+ // activeConversationId points to a conv that does NOT belong to attack B.
437+ // When the user clicks Open Attack on B, App.tsx must clear the stale
438+ // conv id *before* flipping attackResultId — otherwise ChatWindow renders
439+ // with (attackResultId=B, activeConversationId=A_conv) during the in-flight
440+ // getAttack and issues GET /messages?conversation_id=A_conv → 400.
441+
442+ // Defer getAttack so we can inspect the intermediate render before it resolves.
443+ let resolveGetAttack : ( value : unknown ) => void = ( ) => { } ;
444+ mockGetAttack . mockImplementation (
445+ ( ) => new Promise ( ( resolve ) => { resolveGetAttack = resolve } )
446+ ) ;
447+
448+ render ( < App /> ) ;
449+
450+ // Simulate: user is already on attack A with a branched conv selected.
451+ fireEvent . click ( screen . getByTestId ( "nav-chat" ) ) ;
452+ fireEvent . click ( screen . getByTestId ( "set-conversation" ) ) ; // attack A, main conv-123
453+ // Resolve the (unrelated) getAttack triggered earlier to keep state quiet
454+ // — actually nothing called it yet because set-conversation routes through
455+ // onConversationCreated, not handleOpenAttack. Proceed.
456+ fireEvent . click ( screen . getByTestId ( "select-conversation" ) ) ; // branched conv-456 in attack A
457+ expect ( screen . getByTestId ( "attack-result-id" ) ) . toHaveTextContent ( "ar-123" ) ;
458+ expect ( screen . getByTestId ( "active-conversation-id" ) ) . toHaveTextContent ( "conv-456" ) ;
459+
460+ // User clicks Open Attack on attack B in history.
461+ fireEvent . click ( screen . getByTestId ( "nav-history" ) ) ;
462+ fireEvent . click ( screen . getByTestId ( "open-attack-2" ) ) ; // ar-attack-2
463+
464+ // BEFORE getAttack resolves: ChatWindow must NOT see the stale conv id
465+ // alongside the new attack id. This is the invariant the fix establishes.
466+ expect ( screen . getByTestId ( "main-layout" ) ) . toHaveAttribute (
467+ "data-current-view" ,
468+ "chat"
469+ ) ;
470+ expect ( screen . getByTestId ( "attack-result-id" ) ) . toHaveTextContent ( "ar-attack-2" ) ;
471+ expect ( screen . getByTestId ( "active-conversation-id" ) ) . toHaveTextContent ( "none" ) ;
472+ expect ( screen . getByTestId ( "conversation-id" ) ) . toHaveTextContent ( "none" ) ;
473+
474+ // After getAttack resolves: the conv id belonging to attack B is committed.
475+ resolveGetAttack ( {
476+ attack_result_id : "ar-attack-2" ,
477+ conversation_id : "attack-conv-2" ,
478+ labels : { } ,
479+ } ) ;
480+ await waitFor ( ( ) =>
481+ expect ( screen . getByTestId ( "active-conversation-id" ) ) . toHaveTextContent ( "attack-conv-2" )
482+ ) ;
483+ expect ( screen . getByTestId ( "conversation-id" ) ) . toHaveTextContent ( "attack-conv-2" ) ;
484+ } ) ;
485+
422486 it ( "merges default labels from backend version API" , async ( ) => {
423487 mockedVersionApi . getVersion . mockResolvedValueOnce ( {
424488 version : "2.0.0" ,
0 commit comments