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:
2026-08-03 11:06:23 +02:00
parent 22c6bd803d
commit 171c6c923c
59 changed files with 3654 additions and 291 deletions

View File

@ -139,4 +139,25 @@ describe("TauriAgentGateway invoke payloads", () => {
);
expect(out.relaunchedSession).toBeUndefined();
});
it("update_agent_effort wraps the nullable effort override in the request DTO", async () => {
invoke.mockResolvedValueOnce({ id: "agent-2", effort: { kind: "preset", value: "high" } });
await new TauriAgentGateway().updateAgentEffort("proj-1", "agent-2", {
kind: "preset",
value: "high",
});
expect(invoke).toHaveBeenCalledWith("update_agent_effort", {
request: {
projectId: "proj-1",
agentId: "agent-2",
effort: { kind: "preset", value: "high" },
},
});
invoke.mockClear().mockResolvedValueOnce({ id: "agent-2" });
await new TauriAgentGateway().updateAgentEffort("proj-1", "agent-2", null);
expect(invoke).toHaveBeenCalledWith("update_agent_effort", {
request: { projectId: "proj-1", agentId: "agent-2", effort: null },
});
});
});

View File

@ -16,6 +16,7 @@ import { Channel, invoke } from "@tauri-apps/api/core";
import type {
Agent,
EffortSelection,
ResumableAgent,
TerminalSession,
} from "@/domain";
@ -105,6 +106,16 @@ export class TauriAgentGateway implements AgentGateway {
);
}
updateAgentEffort(
projectId: string,
agentId: string,
effort: EffortSelection | null,
): Promise<Agent> {
return invoke<Agent>("update_agent_effort", {
request: { projectId, agentId, effort },
});
}
readContext(projectId: string, agentId: string): Promise<string> {
return invoke<string>("read_agent_context", { projectId, agentId });
}

View File

@ -48,6 +48,7 @@ import {
WebPluginConfigGateway,
WebPluginEventGateway,
WebPluginGateway,
WebPluginStorageGateway,
WebPluginTaskGateway,
WebPluginToolchainGateway,
WebPluginWorkspaceGateway,
@ -151,6 +152,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
pluginToolchain: new WebPluginToolchainGateway(),
pluginEvents: new WebPluginEventGateway(),
pluginConfig: new WebPluginConfigGateway(),
pluginStorage: new WebPluginStorageGateway(),
};
}

View File

@ -15,7 +15,6 @@ import type {
Agent,
AgentDrift,
AgentProfile,
EffectivePermissions,
EmbedderEngines,
EmbedderProfile,
FirstRunState,
@ -43,6 +42,7 @@ import type {
ProjectSystemPermissions,
ProfileAvailability,
ProfileModelCatalog,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
SystemPermissionSet,
Skill,
@ -286,7 +286,14 @@ export class HttpSkillGateway implements SkillGateway {
}
createSkill(input: CreateSkillInput): Promise<Skill> {
return this.http.invoke<Skill>("create_skill", {
request: { projectId: input.projectId, name: input.name, content: input.content, scope: input.scope },
request: {
projectId: input.projectId,
name: input.name,
description: input.description,
kind: input.kind,
content: input.content,
scope: input.scope,
},
});
}
updateSkill(projectId: string, scope: SkillScope, skillId: string, content: string): Promise<Skill> {
@ -381,8 +388,8 @@ export class HttpPermissionGateway implements PermissionGateway {
request: { projectId, agentId, permissions },
});
}
resolveAgentPermissions(projectId: string, agentId: string): Promise<EffectivePermissions | null> {
return this.http.invoke<EffectivePermissions | null>("resolve_agent_permissions", {
resolveAgentPermissions(projectId: string, agentId: string): Promise<ResolvedAgentPermissions> {
return this.http.invoke<ResolvedAgentPermissions>("resolve_agent_permissions", {
request: { projectId, agentId },
});
}

View File

@ -23,6 +23,7 @@ import type {
Agent,
AppExitWorkGuardState,
DomainEvent,
EffortSelection,
HealthReport,
ReplyChunk,
ResumableAgent,
@ -223,6 +224,15 @@ export class HttpAgentGateway implements AgentGateway {
request: { projectId, agentId, profileId, rows, cols },
});
}
updateAgentEffort(
projectId: string,
agentId: string,
effort: EffortSelection | null,
): Promise<Agent> {
return this.http.invoke<Agent>("update_agent_effort", {
request: { projectId, agentId, effort },
});
}
readContext(projectId: string, agentId: string): Promise<string> {
return this.http.invoke<string>("read_agent_context", { projectId, agentId });
}

View File

@ -12,6 +12,7 @@
import type {
EmbeddedServerStatus,
GatewayError,
JsonValue,
PluginAdmin,
PluginCommandTask,
PluginConfigDocument,
@ -44,6 +45,9 @@ import type {
PluginEventSubscribeInput,
PluginEventUnsubscribeInput,
PluginGateway,
PluginStorageGateway,
PluginStorageGetInput,
PluginStorageSetInput,
PluginProjectStructureQuery,
PluginRunCommandInput,
PluginTaskGateway,
@ -263,3 +267,18 @@ export class WebPluginConfigGateway implements PluginConfigGateway {
return unsupportedOnWeb("Plugin structured config documents");
}
}
/** Web stub: plugin-owned storage is owned by the desktop host app-data. */
export class WebPluginStorageGateway implements PluginStorageGateway {
async get(_input: PluginStorageGetInput): Promise<JsonValue | null> {
return unsupportedOnWeb("Plugin storage");
}
async set(_input: PluginStorageSetInput): Promise<void> {
return unsupportedOnWeb("Plugin storage");
}
async delete(_input: PluginStorageGetInput): Promise<boolean> {
return unsupportedOnWeb("Plugin storage");
}
}

View File

@ -40,6 +40,7 @@ import { TauriPluginTaskGateway } from "./pluginTask";
import { TauriPluginToolchainGateway } from "./pluginToolchain";
import { TauriPluginEventGateway } from "./pluginEvents";
import { TauriPluginConfigGateway } from "./pluginConfig";
import { TauriPluginStorageGateway } from "./pluginStorage";
function notImplemented(what: string): never {
const err: GatewayError = {
@ -87,6 +88,7 @@ export function createTauriGateways(): Gateways {
pluginToolchain: new TauriPluginToolchainGateway(),
pluginEvents: new TauriPluginEventGateway(),
pluginConfig: new TauriPluginConfigGateway(),
pluginStorage: new TauriPluginStorageGateway(),
};
}
@ -114,4 +116,5 @@ export {
TauriFocusedProjectGateway,
LocalStorageUiPreferencesGateway,
TauriPluginGateway,
TauriPluginStorageGateway,
};

View File

@ -11,6 +11,7 @@ import type {
AgentProfile,
DiagnosticWarning,
DomainEvent,
EffortSelection,
EmbedderEngines,
EmbedderProfile,
EmbeddedServerStatus,
@ -37,7 +38,6 @@ import type {
OpenCodeProviderCatalogEntry,
ProfileModelCatalog,
ProfileModelCatalogEntry,
EffectivePermissions,
PairedDevice,
PairingCode,
PermissionSet,
@ -72,6 +72,7 @@ import type {
ProjectSystemPermissions,
ProfileAvailability,
ResumableAgent,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
ServerExposurePreview,
ServerExposureSettings,
@ -129,6 +130,9 @@ import type {
PluginEventSubscribeInput,
PluginEventUnsubscribeInput,
PluginGateway,
PluginStorageGateway,
PluginStorageGetInput,
PluginStorageSetInput,
PluginProjectStructureQuery,
PluginRunCommandInput,
PluginTaskGateway,
@ -603,6 +607,28 @@ export class MockAgentGateway implements AgentGateway {
};
}
async updateAgentEffort(
projectId: string,
agentId: string,
effort: EffortSelection | null,
): Promise<Agent> {
const list = this.getAgents(projectId);
const idx = list.findIndex((a) => a.id === agentId);
if (idx === -1) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
const next =
effort === null
? (({ effort: _dropped, ...rest }) => rest)(list[idx])
: { ...list[idx], effort };
list[idx] = next;
return structuredClone(next);
}
// ── Internal helpers for MockTemplateGateway (same-package use only) ──
/**
@ -2006,6 +2032,8 @@ export class MockSkillGateway implements SkillGateway {
const skill: Skill = {
id: `mock-skill-${this.seq}`,
name: input.name,
description: input.description ?? null,
kind: input.kind ?? "workflow",
contentMd: input.content,
scope: input.scope,
};
@ -2431,14 +2459,18 @@ export class MockPermissionGateway implements PermissionGateway {
async resolveAgentPermissions(
projectId: string,
agentId: string,
): Promise<EffectivePermissions | null> {
): Promise<ResolvedAgentPermissions> {
const doc = this.doc(projectId);
const project = doc.projectDefaults;
const agent = doc.agents?.find((entry) => entry.agentId === agentId)?.permissions;
if (!project && !agent) return null;
const shadowed = permissionShadowReport(project, agent);
if (!project && !agent) return { effective: null, shadowed };
return {
rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])],
fallback: mostRestrictive(project?.fallback, agent?.fallback),
effective: {
rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])],
fallback: mostRestrictive(project?.fallback, agent?.fallback),
},
shadowed,
};
}
@ -3466,6 +3498,47 @@ function mostRestrictive(
return rank[agent] >= rank[fallback] ? agent : fallback;
}
function permissionShadowReport(project?: PermissionSet, agent?: PermissionSet) {
const empty = {
read: false,
write: false,
delete: false,
executeBash: false,
fallback: false,
};
if (!agent) return empty;
const shadowed = (capability: "read" | "write" | "delete" | "executeBash") =>
blanketEffect(project, capability, "deny") === "deny" &&
blanketEffect(agent, capability, "allow") === "allow";
return {
read: shadowed("read"),
write: shadowed("write"),
delete: shadowed("delete"),
executeBash: shadowed("executeBash"),
fallback: agent.fallback !== mostRestrictive(project?.fallback, agent.fallback),
};
}
function blanketEffect(
set: PermissionSet | undefined,
capability: "read" | "write" | "delete" | "executeBash",
wins: "allow" | "deny",
) {
let found: "allow" | "deny" | undefined;
for (const rule of set?.rules ?? []) {
if (rule.capability !== capability || !isBlanketRule(rule)) continue;
if (rule.effect === wins) return rule.effect;
found = rule.effect;
}
return found;
}
function isBlanketRule(rule: PermissionSet["rules"][number]) {
if (rule.capability === "executeBash") return (rule.commands ?? []).length === 0;
const paths = rule.paths ?? [];
return paths.length === 1 && paths[0] === "**";
}
/**
* In-memory plugin store (ticket #43, F1). Mirrors the carnet contract closely
* enough to develop/test F1-F4 without the backend (B1-B4, landing in
@ -4016,6 +4089,38 @@ export class MockPluginConfigGateway implements PluginConfigGateway {
}
}
/**
* In-memory plugin-owned storage gateway for plugin runtime tests/dev.
*/
export class MockPluginStorageGateway implements PluginStorageGateway {
private readonly values = new Map<string, JsonValue>();
private storageKey(input: PluginStorageGetInput): string {
if (!input.pluginId.trim()) {
const err: GatewayError = { code: "INVALID", message: "pluginId must not be empty" };
throw err;
}
if (!input.key.trim()) {
const err: GatewayError = { code: "INVALID", message: "key must not be empty" };
throw err;
}
return `${input.pluginId}:${input.key}`;
}
async get(input: PluginStorageGetInput): Promise<JsonValue | null> {
const value = this.values.get(this.storageKey(input));
return value === undefined ? null : cloneJson(value);
}
async set(input: PluginStorageSetInput): Promise<void> {
this.values.set(this.storageKey(input), cloneJson(input.value));
}
async delete(input: PluginStorageGetInput): Promise<boolean> {
return this.values.delete(this.storageKey(input));
}
}
/** Builds the full set of mock gateways. */
export function createMockGateways(): Gateways {
const agentGateway = new MockAgentGateway();
@ -4050,6 +4155,7 @@ export function createMockGateways(): Gateways {
pluginToolchain: new MockPluginToolchainGateway(),
pluginEvents: new MockPluginEventGateway(),
pluginConfig: new MockPluginConfigGateway(),
pluginStorage: new MockPluginStorageGateway(),
};
}

View File

@ -1,12 +1,12 @@
import { invoke } from "@tauri-apps/api/core";
import type {
EffectivePermissions,
McpToolPolicy,
PermissionSet,
ProjectMcpToolPermissions,
ProjectPermissions,
ProjectSystemPermissions,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
SystemPermissionSet,
} from "@/domain";
@ -40,8 +40,8 @@ export class TauriPermissionGateway implements PermissionGateway {
resolveAgentPermissions(
projectId: string,
agentId: string,
): Promise<EffectivePermissions | null> {
return invoke<EffectivePermissions | null>("resolve_agent_permissions", {
): Promise<ResolvedAgentPermissions> {
return invoke<ResolvedAgentPermissions>("resolve_agent_permissions", {
request: { projectId, agentId },
});
}

View File

@ -0,0 +1,26 @@
/**
* Tauri adapter for plugin-owned JSON storage (#139).
*/
import { invoke } from "@tauri-apps/api/core";
import type { JsonValue } from "@/domain";
import type {
PluginStorageGateway,
PluginStorageGetInput,
PluginStorageSetInput,
} from "@/ports";
export class TauriPluginStorageGateway implements PluginStorageGateway {
get(input: PluginStorageGetInput): Promise<JsonValue | null> {
return invoke<JsonValue | null>("plugin_storage_get", { input });
}
async set(input: PluginStorageSetInput): Promise<void> {
await invoke("plugin_storage_set", { input });
}
delete(input: PluginStorageGetInput): Promise<boolean> {
return invoke<boolean>("plugin_storage_delete", { input });
}
}

View File

@ -22,6 +22,8 @@ export class TauriSkillGateway implements SkillGateway {
request: {
projectId: input.projectId,
name: input.name,
description: input.description,
kind: input.kind,
content: input.content,
scope: input.scope,
},

View File

@ -767,6 +767,21 @@ export interface EffectivePermissions {
fallback: PermissionPosture;
}
/** Agent override choices shadowed by stricter project defaults. */
export interface PermissionShadowReport {
read: boolean;
write: boolean;
delete: boolean;
executeBash: boolean;
fallback: boolean;
}
/** Resolved file/bash permissions plus non-authoritative diagnostics. */
export interface ResolvedAgentPermissions {
effective: EffectivePermissions | null;
shadowed: PermissionShadowReport;
}
/** Wanted/effective network policy for system permissions. */
export type NetworkPolicy = "allow" | "deny" | "ask";
@ -1151,6 +1166,21 @@ export interface ProfileModelCatalog {
warnings: string[];
}
/** One native effort/reasoning preset declared by an AI profile. */
export interface EffortOption {
/** Raw value persisted/forwarded to the provider. */
value: string;
/** Human-readable label shown in selectors. */
label: string;
/** Optional short description from the profile declaration. */
hint?: string;
}
/** Per-agent effort override. `undefined`/`null` means profile default. */
export type EffortSelection =
| { kind: "preset"; value: string }
| { kind: "custom"; value: string };
/**
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
* UUID string; `detect` is the optional detection command line.
@ -1192,6 +1222,16 @@ export interface AgentProfile {
* CLI's own default. OpenCode keeps its dedicated provider/local model fields.
*/
model?: string;
/**
* Optional direct CLI reasoning effort configured on the profile. `undefined`
* keeps the CLI/provider default.
*/
modelReasoningEffort?: string;
/**
* Native effort presets exposed by this profile, in declaration order from
* light to deep. Empty/omitted means the provider declares no native options.
*/
effortOptions?: EffortOption[];
}
/** Availability of a candidate profile after detection (mirror of the DTO). */
@ -1233,6 +1273,8 @@ export interface Agent {
synchronized: boolean;
/** Skills assigned to this agent (injected into its convention file). */
skills: SkillRef[];
/** Per-agent effort override. Omitted for older manifests/profile default. */
effort?: EffortSelection;
}
/**
@ -1284,6 +1326,7 @@ export interface ResumableAgent {
* across projects; `project` skills are specific to one project's `.ideai/`.
*/
export type SkillScope = "global" | "project";
export type SkillKind = "workflow" | "reference";
/**
* A reusable, model-agnostic workflow assignable to agents (mirror of the
@ -1292,6 +1335,8 @@ export type SkillScope = "global" | "project";
export interface Skill {
id: string;
name: string;
description?: string | null;
kind: SkillKind;
contentMd: string;
scope: SkillScope;
}

View File

@ -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,
}: {

View File

@ -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();

View File

@ -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,

View File

@ -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),

View File

@ -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) => {

View File

@ -210,6 +210,7 @@ describe("loadPlugins", () => {
"pluginToolchain",
"pluginEvents",
"pluginConfig",
"pluginStorage",
].some((key) => key in ctx);
globalThis.__serviceKeys = Object.keys(ctx.services).sort();
globalThis.__workspaceServiceKeys = Object.keys(ctx.services.workspace).sort();
@ -241,6 +242,7 @@ describe("loadPlugins", () => {
"pluginDisplayName",
"pluginId",
"services",
"storage",
"subscriptions",
"version",
]);
@ -294,6 +296,59 @@ describe("loadPlugins", () => {
]);
});
it("injects plugin-owned storage scoped to the activating plugin id", async () => {
const calls: unknown[] = [];
const storageGateways = {
...gateways,
pluginStorage: {
async get(input: unknown) {
calls.push(["get", input]);
return { launches: 1 };
},
async set(input: unknown) {
calls.push(["set", input]);
},
async delete(input: unknown) {
calls.push(["delete", input]);
return true;
},
},
} as PluginGatewaySet;
const bundle = dataUrl(`
export async function activate(ctx) {
globalThis.__storageKeys = Object.keys(ctx.storage).sort();
globalThis.__storedValue = await ctx.storage.get("helloPlugin.state");
await ctx.storage.set("helloPlugin.state", { launches: 2 });
await ctx.storage.delete("helloPlugin.state");
}
`);
const { failures } = await loadPlugins(
[entry({ id: "dev.acme.storage", displayName: "Storage", bundleUrl: bundle })],
storageGateways,
);
expect(failures).toEqual([]);
expect((globalThis as Record<string, unknown>).__storageKeys).toEqual([
"delete",
"get",
"set",
]);
expect((globalThis as Record<string, unknown>).__storedValue).toEqual({ launches: 1 });
expect(calls).toEqual([
["get", { pluginId: "dev.acme.storage", key: "helloPlugin.state" }],
[
"set",
{
pluginId: "dev.acme.storage",
key: "helloPlugin.state",
value: { launches: 2 },
},
],
["delete", { pluginId: "dev.acme.storage", key: "helloPlugin.state" }],
]);
});
it("loads the hello-plugin command and layout contribution shape", async () => {
const bundle = dataUrl(`
export function activate(ctx) {

View File

@ -15,7 +15,7 @@
* collected, never thrown past `loadPlugins`.
*/
import type { PluginContributionDto, PluginRuntimePlugin } from "@/domain";
import type { JsonValue, PluginContributionDto, PluginRuntimePlugin } from "@/domain";
import {
PluginCommandRegistry,
PluginLayoutRegistry,
@ -38,9 +38,16 @@ export interface IdeaPluginContext {
commands: PluginCommandContext;
layouts: PluginLayoutRegistry;
menu: PluginMenuRegistry;
storage: PluginStorage;
services?: PluginServices;
}
export interface PluginStorage {
get<T extends JsonValue = JsonValue>(key: string): Promise<T | undefined>;
set(key: string, value: JsonValue): Promise<void>;
delete(key: string): Promise<void>;
}
export interface PluginActivation {
dispose?: () => void | Promise<void>;
}
@ -241,6 +248,7 @@ async function loadOne(
const commands = new PluginCommandRegistry(pluginId, declaredCommandIds);
const layouts = new PluginLayoutRegistry(pluginId, declaredLayoutTypes);
const menu = new PluginMenuRegistry(pluginId);
const storage = createPluginStorage(gateways, pluginId);
const ctx: IdeaPluginContext = {
pluginId,
@ -251,6 +259,7 @@ async function loadOne(
commands: createCommandContext(commands),
layouts,
menu,
storage,
};
if (hasCapability(entry, "tooling")) {
ctx.services = createPluginServices(gateways);
@ -287,6 +296,21 @@ async function loadOne(
}
}
function createPluginStorage(gateways: PluginGatewaySet, pluginId: string): PluginStorage {
return {
async get<T extends JsonValue = JsonValue>(key: string): Promise<T | undefined> {
const value = await gateways.pluginStorage.get({ pluginId, key });
return value === null ? undefined : (value as T);
},
async set(key, value) {
await gateways.pluginStorage.set({ pluginId, key, value });
},
async delete(key) {
await gateways.pluginStorage.delete({ pluginId, key });
},
};
}
/**
* Loads every plugin in the catalog into a fresh {@link PluginRuntimeRegistry}.
* Called once at app bootstrap (and never again in the same session — carnet

View File

@ -25,6 +25,7 @@ import type {
GitGateway,
PluginConfigGateway,
PluginEventGateway,
PluginStorageGateway,
PluginTaskGateway,
PluginToolchainGateway,
PluginWorkspaceGateway,
@ -48,6 +49,7 @@ export interface PluginGatewaySet {
pluginToolchain: PluginToolchainGateway;
pluginEvents: PluginEventGateway;
pluginConfig: PluginConfigGateway;
pluginStorage: PluginStorageGateway;
}
/** A disposable handle returned by every `register*` call. */

View File

@ -15,6 +15,7 @@ import type {
AppExitWorkGuardState,
CustomProviderConfig,
DomainEvent,
EffortSelection,
EmbedderEngines,
EmbedderProfile,
EmbeddedServerStatus,
@ -24,6 +25,7 @@ import type {
GitFileStatus,
GraphCommit,
HealthReport,
JsonValue,
LayoutKind,
LayoutList,
LayoutOperation,
@ -38,7 +40,6 @@ import type {
OpenCodeConfig,
OpenCodeProviderCatalogEntry,
ProfileModelCatalog,
EffectivePermissions,
PairedDevice,
PairingCode,
PermissionSet,
@ -71,11 +72,13 @@ import type {
ProjectSystemPermissions,
ProfileAvailability,
ResumableAgent,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
ReplyChunk,
ServerExposurePreview,
ServerExposureSettings,
Skill,
SkillKind,
SkillScope,
SystemPermissionSet,
Sprint,
@ -207,6 +210,15 @@ export interface AgentGateway {
rows: number,
cols: number,
): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }>;
/**
* Sets or clears one agent's effort override. `null` clears the override and
* lets launch fall back to the profile default.
*/
updateAgentEffort(
projectId: string,
agentId: string,
effort: EffortSelection | null,
): Promise<Agent>;
/** Reads an agent's `.md` context by agent id. */
readContext(projectId: string, agentId: string): Promise<string>;
/** Overwrites an agent's `.md` context. */
@ -538,6 +550,8 @@ export interface CreateSkillInput {
/** Owning project (resolved to a root; ignored on disk for `global`). */
projectId: string;
name: string;
description?: string | null;
kind?: SkillKind;
content: string;
scope: SkillScope;
}
@ -925,7 +939,7 @@ export interface PermissionGateway {
resolveAgentPermissions(
projectId: string,
agentId: string,
): Promise<EffectivePermissions | null>;
): Promise<ResolvedAgentPermissions>;
/** Reads the full project system-permission document. */
getProjectSystemPermissions(projectId: string): Promise<ProjectSystemPermissions>;
/** Replaces or removes project-level default system permissions. */
@ -1496,6 +1510,21 @@ export interface PluginConfigGateway {
updateDocument(input: PluginConfigDocumentUpdateInput): Promise<PluginConfigDocumentWriteResult>;
}
export interface PluginStorageGetInput {
pluginId: string;
key: string;
}
export interface PluginStorageSetInput extends PluginStorageGetInput {
value: JsonValue;
}
export interface PluginStorageGateway {
get(input: PluginStorageGetInput): Promise<JsonValue | null>;
set(input: PluginStorageSetInput): Promise<void>;
delete(input: PluginStorageGetInput): Promise<boolean>;
}
export interface Gateways {
system: SystemGateway;
agent: AgentGateway;
@ -1526,4 +1555,5 @@ export interface Gateways {
pluginToolchain: PluginToolchainGateway;
pluginEvents: PluginEventGateway;
pluginConfig: PluginConfigGateway;
pluginStorage: PluginStorageGateway;
}