mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
Remove the `ExperimentAgents` feature flag so the Agents feature is always available without requiring `--experiments=agents`. The feature is now in beta. Existing deployments that still pass `--experiments=agents` will get a harmless "ignoring unknown experiment" warning on startup. ### Changes **Backend:** - Remove `RequireExperimentWithDevBypass` middleware from chat and MCP server routes - Always include `AgentsAccessRole` in assignable site roles (later refactored to org-scoped on main; rebase keeps that) - Always set `AgentsTabVisible = true`, then drop the entire dead `AgentsTabVisible` metadata pipeline (Go htmlState field, populateHTMLState goroutine, HTML meta tag, useEmbeddedMetadata registration, mock); no production consumer reads it. `AgentsNavItem` already gates on `permissions.createChat`. - Make `blob:` CSP `img-src` addition unconditional - Remove `ExperimentAgents` constant, `DisplayName` case, and `ExperimentsKnown` entry **CLI:** - Graduate the agents TUI from `coder exp agents` to `coder agents` (moved from `AGPLExperimental()` to `CoreSubcommands()`) - Drop the `agent` alias so it does not collide with the hidden workspace-agent command - Rename implementation files `cli/exp_agents_*.go` -> `cli/agents_*.go` and internal identifiers (`expChatsTUIModel` -> `chatsTUIModel`, `newExpChatsTUIModel` -> `newChatsTUIModel`, `setupExpAgentsBackend` -> `setupAgentsBackend`, `startExpAgentsSession` -> `startAgentsSession`, `expAgentsPtr` -> `agentsPtr`, `expAgentsSession` -> `agentsSession`, `TestExpAgents*` -> `TestAgents*`). `expClient` (the `*codersdk.ExperimentalClient` local) is kept; `coderd/exp_chats*.go` and other still-experimental `cli/exp_*.go` commands are intentionally untouched. **Frontend:** - Remove experiment check from `AgentsNavItem` - render when `canCreateChat` is true - Remove `agentsEnabled` experiment check from `WorkspacesPage`, then gate `chatsByWorkspace` on `permissions.createChat` so users without chat access don't trigger the per-page DB query (Copilot review feedback) - Add `FeatureStageBadge` (beta) next to the Coder logo in the Agents sidebar (desktop + mobile) **Docs:** - Remove experiment flag setup instructions from `early-access.md` and `getting-started.md` (and rename `early-access.md`'s "Enable Coder Agents" heading to "Set up Coder Agents", since there is no enablement step left) - Update `chats-api.md` and `getting-started.md`'s Chats API note to say "beta" instead of "experimental" - `docs/manifest.json`: drop "experimental" from the Chats API sidebar description - `make gen` regenerated `docs/reference/cli/agents.md` and the CLI index - `scripts/check_emdash.sh`: exclude `cli/testdata/*.golden` and `enterprise/cli/testdata/*.golden` from the new repo-wide emdash lint, since serpent emits emdash borders in every generated `--help` golden file **Tests:** - Remove `ExperimentAgents` setup from all test files (14 occurrences across 7 files) - Update stale "with the agents experiment" comments in `coderd/x/chatd/integration_test.go` and `coderd/mcp_test.go` <img width="1185" height="900" alt="image" src="https://github.com/user-attachments/assets/b420bc8f-41d6-42c6-abd8-ad572533d651" /> > 🤖 Generated by Coder Agents
310 lines
8.0 KiB
TypeScript
310 lines
8.0 KiB
TypeScript
import { act, renderHook } from "@testing-library/react";
|
|
import type { Region, User } from "#/api/typesGenerated";
|
|
import {
|
|
MockAppearanceConfig,
|
|
MockBuildInfo,
|
|
MockEntitlements,
|
|
MockExperiments,
|
|
MockOrganization,
|
|
MockPermissions,
|
|
MockTasksTabVisible,
|
|
MockUserAppearanceSettings,
|
|
MockUserOwner,
|
|
} from "#/testHelpers/entities";
|
|
import {
|
|
DEFAULT_METADATA_KEY,
|
|
type MetadataKey,
|
|
MetadataManager,
|
|
type MetadataValue,
|
|
makeUseEmbeddedMetadata,
|
|
type RuntimeHtmlMetadata,
|
|
useEmbeddedMetadata,
|
|
} from "./useEmbeddedMetadata";
|
|
|
|
// Make sure that no matter what happens in the tests, all metadata is
|
|
// eventually deleted
|
|
const allAppendedNodes = new Set<Set<Element>>();
|
|
afterAll(() => {
|
|
for (const tracker of allAppendedNodes) {
|
|
for (const node of tracker) {
|
|
node.remove();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Using empty array for now, because we don't have a separate mock regions
|
|
// value, but it's still good enough for the tests because it's truthy
|
|
const MockRegions: readonly Region[] = [];
|
|
|
|
const mockDataForTags = {
|
|
appearance: MockAppearanceConfig,
|
|
"build-info": MockBuildInfo,
|
|
entitlements: MockEntitlements,
|
|
experiments: MockExperiments,
|
|
user: MockUserOwner,
|
|
userAppearance: MockUserAppearanceSettings,
|
|
regions: MockRegions,
|
|
"tasks-tab-visible": MockTasksTabVisible,
|
|
permissions: MockPermissions,
|
|
organizations: [MockOrganization],
|
|
} as const satisfies Record<MetadataKey, MetadataValue>;
|
|
|
|
const emptyMetadata: RuntimeHtmlMetadata = {
|
|
appearance: {
|
|
available: false,
|
|
value: undefined,
|
|
},
|
|
"build-info": {
|
|
available: false,
|
|
value: undefined,
|
|
},
|
|
entitlements: {
|
|
available: false,
|
|
value: undefined,
|
|
},
|
|
experiments: {
|
|
available: false,
|
|
value: undefined,
|
|
},
|
|
regions: {
|
|
available: false,
|
|
value: undefined,
|
|
},
|
|
user: {
|
|
available: false,
|
|
value: undefined,
|
|
},
|
|
userAppearance: {
|
|
available: false,
|
|
value: undefined,
|
|
},
|
|
"tasks-tab-visible": {
|
|
available: false,
|
|
value: undefined,
|
|
},
|
|
permissions: {
|
|
available: false,
|
|
value: undefined,
|
|
},
|
|
organizations: {
|
|
available: false,
|
|
value: undefined,
|
|
},
|
|
};
|
|
|
|
const populatedMetadata: RuntimeHtmlMetadata = {
|
|
appearance: {
|
|
available: true,
|
|
value: MockAppearanceConfig,
|
|
},
|
|
"build-info": {
|
|
available: true,
|
|
value: MockBuildInfo,
|
|
},
|
|
entitlements: {
|
|
available: true,
|
|
value: MockEntitlements,
|
|
},
|
|
experiments: {
|
|
available: true,
|
|
value: MockExperiments,
|
|
},
|
|
regions: {
|
|
available: true,
|
|
value: MockRegions,
|
|
},
|
|
user: {
|
|
available: true,
|
|
value: MockUserOwner,
|
|
},
|
|
userAppearance: {
|
|
available: true,
|
|
value: MockUserAppearanceSettings,
|
|
},
|
|
"tasks-tab-visible": {
|
|
available: true,
|
|
value: MockTasksTabVisible,
|
|
},
|
|
permissions: {
|
|
available: true,
|
|
value: MockPermissions,
|
|
},
|
|
organizations: {
|
|
available: true,
|
|
value: [MockOrganization],
|
|
},
|
|
};
|
|
|
|
function seedInitialMetadata(metadataKey: string): () => void {
|
|
// Enforcing this to make sure that even if we start to adopt more concurrent
|
|
// tests through Vitest (or similar), there's no risk of global state causing
|
|
// weird, hard-to-test false positives/negatives with other tests
|
|
if (metadataKey === DEFAULT_METADATA_KEY) {
|
|
throw new Error(
|
|
"Please ensure that the key you provide does not match the key used throughout the majority of the application",
|
|
);
|
|
}
|
|
|
|
const trackedNodes = new Set<Element>();
|
|
allAppendedNodes.add(trackedNodes);
|
|
|
|
for (const metadataName in mockDataForTags) {
|
|
// Serializing first because that's the part that can fail; want to fail
|
|
// early if possible
|
|
const value = mockDataForTags[metadataName as keyof typeof mockDataForTags];
|
|
const serialized = JSON.stringify(value);
|
|
|
|
const newNode = document.createElement("meta");
|
|
newNode.setAttribute(metadataKey, metadataName);
|
|
newNode.setAttribute("content", serialized);
|
|
document.head.append(newNode);
|
|
|
|
trackedNodes.add(newNode);
|
|
}
|
|
|
|
return () => {
|
|
for (const node of trackedNodes) {
|
|
node.remove();
|
|
}
|
|
};
|
|
}
|
|
|
|
function renderMetadataHook(metadataKey: string) {
|
|
const manager = new MetadataManager(metadataKey);
|
|
const hook = makeUseEmbeddedMetadata(manager);
|
|
|
|
return {
|
|
...renderHook(hook),
|
|
manager,
|
|
};
|
|
}
|
|
|
|
// Just to be on the safe side, probably want to make sure that each test case
|
|
// is set up with a unique key
|
|
describe(useEmbeddedMetadata.name, () => {
|
|
it("Correctly detects when metadata is missing in the HTML page", () => {
|
|
const key = "cat";
|
|
|
|
// Deliberately avoid seeding any metadata
|
|
const { result } = renderMetadataHook(key);
|
|
expect(result.current.metadata).toEqual<RuntimeHtmlMetadata>(emptyMetadata);
|
|
});
|
|
|
|
it("Can detect when metadata exists in the HTML", () => {
|
|
const key = "dog";
|
|
|
|
const cleanupTags = seedInitialMetadata(key);
|
|
const { result } = renderMetadataHook(key);
|
|
expect(result.current.metadata).toEqual<RuntimeHtmlMetadata>(
|
|
populatedMetadata,
|
|
);
|
|
|
|
cleanupTags();
|
|
});
|
|
|
|
it("Lets external systems (including React) subscribe to when metadata values are deleted", () => {
|
|
const key = "bird";
|
|
const tag1: MetadataKey = "user";
|
|
const tag2: MetadataKey = "appearance";
|
|
|
|
const cleanupTags = seedInitialMetadata(key);
|
|
const { result: reactResult, manager } = renderMetadataHook(key);
|
|
|
|
const nonReactSubscriber = vi.fn();
|
|
manager.subscribe(nonReactSubscriber);
|
|
|
|
const expectedUpdate1: RuntimeHtmlMetadata = {
|
|
...populatedMetadata,
|
|
[tag1]: {
|
|
available: false,
|
|
value: undefined,
|
|
},
|
|
};
|
|
|
|
// Test that updates work when run directly through the metadata manager
|
|
// itself
|
|
act(() => manager.clearMetadataByKey(tag1));
|
|
expect(reactResult.current.metadata).toEqual(expectedUpdate1);
|
|
expect(nonReactSubscriber).toBeCalledWith(expectedUpdate1);
|
|
|
|
nonReactSubscriber.mockClear();
|
|
const expectedUpdate2: RuntimeHtmlMetadata = {
|
|
...expectedUpdate1,
|
|
[tag2]: {
|
|
available: false,
|
|
value: undefined,
|
|
},
|
|
};
|
|
|
|
// Test that updates work when calling the convenience function exposed
|
|
// through the React hooks
|
|
act(() => reactResult.current.clearMetadataByKey(tag2));
|
|
expect(reactResult.current.metadata).toEqual(expectedUpdate2);
|
|
expect(nonReactSubscriber).toBeCalledWith(expectedUpdate2);
|
|
|
|
cleanupTags();
|
|
});
|
|
|
|
it("Will delete the original metadata node when you try deleting a metadata state value", () => {
|
|
const key = "aardvark";
|
|
const tagToDelete: MetadataKey = "user";
|
|
|
|
const cleanupTags = seedInitialMetadata(key);
|
|
const { result } = renderMetadataHook(key);
|
|
|
|
const query = `meta[${key}=${tagToDelete}]`;
|
|
let userNode = document.querySelector(query);
|
|
expect(userNode).not.toBeNull();
|
|
|
|
act(() => result.current.clearMetadataByKey(tagToDelete));
|
|
userNode = document.querySelector(query);
|
|
expect(userNode).toBeNull();
|
|
|
|
cleanupTags();
|
|
});
|
|
|
|
it("Will not notify subscribers if you try deleting a metadata value that does not exist (or has already been deleted)", () => {
|
|
const key = "giraffe";
|
|
const tagToDelete: MetadataKey = "entitlements";
|
|
|
|
const cleanupTags = seedInitialMetadata(key);
|
|
const { result } = renderMetadataHook(key);
|
|
act(() => result.current.clearMetadataByKey(tagToDelete));
|
|
|
|
const resultBeforeNoOp = result.current.metadata;
|
|
act(() => result.current.clearMetadataByKey(tagToDelete));
|
|
expect(result.current.metadata).toBe(resultBeforeNoOp);
|
|
|
|
cleanupTags();
|
|
});
|
|
|
|
// Need to guarantee this, or else we could have a good number of bugs in the
|
|
// React UI
|
|
it("Always treats metadata as immutable values during all deletions", () => {
|
|
const key = "hamster";
|
|
const tagToDelete: MetadataKey = "user";
|
|
|
|
const cleanupTags = seedInitialMetadata(key);
|
|
const { result } = renderMetadataHook(key);
|
|
|
|
const initialResult = result.current.metadata;
|
|
act(() => result.current.clearMetadataByKey(tagToDelete));
|
|
const newResult = result.current.metadata;
|
|
expect(initialResult).not.toBe(newResult);
|
|
|
|
// Mutate the initial result, and make sure the change doesn't propagate to
|
|
// the updated result
|
|
const mutableUser = initialResult.user as {
|
|
available: boolean;
|
|
value: User | undefined;
|
|
};
|
|
|
|
mutableUser.available = false;
|
|
mutableUser.value = undefined;
|
|
expect(mutableUser).toEqual(newResult.user);
|
|
expect(mutableUser).not.toBe(newResult.user);
|
|
|
|
cleanupTags();
|
|
});
|
|
});
|