feat(wave): #119/#122/#131/#132 verts + sprint plugins ESM/persistance #135/#136/#139
État d'intégration confiné à la branche batch. Les tickets #119 (skills → capacités agent découvrables), #122 (override permissions par défaut), #131 (effort par agent/presets) et #132 (outil MCP d'édition du contexte projet) sont verts en périmètre. Le sprint plugins multi-fichiers ESM / persistance plugin-owned (#135/#136/#139) est co-implémenté dans les MÊMES fichiers de câblage (frontend/src/ports/index.ts, backend/src/lib.rs, domain/ports.rs, backend/dto.rs), inséparable sans staging interactif (indisponible ici). Commit unique volontaire : préserve l'état vert QA sans découpe hunk risquée. NON mergé vers develop tant que #137 (QA e2e plugins) n'est pas vert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -25,7 +25,13 @@ import { useAgents } from "./useAgents";
|
||||
import { correlateModelServerStatus } from "./modelServerLaunch";
|
||||
import { AgentLimitBadge } from "./AgentLimitBadge";
|
||||
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||||
import type { ResolvedAgentSystemPermissions } from "@/domain";
|
||||
import type {
|
||||
Agent,
|
||||
AgentProfile,
|
||||
EffortOption,
|
||||
EffortSelection,
|
||||
ResolvedAgentSystemPermissions,
|
||||
} from "@/domain";
|
||||
|
||||
export interface AgentsPanelProps {
|
||||
/** The project whose agents to manage. */
|
||||
@ -237,7 +243,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
// Determine if a template is chosen → profile selector is hidden (template imposes it).
|
||||
const hasTemplate = newTemplateId !== "";
|
||||
|
||||
const profileLabel = (profile: import("@/domain").AgentProfile): string => {
|
||||
const profileLabel = (profile: AgentProfile): string => {
|
||||
const model =
|
||||
profile.model ??
|
||||
profile.opencode?.model ??
|
||||
@ -365,12 +371,9 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
const isSelected = a.id === vm.selectedAgentId;
|
||||
const isRunning = a.id === activeAgentId;
|
||||
const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id);
|
||||
const agentProfile = vm.profiles.find((p) => p.id === a.profileId) ?? null;
|
||||
const profileName =
|
||||
(() => {
|
||||
const p = vm.profiles.find((p) => p.id === a.profileId);
|
||||
return p ? profileLabel(p) : null;
|
||||
})() ??
|
||||
a.profileId;
|
||||
(agentProfile ? profileLabel(agentProfile) : null) ?? a.profileId;
|
||||
const agentDrift = drift.driftByAgentId.get(a.id);
|
||||
// Source of this agent's last orchestration delegation (mcp vs
|
||||
// file), if any has been observed. Absent ⇒ no badge.
|
||||
@ -480,6 +483,14 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<EffortSelector
|
||||
agent={a}
|
||||
profile={agentProfile}
|
||||
busy={vm.busy}
|
||||
onChange={(effort) =>
|
||||
void vm.updateAgentEffort(a.id, effort)
|
||||
}
|
||||
/>
|
||||
{agentDrift && (
|
||||
<Button
|
||||
size="sm"
|
||||
@ -706,6 +717,141 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const EFFORT_DEFAULT_VALUE = "__profile_default__";
|
||||
const EFFORT_CUSTOM_VALUE = "__custom_effort__";
|
||||
|
||||
const GENERIC_EFFORT_OPTIONS: EffortOption[] = [
|
||||
{
|
||||
value: "low",
|
||||
label: "Rapide (par défaut)",
|
||||
hint: "Fallback générique léger.",
|
||||
},
|
||||
{
|
||||
value: "medium",
|
||||
label: "Standard (par défaut)",
|
||||
hint: "Fallback générique équilibré.",
|
||||
},
|
||||
{
|
||||
value: "high",
|
||||
label: "Approfondi (par défaut)",
|
||||
hint: "Fallback générique profond.",
|
||||
},
|
||||
];
|
||||
|
||||
function rawEffortValue(selection: EffortSelection | undefined): string {
|
||||
return selection?.value ?? "";
|
||||
}
|
||||
|
||||
function EffortSelector({
|
||||
agent,
|
||||
profile,
|
||||
busy,
|
||||
onChange,
|
||||
}: {
|
||||
agent: Agent;
|
||||
profile: AgentProfile | null;
|
||||
busy: boolean;
|
||||
onChange: (effort: EffortSelection | null) => void;
|
||||
}) {
|
||||
const nativeOptions = profile?.effortOptions ?? [];
|
||||
const hasNativeOptions = nativeOptions.length > 0;
|
||||
const displayedOptions = hasNativeOptions
|
||||
? nativeOptions
|
||||
: GENERIC_EFFORT_OPTIONS;
|
||||
const [customText, setCustomText] = useState(rawEffortValue(agent.effort));
|
||||
const [forceCustom, setForceCustom] = useState(agent.effort?.kind === "custom");
|
||||
|
||||
useEffect(() => {
|
||||
setCustomText(rawEffortValue(agent.effort));
|
||||
setForceCustom(agent.effort?.kind === "custom");
|
||||
}, [agent.id, agent.effort]);
|
||||
|
||||
let selectedOption = EFFORT_DEFAULT_VALUE;
|
||||
if (forceCustom) {
|
||||
selectedOption = EFFORT_CUSTOM_VALUE;
|
||||
} else if (
|
||||
hasNativeOptions &&
|
||||
agent.effort?.kind === "preset" &&
|
||||
nativeOptions.some((option) => option.value === agent.effort?.value)
|
||||
) {
|
||||
selectedOption = `preset:${agent.effort.value}`;
|
||||
} else if (
|
||||
!hasNativeOptions &&
|
||||
agent.effort &&
|
||||
displayedOptions.some((option) => option.value === agent.effort?.value)
|
||||
) {
|
||||
selectedOption = `fallback:${agent.effort.value}`;
|
||||
} else if (agent.effort) {
|
||||
selectedOption = EFFORT_CUSTOM_VALUE;
|
||||
}
|
||||
|
||||
const customVisible = selectedOption === EFFORT_CUSTOM_VALUE;
|
||||
|
||||
function commitCustom() {
|
||||
const value = customText.trim();
|
||||
if (value.length === 0) return;
|
||||
if (agent.effort?.kind === "custom" && agent.effort.value === value) return;
|
||||
onChange({ kind: "custom", value });
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex min-w-[11rem] max-w-full flex-wrap items-center gap-1.5">
|
||||
<SmallDropdown
|
||||
aria-label={`effort for ${agent.name}`}
|
||||
value={selectedOption}
|
||||
disabled={busy}
|
||||
onChange={(value) => {
|
||||
if (value === EFFORT_DEFAULT_VALUE) {
|
||||
setForceCustom(false);
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
if (value === EFFORT_CUSTOM_VALUE) {
|
||||
setForceCustom(true);
|
||||
setCustomText(rawEffortValue(agent.effort));
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("preset:")) {
|
||||
setForceCustom(false);
|
||||
onChange({ kind: "preset", value: value.slice("preset:".length) });
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("fallback:")) {
|
||||
setForceCustom(false);
|
||||
onChange({ kind: "custom", value: value.slice("fallback:".length) });
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: EFFORT_DEFAULT_VALUE, label: "Effort: profil" },
|
||||
...displayedOptions.map((option) => ({
|
||||
value: `${hasNativeOptions ? "preset" : "fallback"}:${option.value}`,
|
||||
label: option.label,
|
||||
})),
|
||||
{ value: EFFORT_CUSTOM_VALUE, label: "Personnalisé" },
|
||||
]}
|
||||
/>
|
||||
{customVisible && (
|
||||
<Input
|
||||
aria-label={`custom effort for ${agent.name}`}
|
||||
value={customText}
|
||||
disabled={busy}
|
||||
placeholder="valeur brute"
|
||||
onChange={(event) => setCustomText(event.target.value)}
|
||||
onBlur={commitCustom}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitCustom();
|
||||
}
|
||||
}}
|
||||
className="h-8 min-w-[8rem] flex-1 px-2 text-xs"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkPermissionBadge({
|
||||
state,
|
||||
}: {
|
||||
|
||||
@ -492,6 +492,11 @@ async function seededProfiles(): Promise<MockProfileGateway> {
|
||||
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
|
||||
detect: null,
|
||||
cwdTemplate: "{projectRoot}",
|
||||
effortOptions: [
|
||||
{ value: "low", label: "Léger" },
|
||||
{ value: "medium", label: "Standard" },
|
||||
{ value: "high", label: "Profond" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "prof-2",
|
||||
@ -588,6 +593,123 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentsPanel effort selection (#131)", () => {
|
||||
it("shows native profile effort options in declaration order with Personnalisé last", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
await agent.createAgent(PROJECT_ID, { name: "Thinker", profileId: "prof-1" });
|
||||
const profile = await seededProfiles();
|
||||
|
||||
renderPanel(agent, profile);
|
||||
await waitForIdle();
|
||||
await screen.findByText("Thinker");
|
||||
|
||||
openDropdown("effort for Thinker");
|
||||
const labels = screen
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent);
|
||||
|
||||
expect(labels).toEqual([
|
||||
"Effort: profil",
|
||||
"Léger",
|
||||
"Standard",
|
||||
"Profond",
|
||||
"Personnalisé",
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists a native effort option as a preset", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const created = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Thinker",
|
||||
profileId: "prof-1",
|
||||
});
|
||||
const updateSpy = vi.spyOn(agent, "updateAgentEffort");
|
||||
|
||||
renderPanel(agent, await seededProfiles());
|
||||
await waitForIdle();
|
||||
await screen.findByText("Thinker");
|
||||
|
||||
chooseDropdownOption("effort for Thinker", "Profond");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, created.id, {
|
||||
kind: "preset",
|
||||
value: "high",
|
||||
});
|
||||
});
|
||||
const [updated] = await agent.listAgents(PROJECT_ID);
|
||||
expect(updated.effort).toEqual({ kind: "preset", value: "high" });
|
||||
});
|
||||
|
||||
it("shows generic default fallback options when the profile declares no native options", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const profile = new MockProfileGateway();
|
||||
await profile.configureProfiles([
|
||||
{
|
||||
id: "plain",
|
||||
name: "Plain provider",
|
||||
command: "plain-ai",
|
||||
args: [],
|
||||
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
||||
detect: null,
|
||||
cwdTemplate: "{projectRoot}",
|
||||
},
|
||||
]);
|
||||
const created = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Fallback",
|
||||
profileId: "plain",
|
||||
});
|
||||
|
||||
renderPanel(agent, profile);
|
||||
await waitForIdle();
|
||||
await screen.findByText("Fallback");
|
||||
|
||||
openDropdown("effort for Fallback");
|
||||
const labels = screen
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent);
|
||||
expect(labels).toEqual([
|
||||
"Effort: profil",
|
||||
"Rapide (par défaut)",
|
||||
"Standard (par défaut)",
|
||||
"Approfondi (par défaut)",
|
||||
"Personnalisé",
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByRole("option", { name: "Standard (par défaut)" }));
|
||||
await waitFor(async () => {
|
||||
const [updated] = await agent.listAgents(PROJECT_ID);
|
||||
expect(updated.id).toBe(created.id);
|
||||
expect(updated.effort).toEqual({ kind: "custom", value: "medium" });
|
||||
});
|
||||
});
|
||||
|
||||
it("reveals an inline custom effort field and persists the free text", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const created = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Custom",
|
||||
profileId: "prof-1",
|
||||
});
|
||||
const updateSpy = vi.spyOn(agent, "updateAgentEffort");
|
||||
|
||||
renderPanel(agent, await seededProfiles());
|
||||
await waitForIdle();
|
||||
await screen.findByText("Custom");
|
||||
|
||||
chooseDropdownOption("effort for Custom", "Personnalisé");
|
||||
const input = screen.getByLabelText("custom effort for Custom");
|
||||
fireEvent.change(input, { target: { value: "x-provider-deep" } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, created.id, {
|
||||
kind: "custom",
|
||||
value: "x-provider-deep",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentsPanel live refresh on domain events", () => {
|
||||
it("refreshes the list when an `agentLaunched` event fires (out-of-band creation)", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
|
||||
@ -12,6 +12,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import type {
|
||||
Agent,
|
||||
AgentProfile,
|
||||
EffortSelection,
|
||||
GatewayError,
|
||||
ModelServerStatus,
|
||||
TerminalSession,
|
||||
@ -124,6 +125,11 @@ export interface AgentsViewModel {
|
||||
rows: number,
|
||||
cols: number,
|
||||
) => Promise<TerminalSession | undefined>;
|
||||
/** Sets or clears a per-agent effort override. */
|
||||
updateAgentEffort: (
|
||||
agentId: string,
|
||||
effort: EffortSelection | null,
|
||||
) => Promise<void>;
|
||||
/** Deletes an agent; deselects if it was selected. */
|
||||
deleteAgent: (agentId: string) => Promise<void>;
|
||||
/**
|
||||
@ -427,6 +433,26 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
[agent, projectId, refreshLiveAgents],
|
||||
);
|
||||
|
||||
const updateAgentEffort = useCallback(
|
||||
async (agentId: string, effort: EffortSelection | null): Promise<void> => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await agent.updateAgentEffort(projectId, agentId, effort);
|
||||
setAgents((prev) =>
|
||||
prev.map((candidate) =>
|
||||
candidate.id === updated.id ? updated : candidate,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[agent, projectId],
|
||||
);
|
||||
|
||||
const deleteAgent = useCallback(
|
||||
async (agentId: string) => {
|
||||
setBusy(true);
|
||||
@ -544,6 +570,7 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
selectAgent,
|
||||
saveContext,
|
||||
changeAgentProfile,
|
||||
updateAgentEffort,
|
||||
deleteAgent,
|
||||
launchAgent,
|
||||
stopAgent,
|
||||
|
||||
@ -8,8 +8,10 @@ import type {
|
||||
PermissionPosture,
|
||||
PermissionRule,
|
||||
PermissionSet,
|
||||
PermissionShadowReport,
|
||||
ProjectPermissions,
|
||||
ProjectSystemPermissions,
|
||||
ResolvedAgentPermissions,
|
||||
ResolvedAgentSystemPermissions,
|
||||
SystemPermissionSet,
|
||||
} from "@/domain";
|
||||
@ -27,6 +29,7 @@ export interface PolicyDraft {
|
||||
export interface AgentPermissionRow {
|
||||
agent: Agent;
|
||||
override: PermissionSet | null;
|
||||
shadowed: PermissionShadowReport | null;
|
||||
systemOverride: SystemPermissionSet | null;
|
||||
resolvedSystem: ResolvedAgentSystemPermissions | null;
|
||||
}
|
||||
@ -132,6 +135,9 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
||||
const [resolvedSystemByAgent, setResolvedSystemByAgent] = useState<
|
||||
Record<string, ResolvedAgentSystemPermissions>
|
||||
>({});
|
||||
const [resolvedPermissionsByAgent, setResolvedPermissionsByAgent] = useState<
|
||||
Record<string, ResolvedAgentPermissions>
|
||||
>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -156,9 +162,29 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
||||
}
|
||||
}),
|
||||
);
|
||||
const resolvedPermissionPairs = await Promise.all(
|
||||
agentList.map(async (candidate) => {
|
||||
try {
|
||||
return [
|
||||
candidate.id,
|
||||
await permission.resolveAgentPermissions(projectId, candidate.id),
|
||||
] as const;
|
||||
} catch {
|
||||
return [candidate.id, null] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
setAgents(agentList);
|
||||
setDocument(permissionDoc);
|
||||
setSystemDocument(systemPermissionDoc);
|
||||
setResolvedPermissionsByAgent(
|
||||
Object.fromEntries(
|
||||
resolvedPermissionPairs.filter(
|
||||
(pair): pair is readonly [string, ResolvedAgentPermissions] =>
|
||||
pair[1] !== null,
|
||||
),
|
||||
),
|
||||
);
|
||||
setResolvedSystemByAgent(
|
||||
Object.fromEntries(
|
||||
resolvedPairs.filter(
|
||||
@ -191,10 +217,17 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
||||
return agents.map((candidate) => ({
|
||||
agent: candidate,
|
||||
override: overrides.get(candidate.id) ?? null,
|
||||
shadowed: resolvedPermissionsByAgent[candidate.id]?.shadowed ?? null,
|
||||
systemOverride: systemOverrides.get(candidate.id) ?? null,
|
||||
resolvedSystem: resolvedSystemByAgent[candidate.id] ?? null,
|
||||
}));
|
||||
}, [agents, document, systemDocument, resolvedSystemByAgent]);
|
||||
}, [
|
||||
agents,
|
||||
document,
|
||||
systemDocument,
|
||||
resolvedPermissionsByAgent,
|
||||
resolvedSystemByAgent,
|
||||
]);
|
||||
|
||||
const projectDraft = useMemo(
|
||||
() => draftFromSet(document?.projectDefaults ?? null),
|
||||
|
||||
@ -80,6 +80,7 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
pluginToolchain: gateways.pluginToolchain,
|
||||
pluginEvents: gateways.pluginEvents,
|
||||
pluginConfig: gateways.pluginConfig,
|
||||
pluginStorage: gateways.pluginStorage,
|
||||
}),
|
||||
)
|
||||
.then((result) => {
|
||||
|
||||
Reference in New Issue
Block a user