From e9e4623ecfd13b1d4364ffce5e6c7be58ad2f81e Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 5 Aug 2026 14:24:55 +0200 Subject: [PATCH] fix(chat): stabilise le mode CLI custom contre le refetch stale agents/profiles (#149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le fallback custom→TUI se redéclenchait après le premier chargement dès que refreshProfiles() renvoyait un instant un profil pinné sans structuredAdapter (refetch en vol), coupant la CLI custom ~0.5s après son ouverture. Introduit un binding "trusted" (agent+profil validés) qui reste valide tant qu'aucun changement légitime de profil n'est observé via agentProfileChanged, et ne retombe en TUI que sur perte réelle du CLI custom. Co-Authored-By: Claude Sonnet 5 --- .../features/layout/LayoutGrid.chat.test.tsx | 64 +++++++++- frontend/src/features/layout/LayoutGrid.tsx | 111 ++++++++++++++++-- 2 files changed, 166 insertions(+), 9 deletions(-) diff --git a/frontend/src/features/layout/LayoutGrid.chat.test.tsx b/frontend/src/features/layout/LayoutGrid.chat.test.tsx index eed4c48..c8bad81 100644 --- a/frontend/src/features/layout/LayoutGrid.chat.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.chat.test.tsx @@ -54,7 +54,7 @@ import { } from "@/adapters/mock"; import { DIProvider } from "@/app/di"; import { leaves } from "./layout"; -import { LayoutGrid } from "./LayoutGrid"; +import { LayoutGrid, shouldFallbackCustomCliMode } from "./LayoutGrid"; const structuredProfile: AgentProfile = { id: "mock-structured", @@ -366,4 +366,66 @@ describe("LayoutGrid custom agent CLI (#147)", () => { expect(screen.getByTestId("terminal-view")).toBeTruthy(); expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull(); }); + + it("does not downgrade a validated custom CLI on a contradictory refetch without a profile-change event (#149)", () => { + expect( + shouldFallbackCustomCliMode({ + agentsLoaded: true, + profilesLoaded: true, + cellMode: "custom", + hasPinnedAgent: true, + hasPinnedProfile: true, + customCliAvailable: false, + effectiveCustomCliAvailable: true, + hasTrustedCustomCli: true, + hasLegitProfileChange: false, + }), + ).toBe(false); + }); + + it("falls back to native TUI after a legitimate profile-change event downgrades the profile (#149)", async () => { + const layout = new MockLayoutGateway(); + const agent = new MockAgentGateway(); + const profileGateway = new MockProfileGateway(); + const terminal = new MockTerminalGateway(); + const system = new MockSystemGateway(); + await profileGateway.configureProfiles([structuredProfile, ptyProfile]); + const created = await agent.createAgent("p1", { + name: "Worker", + profileId: structuredProfile.id, + }); + const tree = await layout.loadLayout("p1"); + const leafId = leaves(tree)[0].id; + await layout.mutateLayout("p1", { + type: "setCellAgent", + target: leafId, + agent: created.id, + }); + window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom"); + + renderGrid({ + layout, + agent, + profile: profileGateway, + terminal, + system, + } as unknown as Gateways); + + await waitFor(() => + expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(), + ); + + await agent.changeAgentProfile("p1", created.id, ptyProfile.id, 24, 80); + system.emit({ + type: "agentProfileChanged", + agentId: created.id, + profileId: ptyProfile.id, + }); + + await waitFor(() => + expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("tui"), + ); + expect(screen.getByTestId("terminal-view")).toBeTruthy(); + expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull(); + }); }); diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 6a62bc3..c6980a3 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -280,6 +280,30 @@ interface PendingModeSwitch { target: AgentCellMode; } +interface TrustedCustomCliBinding { + agent: Agent; + profile: AgentProfile; +} + +export function shouldFallbackCustomCliMode(input: { + agentsLoaded: boolean; + profilesLoaded: boolean; + cellMode: AgentCellMode; + hasPinnedAgent: boolean; + hasPinnedProfile: boolean; + customCliAvailable: boolean; + effectiveCustomCliAvailable: boolean; + hasTrustedCustomCli: boolean; + hasLegitProfileChange: boolean; +}): boolean { + if (!input.agentsLoaded || !input.profilesLoaded) return false; + if (input.cellMode !== "custom") return false; + if (!input.hasPinnedAgent || !input.hasPinnedProfile) return false; + if (!input.customCliAvailable && input.hasLegitProfileChange) return true; + if (input.effectiveCustomCliAvailable) return false; + return !input.hasTrustedCustomCli; +} + /** * Focuses the layout leaf with the given node id: scrolls it into view and * flashes a brief outline so the user sees where the agent already lives. Works @@ -443,6 +467,14 @@ function LeafView({ ); const [pendingModeSwitch, setPendingModeSwitch] = useState(null); + const [trustedCustomCli, setTrustedCustomCli] = + useState(null); + const legitProfileChangeRef = useRef<{ + agentId: string; + profileId: string; + seq: number; + } | null>(null); + const [legitProfileChangeSeq, setLegitProfileChangeSeq] = useState(0); // Load the agents currently running (and where), so the dropdown can disable an // agent already live in another cell — it cannot run in two cells at once. The @@ -517,6 +549,40 @@ function LeafView({ agentGateway?.cancelAgentChat && agentGateway?.closeAgentChat, ); + const trustedCustomCliAvailable = Boolean( + cellMode === "custom" && + agentId && + trustedCustomCli?.agent.id === agentId && + trustedCustomCli.profile.structuredAdapter && + agentGateway?.launchAgentChat && + agentGateway?.reattachAgentChat && + agentGateway?.sendAgentChat && + agentGateway?.cancelAgentChat && + agentGateway?.closeAgentChat, + ); + const effectiveCustomCliAvailable = + customCliAvailable || trustedCustomCliAvailable; + const customCliAgent = + customCliAvailable && pinnedAgent ? pinnedAgent : trustedCustomCli?.agent; + const customCliProfile = + customCliAvailable && pinnedProfile ? pinnedProfile : trustedCustomCli?.profile; + useEffect(() => { + if (cellMode !== "custom") { + setTrustedCustomCli(null); + return; + } + if (!customCliAvailable || !pinnedAgent || !pinnedProfile) return; + setTrustedCustomCli((prev) => { + if ( + prev?.agent.id === pinnedAgent.id && + prev.profile.id === pinnedProfile.id && + prev.profile.structuredAdapter === pinnedProfile.structuredAdapter + ) { + return prev; + } + return { agent: pinnedAgent, profile: pinnedProfile }; + }); + }, [cellMode, customCliAvailable, pinnedAgent, pinnedProfile]); useEffect(() => { if (!agentId) return; refreshAgents(); @@ -532,6 +598,13 @@ function LeafView({ void system .onDomainEvent((event) => { if (event.type !== "agentProfileChanged" || event.agentId !== agentId) return; + const seq = legitProfileChangeRef.current?.seq ?? 0; + legitProfileChangeRef.current = { + agentId: event.agentId, + profileId: event.profileId, + seq: seq + 1, + }; + setLegitProfileChangeSeq(seq + 1); refreshAgents(); refreshProfiles(); }) @@ -545,17 +618,39 @@ function LeafView({ }; }, [agentId, refreshAgents, refreshProfiles, system]); useEffect(() => { - if (!agentsLoaded || !profilesLoaded) return; - if (cellMode !== "custom") return; - if (pinnedAgent && pinnedProfile && !customCliAvailable) setCellMode("tui"); + const legitChange = legitProfileChangeRef.current; + const hasLegitDowngrade = Boolean( + pinnedAgent && + legitChange?.agentId === pinnedAgent.id && + legitChange.profileId === pinnedAgent.profileId, + ); + if ( + shouldFallbackCustomCliMode({ + agentsLoaded, + profilesLoaded, + cellMode, + hasPinnedAgent: Boolean(pinnedAgent), + hasPinnedProfile: Boolean(pinnedProfile), + customCliAvailable, + effectiveCustomCliAvailable, + hasTrustedCustomCli: Boolean(trustedCustomCli), + hasLegitProfileChange: hasLegitDowngrade, + }) + ) { + setTrustedCustomCli(null); + setCellMode("tui"); + } }, [ agentsLoaded, cellMode, customCliAvailable, + effectiveCustomCliAvailable, + legitProfileChangeSeq, pinnedAgent, pinnedProfile, profilesLoaded, setCellMode, + trustedCustomCli, ]); const modelServerStatus = statusForAgent(pinnedAgent); const modelServerOverlay = modelServerOverlayText(modelServerStatus); @@ -593,7 +688,7 @@ function LeafView({ } function requestMode(target: AgentCellMode): void { - if (!customCliAvailable || target === cellMode) return; + if (!effectiveCustomCliAvailable || target === cellMode) return; if (session) setPendingModeSwitch({ target }); else setCellMode(target); } @@ -953,7 +1048,7 @@ function LeafView({ })} - {customCliAvailable && ( + {effectiveCustomCliAvailable && (
- {agentId && cellMode === "custom" && customCliAvailable && pinnedAgent && pinnedProfile ? ( + {agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (