feat(cli): dispatch du callback plugin slash-command vers le runtime registry — #166 (QA verte)

Boucle le gap end-to-end de #165 : à l'exécution d'une commande slash plugin
depuis le composer custom, l'UI traite l'effet pluginCallback et le dispatche
au PluginRuntimeRegistry pour que la callback du plugin s'exécute réellement.

- runtime/registry: runCommandStrict (échec explicite si handler absent) +
  propagation de la valeur de retour ; élargit les types de retour (unknown).
- runtime/loader: chargement de contributes.slashCommands au registre +
  propagation du retour du handler.
- features/plugins/usePluginMenus: runCommand retourne unknown.
- features/agents/CustomAgentChatView: handler de l'effet pluginCallback ->
  pluginRuntime.registry.runCommandStrict, avec retour utilisateur en cas
  d'échec (commande indisponible / callback absente).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 15:47:04 +02:00
parent fbaca9d5cc
commit b0be5f04e4
6 changed files with 341 additions and 10 deletions

View File

@ -26,6 +26,7 @@ import type {
SlashCommand,
} from "@/domain";
import { useGateways } from "@/app/di";
import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider";
import { Button, Spinner, cn } from "@/shared";
import type { ChatAttachmentInput } from "@/ports";
@ -66,6 +67,11 @@ interface ProfileCommandDialogState {
error: string | null;
}
interface SlashCommandInvocation {
name: string;
arguments: unknown[];
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
@ -274,6 +280,17 @@ function slashCommandPrefix(draft: string): string | null {
return draft;
}
function slashCommandInvocation(text: string): SlashCommandInvocation | null {
if (!text.startsWith("/")) return null;
const [name, ...argumentParts] = text.split(/\s+/);
if (!name) return null;
const rawArguments = argumentParts.join(" ").trim();
return {
name,
arguments: rawArguments ? [rawArguments] : [],
};
}
function isSlashCommandAvailable(command: SlashCommand): boolean {
return command.availability.status === "available";
}
@ -328,6 +345,7 @@ export function CustomAgentChatView({
onConversationId,
}: CustomAgentChatViewProps) {
const { agent, profile: profileGateway, system } = useGateways();
const pluginRuntime = usePluginRuntime();
const [turns, setTurns] = useState<ChatTurn[]>([]);
const [currentSession, setCurrentSession] = useState(sessionId);
const [externalSessionId, setExternalSessionId] = useState(sessionId);
@ -756,6 +774,45 @@ export function CustomAgentChatView({
}
}
async function executeSlashCommand(invocation: SlashCommandInvocation) {
if (!agent.executeSlashCommand) {
setError(`Commande ${invocation.name} indisponible dans ce runtime.`);
return;
}
setDraft("");
setError(null);
try {
const sid =
currentSession ??
(await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
}));
const result = await agent.executeSlashCommand(invocation.name, {
sessionId: sid,
arguments: invocation.arguments,
});
if (result.effect.kind !== "pluginCallback") {
throw new Error(
`La commande ${invocation.name} n'a pas renvoyé d'effet pluginCallback.`,
);
}
await pluginRuntime.registry.runCommandStrict(
result.effect.pluginId,
result.effect.commandId,
...result.effect.arguments,
);
setTurns((prev) => [
...prev,
{ role: "tool", label: `Commande plugin ${result.command.name} exécutée.` },
]);
} catch (e) {
const message = `Commande ${invocation.name} échouée: ${describe(e)}`;
setError(message);
setTurns((prev) => [...prev, { role: "error", text: message }]);
}
}
async function pickAttachment() {
const path = await system.pickFile();
if (path) {
@ -804,6 +861,11 @@ export function CustomAgentChatView({
await openProfileCommandFlow();
return;
}
const slashInvocation = slashCommandInvocation(text);
if (slashInvocation) {
await executeSlashCommand(slashInvocation);
return;
}
const outgoingAttachments = attachments;
const attachmentInputs = outgoingAttachments.map((item) => item.input);
const attachmentLabels = outgoingAttachments.map((item) => item.label);