diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 9388d805..8205f926 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -1937,11 +1937,20 @@ impl LaunchAgent { { let host_node = structured.node_for_agent_in_project(input.project.id, &input.agent_id); - let node_id = match reattach_decision( - input.node_id, - host_node, - input.conversation_id.as_deref(), - ) { + let decision = if input.require_structured { + input + .node_id + .or(host_node) + .map(|node_id| ReattachDecision::Rebind { node_id }) + .unwrap_or(ReattachDecision::Idempotent) + } else { + reattach_decision( + input.node_id, + host_node, + input.conversation_id.as_deref(), + ) + }; + let node_id = match decision { ReattachDecision::Rebind { node_id } => { let _ = structured.rebind_agent_node_in_project( input.project.id, diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 256f5727..6fcf3f79 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -1344,6 +1344,68 @@ async fn chat_intent_replaces_existing_pty_instead_of_returning_pty_session() { ); } +#[tokio::test] +async fn chat_intent_reattaches_existing_structured_session_after_stale_pty_transition() { + let profile = profile( + pid(9), + ContextInjection::convention_file("CLAUDE.md").unwrap(), + ) + .with_structured_adapter(StructuredAdapter::Claude); + let (launch, agent, _fs, pty, _bus, sessions, tr, _session) = launch_fixture_with_profile( + profile, + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + let structured = Arc::new(StructuredSessions::new()); + let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888)); + let launch = launch + .with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback) + .with_structured(Arc::new(factory.clone()), Arc::clone(&structured)); + + let stale_custom_node = nid(7); + structured.insert_in_project( + project().id, + Arc::new(FakeSession { + id: sid(555), + conversation_id: Some("engine-session-stale".to_owned()), + }), + agent.id, + stale_custom_node, + ); + seed_live_agent_session(&sessions, agent.id, nid(8), sid(42)); + + let target = nid(9); + let mut input = launch_input(agent.id); + input.node_id = Some(target); + input.require_structured = true; + let out = launch.execute(input).await.unwrap(); + + assert_eq!(pty.kills(), vec![sid(42)], "old native TUI PTY is closed"); + assert!( + sessions + .session_for_agent_in_project(project().id, &agent.id) + .is_none(), + "old PTY registration is removed before the custom transition returns" + ); + assert_eq!( + factory.starts(), + Vec::::new(), + "existing custom session is resumed, not restarted" + ); + assert_eq!(out.session.id, sid(555)); + assert_eq!(out.session.node_id, target); + assert_eq!( + structured.node_for_agent_in_project(project().id, &agent.id), + Some(target), + "stale custom host node is rebound to the requested surface" + ); + assert!( + out.structured.is_some(), + "backend still returns a chat session descriptor" + ); +} + #[tokio::test] async fn launch_agent_structured_forwards_resolved_effort_ahead_of_profile_default() { let profile = profile( diff --git a/frontend/src/features/layout/LayoutGrid.chat.test.tsx b/frontend/src/features/layout/LayoutGrid.chat.test.tsx index 4612a8ad..31742442 100644 --- a/frontend/src/features/layout/LayoutGrid.chat.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.chat.test.tsx @@ -159,7 +159,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => { await waitFor(() => expect(screen.getByRole("alertdialog", { name: "Confirmer le changement de CLI" })).toBeTruthy(), ); - fireEvent.click(screen.getByText("Arrêter et relancer")); + fireEvent.click(screen.getByText("Basculer")); await waitFor(() => expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(), ); @@ -264,7 +264,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => { await waitFor(() => expect(screen.getByRole("alertdialog", { name: "Confirmer le changement de CLI" })).toBeTruthy(), ); - fireEvent.click(screen.getByText("Arrêter et relancer")); + fireEvent.click(screen.getByText("Basculer")); await waitFor(() => expect(closeChat).toHaveBeenCalledWith(expect.stringMatching(/^mock-agent-chat-/)), @@ -273,13 +273,26 @@ describe("LayoutGrid custom agent CLI (#147)", () => { await waitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy()); }); - it("requires confirmation before switching a live native TUI session", async () => { - renderGrid(await seeded(structuredProfile)); + it("switches a live native TUI session to custom CLI without stopping it first (#189)", async () => { + const gateways = await seeded(structuredProfile); + const agent = gateways.agent as MockAgentGateway; + const terminal = gateways.terminal as MockTerminalGateway; + const layout = gateways.layout as MockLayoutGateway; + const leafId = leaves(await layout.loadLayout("p1"))[0].id; + const stopLive = vi.spyOn(agent, "stopLiveAgent"); + const closeTerminal = vi.spyOn(terminal, "closeTerminal"); + const launchChat = vi.spyOn(agent, "launchAgentChat"); + + renderGrid(gateways); await waitFor(() => expect(screen.getByText("CLI custom")).toBeTruthy()); await waitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy(), ); + await waitFor(async () => { + const updated = await layout.loadLayout("p1"); + expect(leaves(updated)[0].session).toMatch(/^mock-agent-session-/); + }); fireEvent.click(screen.getByText("CLI custom")); @@ -288,10 +301,18 @@ describe("LayoutGrid custom agent CLI (#147)", () => { ); expect(screen.getByText(/Une reprise est possible/)).toBeTruthy(); - fireEvent.click(screen.getByText("Arrêter et relancer")); + fireEvent.click(screen.getByText("Basculer")); await waitFor(() => expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(), ); + expect(stopLive).not.toHaveBeenCalled(); + expect(closeTerminal).not.toHaveBeenCalled(); + await waitFor(() => expect(launchChat).toHaveBeenCalled()); + expect(launchChat.mock.calls.at(-1)?.[2]).toMatchObject({ nodeId: leafId }); + await waitFor(async () => { + const updated = await layout.loadLayout("p1"); + expect(leaves(updated)[0].session).toMatch(/^mock-agent-chat-/); + }); }); it("keeps a restored custom CLI mode while the agent/profile catalog is still loading (#149)", async () => { diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 10b9c644..1302f887 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -701,8 +701,13 @@ function LeafView({ await vm.setSession(id, nextSession); } - async function stopCurrentSessionForSwitch(): Promise { + async function prepareSessionForModeSwitch(target: AgentCellMode): Promise { if (!session) return; + if (target === "custom") { + await setSessionWithInstrumentation("mode-switch", null); + refreshLive(); + return; + } if (cellMode === "custom" && agentGateway?.closeAgentChat) { await agentGateway.closeAgentChat(session); } else if (agentId && agentGateway?.stopLiveAgent) { @@ -721,7 +726,7 @@ function LeafView({ if (!target) return; setBusyNotice(null); try { - await stopCurrentSessionForSwitch(); + await prepareSessionForModeSwitch(target); setCellMode(target); setPendingModeSwitch(null); } catch (err) { @@ -1522,16 +1527,16 @@ function LeafView({ Changer de CLI agent

- La session courante va être arrêtée avant de relancer{" "} + IdeA va basculer cette cellule vers{" "} {pendingModeSwitch.target === "custom" ? "la CLI custom" : "la TUI native"} - . + {" "}et reprendre la session depuis cette surface.

{conversationId ? "Une reprise est possible si le profil et le backend conservent cette conversation." - : "Aucune conversation reprenable n'est enregistrée pour cette cellule; la relance repartira à neuf."} + : "Aucune conversation reprenable n'est enregistrée pour cette cellule; la bascule repartira à neuf si le backend ne peut pas reprendre."}

- Arrêter et relancer + Basculer