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:
Mohamed Boudra
2026-11-01 23:06:46 +01:00
parent ddae0a4c36
commit ea8c9e7dc4
13 changed files with 523 additions and 199 deletions
@@ -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) {
+70
View File
@@ -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" },
+33 -7
View File
@@ -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,
+6 -1
View File
@@ -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",