merge(cli): intègre feature/164-profile-command — /profile + reset confirmé #164 (QA verte)

Commande /profile first-class avec reset de session explicite et confirmé,
séparée de la palette slash générique. Dernier sous-ticket de l'épic #161
avant #165 (seam plugins).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 15:12:07 +02:00
5 changed files with 367 additions and 16 deletions

View File

@ -244,13 +244,17 @@ mod tests {
}
#[test]
fn profile_is_known_but_not_executable_yet() {
let err = ExecuteSlashCommand::new(SlashCommandRegistry::new())
fn profile_returns_profile_switch_effect() {
let sid = session();
let out = ExecuteSlashCommand::new(SlashCommandRegistry::new())
.execute(ExecuteSlashCommandInput {
name: "/profile".to_owned(),
session_id: Some(session()),
session_id: Some(sid),
})
.unwrap_err();
assert!(matches!(err, AppError::Invalid(message) if message.contains("unavailable")));
.unwrap();
assert_eq!(
out.effect,
SlashCommandEffect::ProfileSwitch { session_id: sid }
);
}
}

View File

@ -133,9 +133,7 @@ pub fn native_slash_commands() -> Vec<SlashCommand> {
"/profile",
"Changer le profil de l'agent",
true,
SlashCommandAvailability::Unavailable {
reason: "La selection de profil est livree par le ticket #164".to_owned(),
},
SlashCommandAvailability::Available,
SlashCommandSource::Native,
Some(NativeSlashCommand::Profile),
)
@ -180,7 +178,7 @@ mod tests {
assert_eq!(names, vec!["/help", "/clean", "/profile"]);
assert!(commands[0].availability.is_available());
assert!(commands[1].availability.is_available());
assert!(!commands[2].availability.is_available());
assert!(commands[2].availability.is_available());
assert!(commands[2].requires_confirmation);
}

View File

@ -422,10 +422,7 @@ export class MockAgentGateway implements AgentGateway {
name: "/profile",
shortDescription: "Changer le profil de l'agent",
requiresConfirmation: true,
availability: {
status: "unavailable",
reason: "La selection de profil est livree par le ticket #164",
},
availability: { status: "available" },
source: "native",
native: "profile",
},
@ -939,6 +936,21 @@ export class MockAgentGateway implements AgentGateway {
},
};
}
if (command.native === "profile") {
if (!options.sessionId) {
throw {
code: "INVALID",
message: "/profile requires a current session id",
} as GatewayError;
}
return {
command: structuredClone(command),
effect: {
kind: "profileSwitch",
sessionId: options.sessionId,
},
};
}
return {
command: structuredClone(command),
effect: {

View File

@ -522,6 +522,129 @@ describe("CustomAgentChatView", () => {
expect(screen.queryByRole("listbox", { name: "suggestions commandes slash" })).toBeNull();
});
it("executes /profile as a confirmed profile-switch flow that resets the current session", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "chat-session-2",
cellKind: "chat" as const,
assignedConversationId: "conversation-2",
})),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
executeSlashCommand: vi.fn(async () => ({
command: {
name: "/profile",
shortDescription: "Changer le profil de l'agent",
requiresConfirmation: true,
availability: { status: "available" as const },
source: "native" as const,
native: "profile" as const,
},
effect: {
kind: "profileSwitch" as const,
sessionId: "chat-session-1",
},
})),
changeAgentProfile: vi.fn(async () => ({ agent: { id: "agent-1" } })),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const profileGateway = {
listProfiles: vi.fn(async () => [
profile,
{
...profile,
id: "codex-high",
name: "Codex High",
},
]),
};
const onSessionId = vi.fn();
const onConversationId = vi.fn();
render(
<DIProvider
gateways={{
agent,
profile: profileGateway,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={onSessionId}
onConversationId={onConversationId}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "/profile" } });
fireEvent.keyDown(composer, { key: "Enter" });
const dialog = await screen.findByRole("dialog", { name: "Changer de profil" });
expect(dialog.textContent).toContain("réinitialiser la session courante");
expect(dialog.textContent).toContain("L'historique de cette CLI custom sera perdu");
expect(agent.executeSlashCommand).toHaveBeenCalledWith("/profile", {
sessionId: "chat-session-1",
});
expect(agent.sendAgentChat).not.toHaveBeenCalled();
const confirmButton = screen.getByRole("button", {
name: "Reset et changer le profil",
}) as HTMLButtonElement;
expect(confirmButton.disabled).toBe(true);
fireEvent.change(screen.getByLabelText("profil cible"), {
target: { value: "codex-high" },
});
fireEvent.click(screen.getByLabelText("Je confirme que la session courante sera reset."));
expect(confirmButton.disabled).toBe(false);
fireEvent.click(confirmButton);
await waitFor(() =>
expect(agent.changeAgentProfile).toHaveBeenCalledWith(
"project-1",
"agent-1",
"codex-high",
24,
80,
),
);
await waitFor(() =>
expect(agent.launchAgentChat).toHaveBeenCalledWith("project-1", "agent-1", {
cwd: "/repo",
rows: 24,
cols: 80,
conversationId: undefined,
nodeId: "node-1",
}),
);
expect(onSessionId).toHaveBeenCalledWith(null);
expect(onConversationId).toHaveBeenCalledWith(null);
expect(onSessionId).toHaveBeenCalledWith("chat-session-2");
expect(onConversationId).toHaveBeenCalledWith("conversation-2");
expect(screen.getByText("Profil changé vers Codex High.")).toBeTruthy();
expect(screen.queryByRole("dialog", { name: "Changer de profil" })).toBeNull();
});
it("pastes a clipboard image as a removable preview chip", async () => {
const agent = {
launchAgentChat: vi.fn(),

View File

@ -58,6 +58,14 @@ interface AttachmentDraft {
previewUrl?: string;
}
interface ProfileCommandDialogState {
profiles: AgentProfile[];
selectedProfileId: string;
confirmedReset: boolean;
busy: boolean;
error: string | null;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
@ -319,7 +327,7 @@ export function CustomAgentChatView({
onSessionId,
onConversationId,
}: CustomAgentChatViewProps) {
const { agent, system } = useGateways();
const { agent, profile: profileGateway, system } = useGateways();
const [turns, setTurns] = useState<ChatTurn[]>([]);
const [currentSession, setCurrentSession] = useState(sessionId);
const [externalSessionId, setExternalSessionId] = useState(sessionId);
@ -328,6 +336,8 @@ export function CustomAgentChatView({
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [slashMenuOpen, setSlashMenuOpen] = useState(false);
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
const [profileDialog, setProfileDialog] =
useState<ProfileCommandDialogState | null>(null);
const [opening, setOpening] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -448,7 +458,13 @@ export function CustomAgentChatView({
);
const recoverStructuredSession = useCallback(
async (options: { applyScrollback?: boolean; retryAttachNotFound?: boolean } = {}) => {
async (
options: {
applyScrollback?: boolean;
retryAttachNotFound?: boolean;
conversationId?: string | null;
} = {},
) => {
if (!agent.launchAgentChat) throw new Error("Structured launch unavailable");
console.debug("[ticket149] recoverStructuredSession:start", {
timestamp: new Date().toISOString(),
@ -462,7 +478,10 @@ export function CustomAgentChatView({
cwd,
rows: 24,
cols: 80,
conversationId: conversationId ?? undefined,
conversationId:
options.conversationId === undefined
? conversationId ?? undefined
: options.conversationId ?? undefined,
nodeId,
};
console.debug("[ticket149] recoverStructuredSession:launchAgentChat:start", {
@ -641,6 +660,102 @@ export function CustomAgentChatView({
[supported, draft, attachments.length, busy, opening],
);
async function openProfileCommandFlow() {
if (!agent.executeSlashCommand) {
setError("Commande /profile indisponible dans ce runtime.");
return;
}
setError(null);
try {
const sid =
currentSession ??
(await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
}));
const result = await agent.executeSlashCommand("/profile", { sessionId: sid });
if (result.effect.kind !== "profileSwitch") {
throw new Error("La commande /profile n'a pas renvoyé le flow profil.");
}
const availableProfiles = (await profileGateway.listProfiles()).filter(
(candidate) => Boolean(candidate.structuredAdapter),
);
if (availableProfiles.length === 0) {
throw new Error("Aucun profil compatible avec la CLI custom n'est configuré.");
}
setDraft("");
setProfileDialog({
profiles: availableProfiles,
selectedProfileId:
availableProfiles.find((candidate) => candidate.id !== profile.id)?.id ??
availableProfiles[0].id,
confirmedReset: false,
busy: false,
error: null,
});
} catch (e) {
setError(describe(e));
}
}
async function confirmProfileCommandChange() {
if (!profileDialog) return;
if (!agent.changeAgentProfile) {
setProfileDialog((prev) =>
prev ? { ...prev, error: "Changement de profil indisponible." } : prev,
);
return;
}
const selected = profileDialog.profiles.find(
(candidate) => candidate.id === profileDialog.selectedProfileId,
);
if (!selected) return;
setProfileDialog((prev) => (prev ? { ...prev, busy: true, error: null } : prev));
try {
const { relaunchedSession } = await agent.changeAgentProfile(
projectId,
agentId,
selected.id,
24,
80,
);
setTurns([]);
setCurrentSession(null);
publishSessionId(null);
onConversationIdRef.current(null);
if (relaunchedSession?.sessionId) {
try {
setCurrentSession(relaunchedSession.sessionId);
publishSessionId(relaunchedSession.sessionId);
await reattachStructuredSession(relaunchedSession.sessionId, {
applyScrollback: false,
});
} catch {
await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
conversationId: null,
});
}
} else {
await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
conversationId: null,
});
}
setTurns([{ role: "tool", label: `Profil changé vers ${selected.name}.` }]);
setProfileDialog(null);
} catch (e) {
setProfileDialog((prev) =>
prev ? { ...prev, busy: false, error: describe(e) } : prev,
);
}
}
async function pickAttachment() {
const path = await system.pickFile();
if (path) {
@ -685,6 +800,10 @@ export function CustomAgentChatView({
async function send() {
const text = draft.trim();
if (!canSend || !agent.sendAgentChat) return;
if (text === "/profile") {
await openProfileCommandFlow();
return;
}
const outgoingAttachments = attachments;
const attachmentInputs = outgoingAttachments.map((item) => item.input);
const attachmentLabels = outgoingAttachments.map((item) => item.label);
@ -797,6 +916,101 @@ export function CustomAgentChatView({
{error}
</p>
)}
{profileDialog && (
<div
role="dialog"
aria-modal="true"
aria-labelledby={`profile-command-title-${nodeId}`}
className="m-3 rounded-md border border-border bg-surface p-3 shadow-lg"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<h2
id={`profile-command-title-${nodeId}`}
className="text-sm font-semibold text-content"
>
Changer de profil
</h2>
<p className="mt-1 text-xs text-muted">
Valider ce changement va réinitialiser la session courante. L'historique de cette CLI custom sera perdu.
</p>
</div>
<Button
size="sm"
variant="ghost"
disabled={profileDialog.busy}
onClick={() => setProfileDialog(null)}
>
Fermer
</Button>
</div>
<div className="mt-3 flex min-w-0 flex-col gap-2">
<label className="flex min-w-0 flex-col gap-1 text-xs text-muted">
Profil
<select
aria-label="profil cible"
value={profileDialog.selectedProfileId}
disabled={profileDialog.busy}
className="rounded-md border border-border bg-raised px-2 py-1 text-sm text-content"
onChange={(event) =>
setProfileDialog((prev) =>
prev
? {
...prev,
selectedProfileId: event.target.value,
confirmedReset: false,
}
: prev,
)
}
>
{profileDialog.profiles.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.name}
</option>
))}
</select>
</label>
<label className="flex items-start gap-2 text-xs text-content">
<input
type="checkbox"
checked={profileDialog.confirmedReset}
disabled={profileDialog.busy}
onChange={(event) =>
setProfileDialog((prev) =>
prev ? { ...prev, confirmedReset: event.target.checked } : prev,
)
}
/>
<span>Je confirme que la session courante sera reset.</span>
</label>
{profileDialog.error && (
<p role="alert" className="text-xs text-danger">
{profileDialog.error}
</p>
)}
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
disabled={profileDialog.busy}
onClick={() => setProfileDialog(null)}
>
Annuler
</Button>
<Button
size="sm"
variant="danger"
disabled={!profileDialog.confirmedReset || profileDialog.busy}
loading={profileDialog.busy}
onClick={() => void confirmProfileCommandChange()}
>
Reset et changer le profil
</Button>
</div>
</div>
</div>
)}
<div
ref={scrollRef}