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