merge(batch): intègre plugin-activation-scope-loading — chargement du scope d'activation des plugins (vert QA)
This commit is contained in:
@ -42,12 +42,13 @@ describe("TauriAgentGateway invoke payloads", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("list_agents / read / delete pass top-level args (no request wrapper)", async () => {
|
||||
it("list_agents / read / delete pass top-level args and unwrap read context DTO", async () => {
|
||||
const gw = new TauriAgentGateway();
|
||||
await gw.listAgents("p");
|
||||
expect(invoke).toHaveBeenCalledWith("list_agents", { projectId: "p" });
|
||||
|
||||
await gw.readContext("p", "a");
|
||||
invoke.mockResolvedValueOnce({ content: "# context" });
|
||||
await expect(gw.readContext("p", "a")).resolves.toBe("# context");
|
||||
expect(invoke).toHaveBeenCalledWith("read_agent_context", {
|
||||
projectId: "p",
|
||||
agentId: "a",
|
||||
@ -139,4 +140,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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -16,6 +16,8 @@ import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
AgentContextDocument,
|
||||
EffortSelection,
|
||||
ResumableAgent,
|
||||
TerminalSession,
|
||||
} from "@/domain";
|
||||
@ -105,8 +107,21 @@ 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 });
|
||||
return invoke<AgentContextDocument>("read_agent_context", {
|
||||
projectId,
|
||||
agentId,
|
||||
}).then((res) => res.content);
|
||||
}
|
||||
|
||||
async updateContext(
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { HttpAgentGateway } from "./streamGateways";
|
||||
import { HttpInvoker } from "./httpInvoker";
|
||||
import { HttpInvoker, type FetchLike } from "./httpInvoker";
|
||||
import { WsLiveClient, type WebSocketLike } from "./wsLiveClient";
|
||||
import { bytesToBase64 } from "./frames";
|
||||
|
||||
@ -45,6 +45,27 @@ function gateway(): { gw: HttpAgentGateway; sockets: FakeSocket[] } {
|
||||
return { gw, sockets };
|
||||
}
|
||||
|
||||
function httpAgentGateway(
|
||||
fetchImpl: FetchLike,
|
||||
): { gw: HttpAgentGateway; calls: { url: string; init: unknown }[] } {
|
||||
const calls: { url: string; init: unknown }[] = [];
|
||||
const recordingFetch: FetchLike = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return fetchImpl(url, init);
|
||||
};
|
||||
const ws = new WsLiveClient({
|
||||
wsUrl: "wss://host",
|
||||
socketFactory: () => new FakeSocket(),
|
||||
});
|
||||
return {
|
||||
gw: new HttpAgentGateway(
|
||||
new HttpInvoker({ baseUrl: "https://host", fetchImpl: recordingFetch }),
|
||||
ws,
|
||||
),
|
||||
calls,
|
||||
};
|
||||
}
|
||||
|
||||
async function replyToLast(
|
||||
socket: FakeSocket,
|
||||
index: number,
|
||||
@ -78,6 +99,26 @@ function attachedAck(
|
||||
const OPTS = { cwd: "/srv/app", rows: 24, cols: 80, nodeId: "node-1" };
|
||||
|
||||
describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
|
||||
it("readContext unwraps the read_agent_context DTO content over HTTP", async () => {
|
||||
const { gw, calls } = httpAgentGateway(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ content: "## Restored context" }),
|
||||
text: async () => JSON.stringify({ content: "## Restored context" }),
|
||||
}));
|
||||
|
||||
await expect(gw.readContext("proj-1", "agent-1")).resolves.toBe(
|
||||
"## Restored context",
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
const init = calls[0].init as { body: string };
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
command: "read_agent_context",
|
||||
args: { projectId: "proj-1", agentId: "agent-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("launch → attached: conforming agent.launch frame, assignedConversationId consumed", async () => {
|
||||
const { gw, sockets } = gateway();
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@ -21,8 +21,10 @@
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
AgentContextDocument,
|
||||
AppExitWorkGuardState,
|
||||
DomainEvent,
|
||||
EffortSelection,
|
||||
HealthReport,
|
||||
ReplyChunk,
|
||||
ResumableAgent,
|
||||
@ -223,8 +225,19 @@ 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 });
|
||||
return this.http
|
||||
.invoke<AgentContextDocument>("read_agent_context", { projectId, agentId })
|
||||
.then((res) => res.content);
|
||||
}
|
||||
async updateContext(projectId: string, agentId: string, content: string): Promise<void> {
|
||||
await this.http.invoke("update_agent_context", { request: { projectId, agentId, content } });
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
26
frontend/src/adapters/pluginStorage.ts
Normal file
26
frontend/src/adapters/pluginStorage.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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,13 @@ 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;
|
||||
}
|
||||
|
||||
/** Response DTO returned by `read_agent_context`; adapters unwrap `content`. */
|
||||
export interface AgentContextDocument {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1284,6 +1331,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 +1340,8 @@ export type SkillScope = "global" | "project";
|
||||
export interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
kind: SkillKind;
|
||||
contentMd: string;
|
||||
scope: SkillScope;
|
||||
}
|
||||
@ -1749,6 +1799,14 @@ export interface PluginRuntimePlugin {
|
||||
publisher?: string;
|
||||
version: string;
|
||||
capabilities?: string[];
|
||||
/**
|
||||
* Runtime activation scope declared by the plugin manifest.
|
||||
*
|
||||
* Omitted by older manifests and treated as `"app"`: the bundle is activated
|
||||
* immediately at app bootstrap. `"project"` plugins are held pending until a
|
||||
* focused project exists, then activated once for the current app session.
|
||||
*/
|
||||
activationScope?: "app" | "project";
|
||||
bundleUrl: string;
|
||||
iconUrl?: string;
|
||||
contentHash: string;
|
||||
|
||||
@ -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,
|
||||
}: {
|
||||
|
||||
@ -241,6 +241,43 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reopening the agents panel reloads the saved context as textarea text", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
await agent.createAgent(PROJECT_ID, {
|
||||
name: "Reopen",
|
||||
profileId: "p1",
|
||||
initialContent: "initial",
|
||||
});
|
||||
const firstRender = renderPanel(agent);
|
||||
await waitForIdle();
|
||||
|
||||
let buttons = screen.getAllByRole("button", { name: /reopen/i });
|
||||
let rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
|
||||
fireEvent.click(rowBtn);
|
||||
|
||||
let textarea = await screen.findByLabelText("agent context");
|
||||
fireEvent.change(textarea, { target: { value: "persisted after reopen" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const agents = await agent.listAgents(PROJECT_ID);
|
||||
await expect(agent.readContext(PROJECT_ID, agents[0].id)).resolves.toBe(
|
||||
"persisted after reopen",
|
||||
);
|
||||
});
|
||||
|
||||
firstRender.unmount();
|
||||
renderPanel(agent);
|
||||
await waitForIdle();
|
||||
|
||||
buttons = screen.getAllByRole("button", { name: /reopen/i });
|
||||
rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
|
||||
fireEvent.click(rowBtn);
|
||||
|
||||
textarea = await screen.findByLabelText("agent context");
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("persisted after reopen");
|
||||
});
|
||||
|
||||
it("deleting an agent removes it from the list", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
await agent.createAgent(PROJECT_ID, {
|
||||
@ -492,6 +529,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 +630,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),
|
||||
|
||||
@ -73,7 +73,7 @@ function renderCell(
|
||||
const gateways: Gateways = createMockGateways();
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PluginRuntimeProvider value={{ registry, failures: [], loading: false }}>
|
||||
<PluginRuntimeProvider value={{ registry, failures: [], pending: [], loading: false }}>
|
||||
<PluginLayoutCellView
|
||||
projectId="proj-1"
|
||||
cell={props.cell ?? cell()}
|
||||
|
||||
@ -13,12 +13,19 @@
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { loadPlugins, PluginRuntimeRegistry, type PluginLoadFailure } from "@/plugins/runtime";
|
||||
import {
|
||||
loadPlugins,
|
||||
PluginRuntimeRegistry,
|
||||
type PluginLoadFailure,
|
||||
type PluginLoadPending,
|
||||
} from "@/plugins/runtime";
|
||||
import { useGateways } from "@/app/di";
|
||||
import type { PluginRuntimePlugin, Unsubscribe } from "@/domain";
|
||||
|
||||
export interface PluginRuntimeContextValue {
|
||||
registry: PluginRuntimeRegistry;
|
||||
failures: PluginLoadFailure[];
|
||||
pending: PluginLoadPending[];
|
||||
/** True until the initial catalog fetch + bundle loads have settled. */
|
||||
loading: boolean;
|
||||
}
|
||||
@ -33,6 +40,7 @@ export interface PluginRuntimeContextValue {
|
||||
const EMPTY_PLUGIN_RUNTIME: PluginRuntimeContextValue = {
|
||||
registry: new PluginRuntimeRegistry(),
|
||||
failures: [],
|
||||
pending: [],
|
||||
loading: false,
|
||||
};
|
||||
|
||||
@ -57,6 +65,7 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
injected ?? {
|
||||
registry: new PluginRuntimeRegistry(),
|
||||
failures: [],
|
||||
pending: [],
|
||||
loading: true,
|
||||
},
|
||||
);
|
||||
@ -64,27 +73,85 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
useEffect(() => {
|
||||
if (injected) return;
|
||||
let cancelled = false;
|
||||
const pluginGateways = {
|
||||
project: gateways.project,
|
||||
git: gateways.git,
|
||||
terminal: gateways.terminal,
|
||||
agents: gateways.agent,
|
||||
system: gateways.system,
|
||||
workState: gateways.workState,
|
||||
focusedProject: gateways.focusedProject,
|
||||
pluginWorkspace: gateways.pluginWorkspace,
|
||||
pluginTask: gateways.pluginTask,
|
||||
pluginToolchain: gateways.pluginToolchain,
|
||||
pluginEvents: gateways.pluginEvents,
|
||||
pluginConfig: gateways.pluginConfig,
|
||||
pluginStorage: gateways.pluginStorage,
|
||||
};
|
||||
let activatedProjectScoped = false;
|
||||
let pendingProjectPlugins: PluginRuntimePlugin[] = [];
|
||||
let unsubscribeFocus: Unsubscribe | undefined;
|
||||
|
||||
void gateways.focusedProject
|
||||
.onFocusedProjectChanged(async (project) => {
|
||||
if (!project || activatedProjectScoped || pendingProjectPlugins.length === 0) return;
|
||||
activatedProjectScoped = true;
|
||||
const projectPlugins = pendingProjectPlugins;
|
||||
pendingProjectPlugins = [];
|
||||
try {
|
||||
const result = await loadPlugins(projectPlugins, pluginGateways);
|
||||
if (cancelled) return;
|
||||
setValue((prev) => {
|
||||
for (const plugin of result.registry.list()) prev.registry.add(plugin);
|
||||
return {
|
||||
registry: prev.registry,
|
||||
failures: [...prev.failures, ...result.failures],
|
||||
pending: prev.pending.filter(
|
||||
(p) => !projectPlugins.some((entry) => entry.id === p.pluginId),
|
||||
),
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
if (cancelled) return;
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
failures: [
|
||||
...prev.failures,
|
||||
...projectPlugins.map((plugin) => ({
|
||||
pluginId: plugin.id,
|
||||
reason: describeError(e),
|
||||
})),
|
||||
],
|
||||
pending: prev.pending.filter(
|
||||
(p) => !projectPlugins.some((entry) => entry.id === p.pluginId),
|
||||
),
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
})
|
||||
.then((unsubscribe) => {
|
||||
if (cancelled) unsubscribe();
|
||||
else unsubscribeFocus = unsubscribe;
|
||||
});
|
||||
|
||||
gateways.plugin
|
||||
.listRuntimeContributions()
|
||||
.then((catalog) =>
|
||||
loadPlugins(catalog.plugins, {
|
||||
project: gateways.project,
|
||||
git: gateways.git,
|
||||
terminal: gateways.terminal,
|
||||
agents: gateways.agent,
|
||||
system: gateways.system,
|
||||
workState: gateways.workState,
|
||||
focusedProject: gateways.focusedProject,
|
||||
pluginWorkspace: gateways.pluginWorkspace,
|
||||
pluginTask: gateways.pluginTask,
|
||||
pluginToolchain: gateways.pluginToolchain,
|
||||
pluginEvents: gateways.pluginEvents,
|
||||
pluginConfig: gateways.pluginConfig,
|
||||
}),
|
||||
)
|
||||
.then(async (catalog) => {
|
||||
const result = await loadPlugins(catalog.plugins, pluginGateways);
|
||||
pendingProjectPlugins = catalog.plugins.filter((entry) =>
|
||||
result.pending.some((p) => p.pluginId === entry.id),
|
||||
);
|
||||
return result;
|
||||
})
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setValue({ registry: result.registry, failures: result.failures, loading: false });
|
||||
setValue({
|
||||
registry: result.registry,
|
||||
failures: result.failures,
|
||||
pending: result.pending,
|
||||
loading: false,
|
||||
});
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
// No plugin gateway / catalog fetch failed: run with zero plugins
|
||||
@ -96,12 +163,14 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
...prev.failures,
|
||||
{ pluginId: "<runtime-catalog>", reason: describeError(e) },
|
||||
],
|
||||
pending: [],
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribeFocus?.();
|
||||
};
|
||||
// Gateways are a stable singleton for the app session (from `useGateways`);
|
||||
// re-running on every render would reload every plugin bundle.
|
||||
|
||||
@ -128,6 +128,23 @@ export function PluginsPanel() {
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{pluginRuntime.pending.length > 0 && (
|
||||
<Panel>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-content">
|
||||
Certains plugins attendent un projet actif.
|
||||
</p>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{pluginRuntime.pending.map((pending) => (
|
||||
<li key={pending.pluginId} className="text-xs text-muted">
|
||||
<span className="font-medium text-content">{pending.displayName}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{vm.plugins.length === 0 ? (
|
||||
<Panel>
|
||||
<p className="text-sm text-muted">Aucun plugin installé.</p>
|
||||
|
||||
@ -6,7 +6,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent, within } from "@testing-library/react";
|
||||
|
||||
import { MockPluginGateway, MockSystemGateway } from "@/adapters/mock";
|
||||
import {
|
||||
createMockGateways,
|
||||
MockFocusedProjectGateway,
|
||||
MockPluginGateway,
|
||||
MockSystemGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { PluginInstallResult, PluginReview, PluginRuntimeContributionCatalog } from "@/domain";
|
||||
import type { Gateways, ReviewPluginPackageInput } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
@ -14,12 +19,17 @@ import { PluginRuntimeRegistry } from "@/plugins/runtime";
|
||||
import { PluginsPanel } from "./PluginsPanel";
|
||||
import { PluginRuntimeProvider, type PluginRuntimeContextValue } from "./PluginRuntimeProvider";
|
||||
|
||||
function dataUrl(source: string): string {
|
||||
return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
}
|
||||
|
||||
function renderPanel(
|
||||
plugin?: MockPluginGateway,
|
||||
system?: MockSystemGateway,
|
||||
runtimeValue: PluginRuntimeContextValue = {
|
||||
registry: new PluginRuntimeRegistry(),
|
||||
failures: [],
|
||||
pending: [],
|
||||
loading: false,
|
||||
},
|
||||
) {
|
||||
@ -40,7 +50,7 @@ function renderPanel(
|
||||
}
|
||||
|
||||
function renderPanelWithLiveRuntime(plugin: MockPluginGateway, system = new MockSystemGateway()) {
|
||||
const gateways = { plugin, system } as unknown as Gateways;
|
||||
const gateways = { ...createMockGateways(), plugin, system };
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PluginRuntimeProvider>
|
||||
@ -77,6 +87,63 @@ class FailingRuntimeCatalogPluginGateway extends MockPluginGateway {
|
||||
}
|
||||
}
|
||||
|
||||
class ProjectScopedRuntimeCatalogPluginGateway extends MockPluginGateway {
|
||||
constructor(private readonly bundleUrl: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
||||
return {
|
||||
plugins: [
|
||||
{
|
||||
id: "dev.acme.project-plugin",
|
||||
displayName: "Project Plugin",
|
||||
version: "1.0.0",
|
||||
activationScope: "project",
|
||||
bundleUrl: this.bundleUrl,
|
||||
contentHash: "project-plugin",
|
||||
contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class MixedActivationScopeRuntimeCatalogPluginGateway extends MockPluginGateway {
|
||||
constructor(
|
||||
private readonly failingAppBundleUrl: string,
|
||||
private readonly projectBundleUrl: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
||||
return {
|
||||
plugins: [
|
||||
{
|
||||
id: "dev.acme.app-needs-project",
|
||||
displayName: "App Needs Project",
|
||||
version: "1.0.0",
|
||||
capabilities: ["tooling"],
|
||||
activationScope: "app",
|
||||
bundleUrl: this.failingAppBundleUrl,
|
||||
contentHash: "app-needs-project",
|
||||
contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] },
|
||||
},
|
||||
{
|
||||
id: "dev.acme.project-plugin",
|
||||
displayName: "Project Plugin",
|
||||
version: "1.0.0",
|
||||
activationScope: "project",
|
||||
bundleUrl: this.projectBundleUrl,
|
||||
contentHash: "project-plugin",
|
||||
contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class BackendShapedReviewPluginGateway extends MockPluginGateway {
|
||||
async reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview> {
|
||||
const label = input.path.split("/").pop() ?? input.path;
|
||||
@ -106,6 +173,7 @@ describe("PluginsPanel", () => {
|
||||
renderPanel(undefined, undefined, {
|
||||
registry: new PluginRuntimeRegistry(),
|
||||
failures: [{ pluginId: "com.example.hello-plugin", reason: "Cannot use import statement outside a module" }],
|
||||
pending: [],
|
||||
loading: false,
|
||||
});
|
||||
|
||||
@ -124,6 +192,130 @@ describe("PluginsPanel", () => {
|
||||
expect(screen.getByText(/runtime catalog failed/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders runtime failures before pending plugins without repeating invariant pending reasons", async () => {
|
||||
renderPanel(undefined, undefined, {
|
||||
registry: new PluginRuntimeRegistry(),
|
||||
failures: [{ pluginId: "dev.acme.failed", reason: "activation failed" }],
|
||||
pending: [
|
||||
{
|
||||
pluginId: "dev.acme.pending",
|
||||
displayName: "Pending Plugin",
|
||||
reason: "En attente d'un projet actif.",
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy();
|
||||
const failureTitle = screen.getByText("Certains plugins installés n'ont pas pu être chargés.");
|
||||
const pendingTitle = screen.getByText("Certains plugins attendent un projet actif.");
|
||||
expect(
|
||||
failureTitle.compareDocumentPosition(pendingTitle) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("Pending Plugin")).toBeTruthy();
|
||||
expect(screen.queryByText("En attente d'un projet actif.")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows project-scoped runtime plugins as pending until a project is focused", async () => {
|
||||
delete (globalThis as Record<string, unknown>).__projectPluginActivations;
|
||||
delete (globalThis as Record<string, unknown>).__projectPluginActivatedWith;
|
||||
const bundle = dataUrl(`
|
||||
export function activate(ctx) {
|
||||
globalThis.__projectPluginActivations = (globalThis.__projectPluginActivations ?? 0) + 1;
|
||||
globalThis.__projectPluginActivatedWith = ctx.pluginId;
|
||||
}
|
||||
`);
|
||||
const focusedProject = new MockFocusedProjectGateway();
|
||||
const gateways = {
|
||||
...createMockGateways(),
|
||||
focusedProject,
|
||||
plugin: new ProjectScopedRuntimeCatalogPluginGateway(bundle),
|
||||
};
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PluginRuntimeProvider>
|
||||
<PluginsPanel />
|
||||
</PluginRuntimeProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Certains plugins attendent un projet actif.")).toBeTruthy();
|
||||
expect(screen.getByText("Project Plugin")).toBeTruthy();
|
||||
expect(screen.queryByText("Certains plugins installés n'ont pas pu être chargés.")).toBeNull();
|
||||
|
||||
await focusedProject.setFocusedProject({ id: "p1", name: "Alpha", root: "/tmp/alpha" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Certains plugins attendent un projet actif.")).toBeNull();
|
||||
expect((globalThis as Record<string, unknown>).__projectPluginActivatedWith).toBe(
|
||||
"dev.acme.project-plugin",
|
||||
);
|
||||
});
|
||||
|
||||
await focusedProject.setFocusedProject({ id: "p2", name: "Beta", root: "/tmp/beta" });
|
||||
expect((globalThis as Record<string, unknown>).__projectPluginActivations).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps app-scope failures distinct from project-scope pending plugins, without cross-blocking", async () => {
|
||||
delete (globalThis as Record<string, unknown>).__mixedProjectPluginActivations;
|
||||
delete (globalThis as Record<string, unknown>).__mixedProjectPluginActivatedWith;
|
||||
const failingAppBundle = dataUrl(`
|
||||
export async function activate(ctx) {
|
||||
await ctx.services.workspace.getProjectRoot();
|
||||
}
|
||||
`);
|
||||
const projectBundle = dataUrl(`
|
||||
export function activate(ctx) {
|
||||
globalThis.__mixedProjectPluginActivations =
|
||||
(globalThis.__mixedProjectPluginActivations ?? 0) + 1;
|
||||
globalThis.__mixedProjectPluginActivatedWith = ctx.pluginId;
|
||||
}
|
||||
`);
|
||||
const focusedProject = new MockFocusedProjectGateway();
|
||||
const gateways = {
|
||||
...createMockGateways(),
|
||||
focusedProject,
|
||||
plugin: new MixedActivationScopeRuntimeCatalogPluginGateway(
|
||||
failingAppBundle,
|
||||
projectBundle,
|
||||
),
|
||||
};
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PluginRuntimeProvider>
|
||||
<PluginsPanel />
|
||||
</PluginRuntimeProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const failureTitle = await screen.findByText("Certains plugins installés n'ont pas pu être chargés.");
|
||||
const failureSection = failureTitle.closest("section");
|
||||
expect(failureSection).not.toBeNull();
|
||||
expect(within(failureSection as HTMLElement).getByText("dev.acme.app-needs-project")).toBeTruthy();
|
||||
expect(
|
||||
within(failureSection as HTMLElement).getByText((_, element) =>
|
||||
element?.tagName === "LI" &&
|
||||
(element.textContent?.includes("no current project is focused") ?? false),
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("Certains plugins attendent un projet actif.")).toBeTruthy();
|
||||
expect(screen.getByText("Project Plugin")).toBeTruthy();
|
||||
|
||||
await focusedProject.setFocusedProject({ id: "p1", name: "Alpha", root: "/tmp/alpha" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Certains plugins attendent un projet actif.")).toBeNull();
|
||||
expect((globalThis as Record<string, unknown>).__mixedProjectPluginActivatedWith).toBe(
|
||||
"dev.acme.project-plugin",
|
||||
);
|
||||
});
|
||||
|
||||
await focusedProject.setFocusedProject({ id: "p2", name: "Beta", root: "/tmp/beta" });
|
||||
expect((globalThis as Record<string, unknown>).__mixedProjectPluginActivations).toBe(1);
|
||||
});
|
||||
|
||||
it("installs from an archive via the review dialog, mentioning full-trust", async () => {
|
||||
renderPanel();
|
||||
await screen.findByText("Aucun plugin installé.");
|
||||
|
||||
@ -118,7 +118,7 @@ function renderWithPlugin(git: GitGateway) {
|
||||
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PluginRuntimeProvider value={{ registry, failures: [], loading: false }}>
|
||||
<PluginRuntimeProvider value={{ registry, failures: [], pending: [], loading: false }}>
|
||||
<ProjectsView />
|
||||
</PluginRuntimeProvider>
|
||||
</DIProvider>,
|
||||
|
||||
@ -16,6 +16,7 @@ export {
|
||||
type IdeaPluginModule,
|
||||
type PluginActivation,
|
||||
type PluginLoadFailure,
|
||||
type PluginLoadPending,
|
||||
type PluginLoadResult,
|
||||
} from "./loader";
|
||||
export {
|
||||
|
||||
@ -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,183 @@ 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("leaves project-scoped plugins pending without a focused project", async () => {
|
||||
const bundle = dataUrl(`
|
||||
export function activate(ctx) {
|
||||
globalThis.__pendingProjectPluginActivated = ctx.pluginId;
|
||||
}
|
||||
`);
|
||||
const noFocusedProjectGateways = {
|
||||
...gateways,
|
||||
focusedProject: {
|
||||
async getFocusedProject() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
} as PluginGatewaySet;
|
||||
|
||||
const { registry, failures, pending } = await loadPlugins(
|
||||
[
|
||||
entry({
|
||||
id: "dev.acme.project-only",
|
||||
displayName: "Project Only",
|
||||
activationScope: "project",
|
||||
bundleUrl: bundle,
|
||||
}),
|
||||
],
|
||||
noFocusedProjectGateways,
|
||||
);
|
||||
|
||||
expect(registry.list()).toEqual([]);
|
||||
expect(failures).toEqual([]);
|
||||
expect(pending).toEqual([
|
||||
{
|
||||
pluginId: "dev.acme.project-only",
|
||||
displayName: "Project Only",
|
||||
reason: "En attente d'un projet actif.",
|
||||
},
|
||||
]);
|
||||
expect((globalThis as Record<string, unknown>).__pendingProjectPluginActivated).toBeUndefined();
|
||||
});
|
||||
|
||||
it("activates project-scoped plugins when a project is already focused", async () => {
|
||||
const bundle = dataUrl(`
|
||||
export function activate(ctx) {
|
||||
globalThis.__focusedProjectPluginActivated = ctx.pluginId;
|
||||
}
|
||||
`);
|
||||
const focusedProjectGateways = {
|
||||
...gateways,
|
||||
focusedProject: {
|
||||
async getFocusedProject() {
|
||||
return { id: "p1", name: "Alpha", root: "/tmp/alpha" };
|
||||
},
|
||||
},
|
||||
} as PluginGatewaySet;
|
||||
|
||||
const { registry, failures, pending } = await loadPlugins(
|
||||
[
|
||||
entry({
|
||||
id: "dev.acme.project-focused",
|
||||
displayName: "Project Focused",
|
||||
activationScope: "project",
|
||||
bundleUrl: bundle,
|
||||
}),
|
||||
],
|
||||
focusedProjectGateways,
|
||||
);
|
||||
|
||||
expect(failures).toEqual([]);
|
||||
expect(pending).toEqual([]);
|
||||
expect(registry.list().map((p) => p.pluginId)).toEqual(["dev.acme.project-focused"]);
|
||||
expect((globalThis as Record<string, unknown>).__focusedProjectPluginActivated).toBe(
|
||||
"dev.acme.project-focused",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats omitted activationScope as app and isolates a project-required activation failure", async () => {
|
||||
const projectDependentBundle = dataUrl(`
|
||||
export async function activate(ctx) {
|
||||
await ctx.services.workspace.getProjectRoot();
|
||||
}
|
||||
`);
|
||||
const healthyBundle = dataUrl(`
|
||||
export function activate(ctx) {
|
||||
globalThis.__healthyAppPluginActivated = ctx.pluginId;
|
||||
}
|
||||
`);
|
||||
const noFocusedProjectGateways = {
|
||||
...gateways,
|
||||
focusedProject: {
|
||||
async getFocusedProject() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
} as PluginGatewaySet;
|
||||
|
||||
const { registry, failures, pending } = await loadPlugins(
|
||||
[
|
||||
entry({
|
||||
id: "dev.acme.default-app-scope",
|
||||
displayName: "Default App Scope",
|
||||
capabilities: ["tooling"],
|
||||
bundleUrl: projectDependentBundle,
|
||||
}),
|
||||
entry({
|
||||
id: "dev.acme.healthy-app",
|
||||
displayName: "Healthy App",
|
||||
bundleUrl: healthyBundle,
|
||||
}),
|
||||
],
|
||||
noFocusedProjectGateways,
|
||||
);
|
||||
|
||||
expect(registry.list().map((p) => p.pluginId)).toEqual(["dev.acme.healthy-app"]);
|
||||
expect(failures).toEqual([
|
||||
{
|
||||
pluginId: "dev.acme.default-app-scope",
|
||||
reason: "no current project is focused",
|
||||
},
|
||||
]);
|
||||
expect(pending).toEqual([]);
|
||||
expect((globalThis as Record<string, unknown>).__healthyAppPluginActivated).toBe(
|
||||
"dev.acme.healthy-app",
|
||||
);
|
||||
});
|
||||
|
||||
it("loads the hello-plugin command and layout contribution shape", async () => {
|
||||
const bundle = dataUrl(`
|
||||
export function activate(ctx) {
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
@ -69,9 +76,16 @@ export interface PluginLoadFailure {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PluginLoadPending {
|
||||
pluginId: string;
|
||||
displayName: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PluginLoadResult {
|
||||
registry: PluginRuntimeRegistry;
|
||||
failures: PluginLoadFailure[];
|
||||
pending: PluginLoadPending[];
|
||||
}
|
||||
|
||||
export interface PluginLoadOptions {
|
||||
@ -183,6 +197,20 @@ function hasCapability(entry: PluginRuntimePlugin, capability: string): boolean
|
||||
return arrayOrEmpty<string>(objectOrEmpty(entry).capabilities).includes(capability);
|
||||
}
|
||||
|
||||
function activationScope(entry: PluginRuntimePlugin): "app" | "project" {
|
||||
return objectOrEmpty(entry).activationScope === "project" ? "project" : "app";
|
||||
}
|
||||
|
||||
function pendingForProjectFocus(entry: PluginRuntimePlugin): PluginLoadPending {
|
||||
const entryObject = objectOrEmpty(entry);
|
||||
const pluginId = safePluginId(entry);
|
||||
return {
|
||||
pluginId,
|
||||
displayName: nonEmptyString(entryObject.displayName) ?? pluginId,
|
||||
reason: "En attente d'un projet actif.",
|
||||
};
|
||||
}
|
||||
|
||||
async function disposeAll(disposables: Disposable[], activation?: void | PluginActivation): Promise<void> {
|
||||
try {
|
||||
await activation?.dispose?.();
|
||||
@ -241,6 +269,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 +280,7 @@ async function loadOne(
|
||||
commands: createCommandContext(commands),
|
||||
layouts,
|
||||
menu,
|
||||
storage,
|
||||
};
|
||||
if (hasCapability(entry, "tooling")) {
|
||||
ctx.services = createPluginServices(gateways);
|
||||
@ -287,6 +317,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
|
||||
@ -300,13 +345,20 @@ export async function loadPlugins(
|
||||
): Promise<PluginLoadResult> {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
const failures: PluginLoadFailure[] = [];
|
||||
const pending: PluginLoadPending[] = [];
|
||||
const resolvedOptions: Required<PluginLoadOptions> = {
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_PLUGIN_LOAD_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
const entries = Array.isArray(catalogPlugins) ? catalogPlugins : [];
|
||||
const focus = await gateways.focusedProject?.getFocusedProject?.();
|
||||
const loadableEntries = entries.filter((entry) => {
|
||||
if (activationScope(entry) !== "project" || focus) return true;
|
||||
pending.push(pendingForProjectFocus(entry));
|
||||
return false;
|
||||
});
|
||||
const results = await Promise.all(
|
||||
entries.map((entry) => loadOne(entry, gateways, resolvedOptions)),
|
||||
loadableEntries.map((entry) => loadOne(entry, gateways, resolvedOptions)),
|
||||
);
|
||||
for (const result of results) {
|
||||
if ("failure" in result) {
|
||||
@ -321,9 +373,9 @@ export async function loadPlugins(
|
||||
|
||||
if (entries.length > 0) {
|
||||
console.info(
|
||||
`[plugins] load complete loaded=${registry.list().length} failed=${failures.length}`,
|
||||
`[plugins] load complete loaded=${registry.list().length} failed=${failures.length} pending=${pending.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { registry, failures };
|
||||
return { registry, failures, pending };
|
||||
}
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user