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} +

+ )} +
+ + +
+
+
+ )}