feat(agents): role orchestrateur du contexte global expose et configurable par agent — isOrchestrator distinct des permissions MCP, DTO update_project_orchestrator (terminologie wire legacy conservee), refus idea_context_update explicite avec writer effectif + orientation idea_context_propose, surface AgentsPanel + adaptateurs frontend — #276 (QA verte: cargo fmt/check, tests cibles application/app-tauri/infrastructure/backend, npm typecheck, 69/69 tests frontend agents/adapters)
This commit is contained in:
@ -188,6 +188,11 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
agentId: string;
|
||||
profileId: string;
|
||||
} | null>(null);
|
||||
const [pendingWriterChange, setPendingWriterChange] = useState<{
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
currentWriterName: string;
|
||||
} | null>(null);
|
||||
|
||||
async function confirmProfileChange() {
|
||||
if (!pendingProfileChange) return;
|
||||
@ -204,6 +209,27 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function requestWriterChange(agent: Agent) {
|
||||
if (agent.isOrchestrator || vm.busy) return;
|
||||
const currentWriter = vm.agents.find((candidate) => candidate.isOrchestrator);
|
||||
if (currentWriter && currentWriter.id !== agent.id) {
|
||||
setPendingWriterChange({
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
currentWriterName: currentWriter.name,
|
||||
});
|
||||
return;
|
||||
}
|
||||
void vm.updateProjectOrchestrator(agent.id);
|
||||
}
|
||||
|
||||
async function confirmWriterChange() {
|
||||
if (!pendingWriterChange) return;
|
||||
const agentId = pendingWriterChange.agentId;
|
||||
setPendingWriterChange(null);
|
||||
await vm.updateProjectOrchestrator(agentId);
|
||||
}
|
||||
|
||||
const selectedTemplate = templates.find((t) => t.id === newTemplateId) ?? null;
|
||||
const selectedTemplateMissingProfile =
|
||||
selectedTemplate !== null && selectedTemplate.defaultProfileId.trim().length === 0;
|
||||
@ -440,6 +466,14 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
>
|
||||
<span className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-content">{a.name}</span>
|
||||
{a.isOrchestrator && (
|
||||
<span
|
||||
aria-label="Writer contexte"
|
||||
className="rounded-full border border-border-strong bg-raised px-2 py-0.5 text-xs font-medium text-content"
|
||||
>
|
||||
Writer contexte
|
||||
</span>
|
||||
)}
|
||||
{agentDrift && (
|
||||
<span
|
||||
aria-label="update available"
|
||||
@ -471,6 +505,35 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isSelected && (
|
||||
<section
|
||||
aria-label={`Rôle dans le projet — ${a.name}`}
|
||||
className="rounded-md border border-border bg-surface p-3"
|
||||
>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Rôle dans le projet
|
||||
</h4>
|
||||
<label className="mt-2 flex cursor-pointer items-start gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="project-context-writer"
|
||||
aria-label={`Writer du contexte projet global pour ${a.name}`}
|
||||
checked={a.isOrchestrator === true}
|
||||
disabled={vm.busy}
|
||||
onChange={() => requestWriterChange(a)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-sm font-medium text-content">
|
||||
Writer du contexte projet global
|
||||
</span>
|
||||
</label>
|
||||
<p className="mt-1 pl-5 text-xs text-muted">
|
||||
Peut modifier directement le contexte partagé du projet.
|
||||
Un seul agent peut avoir ce rôle.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{limitState && (
|
||||
<AgentLimitBadge
|
||||
state={limitState}
|
||||
@ -747,6 +810,44 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingWriterChange && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Confirmer le changement de writer contexte"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
|
||||
<h4 className="text-sm font-semibold text-content">
|
||||
Changer le writer du contexte
|
||||
</h4>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Le rôle sera retiré de {pendingWriterChange.currentWriterName} et
|
||||
attribué à {pendingWriterChange.agentName}. Continuer ?
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="Annuler le changement de writer contexte"
|
||||
disabled={vm.busy}
|
||||
onClick={() => setPendingWriterChange(null)}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
aria-label="Confirmer le changement de writer contexte"
|
||||
loading={vm.busy}
|
||||
disabled={vm.busy}
|
||||
onClick={() => void confirmWriterChange()}
|
||||
>
|
||||
Continuer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@ -144,6 +144,122 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the context-writer badge and keeps the role separate from permissions", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const writer = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Main",
|
||||
profileId: "p1",
|
||||
});
|
||||
await agent.updateProjectOrchestrator(PROJECT_ID, writer.id);
|
||||
|
||||
renderPanel(agent);
|
||||
|
||||
expect(await screen.findByText("Writer contexte")).toBeTruthy();
|
||||
const rowButton = screen
|
||||
.getAllByRole("button", { name: /main/i })
|
||||
.find((button) => button.hasAttribute("aria-pressed"))!;
|
||||
fireEvent.click(rowButton);
|
||||
|
||||
expect(await screen.findByText("Rôle dans le projet")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Peut modifier directement le contexte partagé du projet. Un seul agent peut avoir ce rôle.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole("radio", {
|
||||
name: "Writer du contexte projet global pour Main",
|
||||
}) as HTMLInputElement).checked,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("confirms replacement of the current writer then refreshes the agent list", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const current = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Main",
|
||||
profileId: "p1",
|
||||
});
|
||||
const replacement = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Architect",
|
||||
profileId: "p1",
|
||||
});
|
||||
await agent.updateProjectOrchestrator(PROJECT_ID, current.id);
|
||||
const updateSpy = vi.spyOn(agent, "updateProjectOrchestrator");
|
||||
|
||||
renderPanel(agent);
|
||||
const rowButton = (
|
||||
await screen.findAllByRole("button", { name: /architect/i })
|
||||
).find((button) => button.hasAttribute("aria-pressed"))!;
|
||||
fireEvent.click(rowButton);
|
||||
fireEvent.click(
|
||||
await screen.findByRole("radio", {
|
||||
name: "Writer du contexte projet global pour Architect",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(updateSpy).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("dialog").textContent).toMatch(
|
||||
/Le rôle sera retiré de Main et attribué à Architect\. Continuer/,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Confirmer le changement de writer contexte",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, replacement.id);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(
|
||||
(screen.getByRole("radio", {
|
||||
name: "Writer du contexte projet global pour Architect",
|
||||
}) as HTMLInputElement).checked,
|
||||
).toBe(true);
|
||||
});
|
||||
expect(
|
||||
(await agent.listAgents(PROJECT_ID)).filter(
|
||||
(item) => item.isOrchestrator,
|
||||
),
|
||||
).toEqual([expect.objectContaining({ id: replacement.id })]);
|
||||
});
|
||||
|
||||
it("surfaces a backend error when changing the context writer fails", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const current = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Main",
|
||||
profileId: "p1",
|
||||
});
|
||||
await agent.createAgent(PROJECT_ID, { name: "QA", profileId: "p1" });
|
||||
await agent.updateProjectOrchestrator(PROJECT_ID, current.id);
|
||||
vi.spyOn(agent, "updateProjectOrchestrator").mockRejectedValueOnce({
|
||||
code: "PERSISTENCE_ERROR",
|
||||
message: "Impossible de sauvegarder le writer",
|
||||
});
|
||||
|
||||
renderPanel(agent);
|
||||
await screen.findByText("QA");
|
||||
const rowButton = screen.getAllByRole("button").find(
|
||||
(button) =>
|
||||
button.hasAttribute("aria-pressed") && button.textContent?.includes("QA"),
|
||||
)!;
|
||||
fireEvent.click(rowButton);
|
||||
fireEvent.click(
|
||||
await screen.findByRole("radio", {
|
||||
name: "Writer du contexte projet global pour QA",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Confirmer le changement de writer contexte",
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"Impossible de sauvegarder le writer",
|
||||
);
|
||||
});
|
||||
|
||||
it("the Create button is disabled when the name is empty", async () => {
|
||||
renderPanel();
|
||||
await waitForIdle();
|
||||
|
||||
@ -130,6 +130,8 @@ export interface AgentsViewModel {
|
||||
agentId: string,
|
||||
effort: EffortSelection | null,
|
||||
) => Promise<void>;
|
||||
/** Selects this agent as the single writer of the global project context. */
|
||||
updateProjectOrchestrator: (agentId: string) => Promise<void>;
|
||||
/** Deletes an agent; deselects if it was selected. */
|
||||
deleteAgent: (agentId: string) => Promise<void>;
|
||||
/**
|
||||
@ -453,6 +455,23 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
[agent, projectId],
|
||||
);
|
||||
|
||||
const updateProjectOrchestrator = useCallback(
|
||||
async (agentId: string): Promise<void> => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await agent.updateProjectOrchestrator(projectId, agentId);
|
||||
const refreshed = await agent.listAgents(projectId);
|
||||
setAgents(refreshed);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[agent, projectId],
|
||||
);
|
||||
|
||||
const deleteAgent = useCallback(
|
||||
async (agentId: string) => {
|
||||
setBusy(true);
|
||||
@ -571,6 +590,7 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
saveContext,
|
||||
changeAgentProfile,
|
||||
updateAgentEffort,
|
||||
updateProjectOrchestrator,
|
||||
deleteAgent,
|
||||
launchAgent,
|
||||
stopAgent,
|
||||
|
||||
Reference in New Issue
Block a user