fix(plugins): a model or thinking change, an unreported option key, and a draft model reach the provider unchecked

Paseo no longer checks these in front of the provider. A running agent's model or thinking
change no longer waits for the catalog or checks it, a plugin provider is sent every option
key and refuses what it cannot apply, and a draft setModel keeps the id as the model picker
does.
This commit is contained in:
Mohamed Boudra
2026-11-06 04:48:23 +01:00
parent 7834559915
commit 746165c300
20 changed files with 99 additions and 373 deletions
+5 -6
View File
@@ -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
@@ -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 () => {
@@ -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;
@@ -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" });
});
@@ -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) => {
@@ -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],
);
@@ -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);
@@ -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(
@@ -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;
+1
View File
@@ -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<void>;
/** On a running agent, rejects with the provider's message when it refuses the model. */
setModel(modelId: string): Promise<void>;
setMode(modeId: string): Promise<void>;
setThinking(thinkingOptionId: string): Promise<void>;
@@ -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;
@@ -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<void> {
received.push(["model", modelId]);
}
async setThinkingOption(thinkingOptionId: string | null): Promise<null> {
received.push(["thinking", thinkingOptionId]);
return null;
}
}
class RecordingClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
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<string | undefined> = [];
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<AgentSession> {
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 () => {
@@ -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<HubExecutionAgentValidationIssue[]>;
providerDefinitions?: ProviderEnabledMap;
idFactory?: () => string;
registry?: AgentStorage;
@@ -839,7 +830,6 @@ export class AgentManager {
private logger: Logger;
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
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<void> {
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<void> {
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<void> {
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) {
@@ -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({});
@@ -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<Record<string, JsonValue>>,
) => Promise<ProviderRuntimeSession>,
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
private readonly givenOptions: Readonly<Record<string, JsonValue>>,
givenOptions: Readonly<Record<string, JsonValue>>,
) {
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<string, JsonValue> = {};
// 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
@@ -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 {
@@ -498,7 +498,6 @@ export class ProviderSnapshotManager {
const provider = await this.getProvider({
provider: input.provider,
cwd: input.cwd,
wait: true,
});
if (!provider.enabled) {
-1
View File
@@ -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);
+12 -12
View File
@@ -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
+11 -11
View File
@@ -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}