Files
paseo/packages/app/e2e/support/helpers/agent-profiles.ts
T
Mohamed BoudraandGitHub 308e9af845 Add reusable agent profiles (#3208)
* Search models across all providers from the picker root

* Store agent profiles in daemon config and apply agent config in one RPC

An agent profile is a named bundle of provider, model, mode, thinking
option, feature values and notes, stored host-wide next to terminal
profiles. It carries no system prompt: AgentSessionConfig.systemPrompt is
creation-only, so a profile holding one would apply when starting an agent
and silently do nothing when applied to a running one.

Applying a profile through the four single-field RPCs can fail halfway and
leave an agent matching no profile, so agent.config.apply takes the whole
bundle. Model runs first because a thinking option only means anything
against the model that offers it.

Old daemons parse their persisted config strictly and would drop an
unknown agentProfiles key, making a save appear to succeed and vanish, so
both capabilities are advertised on server_info for the client to gate on.

list_profiles exposes the profiles to orchestrating agents, which read the
notes to pick one and copy its values into create_agent.

* Add the agent profiles settings section

Profiles live behind one module boundary so removing the feature is a
directory plus one mount, which is what favourites never was. The picker
and composer will ask for rows and an apply function rather than learning
what a profile contains.

The edit form follows the form-model pattern instead of the terminal
profile modal's per-field state, because provider selection cascades into
which models, modes, thinking levels and features exist below it.

The section is gated on server_info.features.agentProfiles: an older daemon
drops the key on write, and a save that appears to work and then vanishes
is worse than an unavailable state.

* Document agent profiles and name the four senses of profile

The glossary reserved Profile for HostProfile, terminal profiles owned the
user-facing word, and custom-providers.md used it for provider aliases.
Agent profile is now the term, with all four cross-referenced.

data-model.md never documented terminalProfiles either, so both profile
lists are backfilled together along with the whole-list patch semantics and
why absent and empty differ.

* Pin agent profiles to the model picker and delete favourites

Applying a profile writes its values into the agent controls and is then
forgotten, so rows carry no checkmark and no selected state: after applying,
nothing is selected. Chevron still means navigate deeper, so the absence of
one is what marks a row as an action.

A running agent is its provider's process and agent.config.apply carries no
provider, so the live picker only offers profiles matching the agent's
provider rather than showing rows that cannot do what they say. The draft
path has no such limit and switches provider freely.

Draft feature values go through preferences rather than the feature
callback alone, because the draft feature hook clears local values whenever
the provider changes and a provider-switching profile would lose them.

Favourites are deleted rather than migrated: they were device-local
AsyncStorage keyed by provider and model, while profiles are host-wide
daemon config. ProviderSelectionModelRow.favoriteKey stays as row identity
and moves to its real owner; renaming it is its own commit.

* Cover agent profiles and cross-provider search with E2E specs

Model rows expressed selection only as a checkmark glyph, which assistive
tech cannot see and a spec cannot assert. Rows that draw the indicator now
carry aria-selected; profile rows deliberately carry none, because a
profile is an action rather than a selection. That is what makes "nothing
is marked selected after applying a profile" a real assertion instead of a
tautology, with the materialized model one level down as the control.

The search fixture extends claude rather than mock: provider-config.ts
only allows the six shipped providers as extends targets, so a derived
provider cannot inherit the dev-only mock provider.

* State what applying a config bundle does on partial failure

applyBundle is not a transaction and the earlier commit message implied it
was. A step that rejects aborts the rest and the steps that already applied
stay applied, which a profile omitting modelId can reach: provider setters
validate against the agent's live model, so mode can land while a feature
that model does not support is rejected.

Nothing is hidden by that. Every setter emits agent state before returning,
so what applied is streamed to the client next to the rejected response.
Rolling back would re-invoke the setters that just failed, usually against
a session that is gone, and pre-flight validation would restate each
provider's model-dependent checks where they would drift from the setters
that own them.

What sending the bundle in one request removes is the multi-round-trip
failure class: interruption between hops, and interleaving with another
client. The test already pinned this behaviour; its name now says so.

* feat(agent-profiles): refine profile discovery and appearance

Make profiles discoverable from empty and provider-scoped model pickers, keep global search stable and virtualized, and move profile settings under Agents. Replace emoji identity with a shared Lucide icon and host-color picker, while requiring concrete provider selections so profiles materialize predictably.

* fix(agent-profiles): defer profile swatch styles

Resolving Unistyles proxies at module load can freeze the temporary startup color scheme before the persisted theme is restored. Keep only style-key names at module scope and resolve the selected swatch during render.
2026-08-11 17:42:35 +02:00

565 lines
22 KiB
TypeScript

import { expect, type Locator, type Page } from "@playwright/test";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { gotoAppShell, openSettings } from "./app";
import { connectDaemonClient } from "./daemon-client-loader";
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import { openSettingsHost } from "./settings";
// ─── Daemon-side seeding ───────────────────────────────────────────────────
interface AgentProfilesDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
getDaemonConfig(): Promise<{ config: { agentProfiles?: AgentProfile[] } }>;
patchDaemonConfig(config: {
agentProfiles?: AgentProfile[];
providers?: Record<string, Record<string, unknown>>;
removeProviders?: string[];
}): Promise<unknown>;
}
export interface HostSeed {
/** Puts the host's config back the way the test found it and drops the client. */
restore(): Promise<void>;
}
async function connectAgentProfilesClient(): Promise<AgentProfilesDaemonClient> {
return connectDaemonClient<AgentProfilesDaemonClient>({ clientIdPrefix: "agent-profiles-e2e" });
}
/**
* Writes profiles into host config directly. Journeys that are *about* the
* settings UI create theirs through it; every other journey starts from a host
* that already has them.
*/
export async function seedAgentProfiles(profiles: AgentProfile[]): Promise<HostSeed> {
const client = await connectAgentProfilesClient();
const previous = (await client.getDaemonConfig()).config.agentProfiles ?? [];
await client.patchDaemonConfig({ agentProfiles: profiles });
return {
async restore() {
await client.patchDaemonConfig({ agentProfiles: previous }).catch(() => undefined);
await client.close().catch(() => undefined);
},
};
}
export async function stageLegacyFavoritesForHostMigration(
page: Page,
favorites: Array<{ provider: string; modelId: string }>,
): Promise<void> {
const serverId = getServerId();
await gotoAppShell(page);
await page.evaluate(
({ favorites: storedFavorites, serverId: hostId }) => {
const preferencesKey = "@paseo:create-agent-preferences";
const raw = localStorage.getItem(preferencesKey);
const preferences = raw ? JSON.parse(raw) : {};
localStorage.setItem(
preferencesKey,
JSON.stringify({ ...preferences, favoriteModels: storedFavorites }),
);
localStorage.removeItem(`@paseo:legacy-favorites-to-agent-profiles:v1:${hostId}`);
// Preserve this upgrade-shaped storage across the fixture's next-page
// default seed; a reload is the startup boundary under test.
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce");
if (nonce) {
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
}
},
{ favorites, serverId },
);
await page.reload();
}
export async function expectHostAgentProfiles(expected: AgentProfile[]): Promise<void> {
const client = await connectAgentProfilesClient();
try {
await expect
.poll(async () => (await client.getDaemonConfig()).config.agentProfiles ?? [], {
timeout: 30_000,
})
.toEqual(expected);
} finally {
await client.close().catch(() => undefined);
}
}
export interface SeededProviderModel {
id: string;
label: string;
description: string;
}
/**
* Registers a second provider that reports a fixed catalog. The E2E daemon has
* exactly one provider with models (`mock`), so nothing cross-provider can be
* proven without one more.
*
* `extends: "mock"` is rejected — the config validator only accepts the six
* shipped providers plus `acp`. Extending `claude` with a replacement `models`
* list is what avoids running anything: replacement models skip catalog
* discovery, and Claude's static modes mean the provider never spawns. The
* command only has to resolve for the availability probe, hence `node`.
*/
export async function seedModelProvider(input: {
id: string;
label: string;
models: SeededProviderModel[];
}): Promise<HostSeed> {
const client = await connectAgentProfilesClient();
await client.patchDaemonConfig({
providers: {
[input.id]: {
extends: "claude",
label: input.label,
description: `${input.label} test provider`,
enabled: true,
command: ["node"],
models: input.models,
},
},
});
return {
async restore() {
await client.patchDaemonConfig({ removeProviders: [input.id] }).catch(() => undefined);
await client.close().catch(() => undefined);
},
};
}
// ─── Settings: navigation ──────────────────────────────────────────────────
/** Reach agent profiles through the same visible Settings path a person uses. */
export async function openAgentProfileSettings(page: Page): Promise<void> {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await page.getByRole("button", { name: "Agents", exact: true }).click();
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "agents"));
await expect(page.getByTestId("agent-profiles-card")).toBeVisible({ timeout: 30_000 });
}
export async function expectNoAgentProfiles(page: Page): Promise<void> {
const card = page.getByTestId("agent-profiles-card");
await expect(card.getByTestId("agent-profiles-empty")).toBeVisible();
await expect(card.getByRole("button", { name: "New profile", exact: true })).toBeVisible();
}
// ─── Settings: the profile list ────────────────────────────────────────────
export function agentProfileRow(page: Page, name: string): Locator {
return page.locator('[data-testid^="agent-profile-row-"]').filter({ hasText: name });
}
export async function expectAgentProfile(
page: Page,
expected: { name: string; tags: string[]; notes?: string },
): Promise<void> {
const row = agentProfileRow(page, expected.name);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText(expected.tags.join(" · "), { exact: true })).toBeVisible();
if (expected.notes !== undefined) {
await expect(row.getByText(expected.notes, { exact: true })).toBeVisible();
}
}
export async function expectAgentProfileTagsGone(
page: Page,
input: { name: string; tags: string[] },
): Promise<void> {
const row = agentProfileRow(page, input.name);
for (const tag of input.tags) {
await expect(row.getByText(tag, { exact: true })).toHaveCount(0);
}
}
/** Order is what the user sees, so it is measured on screen rather than in the DOM. */
export async function expectAgentProfileOrder(page: Page, names: string[]): Promise<void> {
await expect
.poll(
async () => {
const measured = await Promise.all(
names.map(async (name) => ({ name, top: await boxTop(agentProfileRow(page, name)) })),
);
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.name);
},
{ timeout: 15_000 },
)
.toEqual(names);
}
export async function moveAgentProfileUp(page: Page, name: string): Promise<void> {
await agentProfileRow(page, name).getByRole("button", { name: "Move up", exact: true }).click();
}
export async function moveAgentProfileDown(page: Page, name: string): Promise<void> {
await agentProfileRow(page, name).getByRole("button", { name: "Move down", exact: true }).click();
}
/**
* The remove confirmation is `window.confirm` on browser web, so the dialog is
* consumed here — and its text is asserted, since naming the profile is the
* whole point of confirming.
*/
export async function removeAgentProfile(page: Page, name: string): Promise<void> {
const dialogMessage = page.waitForEvent("dialog").then(async (dialog) => {
const message = dialog.message();
await dialog.accept();
return message;
});
await agentProfileRow(page, name).getByRole("button", { name: "Remove", exact: true }).click();
expect(await dialogMessage).toContain(`Remove "${name}"?`);
await expect(agentProfileRow(page, name)).toHaveCount(0, { timeout: 30_000 });
}
// ─── Settings: the edit modal ──────────────────────────────────────────────
export interface AgentProfileDraft {
name?: string;
provider?: string;
model?: string;
thinking?: string;
mode?: string;
notes?: string;
}
function editModal(page: Page): Locator {
return page.getByTestId("agent-profile-edit-modal");
}
async function chooseSelectFieldOption(
page: Page,
input: { field: "provider" | "model" | "mode" | "thinking"; option: string },
): Promise<void> {
await editModal(page).getByTestId(`agent-profile-${input.field}-trigger`).click();
const option = page
.getByTestId("combobox-desktop-container")
.getByRole("button", { name: input.option, exact: true });
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
await expect(
page.getByRole("button", { name: `${capitalize(input.field)} (${input.option})` }),
).toBeVisible({ timeout: 30_000 });
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promise<void> {
const modal = editModal(page);
if (draft.name !== undefined) {
await modal.getByRole("textbox", { name: "Name", exact: true }).fill(draft.name);
}
// Provider first: it resets every provider-scoped field below it.
if (draft.provider !== undefined) {
await chooseSelectFieldOption(page, { field: "provider", option: draft.provider });
}
// Thinking after model: thinking levels hang off the model, and switching the
// model drops a level the new one does not offer.
if (draft.model !== undefined) {
await chooseSelectFieldOption(page, { field: "model", option: draft.model });
}
if (draft.thinking !== undefined) {
await chooseSelectFieldOption(page, { field: "thinking", option: draft.thinking });
}
if (draft.mode !== undefined) {
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
}
if (draft.notes !== undefined) {
await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes);
}
}
async function saveAgentProfile(page: Page): Promise<void> {
await editModal(page).getByRole("button", { name: "Save", exact: true }).click();
await expect(editModal(page)).toHaveCount(0, { timeout: 30_000 });
}
export async function createAgentProfile(page: Page, draft: AgentProfileDraft): Promise<void> {
await page.getByRole("button", { name: "Add agent profile", exact: true }).click();
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
await fillAgentProfileForm(page, draft);
await saveAgentProfile(page);
}
export async function createAgentProfileFromEmptyState(
page: Page,
draft: AgentProfileDraft,
): Promise<void> {
await page
.getByTestId("agent-profiles-card")
.getByRole("button", { name: "New profile", exact: true })
.click();
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
await fillAgentProfileForm(page, draft);
await saveAgentProfile(page);
}
export async function editAgentProfile(
page: Page,
name: string,
changes: AgentProfileDraft,
): Promise<void> {
await agentProfileRow(page, name)
.getByRole("button", { name: "Edit profile", exact: true })
.click();
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
await fillAgentProfileForm(page, changes);
await saveAgentProfile(page);
}
/** Opens the modal for a profile purely to read back what it stored. */
export async function expectAgentProfileForm(
page: Page,
name: string,
expected: { provider?: string; model?: string; mode?: string; notes?: string },
): Promise<void> {
await agentProfileRow(page, name)
.getByRole("button", { name: "Edit profile", exact: true })
.click();
const modal = editModal(page);
await expect(modal).toBeVisible({ timeout: 30_000 });
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
for (const [field, value] of Object.entries(expected)) {
if (field === "notes") {
await expect(
modal.getByRole("textbox", { name: "Notes for agents", exact: true }),
).toHaveValue(value);
continue;
}
await expect(
modal.getByRole("button", { name: `${capitalize(field)} (${value})` }),
).toBeVisible({ timeout: 30_000 });
}
await modal.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(editModal(page)).toHaveCount(0, { timeout: 30_000 });
}
// ─── The model picker ──────────────────────────────────────────────────────
/** Desktop web renders the model browser inside the combobox popover. */
function pickerViewport(page: Page): Locator {
return page.getByTestId("combobox-desktop-container");
}
export async function openModelPicker(page: Page): Promise<void> {
await page.getByTestId("combined-model-selector").filter({ visible: true }).first().click();
await expect(pickerViewport(page)).toBeVisible({ timeout: 30_000 });
}
export async function closeModelPicker(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(pickerViewport(page)).toHaveCount(0, { timeout: 30_000 });
}
export function profilePickerRow(page: Page, name: string): Locator {
return pickerViewport(page)
.locator('[data-testid^="model-profile-row-"]')
.filter({ hasText: name });
}
/**
* Profiles are pinned above the provider list, so "pinned" is an ordering claim:
* the profile row's top edge sits above every provider row's.
*/
export async function expectProfilePinnedAboveProviders(
page: Page,
input: { name: string; summary: string },
): Promise<void> {
const viewport = pickerViewport(page);
await expect(viewport.getByText("Profiles", { exact: true })).toBeVisible({ timeout: 30_000 });
const row = profilePickerRow(page, input.name);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText(input.summary, { exact: true })).toBeVisible();
const profileTop = await boxTop(row);
const providerRows = viewport.locator('[data-testid^="model-provider-"]');
await expect(providerRows.first()).toBeVisible();
const providerCount = await providerRows.count();
for (let index = 0; index < providerCount; index += 1) {
expect(profileTop).toBeLessThan(await boxTop(providerRows.nth(index)));
}
}
export async function expectProfileVisibleForProvider(
page: Page,
input: { name: string; summary: string },
): Promise<void> {
const viewport = pickerViewport(page);
await expect(viewport.getByText("Profiles", { exact: true })).toBeVisible({ timeout: 30_000 });
const row = profilePickerRow(page, input.name);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText(input.summary, { exact: true })).toBeVisible();
}
export async function expectProfileEditIsPencilOnly(page: Page): Promise<void> {
const viewport = pickerViewport(page);
await expect(
viewport.getByRole("button", { name: "Edit agent profiles", exact: true }),
).toBeVisible();
await expect(viewport.getByText("Edit", { exact: true })).toHaveCount(0);
}
export async function expectAgentProfilesEmptyPrompt(page: Page): Promise<void> {
const viewport = pickerViewport(page);
await expect(viewport.getByText("Profiles", { exact: true })).toHaveCount(0);
await expect(
viewport.getByRole("button", { name: "Edit agent profiles", exact: true }),
).toHaveCount(0);
await expect(viewport.getByRole("button", { name: "Create profile", exact: true })).toBeVisible({
timeout: 30_000,
});
}
export async function expectProfileEditTooltip(page: Page): Promise<void> {
const edit = pickerViewport(page).getByRole("button", {
name: "Edit agent profiles",
exact: true,
});
await edit.hover();
await expect(page.getByText("Edit agent profiles", { exact: true })).toBeVisible();
}
export async function openAgentProfilesFromEmptyPrompt(page: Page): Promise<void> {
await pickerViewport(page).getByRole("button", { name: "Create profile", exact: true }).click();
await expect(page.getByTestId("agent-profiles-card")).toBeVisible({ timeout: 30_000 });
}
async function boxTop(locator: Locator): Promise<number> {
const box = await locator.boundingBox();
if (!box) {
throw new Error("Expected a laid-out element to measure");
}
return box.y;
}
export async function applyProfileFromPicker(page: Page, name: string): Promise<void> {
await profilePickerRow(page, name).click();
await expect(pickerViewport(page)).toHaveCount(0, { timeout: 30_000 });
}
/**
* Applying a profile materializes it and forgets it: nothing in the root view
* claims selection. Model rows do carry `aria-selected`, so a count of zero here
* is the absence of a checkmark, not the absence of the attribute everywhere.
*/
export async function expectNothingSelectedInPickerRoot(page: Page): Promise<void> {
await expect(pickerViewport(page).locator("[aria-selected]")).toHaveCount(0);
}
export async function expectAgentProfilesEditShortcut(page: Page): Promise<void> {
await expect(
pickerViewport(page).getByRole("button", { name: "Edit agent profiles", exact: true }),
).toBeVisible({ timeout: 30_000 });
}
export async function drillIntoProvider(page: Page, providerId: string): Promise<void> {
await pickerViewport(page).getByTestId(`model-provider-${providerId}`).click();
}
export async function expectModelRowSelected(
page: Page,
input: { provider: string; modelId: string },
): Promise<void> {
await expect(
pickerViewport(page).getByTestId(`model-row-${input.provider}-${input.modelId}`),
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
}
// ─── Cross-provider model search ───────────────────────────────────────────
export async function searchAllModels(page: Page, query: string): Promise<void> {
const input = page.getByTestId("model-search-all-input");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill(query);
}
export async function expectModelSearchResult(
page: Page,
expected: { provider: string; modelId: string; providerLabel: string; modelLabel: string },
): Promise<void> {
const row = pickerViewport(page).getByTestId(
`model-row-${expected.provider}-${expected.modelId}`,
);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText(expected.modelLabel, { exact: true })).toBeVisible();
await expect(
row.getByText(new RegExp(`^${escapeForRegex(expected.providerLabel)}\\b`)),
).toBeVisible();
}
function escapeForRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Search results replace the whole root view, pinned profiles included. */
export async function expectPinnedProfilesHidden(page: Page): Promise<void> {
await expect(pickerViewport(page).locator('[data-testid^="model-profile-row-"]')).toHaveCount(0);
}
export async function expectModelSearchEmptyState(page: Page, query: string): Promise<void> {
const empty = page.getByTestId("model-search-empty");
await expect(empty).toBeVisible({ timeout: 30_000 });
await expect(empty.getByText(`No models match "${query}"`, { exact: true })).toBeVisible();
}
export async function readModelPickerHeight(page: Page): Promise<number> {
const box = await pickerViewport(page).boundingBox();
if (!box) {
throw new Error("Expected the model picker to be laid out");
}
return box.height;
}
export async function readModelPickerWidth(page: Page): Promise<number> {
const box = await pickerViewport(page).boundingBox();
if (!box) {
throw new Error("Expected the model picker to be laid out");
}
return box.width;
}
export async function expectModelPickerHeight(page: Page, expectedHeight: number): Promise<void> {
await expect.poll(() => readModelPickerHeight(page)).toBe(expectedHeight);
}
export async function expectModelPickerWidth(page: Page, expectedWidth: number): Promise<void> {
await expect.poll(() => readModelPickerWidth(page)).toBe(expectedWidth);
}
export async function expectSearchResultsVirtualized(
page: Page,
input: { provider: string; total: number },
): Promise<void> {
const mountedRows = pickerViewport(page).locator(`[data-testid^="model-row-${input.provider}-"]`);
await expect.poll(() => mountedRows.count()).toBeGreaterThan(0);
expect(await mountedRows.count()).toBeLessThan(input.total);
}
// ─── Composer agent controls ───────────────────────────────────────────────
export async function expectComposerModel(page: Page, modelLabel: string): Promise<void> {
await expect(
page.getByRole("button", { name: `Select model (${modelLabel})`, exact: true }),
).toBeVisible({ timeout: 30_000 });
}
export async function expectComposerMode(page: Page, modeLabel: string): Promise<void> {
await expect(
page.getByRole("button", { name: `Select agent mode (${modeLabel})`, exact: true }).first(),
).toBeVisible({ timeout: 30_000 });
}
/** The composer never shows the profile's name — a profile is not a selection. */
export async function expectComposerDoesNotName(page: Page, profileName: string): Promise<void> {
await expect(
page.locator('[data-testid="message-input-root"]:visible').getByText(profileName),
).toHaveCount(0);
}