* 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.
444 lines
12 KiB
TypeScript
444 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
|
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
|
import { i18n } from "@/i18n/i18next";
|
|
import {
|
|
buildProviderQualifiedDescription,
|
|
buildProviderSelectorProviders,
|
|
buildSelectableProviderSelectorProviders,
|
|
buildSelectedTriggerLabel,
|
|
filterAndRankModelRows,
|
|
matchesModelSearch,
|
|
resolveSelectedModelLabel,
|
|
resolveSubmissionReadiness,
|
|
} from "./provider-selection";
|
|
|
|
describe("combined model selector data", () => {
|
|
const codexModel: AgentModelDefinition = {
|
|
provider: "codex",
|
|
id: "gpt-5.4",
|
|
label: "GPT-5.4",
|
|
};
|
|
|
|
function snapshotEntry(
|
|
overrides: Partial<ProviderSnapshotEntry> & Pick<ProviderSnapshotEntry, "provider">,
|
|
): ProviderSnapshotEntry {
|
|
return {
|
|
...overrides,
|
|
provider: overrides.provider,
|
|
status: overrides.status ?? "ready",
|
|
enabled: overrides.enabled ?? true,
|
|
label: overrides.label ?? overrides.provider,
|
|
description: overrides.description ?? `${overrides.provider} provider`,
|
|
defaultModeId: overrides.defaultModeId ?? "default",
|
|
modes: overrides.modes ?? [],
|
|
models: overrides.models ?? [codexModel],
|
|
};
|
|
}
|
|
|
|
it("builds selector providers from ready enabled snapshot entries", () => {
|
|
expect(
|
|
buildSelectableProviderSelectorProviders([
|
|
snapshotEntry({
|
|
provider: "codex",
|
|
label: "Codex",
|
|
models: [codexModel],
|
|
}),
|
|
]),
|
|
).toEqual([
|
|
{
|
|
id: "codex",
|
|
label: "Codex",
|
|
modelSelection: {
|
|
kind: "models",
|
|
rows: [
|
|
{
|
|
favoriteKey: "codex:gpt-5.4",
|
|
provider: "codex",
|
|
providerLabel: "Codex",
|
|
modelId: "gpt-5.4",
|
|
modelLabel: "GPT-5.4",
|
|
description: "gpt-5.4",
|
|
isDefault: undefined,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("hides compatibility-only model entries from new clients", () => {
|
|
const compatibilityModel: AgentModelDefinition = {
|
|
...codexModel,
|
|
id: "gpt-5.4-legacy",
|
|
label: "GPT-5.4 legacy",
|
|
isSelectable: false,
|
|
};
|
|
|
|
const [provider] = buildSelectableProviderSelectorProviders([
|
|
snapshotEntry({ provider: "codex", models: [codexModel, compatibilityModel] }),
|
|
]);
|
|
|
|
expect(provider?.modelSelection).toMatchObject({
|
|
kind: "models",
|
|
rows: [{ modelId: "gpt-5.4" }],
|
|
});
|
|
});
|
|
|
|
it("synthesizes a default model row for ready enabled providers without explicit models", () => {
|
|
expect(
|
|
buildSelectableProviderSelectorProviders([
|
|
snapshotEntry({
|
|
provider: "codewhale",
|
|
label: "CodeWhale",
|
|
models: [],
|
|
}),
|
|
]),
|
|
).toEqual([
|
|
{
|
|
id: "codewhale",
|
|
label: "CodeWhale",
|
|
modelSelection: {
|
|
kind: "models",
|
|
rows: [
|
|
{
|
|
favoriteKey: "codewhale:",
|
|
provider: "codewhale",
|
|
providerLabel: "CodeWhale",
|
|
modelId: "",
|
|
modelLabel: "Default",
|
|
description: undefined,
|
|
isDefault: true,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("excludes disabled providers from selector data", () => {
|
|
expect(
|
|
buildSelectableProviderSelectorProviders([
|
|
snapshotEntry({
|
|
provider: "codewhale",
|
|
label: "CodeWhale",
|
|
enabled: false,
|
|
models: [],
|
|
}),
|
|
]),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("surfaces non-ready providers with their state-specific selection", () => {
|
|
expect(
|
|
buildSelectableProviderSelectorProviders([
|
|
snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
|
|
snapshotEntry({
|
|
provider: "error-provider",
|
|
status: "error",
|
|
error: "boom",
|
|
models: [],
|
|
}),
|
|
snapshotEntry({
|
|
provider: "unavailable-provider",
|
|
status: "unavailable",
|
|
models: [],
|
|
}),
|
|
]),
|
|
).toEqual([
|
|
{
|
|
id: "loading-provider",
|
|
label: "loading-provider",
|
|
modelSelection: { kind: "loading" },
|
|
},
|
|
{
|
|
id: "error-provider",
|
|
label: "error-provider",
|
|
modelSelection: { kind: "error", message: "boom" },
|
|
},
|
|
{
|
|
id: "unavailable-provider",
|
|
label: "unavailable-provider",
|
|
modelSelection: { kind: "error", message: "Unavailable" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("builds selector providers from an already-curated provider list", () => {
|
|
const providerDefinitions: AgentProviderDefinition[] = [
|
|
{
|
|
id: "codex",
|
|
label: "Codex",
|
|
description: "Codex provider",
|
|
defaultModeId: "auto",
|
|
modes: [],
|
|
},
|
|
];
|
|
|
|
expect(
|
|
buildProviderSelectorProviders({
|
|
providerDefinitions,
|
|
modelsByProvider: new Map([["codex", [codexModel]]]),
|
|
}),
|
|
).toEqual([
|
|
{
|
|
id: "codex",
|
|
label: "Codex",
|
|
modelSelection: {
|
|
kind: "models",
|
|
rows: [
|
|
expect.objectContaining({
|
|
provider: "codex",
|
|
providerLabel: "Codex",
|
|
modelId: "gpt-5.4",
|
|
modelLabel: "GPT-5.4",
|
|
}),
|
|
],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("matches across label, provider, and description with multi-token fuzzy search", () => {
|
|
const row = {
|
|
favoriteKey: "opencode:opencode-zen/kimi-k2.5",
|
|
provider: "opencode",
|
|
providerLabel: "OpenCode",
|
|
modelId: "opencode-zen/kimi-k2.5",
|
|
modelLabel: "Kimi K2.5",
|
|
description: "OpenCode Zen - kimi",
|
|
};
|
|
|
|
expect(matchesModelSearch(row, "kimi zen")).toBe(true);
|
|
expect(matchesModelSearch(row, "zen kimi")).toBe(true);
|
|
expect(matchesModelSearch(row, "k2.5 zen")).toBe(true);
|
|
expect(matchesModelSearch(row, "kimi gemini")).toBe(false);
|
|
});
|
|
|
|
it("ranks model search results by fuzzy match quality", () => {
|
|
const rows = [
|
|
{
|
|
favoriteKey: "openai:gpt-4.1",
|
|
provider: "openai",
|
|
providerLabel: "OpenAI",
|
|
modelId: "gpt-4.1",
|
|
modelLabel: "GPT-4.1",
|
|
},
|
|
{
|
|
favoriteKey: "openai:gpt-5.4",
|
|
provider: "openai",
|
|
providerLabel: "OpenAI",
|
|
modelId: "gpt-5.4",
|
|
modelLabel: "GPT-5.4",
|
|
},
|
|
{
|
|
favoriteKey: "google:gemini",
|
|
provider: "google",
|
|
providerLabel: "Google",
|
|
modelId: "gemini",
|
|
modelLabel: "Gemini",
|
|
},
|
|
];
|
|
|
|
expect(filterAndRankModelRows(rows, "gpt54").map((row) => row.modelId)).toEqual(["gpt-5.4"]);
|
|
});
|
|
|
|
it("keeps the selected trigger label model-only", () => {
|
|
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
|
});
|
|
|
|
it("names the provider first when a model row is shown outside its provider", () => {
|
|
const row = {
|
|
favoriteKey: "copilot:claude-opus-5",
|
|
provider: "copilot",
|
|
providerLabel: "Copilot",
|
|
modelId: "claude-opus-5",
|
|
modelLabel: "Opus 5",
|
|
description: "claude-opus-5",
|
|
};
|
|
|
|
expect(buildProviderQualifiedDescription(row)).toBe("Copilot · claude-opus-5");
|
|
expect(buildProviderQualifiedDescription({ ...row, description: undefined })).toBe("Copilot");
|
|
});
|
|
|
|
it("resolves selected labels from explicit provider model-selection state", () => {
|
|
const providers = buildSelectableProviderSelectorProviders([
|
|
snapshotEntry({
|
|
provider: "codex",
|
|
label: "Codex",
|
|
models: [codexModel],
|
|
}),
|
|
snapshotEntry({
|
|
provider: "codewhale",
|
|
label: "CodeWhale",
|
|
models: [],
|
|
}),
|
|
]);
|
|
|
|
expect(
|
|
resolveSelectedModelLabel({
|
|
providers,
|
|
selectedProvider: "codex",
|
|
selectedModel: "gpt-5.4",
|
|
isLoading: false,
|
|
}),
|
|
).toBe("GPT-5.4");
|
|
expect(
|
|
resolveSelectedModelLabel({
|
|
providers,
|
|
selectedProvider: "codewhale",
|
|
selectedModel: "",
|
|
isLoading: false,
|
|
}),
|
|
).toBe("Default");
|
|
});
|
|
|
|
it("distinguishes a loading selection from a resolved empty selection", () => {
|
|
expect(
|
|
resolveSelectedModelLabel({
|
|
providers: [],
|
|
selectedProvider: "",
|
|
selectedModel: "",
|
|
isLoading: true,
|
|
}),
|
|
).toBe("Loading...");
|
|
expect(
|
|
resolveSelectedModelLabel({
|
|
providers: [],
|
|
selectedProvider: "",
|
|
selectedModel: "",
|
|
isLoading: false,
|
|
}),
|
|
).toBe("Select model");
|
|
});
|
|
|
|
it("keeps a stored selected model visible when current snapshot rows no longer offer it", () => {
|
|
const providers = buildSelectableProviderSelectorProviders([
|
|
snapshotEntry({
|
|
provider: "codex",
|
|
label: "Codex",
|
|
models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }],
|
|
}),
|
|
]);
|
|
|
|
expect(
|
|
resolveSelectedModelLabel({
|
|
providers,
|
|
selectedProvider: "codex",
|
|
selectedModel: "gpt-5.3",
|
|
isLoading: false,
|
|
}),
|
|
).toBe("gpt-5.3");
|
|
});
|
|
|
|
it("keeps provider snapshot errors visible in the selected trigger label", () => {
|
|
const providers = buildSelectableProviderSelectorProviders([
|
|
snapshotEntry({
|
|
provider: "opencode",
|
|
label: "OpenCode",
|
|
status: "error",
|
|
error: "OpenCode app.agents timed out after 10s",
|
|
models: [],
|
|
}),
|
|
]);
|
|
|
|
expect(
|
|
resolveSelectedModelLabel({
|
|
providers,
|
|
selectedProvider: "opencode",
|
|
selectedModel: "",
|
|
isLoading: false,
|
|
}),
|
|
).toBe("Error");
|
|
});
|
|
|
|
it("returns observable submission readiness reasons", () => {
|
|
expect(
|
|
resolveSubmissionReadiness({
|
|
text: "hello",
|
|
allowsEmptyAutoSubmit: false,
|
|
providerCount: 1,
|
|
selection: {
|
|
provider: "codex",
|
|
modelId: "",
|
|
availableModels: [codexModel],
|
|
isModelLoading: false,
|
|
},
|
|
autoSubmitConfig: null,
|
|
workspaceDirectory: "/repo",
|
|
hasClient: true,
|
|
}),
|
|
).toEqual({
|
|
ok: false,
|
|
reason: "No model is available for the selected provider",
|
|
});
|
|
|
|
expect(
|
|
resolveSubmissionReadiness({
|
|
text: "hello",
|
|
allowsEmptyAutoSubmit: false,
|
|
providerCount: 1,
|
|
selection: {
|
|
provider: "codewhale",
|
|
modelId: "",
|
|
availableModels: [],
|
|
isModelLoading: false,
|
|
},
|
|
autoSubmitConfig: null,
|
|
workspaceDirectory: "/repo",
|
|
hasClient: true,
|
|
}),
|
|
).toEqual({ ok: true });
|
|
});
|
|
|
|
it("uses the active app language for utility labels", async () => {
|
|
await i18n.changeLanguage("zh-CN");
|
|
try {
|
|
const providers = buildSelectableProviderSelectorProviders([
|
|
snapshotEntry({
|
|
provider: "deepseek-tui",
|
|
label: "DeepSeek TUI",
|
|
models: [],
|
|
}),
|
|
snapshotEntry({
|
|
provider: "unavailable-provider",
|
|
status: "unavailable",
|
|
models: [],
|
|
}),
|
|
]);
|
|
|
|
expect(getAllModelLabels(providers)).toContain("默认");
|
|
expect(providers[1]?.modelSelection).toEqual({
|
|
kind: "error",
|
|
message: "不可用",
|
|
});
|
|
expect(
|
|
resolveSubmissionReadiness({
|
|
text: "",
|
|
allowsEmptyAutoSubmit: false,
|
|
providerCount: 1,
|
|
selection: {
|
|
provider: "codex",
|
|
modelId: "gpt-5.4",
|
|
availableModels: [codexModel],
|
|
isModelLoading: false,
|
|
},
|
|
autoSubmitConfig: null,
|
|
workspaceDirectory: "/repo",
|
|
hasClient: true,
|
|
}),
|
|
).toEqual({ ok: false, reason: "初始 prompt 必填" });
|
|
} finally {
|
|
await i18n.changeLanguage("en");
|
|
}
|
|
});
|
|
});
|
|
|
|
function getAllModelLabels(providers: ReturnType<typeof buildSelectableProviderSelectorProviders>) {
|
|
return providers.flatMap((provider) =>
|
|
provider.modelSelection.kind === "models"
|
|
? provider.modelSelection.rows.map((row) => row.modelLabel)
|
|
: [],
|
|
);
|
|
}
|