mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 23:44:41 +00:00
feat(app): the client and app work on options and controls
The agent snapshot's `options` are the state and `controls` only draw them. Running and closed agents draw from the snapshot; drafts and profile forms ask `provider.controls.list.request` with their `providerOptions`, so aliases such as a legacy Fast profile's `service_tier: "fast"` are resolved once, by the daemon, and the draft keeps Fast highlighted. A draft adds only the user's edits and each provider's last-used control values; applying a profile seeds its non-control options (e.g. `sandbox_mode`) into the draft, and a profile form saves them back instead of pruning to the controls it can draw. A daemon without `server_info.features.providerOptionControls` still sends `features` and `featureValues`. `LegacyProviderOptions` in the client package is the one place that translates snapshots, draft listings, create and apply requests, and daemon-config profiles, so the app never branches on it. Persisted preferences and tab layouts rename `featureValues` through tagged COMPAT preprocessors. The features/controls bridge moves from the server into the protocol package so the client can reuse it.
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
import { controlsFromFeatures, optionsFromFeatures } from "@getpaseo/protocol/option-controls";
|
||||
import type {
|
||||
AgentProfile,
|
||||
AgentSnapshotPayload,
|
||||
MutableDaemonConfig,
|
||||
ListProviderFeaturesResponseMessage,
|
||||
ProviderControlsListResponseMessage,
|
||||
ServerInfoStatusPayload,
|
||||
SessionInboundMessage,
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
/**
|
||||
* COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01 once daemon floor >=
|
||||
* v0.15.0.
|
||||
*
|
||||
* A daemon without `server_info.features.providerOptionControls` sends `features` (a control with
|
||||
* its value attached) and takes composer values as `featureValues`. This is the one place that
|
||||
* turns that into the shape everyone else uses: `controls` over `options`, written through
|
||||
* `providerOptions`.
|
||||
*/
|
||||
export function usesLegacyProviderOptions(info: ServerInfoStatusPayload): boolean {
|
||||
return info.features?.providerOptionControls !== true;
|
||||
}
|
||||
|
||||
export class LegacyProviderOptions {
|
||||
/** Option keys a legacy daemon exposes as features, per provider, as last seen. */
|
||||
private readonly controlKeys = new Map<string, Set<string>>();
|
||||
|
||||
/** Gives agent snapshots `controls` and `options`, built from their `features`. */
|
||||
normalize(message: SessionOutboundMessage): SessionOutboundMessage {
|
||||
switch (message.type) {
|
||||
case "agent_update":
|
||||
if (message.payload.kind !== "upsert") return message;
|
||||
return {
|
||||
...message,
|
||||
payload: { ...message.payload, agent: this.snapshot(message.payload.agent) },
|
||||
};
|
||||
case "agent_status":
|
||||
return {
|
||||
...message,
|
||||
payload: { ...message.payload, info: this.snapshot(message.payload.info) },
|
||||
};
|
||||
case "fetch_agents_response":
|
||||
return {
|
||||
...message,
|
||||
payload: {
|
||||
...message.payload,
|
||||
entries: message.payload.entries.map((entry) => ({
|
||||
...entry,
|
||||
agent: this.snapshot(entry.agent),
|
||||
})),
|
||||
},
|
||||
};
|
||||
case "fetch_agent_response":
|
||||
case "agent.create.response":
|
||||
case "cancel_agent_response":
|
||||
return {
|
||||
...message,
|
||||
payload: {
|
||||
...message.payload,
|
||||
agent: message.payload.agent && this.snapshot(message.payload.agent),
|
||||
},
|
||||
} as SessionOutboundMessage;
|
||||
case "workspace.create.response":
|
||||
return {
|
||||
...message,
|
||||
payload: {
|
||||
...message.payload,
|
||||
...(message.payload.agent ? { agent: this.snapshot(message.payload.agent) } : {}),
|
||||
},
|
||||
};
|
||||
case "wait_for_finish_response":
|
||||
return {
|
||||
...message,
|
||||
payload: {
|
||||
...message.payload,
|
||||
final: message.payload.final && this.snapshot(message.payload.final),
|
||||
},
|
||||
};
|
||||
case "get_daemon_config_response":
|
||||
case "set_daemon_config_response":
|
||||
return {
|
||||
...message,
|
||||
payload: { ...message.payload, config: this.daemonConfig(message.payload.config) },
|
||||
};
|
||||
case "status":
|
||||
if (message.payload.status !== "daemon_config_changed") return message;
|
||||
return {
|
||||
...message,
|
||||
payload: {
|
||||
...message.payload,
|
||||
config: this.daemonConfig(message.payload.config as MutableDaemonConfig),
|
||||
},
|
||||
} as SessionOutboundMessage;
|
||||
case "list_provider_features_response":
|
||||
// Remember the keys even when the caller used the legacy listing directly.
|
||||
this.remember(message.payload.provider, message.payload.features);
|
||||
return message;
|
||||
default:
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends control keys as `featureValues`, which is where a legacy daemon reads them. */
|
||||
prepare(message: SessionInboundMessage): SessionInboundMessage {
|
||||
switch (message.type) {
|
||||
case "create_agent_request":
|
||||
case "agent.create.request":
|
||||
return { ...message, config: this.config(message.config) };
|
||||
case "workspace.create.request":
|
||||
if (!message.agent) return message;
|
||||
return {
|
||||
...message,
|
||||
agent: { ...message.agent, config: this.config(message.agent.config) },
|
||||
};
|
||||
case "agent.config.apply.request": {
|
||||
const { options, ...config } = message.config;
|
||||
if (!options) return message;
|
||||
return {
|
||||
...message,
|
||||
config: { ...config, featureValues: { ...config.featureValues, ...options } },
|
||||
};
|
||||
}
|
||||
case "list_commands_request":
|
||||
if (!message.draftConfig) return message;
|
||||
return { ...message, draftConfig: this.config(message.draftConfig) };
|
||||
case "set_daemon_config_request":
|
||||
if (!message.config.agentProfiles) return message;
|
||||
return {
|
||||
...message,
|
||||
config: {
|
||||
...message.config,
|
||||
agentProfiles: message.config.agentProfiles.map(profileWithFeatureValues),
|
||||
},
|
||||
};
|
||||
default:
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/** The legacy answer to a controls listing, in the shape of the current response. */
|
||||
controlsResponse(
|
||||
legacy: ListProviderFeaturesResponseMessage["payload"],
|
||||
): ProviderControlsListResponseMessage["payload"] {
|
||||
const { features, ...rest } = legacy;
|
||||
this.remember(legacy.provider, features);
|
||||
return {
|
||||
...rest,
|
||||
...(features
|
||||
? { controls: controlsFromFeatures(features), options: optionsFromFeatures(features) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private daemonConfig(config: MutableDaemonConfig): MutableDaemonConfig {
|
||||
if (!config.agentProfiles) return config;
|
||||
return { ...config, agentProfiles: config.agentProfiles.map(profileWithOptions) };
|
||||
}
|
||||
|
||||
private readonly snapshot = (agent: AgentSnapshotPayload): AgentSnapshotPayload => {
|
||||
if (agent.controls || !agent.features) return agent;
|
||||
this.remember(agent.provider, agent.features);
|
||||
return {
|
||||
...agent,
|
||||
controls: controlsFromFeatures(agent.features),
|
||||
options: optionsFromFeatures(agent.features),
|
||||
};
|
||||
};
|
||||
|
||||
private remember(provider: string, features: Parameters<typeof controlsFromFeatures>[0]): void {
|
||||
if (features) this.controlKeys.set(provider, new Set(features.map((feature) => feature.id)));
|
||||
}
|
||||
|
||||
private config<
|
||||
T extends {
|
||||
provider?: string;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
featureValues?: Record<string, unknown>;
|
||||
},
|
||||
>(config: T): T {
|
||||
if (!config.provider || !config.providerOptions) return config;
|
||||
const { featureValues, providerOptions } = this.split(config.provider, config.providerOptions);
|
||||
const { providerOptions: _options, ...rest } = config;
|
||||
return {
|
||||
...(rest as T),
|
||||
...(Object.keys(providerOptions).length > 0 ? { providerOptions } : {}),
|
||||
...(Object.keys(featureValues).length > 0
|
||||
? { featureValues: { ...config.featureValues, ...featureValues } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private split(
|
||||
provider: string,
|
||||
options: Record<string, unknown>,
|
||||
): { featureValues: Record<string, unknown>; providerOptions: Record<string, unknown> } {
|
||||
const keys = this.controlKeys.get(provider);
|
||||
const featureValues: Record<string, unknown> = {};
|
||||
const providerOptions: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(options)) {
|
||||
(keys?.has(key) ? featureValues : providerOptions)[key] = value;
|
||||
}
|
||||
return { featureValues, providerOptions };
|
||||
}
|
||||
}
|
||||
|
||||
/** A legacy daemon stores a profile's options under `featureValues`. */
|
||||
function profileWithOptions(profile: AgentProfile): AgentProfile {
|
||||
if (profile.options || !profile.featureValues) return profile;
|
||||
const { featureValues, ...rest } = profile;
|
||||
return { ...rest, options: featureValues };
|
||||
}
|
||||
|
||||
function profileWithFeatureValues(profile: AgentProfile): AgentProfile {
|
||||
if (!profile.options) return profile;
|
||||
const { options, ...rest } = profile;
|
||||
return { ...rest, featureValues: options };
|
||||
}
|
||||
@@ -8926,3 +8926,230 @@ test("routed channels inherit an origin without drain support", async () => {
|
||||
expect(route.supportsDrain).toBe(false);
|
||||
route.close();
|
||||
});
|
||||
|
||||
describe("provider options across daemon versions", () => {
|
||||
const agentSnapshot = {
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
createdAt: "2026-04-30T00:00:00.000Z",
|
||||
updatedAt: "2026-04-30T00:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
status: "idle",
|
||||
capabilities: {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
};
|
||||
const speedFeature = {
|
||||
type: "select",
|
||||
id: "service_tier",
|
||||
label: "Speed",
|
||||
value: "priority",
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "priority", label: "Fast" },
|
||||
],
|
||||
};
|
||||
|
||||
async function connect(features: Record<string, boolean>) {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "provider_options",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connecting = client.connect();
|
||||
mock.triggerOpen({ features });
|
||||
await connecting;
|
||||
const sentMessages = () =>
|
||||
mock.sent.map(
|
||||
(frame) => (JSON.parse(frame as string) as { message: Record<string, unknown> }).message,
|
||||
);
|
||||
return { mock, client, sentMessages };
|
||||
}
|
||||
|
||||
test("a legacy daemon's features arrive as controls over options", async () => {
|
||||
const { mock, client } = await connect({});
|
||||
const received: Array<{ controls?: unknown; options?: unknown }> = [];
|
||||
client.on("agent_update", (message) => {
|
||||
if (message.payload.kind === "upsert") received.push(message.payload.agent);
|
||||
});
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "agent_update",
|
||||
payload: { kind: "upsert", agent: { ...agentSnapshot, features: [speedFeature] } },
|
||||
}),
|
||||
);
|
||||
expect(received).toEqual([
|
||||
expect.objectContaining({
|
||||
options: { service_tier: "priority" },
|
||||
controls: [
|
||||
expect.objectContaining({
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("a legacy daemon takes option writes as feature writes", async () => {
|
||||
const { client, sentMessages, mock } = await connect({});
|
||||
const write = client.setAgentOptions("agent-1", { service_tier: "default" });
|
||||
const [request] = sentMessages();
|
||||
expect(request).toMatchObject({
|
||||
type: "set_agent_feature_request",
|
||||
agentId: "agent-1",
|
||||
featureId: "service_tier",
|
||||
value: "default",
|
||||
});
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "set_agent_feature_response",
|
||||
payload: { requestId: request.requestId, agentId: "agent-1", accepted: true, error: null },
|
||||
}),
|
||||
);
|
||||
await write;
|
||||
});
|
||||
|
||||
test("a current daemon takes option writes as one options patch", async () => {
|
||||
const { client, sentMessages, mock } = await connect({ providerOptionControls: true });
|
||||
const write = client.setAgentOptions("agent-1", { service_tier: null, sandbox_mode: "x" });
|
||||
const [request] = sentMessages();
|
||||
expect(request).toMatchObject({
|
||||
type: "agent.config.apply.request",
|
||||
agentId: "agent-1",
|
||||
config: { options: { service_tier: null, sandbox_mode: "x" } },
|
||||
});
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "agent.config.apply.response",
|
||||
payload: { requestId: request.requestId, agentId: "agent-1", accepted: true, error: null },
|
||||
}),
|
||||
);
|
||||
await write;
|
||||
});
|
||||
|
||||
test("a legacy draft listing answers with controls and options", async () => {
|
||||
const { client, sentMessages, mock } = await connect({});
|
||||
const listing = client.listProviderControls({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
providerOptions: { service_tier: "default" },
|
||||
});
|
||||
const [request] = sentMessages();
|
||||
expect(request).toMatchObject({
|
||||
type: "list_provider_features_request",
|
||||
draftConfig: { provider: "codex", featureValues: { service_tier: "default" } },
|
||||
});
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "list_provider_features_response",
|
||||
payload: {
|
||||
provider: "codex",
|
||||
features: [{ ...speedFeature, value: "default" }],
|
||||
error: null,
|
||||
fetchedAt: "2026-04-30T00:00:00.000Z",
|
||||
requestId: request.requestId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(listing).resolves.toMatchObject({
|
||||
provider: "codex",
|
||||
options: { service_tier: "default" },
|
||||
controls: [expect.objectContaining({ key: "service_tier" })],
|
||||
});
|
||||
});
|
||||
|
||||
test("a legacy daemon's profiles are read and saved by their options", async () => {
|
||||
const { client, sentMessages, mock } = await connect({});
|
||||
const reading = client.getDaemonConfig();
|
||||
const [read] = sentMessages();
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "get_daemon_config_response",
|
||||
payload: {
|
||||
requestId: read.requestId,
|
||||
config: {
|
||||
mcp: { injectIntoAgents: true },
|
||||
agentProfiles: [
|
||||
{
|
||||
id: "p1",
|
||||
name: "Fast",
|
||||
provider: "codex",
|
||||
featureValues: { service_tier: "fast" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { config } = await reading;
|
||||
expect(config.agentProfiles?.[0]).toMatchObject({ options: { service_tier: "fast" } });
|
||||
expect(config.agentProfiles?.[0]).not.toHaveProperty("featureValues");
|
||||
|
||||
void client.patchDaemonConfig({ agentProfiles: config.agentProfiles }).catch(() => {});
|
||||
const written = sentMessages().find((message) => message.type === "set_daemon_config_request");
|
||||
expect(written).toMatchObject({
|
||||
config: { agentProfiles: [{ featureValues: { service_tier: "fast" } }] },
|
||||
});
|
||||
expect(written).not.toMatchObject({ config: { agentProfiles: [{ options: {} }] } });
|
||||
});
|
||||
|
||||
test("a legacy daemon applies a profile's options as feature values", async () => {
|
||||
const { client, sentMessages } = await connect({});
|
||||
void client.applyAgentConfig("agent-1", { options: { service_tier: "fast" } }).catch(() => {});
|
||||
const [request] = sentMessages();
|
||||
expect(request).toMatchObject({
|
||||
type: "agent.config.apply.request",
|
||||
config: { featureValues: { service_tier: "fast" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("a legacy daemon creates an agent with control keys as feature values", async () => {
|
||||
const { client, sentMessages, mock } = await connect({});
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "agent_update",
|
||||
payload: { kind: "upsert", agent: { ...agentSnapshot, features: [speedFeature] } },
|
||||
}),
|
||||
);
|
||||
void client
|
||||
.createAgent({
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
providerOptions: { service_tier: "priority", sandbox_mode: "read-only" },
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
const [request] = sentMessages();
|
||||
expect(request.config).toMatchObject({
|
||||
featureValues: { service_tier: "priority" },
|
||||
providerOptions: { sandbox_mode: "read-only" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,6 +102,7 @@ import type {
|
||||
WorkspaceSetupStatusResponseMessage,
|
||||
ListCommandsResponse,
|
||||
ListProviderFeaturesResponseMessage,
|
||||
ProviderControlsListResponseMessage,
|
||||
ListProviderModelsResponseMessage,
|
||||
ListProviderModesResponseMessage,
|
||||
ListAvailableProvidersResponse,
|
||||
@@ -189,6 +190,10 @@ import {
|
||||
normalizeProviderSnapshotUpdateMessage,
|
||||
normalizeProvidersSnapshotPayload,
|
||||
} from "./compat/normalize-provider-models.js";
|
||||
import {
|
||||
LegacyProviderOptions,
|
||||
usesLegacyProviderOptions,
|
||||
} from "./compat/legacy-provider-options.js";
|
||||
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
|
||||
import type {
|
||||
BrowserAutomationExecuteRequest,
|
||||
@@ -604,6 +609,7 @@ export interface FileUploadInput {
|
||||
export type FileUploadResult = FileUploadResponse["payload"];
|
||||
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
|
||||
type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"];
|
||||
export type ListProviderControlsPayload = ProviderControlsListResponseMessage["payload"];
|
||||
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
||||
type ListProviderModesPayload = ListProviderModesResponseMessage["payload"];
|
||||
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
|
||||
@@ -630,7 +636,7 @@ type WriteProjectConfigPayload = Extract<
|
||||
type ListCommandsPayload = ListCommandsResponse["payload"];
|
||||
type ListCommandsDraftConfig = Pick<
|
||||
AgentSessionConfig,
|
||||
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues"
|
||||
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues" | "providerOptions"
|
||||
>;
|
||||
export interface WriteProjectConfigInput {
|
||||
repoRoot: string;
|
||||
@@ -2103,17 +2109,13 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
private sendSessionPayload(payload: SessionInboundMessage): Promise<void> {
|
||||
return this.sendJsonMessage(
|
||||
"session",
|
||||
payload.type,
|
||||
{ type: "session", message: payload },
|
||||
() => {
|
||||
if (!("requestId" in payload) || typeof payload.requestId !== "string") return;
|
||||
for (const waiter of this.waiters) {
|
||||
if (waiter.requestId === payload.requestId) waiter.submitted = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
const wire = this.legacyProviderOptions()?.prepare(payload) ?? payload;
|
||||
return this.sendJsonMessage("session", payload.type, { type: "session", message: wire }, () => {
|
||||
if (!("requestId" in payload) || typeof payload.requestId !== "string") return;
|
||||
for (const waiter of this.waiters) {
|
||||
if (waiter.requestId === payload.requestId) waiter.submitted = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private sendTransportFrame(
|
||||
@@ -4132,6 +4134,24 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the agent's own provider options: top-level keys replace, `null` removes the key.
|
||||
* A daemon without `providerOptionControls` takes each key as a feature write.
|
||||
*/
|
||||
async setAgentOptions(
|
||||
agentId: string,
|
||||
patch: Record<string, unknown>,
|
||||
): Promise<AgentProviderNotice | null> {
|
||||
if (this.legacyProviderOptions()) {
|
||||
// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01.
|
||||
for (const [featureId, value] of Object.entries(patch)) {
|
||||
await this.setAgentFeature(agentId, featureId, value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return this.applyAgentConfig(agentId, { options: patch });
|
||||
}
|
||||
|
||||
async setAgentThinkingOption(
|
||||
agentId: string,
|
||||
thinkingOptionId: string | null,
|
||||
@@ -5561,6 +5581,36 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The controls a draft agent would show and the options it would run with: `config.json`
|
||||
* defaults merged with the draft's own `providerOptions`, resolved by the daemon.
|
||||
*/
|
||||
async listProviderControls(
|
||||
draftConfig: ListCommandsDraftConfig,
|
||||
options?: { requestId?: string },
|
||||
): Promise<ListProviderControlsPayload> {
|
||||
const legacy = this.legacyProviderOptions();
|
||||
if (legacy) {
|
||||
const { providerOptions, ...rest } = draftConfig;
|
||||
const payload = await this.listProviderFeatures(
|
||||
{
|
||||
...rest,
|
||||
// A legacy daemon reads the options it exposes as features, whatever the others are.
|
||||
...(providerOptions ? { featureValues: providerOptions } : {}),
|
||||
},
|
||||
options,
|
||||
);
|
||||
return legacy.controlsResponse(payload);
|
||||
}
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId: options?.requestId,
|
||||
message: { type: "provider.controls.list.request", draftConfig },
|
||||
responseType: "provider.controls.list.response",
|
||||
timeout: 90000,
|
||||
});
|
||||
}
|
||||
|
||||
/** @deprecated Use `listProviderControls`. */
|
||||
async listProviderFeatures(
|
||||
draftConfig: ListCommandsDraftConfig,
|
||||
options?: { requestId?: string },
|
||||
@@ -6851,6 +6901,14 @@ export class DaemonClient {
|
||||
return requestId ?? crypto.randomUUID();
|
||||
}
|
||||
|
||||
private readonly legacyOptions = new LegacyProviderOptions();
|
||||
|
||||
/** The translation for a daemon without `providerOptionControls`; null for a current one. */
|
||||
private legacyProviderOptions(): LegacyProviderOptions | null {
|
||||
const info = this.lastServerInfoMessage;
|
||||
return info && usesLegacyProviderOptions(info) ? this.legacyOptions : null;
|
||||
}
|
||||
|
||||
getLastServerInfoMessage(): ServerInfoStatusPayload | null {
|
||||
return this.lastServerInfoMessage;
|
||||
}
|
||||
@@ -7433,6 +7491,7 @@ export class DaemonClient {
|
||||
|
||||
private handleSessionMessage(msg: SessionOutboundMessage): void {
|
||||
msg = this.owned.normalize(msg);
|
||||
msg = this.legacyProviderOptions()?.normalize(msg) ?? msg;
|
||||
if (
|
||||
msg.type === "providers_snapshot_update" &&
|
||||
this.config.providerSnapshots !== "wire" &&
|
||||
|
||||
@@ -1715,8 +1715,8 @@ test("agent config maps provider/model and provider-native options to the daemon
|
||||
model: "gpt-5.4",
|
||||
modeId: "full-access",
|
||||
thinkingOptionId: "high",
|
||||
featureValues: { webSearch: true },
|
||||
providerOptions: {
|
||||
webSearch: true,
|
||||
approval_policy: "never",
|
||||
sandbox_mode: "workspace-write",
|
||||
sandbox_workspace_write: {
|
||||
|
||||
@@ -20,6 +20,8 @@ import type {
|
||||
ListCommandsResponse,
|
||||
ListProviderFeaturesRequestMessage,
|
||||
ListProviderFeaturesResponseMessage,
|
||||
ProviderControlsListRequestMessage,
|
||||
ProviderControlsListResponseMessage,
|
||||
ListProviderModelsResponseMessage,
|
||||
ProjectListRequestMessage,
|
||||
ProjectListResponseMessage,
|
||||
@@ -239,8 +241,12 @@ export interface PaseoAgentConfig {
|
||||
provider: string;
|
||||
modeId?: PaseoAgentSessionConfig["modeId"];
|
||||
thinkingOptionId?: PaseoAgentSessionConfig["thinkingOptionId"];
|
||||
/** @deprecated Put these in `options`; they are folded into it on send. */
|
||||
featureValues?: PaseoProviderFeatureValues;
|
||||
/** JSON-safe provider-native settings, validated by the selected provider. */
|
||||
/**
|
||||
* JSON-safe provider settings, validated by the selected provider. They include the settings the
|
||||
* provider offers a composer control for, such as `service_tier` or `fast_mode`.
|
||||
*/
|
||||
options?: PaseoAgentSessionConfig["providerOptions"];
|
||||
systemPrompt?: PaseoAgentSessionConfig["systemPrompt"];
|
||||
toolPolicy?: PaseoAgentSessionConfig["toolPolicy"];
|
||||
@@ -368,7 +374,12 @@ export interface PaseoAgentHandle {
|
||||
readonly activeTurn: NonNullable<PaseoAgent["activeTurn"]> | null;
|
||||
readonly lastUsage: NonNullable<PaseoAgent["lastUsage"]> | null;
|
||||
readonly lastError: NonNullable<PaseoAgent["lastError"]> | null;
|
||||
/** @deprecated Use `controls` and `options`. */
|
||||
readonly features: NonNullable<PaseoAgent["features"]> | null;
|
||||
/** The provider options the agent runs with: `config.json` defaults merged with its own. */
|
||||
readonly options: NonNullable<PaseoAgent["options"]> | null;
|
||||
/** How the provider wants its options drawn. Controls hold no value; read `options`. */
|
||||
readonly controls: NonNullable<PaseoAgent["controls"]> | null;
|
||||
readonly runtimeInfo: NonNullable<PaseoAgent["runtimeInfo"]> | null;
|
||||
readonly archivedAt: NonNullable<PaseoAgent["archivedAt"]> | null;
|
||||
readonly timeline: PaseoAgentTimelineHandle;
|
||||
@@ -381,6 +392,12 @@ export interface PaseoAgentHandle {
|
||||
reload(options?: { requestId?: string }): Promise<PaseoAgentRefetchResult | null>;
|
||||
send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
|
||||
respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise<void>;
|
||||
/**
|
||||
* Changes the agent's own options: top-level keys replace, `null` removes the key so the
|
||||
* `config.json` default (or the provider's own default) applies again. Resolves once the
|
||||
* provider has applied the patch; a provider that cannot apply a key live rejects it.
|
||||
*/
|
||||
setOptions(patch: Record<string, unknown>): Promise<void>;
|
||||
/** Sends a prompt and resolves when that turn finishes or needs attention. */
|
||||
run(text: string, options?: PaseoAgentRunOptions): Promise<PaseoAgentRunResult>;
|
||||
/** Waits for the current turn, including one started with `prompt`. */
|
||||
@@ -417,14 +434,21 @@ export interface PaseoAgentActions {
|
||||
|
||||
export type PaseoProviderModelsResult = ListProviderModelsResponseMessage["payload"];
|
||||
export type PaseoProviderModesResult = ListProviderModesResponseMessage["payload"];
|
||||
type PaseoProviderFeaturesDraft = ListProviderFeaturesRequestMessage["draftConfig"];
|
||||
export interface PaseoProviderFeaturesInput extends Omit<
|
||||
PaseoProviderFeaturesDraft,
|
||||
type PaseoProviderControlsDraft = ProviderControlsListRequestMessage["draftConfig"];
|
||||
export interface PaseoProviderControlsInput extends Omit<
|
||||
PaseoProviderControlsDraft,
|
||||
"provider" | "model"
|
||||
> {
|
||||
/** Provider and model in `provider/model` format. */
|
||||
provider: string;
|
||||
}
|
||||
export type PaseoProviderControlsResult = ProviderControlsListResponseMessage["payload"];
|
||||
/** @deprecated Use `PaseoProviderControlsInput`. */
|
||||
export type PaseoProviderFeaturesInput = Omit<
|
||||
ListProviderFeaturesRequestMessage["draftConfig"],
|
||||
"provider" | "model"
|
||||
> & { provider: string };
|
||||
/** @deprecated Use `PaseoProviderControlsResult`. */
|
||||
export type PaseoProviderFeaturesResult = ListProviderFeaturesResponseMessage["payload"];
|
||||
export type PaseoProviderAvailabilityResult = ListAvailableProvidersResponse["payload"];
|
||||
export type PaseoProviderSnapshotResult = GetProvidersSnapshotResponseMessage["payload"];
|
||||
@@ -463,6 +487,15 @@ export interface PaseoProviderActions {
|
||||
provider: PaseoAgentProvider,
|
||||
options?: PaseoProviderListOptions,
|
||||
): Promise<PaseoProviderModesResult>;
|
||||
/**
|
||||
* The controls a draft agent would show and the options it would run with, resolved by the
|
||||
* daemon from `config.json` defaults and the draft's own `providerOptions`.
|
||||
*/
|
||||
listControls(
|
||||
draftConfig: PaseoProviderControlsInput,
|
||||
options?: { requestId?: string },
|
||||
): Promise<PaseoProviderControlsResult>;
|
||||
/** @deprecated Use `listControls`. */
|
||||
listFeatures(
|
||||
draftConfig: PaseoProviderFeaturesInput,
|
||||
options?: { requestId?: string },
|
||||
@@ -547,7 +580,14 @@ function toDaemonAgentCreateOptions(
|
||||
serverId?: string,
|
||||
): CreateAgentRequestOptions {
|
||||
const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options;
|
||||
const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig;
|
||||
const {
|
||||
provider: providerModel,
|
||||
options: ownOptions,
|
||||
featureValues,
|
||||
...runtimeConfig
|
||||
} = agentConfig;
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. An explicit option wins.
|
||||
const providerOptions = featureValues ? { ...featureValues, ...ownOptions } : ownOptions;
|
||||
const { provider, model } = parseProviderModel(providerModel);
|
||||
const relationship = parent ? encodeParent(parent, serverId) : {};
|
||||
return {
|
||||
@@ -782,6 +822,10 @@ export function createPaseoApi(
|
||||
const { provider, model } = parseProviderModel(providerModel);
|
||||
return daemonClient.listProviderFeatures({ ...draftConfig, provider, model }, options);
|
||||
},
|
||||
listControls: ({ provider: providerModel, ...draftConfig }, options) => {
|
||||
const { provider, model } = parseProviderModel(providerModel);
|
||||
return daemonClient.listProviderControls({ ...draftConfig, provider, model }, options);
|
||||
},
|
||||
listAvailable: (options) => daemonClient.listAvailableProviders(options),
|
||||
snapshot: (options) => daemonClient.getProvidersSnapshot(options),
|
||||
waitForReady: (options) =>
|
||||
@@ -963,6 +1007,12 @@ function createAgentHandleFactory(
|
||||
get features() {
|
||||
return current?.features ?? null;
|
||||
},
|
||||
get options() {
|
||||
return current?.options ?? null;
|
||||
},
|
||||
get controls() {
|
||||
return current?.controls ?? null;
|
||||
},
|
||||
get runtimeInfo() {
|
||||
return current?.runtimeInfo ?? null;
|
||||
},
|
||||
@@ -984,6 +1034,9 @@ function createAgentHandleFactory(
|
||||
send: async (text, options) => {
|
||||
await daemonClient.sendAgentMessage(id, text, options);
|
||||
},
|
||||
setOptions: async (patch) => {
|
||||
await daemonClient.setAgentOptions(id, patch);
|
||||
},
|
||||
respondToPermission: async ({ requestId, response }) => {
|
||||
await daemonClient.respondToPermission(id, requestId, response);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user