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:
2026-09-07 22:49:12 +02:00
parent ad2c6b4354
commit cb5196fd69
24 changed files with 663 additions and 37 deletions

View File

@ -176,6 +176,18 @@ describe("TauriAgentGateway invoke payloads", () => {
});
});
it("update_project_orchestrator wraps the project and agent in the request DTO", async () => {
invoke.mockResolvedValueOnce({ orchestratorAgentId: "agent-2" });
await expect(
new TauriAgentGateway().updateProjectOrchestrator("proj-1", "agent-2"),
).resolves.toEqual({ orchestratorAgentId: "agent-2" });
expect(invoke).toHaveBeenCalledWith("update_project_orchestrator", {
request: { projectId: "proj-1", agentId: "agent-2" },
});
});
it("cancelAgentChat invokes cancel_agent_chat without closing the session", async () => {
await new TauriAgentGateway().cancelAgentChat("chat-session-1");

View File

@ -154,6 +154,16 @@ export class TauriAgentGateway implements AgentGateway {
});
}
updateProjectOrchestrator(
projectId: string,
agentId: string,
): Promise<{ orchestratorAgentId: string }> {
return invoke<{ orchestratorAgentId: string }>(
"update_project_orchestrator",
{ request: { projectId, agentId } },
);
}
readContext(projectId: string, agentId: string): Promise<string> {
return invoke<AgentContextDocument>("read_agent_context", {
projectId,

View File

@ -141,6 +141,26 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
});
});
it("updates the project orchestrator through the dedicated request DTO", async () => {
const response = { orchestratorAgentId: "agent-2" };
const { gw, calls } = httpAgentGateway(async () => ({
ok: true,
status: 200,
json: async () => response,
text: async () => JSON.stringify(response),
}));
await expect(
gw.updateProjectOrchestrator("proj-1", "agent-2"),
).resolves.toEqual(response);
const init = calls[0].init as { body: string };
expect(JSON.parse(init.body)).toEqual({
command: "update_project_orchestrator",
args: { request: { projectId: "proj-1", agentId: "agent-2" } },
});
});
it("launch → attached: conforming agent.launch frame, assignedConversationId consumed", async () => {
const { gw, sockets } = gateway();
const chunks: Uint8Array[] = [];

View File

@ -258,6 +258,15 @@ export class HttpAgentGateway implements AgentGateway {
request: { projectId, agentId, contactable },
});
}
updateProjectOrchestrator(
projectId: string,
agentId: string,
): Promise<{ orchestratorAgentId: string }> {
return this.http.invoke<{ orchestratorAgentId: string }>(
"update_project_orchestrator",
{ request: { projectId, agentId } },
);
}
readContext(projectId: string, agentId: string): Promise<string> {
return this.http
.invoke<AgentContextDocument>("read_agent_context", { projectId, agentId })

View File

@ -720,6 +720,24 @@ export class MockAgentGateway implements AgentGateway {
return structuredClone(list[idx]);
}
async updateProjectOrchestrator(
projectId: string,
agentId: string,
): Promise<{ orchestratorAgentId: string }> {
const list = this.getAgents(projectId);
if (!list.some((agent) => agent.id === agentId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
for (let index = 0; index < list.length; index += 1) {
list[index] = { ...list[index], isOrchestrator: list[index].id === agentId };
}
return { orchestratorAgentId: agentId };
}
// ── Internal helpers for MockTemplateGateway (same-package use only) ──
/**

View File

@ -1317,6 +1317,8 @@ export interface Agent {
effort?: EffortSelection;
/** Whether agents from other projects may contact this agent. Legacy absence means false. */
contactable?: boolean;
/** Whether this agent is the effective single writer of the global project context. */
isOrchestrator?: boolean;
}
/** Response DTO returned by `read_agent_context`; adapters unwrap `content`. */

View File

@ -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&nbsp;?
</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>
);
}

View File

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

View File

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

View File

@ -277,6 +277,11 @@ export interface AgentGateway {
agentId: string,
contactable: boolean,
): Promise<Agent>;
/** Selects the project's single writer of the global shared context. */
updateProjectOrchestrator(
projectId: string,
agentId: string,
): Promise<{ orchestratorAgentId: string }>;
/** Reads an agent's `.md` context by agent id. */
readContext(projectId: string, agentId: string): Promise<string>;
/** Overwrites an agent's `.md` context. */