mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
test(plugins): composer commands tested on the models, overflow sheet in Playwright
The draft composer's command rules (unavailable provider, unlisted model, edits belonging to the provider the form has now) moved from the React hooks into the form store and the draft options model. They take the host's catalog as a plain input, so the command tests run on the real models with no module mocks and no renderer. The overflow-sheet test faked the portal it was meant to verify. It is replaced by a Playwright spec that installs the composer-controls example and checks its rows in the narrow composer's Features sheet; it fails when the sheet's context bridge is removed.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { buildAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { copyPluginExample } from "../support/helpers/plugin-fixture";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const NARROW = { width: 500, height: 800 };
|
||||
|
||||
test("a narrow composer's overflow sheet shows a plugin item's row", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "composer-items-",
|
||||
title: "Composer items",
|
||||
});
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Composer items agent",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("composer-controls");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await page.setViewportSize(NARROW);
|
||||
await gotoAppShell(page);
|
||||
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
|
||||
|
||||
await page.getByRole("button", { name: "Open agent features", exact: true }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Needs review", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Insert checklist", exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await client.removePlugin("composer-controls-example");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -47,6 +47,11 @@ export interface DraftOptionsModel {
|
||||
export function openDraftOptions(input: {
|
||||
provider: string | null;
|
||||
seed?: ProviderOptions;
|
||||
/**
|
||||
* The provider the draft's form has now. An edit belongs to it even before the screen has
|
||||
* rendered the change, so `setOption` observes it first.
|
||||
*/
|
||||
currentProvider?: () => string | null;
|
||||
}): DraftOptionsModel {
|
||||
let state: DraftOptionsState = {
|
||||
provider: input.provider,
|
||||
@@ -64,6 +69,15 @@ export function openDraftOptions(input: {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function observeProvider(provider: string | null): void {
|
||||
publish((current) => {
|
||||
if (provider === current.provider) return current;
|
||||
// The first provider a draft picks does not discard what it was opened with.
|
||||
if (current.provider === null) return { ...current, provider };
|
||||
return { ...current, provider, seed: {}, edits: {}, resolved: null };
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe: (listener) => {
|
||||
@@ -72,13 +86,7 @@ export function openDraftOptions(input: {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
observeProvider: (provider) =>
|
||||
publish((current) => {
|
||||
if (provider === current.provider) return current;
|
||||
// The first provider a draft picks does not discard what it was opened with.
|
||||
if (current.provider === null) return { ...current, provider };
|
||||
return { ...current, provider, seed: {}, edits: {}, resolved: null };
|
||||
}),
|
||||
observeProvider,
|
||||
applyProfile: (profile) =>
|
||||
publish((current) => ({
|
||||
...current,
|
||||
@@ -88,12 +96,14 @@ export function openDraftOptions(input: {
|
||||
resolved: current.provider === profile.provider ? current.resolved : null,
|
||||
})),
|
||||
setRemembered: (remembered) => publish((current) => ({ ...current, remembered })),
|
||||
setOption: (key, value) =>
|
||||
setOption: (key, value) => {
|
||||
if (input.currentProvider) observeProvider(input.currentProvider());
|
||||
publish((current) =>
|
||||
Object.is(current.edits[key], value)
|
||||
? current
|
||||
: { ...current, edits: { ...current.edits, [key]: value } },
|
||||
),
|
||||
);
|
||||
},
|
||||
applyResolved: (resolved) => publish((current) => ({ ...current, resolved })),
|
||||
};
|
||||
}
|
||||
@@ -166,3 +176,17 @@ export function viewDraftOptions(
|
||||
: { ...resolved.options, ...state.edits },
|
||||
};
|
||||
}
|
||||
|
||||
const NO_OPTIONS: ProviderOptions = {};
|
||||
|
||||
/**
|
||||
* The options the draft will create with, for a plugin or a control to read: the daemon's answer
|
||||
* for this provider when there is one, otherwise the draft's own layers, so an edit shows before
|
||||
* the daemon has answered.
|
||||
*/
|
||||
export function shownDraftOptions(
|
||||
state: DraftOptionsState,
|
||||
current: { provider: string | null; requestKey: string },
|
||||
): ProviderOptions {
|
||||
return viewDraftOptions(state, current)?.options ?? ownDraftOptions(state) ?? NO_OPTIONS;
|
||||
}
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act, useSyncExternalStore } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
|
||||
/**
|
||||
* A faithful stand-in for `@gorhom/portal`: it renders nothing where it sits and hands its
|
||||
* element to a host elsewhere in the tree, so the sheet's content loses the composer's context
|
||||
* exactly as it does on a phone and on a narrow window.
|
||||
*/
|
||||
const portalStore = vi.hoisted(() => {
|
||||
const listeners = new Set<() => void>();
|
||||
let node: unknown = null;
|
||||
return {
|
||||
put(next: unknown) {
|
||||
node = next;
|
||||
for (const listener of listeners) listener();
|
||||
},
|
||||
read: () => node,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
reset() {
|
||||
node = null;
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@gorhom/portal", () => ({
|
||||
Portal: ({ children }: { children?: React.ReactNode }) => {
|
||||
React.useEffect(() => {
|
||||
portalStore.put(children);
|
||||
}, [children]);
|
||||
return null;
|
||||
},
|
||||
PortalHost: () => React.createElement("div", { "data-host": true }),
|
||||
}));
|
||||
|
||||
vi.mock("@gorhom/bottom-sheet", async () => {
|
||||
const { Portal } = await import("@gorhom/portal");
|
||||
return {
|
||||
BottomSheetHandle: () => null,
|
||||
useBottomSheetGestureHandlers: () => ({}),
|
||||
BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", null, children),
|
||||
BottomSheetModal: React.forwardRef(({ children }: { children?: React.ReactNode }, _ref) =>
|
||||
React.createElement(Portal, null, children),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/plugins/client-runtime", () => ({
|
||||
createPluginClientRuntime: () => ({
|
||||
paseo: { dispose: async () => {} },
|
||||
rpc: async () => undefined,
|
||||
openSurface: () => undefined,
|
||||
openPanel: () => undefined,
|
||||
addComposerPill: () => ({ update() {}, remove() {} }),
|
||||
addHeaderButton: () => ({ update() {}, remove() {} }),
|
||||
}),
|
||||
}));
|
||||
|
||||
import appPackage from "../../../package.json";
|
||||
import { IsolatedBottomSheetModal } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { ToastApiProvider } from "@/contexts/toast-context";
|
||||
import { PluginComposerItems } from "@/plugins/composer-items";
|
||||
import { pluginRegistry } from "@/plugins/registry";
|
||||
import {
|
||||
ComposerAgentProvider,
|
||||
ComposerInputProvider,
|
||||
type ComposerAgent,
|
||||
type ComposerInput,
|
||||
} from "@/composer/session";
|
||||
import { useComposerSheetBridge } from "./sheet-bridge";
|
||||
|
||||
// One toggle that reads the composer's metadata, as a plugin author writes it.
|
||||
const PLUGIN_BUNDLE = `(function(require) {
|
||||
const React = require("react");
|
||||
const { useComposer } = require("@getpaseo/plugin/client");
|
||||
const { Toggle } = require("@getpaseo/plugin/client/ui");
|
||||
const module = { exports: {} };
|
||||
function NeedsReview() {
|
||||
const { agent, setMetadata } = useComposer();
|
||||
return React.createElement(Toggle, {
|
||||
title: "Needs review",
|
||||
icon: "Scan",
|
||||
value: agent.metadata.needsReview === true,
|
||||
onValueChange: ({ value }) => setMetadata({ needsReview: value }),
|
||||
});
|
||||
}
|
||||
module.exports.default = function(plugin) {
|
||||
plugin.addComposerItem({ id: "needs-review", Component: NeedsReview });
|
||||
return function() {};
|
||||
};
|
||||
return module.exports;
|
||||
})`;
|
||||
|
||||
const agent: ComposerAgent = {
|
||||
context: "draft",
|
||||
provider: "claude",
|
||||
model: "sonnet",
|
||||
mode: null,
|
||||
thinking: null,
|
||||
options: {},
|
||||
metadata: { needsReview: true },
|
||||
setProvider: async () => {},
|
||||
setModel: async () => {},
|
||||
setMode: async () => {},
|
||||
setThinking: async () => {},
|
||||
setOptions: async () => {},
|
||||
setMetadata: async () => {},
|
||||
};
|
||||
|
||||
const input: ComposerInput = {
|
||||
textSource: { getSnapshot: () => "", subscribe: () => () => {} },
|
||||
setText: () => {},
|
||||
insertText: () => {},
|
||||
attachments: [],
|
||||
changeAttachments: () => {},
|
||||
};
|
||||
|
||||
const toast = { error: () => {} } as unknown as ToastApi;
|
||||
|
||||
/** The overflow sheet: the plugin's items inside a sheet, opened from the composer. */
|
||||
function OverflowSheet() {
|
||||
const sheetBridge = useComposerSheetBridge();
|
||||
return (
|
||||
<IsolatedBottomSheetModal contextBridge={sheetBridge}>
|
||||
<PluginComposerItems serverId="host-a" surface="sheet" />
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders whatever the portal holds from the app root, outside every composer provider. */
|
||||
function PortalHostProbe() {
|
||||
const node = useSyncExternalStore(portalStore.subscribe, portalStore.read, portalStore.read);
|
||||
return <div data-portal-host>{node as React.ReactNode}</div>;
|
||||
}
|
||||
|
||||
describe("the composer's overflow sheet", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
pluginRegistry.installCatalog(
|
||||
"host-a",
|
||||
[
|
||||
{
|
||||
id: "review",
|
||||
clientBundle: PLUGIN_BUNDLE,
|
||||
requirements: { paseo: `>=${appPackage.version}` },
|
||||
},
|
||||
],
|
||||
{ client: {} as DaemonClient, audio: { play: async () => 0 } },
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
portalStore.reset();
|
||||
pluginRegistry.removeHost("host-a");
|
||||
});
|
||||
|
||||
it("draws a plugin's composer item as a row that reads the composer", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ToastApiProvider api={toast}>
|
||||
<ComposerAgentProvider value={agent}>
|
||||
<ComposerInputProvider value={input}>
|
||||
<OverflowSheet />
|
||||
</ComposerInputProvider>
|
||||
</ComposerAgentProvider>
|
||||
</ToastApiProvider>
|
||||
<PortalHostProbe />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const sheet = container.querySelector("[data-portal-host]");
|
||||
expect(sheet?.textContent).toContain("Needs review");
|
||||
// The toggle reads `agent.metadata.needsReview`, so the row shows the composer's value.
|
||||
expect(sheet?.textContent).toContain("On");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import {
|
||||
draftControlsRequest,
|
||||
openDraftOptions,
|
||||
shownDraftOptions,
|
||||
} from "@/agent-controls/draft-options-model";
|
||||
import { openAgentForm, type AgentFormCatalog } from "@/provider-selection/agent-form-store";
|
||||
import { draftComposerAgent } from "./composer-agent";
|
||||
|
||||
function definition(id: string, modeId: string): AgentProviderDefinition {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
description: id,
|
||||
defaultModeId: modeId,
|
||||
modes: [{ id: modeId, label: modeId, icon: "ShieldCheck", colorTier: "safe" }],
|
||||
};
|
||||
}
|
||||
|
||||
function model(provider: string, id: string, isDefault = false): AgentModelDefinition {
|
||||
return { provider, id, label: id, isDefault };
|
||||
}
|
||||
|
||||
/** The host: two providers with catalogs, and one that is ready but lists no models. */
|
||||
const catalog: AgentFormCatalog = {
|
||||
selectableProviders: new Map([
|
||||
["claude", definition("claude", "default")],
|
||||
["codex", definition("codex", "auto")],
|
||||
["opencode", definition("opencode", "build")],
|
||||
]),
|
||||
models: new Map([
|
||||
["claude", [model("claude", "sonnet", true)]],
|
||||
["codex", [model("codex", "gpt-5.4", true), model("codex", "gpt-5.5")]],
|
||||
["opencode", []],
|
||||
]),
|
||||
providerPreferences: () => undefined,
|
||||
};
|
||||
|
||||
/** A draft composer on the real form and options models. */
|
||||
function openDraft(context: "draft" | "new-workspace") {
|
||||
const form = openAgentForm(() => catalog);
|
||||
const options = openDraftOptions({
|
||||
provider: null,
|
||||
currentProvider: () => form.getState().form.provider,
|
||||
});
|
||||
// The screen hands the options the provider the form reaches.
|
||||
form.subscribe(() => options.observeProvider(form.getState().form.provider));
|
||||
form.setProviderAndModel("claude", "");
|
||||
|
||||
function agent() {
|
||||
const { form: current } = form.getState();
|
||||
return draftComposerAgent({
|
||||
context,
|
||||
selectedProvider: current.provider,
|
||||
selectedModel: current.model,
|
||||
selectedMode: current.modeId,
|
||||
thinking: current.thinkingOptionId || null,
|
||||
options: shownDraftOptions(options.getState(), {
|
||||
provider: current.provider,
|
||||
requestKey: "",
|
||||
}),
|
||||
metadata: {},
|
||||
onSelectProviderAndModel: form.setProviderAndModel,
|
||||
onSelectModel: form.setModel,
|
||||
onSelectMode: () => undefined,
|
||||
onSelectThinkingOption: () => undefined,
|
||||
onSetOption: options.setOption,
|
||||
onSetMetadata: () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** What the daemon is asked to resolve for the agent this draft would create. */
|
||||
function request() {
|
||||
const { form: current } = form.getState();
|
||||
return draftControlsRequest(options.getState(), {
|
||||
provider: current.provider,
|
||||
cwd: "/repo",
|
||||
modeId: current.modeId,
|
||||
modelId: current.model,
|
||||
});
|
||||
}
|
||||
|
||||
return { form, agent, request };
|
||||
}
|
||||
|
||||
describe.each(["draft", "new-workspace"] as const)("the %s composer's agent", (context) => {
|
||||
it("applies each awaited command before the next one runs", async () => {
|
||||
const draft = openDraft(context);
|
||||
// A plugin keeps the object it was handed across its awaits.
|
||||
const retained = draft.agent();
|
||||
|
||||
await retained.setProvider("codex");
|
||||
await retained.setModel("gpt-5.5");
|
||||
await retained.setOptions({ context: "1m" });
|
||||
|
||||
const agent = draft.agent();
|
||||
expect(agent).toMatchObject({
|
||||
context,
|
||||
provider: "codex",
|
||||
model: "gpt-5.5",
|
||||
options: { context: "1m" },
|
||||
});
|
||||
expect(draft.request()).toMatchObject({
|
||||
provider: "codex",
|
||||
model: "gpt-5.5",
|
||||
providerOptions: { context: "1m" },
|
||||
});
|
||||
});
|
||||
|
||||
it("starts the options over when the provider is chosen by hand", async () => {
|
||||
const draft = openDraft(context);
|
||||
const retained = draft.agent();
|
||||
|
||||
await retained.setOptions({ context: "1m" });
|
||||
expect(draft.agent().options).toEqual({ context: "1m" });
|
||||
await retained.setProvider("codex");
|
||||
|
||||
expect(draft.agent().options).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects a provider the host cannot run and a model the catalog does not list", async () => {
|
||||
const draft = openDraft(context);
|
||||
const retained = draft.agent();
|
||||
|
||||
await expect(retained.setProvider("nope")).rejects.toThrow(
|
||||
"Unknown or unavailable provider: nope",
|
||||
);
|
||||
expect(draft.agent().provider).toBe("claude");
|
||||
|
||||
await retained.setProvider("codex");
|
||||
await expect(retained.setModel("missing")).rejects.toThrow("Unknown model: missing");
|
||||
expect(draft.agent()).toMatchObject({ provider: "codex", model: "gpt-5.4" });
|
||||
});
|
||||
|
||||
it("keeps a model id as given when the provider lists no catalog", async () => {
|
||||
const draft = openDraft(context);
|
||||
const retained = draft.agent();
|
||||
|
||||
await retained.setProvider("opencode");
|
||||
await retained.setModel("anthropic/claude-x");
|
||||
|
||||
expect(draft.agent().model).toBe("anthropic/claude-x");
|
||||
});
|
||||
});
|
||||
@@ -1,198 +0,0 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import React, { type ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
const { asyncStorage, runtime } = vi.hoisted(() => ({
|
||||
asyncStorage: new Map<string, string>(),
|
||||
runtime: {
|
||||
controlRequests: [] as unknown[],
|
||||
client: null as unknown,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: async (key: string) => asyncStorage.get(key) ?? null,
|
||||
setItem: async (key: string, value: string) => void asyncStorage.set(key, value),
|
||||
removeItem: async (key: string) => void asyncStorage.delete(key),
|
||||
},
|
||||
}));
|
||||
|
||||
const entries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
modes: [{ id: "default", label: "Default" }],
|
||||
models: [{ provider: "claude", id: "sonnet", label: "Sonnet", isDefault: true }],
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
modes: [{ id: "auto", label: "Auto" }],
|
||||
models: [
|
||||
{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true },
|
||||
{ provider: "codex", id: "gpt-5.5", label: "GPT-5.5" },
|
||||
],
|
||||
},
|
||||
// Ready, but it lists no models: the host cannot say which ids exist.
|
||||
{
|
||||
provider: "opencode",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@/hooks/use-providers-snapshot", () => ({
|
||||
useProvidersSnapshot: () => ({
|
||||
entries,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isRefreshing: false,
|
||||
error: null,
|
||||
supportsSnapshot: true,
|
||||
refresh: async () => undefined,
|
||||
refetchIfStale: () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeClient: () => runtime.client,
|
||||
useHostRuntimeIsConnected: () => true,
|
||||
}));
|
||||
|
||||
const { useAgentFormState } = await import("@/hooks/use-agent-form-state");
|
||||
const { useDraftProviderOptions } = await import("@/hooks/use-draft-provider-options");
|
||||
const { draftComposerAgent } = await import("./composer-agent");
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
/** The draft composer's hooks, joined the way the screens join them. */
|
||||
const initialValues = { provider: "claude" } as const;
|
||||
|
||||
function useDraftComposer(context: "draft" | "new-workspace") {
|
||||
const form = useAgentFormState({
|
||||
serverId: "host-1",
|
||||
workingDir: "/repo",
|
||||
isVisible: true,
|
||||
initialValues,
|
||||
});
|
||||
const options = useDraftProviderOptions({
|
||||
serverId: "host-1",
|
||||
provider: form.selectedProvider,
|
||||
getProvider: form.getSelectedProvider,
|
||||
cwd: "/repo",
|
||||
modeId: form.selectedMode,
|
||||
modelId: form.selectedModel,
|
||||
thinkingOptionId: form.selectedThinkingOptionId,
|
||||
});
|
||||
const agent = draftComposerAgent({
|
||||
context,
|
||||
selectedProvider: form.selectedProvider,
|
||||
selectedModel: form.selectedModel,
|
||||
selectedMode: form.selectedMode,
|
||||
thinking: form.selectedThinkingOptionId || null,
|
||||
options: options.shownOptions,
|
||||
metadata: {},
|
||||
onSelectProviderAndModel: form.setProviderAndModelFromUser,
|
||||
onSelectModel: form.setModelFromUser,
|
||||
onSelectMode: form.setModeFromUser,
|
||||
onSelectThinkingOption: form.setThinkingOptionFromUser,
|
||||
onSetOption: options.setOption,
|
||||
onSetMetadata: () => undefined,
|
||||
});
|
||||
return { form, agent };
|
||||
}
|
||||
|
||||
async function mount(context: "draft" | "new-workspace") {
|
||||
const view = renderHook(() => useDraftComposer(context), { wrapper });
|
||||
await waitFor(() => expect(view.result.current.form.selectedProvider).not.toBeNull());
|
||||
return view;
|
||||
}
|
||||
|
||||
describe.each(["draft", "new-workspace"] as const)("the %s composer's agent", (context) => {
|
||||
beforeEach(() => {
|
||||
asyncStorage.clear();
|
||||
runtime.controlRequests = [];
|
||||
runtime.client = {
|
||||
listProviderControls: async (request: unknown) => {
|
||||
runtime.controlRequests.push(request);
|
||||
return { controls: [], options: {} };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it("applies each awaited command before the next one runs", async () => {
|
||||
const view = await mount(context);
|
||||
// A plugin keeps the object it was handed across its awaits.
|
||||
const retained = view.result.current.agent;
|
||||
const other = retained.provider === "codex" ? "claude" : "codex";
|
||||
const model = other === "codex" ? "gpt-5.5" : "sonnet";
|
||||
|
||||
await act(async () => {
|
||||
await retained.setProvider(other);
|
||||
await retained.setModel(model);
|
||||
await retained.setOptions({ context: "1m" });
|
||||
});
|
||||
|
||||
const { form, agent } = view.result.current;
|
||||
expect(agent.context).toBe(context);
|
||||
expect(form.selectedProvider).toBe(other);
|
||||
expect(form.selectedModel).toBe(model);
|
||||
expect(agent.options).toEqual({ context: "1m" });
|
||||
await waitFor(() =>
|
||||
expect(runtime.controlRequests.at(-1)).toMatchObject({
|
||||
provider: other,
|
||||
model,
|
||||
providerOptions: { context: "1m" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the options of the provider they were set for", async () => {
|
||||
const view = await mount(context);
|
||||
const retained = view.result.current.agent;
|
||||
|
||||
await act(async () => {
|
||||
await retained.setOptions({ context: "1m" });
|
||||
await retained.setProvider(retained.provider === "codex" ? "claude" : "codex");
|
||||
});
|
||||
|
||||
// Choosing a provider by hand starts the options over.
|
||||
expect(view.result.current.agent.options).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects a provider the host cannot run and a model the catalog does not list", async () => {
|
||||
const view = await mount(context);
|
||||
const retained = view.result.current.agent;
|
||||
|
||||
await act(async () => {
|
||||
await expect(retained.setProvider("nope")).rejects.toThrow(
|
||||
"Unknown or unavailable provider: nope",
|
||||
);
|
||||
await retained.setProvider("codex");
|
||||
await expect(retained.setModel("missing")).rejects.toThrow("Unknown model: missing");
|
||||
});
|
||||
expect(view.result.current.form.selectedProvider).toBe("codex");
|
||||
expect(view.result.current.form.selectedModel).not.toBe("missing");
|
||||
});
|
||||
|
||||
it("keeps a model id as given when the provider lists no catalog", async () => {
|
||||
const view = await mount(context);
|
||||
const retained = view.result.current.agent;
|
||||
|
||||
await act(async () => {
|
||||
await retained.setProvider("opencode");
|
||||
await retained.setModel("anthropic/claude-x");
|
||||
});
|
||||
expect(view.result.current.form.selectedModel).toBe("anthropic/claude-x");
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
type FormState,
|
||||
type ProviderModelsByProvider,
|
||||
} from "@/provider-selection/resolve-agent-form";
|
||||
import { openAgentForm } from "@/provider-selection/agent-form-store";
|
||||
import { openAgentForm, type AgentFormCatalog } from "@/provider-selection/agent-form-store";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { MaterializedAgentProfile } from "@/agent-profiles";
|
||||
|
||||
@@ -84,6 +84,12 @@ export interface UseAgentFormStateResult {
|
||||
persistFormPreferences: () => Promise<void>;
|
||||
}
|
||||
|
||||
const EMPTY_CATALOG: AgentFormCatalog = {
|
||||
selectableProviders: new Map(),
|
||||
models: new Map(),
|
||||
providerPreferences: () => undefined,
|
||||
};
|
||||
|
||||
function resolveSelectedProviderModes(input: {
|
||||
selectedEntry: ProviderSnapshotEntry | null;
|
||||
provider: AgentProvider | null;
|
||||
@@ -175,7 +181,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
[updatePreferences],
|
||||
);
|
||||
|
||||
const [formStore] = useState(openAgentForm);
|
||||
// The store reads what the host offers when a command runs; each render publishes the latest.
|
||||
const catalogRef = useRef<AgentFormCatalog>(EMPTY_CATALOG);
|
||||
const [formStore] = useState(() => openAgentForm(() => catalogRef.current));
|
||||
const { form: formState, resolution } = useSyncExternalStore(
|
||||
formStore.subscribe,
|
||||
formStore.getState,
|
||||
@@ -251,6 +259,12 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
||||
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
|
||||
const allProviderModels = snapshotAllProviderModels;
|
||||
catalogRef.current = {
|
||||
selectableProviders: selectableProviderDefinitionMap,
|
||||
models: allProviderModels,
|
||||
providerPreferences: (provider) =>
|
||||
preferenceOverlayRef.current.current().providerPreferences?.[provider],
|
||||
};
|
||||
const modelSelectorProviders = snapshotModelSelectorProviders;
|
||||
const availableModels = snapshotSelectedProviderModels;
|
||||
const modeOptions = snapshotSelectedProviderModes;
|
||||
@@ -287,23 +301,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
|
||||
const setProviderAndModelFromUser = useCallback(
|
||||
(provider: AgentProvider, modelId: string): boolean => {
|
||||
if (!selectableProviderDefinitionMap.has(provider)) {
|
||||
return false;
|
||||
}
|
||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||
if (!formStore.setProviderAndModel(provider, modelId)) return false;
|
||||
const providerModels = allProviderModels.get(provider) ?? null;
|
||||
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
||||
|
||||
dispatch({
|
||||
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||
provider,
|
||||
modelId,
|
||||
providerDef,
|
||||
providerModels,
|
||||
providerPrefs,
|
||||
});
|
||||
const nextModelId =
|
||||
normalizeSelectedModelId(modelId) || resolveDefaultModelId(providerModels);
|
||||
void updateCurrentPreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
preferences: current,
|
||||
@@ -315,7 +316,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
);
|
||||
return true;
|
||||
},
|
||||
[allProviderModels, dispatch, selectableProviderDefinitionMap, updateCurrentPreferences],
|
||||
[allProviderModels, formStore, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const clearProviderSelectionFromUser = useCallback(() => {
|
||||
@@ -395,33 +396,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
[dispatch, formStore, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
// Reads the form as it is now: a command that follows `setProviderAndModelFromUser` sees the new
|
||||
// provider and its catalog. A catalog that has not arrived keeps the id as given; one that has
|
||||
// arrived refuses an id it does not list.
|
||||
const setModelFromUser = useCallback(
|
||||
(modelId: string): boolean => {
|
||||
if (!formStore.setModel(modelId)) return false;
|
||||
const { provider } = formStore.getState().form;
|
||||
const listed = provider
|
||||
? filterSelectableModels(
|
||||
(snapshotEntries ?? []).find((entry) => entry.provider === provider)?.models ?? null,
|
||||
)
|
||||
: null;
|
||||
const models = listed?.length ? listed : null;
|
||||
if (models && normalizeSelectedModelId(modelId) && !resolveEffectiveModel(models, modelId)) {
|
||||
return false;
|
||||
}
|
||||
const providerPrefs = provider
|
||||
? preferenceOverlayRef.current.current().providerPreferences?.[provider]
|
||||
: undefined;
|
||||
dispatch({
|
||||
type: "SET_MODEL_FROM_USER",
|
||||
modelId,
|
||||
availableModels: models,
|
||||
providerPrefs,
|
||||
});
|
||||
if (provider) {
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(models);
|
||||
const listed = allProviderModels.get(provider);
|
||||
const nextModelId =
|
||||
normalizeSelectedModelId(modelId) ||
|
||||
resolveDefaultModelId(listed?.length ? listed : null);
|
||||
void updateCurrentPreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
preferences: current,
|
||||
@@ -434,7 +417,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[dispatch, formStore, snapshotEntries, updateCurrentPreferences],
|
||||
[allProviderModels, formStore, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const setThinkingOptionFromUser = useCallback(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
draftControlsRequest,
|
||||
openDraftOptions,
|
||||
ownDraftOptions,
|
||||
shownDraftOptions,
|
||||
viewDraftOptions,
|
||||
} from "@/agent-controls/draft-options-model";
|
||||
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
|
||||
@@ -12,8 +13,6 @@ import { useFetchQuery } from "@/data/query";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
|
||||
|
||||
const EMPTY_OPTIONS: ProviderOptions = {};
|
||||
|
||||
/**
|
||||
* Adapter between a draft's options model and the screen: it sends the model's complete options
|
||||
* to the daemon, hands the answer back, and persists the user's toggles as last-used values.
|
||||
@@ -40,7 +39,9 @@ export function useDraftProviderOptions(input: {
|
||||
thinkingOptionId,
|
||||
initialOptions,
|
||||
} = input;
|
||||
const [model] = useState(() => openDraftOptions({ provider, seed: initialOptions }));
|
||||
const [model] = useState(() =>
|
||||
openDraftOptions({ provider, seed: initialOptions, currentProvider: getProvider }),
|
||||
);
|
||||
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
@@ -93,9 +94,8 @@ export function useDraftProviderOptions(input: {
|
||||
// An edit belongs to the provider the form has now, even before the render that shows it.
|
||||
const setOption = useCallback(
|
||||
(key: string, value: unknown) => {
|
||||
const current = getProvider();
|
||||
model.observeProvider(current);
|
||||
model.setOption(key, value);
|
||||
const current = getProvider();
|
||||
if (!current) return;
|
||||
void updatePreferences((stored) =>
|
||||
mergeProviderPreferences({
|
||||
@@ -122,7 +122,7 @@ export function useDraftProviderOptions(input: {
|
||||
);
|
||||
|
||||
// What the draft will create with, the plugin's own edits included, before the daemon answers.
|
||||
const shownOptions = viewOptions ?? ownOptions ?? EMPTY_OPTIONS;
|
||||
const shownOptions = shownDraftOptions(state, { provider, requestKey });
|
||||
|
||||
return {
|
||||
optionControls,
|
||||
|
||||
@@ -1,36 +1,90 @@
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { FormPreferences } from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
INITIAL_AGENT_FORM_RESOLUTION,
|
||||
INITIAL_USER_MODIFIED,
|
||||
normalizeSelectedModelId,
|
||||
resolveAgentForm,
|
||||
resolveEffectiveModel,
|
||||
type AgentFormAction,
|
||||
type AgentFormReducerState,
|
||||
} from "@/provider-selection/resolve-agent-form";
|
||||
|
||||
/** What the host offers right now. The store reads it when a command runs, never earlier. */
|
||||
export interface AgentFormCatalog {
|
||||
/** The providers the host can run. */
|
||||
selectableProviders: ReadonlyMap<AgentProvider, AgentProviderDefinition>;
|
||||
/** Each provider's selectable models; empty when the host has listed none. */
|
||||
models: ReadonlyMap<string, AgentModelDefinition[]>;
|
||||
providerPreferences: (
|
||||
provider: AgentProvider,
|
||||
) => NonNullable<FormPreferences["providerPreferences"]>[AgentProvider] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The draft form as a plain model: `resolveAgentForm` applied synchronously. A command reads
|
||||
* `getState()` when it runs, so the one after `await setProvider(...)` sees the new provider
|
||||
* instead of the render that created it.
|
||||
* `getState()` and the catalog when it runs, so the one after `await setProvider(...)` sees the
|
||||
* new provider instead of the render that created it.
|
||||
*/
|
||||
export interface AgentFormStore {
|
||||
getState: () => AgentFormReducerState;
|
||||
dispatch: (action: AgentFormAction) => void;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
/** `false` when the provider is unknown or not ready; the form is left as it was. */
|
||||
setProviderAndModel: (provider: AgentProvider, modelId: string) => boolean;
|
||||
/**
|
||||
* `false` when the provider's catalog is loaded and does not list the model. A catalog that has
|
||||
* not arrived keeps the id as given.
|
||||
*/
|
||||
setModel: (modelId: string) => boolean;
|
||||
}
|
||||
|
||||
export function openAgentForm(): AgentFormStore {
|
||||
export function openAgentForm(catalog: () => AgentFormCatalog): AgentFormStore {
|
||||
let state: AgentFormReducerState = {
|
||||
form: { provider: null, modeId: "", model: "", thinkingOptionId: "" },
|
||||
userModified: INITIAL_USER_MODIFIED,
|
||||
resolution: INITIAL_AGENT_FORM_RESOLUTION,
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
function dispatch(action: AgentFormAction): void {
|
||||
const next = resolveAgentForm(state, action);
|
||||
if (next === state) return;
|
||||
state = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
return {
|
||||
getState: () => state,
|
||||
dispatch: (action) => {
|
||||
const next = resolveAgentForm(state, action);
|
||||
if (next === state) return;
|
||||
state = next;
|
||||
for (const listener of listeners) listener();
|
||||
dispatch,
|
||||
setProviderAndModel: (provider, modelId) => {
|
||||
const host = catalog();
|
||||
const providerDef = host.selectableProviders.get(provider);
|
||||
if (!providerDef) return false;
|
||||
dispatch({
|
||||
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||
provider,
|
||||
modelId,
|
||||
providerDef,
|
||||
providerModels: host.models.get(provider) ?? null,
|
||||
providerPrefs: host.providerPreferences(provider),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
setModel: (modelId) => {
|
||||
const { provider } = state.form;
|
||||
const host = catalog();
|
||||
const listed = provider ? host.models.get(provider) : undefined;
|
||||
const models = listed?.length ? listed : null;
|
||||
if (models && normalizeSelectedModelId(modelId) && !resolveEffectiveModel(models, modelId)) {
|
||||
return false;
|
||||
}
|
||||
dispatch({
|
||||
type: "SET_MODEL_FROM_USER",
|
||||
modelId,
|
||||
availableModels: models,
|
||||
providerPrefs: provider ? host.providerPreferences(provider) : undefined,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
|
||||
Reference in New Issue
Block a user