fix(tui): la bascule TUI native <-> CLI custom ne déclenche plus de faux conflit de visibilité (#189)

This commit is contained in:
2026-08-11 19:27:15 +02:00
parent 252e82ecea
commit 17b98e3d03
4 changed files with 113 additions and 16 deletions

View File

@ -1937,11 +1937,20 @@ impl LaunchAgent {
{ {
let host_node = let host_node =
structured.node_for_agent_in_project(input.project.id, &input.agent_id); structured.node_for_agent_in_project(input.project.id, &input.agent_id);
let node_id = match reattach_decision( let decision = if input.require_structured {
input.node_id, input
host_node, .node_id
input.conversation_id.as_deref(), .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 } => { ReattachDecision::Rebind { node_id } => {
let _ = structured.rebind_agent_node_in_project( let _ = structured.rebind_agent_node_in_project(
input.project.id, input.project.id,

View File

@ -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::<ProfileId>::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] #[tokio::test]
async fn launch_agent_structured_forwards_resolved_effort_ahead_of_profile_default() { async fn launch_agent_structured_forwards_resolved_effort_ahead_of_profile_default() {
let profile = profile( let profile = profile(

View File

@ -159,7 +159,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
await waitFor(() => await waitFor(() =>
expect(screen.getByRole("alertdialog", { name: "Confirmer le changement de CLI" })).toBeTruthy(), 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(() => await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(), expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
); );
@ -264,7 +264,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
await waitFor(() => await waitFor(() =>
expect(screen.getByRole("alertdialog", { name: "Confirmer le changement de CLI" })).toBeTruthy(), 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(() => await waitFor(() =>
expect(closeChat).toHaveBeenCalledWith(expect.stringMatching(/^mock-agent-chat-/)), 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()); await waitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy());
}); });
it("requires confirmation before switching a live native TUI session", async () => { it("switches a live native TUI session to custom CLI without stopping it first (#189)", async () => {
renderGrid(await seeded(structuredProfile)); 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.getByText("CLI custom")).toBeTruthy());
await waitFor(() => await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toBeTruthy(), 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")); fireEvent.click(screen.getByText("CLI custom"));
@ -288,10 +301,18 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
); );
expect(screen.getByText(/Une reprise est possible/)).toBeTruthy(); expect(screen.getByText(/Une reprise est possible/)).toBeTruthy();
fireEvent.click(screen.getByText("Arrêter et relancer")); fireEvent.click(screen.getByText("Basculer"));
await waitFor(() => await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(), 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 () => { it("keeps a restored custom CLI mode while the agent/profile catalog is still loading (#149)", async () => {

View File

@ -701,8 +701,13 @@ function LeafView({
await vm.setSession(id, nextSession); await vm.setSession(id, nextSession);
} }
async function stopCurrentSessionForSwitch(): Promise<void> { async function prepareSessionForModeSwitch(target: AgentCellMode): Promise<void> {
if (!session) return; if (!session) return;
if (target === "custom") {
await setSessionWithInstrumentation("mode-switch", null);
refreshLive();
return;
}
if (cellMode === "custom" && agentGateway?.closeAgentChat) { if (cellMode === "custom" && agentGateway?.closeAgentChat) {
await agentGateway.closeAgentChat(session); await agentGateway.closeAgentChat(session);
} else if (agentId && agentGateway?.stopLiveAgent) { } else if (agentId && agentGateway?.stopLiveAgent) {
@ -721,7 +726,7 @@ function LeafView({
if (!target) return; if (!target) return;
setBusyNotice(null); setBusyNotice(null);
try { try {
await stopCurrentSessionForSwitch(); await prepareSessionForModeSwitch(target);
setCellMode(target); setCellMode(target);
setPendingModeSwitch(null); setPendingModeSwitch(null);
} catch (err) { } catch (err) {
@ -1522,16 +1527,16 @@ function LeafView({
Changer de CLI agent Changer de CLI agent
</h2> </h2>
<p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-content-muted, #9a9a9a)" }}> <p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-content-muted, #9a9a9a)" }}>
La session courante va être arrêtée avant de relancer{" "} IdeA va basculer cette cellule vers{" "}
{pendingModeSwitch.target === "custom" {pendingModeSwitch.target === "custom"
? "la CLI custom" ? "la CLI custom"
: "la TUI native"} : "la TUI native"}
. {" "}et reprendre la session depuis cette surface.
</p> </p>
<p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-warning, #d49b3a)" }}> <p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-warning, #d49b3a)" }}>
{conversationId {conversationId
? "Une reprise est possible si le profil et le backend conservent cette conversation." ? "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."}
</p> </p>
<div <div
style={{ style={{
@ -1567,7 +1572,7 @@ function LeafView({
cursor: "pointer", cursor: "pointer",
}} }}
> >
Arrêter et relancer Basculer
</button> </button>
</div> </div>
</div> </div>