mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 15:12:48 +00:00
fix(app): drafts and profile forms send every option layer to the daemon
A cold create against a daemon without providerOptionControls routed control options (Claude fast_mode, Codex service_tier) as providerOptions, which that daemon does not read for them. The client now lists the provider's features before the first create or workspace create that carries options, so callers never prime a cache. Draft options moved into a plain model. Applying a profile replaces provider and options in one step, so the form's provider change that follows no longer clears a cross-provider profile's non-control options; choosing a provider by hand still starts over. The model sends its complete options (seed, last-used values once the provider says it still draws them, edits) to the daemon and the controls draw only the answer, so a remembered legacy fast is drawn as what it runs as. A toggle shows over the last answer until its own arrives. The profile form does the same: its edits go through resolution too.
This commit is contained in:
@@ -27,6 +27,11 @@ export class LegacyProviderOptions {
|
||||
/** Option keys a legacy daemon exposes as features, per provider, as last seen. */
|
||||
private readonly controlKeys = new Map<string, Set<string>>();
|
||||
|
||||
/** Whether the keys a provider exposes as features are known, so options can be routed. */
|
||||
knowsControlKeys(provider: string): boolean {
|
||||
return this.controlKeys.has(provider);
|
||||
}
|
||||
|
||||
/** Gives agent snapshots `controls` and `options`, built from their `features`. */
|
||||
normalize(message: SessionOutboundMessage): SessionOutboundMessage {
|
||||
switch (message.type) {
|
||||
|
||||
@@ -9127,6 +9127,75 @@ describe("provider options across daemon versions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("a cold legacy create lists the provider's features before routing its options", async () => {
|
||||
const { client, sentMessages, mock } = await connect({});
|
||||
void client
|
||||
.createAgent({
|
||||
config: {
|
||||
provider: "claude",
|
||||
cwd: "/repo",
|
||||
providerOptions: { fast_mode: true, sandbox_mode: "read-only" },
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
await vi.waitFor(() => expect(sentMessages()).toHaveLength(1));
|
||||
const [listing] = sentMessages();
|
||||
expect(listing).toMatchObject({
|
||||
type: "list_provider_features_request",
|
||||
draftConfig: { provider: "claude", cwd: "/repo" },
|
||||
});
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "list_provider_features_response",
|
||||
payload: {
|
||||
provider: "claude",
|
||||
features: [{ type: "toggle", id: "fast_mode", label: "Fast", value: false }],
|
||||
error: null,
|
||||
fetchedAt: "2026-04-30T00:00:00.000Z",
|
||||
requestId: listing.requestId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(sentMessages()).toHaveLength(2));
|
||||
expect(sentMessages()[1].config).toMatchObject({
|
||||
featureValues: { fast_mode: true },
|
||||
providerOptions: { sandbox_mode: "read-only" },
|
||||
});
|
||||
});
|
||||
|
||||
test("a cold legacy workspace create routes its agent's options the same way", async () => {
|
||||
const { client, sentMessages, mock } = await connect({ creationLifecycle: true });
|
||||
void client
|
||||
.createWorkspace({
|
||||
source: { kind: "directory", path: "/repo" },
|
||||
agent: {
|
||||
config: { provider: "codex", cwd: "/repo", providerOptions: { service_tier: "fast" } },
|
||||
},
|
||||
} as never)
|
||||
.catch(() => {});
|
||||
await vi.waitFor(() => expect(sentMessages()).toHaveLength(1));
|
||||
const [listing] = sentMessages();
|
||||
expect(listing).toMatchObject({ type: "list_provider_features_request" });
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "list_provider_features_response",
|
||||
payload: {
|
||||
provider: "codex",
|
||||
features: [speedFeature],
|
||||
error: null,
|
||||
fetchedAt: "2026-04-30T00:00:00.000Z",
|
||||
requestId: listing.requestId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sentTypes = () => sentMessages().map((message) => message.type);
|
||||
await vi.waitFor(() => expect(sentTypes()).toContain("workspace.create.request"));
|
||||
const created = sentMessages()[sentTypes().indexOf("workspace.create.request")];
|
||||
expect(created).toMatchObject({
|
||||
agent: { 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(
|
||||
@@ -9144,6 +9213,7 @@ describe("provider options across daemon versions", () => {
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
await vi.waitFor(() => expect(sentMessages()).toHaveLength(1));
|
||||
const [request] = sentMessages();
|
||||
expect(request.config).toMatchObject({
|
||||
featureValues: { service_tier: "priority" },
|
||||
|
||||
@@ -3269,14 +3269,39 @@ export class DaemonClient {
|
||||
);
|
||||
}
|
||||
if (options.background !== undefined) this.requireBackgroundWorkspaces();
|
||||
const result = await this.creations.createAgent({
|
||||
...options,
|
||||
config: resolveAgentConfig(options),
|
||||
});
|
||||
const config = resolveAgentConfig(options);
|
||||
// Awaited only when there is something to learn, so other creates still send synchronously.
|
||||
const learning = this.learnLegacyControlKeys(config);
|
||||
if (learning) await learning;
|
||||
const result = await this.creations.createAgent({ ...options, config });
|
||||
if (result.error || !result.agent) throw new Error(result.error ?? "Agent creation failed");
|
||||
return result.agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* A legacy daemon reads control options as `featureValues`, so routing a create's options needs
|
||||
* the provider's feature ids. Callers never prime that: the first create for a provider lists
|
||||
* them, and the answer is remembered with the snapshots and listings that also teach it.
|
||||
*/
|
||||
private learnLegacyControlKeys(config: {
|
||||
provider?: string;
|
||||
cwd?: string;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
}): Promise<void> | undefined {
|
||||
const legacy = this.legacyProviderOptions();
|
||||
const { provider, providerOptions } = config;
|
||||
if (!legacy || !provider || !providerOptions || Object.keys(providerOptions).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (legacy.knowsControlKeys(provider)) return undefined;
|
||||
return this.listFeaturesOrThrow(provider, config.cwd || "~");
|
||||
}
|
||||
|
||||
private async listFeaturesOrThrow(provider: string, cwd: string): Promise<void> {
|
||||
const payload = await this.listProviderFeatures({ provider, cwd });
|
||||
if (payload.error) throw new Error(payload.error);
|
||||
}
|
||||
|
||||
private async createLegacyAgent(
|
||||
options: CreateAgentRequestOptions,
|
||||
): Promise<AgentSnapshotPayload> {
|
||||
@@ -5028,12 +5053,13 @@ export class DaemonClient {
|
||||
if (input.background !== undefined || input.callerAgentId) this.requireBackgroundWorkspaces();
|
||||
if (input.agent?.background !== undefined)
|
||||
throw new Error("Configure background on the workspace, not its initial agent.");
|
||||
const agentConfig = input.agent ? resolveAgentConfig(input.agent) : undefined;
|
||||
const learning = agentConfig ? this.learnLegacyControlKeys(agentConfig) : undefined;
|
||||
if (learning) await learning;
|
||||
const result = await this.creations.createWorkspace({
|
||||
...input,
|
||||
requestId: resolvedRequestId,
|
||||
...(input.agent
|
||||
? { agent: { ...input.agent, config: resolveAgentConfig(input.agent) } }
|
||||
: {}),
|
||||
...(input.agent && agentConfig ? { agent: { ...input.agent, config: agentConfig } } : {}),
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
|
||||
@@ -1601,7 +1601,12 @@ test("config actions delegate to existing daemon config RPCs", async () => {
|
||||
});
|
||||
|
||||
test("agent config maps provider/model and provider-native options to the daemon", async () => {
|
||||
const { client, ws } = await connectClient();
|
||||
const { client, ws } = await connectClient({
|
||||
providerUsageList: true,
|
||||
providersSnapshotCwd: true,
|
||||
ownedSubscriptions: true,
|
||||
providerOptionControls: true,
|
||||
});
|
||||
const createdAgent = createAgent({
|
||||
model: "gpt-5.4",
|
||||
currentModeId: "full-access",
|
||||
|
||||
Reference in New Issue
Block a user