fix(tui): la bascule TUI native <-> CLI custom ne déclenche plus de faux conflit de visibilité (#189)
This commit is contained in:
@ -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,
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user