diff --git a/docs/plugins.md b/docs/plugins.md index 408b78369a..a5670fcbdf 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -544,7 +544,7 @@ The provider draws some of those keys in the composer by reporting `controls` an built-in providers ([options flow](providers.md#options-flow)). `PluginAgentSession` in `packages/server/src/server/agent/plugin-provider.ts` is that adapter. A provider still reporting `settings` is read through `plugin-provider-options.ts`, which turns each setting into a control and -its value into an option and is sent both shapes in `session.configure`; delete it with +its value into an option and is sent both shapes in `session.configure` (`settings` holds only the keys it reports); delete it with `COMPAT(plugin-provider-settings)`. The ACP shim still reports `settings`. Providers add an optional veto to daemon baseline cleanup through the per-session @@ -698,9 +698,8 @@ implementations provide it: `ComposerInput.isLive()` is how the model tells: it needs the ref and a retained panel that is showing (`useRetainedPanelActive`). Closing a root agent's tab archives it, so a running agent with no mounted composer at all only exists as a subagent whose tab was - closed. The daemon is the authority for `setModel` and `setThinking`. It validates against the - catalog of the agent's directory, the one the picker reads, and checks a thinking option against - the model the agent runs: the runtime model when the catalog lists it, else the configured one. + closed. The daemon does not check `setModel` and `setThinking` against a catalog; the provider + decides and its refusal rejects the call. - A draft or New workspace screen's `composerModelFromHalves`, an adapter over the two contexts in `composer/session.tsx`: `Composer` provides `ComposerInput` (prompt, attachments), `DraftAgentControls` provides `ComposerAgent` (settings), and the adapter enforces the lock. @@ -728,8 +727,8 @@ item that renders nothing still counts for density; there is no registration for A command runs against the owner's current state, not the render that built the callback. The draft's form (`openAgentForm`) and options (`openDraftOptions`) are plain stores for that reason, -and `draftComposerAgent` rejects a provider the host does not offer or a model its loaded catalog -does not list; for a running agent the daemon rejects it. `ComposerControl` owns the pending state and the error toast for callbacks that +and `draftComposerAgent` rejects a provider the host does not offer. It passes a model id to the form +as the picker does, with no catalog check; for a running agent the provider decides. `ComposerControl` owns the pending state and the error toast for callbacks that return a promise. The overflow sheet's content portals to the app root, away from the composer. Pass diff --git a/packages/app/src/composer/agent-composer/index.test.ts b/packages/app/src/composer/agent-composer/index.test.ts index b668893df8..3eb859d726 100644 --- a/packages/app/src/composer/agent-composer/index.test.ts +++ b/packages/app/src/composer/agent-composer/index.test.ts @@ -88,7 +88,7 @@ function fakeClient() { }, async setAgentModel(id: string, model: string | null) { calls.push(`model:${id}:${model}`); - if (model === "nope") throw new Error("Model 'nope' is not available for provider 'claude'"); + if (model === "nope") throw new Error("The provider refused model 'nope'"); }, async setAgentMetadata(id: string, patch: unknown) { calls.push(`metadata:${id}:${JSON.stringify(patch)}`); @@ -294,11 +294,9 @@ describe("agent composer", () => { ); }); - it("rejects an unlisted model with the daemon's message", async () => { + it("rejects a model the provider refuses with the provider's message", async () => { openSession(agent()); - await expect(composer().setModel("nope")).rejects.toThrow( - "Model 'nope' is not available for provider 'claude'", - ); + await expect(composer().setModel("nope")).rejects.toThrow("The provider refused model 'nope'"); }); it("takes the catalog the host changed while the picker was not open, once the host reconnects", async () => { diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 80c9e55823..a5e7e7ca56 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -133,8 +133,7 @@ export interface DraftAgentControlsProps { onSelectMode: (modeId: string) => void; models: AgentModelDefinition[]; selectedModel: string; - /** `false` when the provider's loaded catalog does not list the model. */ - onSelectModel: (modelId: string) => boolean; + onSelectModel: (modelId: string) => void; isModelLoading: boolean; modelSelectorProviders: ProviderSelectorProvider[]; isAllModelsLoading: boolean; diff --git a/packages/app/src/composer/draft/composer-agent.test.ts b/packages/app/src/composer/draft/composer-agent.test.ts index 6fd21d3b81..35256eba4c 100644 --- a/packages/app/src/composer/draft/composer-agent.test.ts +++ b/packages/app/src/composer/draft/composer-agent.test.ts @@ -156,7 +156,7 @@ describe.each(["draft", "new-workspace"] as const)("the %s composer's agent", (c expect(draft.request()?.providerOptions).toBeUndefined(); }); - it("rejects a provider the host cannot run and a model the catalog does not list", async () => { + it("rejects a provider the host cannot run", async () => { const draft = openDraft(context); const retained = draft.agent(); @@ -164,9 +164,16 @@ describe.each(["draft", "new-workspace"] as const)("the %s composer's agent", (c "Unknown or unavailable provider: nope", ); expect(draft.agent().provider).toBe("claude"); + }); + + it("accepts a model id the catalog does not list; the form resolves it as the model picker does", async () => { + const draft = openDraft(context); + const retained = draft.agent(); await retained.setProvider("codex"); - await expect(retained.setModel("missing")).rejects.toThrow("Unknown model: missing"); + await expect(retained.setModel("missing")).resolves.toBeUndefined(); + + // The form keeps a listed model when the one asked for is not in the catalog. expect(draft.agent()).toMatchObject({ provider: "codex", model: "gpt-5.4" }); }); diff --git a/packages/app/src/composer/draft/composer-agent.ts b/packages/app/src/composer/draft/composer-agent.ts index 3a64b37dc8..426f2edf4e 100644 --- a/packages/app/src/composer/draft/composer-agent.ts +++ b/packages/app/src/composer/draft/composer-agent.ts @@ -39,9 +39,7 @@ export function draftComposerAgent( throw new Error(`Unknown or unavailable provider: ${provider}`); } }, - setModel: async (modelId) => { - if (!props.onSelectModel(modelId)) throw new Error(`Unknown model: ${modelId}`); - }, + setModel: async (modelId) => props.onSelectModel(modelId), setMode: async (modeId) => props.onSelectMode(modeId), setThinking: async (thinkingOptionId) => props.onSelectThinkingOption(thinkingOptionId), setOptions: async (patch) => { diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 01134b37eb..03de6b6806 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -56,8 +56,7 @@ export interface UseAgentFormStateResult { selectedMode: string; setModeFromUser: (modeId: string) => void; selectedModel: string; - /** `false` when the provider's loaded catalog does not list the model. */ - setModelFromUser: (modelId: string) => boolean; + setModelFromUser: (modelId: string) => void; selectedThinkingOptionId: string; setThinkingOptionFromUser: (thinkingOptionId: string) => void; workingDir: string; @@ -397,8 +396,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo ); const setModelFromUser = useCallback( - (modelId: string): boolean => { - if (!formStore.setModel(modelId)) return false; + (modelId: string) => { + formStore.setModel(modelId); const { provider } = formStore.getState().form; if (provider) { const listed = allProviderModels.get(provider); @@ -415,7 +414,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo }), ); } - return true; }, [allProviderModels, formStore, updateCurrentPreferences], ); diff --git a/packages/app/src/provider-selection/agent-form-store.ts b/packages/app/src/provider-selection/agent-form-store.ts index 73aad728ac..2db3837d22 100644 --- a/packages/app/src/provider-selection/agent-form-store.ts +++ b/packages/app/src/provider-selection/agent-form-store.ts @@ -4,7 +4,6 @@ import type { FormPreferences } from "@/hooks/use-form-preferences"; import { INITIAL_AGENT_FORM_RESOLUTION, INITIAL_USER_MODIFIED, - isUnlistedModel, resolveAgentForm, type AgentFormAction, type AgentFormReducerState, @@ -32,11 +31,8 @@ export interface AgentFormStore { 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; + /** The id is kept as given; a catalog that does not list it is the provider's to refuse. */ + setModel: (modelId: string) => void; } export function openAgentForm(catalog: () => AgentFormCatalog): AgentFormStore { @@ -74,14 +70,12 @@ export function openAgentForm(catalog: () => AgentFormCatalog): AgentFormStore { const host = catalog(); const listed = provider ? host.models.get(provider) : undefined; const models = listed?.length ? listed : null; - if (isUnlistedModel(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); diff --git a/packages/app/src/provider-selection/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts index 95119e542e..877898b979 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.test.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.test.ts @@ -3,7 +3,6 @@ import { resolveAgentForm, resolveFormState, resolveEffectiveModel, - isUnlistedModel, resolveThinkingOptionId, mergeSelectedComposerPreferences, buildProviderDefinitionMap, @@ -132,19 +131,6 @@ describe("resolveDefaultModel", () => { }); }); -describe("isUnlistedModel", () => { - it("rejects an id the provider's catalog lacks, and accepts a listed id or alias", () => { - expect(isUnlistedModel(ALIASED_CODEX_MODELS, "not-in-the-catalog")).toBe(true); - expect(isUnlistedModel(ALIASED_CODEX_MODELS, "gpt-5.3-codex")).toBe(false); - expect(isUnlistedModel(ALIASED_CODEX_MODELS, "gpt-5.3-codex-legacy")).toBe(false); - }); - - it("accepts any id while the provider has listed no models", () => { - expect(isUnlistedModel(null, "anything")).toBe(false); - expect(isUnlistedModel([], "anything")).toBe(false); - }); -}); - describe("model aliases", () => { it("canonicalizes a retired preferred model and restores thinking from its alias key", () => { const resolved = resolveFormState( diff --git a/packages/app/src/provider-selection/resolve-agent-form.ts b/packages/app/src/provider-selection/resolve-agent-form.ts index b77ba5a37a..08e88fd7a3 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.ts @@ -153,19 +153,6 @@ export function resolveEffectiveModel( return findModelByReference(availableModels, modelId) ?? null; } -/** - * True when the provider has listed its models and this id is not among them. A provider that - * lists none accepts any id. - */ -export function isUnlistedModel( - availableModels: AgentModelDefinition[] | null, - modelId: string, -): boolean { - if (!availableModels || availableModels.length === 0) return false; - if (!normalizeSelectedModelId(modelId)) return false; - return !resolveEffectiveModel(availableModels, modelId); -} - function resolvePreferredThinkingOptionId(input: { availableModels: AgentModelDefinition[] | null; providerPrefs: ProviderPrefs | undefined; diff --git a/packages/plugin/src/client/composer.tsx b/packages/plugin/src/client/composer.tsx index 04f0cec142..0055729fcc 100644 --- a/packages/plugin/src/client/composer.tsx +++ b/packages/plugin/src/client/composer.tsx @@ -43,6 +43,7 @@ export interface PluginComposer { removeAttachment(input: { sourceId: string; itemId: string }): void; /** Resets the model like the built-in control. Rejects on a running agent, which keeps its provider. */ setProvider(provider: string): Promise; + /** On a running agent, rejects with the provider's message when it refuses the model. */ setModel(modelId: string): Promise; setMode(modeId: string): Promise; setThinking(thinkingOptionId: string): Promise; diff --git a/packages/server/src/server/agent/agent-configuration-validator.ts b/packages/server/src/server/agent/agent-configuration-validator.ts index bee8168b5a..0893f3a692 100644 --- a/packages/server/src/server/agent/agent-configuration-validator.ts +++ b/packages/server/src/server/agent/agent-configuration-validator.ts @@ -6,8 +6,6 @@ import { filterSelectableAgentModels } from "./agent-sdk-types.js"; export interface AgentConfigurationValidationInput { provider: string; - /** The directory whose catalog lists the choices; omitted, the provider's global catalog does. */ - cwd?: string; model?: string; modeId?: string; thinkingOptionId?: string; diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 956ba1eea4..8247605e91 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -20,7 +20,6 @@ import { type ManagedAgent, } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; -import { validateAgentConfigurationAgainstProvider } from "./agent-configuration-validator.js"; import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; import { toAgentPayload } from "./agent-projections.js"; @@ -55,7 +54,6 @@ import type { AgentTimelineItem, ImportProviderSessionInput, ImportProviderSessionContext, - ProviderSnapshotEntry, ResolveAgentDefaultModeInput, } from "./agent-sdk-types.js"; import type { PaseoToolCatalog } from "./tools/types.js"; @@ -5391,28 +5389,26 @@ test("later explicit config mutations win over events emitted by earlier mutatio expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("high"); }); -test("a running agent rejects a model or thinking option its provider does not list", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-selection-validation-")); - const provider: ProviderSnapshotEntry = { - provider: "codex", - status: "ready", - enabled: true, - models: [ - { - provider: "codex", - id: "gpt-5.4", - label: "GPT-5.4", - isDefault: true, - thinkingOptions: [{ id: "low", label: "Low" }], - }, - ], - modes: [], - }; +test("a running agent's model and thinking option changes reach the session unchecked", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-selection-unchecked-")); + const received: Array<[string, string | null]> = []; + class RecordingSession extends TestAgentSession { + async setModel(modelId: string | null): Promise { + received.push(["model", modelId]); + } + async setThinkingOption(thinkingOptionId: string | null): Promise { + received.push(["thinking", thinkingOptionId]); + return null; + } + } + class RecordingClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new RecordingSession(config); + } + } const manager = new AgentManager({ - clients: { codex: new TestAgentClient() }, + clients: { codex: new RecordingClient() }, registry: new AgentStorage(join(workdir, "agents"), logger), - validateAgentSelection: async (input) => - validateAgentConfigurationAgainstProvider({ input, provider }), logger, idFactory: () => "00000000-0000-4000-8000-000000000136", }); @@ -5422,119 +5418,14 @@ test("a running agent rejects a model or thinking option its provider does not l { workspaceId: undefined }, ); - await expect(manager.setAgentModel(snapshot.id, "nope")).rejects.toThrow( - "Model 'nope' is not available for provider 'codex'", - ); - await expect(manager.setAgentThinkingOption(snapshot.id, "extreme")).rejects.toThrow( - "Thinking option 'extreme' is not available for provider 'codex'", - ); - expect(manager.getAgent(snapshot.id)?.config.model).toBe("gpt-5.4"); + await manager.setAgentModel(snapshot.id, "not-in-any-catalog"); + await manager.setAgentThinkingOption(snapshot.id, "not-a-level"); - await manager.setAgentThinkingOption(snapshot.id, "low"); - await manager.setAgentModel(snapshot.id, null); - expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("low"); - expect(manager.getAgent(snapshot.id)?.config.model).toBeUndefined(); -}); - -test("a selection is validated against the catalog of the agent's directory", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-selection-cwd-")); - const asked: Array = []; - const manager = new AgentManager({ - clients: { codex: new TestAgentClient() }, - registry: new AgentStorage(join(workdir, "agents"), logger), - validateAgentSelection: async (input) => { - asked.push(input.cwd); - return []; - }, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000137", - }); - const snapshot = await manager.createAgent( - { provider: "codex", cwd: workdir, model: "gpt-5.4" }, - undefined, - { workspaceId: undefined }, - ); - - await manager.setAgentModel(snapshot.id, "gpt-5.4"); - await manager.setAgentThinkingOption(snapshot.id, "low"); - - expect(asked.length).toBeGreaterThan(0); - expect(asked.every((cwd) => cwd === workdir)).toBe(true); -}); - -test("a thinking option is checked against the model the agent runs, as the picker chooses it", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-runtime-model-")); - const provider: ProviderSnapshotEntry = { - provider: "codex", - status: "ready", - enabled: true, - models: [ - { - provider: "codex", - id: "configured", - label: "Configured", - isDefault: true, - thinkingOptions: [{ id: "configured-only", label: "Configured only" }], - }, - { - provider: "codex", - id: "runtime", - label: "Runtime", - thinkingOptions: [{ id: "runtime-only", label: "Runtime only" }], - }, - ], - modes: [], - }; - class RuntimeModelSession extends TestAgentSession { - constructor( - config: AgentSessionConfig, - private readonly runtimeModel: string, - ) { - super(config); - } - override async getRuntimeInfo() { - return { ...(await super.getRuntimeInfo()), model: this.runtimeModel }; - } - } - class RuntimeModelClient extends TestAgentClient { - constructor(private readonly runtimeModel: string) { - super(); - } - override async createSession(config: AgentSessionConfig): Promise { - return new RuntimeModelSession(config, this.runtimeModel); - } - } - async function agentRunning(runtimeModel: string) { - const manager = new AgentManager({ - clients: { codex: new RuntimeModelClient(runtimeModel) }, - registry: new AgentStorage(join(workdir, `agents-${runtimeModel}`), logger), - validateAgentSelection: async (input) => - validateAgentConfigurationAgainstProvider({ input, provider }), - logger, - idFactory: () => - `00000000-0000-4000-8000-${runtimeModel === "runtime" ? "000000000137" : "000000000138"}`, - }); - const snapshot = await manager.createAgent( - { provider: "codex", cwd: workdir, model: "configured" }, - undefined, - { workspaceId: undefined }, - ); - return { manager, id: snapshot.id }; - } - - // The session runs a listed model other than the configured one: its options are the ones shown. - const listed = await agentRunning("runtime"); - await expect(listed.manager.setAgentThinkingOption(listed.id, "configured-only")).rejects.toThrow( - "Thinking option 'configured-only' is not available", - ); - await listed.manager.setAgentThinkingOption(listed.id, "runtime-only"); - - // The session reports a model the catalog does not list: the picker falls back to the configured one. - const unlisted = await agentRunning("unlisted"); - await unlisted.manager.setAgentThinkingOption(unlisted.id, "configured-only"); - await expect( - unlisted.manager.setAgentThinkingOption(unlisted.id, "runtime-only"), - ).rejects.toThrow("Thinking option 'runtime-only' is not available"); + expect(received).toEqual([ + ["model", "not-in-any-catalog"], + ["thinking", "not-a-level"], + ]); + expect(manager.getAgent(snapshot.id)?.config.model).toBe("not-in-any-catalog"); }); test("setAgentThinkingOption adopts the session's effective clamped level", async () => { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 0894438d00..afa6d51848 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -3,8 +3,6 @@ import { ProviderOptionsValidationError } from "./provider-options.js"; import type { AgentToolResult } from "@getpaseo/plugin/server"; import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js"; import { ensureAgentLoaded } from "./agent-loading.js"; -import type { AgentConfigurationValidationInput } from "./agent-configuration-validator.js"; -import type { HubExecutionAgentValidationIssue } from "@getpaseo/protocol/messages"; import { projectTimelineRows } from "./timeline-projection.js"; import type { PluginLifecycle } from "../plugins/lifecycle/index.js"; import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js"; @@ -360,13 +358,6 @@ export interface AgentManagerOptions { pluginLifecycle?: PluginLifecycle; /** An internal workspace makes every agent created inside it internal. */ clients?: ProviderClientMap; - /** - * Asks whether the provider lists a model or thinking option. A running agent rejects a - * selection it does not; omitted, any value is accepted. - */ - validateAgentSelection?: ( - input: AgentConfigurationValidationInput, - ) => Promise; providerDefinitions?: ProviderEnabledMap; idFactory?: () => string; registry?: AgentStorage; @@ -839,7 +830,6 @@ export class AgentManager { private logger: Logger; private readonly rescueTimeouts: Required; private readonly beforeSteerUnavailableFallback?: AgentManagerOptions["beforeSteerUnavailableFallback"]; - private readonly validateAgentSelection?: AgentManagerOptions["validateAgentSelection"]; private acceptingAgentRegistrations = true; constructor(options: AgentManagerOptions) { @@ -864,7 +854,6 @@ export class AgentManager { options.rescueTimeouts?.interruptSessionMs ?? INTERRUPT_SESSION_TIMEOUT_MS, }; this.beforeSteerUnavailableFallback = options.beforeSteerUnavailableFallback; - this.validateAgentSelection = options.validateAgentSelection; this.agentStreamCoalescer = new AgentStreamCoalescer({ windowMs: options.agentStreamCoalesceWindowMs ?? AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS, timers: { setTimeout, clearTimeout }, @@ -2297,56 +2286,10 @@ export class AgentManager { return notice; } - private async assertSelectionListed(input: AgentConfigurationValidationInput): Promise { - const issues = (await this.validateAgentSelection?.(input)) ?? []; - if (issues.length > 0) throw new Error(issues.map((issue) => issue.message).join("; ")); - } - - /** - * Checks the option against the model the agent runs, chosen as the model picker chooses it: the - * model the session reports when the provider lists it, otherwise the configured model, otherwise - * the provider's default. - */ - private async assertThinkingOptionListed( - agent: ActiveManagedAgent, - thinkingOptionId: string, - ): Promise { - const reported = agent.runtimeInfo?.model; - const candidates = [reported, agent.config.model].filter( - (model): model is string => typeof model === "string" && model.length > 0, - ); - for (const model of candidates) { - const issues = - (await this.validateAgentSelection?.({ - provider: agent.provider, - cwd: agent.cwd, - model, - thinkingOptionId, - })) ?? []; - const modelListed = !issues.some((issue) => issue.path[0] === "model"); - if (modelListed) { - if (issues.length > 0) throw new Error(issues.map((issue) => issue.message).join("; ")); - return; - } - } - await this.assertSelectionListed({ - provider: agent.provider, - cwd: agent.cwd, - thinkingOptionId, - }); - } - async setAgentModel(agentId: string, modelId: string | null): Promise { const agent = await this.ensureInteractiveSession(agentId); const normalizedModelId = typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null; - if (normalizedModelId) { - await this.assertSelectionListed({ - provider: agent.provider, - cwd: agent.cwd, - model: normalizedModelId, - }); - } if (agent.session.setModel) { await agent.session.setModel(normalizedModelId); @@ -2371,9 +2314,6 @@ export class AgentManager { typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0 ? thinkingOptionId : null; - if (normalizedThinkingOptionId) { - await this.assertThinkingOptionListed(agent, normalizedThinkingOptionId); - } let notice: AgentProviderNotice | null = null; if (agent.session.setThinkingOption) { diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 1ec9d45145..465dcda931 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -1472,27 +1472,19 @@ describe("PluginAgentClientRegistry", () => { }, ); - test.runIf(shape === "settings")( - "rejects a key the plugin does not report as a setting, and configures none of a mixed patch", - async () => { - const { harness, session } = await openStubbornVoiceSession({ launch_key: "original" }); - const configureBefore = lastConfigure(harness.inputs); + test("sends a key the plugin does not report, for the plugin to accept or refuse", async () => { + const { harness, session } = await openStubbornVoiceSession({}); - await expect(session.applyOptions?.({ loud: true, launch_key: "changed" })).rejects.toThrow( - /launch_key.*cannot change on a running session/, - ); + await session.applyOptions?.({ loud: true, launch_key: "changed" }); - expect(lastConfigure(harness.inputs)).toBe(configureBefore); - - // The value it opened with is not a change, so a whole profile can be applied. - await session.applyOptions?.({ loud: true, launch_key: "original" }); - const configure = lastConfigure(harness.inputs) as { - changes: { providerOptions: object }; - }; - expect(Object.keys(configure.changes.providerOptions)).toEqual(["loud"]); - await session.close(); - }, - ); + const configure = lastConfigure(harness.inputs) as { + changes: { providerOptions: object; settings?: object }; + }; + expect(configure.changes.providerOptions).toEqual({ loud: true, launch_key: "changed" }); + // A plugin reporting `settings` reads only the ones it reports. + expect(configure.changes.settings).toEqual(shape === "settings" ? { loud: true } : undefined); + await session.close(); + }); test("applying options returns the settings that ended up different, and emits nothing", async () => { const { session, events } = await openStubbornVoiceSession({}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index becb70d442..2c30d2c144 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -64,7 +64,6 @@ import { createProviderEnv, type ProviderRuntimeSettings, } from "./provider-launch-config.js"; -import { assertChangesAreLive } from "./provider-options.js"; import { readPluginOptionState } from "./plugin-provider-options.js"; import type { RegisteredProviderDefinition } from "./provider-registry.js"; import { runProviderTurn } from "./providers/provider-runner.js"; @@ -1235,7 +1234,7 @@ class PluginAgentSession implements AgentSession { options: Readonly>, ) => Promise, private readonly onBind: (bridge: ProviderRuntimeSession) => void, - private readonly givenOptions: Readonly>, + givenOptions: Readonly>, ) { this.subagentIdsBySession.set(bridge.id, null); // Every setting the session opened with that was not given, including ones nobody set. @@ -1432,34 +1431,27 @@ class PluginAgentSession implements AgentSession { this.assertReplayComplete(); const before = this.currentOptions(); const { controls, legacySettings } = readPluginOptionState(this.bridge.config); + // The plugin applies every key it is given or rejects the whole request. const settings: Record = {}; - // COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01. A plugin - // reporting `settings` cannot refuse a key, so only its settings are sent and any other - // changed key is rejected here; a value equal to the one it opened with is not a change. - if (legacySettings) { - const live = controls.map((control) => control.key); - assertChangesAreLive({ - provider: this.provider, - patch: options, - current: this.givenOptions, - live, - }); - for (const [id, value] of Object.entries(options)) { - if (live.includes(id)) settings[id] = toJsonValue(value, `option '${id}'`); - } - } else { - // The plugin applies every key it is given or rejects the whole request. - for (const [id, value] of Object.entries(options)) { - settings[id] = toJsonValue(value, `option '${id}'`); - } + for (const [id, value] of Object.entries(options)) { + settings[id] = toJsonValue(value, `option '${id}'`); } if (Object.keys(settings).length === 0) return {}; this.applyingOptions = true; try { - // COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01. + // COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01. A plugin + // reporting `settings` reads only its own settings, so it is sent those. await this.bridge.configure({ providerOptions: settings, - ...(legacySettings ? { settings } : {}), + ...(legacySettings + ? { + settings: Object.fromEntries( + Object.entries(settings).filter(([id]) => + controls.some((control) => control.key === id), + ), + ), + } + : {}), }); } catch (error) { // Nothing is stored for a refused patch, so what the plugin moved is reported once the diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 42684a0ea7..46d1633da2 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -311,58 +311,6 @@ describe("ProviderSnapshotManager public surface", () => { } }); - test("validates a selection against the catalog of the directory it is for", async () => { - const manager = new ProviderSnapshotManager({ - logger: createTestLogger(), - providerOverrides: { - claude: { enabled: false }, - codex: { enabled: false }, - copilot: { enabled: false }, - pi: { enabled: false }, - }, - extraClients: { - opencode: createExtraClient("opencode", { - isAvailable: async () => true, - // A project can configure models its global catalog does not list. - fetchCatalog: async (options) => ({ - models: [ - { - provider: "opencode", - id: options.scope === "workspace" ? "project/custom" : "global/default", - label: "Model", - thinkingOptions: [ - { id: options.scope === "workspace" ? "deep" : "plain", label: "Level" }, - ], - }, - ], - modes: [], - }), - }), - }, - }); - - try { - await expect( - manager.validateAgentConfiguration({ - provider: "opencode", - cwd: "/tmp/project", - model: "project/custom", - thinkingOptionId: "deep", - }), - ).resolves.toEqual([]); - await expect( - manager.validateAgentConfiguration({ provider: "opencode", model: "project/custom" }), - ).resolves.toEqual([ - { - path: ["model"], - message: "Model 'project/custom' is not available for provider 'opencode'", - }, - ]); - } finally { - manager.destroy(); - } - }); - test("reports an unavailable Hub agent provider at the authored provider field", async () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); try { diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 3ddedb1612..7e728713be 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -498,7 +498,6 @@ export class ProviderSnapshotManager { const provider = await this.getProvider({ provider: input.provider, - cwd: input.cwd, wait: true, }); if (!provider.enabled) { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index c9e18d1aab..59990f817f 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1017,7 +1017,6 @@ export async function createPaseoDaemon( clients: initialAgentManagerState.clients, providerDefinitions: initialAgentManagerState.providerDefinitions, registry: agentStorage, - validateAgentSelection: (input) => providerSnapshotManager.validateAgentConfiguration(input), appendSystemPrompt: config.appendSystemPrompt, onWorkspaceStateMayHaveChanged: ({ cwd }) => { workspaceGitService.onWorkspaceStateMayHaveChanged(cwd); diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index a6dd414650..952bb0775b 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1942,16 +1942,16 @@ export default function contribute(client: PluginClientContext) { `useComposer()` returns the composer the item sits in: -| Field or command | Meaning | -| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `context` | `"agent"` for a running agent, `"draft"` for a new agent in a workspace, `"new-workspace"` for the New workspace screen. | -| `prompt`, `setPrompt(text)`, `insertText(text)` | The prompt. `insertText` replaces the selection or inserts at the cursor. | -| `attachments`, `addAttachment`, `removeAttachment` | Items from [your own attachment sources](#add-a-composer-attachment-source). Attachments from other plugins and Paseo's own are not listed. | -| `agent.provider`, `model`, `mode`, `thinking` | What the agent runs with, or what the draft will create. | -| `agent.options` | The provider options in effect, defaults included. In a draft: the options the agent will be created with, including your changes. | -| `agent.metadata` | The agent's [metadata](/docs/plugins/hooks#metadata). In New workspace, the first agent's. | -| `setProvider`, `setModel`, `setMode`, `setThinking` | Return promises. `setProvider` resets the model like the provider picker. It rejects on `context: "agent"`, which keeps its provider, and for a provider the host does not offer. `setModel` rejects for a model the provider's loaded catalog does not list. | -| `setOptions(patch)`, `setMetadata(patch)` | Top-level keys replace; `null` removes the key. Metadata set in a draft is sent when the agent is created and cleared once the prompt is sent. | +| Field or command | Meaning | +| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `context` | `"agent"` for a running agent, `"draft"` for a new agent in a workspace, `"new-workspace"` for the New workspace screen. | +| `prompt`, `setPrompt(text)`, `insertText(text)` | The prompt. `insertText` replaces the selection or inserts at the cursor. | +| `attachments`, `addAttachment`, `removeAttachment` | Items from [your own attachment sources](#add-a-composer-attachment-source). Attachments from other plugins and Paseo's own are not listed. | +| `agent.provider`, `model`, `mode`, `thinking` | What the agent runs with, or what the draft will create. | +| `agent.options` | The provider options in effect, defaults included. In a draft: the options the agent will be created with, including your changes. | +| `agent.metadata` | The agent's [metadata](/docs/plugins/hooks#metadata). In New workspace, the first agent's. | +| `setProvider`, `setModel`, `setMode`, `setThinking` | Return promises. `setProvider` resets the model like the provider picker. It rejects on `context: "agent"`, which keeps its provider, and for a provider the host does not offer. `setModel` and `setThinking` pass the value on as given; on a running agent the provider's refusal rejects the promise. | +| `setOptions(patch)`, `setMetadata(patch)` | Top-level keys replace; `null` removes the key. Metadata set in a draft is sent when the agent is created and cleared once the prompt is sent. | The same call does the same thing in every composer; a plugin does not choose a composer's implementation. `setMetadata` on a running agent needs a host that supports metadata. On an older @@ -2043,8 +2043,8 @@ you reach from outside: is in the input when the user returns. `setPrompt` replaces the saved prompt the same way. - A running agent's composer is never locked. - `setProvider` rejects, as it does inside the agent's own composer. -- `setModel` and `setThinking` reject, with the daemon's message, for a value the provider does not - list. Pass an id the provider's catalog lists. `model` and `thinking` read what the model picker +- `setModel` and `setThinking` send the value to the provider unchecked. A value the provider refuses + rejects with the provider's message. `model` and `thinking` read what the model picker shows at that moment: until the provider's catalog loads that is the id the agent reports or was created with, which may be an alias, and once it loads the catalog's own id. - Drafts and the New workspace screen are not addressable by reference. Only an item inside one diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 8eb6ce07d2..0f5d125fd8 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -1642,16 +1642,16 @@ export default function contribute(client: PluginClientContext) { `useComposer()` 返回该项所在的 composer: -| 字段或命令 | 含义 | -| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `context` | `"agent"` 表示运行中的 Agent,`"draft"` 表示工作区中的新 Agent,`"new-workspace"` 表示新建工作区界面。 | -| `prompt`、`setPrompt(text)`、`insertText(text)` | 提示词。`insertText` 会替换选区,或在光标处插入。 | -| `attachments`、`addAttachment`、`removeAttachment` | 来自[你自己的附件来源](#add-a-composer-attachment-source)的项目。其他插件和 Paseo 自身的附件不会列出。 | -| `agent.provider`、`model`、`mode`、`thinking` | Agent 当前使用的配置,或草稿将要创建的配置。 | -| `agent.options` | 当前生效的 Provider 选项,包含默认值。在草稿中:创建 Agent 时将使用的选项,包含你所做的修改。 | -| `agent.metadata` | Agent 的[元数据](/docs/plugins/hooks#metadata)。在新建工作区中是第一个 Agent 的元数据。 | -| `setProvider`、`setModel`、`setMode`、`setThinking` | 返回 Promise。`setProvider` 会像 Provider 选择器一样重置模型。在 `context: "agent"` 下(运行中的 Agent 保持其 Provider)以及主机不提供该 Provider 时会被拒绝。Provider 的模型目录已加载但未列出该模型时,`setModel` 会被拒绝。 | -| `setOptions(patch)`、`setMetadata(patch)` | 顶层键替换,`null` 删除该键。草稿中设置的元数据在创建 Agent 时发送,发送提示词后清空。 | +| 字段或命令 | 含义 | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `context` | `"agent"` 表示运行中的 Agent,`"draft"` 表示工作区中的新 Agent,`"new-workspace"` 表示新建工作区界面。 | +| `prompt`、`setPrompt(text)`、`insertText(text)` | 提示词。`insertText` 会替换选区,或在光标处插入。 | +| `attachments`、`addAttachment`、`removeAttachment` | 来自[你自己的附件来源](#add-a-composer-attachment-source)的项目。其他插件和 Paseo 自身的附件不会列出。 | +| `agent.provider`、`model`、`mode`、`thinking` | Agent 当前使用的配置,或草稿将要创建的配置。 | +| `agent.options` | 当前生效的 Provider 选项,包含默认值。在草稿中:创建 Agent 时将使用的选项,包含你所做的修改。 | +| `agent.metadata` | Agent 的[元数据](/docs/plugins/hooks#metadata)。在新建工作区中是第一个 Agent 的元数据。 | +| `setProvider`、`setModel`、`setMode`、`setThinking` | 返回 Promise。`setProvider` 会像 Provider 选择器一样重置模型。在 `context: "agent"` 下(运行中的 Agent 保持其 Provider)以及主机不提供该 Provider 时会被拒绝。`setModel` 和 `setThinking` 原样传递该值;对运行中的 Agent,Provider 拒绝时 Promise 会被拒绝。 | +| `setOptions(patch)`、`setMetadata(patch)` | 顶层键替换,`null` 删除该键。草稿中设置的元数据在创建 Agent 时发送,发送提示词后清空。 | 同一个调用在每个 composer 中行为一致,插件不需要选择 composer 的实现。在运行中的 Agent 上调用 `setMetadata` 需要支持元数据的主机;主机过旧时,Promise 会以 “Update the host to use metadata.” 拒绝。 @@ -1716,7 +1716,7 @@ export default function contribute(client: PluginClientContext) { - Agent 的 composer 在屏幕上时,`insertText` 在光标处插入。不在屏幕上时(例如用户在另一个工作区),它会追加到该 Agent 已保存的提示词,用户回来时文本就在输入框里。`setPrompt` 以同样的方式替换已保存的提示词。 - 运行中 Agent 的 composer 从不锁定。 - `setProvider` 会被拒绝,与在 Agent 自己的 composer 内一样。 -- 对于 Provider 未列出的值,`setModel` 和 `setThinking` 会以 daemon 的消息拒绝。请传入 Provider 目录中列出的 id。`model` 和 `thinking` 读取的是模型选择器此刻显示的值:在 Provider 的模型目录加载完成之前,是 Agent 上报或创建时使用的 id(可能是别名),加载完成后是目录中的 id。 +- `setModel` 和 `setThinking` 不加检查地把值发给 Provider。Provider 拒绝的值会以 Provider 的消息拒绝。`model` 和 `thinking` 读取的是模型选择器此刻显示的值:在 Provider 的模型目录加载完成之前,是 Agent 上报或创建时使用的 id(可能是别名),加载完成后是目录中的 id。 - 草稿和新建工作区界面不能按引用访问,只有其中的项能通过 `useComposer()` 访问其 composer。 ## 新建工作区项 {#new-workspace-items}