chantier: durcissement reprise headless inter-projets + persistance profils Opencode

- Implémentation reprise de session Opencode headless (conversational_recovery)
- Persistance des profils IA Opencode dans .ideai/memory avec JSON Schema
- Gestion des permissions MCP pour agents externes
- Tests QA verts : structured_launch_d3, conversation_log, tickets_missing_carnet, agents, ProfilesSettings
This commit is contained in:
2026-07-28 12:23:34 +02:00
parent 78f7c8fe2d
commit 045e0984cc
15 changed files with 782 additions and 49 deletions

View File

@ -364,6 +364,21 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
const options = Array.from(select.options).map((o) => o.text);
expect(options).toContain("Claude Code");
});
it("shows persisted OpenCode profiles in the agent profile dropdown", async () => {
const profile = new MockProfileGateway();
const openCode = await profile.cloneOpenCodeProfileFromSeed({
name: "OpenCode Local Durable",
});
await profile.saveProfile(openCode);
renderPanel(new MockAgentGateway(), profile);
await waitForIdle();
const select = screen.getByLabelText("agent profile") as HTMLSelectElement;
const options = Array.from(select.options).map((o) => o.text);
expect(options.some((text) => text.includes("OpenCode Local Durable"))).toBe(true);
});
});
// ---------------------------------------------------------------------------

View File

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { DIProvider } from "@/app/di";
@ -221,4 +221,70 @@ describe("ProfilesSettings", () => {
),
).toBeTruthy();
});
it("creates OpenCode profiles through the OpenCode clone flow and persists them for agent pickers", async () => {
const profile = new MockProfileGateway();
const cloneOpenCodeSpy = vi.spyOn(profile, "cloneOpenCodeProfileFromSeed");
const cloneGenericSpy = vi.spyOn(profile, "cloneProfileFromSeed");
renderSettings(profile);
await waitReady();
fireEvent.click(screen.getByRole("tab", { name: "OpenCode-local" }));
await waitReady();
fireEvent.click(screen.getByRole("button", { name: "Creer un profil" }));
const name = await screen.findByDisplayValue("OpenCode + llama.cpp copy");
expect(name).toBeTruthy();
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved.map((p) => p.name)).toContain("OpenCode + llama.cpp copy");
expect(saved.some((p) => p.structuredAdapter === "openCode")).toBe(true);
});
expect(cloneOpenCodeSpy).toHaveBeenCalledWith({
name: "OpenCode + llama.cpp copy",
opencode: expect.objectContaining({
baseURL: "http://localhost:8080/v1",
model: "qwen3-coder-30b",
}),
});
expect(cloneGenericSpy).not.toHaveBeenCalled();
});
it("duplicates OpenCode profiles through the OpenCode clone flow and preserves their local config", async () => {
const profile = new MockProfileGateway();
renderSettings(profile);
await waitReady();
fireEvent.click(screen.getByRole("tab", { name: "OpenCode-local" }));
await waitReady();
await createProfile();
const row = screen.getAllByRole("listitem")[0];
fireEvent.change(within(row).getByLabelText("OpenCode + llama.cpp copy base url"), {
target: { value: "http://localhost:9090/v1" },
});
fireEvent.change(within(row).getByLabelText("OpenCode + llama.cpp copy model"), {
target: { value: "local-special" },
});
fireEvent.click(within(row).getByRole("button", { name: "Enregistrer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved[0]?.opencode?.baseURL).toBe("http://localhost:9090/v1");
});
fireEvent.click(within(screen.getAllByRole("listitem")[0]).getByRole("button", {
name: "Dupliquer",
}));
await waitFor(async () => {
const saved = await profile.listProfiles();
const copies = saved.filter((p) => p.structuredAdapter === "openCode");
expect(copies).toHaveLength(2);
expect(copies[1]?.name).toBe("OpenCode + llama.cpp copy copy");
expect(copies[1]?.opencode?.baseURL).toBe("http://localhost:9090/v1");
expect(copies[1]?.opencode?.model).toBe("local-special");
});
});
});

View File

@ -56,7 +56,10 @@ function describe(e: unknown): string {
function tabFor(profile: AgentProfile): ProfileTab | null {
if (profile.structuredAdapter === "codex") return "codex";
if (profile.structuredAdapter === "claude") return "claude";
if (profile.structuredAdapter === "openCode" && profile.opencode) {
if (
profile.structuredAdapter === "openCode" &&
(profile.opencode || profile.opencodeProvider)
) {
return "openCode";
}
return null;
@ -352,6 +355,16 @@ export function ProfilesSettings() {
setBusy(true);
setError(null);
try {
if (activeTab === "openCode") {
const created = await profile.cloneOpenCodeProfileFromSeed({
name: `${seed.name} copy`,
opencode: seed.opencode,
});
await profile.saveProfile(created);
await refresh();
return;
}
const models =
activeTab === "codex" || activeTab === "claude"
? catalogue[activeTab].models
@ -404,6 +417,16 @@ export function ProfilesSettings() {
setBusy(true);
setError(null);
try {
if (source.structuredAdapter === "openCode") {
const created = await profile.cloneOpenCodeProfileFromSeed({
name: `${source.name} copy`,
opencode: source.opencode,
});
await profile.saveProfile(created);
await refresh();
return;
}
await profile.cloneProfileFromSeed({
seedProfileId: source.id,
name: `${source.name} copy`,