mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
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:
+5
-6
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user