merge(tui): bascule robuste TUI native <-> CLI custom sans faux 'agent déjà ouvert ailleurs' (#189, QA verte)
This commit is contained in:
@ -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,
|
||||
|
||||
@ -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]
|
||||
async fn launch_agent_structured_forwards_resolved_effort_ahead_of_profile_default() {
|
||||
let profile = profile(
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -701,8 +701,13 @@ function LeafView({
|
||||
await vm.setSession(id, nextSession);
|
||||
}
|
||||
|
||||
async function stopCurrentSessionForSwitch(): Promise<void> {
|
||||
async function prepareSessionForModeSwitch(target: AgentCellMode): Promise<void> {
|
||||
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
|
||||
</h2>
|
||||
<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"
|
||||
? "la CLI custom"
|
||||
: "la TUI native"}
|
||||
.
|
||||
{" "}et reprendre la session depuis cette surface.
|
||||
</p>
|
||||
<p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-warning, #d49b3a)" }}>
|
||||
{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."}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
@ -1567,7 +1572,7 @@ function LeafView({
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Arrêter et relancer
|
||||
Basculer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user