diff --git a/crates/application/src/slash_command.rs b/crates/application/src/slash_command.rs
index 25a1380..c221bd8 100644
--- a/crates/application/src/slash_command.rs
+++ b/crates/application/src/slash_command.rs
@@ -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 }
+ );
}
}
diff --git a/crates/domain/src/slash_command.rs b/crates/domain/src/slash_command.rs
index 7af9d21..dba1911 100644
--- a/crates/domain/src/slash_command.rs
+++ b/crates/domain/src/slash_command.rs
@@ -133,9 +133,7 @@ pub fn native_slash_commands() -> Vec {
"/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);
}
diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts
index b26bc99..100065f 100644
--- a/frontend/src/adapters/mock/index.ts
+++ b/frontend/src/adapters/mock/index.ts
@@ -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: {
diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx
index 5df7c9d..dc61888 100644
--- a/frontend/src/features/agents/CustomAgentChatView.test.tsx
+++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx
@@ -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(
+ null) },
+ } as unknown as Gateways}
+ >
+
+ ,
+ );
+
+ 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(),
diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx
index c938aef..9406eb7 100644
--- a/frontend/src/features/agents/CustomAgentChatView.tsx
+++ b/frontend/src/features/agents/CustomAgentChatView.tsx
@@ -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([]);
const [currentSession, setCurrentSession] = useState(sessionId);
const [externalSessionId, setExternalSessionId] = useState(sessionId);
@@ -328,6 +336,8 @@ export function CustomAgentChatView({
const [slashCommands, setSlashCommands] = useState([]);
const [slashMenuOpen, setSlashMenuOpen] = useState(false);
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
+ const [profileDialog, setProfileDialog] =
+ useState(null);
const [opening, setOpening] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(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}
)}
+ {profileDialog && (
+
+
+
+
+ Changer de profil
+
+
+ Valider ce changement va réinitialiser la session courante. L'historique de cette CLI custom sera perdu.
+
+
+
+
+
+
+
+ {profileDialog.error && (
+
+ {profileDialog.error}
+
+ )}
+
+
+
+
+
+
+ )}