Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/renderer/components/composer/ComposerAddMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ describe("ComposerAddMenu", () => {
bridgeMock.isRemoteSession.mockReturnValue(false);
});

it("keeps Chrome unavailable for WSL projects", () => {
expect(
chromeMcpServer.isAvailable({
kind: "wsl",
distro: "Ubuntu",
linuxPath: "/home/demo/repo",
uncPath: "\\\\wsl.localhost\\Ubuntu\\home\\demo\\repo",
}),
).toBe(false);
expect(chromeMcpServer.isAvailable({ kind: "windows", path: "C:\\repo" })).toBe(true);
});

it("keeps the desktop dropdown trigger free of nested buttons", () => {
const { container } = render(
<ComposerAddMenu mcpServers={[]} onPickFiles={vi.fn<() => void>()} />,
Expand Down Expand Up @@ -324,6 +336,33 @@ describe("ComposerAddMenu", () => {
expect(browserToggle).not.toHaveBeenCalled();
});

it("accepts provider-settings guidance for read-only draft bindings", () => {
render(
<ComposerAddMenu
readOnly
readOnlyCaption="Change servers in provider settings"
mcpServers={[
{
descriptor: browserMcpServer,
enabled: true,
visible: true,
onToggle: vi.fn<(next: boolean) => void>(),
},
]}
showFileOption={false}
onPickFiles={vi.fn<() => void>()}
/>,
);

openMenu();
openMcpSubmenu();

expect(screen.getByText("Change servers in provider settings")).toBeInTheDocument();
expect(
screen.queryByText("Set when this session started — start a new thread to change servers"),
).not.toBeInTheDocument();
});

it("shows an explicit empty state in read-only mode with no servers", () => {
render(
<ComposerAddMenu
Expand Down
7 changes: 5 additions & 2 deletions src/renderer/components/composer/ComposerAddMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, type ReactNode } from "react";
import {
ChevronLeft,
ChevronRight,
Expand Down Expand Up @@ -131,6 +131,7 @@ export function ComposerAddMenu(props: {
* being interactive.
*/
readOnly?: boolean;
readOnlyCaption?: ReactNode;
}) {
const { mcpServers, showFileOption = true, onPickFiles, computerUse, experiment } = props;
const customMcpServers = props.customMcpServers ?? [];
Expand Down Expand Up @@ -200,7 +201,9 @@ export function ComposerAddMenu(props: {
};

const persistenceCaption = readOnly ? (
<Trans>Set when this session started — start a new thread to change servers</Trans>
(props.readOnlyCaption ?? (
<Trans>Set when this session started — start a new thread to change servers</Trans>
))
) : (
<Trans>Enabled servers stay on for new threads</Trans>
);
Expand Down
21 changes: 16 additions & 5 deletions src/renderer/components/composer/composerMcpServers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,24 @@ export const resolveMcpScope = resolveComposerMcpScope;

/**
* Providers that declare `mcpConfigSource: "agentSettings"` configure MCP on
* their settings page instead of the composer: the "+" menu shows no MCP rows
* at all for their threads (built-ins are hidden by their `"none"` scopes;
* callers use this to suppress the custom-server rows and read-only fallbacks
* too).
* their settings page instead of the composer: the "+" menu shows their
* effective MCP rows read-only instead of exposing per-thread toggles.
*/
export function providerOwnsMcpConfig(
capabilities: Pick<AgentCapability, "mcpConfigSource">,
): boolean {
return capabilities.mcpConfigSource === "agentSettings";
}

/** Resolve a provider-owned MCP flag the same way as the supervisor runtime. */
export function providerMcpSettingEnabled(
capabilities: Pick<AgentCapability, "agentSettingsDefaults">,
settings: Record<string, boolean | string> | undefined,
key: ComposerMcpConfigKey | "computerUse",
): boolean {
return (settings?.[key] ?? capabilities.agentSettingsDefaults?.[key]) === true;
}

export interface ComposerMcpServerDescriptor {
id: "browser" | "crossagents" | "chrome";
configKey: ComposerMcpConfigKey;
Expand All @@ -56,6 +63,7 @@ export interface ComposerMcpServerDescriptor {
enabledTitle: MessageDescriptor;
/** aria-label for the chip's remove button. */
disableLabel: MessageDescriptor;
isAvailable: (projectLocation?: ProjectLocation) => boolean;
getScope: (
capabilities: AgentCapability,
presentationMode: ThreadPresentationMode,
Expand All @@ -70,6 +78,7 @@ export const browserMcpServer: ComposerMcpServerDescriptor = {
label: msg`Browser`,
enabledTitle: msg`Browser MCP enabled for this thread`,
disableLabel: msg`Disable Browser MCP`,
isAvailable: () => true,
getScope: (capabilities, presentationMode) =>
resolveMcpScope(capabilities.mcpScope, presentationMode),
};
Expand All @@ -81,6 +90,7 @@ export const crossagentMcpServer: ComposerMcpServerDescriptor = {
label: msg`Crossagents`,
enabledTitle: msg`Crossagents enabled for this thread`,
disableLabel: msg`Disable Crossagents`,
isAvailable: () => true,
getScope: (capabilities, presentationMode) =>
resolveMcpScope(capabilities.mcpScope, presentationMode),
};
Expand All @@ -92,8 +102,9 @@ export const chromeMcpServer: ComposerMcpServerDescriptor = {
label: msg`Chrome`,
enabledTitle: msg`Chrome MCP enabled for this thread`,
disableLabel: msg`Disable Chrome MCP`,
isAvailable: (projectLocation) => projectLocation?.kind !== "wsl",
getScope: (capabilities, presentationMode, projectLocation) =>
projectLocation?.kind === "wsl"
!chromeMcpServer.isAvailable(projectLocation)
? "none"
: resolveMcpScope(capabilities.mcpScope, presentationMode),
};
Expand Down
117 changes: 117 additions & 0 deletions src/renderer/components/thread/ThreadComposerSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ const analytics = vi.hoisted(() => ({
captureThreadPromptSubmitted: vi.fn<() => void>(),
}));

const composerAddMenuSpy = vi.hoisted(() => vi.fn<(props: unknown) => void>());

vi.mock("@/renderer/analytics/posthog", async (importOriginal) => ({
...(await importOriginal<typeof import("@/renderer/analytics/posthog")>()),
captureThreadPromptSubmitted: analytics.captureThreadPromptSubmitted,
Expand All @@ -67,6 +69,13 @@ vi.mock("@/renderer/actions/agentLoginActions", () => ({
runAgentLoginCommand: loginActions.runAgentLoginCommand,
}));

vi.mock("../composer/ComposerAddMenu", () => ({
ComposerAddMenu: (props: unknown) => {
composerAddMenuSpy(props);
return null;
},
}));

vi.mock("../../bridge", () => ({
isRemoteSession: bridgeMock.isRemoteSession,
readBridge: () => ({
Expand All @@ -92,6 +101,8 @@ vi.mock("./ThreadComposer", () => ({
fixedContent?: ReactNode;
attachmentBar?: ReactNode;
inputContent?: ReactNode;
leadingControls?: ReactNode | (() => ReactNode);
afterControls?: ReactNode | (() => ReactNode);
onAttachFiles?: (paths: string[]) => void;
onStop?: () => void;
onSubmit: () => void;
Expand All @@ -101,6 +112,10 @@ vi.mock("./ThreadComposer", () => ({
{props.fixedContent}
{props.attachmentBar}
{props.inputContent}
{typeof props.leadingControls === "function"
? props.leadingControls()
: props.leadingControls}
{typeof props.afterControls === "function" ? props.afterControls() : props.afterControls}
<output data-testid="control-kinds">
{props.controls?.map((control) => control.kind ?? control.label ?? "").join(",") ?? ""}
</output>
Expand Down Expand Up @@ -242,6 +257,8 @@ describe("ThreadComposerSection", () => {
pendingComposerFocusThreadId: null,
threadDraftContents: {},
provisioningWorktreeThreadIds: {},
runtimeLaunchConfigByThreadId: {},
mcpLaunchCustomServerNamesByThreadId: {},
});
useGitStore.setState({ statuses: {} });
useComposerInputInbox.setState({ itemsByComposer: {} });
Expand All @@ -256,6 +273,7 @@ describe("ThreadComposerSection", () => {
bridgeMock.setPendingSteer.mockResolvedValue(undefined);
analytics.captureProductEvent.mockClear();
analytics.captureThreadPromptSubmitted.mockClear();
composerAddMenuSpy.mockClear();
runtimeActions.changeThreadConfig.mockClear();
runtimeActions.resolveThreadServerRequest.mockClear();
runtimeActions.resolveThreadServerRequest.mockResolvedValue(undefined);
Expand Down Expand Up @@ -442,6 +460,105 @@ describe("ThreadComposerSection", () => {
expect(screen.queryByRole("option")).not.toBeInTheDocument();
});

it("shows provider-owned enabled MCPs in the indicator and @ mentions", () => {
useAppStore.setState({
runtimeLaunchConfigByThreadId: {
[guiThread.id]: { model: "gpt-5.4", crossagentMcp: true },
},
mcpLaunchCustomServerNamesByThreadId: {
[guiThread.id]: ["Vision-MCP"],
},
});
const rangeRectDescriptor = Object.getOwnPropertyDescriptor(
Range.prototype,
"getBoundingClientRect",
);
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"scrollIntoView",
);
Object.defineProperty(Range.prototype, "getBoundingClientRect", {
configurable: true,
value: () => ({ left: 0, top: 0 }),
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: () => undefined,
});

try {
renderComposer({
agentStatus: {
...codexGuiStatus,
capabilities: {
...codexGuiStatus.capabilities,
mcpConfigSource: "agentSettings",
},
},
});

const menuProps = composerAddMenuSpy.mock.lastCall?.[0] as {
mcpServers: Array<{ descriptor: { id: string }; visible: boolean }>;
customMcpServers: Array<{ name: string; enabled: boolean }>;
readOnly: boolean;
};
expect(
menuProps.mcpServers
.filter((server) => server.visible)
.map((server) => server.descriptor.id),
).toEqual(["crossagents"]);
expect(menuProps.customMcpServers).toEqual([
expect.objectContaining({ name: "Vision-MCP", enabled: true }),
]);
expect(menuProps.readOnly).toBe(true);

const input = screen.getByRole("textbox");
typeComposerText(input, "@cro");
expect(screen.getByRole("option")).toHaveTextContent("Crossagents");

typeComposerText(input, "@vis");
expect(screen.getByRole("option")).toHaveTextContent("Vision-MCP");
} finally {
if (rangeRectDescriptor) {
Object.defineProperty(Range.prototype, "getBoundingClientRect", rangeRectDescriptor);
} else {
Reflect.deleteProperty(Range.prototype, "getBoundingClientRect");
}
if (scrollIntoViewDescriptor) {
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", scrollIntoViewDescriptor);
} else {
Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
}
}
});

it("does not report client-local custom MCPs for a remote provider-owned thread", () => {
useAppStore.setState({
runtimeLaunchConfigByThreadId: {
[guiThread.id]: { model: "gpt-5.4", crossagentMcp: true },
},
mcpLaunchCustomServerNamesByThreadId: {
[guiThread.id]: ["Client-only MCP"],
},
});

renderComposer({
thread: { ...guiThread, remoteServerId: "desktop-1", remoteId: "remote-thread-1" },
agentStatus: {
...codexGuiStatus,
capabilities: {
...codexGuiStatus.capabilities,
mcpConfigSource: "agentSettings",
},
},
});

const menuProps = composerAddMenuSpy.mock.lastCall?.[0] as {
customMcpServers: unknown[];
};
expect(menuProps.customMcpServers).toEqual([]);
});

it("uses GUI presentation capabilities for slash commands and /fast submission", () => {
const divergentStatus: AgentStatus = {
...codexGuiStatus,
Expand Down
Loading