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:
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}
|
||||
|
||||
Reference in New Issue
Block a user