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

@ -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<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
// 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({
})}
</select>
{customCliAvailable && (
{effectiveCustomCliAvailable && (
<div
role="group"
aria-label={`mode CLI agent ${id}`}
@ -1193,13 +1288,13 @@ function LeafView({
minWidth: 0,
}}
>
{agentId && cellMode === "custom" && customCliAvailable && pinnedAgent && pinnedProfile ? (
{agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
<CustomAgentChatView
key={`${id}-${agentId}-custom`}
projectId={projectId}
agentId={agentId}
agentName={pinnedAgent.name}
profile={pinnedProfile}
agentName={customCliAgent.name}
profile={customCliProfile}
cwd={cwd}
nodeId={id}
sessionId={session}