Redesign Extensions page. Remove enable toggle in UI. Treat Extension Manager as core MCP enabler per session. (#8940)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-05-01 19:16:55 -07:00
committed by GitHub
parent c365e7b950
commit 45d8bf81d0
67 changed files with 2415 additions and 980 deletions
@@ -125,6 +125,8 @@ describe("acpNotificationHandler", () => {
type: "toolRequest",
id: "tool-1",
name: "mcp_app_bench__inspect_host_info",
toolName: "mcp_app_bench__inspect_host_info",
extensionName: "mcp_app_bench",
status: "completed",
});
expect(message.content[1]).toMatchObject({
@@ -234,6 +236,11 @@ describe("acpNotificationHandler", () => {
"mcpApp",
"text",
]);
expect(buffer?.[1]?.content[0]).toMatchObject({
type: "toolRequest",
toolName: "mcp_app_bench__inspect_host_info",
extensionName: "mcp_app_bench",
});
expect(buffer?.[1]?.content[2]).toMatchObject({
type: "mcpApp",
id: "tool-1",
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
clearReplayBuffer,
getReplayBuffer,
} from "@/features/chat/hooks/replayBuffer";
import { useChatStore } from "@/features/chat/stores/chatStore";
import {
clearMessageTracking,
handleSessionNotification,
setActiveMessageId,
} from "../acpNotificationHandler";
import { registerSession } from "../acpSessionTracker";
describe("ACP tool call status handling", () => {
beforeEach(() => {
clearMessageTracking();
clearReplayBuffer("replay-failed-tool-session");
clearReplayBuffer("goose-session");
useChatStore.setState({
messagesBySession: {},
sessionStateById: {},
queuedMessageBySession: {},
draftsBySession: {},
activeSessionId: null,
isConnected: false,
loadingSessionIds: new Set<string>(),
scrollTargetMessageBySession: {},
});
});
it("marks failed replay tool updates as errors", async () => {
const replaySessionId = "replay-failed-tool-session";
useChatStore.setState({
loadingSessionIds: new Set<string>([replaySessionId]),
});
await handleSessionNotification({
sessionId: replaySessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-1",
title: "shell",
},
} as never);
await handleSessionNotification({
sessionId: replaySessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
status: "failed",
content: [
{
type: "content",
content: {
type: "text",
text: "Command failed.",
},
},
],
},
} as never);
const assistant = getReplayBuffer(replaySessionId)?.[0];
expect(assistant?.content[0]).toMatchObject({
type: "toolRequest",
id: "tool-1",
status: "error",
});
expect(assistant?.content[1]).toMatchObject({
type: "toolResponse",
id: "tool-1",
isError: true,
result: "Command failed.",
});
});
it("marks failed live tool updates as errors", async () => {
registerSession(
"local-session",
"goose-session",
"goose",
"/Users/aharvard/.goose/artifacts",
);
setActiveMessageId("goose-session", "assistant-1");
await handleSessionNotification({
sessionId: "goose-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-1",
title: "shell",
},
} as never);
await handleSessionNotification({
sessionId: "goose-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
status: "failed",
content: [
{
type: "content",
content: {
type: "text",
text: "Command failed.",
},
},
],
},
} as never);
const [message] =
useChatStore.getState().messagesBySession["local-session"];
expect(message.content[0]).toMatchObject({
type: "toolRequest",
id: "tool-1",
status: "error",
});
expect(message.content[1]).toMatchObject({
type: "toolResponse",
id: "tool-1",
isError: true,
result: "Command failed.",
});
});
});
@@ -9,6 +9,7 @@ import {
findLatestUnpairedToolRequest,
} from "@/features/chat/hooks/replayBuffer";
import type {
ToolCallStatus,
ToolRequestContent,
ToolResponseContent,
} from "@/shared/types/messages";
@@ -30,6 +31,7 @@ import {
getLocalSessionId,
subscribeToSessionRegistration,
} from "./acpSessionTracker";
import { getToolCallIdentity } from "./acpToolCallIdentity";
import { perfLog } from "@/shared/lib/perfLog";
// Pre-set message ID for the next live stream per goose session
@@ -53,6 +55,9 @@ const pendingUsageUpdates = new Map<
{ accumulatedTotal: number; contextLimit: number }
>();
const toolCallStatusFromUpdate = (status: string): ToolCallStatus =>
status === "failed" ? "error" : "completed";
subscribeToSessionRegistration((localSessionId, gooseSessionId) => {
const pendingUsage = pendingUsageUpdates.get(gooseSessionId);
if (!pendingUsage) {
@@ -180,6 +185,7 @@ function handleReplay(
case "tool_call": {
const created = getReplayCreated(update);
const identity = getToolCallIdentity(update);
const msg = ensureReplayAssistantMessage(
sessionId,
getReplayMessageId(update),
@@ -189,6 +195,7 @@ function handleReplay(
type: "toolRequest",
id: update.toolCallId,
name: update.title,
...identity,
arguments: {},
status: "executing",
startedAt: created ?? Date.now(),
@@ -199,6 +206,7 @@ function handleReplay(
case "tool_call_update": {
const created = getReplayCreated(update);
const replayMessageId = getReplayMessageId(update);
const identity = getToolCallIdentity(update);
const trackedMessageId = getTrackedReplayAssistantMessageId(sessionId);
const replayMsg = replayMessageId
? getBufferedMessage(sessionId, replayMessageId)
@@ -216,15 +224,19 @@ function handleReplay(
if (created !== undefined && !existingMsg && msg === replayMsg) {
msg.created = created;
}
if (update.title) {
if (update.title || Object.keys(identity).length > 0) {
const tc = msg.content.find(
(c) => c.type === "toolRequest" && c.id === update.toolCallId,
);
if (tc && tc.type === "toolRequest") {
(tc as ToolRequestContent).name = update.title;
Object.assign(tc as ToolRequestContent, {
...(update.title ? { name: update.title } : {}),
...identity,
});
}
}
if (update.status === "completed" || update.status === "failed") {
const toolCallStatus = toolCallStatusFromUpdate(update.status);
const tc = msg.content.find(
(c) => c.type === "toolRequest" && c.id === update.toolCallId,
);
@@ -233,7 +245,8 @@ function handleReplay(
if (idx >= 0) {
msg.content[idx] = {
...tc,
status: "completed",
...identity,
status: toolCallStatus,
} as ToolRequestContent;
}
}
@@ -299,11 +312,13 @@ function handleLive(
case "tool_call": {
const messageId = ensureLiveAssistantMessage(sessionId, gooseSessionId);
const identity = getToolCallIdentity(update);
const toolRequest: ToolRequestContent = {
type: "toolRequest",
id: update.toolCallId,
name: update.title,
...identity,
arguments: {},
status: "executing",
startedAt: Date.now(),
@@ -315,19 +330,25 @@ function handleLive(
case "tool_call_update": {
const messageId = ensureLiveAssistantMessage(sessionId, gooseSessionId);
const identity = getToolCallIdentity(update);
if (update.title) {
if (update.title || Object.keys(identity).length > 0) {
store.updateMessage(sessionId, messageId, (msg) => ({
...msg,
content: msg.content.map((c) =>
c.type === "toolRequest" && c.id === update.toolCallId
? { ...c, name: update.title ?? "" }
? {
...c,
...(update.title ? { name: update.title } : {}),
...identity,
}
: c,
),
}));
}
if (update.status === "completed" || update.status === "failed") {
const toolCallStatus = toolCallStatusFromUpdate(update.status);
const streamingMessage = store.messagesBySession[sessionId]?.find(
(m) => m.id === messageId,
);
@@ -339,7 +360,11 @@ function handleLive(
...msg,
content: msg.content.map((block) =>
block.type === "toolRequest" && block.id === update.toolCallId
? { ...block, status: "completed" }
? {
...block,
...identity,
status: toolCallStatus,
}
: block,
),
}));
@@ -0,0 +1,36 @@
import type { SessionUpdate } from "@agentclientprotocol/sdk";
export interface ToolCallIdentity {
toolName?: string;
extensionName?: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function getToolCallIdentity(update: SessionUpdate): ToolCallIdentity {
if (!isRecord(update._meta)) {
return {};
}
const goose = update._meta.goose;
if (!isRecord(goose)) {
return {};
}
const toolCall = isRecord(goose.mcpApp)
? goose.mcpApp
: isRecord(goose.toolCall)
? goose.toolCall
: null;
if (!toolCall) return {};
return {
...(typeof toolCall.toolName === "string"
? { toolName: toolCall.toolName }
: {}),
...(typeof toolCall.extensionName === "string"
? { extensionName: toolCall.extensionName }
: {}),
};
}
@@ -45,9 +45,7 @@
"empty": {
"folderNotSet": "Folder not set",
"noChanges": "No uncommitted changes",
"noProjectAssigned": "No project assigned",
"noExtensions": "No extensions enabled",
"noMatchingExtensions": "No matching extensions"
"noProjectAssigned": "No project assigned"
},
"errors": {
"gitRead": "Couldn't read git status."
@@ -91,8 +89,6 @@
"artifacts": "Artifacts",
"changes": "Changes",
"changesOnBranch": "on",
"extensions": "Extensions",
"searchExtensions": "Search extensions...",
"workspace": "Workspace"
}
},
@@ -83,16 +83,17 @@
"extensions": {
"title": "Extensions",
"search": "Search extensions...",
"enabled": "Enabled",
"enabledCount": "Enabled ({{count}})",
"available": "Available",
"availableCount": "Available ({{count}})",
"empty": "No extensions configured.",
"noResults": "No extensions match your search.",
"addExtension": "Add extension",
"editExtension": "Edit Extension",
"deleteExtension": "Delete Extension",
"toggle": "Toggle {{name}}",
"deleteConfirmation": {
"title": "Delete \"{{name}}\" permanently?",
"description": "This will permanently remove this extension and all of its settings.",
"confirm": "Delete Extension",
"deleting": "Deleting..."
},
"configure": "Configure {{name}}",
"save": "Save",
"cancel": "Cancel",
@@ -120,8 +121,20 @@
"streamable_http": "HTTP",
"builtin": "Built-in"
},
"filters": {
"all": "All"
},
"categories": {
"appsServices": "Apps & services",
"gooseCapabilities": "Goose capabilities"
},
"sections": {
"extensions": "Extensions",
"gooseCapabilities": "Built-in Goose capabilities"
},
"showGooseCapabilities": "Show {{count}} built-in Goose capabilities",
"hideGooseCapabilities": "Hide built-in Goose capabilities",
"errors": {
"toggleFailed": "Failed to toggle extension. Please try again.",
"saveFailed": "Failed to save extension. Please try again.",
"deleteFailed": "Failed to delete extension. Please try again.",
"nameConflict": "An extension named \"{{name}}\" already exists."
@@ -198,7 +211,6 @@
"doctor": "Doctor",
"general": "General",
"projects": "Projects",
"extensions": "Extensions",
"providers": "Providers",
"voice": "Voice"
},
@@ -12,6 +12,7 @@
},
"navigation": {
"agents": "Agents",
"extensions": "Extensions",
"home": "Home",
"sessionHistory": "Session history",
"skills": "Skills"
@@ -45,9 +45,7 @@
"empty": {
"folderNotSet": "Carpeta no configurada",
"noChanges": "No hay cambios sin confirmar",
"noProjectAssigned": "No hay proyecto asignado",
"noExtensions": "No hay extensiones habilitadas",
"noMatchingExtensions": "No hay extensiones que coincidan"
"noProjectAssigned": "No hay proyecto asignado"
},
"errors": {
"gitRead": "No se pudo leer el estado de git."
@@ -91,8 +89,6 @@
"artifacts": "Artefactos",
"changes": "Cambios",
"changesOnBranch": "en",
"extensions": "Extensiones",
"searchExtensions": "Buscar extensiones...",
"workspace": "Espacio de trabajo"
}
},
@@ -83,16 +83,17 @@
"extensions": {
"title": "Extensiones",
"search": "Buscar extensiones...",
"enabled": "Habilitadas",
"enabledCount": "Habilitadas ({{count}})",
"available": "Disponibles",
"availableCount": "Disponibles ({{count}})",
"empty": "No hay extensiones configuradas.",
"noResults": "Ninguna extensión coincide con tu búsqueda.",
"addExtension": "Agregar extensión",
"editExtension": "Editar extensión",
"deleteExtension": "Eliminar extensión",
"toggle": "Activar/desactivar {{name}}",
"deleteConfirmation": {
"title": "¿Eliminar \"{{name}}\" de forma permanente?",
"description": "Esto eliminará de forma permanente esta extensión y toda su configuración.",
"confirm": "Eliminar extensión",
"deleting": "Eliminando..."
},
"configure": "Configurar {{name}}",
"save": "Guardar",
"cancel": "Cancelar",
@@ -120,8 +121,20 @@
"streamable_http": "HTTP",
"builtin": "Integrada"
},
"filters": {
"all": "Todas"
},
"categories": {
"appsServices": "Apps y servicios",
"gooseCapabilities": "Capacidades de Goose"
},
"sections": {
"extensions": "Extensiones",
"gooseCapabilities": "Capacidades integradas de Goose"
},
"showGooseCapabilities": "Mostrar {{count}} capacidades integradas de Goose",
"hideGooseCapabilities": "Ocultar capacidades integradas de Goose",
"errors": {
"toggleFailed": "Error al cambiar la extensión. Inténtalo de nuevo.",
"saveFailed": "Error al guardar la extensión. Inténtalo de nuevo.",
"deleteFailed": "Error al eliminar la extensión. Inténtalo de nuevo.",
"nameConflict": "Ya existe una extensión llamada \"{{name}}\"."
@@ -198,7 +211,6 @@
"doctor": "Diagnóstico",
"general": "General",
"projects": "Proyectos",
"extensions": "Extensiones",
"providers": "Proveedores",
"voice": "Voz"
},
@@ -12,6 +12,7 @@
},
"navigation": {
"agents": "Agentes",
"extensions": "Extensiones",
"home": "Inicio",
"sessionHistory": "Historial de sesiones",
"skills": "Habilidades"
+2
View File
@@ -78,6 +78,8 @@ export interface ToolRequestContent {
type: "toolRequest";
id: string;
name: string;
toolName?: string;
extensionName?: string;
arguments: Record<string, unknown>;
status: ToolCallStatus;
/** Epoch ms when the tool call started executing (set on event receipt). */
@@ -21,7 +21,7 @@ export function SessionActivityIndicator({
role="status"
aria-label="Chat active"
className={cn(
"absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full border border-background bg-background shadow-sm transition-opacity duration-200 ease-out animate-in fade-in-0",
"absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center transition-opacity duration-200 ease-out animate-in fade-in-0",
className,
)}
>
@@ -0,0 +1,33 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ConfirmDialog } from "./confirm-dialog";
describe("ConfirmDialog", () => {
it("routes rejected confirm actions to onConfirmError", async () => {
const user = userEvent.setup();
const error = new Error("Delete failed");
const onConfirm = vi.fn().mockRejectedValue(error);
const onConfirmError = vi.fn();
render(
<ConfirmDialog
open
onOpenChange={vi.fn()}
title="Delete item?"
description="This cannot be undone."
cancelLabel="Cancel"
confirmLabel="Delete"
onConfirm={onConfirm}
onConfirmError={onConfirmError}
/>,
);
await user.click(screen.getByRole("button", { name: "Delete" }));
expect(onConfirm).toHaveBeenCalledOnce();
await waitFor(() => {
expect(onConfirmError).toHaveBeenCalledWith(error);
});
});
});
@@ -0,0 +1,92 @@
import type * as React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Button, type ButtonProps } from "@/shared/ui/button";
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: React.ReactNode;
description: React.ReactNode;
cancelLabel: React.ReactNode;
confirmLabel: React.ReactNode;
loadingLabel?: React.ReactNode;
isLoading?: boolean;
confirmVariant?: ButtonProps["variant"];
contentClassName?: string;
overlayClassName?: string;
positionerClassName?: string;
onConfirm: () => void | Promise<void>;
onConfirmError?: (error: unknown) => void;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
cancelLabel,
confirmLabel,
loadingLabel,
isLoading = false,
confirmVariant = "destructive",
contentClassName = "max-w-sm",
overlayClassName,
positionerClassName,
onConfirm,
onConfirmError,
}: ConfirmDialogProps) {
const handleConfirm = async () => {
try {
await onConfirm();
} catch (error) {
onConfirmError?.(error);
}
};
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!isLoading) {
onOpenChange(nextOpen);
}
}}
>
<DialogContent
className={contentClassName}
overlayClassName={overlayClassName}
positionerClassName={positionerClassName}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isLoading}
onClick={() => onOpenChange(false)}
>
{cancelLabel}
</Button>
<Button
type="button"
variant={confirmVariant}
disabled={isLoading}
onClick={() => void handleConfirm()}
>
{isLoading && loadingLabel ? loadingLabel : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+9 -2
View File
@@ -47,17 +47,24 @@ function DialogOverlay({
function DialogContent({
className,
children,
overlayClassName,
positionerClassName,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
overlayClassName?: string;
positionerClassName?: string;
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogOverlay className={overlayClassName} />
<div
data-slot="dialog-positioner"
className="pointer-events-none fixed inset-0 z-[61] grid place-items-center p-4"
className={cn(
"pointer-events-none fixed inset-0 z-[61] grid place-items-center p-4",
positionerClassName,
)}
>
<DialogPrimitive.Content
data-slot="dialog-content"