fix(chat): stabilise le mode CLI custom contre le refetch stale agents/profiles (#149)

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 14:24:55 +02:00
parent 0e2fc83689
commit e9e4623ecf
2 changed files with 166 additions and 9 deletions

View File

@ -54,7 +54,7 @@ import {
} from "@/adapters/mock"; } from "@/adapters/mock";
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
import { leaves } from "./layout"; import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid"; import { LayoutGrid, shouldFallbackCustomCliMode } from "./LayoutGrid";
const structuredProfile: AgentProfile = { const structuredProfile: AgentProfile = {
id: "mock-structured", id: "mock-structured",
@ -366,4 +366,66 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
expect(screen.getByTestId("terminal-view")).toBeTruthy(); expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull(); 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();
});
}); });

View File

@ -280,6 +280,30 @@ interface PendingModeSwitch {
target: AgentCellMode; 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 * 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 * flashes a brief outline so the user sees where the agent already lives. Works
@ -443,6 +467,14 @@ function LeafView({
); );
const [pendingModeSwitch, setPendingModeSwitch] = const [pendingModeSwitch, setPendingModeSwitch] =
useState<PendingModeSwitch | null>(null); useState<PendingModeSwitch | null>(null);
const [trustedCustomCli, setTrustedCustomCli] =
useState<TrustedCustomCliBinding | null>(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 // 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 // agent already live in another cell — it cannot run in two cells at once. The
@ -517,6 +549,40 @@ function LeafView({
agentGateway?.cancelAgentChat && agentGateway?.cancelAgentChat &&
agentGateway?.closeAgentChat, 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(() => { useEffect(() => {
if (!agentId) return; if (!agentId) return;
refreshAgents(); refreshAgents();
@ -532,6 +598,13 @@ function LeafView({
void system void system
.onDomainEvent((event) => { .onDomainEvent((event) => {
if (event.type !== "agentProfileChanged" || event.agentId !== agentId) return; 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(); refreshAgents();
refreshProfiles(); refreshProfiles();
}) })
@ -545,17 +618,39 @@ function LeafView({
}; };
}, [agentId, refreshAgents, refreshProfiles, system]); }, [agentId, refreshAgents, refreshProfiles, system]);
useEffect(() => { useEffect(() => {
if (!agentsLoaded || !profilesLoaded) return; const legitChange = legitProfileChangeRef.current;
if (cellMode !== "custom") return; const hasLegitDowngrade = Boolean(
if (pinnedAgent && pinnedProfile && !customCliAvailable) setCellMode("tui"); 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, agentsLoaded,
cellMode, cellMode,
customCliAvailable, customCliAvailable,
effectiveCustomCliAvailable,
legitProfileChangeSeq,
pinnedAgent, pinnedAgent,
pinnedProfile, pinnedProfile,
profilesLoaded, profilesLoaded,
setCellMode, setCellMode,
trustedCustomCli,
]); ]);
const modelServerStatus = statusForAgent(pinnedAgent); const modelServerStatus = statusForAgent(pinnedAgent);
const modelServerOverlay = modelServerOverlayText(modelServerStatus); const modelServerOverlay = modelServerOverlayText(modelServerStatus);
@ -593,7 +688,7 @@ function LeafView({
} }
function requestMode(target: AgentCellMode): void { function requestMode(target: AgentCellMode): void {
if (!customCliAvailable || target === cellMode) return; if (!effectiveCustomCliAvailable || target === cellMode) return;
if (session) setPendingModeSwitch({ target }); if (session) setPendingModeSwitch({ target });
else setCellMode(target); else setCellMode(target);
} }
@ -953,7 +1048,7 @@ function LeafView({
})} })}
</select> </select>
{customCliAvailable && ( {effectiveCustomCliAvailable && (
<div <div
role="group" role="group"
aria-label={`mode CLI agent ${id}`} aria-label={`mode CLI agent ${id}`}
@ -1193,13 +1288,13 @@ function LeafView({
minWidth: 0, minWidth: 0,
}} }}
> >
{agentId && cellMode === "custom" && customCliAvailable && pinnedAgent && pinnedProfile ? ( {agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
<CustomAgentChatView <CustomAgentChatView
key={`${id}-${agentId}-custom`} key={`${id}-${agentId}-custom`}
projectId={projectId} projectId={projectId}
agentId={agentId} agentId={agentId}
agentName={pinnedAgent.name} agentName={customCliAgent.name}
profile={pinnedProfile} profile={customCliProfile}
cwd={cwd} cwd={cwd}
nodeId={id} nodeId={id}
sessionId={session} sessionId={session}